feat(apps): W1 fidelity — music quiz, shuffle by album, piano, drum machine

- music quiz: deterministic 20-question timed rounds (4/6 options, five
  question kinds, hint, 30s bonus round), seeded QuizEngine, per-mix high
  scores via graph.games, settings via appState.
- shuffle by album: scoped shuffled album queue with in-order track playback
  and auto-advance, five-album drag carousel, queue sheet, idle screensaver,
  controller integration.
- piano: 52-white-key scrolling keyboard (A0-C8), octave bar, multi-touch
  glissando, sustain/dampen, volume, notation; pre-rendered synth voices
  with documented AudioTrack serialization limits.
- drum machine: layered pads with zone splitting, hold-to-loop, three kits,
  record/playback sequencer, 40-340 BPM, synth-only voices.

40 new engine tests; full suite 330/330 green.
This commit is contained in:
John Smith
2026-09-11 04:40:07 -04:00
parent d1d3f53226
commit 6aaf48daf7
8 changed files with 3307 additions and 290 deletions
@@ -1,10 +1,7 @@
package com.heretek.dorado_hd.ui.apps
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -17,117 +14,497 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.heretek.dorado_hd.data.db.NoteEntity
import com.heretek.dorado_hd.data.model.Track
import com.heretek.dorado_hd.design.DoradoTokens
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.AlbumArt
import com.heretek.dorado_hd.design.components.EdgeCropText
import com.heretek.dorado_hd.design.components.KineticList
import com.heretek.dorado_hd.design.components.firstLetterOf
import com.heretek.dorado_hd.ui.LocalDoradoGraph
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 kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.math.abs
/* ============================== Calculator ============================== */
private enum class QuizScreen { MENU, CHOOSE_MIX, ROUND, RESULTS }
@Composable
fun MusicQuizApp() {
val graph = LocalDoradoGraph.current
val colors = LocalDoradoColors.current
val scope = rememberCoroutineScope()
val tracks by graph.library.tracks().collectAsState(initial = emptyList())
var question by remember { mutableStateOf<QuizEngine.Question?>(null) }
var correct by remember { mutableStateOf(0) }
var wrong by remember { mutableStateOf(0) }
var exhausted by remember { mutableStateOf(false) }
val highScores by graph.games.top("musicquiz", 50).collectAsState(initial = emptyList())
val synth = remember { MiniSynth(scope) }
val bank = remember { SfxBank(synth) }
LaunchedEffect(tracks) {
if (question == null && !exhausted) {
question = QuizEngine.buildQuestion(tracks)
if (question == null && tracks.isNotEmpty()) exhausted = true
var screen by remember { mutableStateOf(QuizScreen.MENU) }
var difficulty by remember { mutableStateOf(QuizEngine.Difficulty.NORMAL) }
var bonusEnabled by remember { mutableStateOf(true) }
var soundLevel by remember { mutableStateOf(2) }
var mixName by remember { mutableStateOf("megamix") }
var round by remember { mutableStateOf<QuizEngine.QuizState?>(null) }
var recorded by remember { mutableStateOf(false) }
var paused by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
synth.start()
onDispose { synth.stop() }
}
LaunchedEffect(Unit) {
graph.appState.get("musicquiz")?.let { blob ->
val parts = blob.split(';').mapNotNull { it.split('=').takeIf { p -> p.size == 2 } }
val map = parts.associate { it[0] to it[1] }
difficulty = if (map["difficulty"] == "hardcore") QuizEngine.Difficulty.HARDCORE else QuizEngine.Difficulty.NORMAL
bonusEnabled = map["bonus"] != "0"
soundLevel = map["sound"]?.toIntOrNull()?.coerceIn(0, 3) ?: 2
mixName = map["mix"] ?: "megamix"
}
}
DetailScaffold(title = "music quiz") {
Column(Modifier.fillMaxSize().padding(DoradoTokens.EDGE.dp)) {
val q = question
if (q == null) {
EdgeCropText(
text = if (exhausted) "need at least 4 artists or albums to quiz" else "loading…",
fontSize = DoradoTokens.TYPE_NOW_META.dp,
alpha = 0.6f,
)
if (exhausted) {
Spacer(Modifier.height(8.dp))
EdgeCropText(text = "correct $correct — wrong $wrong", fontSize = DoradoTokens.TYPE_CAPTION.dp, alpha = 0.6f)
}
return@Column
}
BasicText(
text = q.prompt,
style = TextStyle(
fontFamily = Selawik,
fontWeight = FontWeight.Light,
fontSize = DoradoTokens.TYPE_NOW_TITLE.sp,
color = LocalDoradoColors.current.textPrimary,
),
maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
fun persist() {
scope.launch {
graph.appState.put(
"musicquiz",
"difficulty=${difficulty.name.lowercase()};bonus=${if (bonusEnabled) 1 else 0};" +
"sound=$soundLevel;mix=$mixName",
)
Spacer(Modifier.height(8.dp))
q.options.forEachIndexed { i, opt ->
EdgeCropText(
text = "${i + 1}. $opt",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = LocalDoradoColors.current.accent,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
if (opt == q.answer) {
correct++
} else {
wrong++
}
val next = QuizEngine.buildQuestion(tracks)
if (next == null) exhausted = true
question = next
},
onLongClick = {},
)
.padding(vertical = 6.dp),
}
}
fun cue(name: String) {
if (soundLevel > 0) bank.play(name)
}
fun startRound() {
val pool = if (mixName.equals("megamix", ignoreCase = true)) {
tracks
} else {
tracks.filter { it.artist.equals(mixName, ignoreCase = true) }
}
val state = QuizEngine.startRound(
tracks = pool,
difficulty = difficulty,
seed = System.currentTimeMillis(),
bonusEnabled = bonusEnabled,
mixName = mixName,
)
if (state != null) {
round = state
recorded = false
paused = false
screen = QuizScreen.ROUND
}
}
fun exitToMenu() {
round = null
paused = false
screen = QuizScreen.MENU
}
Column(Modifier.fillMaxSize()) {
DetailScaffold(title = "music quiz") {
when (screen) {
QuizScreen.MENU -> QuizMenu(
tracks = tracks,
difficulty = difficulty,
bonusEnabled = bonusEnabled,
soundLevel = soundLevel,
mixName = mixName,
highScores = highScores.filter { it.meta == QuizEngine.highScoreKey(mixName, difficulty) },
onToggleDifficulty = {
difficulty = if (difficulty == QuizEngine.Difficulty.NORMAL) QuizEngine.Difficulty.HARDCORE else QuizEngine.Difficulty.NORMAL
cue("select")
persist()
},
onToggleBonus = { bonusEnabled = !bonusEnabled; cue("select"); persist() },
onCycleSound = { soundLevel = (soundLevel + 1) % 4; persist() },
onChooseMix = { screen = QuizScreen.CHOOSE_MIX },
onStart = { cue("score"); startRound() },
)
QuizScreen.CHOOSE_MIX -> QuizMixPicker(
tracks = tracks,
current = mixName,
onPick = { name -> mixName = name; persist(); cue("select"); screen = QuizScreen.MENU },
onBack = { screen = QuizScreen.MENU },
)
QuizScreen.ROUND -> {
val state = round
if (state == null) {
QuizMenu(
tracks = tracks,
difficulty = difficulty,
bonusEnabled = bonusEnabled,
soundLevel = soundLevel,
mixName = mixName,
highScores = emptyList(),
onToggleDifficulty = {},
onToggleBonus = {},
onCycleSound = {},
onChooseMix = { screen = QuizScreen.CHOOSE_MIX },
onStart = { startRound() },
)
} else {
QuizRound(
state = state,
tracks = tracks,
paused = paused,
soundLevel = soundLevel,
onAnswer = { index ->
val before = state.question
val answered = QuizEngine.answer(state, index)
if (answered.phase == QuizEngine.Phase.REVEAL) {
val wasCorrect = answered.chosenIndex == before?.answerIndex
cue(if (wasCorrect) "score" else "error")
}
round = answered
},
onHint = {
val hinted = QuizEngine.useHint(state, tracks)
if (hinted.hintsUsed != state.hintsUsed) {
state.question?.mediaId?.let { id ->
tracks.firstOrNull { it.mediaId == id }?.let { track ->
graph.controller.play(listOf(track), 0)
graph.controller.seekTo(hinted.hintSeekMs)
}
}
cue("pop")
}
round = hinted
},
onPlayClip = {
state.question?.mediaId?.let { id ->
tracks.firstOrNull { it.mediaId == id }?.let { track ->
graph.controller.play(listOf(track), 0)
}
}
},
onBonusAnswer = { index ->
val bonus = state.bonusQuestion
val isCorrect = bonus != null && index == bonus.answerIndex
cue(if (isCorrect) "coin" else "error")
round = QuizEngine.answerBonus(state, tracks, index)
},
onPause = { paused = !paused },
onQuit = { exitToMenu() },
)
}
}
QuizScreen.RESULTS -> {
val state = round
if (state != null) {
LaunchedEffect(state) {
if (!recorded) {
recorded = true
cue("win")
scope.launch {
graph.games.record(
"musicquiz",
state.score,
QuizEngine.highScoreKey(state.mixName, state.difficulty),
)
}
}
}
QuizResults(
state = state,
best = highScores
.filter { it.meta == QuizEngine.highScoreKey(state.mixName, state.difficulty) }
.maxOfOrNull { it.score } ?: 0,
onAgain = { cue("select"); startRound() },
onMenu = { exitToMenu() },
)
}
}
}
Spacer(Modifier.height(8.dp))
EdgeCropText(text = "correct $correct — wrong $wrong", fontSize = DoradoTokens.TYPE_CAPTION.dp, alpha = 0.6f)
}
}
LaunchedEffect(round?.questionNumber, round?.phase) {
val state = round ?: return@LaunchedEffect
if (state.phase != QuizEngine.Phase.QUESTION && state.phase != QuizEngine.Phase.BONUS) return@LaunchedEffect
while (true) {
delay(100)
val current = round ?: return@LaunchedEffect
if (paused) continue
if (current.phase != QuizEngine.Phase.QUESTION && current.phase != QuizEngine.Phase.BONUS) return@LaunchedEffect
val ticked = QuizEngine.tick(current, 0.1f)
round = ticked
if (ticked.phase != current.phase) return@LaunchedEffect
}
}
LaunchedEffect(round?.phase, round?.questionNumber, round?.bonusCorrect) {
val state = round ?: return@LaunchedEffect
when (state.phase) {
QuizEngine.Phase.REVEAL -> {
delay(1100)
round?.let { round = QuizEngine.proceed(it, tracks) }
if (round?.phase == QuizEngine.Phase.RESULTS) screen = QuizScreen.RESULTS
}
QuizEngine.Phase.RESULTS -> screen = QuizScreen.RESULTS
else -> Unit
}
}
}
/* ============================== Alarm Clock ============================== */
@Composable
private fun QuizMenu(
tracks: List<Track>,
difficulty: QuizEngine.Difficulty,
bonusEnabled: Boolean,
soundLevel: Int,
mixName: String,
highScores: List<com.heretek.dorado_hd.data.db.GameScoreEntity>,
onToggleDifficulty: () -> Unit,
onToggleBonus: () -> Unit,
onCycleSound: () -> Unit,
onChooseMix: () -> Unit,
onStart: () -> Unit,
) {
val colors = LocalDoradoColors.current
val soundLabels = listOf("off", "low", "medium", "high")
Column(
Modifier.fillMaxSize().padding(DoradoTokens.EDGE.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
EdgeCropText(text = "mega mix", fontSize = DoradoTokens.TYPE_MENU_ITEM.dp, color = colors.accent, modifier = Modifier.pointerInput(Unit) { tap { onStart() } })
EdgeCropText(text = "custom mix — $mixName", fontSize = DoradoTokens.TYPE_LIST.dp, color = colors.textSecondary, modifier = Modifier.pointerInput(Unit) { tap { onChooseMix() } })
EdgeCropText(text = "answers: ${difficulty.name.lowercase()} (${difficulty.optionCount})", fontSize = DoradoTokens.TYPE_LIST.dp, modifier = Modifier.pointerInput(Unit) { tap { onToggleDifficulty() } })
EdgeCropText(text = "bonus round: ${if (bonusEnabled) "on" else "off"}", fontSize = DoradoTokens.TYPE_LIST.dp, modifier = Modifier.pointerInput(Unit) { tap { onToggleBonus() } })
EdgeCropText(text = "sound: ${soundLabels[soundLevel]}", fontSize = DoradoTokens.TYPE_LIST.dp, modifier = Modifier.pointerInput(Unit) { tap { onCycleSound() } })
if (tracks.count { QuizEngine.isContentValid(it) } < difficulty.optionCount) {
EdgeCropText(text = "need more content for this mode", fontSize = DoradoTokens.TYPE_NOW_META.dp, color = colors.textInactive)
}
Spacer(Modifier.height(4.dp))
EdgeCropText(
text = "high scores — ${QuizEngine.highScoreKey(mixName, difficulty)}",
fontSize = DoradoTokens.TYPE_CAPTION.dp,
color = colors.textSecondary,
)
if (highScores.isEmpty()) {
EdgeCropText(text = "none yet", fontSize = DoradoTokens.TYPE_LIST.dp, color = colors.textInactive)
} else {
highScores.take(5).forEachIndexed { i, row ->
EdgeCropText(
text = "${i + 1}. ${row.score}",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = if (i == 0) colors.accent else colors.textSecondary,
)
}
}
}
}
/** Stable PendingIntent request code from a Long alarm id (no truncation). */
@Composable
private fun QuizMixPicker(
tracks: List<Track>,
current: String,
onPick: (String) -> Unit,
onBack: () -> Unit,
) {
val colors = LocalDoradoColors.current
val mixes = remember(tracks) {
listOf("megamix") + tracks.map { it.artist }.filter { it.isNotBlank() }.distinct().sorted()
}
Column(
Modifier.fillMaxSize().padding(DoradoTokens.EDGE.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
EdgeCropText(text = "custom mix", fontSize = DoradoTokens.TYPE_NOW_TITLE.dp)
EdgeCropText(text = "mix the answers from one artist", fontSize = DoradoTokens.TYPE_CAPTION.dp, color = colors.textSecondary)
mixes.forEach { mix ->
EdgeCropText(
text = if (mix == current) "$mix —" else mix,
fontSize = DoradoTokens.TYPE_LIST.dp,
color = if (mix == current) colors.accent else colors.textPrimary,
modifier = Modifier.fillMaxWidth().pointerInput(mix) { tap { onPick(mix) } }.padding(vertical = 4.dp),
)
}
EdgeCropText(text = "back", fontSize = DoradoTokens.TYPE_LIST.dp, color = colors.textSecondary, modifier = Modifier.pointerInput(Unit) { tap { onBack() } })
}
}
@Composable
private fun QuizRound(
state: QuizEngine.QuizState,
tracks: List<Track>,
paused: Boolean,
soundLevel: Int,
onAnswer: (Int) -> Unit,
onHint: () -> Unit,
onPlayClip: () -> Unit,
onBonusAnswer: (Int) -> Unit,
onPause: () -> Unit,
onQuit: () -> Unit,
) {
val colors = LocalDoradoColors.current
val question = if (state.phase == QuizEngine.Phase.BONUS) state.bonusQuestion else state.question
val bonus = state.phase == QuizEngine.Phase.BONUS
val seconds = if (bonus) state.bonusSecondsLeft else state.secondsLeft
val totalSeconds = if (bonus) QuizEngine.BONUS_SECONDS else QuizEngine.QUESTION_SECONDS
Box(Modifier.fillMaxSize()) {
Column(
Modifier.fillMaxSize().padding(DoradoTokens.EDGE.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
EdgeCropText(
text = if (bonus) "bonus" else "question ${state.questionNumber} of ${state.totalQuestions}",
fontSize = DoradoTokens.TYPE_NOW_META.dp,
color = colors.accent,
modifier = Modifier.weight(1f),
)
EdgeCropText(text = "score ${state.score}", fontSize = DoradoTokens.TYPE_NOW_META.dp)
Spacer(Modifier.width(10.dp))
EdgeCropText(
text = if (paused) "resume" else "pause",
fontSize = DoradoTokens.TYPE_NOW_META.dp,
color = colors.textSecondary,
modifier = Modifier.pointerInput(Unit) { tap { onPause() } },
)
}
Box(Modifier.fillMaxWidth().height(4.dp).background(colors.tile)) {
Box(
Modifier.fillMaxWidth((seconds / totalSeconds).coerceIn(0f, 1f)).fillMaxHeight().background(colors.accent),
)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
EdgeCropText(text = "%02d".format(seconds.toInt().coerceAtLeast(0)), fontSize = DoradoTokens.TYPE_NOW_META.dp)
if (!bonus && state.question?.kind == QuizEngine.Kind.ALBUM_ART) {
val track = tracks.firstOrNull { it.mediaId == question?.mediaId }
if (track != null) {
AlbumArt(model = track.albumArtUri, contentDescription = track.album, modifier = Modifier.size(72.dp))
}
} else if (!bonus && question?.mediaId != null) {
EdgeCropText(
text = "play clip",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.accent,
modifier = Modifier.pointerInput(Unit) { tap { onPlayClip() } },
)
}
if (!bonus) {
EdgeCropText(
text = "hint (${state.hintsLeft})",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = if (state.hintsLeft > 0) colors.accent else colors.textInactive,
modifier = Modifier.pointerInput(Unit) { tap { onHint() } },
)
}
}
EdgeCropText(
text = question?.prompt ?: "…",
fontSize = DoradoTokens.TYPE_NOW_TITLE.dp,
modifier = Modifier.fillMaxWidth(),
)
Column(Modifier.fillMaxWidth().weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
question?.options?.forEachIndexed { index, option ->
val revealed = !bonus && state.phase == QuizEngine.Phase.REVEAL
val isAnswer = index == question.answerIndex
val isChosen = state.chosenIndex == index
val color = when {
revealed && isAnswer -> colors.accentBright
revealed && isChosen && !isAnswer -> colors.textInactive
else -> colors.textPrimary
}
val prefix = when {
revealed && isAnswer -> "✓ "
revealed && isChosen && !isAnswer -> "✗ "
else -> "${index + 1}. "
}
EdgeCropText(
text = prefix + option,
fontSize = DoradoTokens.TYPE_LIST.dp,
color = color,
modifier = Modifier
.fillMaxWidth()
.pointerInput(index, bonus, state.phase) {
tap { if (bonus) onBonusAnswer(index) else onAnswer(index) }
}
.padding(vertical = 4.dp),
)
}
}
val feedback = when {
bonus -> "bonus hits ${state.bonusCorrect}"
state.phase == QuizEngine.Phase.REVEAL && state.chosenIndex == question?.answerIndex -> "+${state.points} points"
state.phase == QuizEngine.Phase.REVEAL && state.chosenIndex != null -> "wrong"
state.phase == QuizEngine.Phase.REVEAL -> "out of time"
else -> " "
}
EdgeCropText(text = feedback, fontSize = DoradoTokens.TYPE_NOW_META.dp, color = colors.accent)
}
if (paused) {
Box(Modifier.fillMaxSize().background(colors.background.copy(alpha = 0.92f))) {
Column(
Modifier.fillMaxSize().padding(DoradoTokens.EDGE.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(40.dp))
EdgeCropText(text = "paused", fontSize = DoradoTokens.TYPE_MENU_ITEM.dp)
EdgeCropText(text = "resume", fontSize = DoradoTokens.TYPE_LIST.dp, color = colors.accent, modifier = Modifier.pointerInput(Unit) { tap { onPause() } })
EdgeCropText(text = "main menu", fontSize = DoradoTokens.TYPE_LIST.dp, color = colors.accent, modifier = Modifier.pointerInput(Unit) { tap { onQuit() } })
}
}
}
}
}
@Composable
private fun QuizResults(
state: QuizEngine.QuizState,
best: Int,
onAgain: () -> Unit,
onMenu: () -> Unit,
) {
val colors = LocalDoradoColors.current
Column(
Modifier.fillMaxSize().padding(DoradoTokens.EDGE.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
EdgeCropText(text = "results", fontSize = DoradoTokens.TYPE_MENU_ITEM.dp)
EdgeCropText(text = "correct ${state.correct} of ${state.totalQuestions}", fontSize = DoradoTokens.TYPE_NOW_TITLE.dp)
EdgeCropText(text = "score ${state.score}", fontSize = DoradoTokens.TYPE_NOW_TITLE.dp, color = colors.accent)
if (state.bonusEnabled) {
EdgeCropText(text = "bonus ${state.bonusCorrect} x 5 = ${state.bonusPoints}", fontSize = DoradoTokens.TYPE_LIST.dp, color = colors.textSecondary)
}
EdgeCropText(
text = "high score ${maxOf(best, state.score)}",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.textSecondary,
)
Spacer(Modifier.height(8.dp))
EdgeCropText(text = "play again", fontSize = DoradoTokens.TYPE_LIST.dp, color = colors.accent, modifier = Modifier.pointerInput(Unit) { tap { onAgain() } })
EdgeCropText(text = "main menu", fontSize = DoradoTokens.TYPE_LIST.dp, color = colors.textSecondary, modifier = Modifier.pointerInput(Unit) { tap { onMenu() } })
}
}
private suspend fun PointerInputScope.tap(action: () -> Unit) {
detectTapGestures { action() }
}
File diff suppressed because it is too large Load Diff
@@ -1,60 +1,530 @@
package com.heretek.dorado_hd.ui.apps
import com.heretek.dorado_hd.data.model.Track
import kotlin.math.floor
import kotlin.random.Random
/**
* Pure quiz question builder used by the Music Quiz mini-app. Produces
* multiple-choice questions of two kinds from the live library:
* - "Which song is by <artist>?" (kind = BY_ARTIST)
* - "Which album is '<song>' on?" (kind = BY_ALBUM)
* Distractors are 3 random siblings; returns null when the library has
* fewer than 4 distinct artists/albums.
* Pure round machine for the Music Quiz mini-app.
*
* A round is [TOTAL_QUESTIONS] metadata questions over the user's library:
* name the song, name the artist, name the album, spot the odd one out, or
* identify album art. Every question offers [Difficulty.optionCount] distinct
* answers, carries the tracks it referenced so the pool never repeats within
* a round, and is generated from a seed so a round is fully deterministic.
*
* Nothing Android is touched here; the UI owns timers, playback and storage.
*/
object QuizEngine {
enum class Kind { BY_ARTIST, BY_ALBUM }
const val TOTAL_QUESTIONS = 20
const val NORMAL_OPTIONS = 4
const val HARDCORE_OPTIONS = 6
const val QUESTION_SECONDS = 15f
const val BONUS_SECONDS = 30f
const val BONUS_POINTS_PER_CORRECT = 5
const val MAX_POINTS = 10
const val HINT_LIMIT_NORMAL = 4
const val HINT_LIMIT_HARDCORE = 2
const val EXPIRED_CONTENT_ABORT = 25
enum class Kind { SONG_TITLE, BY_ARTIST, BY_ALBUM, ODD_ONE_OUT, ALBUM_ART }
enum class Difficulty {
NORMAL,
HARDCORE;
val optionCount: Int get() = if (this == HARDCORE) HARDCORE_OPTIONS else NORMAL_OPTIONS
val hintLimit: Int get() = if (this == HARDCORE) HINT_LIMIT_HARDCORE else HINT_LIMIT_NORMAL
}
enum class Phase { QUESTION, REVEAL, BONUS, RESULTS }
data class Question(
val kind: Kind,
val prompt: String,
val answer: String,
val options: List<String>,
val answerIndex: Int,
/** Track the clip/hint/art question is about; null for odd-one-out. */
val mediaId: Long? = null,
val albumId: Long? = null,
/** Every track referenced (answer + distractors) so pools can advance. */
val referencedSongs: Set<Long> = emptySet(),
/** Every album referenced so album questions do not repeat. */
val referencedAlbums: Set<Long> = emptySet(),
)
fun buildQuestion(tracks: List<Track>, rng: Random = Random.Default): Question? {
if (tracks.size < 4) return null
val byArtist = tracks.groupBy { it.artist }
val byAlbum = tracks.groupBy { it.album }
val kind = if (rng.nextBoolean() && byArtist.size >= 4) Kind.BY_ARTIST else Kind.BY_ALBUM
return when (kind) {
Kind.BY_ARTIST -> artistQuestion(byArtist, rng)
Kind.BY_ALBUM -> buildAlbumQuestion(byAlbum, rng)
data class QuizState(
val difficulty: Difficulty,
val questionNumber: Int,
val totalQuestions: Int = TOTAL_QUESTIONS,
val question: Question?,
val phase: Phase = Phase.QUESTION,
val secondsLeft: Float = QUESTION_SECONDS,
val score: Int = 0,
val correct: Int = 0,
val points: Int = 0,
val hintsLeft: Int,
val hintsUsed: Int = 0,
val chosenIndex: Int? = null,
val hintActive: Boolean = false,
val hintSeekMs: Long = 0L,
val bonusEnabled: Boolean = true,
val bonusQuestion: Question? = null,
val bonusCorrect: Int = 0,
val bonusSecondsLeft: Float = BONUS_SECONDS,
val seed: Long = 0L,
val mixName: String = "megamix",
val usedSongs: Set<Long> = emptySet(),
val usedAlbums: Set<Long> = emptySet(),
val previousWasAlbumArt: Boolean = false,
val expired: Int = 0,
) {
val optionCount: Int get() = difficulty.optionCount
val bonusPoints: Int get() = bonusCorrect * BONUS_POINTS_PER_CORRECT
val isLastQuestion: Boolean get() = questionNumber >= totalQuestions
}
fun isContentValid(track: Track): Boolean =
track.title.isNotBlank() && track.artist.isNotBlank() && track.album.isNotBlank()
/**
* Legacy single-question API kept for LogicTest and any one-shot callers.
* Round play should use [startRound]/[proceed] so pools and difficulty
* apply.
*/
fun buildQuestion(tracks: List<Track>, rng: Random = Random.Default): Question? =
nextQuestion(tracks, Difficulty.NORMAL, rng = rng)
/**
* Deterministically generate the next question from [tracks], avoiding
* [usedSongs]/[usedAlbums] where possible and never chaining two album-art
* questions.
*/
fun nextQuestion(
tracks: List<Track>,
difficulty: Difficulty = Difficulty.NORMAL,
usedSongs: Set<Long> = emptySet(),
usedAlbums: Set<Long> = emptySet(),
previousWasAlbumArt: Boolean = false,
rng: Random = Random.Default,
): Question? {
val n = difficulty.optionCount
val usable = tracks.filter { isContentValid(it) }
if (usable.size < n) return null
val byArtist = usable.groupBy { it.artist }
val byAlbum = usable.groupBy { it.album }
val kinds = mutableListOf<Kind>()
if (byArtist.size >= n) {
kinds += Kind.BY_ARTIST
kinds += Kind.ODD_ONE_OUT
}
if (byAlbum.size >= n) {
kinds += Kind.BY_ALBUM
kinds += Kind.SONG_TITLE
if (!previousWasAlbumArt) kinds += Kind.ALBUM_ART
}
if (kinds.isEmpty()) return null
for (kind in kinds.shuffled(rng)) {
val q = when (kind) {
Kind.BY_ARTIST -> artistQuestion(byArtist, usedSongs, n, rng)
Kind.BY_ALBUM -> albumQuestion(byAlbum, usedSongs, usedAlbums, n, rng, albumArt = false)
Kind.ALBUM_ART -> albumQuestion(byAlbum, usedSongs, usedAlbums, n, rng, albumArt = true)
Kind.SONG_TITLE -> songTitleQuestion(byAlbum, usedSongs, n, rng)
Kind.ODD_ONE_OUT -> oddOneOutQuestion(byArtist, usedSongs, n, rng)
}
if (q != null) return q
}
return null
}
/**
* Start a full round, or null when the library cannot support one
* (too few usable tracks, or too much unreadable content).
*/
fun startRound(
tracks: List<Track>,
difficulty: Difficulty = Difficulty.NORMAL,
seed: Long = 0L,
bonusEnabled: Boolean = true,
mixName: String = "megamix",
): QuizState? {
val expired = tracks.count { !isContentValid(it) }
if (expired >= EXPIRED_CONTENT_ABORT) return null
val usable = tracks.filter { isContentValid(it) }
if (usable.size < difficulty.optionCount) return null
val rng = Random(seed)
val q = nextQuestion(usable, difficulty, rng = rng) ?: return null
return QuizState(
difficulty = difficulty,
questionNumber = 1,
question = q,
secondsLeft = QUESTION_SECONDS,
hintsLeft = difficulty.hintLimit,
seed = seed,
bonusEnabled = bonusEnabled,
mixName = mixName,
usedSongs = q.referencedSongs,
usedAlbums = q.referencedAlbums,
previousWasAlbumArt = q.kind == Kind.ALBUM_ART,
expired = expired,
)
}
/** Device points rule: min(10, floor(seconds remaining) + 1). */
fun pointsFor(secondsLeft: Float): Int =
(floor(secondsLeft.coerceAtLeast(0f)).toInt() + 1).coerceAtMost(MAX_POINTS)
fun tick(state: QuizState, deltaSeconds: Float): QuizState {
if (deltaSeconds <= 0f) return state
return when (state.phase) {
Phase.QUESTION -> {
val left = (state.secondsLeft - deltaSeconds).coerceAtLeast(0f)
if (left <= 0f) {
state.copy(secondsLeft = 0f, phase = Phase.REVEAL, chosenIndex = null, points = 0)
} else {
state.copy(secondsLeft = left)
}
}
Phase.BONUS -> {
val left = (state.bonusSecondsLeft - deltaSeconds).coerceAtLeast(0f)
if (left <= 0f) endBonus(state) else state.copy(bonusSecondsLeft = left)
}
else -> state
}
}
private fun artistQuestion(byArtist: Map<String, List<Track>>, rng: Random): Question? {
val artists = byArtist.keys.filter { it.isNotBlank() }.distinct()
if (artists.size < 4) return null
val target = artists.random(rng)
val siblings = artists.filter { it != target }.shuffled(rng).take(3)
val targetTracks = byArtist[target] ?: return null
val answerTrack = targetTracks.random(rng)
return Question(
prompt = "which song is by \"$target\"?",
answer = answerTrack.title,
options = (siblings.map { (byArtist[it] ?: emptyList()).random(rng).title } + answerTrack.title).shuffled(rng),
fun answer(state: QuizState, optionIndex: Int): QuizState {
if (state.phase != Phase.QUESTION) return state
val q = state.question ?: return state
if (optionIndex !in q.options.indices) return state
val isCorrect = optionIndex == q.answerIndex
val points = if (isCorrect) pointsFor(state.secondsLeft) else 0
return state.copy(
phase = Phase.REVEAL,
chosenIndex = optionIndex,
points = points,
score = state.score + points,
correct = state.correct + if (isCorrect) 1 else 0,
)
}
private fun buildAlbumQuestion(byAlbum: Map<String, List<Track>>, rng: Random): Question? {
val albums = byAlbum.keys.filter { it.isNotBlank() }.distinct()
if (albums.size < 4) return null
val target = albums.random(rng)
val siblings = albums.filter { it != target }.shuffled(rng).take(3)
val track = (byAlbum[target] ?: return null).random(rng)
return Question(
prompt = "which album is \"${track.title}\" on?",
answer = target,
options = (siblings + target).shuffled(rng),
fun useHint(state: QuizState, tracks: List<Track>): QuizState {
if (state.phase != Phase.QUESTION || state.hintsLeft <= 0) return state
val duration = tracks.firstOrNull { it.mediaId == state.question?.mediaId }?.durationMs ?: 0L
return state.copy(
hintsLeft = state.hintsLeft - 1,
hintsUsed = state.hintsUsed + 1,
hintActive = true,
secondsLeft = QUESTION_SECONDS,
hintSeekMs = hintSeekFor(duration, state.seed + state.hintsUsed * 7919L),
)
}
/** Device hint: very short songs restart from the top, longer ones seek. */
fun hintSeekFor(durationMs: Long, seed: Long): Long {
if (durationMs <= 10_000L) return 0L
val window = (durationMs / 2).coerceAtLeast(1L)
return Random(seed).nextInt(window.toInt().coerceAtLeast(1)).toLong()
}
/**
* Leave the reveal state: next question, bonus round after question 20, or
* results.
*/
fun proceed(state: QuizState, tracks: List<Track>): QuizState {
if (state.phase != Phase.REVEAL) return state
if (state.isLastQuestion) {
if (!state.bonusEnabled) return state.copy(phase = Phase.RESULTS, chosenIndex = null)
val bonus = albumArtQuestion(
tracks.filter { isContentValid(it) }.groupBy { it.album },
emptySet(),
emptySet(),
state.difficulty.optionCount,
Random(state.seed + 977L),
)
return state.copy(
phase = Phase.BONUS,
question = bonus ?: state.question,
chosenIndex = null,
points = 0,
bonusQuestion = bonus,
bonusCorrect = 0,
bonusSecondsLeft = BONUS_SECONDS,
)
}
val rng = Random(state.seed + state.questionNumber * 31L)
val next = nextQuestion(
tracks,
state.difficulty,
state.usedSongs,
state.usedAlbums,
state.previousWasAlbumArt,
rng,
)
return state.copy(
questionNumber = state.questionNumber + 1,
question = next ?: state.question,
phase = Phase.QUESTION,
secondsLeft = QUESTION_SECONDS,
chosenIndex = null,
points = 0,
hintActive = false,
hintSeekMs = 0L,
usedSongs = next?.referencedSongs ?: state.usedSongs,
usedAlbums = next?.referencedAlbums ?: state.usedAlbums,
previousWasAlbumArt = next?.kind == Kind.ALBUM_ART,
)
}
/** Bonus round taps: correct answers stack, then pay out at the end. */
fun answerBonus(state: QuizState, tracks: List<Track>, optionIndex: Int): QuizState {
if (state.phase != Phase.BONUS) return state
val q = state.bonusQuestion ?: return state
if (optionIndex !in q.options.indices) return state
val isCorrect = optionIndex == q.answerIndex
val next = albumArtQuestion(
tracks.filter { isContentValid(it) }.groupBy { it.album },
emptySet(),
emptySet(),
state.difficulty.optionCount,
Random(state.seed + state.bonusCorrect * 131L + 17L),
)
return state.copy(
bonusCorrect = state.bonusCorrect + if (isCorrect) 1 else 0,
bonusQuestion = next ?: q,
)
}
/** Close the bonus round and bank NumberCorrect x 5. */
fun endBonus(state: QuizState): QuizState =
state.copy(
phase = Phase.RESULTS,
score = state.score + state.bonusPoints,
bonusSecondsLeft = 0f,
)
fun highScoreKey(mixName: String, difficulty: Difficulty): String =
"${mixName.trim().ifBlank { "megamix" }.lowercase()}/${difficulty.name.lowercase()}"
fun updateHighScore(scores: Map<String, Int>, key: String, score: Int): Map<String, Int> {
val best = maxOf(scores[key] ?: 0, score)
return scores + (key to best)
}
fun encodeHighScores(scores: Map<String, Int>): String =
scores.entries.sortedBy { it.key }.joinToString(";") { "${it.key}=${it.value}" }
fun decodeHighScores(encoded: String?): Map<String, Int> {
if (encoded.isNullOrBlank()) return emptyMap()
return encoded.split(';').mapNotNull { entry ->
val sep = entry.lastIndexOf('=')
if (sep <= 0) null else {
val key = entry.substring(0, sep)
val value = entry.substring(sep + 1).toIntOrNull()
if (value == null) null else key to value
}
}.toMap()
}
/* ------------------------------ builders ------------------------------ */
private fun artistQuestion(
byArtist: Map<String, List<Track>>,
usedSongs: Set<Long>,
n: Int,
rng: Random,
): Question? {
val artists = byArtist.keys.filter { it.isNotBlank() }.shuffled(rng)
val preferred = artists.filter { a -> byArtist[a].orEmpty().any { it.mediaId !in usedSongs } } +
artists.filter { a -> byArtist[a].orEmpty().none { it.mediaId !in usedSongs } }
for (target in preferred) {
val targetTracks = byArtist[target].orEmpty()
if (targetTracks.isEmpty()) continue
val answer = targetTracks.filter { it.mediaId !in usedSongs }.ifEmpty { targetTracks }.random(rng)
val referencedSongs = mutableSetOf(answer.mediaId)
val referencedAlbums = mutableSetOf(answer.albumId)
val distractors = mutableListOf<String>()
for (artist in artists) {
if (artist == target) continue
val track = byArtist[artist].orEmpty().randomOrNull(rng) ?: continue
if (track.title.isBlank() || track.title == answer.title) continue
if (distractors.contains(track.title)) continue
distractors += track.title
referencedSongs += track.mediaId
referencedAlbums += track.albumId
if (distractors.size == n - 1) break
}
if (distractors.size < n - 1) continue
val options = (distractors + answer.title).shuffled(rng)
return Question(
kind = Kind.BY_ARTIST,
prompt = "which song is by \"$target\"?",
answer = answer.title,
options = options,
answerIndex = options.indexOf(answer.title),
mediaId = answer.mediaId,
albumId = answer.albumId,
referencedSongs = referencedSongs,
referencedAlbums = referencedAlbums,
)
}
return null
}
private fun albumQuestion(
byAlbum: Map<String, List<Track>>,
usedSongs: Set<Long>,
usedAlbums: Set<Long>,
n: Int,
rng: Random,
albumArt: Boolean,
): Question? {
if (byAlbum.size < n) return null
val albums = byAlbum.keys.filter { it.isNotBlank() }.shuffled(rng)
val fresh = albums.filter { name -> byAlbum[name].orEmpty().none { it.albumId in usedAlbums } }
val stale = albums.filter { name -> byAlbum[name].orEmpty().any { it.albumId in usedAlbums } }
val preferred = fresh + stale
for (target in preferred) {
val targetTracks = byAlbum[target].orEmpty()
if (targetTracks.isEmpty()) continue
val answer = targetTracks.filter { it.mediaId !in usedSongs }.ifEmpty { targetTracks }.random(rng)
val distractors = albums.filter { it != target }.take(n - 1)
if (distractors.size < n - 1) continue
val options = (distractors + target).shuffled(rng)
val referencedSongs = mutableSetOf(answer.mediaId)
val referencedAlbums = mutableSetOf(answer.albumId)
for (name in distractors) {
byAlbum[name].orEmpty().randomOrNull(rng)?.let {
referencedSongs += it.mediaId
referencedAlbums += it.albumId
}
}
return Question(
kind = if (albumArt) Kind.ALBUM_ART else Kind.BY_ALBUM,
prompt = if (albumArt) "which album is this?" else "which album is \"${answer.title}\" on?",
answer = target,
options = options,
answerIndex = options.indexOf(target),
mediaId = if (albumArt) answer.mediaId else null,
albumId = answer.albumId,
referencedSongs = referencedSongs,
referencedAlbums = referencedAlbums,
)
}
return null
}
private fun songTitleQuestion(
byAlbum: Map<String, List<Track>>,
usedSongs: Set<Long>,
n: Int,
rng: Random,
): Question? {
val albums = byAlbum.keys.filter { it.isNotBlank() }.shuffled(rng)
for (target in albums) {
val targetTracks = byAlbum[target].orEmpty()
if (targetTracks.isEmpty()) continue
val answer = targetTracks.filter { it.mediaId !in usedSongs }.ifEmpty { targetTracks }.random(rng)
val referenced = mutableSetOf(answer.mediaId)
val distractors = mutableListOf<String>()
for (album in albums) {
if (album == target) continue
val track = byAlbum[album].orEmpty().randomOrNull(rng) ?: continue
if (track.title.isBlank() || track.title == answer.title) continue
if (distractors.contains(track.title)) continue
distractors += track.title
referenced += track.mediaId
if (distractors.size == n - 1) break
}
if (distractors.size < n - 1) continue
val options = (distractors + answer.title).shuffled(rng)
return Question(
kind = Kind.SONG_TITLE,
prompt = "which song is on \"$target\"?",
answer = answer.title,
options = options,
answerIndex = options.indexOf(answer.title),
mediaId = answer.mediaId,
albumId = answer.albumId,
referencedSongs = referenced,
referencedAlbums = setOf(answer.albumId),
)
}
return null
}
private fun oddOneOutQuestion(
byArtist: Map<String, List<Track>>,
usedSongs: Set<Long>,
n: Int,
rng: Random,
): Question? {
val majorityArtists = byArtist.entries
.filter { it.key.isNotBlank() && it.value.size >= n - 1 }
.shuffled(rng)
for ((majority, majorityTracks) in majorityArtists) {
val odd = byArtist.entries
.filter { it.key != majority && it.value.isNotEmpty() }
.shuffled(rng)
.firstOrNull { it.value.none { t -> majorityTracks.any { m -> m.mediaId == t.mediaId } } }
?: continue
val oddTrack = odd.value.random(rng)
val referenced = mutableSetOf(oddTrack.mediaId)
val belong = majorityTracks
.filter { it.mediaId !in usedSongs }
.ifEmpty { majorityTracks }
.shuffled(rng)
.take(n - 1)
belong.forEach { referenced += it.mediaId }
val options = (belong.map { it.title } + oddTrack.title).shuffled(rng)
if (options.distinct().size < n) continue
return Question(
kind = Kind.ODD_ONE_OUT,
prompt = "which song doesn't belong?",
answer = oddTrack.title,
options = options,
answerIndex = options.indexOf(oddTrack.title),
mediaId = null,
albumId = null,
referencedSongs = referenced,
referencedAlbums = (belong.map { it.albumId } + oddTrack.albumId).toSet(),
)
}
return null
}
private fun albumArtQuestion(
byAlbum: Map<String, List<Track>>,
usedSongs: Set<Long>,
usedAlbums: Set<Long>,
n: Int,
rng: Random,
): Question? {
if (byAlbum.size < n) return null
val albums = byAlbum.keys.filter { it.isNotBlank() }.shuffled(rng)
for (target in albums) {
val targetTracks = byAlbum[target].orEmpty()
if (targetTracks.isEmpty()) continue
val answer = targetTracks.filter { it.mediaId !in usedSongs }.ifEmpty { targetTracks }.random(rng)
val distractors = albums.filter { it != target }.take(n - 1)
if (distractors.size < n - 1) continue
val options = (distractors + target).shuffled(rng)
return Question(
kind = Kind.ALBUM_ART,
prompt = "which album is this?",
answer = target,
options = options,
answerIndex = options.indexOf(target),
mediaId = answer.mediaId,
albumId = answer.albumId,
referencedSongs = setOf(answer.mediaId),
referencedAlbums = setOf(answer.albumId),
)
}
return null
}
private fun <T> List<T>.randomOrNull(rng: Random): T? = if (isEmpty()) null else this.random(rng)
}
@@ -1,110 +1,648 @@
package com.heretek.dorado_hd.ui.apps
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
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.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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.heretek.dorado_hd.data.db.NoteEntity
import com.heretek.dorado_hd.data.model.Track
import com.heretek.dorado_hd.design.DoradoTokens
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.AlbumArt
import com.heretek.dorado_hd.design.components.EdgeCropText
import com.heretek.dorado_hd.design.components.KineticList
import com.heretek.dorado_hd.design.components.firstLetterOf
import com.heretek.dorado_hd.ui.LocalDoradoGraph
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.screens.formatTime
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.random.Random
/**
* Pure queue builder for Shuffle By Album: shuffle the album order, keep every
* album's track order, and hand the flattened list to the playback controller.
*/
object ShuffleEngine {
enum class Scope(val label: String) {
ALL("all"),
ARTIST("artist"),
GENRE("genre"),
PLAYLIST("playlist"),
}
const val VARIOUS_ARTISTS = "Various Artists"
const val UNKNOWN_ARTIST = "Unknown Artist"
const val SOUNDTRACK = "Soundtrack"
val PLACEHOLDER_ARTISTS: Set<String> = setOf(VARIOUS_ARTISTS, UNKNOWN_ARTIST, SOUNDTRACK)
data class AlbumGroup(
val albumId: Long,
val title: String,
val artist: String,
val tracks: List<Track>,
)
data class ShuffleState(
val scope: Scope,
val scopeName: String?,
val groupByArtist: Boolean,
val albums: List<AlbumGroup>,
val albumIndex: Int = 0,
val songIndex: Int = 0,
val isPlaying: Boolean = false,
val generation: Int = 0,
)
data class ShufflePrefs(
val groupByArtist: Boolean,
val scope: Scope,
val scopeName: String?,
)
fun buildQueue(
albums: List<AlbumGroup>,
scope: Scope = Scope.ALL,
scopeName: String? = null,
groupByArtist: Boolean = false,
rng: Random = Random.Default,
retainAlbumId: Long? = null,
retainSongIndex: Int = 0,
): ShuffleState? {
val playable = albums.filter { it.tracks.isNotEmpty() }
val scoped = when (scope) {
Scope.ALL -> playable
Scope.ARTIST ->
if (scopeName.isNullOrBlank()) playable.filter { it.artist !in PLACEHOLDER_ARTISTS }
else playable.filter { it.artist.equals(scopeName, ignoreCase = true) }
Scope.GENRE ->
if (scopeName.isNullOrBlank()) playable
else playable.filter { group -> group.tracks.any { it.genre.equals(scopeName, ignoreCase = true) } }
Scope.PLAYLIST -> playable
}
if (scoped.isEmpty()) return null
val ordered = when {
groupByArtist && scope == Scope.ARTIST && scopeName.isNullOrBlank() -> artistBlockOrder(scoped, rng)
groupByArtist -> deinterleave(scoped, rng)
else -> scoped.shuffled(rng)
}
val finalOrder = if (retainAlbumId != null) {
val from = ordered.indexOfFirst { it.albumId == retainAlbumId }
if (from > 0) listOf(ordered[from]) + ordered.filterIndexed { index, _ -> index != from } else ordered
} else {
ordered
}
val retained = retainAlbumId != null && finalOrder.firstOrNull()?.albumId == retainAlbumId
val songIndex = if (retained) retainSongIndex.coerceIn(0, finalOrder.first().tracks.lastIndex) else 0
return ShuffleState(
scope = scope,
scopeName = scopeName,
groupByArtist = groupByArtist,
albums = finalOrder,
albumIndex = 0,
songIndex = songIndex,
)
}
private fun artistBlockOrder(albums: List<AlbumGroup>, rng: Random): List<AlbumGroup> {
val byArtist = albums.groupBy { it.artist }
val artists = byArtist.keys.shuffled(rng)
return artists.flatMap { artist -> byArtist.getValue(artist).shuffled(rng) }
}
/**
* Shuffle albums then greedily pick the artist with the most remaining
* albums that differs from the previous one, so neighbours differ whenever
* a rearrangement allows it.
*/
private fun deinterleave(albums: List<AlbumGroup>, rng: Random): List<AlbumGroup> {
val buckets = albums.groupBy { it.artist }.values.map { it.shuffled(rng).toMutableList() }.toMutableList()
val out = mutableListOf<AlbumGroup>()
var lastArtist: String? = null
while (buckets.any { it.isNotEmpty() }) {
val nonEmpty = buckets.filter { it.isNotEmpty() }
val candidates = nonEmpty.filter { it.first().artist != lastArtist }
val pool = if (candidates.isNotEmpty()) candidates else nonEmpty
val maxRemaining = pool.maxOf { it.size }
val best = pool.filter { it.size == maxRemaining }
val bucket = best.random(rng)
val album = bucket.removeAt(0)
out += album
lastArtist = album.artist
}
return out
}
fun currentTrack(state: ShuffleState): Track? =
state.albums.getOrNull(state.albumIndex)?.tracks?.getOrNull(state.songIndex)
fun currentAlbum(state: ShuffleState): AlbumGroup? = state.albums.getOrNull(state.albumIndex)
fun atQueueEnd(state: ShuffleState): Boolean {
val last = state.albums.lastOrNull() ?: return true
return state.albumIndex >= state.albums.lastIndex && state.songIndex >= last.tracks.lastIndex
}
fun flatten(state: ShuffleState): List<Pair<Int, Int>> =
state.albums.flatMapIndexed { albumIndex, group -> group.tracks.indices.map { albumIndex to it } }
fun flatIndexOf(state: ShuffleState): Int {
var index = 0
for (a in 0 until state.albumIndex.coerceAtMost(state.albums.size)) {
index += state.albums[a].tracks.size
}
return index + state.songIndex
}
fun trackAtFlat(state: ShuffleState, flatIndex: Int): Track? {
var remaining = flatIndex
for (group in state.albums) {
if (remaining < group.tracks.size) return group.tracks.getOrNull(remaining)
remaining -= group.tracks.size
}
return null
}
fun upcoming(state: ShuffleState, count: Int = 5): List<Track> {
val flat = flatten(state)
val start = flatIndexOf(state)
return flat.drop(start + 1).take(count).mapNotNull { (a, s) -> state.albums.getOrNull(a)?.tracks?.getOrNull(s) }
}
fun next(state: ShuffleState, forward: Boolean = true, rng: Random = Random.Default): ShuffleState {
if (state.albums.isEmpty()) return state
val album = state.albums[state.albumIndex]
if (forward && state.songIndex < album.tracks.lastIndex) {
return state.copy(songIndex = state.songIndex + 1)
}
return nextAlbum(state, forward, rng)
}
fun nextAlbum(state: ShuffleState, forward: Boolean = true, rng: Random = Random.Default): ShuffleState {
if (state.albums.isEmpty()) return state
if (forward) {
if (state.albumIndex >= state.albums.lastIndex) {
return state.copy(
albums = state.albums.shuffled(rng),
albumIndex = 0,
songIndex = 0,
generation = state.generation + 1,
)
}
return state.copy(albumIndex = state.albumIndex + 1, songIndex = 0)
}
return if (state.albumIndex <= 0) {
state.copy(songIndex = 0)
} else {
val previous = state.albums[state.albumIndex - 1]
state.copy(albumIndex = state.albumIndex - 1, songIndex = previous.tracks.lastIndex)
}
}
fun previous(state: ShuffleState): ShuffleState {
val album = state.albums.getOrNull(state.albumIndex) ?: return state
if (state.songIndex > 0) return state.copy(songIndex = state.songIndex - 1)
return if (state.albumIndex > 0) {
val previousAlbum = state.albums[state.albumIndex - 1]
state.copy(albumIndex = state.albumIndex - 1, songIndex = previousAlbum.tracks.lastIndex)
} else {
state
}
}
/** Follow an external (controller) track change; null when not in the queue. */
fun syncToMediaId(state: ShuffleState, mediaId: Long): ShuffleState? {
state.albums.forEachIndexed { a, group ->
val s = group.tracks.indexOfFirst { it.mediaId == mediaId }
if (s >= 0) return state.copy(albumIndex = a, songIndex = s)
}
return null
}
fun carousel(state: ShuffleState, radius: Int = 2): List<AlbumGroup> {
if (state.albums.isEmpty()) return emptyList()
return (-radius..radius).mapNotNull { offset ->
state.albums.getOrNull(state.albumIndex + offset)
}
}
fun encodePrefs(prefs: ShufflePrefs): String =
"group=${if (prefs.groupByArtist) 1 else 0};scope=${prefs.scope.name.lowercase()};" +
"name=${prefs.scopeName ?: ""}"
fun decodePrefs(blob: String?): ShufflePrefs {
val map = blob.orEmpty().split(';').mapNotNull {
val parts = it.split('=').takeIf { p -> p.size == 2 } ?: return@mapNotNull null
parts[0] to parts[1]
}.toMap()
val scope = runCatching { Scope.valueOf(map["scope"]?.uppercase() ?: "ALL") }.getOrDefault(Scope.ALL)
return ShufflePrefs(
groupByArtist = map["group"] == "1",
scope = scope,
scopeName = map["name"]?.ifBlank { null },
)
}
}
private enum class ShuffleOverlay { NONE, SCOPE, ARTISTS, GENRES, PLAYLISTS, QUEUE }
/* ============================== Calculator ============================== */
@Composable
fun ShuffleByAlbumApp() {
val graph = LocalDoradoGraph.current
val scope = rememberCoroutineScope()
val colors = LocalDoradoColors.current
val albums by graph.library.albums().collectAsState(initial = emptyList())
var pick by remember { mutableStateOf<com.heretek.dorado_hd.data.model.Album?>(null) }
val scope = rememberCoroutineScope()
val tracks by graph.library.tracks().collectAsState(initial = emptyList())
val nowPlaying by graph.controller.nowPlaying.collectAsState()
val isPlaying by graph.controller.isPlaying.collectAsState()
val playlists by graph.library.playlists().collectAsState(initial = emptyList())
val groups = remember(tracks) {
tracks.groupBy { it.albumId }
.map { (albumId, list) ->
val ordered = list.sortedWith(compareBy({ it.trackNumber.takeIf { n -> n > 0 } ?: Int.MAX_VALUE }, { it.title }))
ShuffleEngine.AlbumGroup(
albumId = albumId,
title = list.first().album,
artist = list.first().artist,
tracks = ordered,
)
}
.filter { it.title.isNotBlank() }
}
var state by remember { mutableStateOf<ShuffleEngine.ShuffleState?>(null) }
var overlay by remember { mutableStateOf(ShuffleOverlay.NONE) }
var dragX by remember { mutableStateOf(0f) }
var lastTouchAt by remember { mutableStateOf(System.currentTimeMillis()) }
var saver by remember { mutableStateOf(false) }
var prefs by remember { mutableStateOf(ShuffleEngine.ShufflePrefs(false, ShuffleEngine.Scope.ALL, null)) }
fun rebuild(retainCurrent: Boolean) {
val current = state?.let { ShuffleEngine.currentTrack(it) }
val rebuilt = ShuffleEngine.buildQueue(
albums = groups,
scope = prefs.scope,
scopeName = prefs.scopeName,
groupByArtist = prefs.groupByArtist,
rng = Random(state?.generation ?: 0),
retainAlbumId = if (retainCurrent) current?.albumId else null,
retainSongIndex = if (retainCurrent) state?.songIndex ?: 0 else 0,
)
state = rebuilt
}
LaunchedEffect(Unit) {
prefs = ShuffleEngine.decodePrefs(graph.appState.get("shufflebyalbum"))
if (groups.isNotEmpty() && state == null) rebuild(retainCurrent = false)
}
LaunchedEffect(groups) {
if (groups.isEmpty()) {
state = null
} else if (state == null || state?.albums?.none { album -> groups.any { it.albumId == album.albumId } } == true) {
rebuild(retainCurrent = false)
}
}
LaunchedEffect(prefs) {
graph.appState.put("shufflebyalbum", ShuffleEngine.encodePrefs(prefs))
}
LaunchedEffect(nowPlaying?.mediaId) {
val track = nowPlaying ?: return@LaunchedEffect
val current = state ?: return@LaunchedEffect
ShuffleEngine.syncToMediaId(current, track.mediaId)?.let { state = it }
}
LaunchedEffect(Unit) {
while (true) {
delay(500)
if (System.currentTimeMillis() - lastTouchAt >= DoradoTokens.IDLE_SCREENSAVER_MS) saver = true
}
}
fun touch() {
lastTouchAt = System.currentTimeMillis()
saver = false
}
fun playQueue(index: Int = state?.let { ShuffleEngine.flatIndexOf(it) } ?: 0) {
val current = state ?: return
val flat = current.albums.flatMap { it.tracks }
if (flat.isEmpty()) return
graph.controller.play(flat, index.coerceIn(0, flat.lastIndex))
state = current.copy(isPlaying = true)
}
fun skip(forward: Boolean) {
val current = state ?: return
val next = if (forward) ShuffleEngine.next(current, forward = true, rng = Random(current.generation + 17)) else ShuffleEngine.previous(current)
state = next
val flat = next.albums.flatMap { it.tracks }
val index = ShuffleEngine.flatIndexOf(next)
if (flat.isNotEmpty()) {
if (next.generation != current.generation) {
graph.controller.play(flat, index)
} else {
graph.controller.seekToIndex(index)
}
}
}
fun skipToCurrent() {
val current = state ?: return
val flat = current.albums.flatMap { it.tracks }
if (flat.isNotEmpty()) graph.controller.seekToIndex(ShuffleEngine.flatIndexOf(current).coerceIn(0, flat.lastIndex))
}
DetailScaffold(title = "shuffle by album") {
Column(Modifier.fillMaxSize().padding(DoradoTokens.EDGE.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
EdgeCropText(
text = "shuffle",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.accent,
modifier = Modifier.combinedClickable(
onClick = {
if (albums.isNotEmpty()) pick = albums.random()
},
onLongClick = {},
),
)
if (albums.isEmpty()) {
EdgeCropText(text = "no albums on device", fontSize = DoradoTokens.TYPE_NOW_META.dp, color = colors.textSecondary)
return@Column
}
pick?.let { album ->
Box(Modifier.fillMaxSize()) {
Column(
Modifier.fillMaxSize().padding(DoradoTokens.EDGE.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
val current = state
if (current == null) {
EdgeCropText(text = "no albums on device", fontSize = DoradoTokens.TYPE_NOW_META.dp, color = colors.textSecondary)
return@Column
}
val track = ShuffleEngine.currentTrack(current)
val album = ShuffleEngine.currentAlbum(current)
EdgeCropText(
text = album?.artist ?: track?.artist ?: "—",
fontSize = DoradoTokens.TYPE_NOW_META.dp,
color = colors.textSecondary,
)
EdgeCropText(
text = album?.title ?: "—",
fontSize = DoradoTokens.TYPE_NOW_TITLE.dp,
modifier = Modifier.fillMaxWidth(),
)
EdgeCropText(
text = track?.title ?: "—",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.accent,
modifier = Modifier.fillMaxWidth(),
)
// Five-art carousel: ±2 around the current album; drag to flip.
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
com.heretek.dorado_hd.design.components.AlbumArt(
model = album.albumArtUri,
contentDescription = album.title,
modifier = Modifier.size(88.dp),
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
EdgeCropText(text = album.title, fontSize = DoradoTokens.TYPE_NOW_META.dp)
EdgeCropText(text = album.artist, fontSize = DoradoTokens.TYPE_LIST.dp, color = colors.textSecondary)
EdgeCropText(
text = "play",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.accent,
modifier = Modifier.combinedClickable(
onClick = {
scope.launch {
val tracks = graph.library.tracksByAlbum(album.albumId)
if (tracks.isNotEmpty()) graph.controller.play(tracks, 0)
Modifier
.fillMaxWidth()
.height(84.dp)
.pointerInput(current.albumIndex) {
detectHorizontalDragGestures(
onDragStart = { touch() },
onDragEnd = {
if (dragX < -60f) {
state = ShuffleEngine.nextAlbum(current, forward = true, rng = Random(current.generation + 3))
skipToCurrent()
} else if (dragX > 60f) {
state = ShuffleEngine.nextAlbum(current, forward = false)
skipToCurrent()
}
dragX = 0f
},
onLongClick = {},
),
) { _, delta -> dragX += delta }
},
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ShuffleEngine.carousel(current, radius = 2).forEach { entry ->
val focused = entry.albumId == album?.albumId
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
.graphicsLayer {
alpha = if (focused) 1f else 0.55f
translationX = if (focused) dragX * 0.25f else 0f
},
) {
AlbumArt(
model = entry.tracks.firstOrNull()?.albumArtUri,
contentDescription = entry.title,
modifier = Modifier.size(if (focused) 64.dp else 48.dp),
)
EdgeCropText(
text = entry.title,
fontSize = DoradoTokens.TYPE_CAPTION.dp,
color = if (focused) colors.accent else colors.textSecondary,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
EdgeCropText(
text = "◀◀",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.textPrimary,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { touch(); skip(false) } },
)
EdgeCropText(
text = if (isPlaying) "pause" else "play",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.accent,
modifier = Modifier.pointerInput(Unit) {
detectTapGestures {
touch()
if (isPlaying) graph.controller.toggle() else playQueue()
}
},
)
EdgeCropText(
text = "▶▶",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.textPrimary,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { touch(); skip(true) } },
)
EdgeCropText(
text = "queue",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.textSecondary,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { touch(); overlay = ShuffleOverlay.QUEUE } },
)
EdgeCropText(
text = "shuffle",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.textSecondary,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { touch(); overlay = ShuffleOverlay.SCOPE } },
)
}
val upcoming = ShuffleEngine.upcoming(current, 5)
EdgeCropText(text = "up next", fontSize = DoradoTokens.TYPE_CAPTION.dp, color = colors.textSecondary)
upcoming.forEach { next ->
EdgeCropText(
text = "${next.title} — ${next.artist}",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.textSecondary,
modifier = Modifier.fillMaxWidth(),
)
}
}
when (overlay) {
ShuffleOverlay.QUEUE -> QueueSheet(
state = state,
onPick = { index ->
touch()
playQueue(index)
overlay = ShuffleOverlay.NONE
},
onDismiss = { touch(); overlay = ShuffleOverlay.NONE },
)
ShuffleOverlay.SCOPE -> ScopePicker(
prefs = prefs,
onPickAll = {
prefs = prefs.copy(scope = ShuffleEngine.Scope.ALL, scopeName = null)
rebuild(retainCurrent = true)
overlay = ShuffleOverlay.NONE
},
onPickArtists = { overlay = ShuffleOverlay.ARTISTS },
onPickGenres = { overlay = ShuffleOverlay.GENRES },
onPickPlaylists = { overlay = ShuffleOverlay.PLAYLISTS },
onToggleGroup = {
prefs = prefs.copy(groupByArtist = !prefs.groupByArtist)
rebuild(retainCurrent = true)
},
onDismiss = { touch(); overlay = ShuffleOverlay.NONE },
)
ShuffleOverlay.ARTISTS -> PickerList(
title = "artist",
entries = tracks.map { it.artist }.filter { it.isNotBlank() }.distinct().sorted(),
onPick = { name ->
prefs = prefs.copy(scope = ShuffleEngine.Scope.ARTIST, scopeName = name)
rebuild(retainCurrent = false)
overlay = ShuffleOverlay.NONE
},
onDismiss = { touch(); overlay = ShuffleOverlay.NONE },
)
ShuffleOverlay.GENRES -> PickerList(
title = "genre",
entries = tracks.map { it.genre }.filter { it.isNotBlank() }.distinct().sorted(),
onPick = { name ->
prefs = prefs.copy(scope = ShuffleEngine.Scope.GENRE, scopeName = name)
rebuild(retainCurrent = false)
overlay = ShuffleOverlay.NONE
},
onDismiss = { touch(); overlay = ShuffleOverlay.NONE },
)
ShuffleOverlay.PLAYLISTS -> PickerList(
title = "playlist",
entries = playlists.map { it.name },
onPick = { name ->
val playlist = playlists.firstOrNull { it.name == name }
if (playlist != null) {
scope.launch {
val list = graph.library.tracksInPlaylist(playlist.id)
val grouped = list.groupBy { it.albumId }.map { (albumId, ts) ->
ShuffleEngine.AlbumGroup(albumId, ts.first().album, ts.first().artist, ts.sortedBy { it.trackNumber })
}
val built = ShuffleEngine.buildQueue(grouped, ShuffleEngine.Scope.PLAYLIST, name, prefs.groupByArtist)
if (built != null) state = built
prefs = prefs.copy(scope = ShuffleEngine.Scope.PLAYLIST, scopeName = name)
overlay = ShuffleOverlay.NONE
}
}
},
onDismiss = { touch(); overlay = ShuffleOverlay.NONE },
)
ShuffleOverlay.NONE -> Unit
}
if (saver) {
Screensaver(state = state, onAnyTouch = { touch() })
}
}
}
}
private fun playQueue() {}
@Composable
private fun QueueSheet(
state: ShuffleEngine.ShuffleState?,
onPick: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val colors = LocalDoradoColors.current
if (state == null) return
val flat = state.albums.flatMap { it.tracks }
val currentIndex = ShuffleEngine.flatIndexOf(state)
val remaining = flat.drop(currentIndex).sumOf { it.durationMs }
Box(Modifier.fillMaxSize().background(colors.background)) {
Column(Modifier.fillMaxSize().padding(DoradoTokens.EDGE.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
EdgeCropText(
text = "queue — song ${currentIndex + 1} of ${flat.size}",
fontSize = DoradoTokens.TYPE_NOW_META.dp,
color = colors.accent,
modifier = Modifier.weight(1f),
)
EdgeCropText(
text = "${formatTime(remaining)} left",
fontSize = DoradoTokens.TYPE_NOW_META.dp,
color = colors.textSecondary,
)
Spacer(Modifier.width(12.dp))
EdgeCropText(
text = "close",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.textSecondary,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onDismiss() } },
)
}
Spacer(Modifier.height(6.dp))
LazyColumn(Modifier.fillMaxSize()) {
itemsIndexed(flat) { index, track ->
Column(
Modifier
.fillMaxWidth()
.pointerInput(index) { detectTapGestures { onPick(index) } }
.padding(vertical = 4.dp),
) {
EdgeCropText(
text = if (index == currentIndex) "▸ ${track.title}" else track.title,
fontSize = DoradoTokens.TYPE_LIST.dp,
color = if (index == currentIndex) colors.accent else colors.textPrimary,
)
EdgeCropText(
text = "${track.album} — ${track.artist}",
fontSize = DoradoTokens.TYPE_CAPTION.dp,
color = colors.textSecondary,
)
}
}
@@ -113,4 +651,135 @@ fun ShuffleByAlbumApp() {
}
}
/* ============================== Music Quiz ============================== */
@Composable
private fun ScopePicker(
prefs: ShuffleEngine.ShufflePrefs,
onPickAll: () -> Unit,
onPickArtists: () -> Unit,
onPickGenres: () -> Unit,
onPickPlaylists: () -> Unit,
onToggleGroup: () -> Unit,
onDismiss: () -> Unit,
) {
val colors = LocalDoradoColors.current
Box(Modifier.fillMaxSize().background(colors.background)) {
Column(
Modifier.fillMaxSize().padding(DoradoTokens.EDGE.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
EdgeCropText(text = "shuffle", fontSize = DoradoTokens.TYPE_MENU_ITEM.dp)
EdgeCropText(
text = if (prefs.scope == ShuffleEngine.Scope.ALL && prefs.scopeName == null) "all — " else "all",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.accent,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onPickAll() } },
)
EdgeCropText(
text = if (prefs.scope == ShuffleEngine.Scope.ARTIST) "artist — ${prefs.scopeName ?: "pick"}" else "artist",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = if (prefs.scope == ShuffleEngine.Scope.ARTIST) colors.accent else colors.textPrimary,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onPickArtists() } },
)
EdgeCropText(
text = if (prefs.scope == ShuffleEngine.Scope.GENRE) "genre — ${prefs.scopeName ?: "pick"}" else "genre",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = if (prefs.scope == ShuffleEngine.Scope.GENRE) colors.accent else colors.textPrimary,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onPickGenres() } },
)
EdgeCropText(
text = if (prefs.scope == ShuffleEngine.Scope.PLAYLIST) "playlist — ${prefs.scopeName ?: "pick"}" else "playlist",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = if (prefs.scope == ShuffleEngine.Scope.PLAYLIST) colors.accent else colors.textPrimary,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onPickPlaylists() } },
)
EdgeCropText(
text = "group by artist: ${if (prefs.groupByArtist) "on" else "off"}",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.textSecondary,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onToggleGroup() } },
)
EdgeCropText(
text = "close",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.textSecondary,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onDismiss() } },
)
}
}
}
@Composable
private fun PickerList(
title: String,
entries: List<String>,
onPick: (String) -> Unit,
onDismiss: () -> Unit,
) {
val colors = LocalDoradoColors.current
Box(Modifier.fillMaxSize().background(colors.background)) {
Column(Modifier.fillMaxSize().padding(DoradoTokens.EDGE.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
EdgeCropText(text = title, fontSize = DoradoTokens.TYPE_NOW_TITLE.dp, modifier = Modifier.weight(1f))
EdgeCropText(
text = "close",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.textSecondary,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onDismiss() } },
)
}
if (entries.isEmpty()) {
EdgeCropText(text = "none", fontSize = DoradoTokens.TYPE_LIST.dp, color = colors.textInactive)
return@Column
}
LazyColumn(Modifier.fillMaxSize()) {
itemsIndexed(entries) { _, entry ->
EdgeCropText(
text = entry,
fontSize = DoradoTokens.TYPE_LIST.dp,
modifier = Modifier
.fillMaxWidth()
.pointerInput(entry) { detectTapGestures { onPick(entry) } }
.padding(vertical = 5.dp),
)
}
}
}
}
}
@Composable
private fun Screensaver(state: ShuffleEngine.ShuffleState?, onAnyTouch: () -> Unit) {
val colors = LocalDoradoColors.current
val current = state ?: return
val track = ShuffleEngine.currentTrack(current)
val transition = rememberInfiniteTransition(label = "saver")
val drift by transition.animateFloat(
initialValue = -24f,
targetValue = 24f,
animationSpec = infiniteRepeatable(
animation = tween(5_000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "drift",
)
Box(
Modifier
.fillMaxSize()
.background(colors.background)
.graphicsLayer { translationX = drift }
.pointerInput(Unit) { detectTapGestures { onAnyTouch() } },
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
AlbumArt(
model = track?.albumArtUri,
contentDescription = track?.album,
modifier = Modifier.size(120.dp),
)
EdgeCropText(text = track?.title ?: "—", fontSize = DoradoTokens.TYPE_SAVER_TITLE.dp)
EdgeCropText(text = track?.artist ?: "—", fontSize = DoradoTokens.TYPE_SAVER_ARTIST.dp, color = colors.textSecondary)
EdgeCropText(text = track?.album ?: "—", fontSize = DoradoTokens.TYPE_SAVER_ALBUM.dp, color = colors.textInactive)
}
}
}
@@ -0,0 +1,129 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.ui.apps.DrumMachineEngine
import com.heretek.dorado_hd.ui.apps.DrumMachineEngine.DrumPad
import com.heretek.dorado_hd.ui.apps.DrumMachineEngine.DrumType
import com.heretek.dorado_hd.ui.apps.DrumMachineEngine.Zone
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class DrumMachineTest {
private fun pad(
id: Int = 1,
type: DrumType,
x: Float = 0f,
y: Float = 0f,
size: Float = 100f,
loop: Boolean = true,
) = DrumPad(id, x, y, size, type, "a", "b", loop = loop)
@Test
fun `left right split sends hats open left closed right`() {
val hat = pad(type = DrumType.HIHAT, size = 100f)
assertEquals(Zone.ONE, DrumMachineEngine.zoneFor(hat, 5f, 10f))
assertEquals(Zone.TWO, DrumMachineEngine.zoneFor(hat, 20f, 10f))
val kick = pad(type = DrumType.KICK, size = 100f)
assertEquals(Zone.ONE, DrumMachineEngine.zoneFor(kick, 14f, 50f))
assertEquals(Zone.TWO, DrumMachineEngine.zoneFor(kick, 16f, 50f))
}
@Test
fun `ride splits bell from edge radially`() {
val ride = pad(type = DrumType.RIDE, size = 1f)
assertEquals(Zone.ONE, DrumMachineEngine.zoneFor(ride, 0.5f, 0.5f))
assertEquals(Zone.TWO, DrumMachineEngine.zoneFor(ride, 50f, 50f))
}
@Test
fun `held pads retrigger on their loop period`() {
assertEquals(250L, DrumMachineEngine.loopIntervalMs(DrumMachineEngine.DEFAULT_LOOP_SECONDS))
assertEquals(
listOf(250L, 500L, 750L, 1_000L),
DrumMachineEngine.pendingLoopTriggers(heldSinceMs = 0L, nowMs = 1_000L, loopSeconds = 0.25),
)
assertTrue(DrumMachineEngine.pendingLoopTriggers(500L, 500L, 0.25).isEmpty())
assertEquals(listOf(400L), DrumMachineEngine.pendingLoopTriggers(0L, 400L, 0.4))
}
@Test
fun `polyphony is capped at twelve oldest first`() {
val active = (1L..12L).toList()
val next = DrumMachineEngine.allocateVoices(active, 13L)
assertEquals(12, next.size)
assertEquals(2L, next.first())
assertEquals(13L, next.last())
assertEquals(12, DrumMachineEngine.allocateVoices(active.take(11), 12L).size)
}
@Test
fun `record to playback keeps timestamp order and fires each event once`() {
var state = DrumMachineEngine.beginRecording(DrumMachineEngine.DrumMachineState(), nowMs = 1_000L)
state = DrumMachineEngine.record(state, 1_100L, padId = 1, zone = Zone.ONE)
state = DrumMachineEngine.record(state, 1_050L, padId = 2, zone = Zone.TWO)
state = DrumMachineEngine.record(state, 1_300L, padId = 3, zone = Zone.ONE)
assertEquals(listOf(100L, 50L, 300L), state.recorded.map { it.atMs })
state = DrumMachineEngine.beginPlayback(state)
assertEquals(listOf(50L, 100L, 300L), state.playback.map { it.atMs })
val (early, afterEarly) = DrumMachineEngine.dueEvents(state, elapsedMs = 120L)
assertEquals(listOf(50L, 100L), early.map { it.atMs })
assertEquals(listOf(300L), afterEarly.playback.map { it.atMs })
val (late, afterLate) = DrumMachineEngine.dueEvents(afterEarly, elapsedMs = 300L)
assertEquals(listOf(300L), late.map { it.atMs })
assertTrue(afterLate.playback.isEmpty())
val (none, _) = DrumMachineEngine.dueEvents(afterLate, elapsedMs = 10_000L)
assertTrue(none.isEmpty())
}
@Test
fun `bpm clamps to the device range`() {
val state = DrumMachineEngine.DrumMachineState()
assertEquals(40, DrumMachineEngine.setBpm(state, 10).bpm)
assertEquals(340, DrumMachineEngine.setBpm(state, 500).bpm)
assertEquals(120, DrumMachineEngine.setBpm(state, 120).bpm)
assertEquals(500L, DrumMachineEngine.metronomePeriodMs(120))
assertEquals(1_500L, DrumMachineEngine.metronomePeriodMs(40))
}
@Test
fun `pad geometry and topmost hit`() {
val first = pad(id = 1, type = DrumType.KICK, x = 0f, y = 0f, size = 100f)
val second = pad(id = 2, type = DrumType.SNARE, x = 40f, y = 40f, size = 100f)
val state = DrumMachineEngine.DrumMachineState(
pads = listOf(first.copy(layerDepth = 0), second.copy(layerDepth = 5)),
)
assertTrue(DrumMachineEngine.contains(first, 50f, 50f))
assertEquals(2, DrumMachineEngine.padAt(state, 50f, 50f)?.id)
val moved = DrumMachineEngine.movePad(state, 2, -500f, -500f)
assertEquals(0f, moved.pads.first { it.id == 2 }.x, 0.001f)
assertEquals(0f, moved.pads.first { it.id == 2 }.y, 0.001f)
}
@Test
fun `layout and bpm round trip through the state blob`() {
val state = DrumMachineEngine.DrumMachineState(
bpm = 155,
metronome = true,
pads = DrumMachineEngine.preset(2),
)
val decoded = DrumMachineEngine.decode(DrumMachineEngine.encode(state))!!
assertEquals(155, decoded.bpm)
assertTrue(decoded.metronome)
assertEquals(state.pads, decoded.pads)
assertEquals(null, DrumMachineEngine.decode(null))
}
@Test
fun `three presets rebuild fixed layouts`() {
val ids = (0..2).map { DrumMachineEngine.preset(it).map { pad -> pad.id }.toSet() }
assertEquals(3, ids.toSet().size)
assertTrue(DrumMachineEngine.preset(0).isNotEmpty())
assertTrue(DrumMachineEngine.preset(1).isNotEmpty())
assertTrue(DrumMachineEngine.preset(2).isNotEmpty())
}
}
@@ -0,0 +1,151 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.ui.apps.PianoEngine
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.abs
class PianoLayoutTest {
@Test
fun `keyboard has 52 white and 36 black keys`() {
assertEquals(52, PianoEngine.whiteKeys().size)
assertEquals(36, PianoEngine.blackKeys().size)
assertEquals(52, PianoEngine.WHITE_KEYS)
assertEquals(36, PianoEngine.BLACK_KEYS)
}
@Test
fun `white keys run A0 to C8`() {
assertEquals(21, PianoEngine.whiteMidi(0))
assertEquals(108, PianoEngine.whiteMidi(51))
assertEquals("A0", PianoEngine.noteName(PianoEngine.whiteMidi(0)))
assertEquals("C8", PianoEngine.noteName(PianoEngine.whiteMidi(51)))
assertEquals(60, PianoEngine.whiteMidi(23))
assertEquals("C4", PianoEngine.noteName(60))
assertEquals("C#4", PianoEngine.noteName(61))
}
@Test
fun `black keys sit after A C D F and G only`() {
val placed = PianoEngine.blackKeys().map { it.whiteIndex }
val expected = (0 until 52).filter { i ->
val pc = PianoEngine.whiteMidi(i) % 12
i < 51 && pc in setOf(9, 0, 2, 5, 7)
}
assertEquals(expected, placed)
assertTrue(PianoEngine.hasBlackAfter(0)) // A0 -> A#0
assertFalse(PianoEngine.hasBlackAfter(1)) // B0 has no sharp
assertTrue(PianoEngine.hasBlackAfter(2)) // C1 -> C#1
assertTrue(PianoEngine.hasBlackAfter(6)) // G1 -> G#1
assertFalse(PianoEngine.hasBlackAfter(8)) // B1 has no sharp
assertFalse(PianoEngine.hasBlackAfter(51)) // C8 ends the keyboard
assertEquals(106, PianoEngine.blackKeys().last().midi) // A#7
}
@Test
fun `frequencies follow equal temperament`() {
assertEquals(440.0, PianoEngine.midiToFrequency(69), 0.0001)
assertEquals(261.6256, PianoEngine.midiToFrequency(60), 0.01)
assertEquals(880.0, PianoEngine.midiToFrequency(81), 0.0001)
}
@Test
fun `octave bar snaps to C keys`() {
assertEquals(2, PianoEngine.snapWhiteIndex(1))
assertEquals(23, PianoEngine.snapWhiteIndex(4))
assertEquals(51, PianoEngine.snapWhiteIndex(8))
assertEquals(4, PianoEngine.octaveForOffset(23f))
assertEquals(4, PianoEngine.octaveForOffset(22.4f))
assertEquals(23f, PianoEngine.snapOffset(25f))
assertEquals(1, PianoEngine.octaveForOffset(-100f))
assertEquals(7, PianoEngine.octaveForOffset(999f))
}
@Test
fun `voices are capped at fourteen oldest first`() {
var voices = emptyList<PianoEngine.Voice>()
for (midi in 21 until 21 + PianoEngine.VOICE_CAP) {
voices = PianoEngine.allocate(voices, PianoEngine.Voice(midi, 0L))
}
assertEquals(PianoEngine.VOICE_CAP, voices.size)
voices = PianoEngine.allocate(voices, PianoEngine.Voice(99, 1L))
assertEquals(PianoEngine.VOICE_CAP, voices.size)
assertFalse(voices.any { it.midi == 21 })
assertEquals(99, voices.last().midi)
}
@Test
fun `sustain keeps released notes ringing until unsustained`() {
val key = PianoEngine.PianoKey(0, false, 21)
var state = PianoEngine.PianoState(sustain = true)
state = PianoEngine.press(state, key, 0L)
state = PianoEngine.release(state, 21, 100L)
val ringing = state.voices.single()
assertFalse(ringing.held)
assertNull("sustained release must not start the fade", ringing.releasedAtMs)
assertEquals(1f, PianoEngine.gainFor(ringing, 5_000L, dampen = false), 0.001f)
state = PianoEngine.unsustain(state, 200L)
val fading = state.voices.single()
assertEquals(200L, fading.releasedAtMs)
assertEquals(0.5f, PianoEngine.gainFor(fading, 700L, dampen = false), 0.001f)
assertEquals(0f, PianoEngine.gainFor(fading, 1_200L, dampen = false), 0.001f)
}
@Test
fun `dampen drops the gain to seventy percent`() {
val voice = PianoEngine.Voice(60, 0L)
assertEquals(PianoEngine.DAMPEN_GAIN, PianoEngine.gainFor(voice, 100L, dampen = true), 0.001f)
assertEquals(1f, PianoEngine.gainFor(voice, 100L, dampen = false), 0.001f)
val state = PianoEngine.PianoState(dampen = true, volume = 0)
assertEquals(PianoEngine.VOLUME_GAINS[0] * PianoEngine.DAMPEN_GAIN, state.gain, 0.001f)
}
@Test
fun `volume cycles through three levels`() {
assertEquals(1, PianoEngine.cycleVolume(0))
assertEquals(2, PianoEngine.cycleVolume(1))
assertEquals(0, PianoEngine.cycleVolume(2))
}
@Test
fun `glissando path samples every ten pixels`() {
val path = PianoEngine.samplePath(0f, 35f, 10f)
assertEquals(4, path.size)
assertEquals(35f, path.last(), 0.001f)
var previous = 0f
for (point in path) {
assertTrue("step must not exceed 10px", abs(point - previous) <= 10.001f)
previous = point
}
assertEquals(listOf(7f), PianoEngine.samplePath(7f, 7f, 10f))
}
@Test
fun `hit test claims black keys above white keys`() {
val whiteWidth = 60f
val blackWidth = 40f
val blackHeight = 127f
val aSharp = PianoEngine.hitTest(60f, 5f, whiteWidth, blackWidth, blackHeight, 0f)
assertNotNull(aSharp)
assertTrue(aSharp!!.black)
assertEquals(22, aSharp.midi)
val cSharp = PianoEngine.hitTest(180f, 5f, whiteWidth, blackWidth, blackHeight, 0f)
assertEquals(25, cSharp!!.midi)
val white = PianoEngine.hitTest(59f, 200f, whiteWidth, blackWidth, blackHeight, 0f)
assertFalse(white!!.black)
assertEquals(21, white.midi)
// Scrolled one white key right: the same point is now D0's black key region.
val scrolled = PianoEngine.hitTest(0f, 5f, whiteWidth, blackWidth, blackHeight, 1f)
assertEquals(22, scrolled!!.midi)
assertNull(PianoEngine.hitTest(-5f, 200f, whiteWidth, blackWidth, blackHeight, 0f))
}
}
@@ -0,0 +1,201 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.data.model.Track
import com.heretek.dorado_hd.ui.apps.QuizEngine
import com.heretek.dorado_hd.ui.apps.QuizEngine.Difficulty
import com.heretek.dorado_hd.ui.apps.QuizEngine.Kind
import com.heretek.dorado_hd.ui.apps.QuizEngine.Phase
import com.heretek.dorado_hd.ui.apps.QuizEngine.QuizState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.random.Random
@RunWith(RobolectricTestRunner::class)
class QuizEngineTest {
private val uri = android.net.Uri.parse("file:///quiz")
private fun track(
id: Long,
title: String = "song $id",
artist: String = "artist ${id % 6}",
album: String = "album ${id / 6}",
albumId: Long = id / 6,
durationMs: Long = 180_000,
) = Track(id, title, artist, 0, album, albumId, "rock", durationMs, 0, 1, "2020", uri)
/** 6 artists x 6 albums x 6 tracks — every question kind is feasible. */
private fun library(): List<Track> = (1..36).map { track(it.toLong()) }
private fun playRound(tracks: List<Track>, difficulty: Difficulty, seed: Long, bonus: Boolean): QuizState {
var state = QuizEngine.startRound(tracks, difficulty, seed, bonusEnabled = bonus) ?: error("expected round")
var guard = 0
while (state.phase != Phase.RESULTS && guard++ < 300) {
state = when (state.phase) {
Phase.QUESTION -> QuizEngine.answer(state, state.question!!.answerIndex)
Phase.REVEAL -> QuizEngine.proceed(state, tracks)
Phase.BONUS -> {
val answered = QuizEngine.answerBonus(state, tracks, state.bonusQuestion!!.answerIndex)
QuizEngine.endBonus(answered)
}
Phase.RESULTS -> state
}
}
return state
}
@Test
fun `round is twenty questions and pays ten points each`() {
val tracks = library()
val done = playRound(tracks, Difficulty.NORMAL, seed = 42L, bonus = true)
assertEquals(20, done.totalQuestions)
assertEquals(20, done.questionNumber)
assertEquals(20, done.correct)
assertEquals(20 * 10 + 5, done.score)
}
@Test
fun `difficulty selects four or six distinct options`() {
val tracks = library()
val normal = QuizEngine.startRound(tracks, Difficulty.NORMAL, seed = 1L)!!
val hardcore = QuizEngine.startRound(tracks, Difficulty.HARDCORE, seed = 1L)!!
assertEquals(4, normal.question!!.options.size)
assertEquals(6, hardcore.question!!.options.size)
assertEquals(4, normal.optionCount)
assertEquals(6, hardcore.optionCount)
}
@Test
fun `every question kind offers distinct options containing the answer`() {
val tracks = library()
val seen = mutableSetOf<Kind>()
for (seed in 0L until 300L) {
val question = QuizEngine.nextQuestion(tracks, Difficulty.NORMAL, rng = Random(seed)) ?: continue
seen += question.kind
assertEquals("options must be distinct", question.options.size, question.options.distinct().size)
assertEquals(question.answer, question.options[question.answerIndex])
assertTrue(question.answerIndex in question.options.indices)
}
assertEquals(Kind.entries.toSet(), seen)
}
@Test
fun `album art never follows album art`() {
val tracks = library()
var state = QuizEngine.startRound(tracks, Difficulty.NORMAL, seed = 7L, bonusEnabled = false)!!
val kinds = mutableListOf<Kind>()
var guard = 0
while (state.phase != Phase.RESULTS && guard++ < 300) {
if (state.phase == Phase.QUESTION) kinds += state.question!!.kind
state = when (state.phase) {
Phase.QUESTION -> QuizEngine.answer(state, state.question!!.answerIndex)
Phase.REVEAL -> QuizEngine.proceed(state, tracks)
else -> state
}
}
assertTrue("expected questions", kinds.isNotEmpty())
for (i in 1 until kinds.size) {
assertFalse(
"album art at $i must not follow album art",
kinds[i - 1] == Kind.ALBUM_ART && kinds[i] == Kind.ALBUM_ART,
)
}
// Explicit guard against the chained-question builder.
for (seed in 0L until 50L) {
val q = QuizEngine.nextQuestion(tracks, Difficulty.NORMAL, previousWasAlbumArt = true, rng = Random(seed))
assertTrue(q == null || q.kind != Kind.ALBUM_ART)
}
}
@Test
fun `points are seconds remaining capped at ten`() {
assertEquals(10, QuizEngine.pointsFor(15f))
assertEquals(10, QuizEngine.pointsFor(9.99f))
assertEquals(4, QuizEngine.pointsFor(3.2f))
assertEquals(1, QuizEngine.pointsFor(0f))
val tracks = library()
var state = QuizEngine.startRound(tracks, Difficulty.NORMAL, seed = 9L)!!
state = QuizEngine.tick(state, 7f)
val left = state.secondsLeft
val answered = QuizEngine.answer(state, state.question!!.answerIndex)
assertEquals(QuizEngine.pointsFor(left), answered.points)
}
@Test
fun `hint limit is four on normal and two on hardcore`() {
val tracks = library()
var normal = QuizEngine.startRound(tracks, Difficulty.NORMAL, seed = 3L)!!
repeat(4) { normal = QuizEngine.useHint(normal, tracks) }
assertEquals(0, normal.hintsLeft)
val normalPast = QuizEngine.useHint(normal, tracks)
assertEquals(0, normalPast.hintsLeft)
assertEquals(4, normalPast.hintsUsed)
assertEquals(15f, normalPast.secondsLeft)
var hard = QuizEngine.startRound(tracks, Difficulty.HARDCORE, seed = 3L)!!
repeat(2) { hard = QuizEngine.useHint(hard, tracks) }
assertEquals(0, hard.hintsLeft)
assertEquals(0, QuizEngine.useHint(hard, tracks).hintsLeft)
}
@Test
fun `bonus round banks five points per correct hit`() {
val tracks = library()
var state = QuizEngine.startRound(tracks, Difficulty.NORMAL, seed = 11L, bonusEnabled = true)!!
var guard = 0
while (state.phase != Phase.BONUS && guard++ < 100) {
state = when (state.phase) {
Phase.QUESTION -> QuizEngine.answer(state, state.question!!.answerIndex)
Phase.REVEAL -> QuizEngine.proceed(state, tracks)
else -> state
}
}
assertEquals(Phase.BONUS, state.phase)
repeat(3) {
state = QuizEngine.answerBonus(state, tracks, state.bonusQuestion!!.answerIndex)
}
assertEquals(3, state.bonusCorrect)
val ended = QuizEngine.endBonus(state)
assertEquals(Phase.RESULTS, ended.phase)
assertEquals(15, ended.score - state.score)
assertEquals(15, ended.bonusPoints)
}
@Test
fun `expired content aborts the round at twenty five`() {
val tracks = library()
val expired = (1..25).map { i -> track(500L + i, title = "", artist = "", album = "") }
assertNull(QuizEngine.startRound(tracks + expired, Difficulty.NORMAL, seed = 5L))
assertNotNull(QuizEngine.startRound(tracks + expired.dropLast(1), Difficulty.NORMAL, seed = 5L))
}
@Test
fun `high scores keep the best per mix and round trip`() {
val key = QuizEngine.highScoreKey("MegaMix", Difficulty.NORMAL)
assertEquals("megamix/normal", key)
var scores: Map<String, Int> = emptyMap()
scores = QuizEngine.updateHighScore(scores, key, 70)
scores = QuizEngine.updateHighScore(scores, key, 40)
scores = QuizEngine.updateHighScore(scores, key, 95)
assertEquals(95, scores[key])
val decoded = QuizEngine.decodeHighScores(QuizEngine.encodeHighScores(scores))
assertEquals(scores, decoded)
}
@Test
fun `tick reveals on timeout with no points`() {
val tracks = library()
var state = QuizEngine.startRound(tracks, Difficulty.NORMAL, seed = 2L)!!
state = QuizEngine.tick(state, 15f)
assertEquals(Phase.REVEAL, state.phase)
assertNull(state.chosenIndex)
assertEquals(0, state.points)
assertEquals(0, state.score)
}
}
@@ -0,0 +1,166 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.data.model.Track
import com.heretek.dorado_hd.ui.apps.ShuffleEngine
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.random.Random
@RunWith(RobolectricTestRunner::class)
class ShuffleByAlbumTest {
private val uri = android.net.Uri.parse("file:///shuffle")
private fun track(id: Long, artist: String, album: String, albumId: Long, trackNumber: Int): Track =
Track(id, "song $id", artist, 0, album, albumId, "rock", 180_000, 0, trackNumber, "2020", uri)
private fun album(albumId: Long, artist: String, tracks: Int = 3): ShuffleEngine.AlbumGroup =
ShuffleEngine.AlbumGroup(
albumId = albumId,
title = "album $albumId",
artist = artist,
tracks = (1..tracks).map { n -> track(albumId * 100 + n, artist, "album $albumId", albumId, n) },
)
private fun fixture(): List<ShuffleEngine.AlbumGroup> = listOf(
album(1, "A"), album(2, "A"), album(3, "B"), album(4, "B"), album(5, "C"), album(6, "C"),
album(7, ShuffleEngine.VARIOUS_ARTISTS), album(8, ShuffleEngine.UNKNOWN_ARTIST), album(9, ShuffleEngine.SOUNDTRACK),
)
@Test
fun `every scoped album appears exactly once`() {
val state = ShuffleEngine.buildQueue(fixture(), ShuffleEngine.Scope.ARTIST, "A", rng = Random(1))!!
assertEquals(listOf(1L, 2L), state.albums.map { it.albumId }.sorted())
assertEquals(2, state.albums.size)
// Track order within an album is preserved.
assertTrue(state.albums.all { group -> group.tracks.map { it.trackNumber } == listOf(1, 2, 3) })
val genre = ShuffleEngine.buildQueue(fixture(), ShuffleEngine.Scope.GENRE, "rock", rng = Random(1))!!
assertEquals(9, genre.albums.size)
assertEquals(9, genre.albums.map { it.albumId }.toSet().size)
}
@Test
fun `grouped queue never places the same artist twice in a row`() {
for (seed in 0L until 25L) {
val state = ShuffleEngine.buildQueue(fixture(), groupByArtist = true, rng = Random(seed)) ?: error("queue")
val artists = state.albums.map { it.artist }
for (i in 1 until artists.size) {
assertFalse("seed $seed placed ${artists[i]} twice at $i", artists[i - 1] == artists[i])
}
assertEquals(9, state.albums.size)
}
}
@Test
fun `artist scope with grouping excludes placeholder artists`() {
val state = ShuffleEngine.buildQueue(
fixture(),
ShuffleEngine.Scope.ARTIST,
scopeName = null,
groupByArtist = true,
rng = Random(2),
)!!
assertTrue(state.albums.none { it.artist in ShuffleEngine.PLACEHOLDER_ARTISTS })
assertEquals(setOf(1L, 2L, 3L, 4L, 5L, 6L), state.albums.map { it.albumId }.toSet())
}
@Test
fun `next advances songs then albums and reshuffles at the end`() {
val state = ShuffleEngine.buildQueue(
listOf(album(1, "A", tracks = 2), album(2, "B", tracks = 2)),
rng = Random(3),
)!!
var next = ShuffleEngine.next(state, rng = Random(4))
assertEquals(0, next.albumIndex)
assertEquals(1, next.songIndex)
next = ShuffleEngine.next(next, rng = Random(5))
assertEquals(1, next.albumIndex)
assertEquals(0, next.songIndex)
var lastSong = next.copy(songIndex = 1)
assertTrue(ShuffleEngine.atQueueEnd(lastSong))
val wrapped = ShuffleEngine.next(lastSong, rng = Random(6))
assertEquals(state.generation + 1, wrapped.generation)
assertEquals(0, wrapped.albumIndex)
assertEquals(0, wrapped.songIndex)
assertEquals(setOf(1L, 2L), wrapped.albums.map { it.albumId }.toSet())
}
@Test
fun `previous starts the previous album at its last track`() {
val state = ShuffleEngine.buildQueue(
listOf(album(1, "A", tracks = 2), album(2, "B", tracks = 3)),
rng = Random(7),
)!!
val atSecondAlbum = state.copy(albumIndex = 1, songIndex = 0)
val previous = ShuffleEngine.previous(atSecondAlbum)
assertEquals(0, previous.albumIndex)
assertEquals(1, previous.songIndex)
// At the very start, back is clamped.
val clamped = ShuffleEngine.previous(previous.copy(songIndex = 0))
assertEquals(0, clamped.albumIndex)
assertEquals(0, clamped.songIndex)
}
@Test
fun `retain current song moves its album to the front`() {
val state = ShuffleEngine.buildQueue(
fixture(),
groupByArtist = true,
rng = Random(8),
retainAlbumId = 4L,
retainSongIndex = 2,
)!!
assertEquals(4L, state.albums.first().albumId)
assertEquals(2, state.songIndex)
assertEquals(9, state.albums.size)
assertEquals(9, state.albums.map { it.albumId }.toSet().size)
}
@Test
fun `index mapping matches the flattened queue`() {
val state = ShuffleEngine.buildQueue(fixture(), rng = Random(9))!!
val flat = ShuffleEngine.flatten(state)
assertEquals(9 * 3, flat.size)
val moved = state.copy(albumIndex = 4, songIndex = 1)
val index = ShuffleEngine.flatIndexOf(moved)
assertEquals(4 * 3 + 1, index)
assertEquals(ShuffleEngine.currentTrack(moved), ShuffleEngine.trackAtFlat(moved, index))
assertNull(ShuffleEngine.trackAtFlat(moved, 999))
}
@Test
fun `sync follows an external controller track`() {
val state = ShuffleEngine.buildQueue(fixture(), rng = Random(10))!!
val target = state.albums[3].tracks[2]
val synced = ShuffleEngine.syncToMediaId(state, target.mediaId)!!
assertEquals(3, synced.albumIndex)
assertEquals(2, synced.songIndex)
assertNull(ShuffleEngine.syncToMediaId(state, 999_999L))
}
@Test
fun `prefs round trip`() {
val prefs = ShuffleEngine.ShufflePrefs(true, ShuffleEngine.Scope.GENRE, "rock")
val decoded = ShuffleEngine.decodePrefs(ShuffleEngine.encodePrefs(prefs))
assertEquals(prefs, decoded)
assertEquals(ShuffleEngine.ShufflePrefs(false, ShuffleEngine.Scope.ALL, null), ShuffleEngine.decodePrefs(null))
}
@Test
fun `empty library yields no queue`() {
assertNull(ShuffleEngine.buildQueue(emptyList()))
assertNull(
ShuffleEngine.buildQueue(
listOf(ShuffleEngine.AlbumGroup(1, "empty", "A", emptyList())),
),
)
}
}