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