mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-14 00:16:47 +00:00
fix(android): live reduced-motion, off-main voice manifest, audio-only asset mount, voice re-apply after player rebuild
This commit is contained in:
1 parent
2831953380
commit
68ca729be4
16 files changed
+269
-91
No files matched your search
+4
-3
@@ -24,7 +24,7 @@ behavior change on the web side needs a matching Kotlin change here.
|
||||
| Package | What it is |
|
||||
|---------|------------|
|
||||
| `game` | Pure game rules ported from `web/src/lib/game-logic.js` and friends: card generator, row state, draw deck, auto-cross |
|
||||
| `audio` | Voice catalog (parses `assets/audio/manifest.json`) + ExoPlayer wrapper with the web's cancellation semantics |
|
||||
| `audio` | Voice catalog (parses the bundled `manifest.json`) + ExoPlayer wrapper with the web's cancellation semantics |
|
||||
| `settings` | `Settings` model + DataStore repository — same fields, defaults, and per-field validation as the web store |
|
||||
| `state` | Round persistence, the shared master store, and the three ViewModels |
|
||||
| `ui` | Compose screens: player board, master panel, settings sheet, theme |
|
||||
@@ -32,8 +32,9 @@ behavior change on the web side needs a matching Kotlin change here.
|
||||
## Audio assets
|
||||
|
||||
The 184 MP3s (2 voices × 92 clips) and their manifest are **not copied** into
|
||||
this project. `app/build.gradle.kts` mounts `../web/static/audio/` as
|
||||
`assets/audio/` at build time, so web and Android always ship the same files.
|
||||
this project. `app/build.gradle.kts` mounts `../web/static/audio/` (and only
|
||||
that directory) as the assets root at build time, so web and Android always
|
||||
ship the same clips, and nothing else added to `web/static/` reaches the APK.
|
||||
Regenerate them with the web app's `scripts/generate-audio.py`.
|
||||
|
||||
## Audio focus
|
||||
|
||||
@@ -25,8 +25,10 @@ android {
|
||||
getByName("main") {
|
||||
// Voice clips and their manifest are mounted straight from the web
|
||||
// app's static dir so both apps ship the exact same files — no copy
|
||||
// step, no drift. Mounts web/static/audio/** as assets/audio/**.
|
||||
assets.srcDir("../../web/static")
|
||||
// step, no drift. Only web/static/audio/ is mounted (as the assets
|
||||
// root), so anything else added to web/static never reaches the
|
||||
// APK; VoiceCatalog's asset paths are relative to this dir.
|
||||
assets.srcDir("../../web/static/audio")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +37,6 @@ android {
|
||||
// `resourceConfigurations` (defaultConfig) is deprecated for locales
|
||||
// in AGP 8.13 in favor of this (L11).
|
||||
localeFilters += listOf("vi")
|
||||
// Keep only the audio/ subtree from the mounted web/static dir.
|
||||
// Pattern extends AAPT's default ignore list (hidden files, VCS dirs).
|
||||
ignoreAssetsPattern =
|
||||
"!icons:!manifest.webmanifest:!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -15,10 +15,13 @@ import com.miti99.loto.settings.SettingsRepository
|
||||
import com.miti99.loto.state.GameStateRepository
|
||||
import com.miti99.loto.state.MasterStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -45,15 +48,26 @@ class LotoApplication : Application() {
|
||||
|
||||
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
/** Bundled announcer voices, parsed once from the asset manifest. */
|
||||
val voices: List<Voice> by lazy { VoiceCatalog.load(this) }
|
||||
/**
|
||||
* Bundled announcer voices, parsed once from the asset manifest on an IO
|
||||
* thread (asset read + JSON parse stay off the main thread). Started in
|
||||
* [onCreate]. Nothing reads it synchronously: [settingsRepository]
|
||||
* awaits it before publishing any settings, so the voice id every draw
|
||||
* announces with is validated against the real manifest, and until then
|
||||
* the placeholder defaults name the same default voice.
|
||||
*/
|
||||
private val voiceManifest: Deferred<List<Voice>> by lazy {
|
||||
appScope.async(Dispatchers.IO) { VoiceCatalog.load(this@LotoApplication) }
|
||||
}
|
||||
|
||||
/** Bundled voices for the picker; empty until [voiceManifest] resolves. */
|
||||
val voices: StateFlow<List<Voice>> by lazy {
|
||||
flow { emit(voiceManifest.await()) }
|
||||
.stateIn(appScope, SharingStarted.Eagerly, emptyList())
|
||||
}
|
||||
|
||||
val settingsRepository: SettingsRepository by lazy {
|
||||
SettingsRepository(
|
||||
dataStore = settingsDataStore,
|
||||
validVoiceIds = voices.map { it.id }.toSet(),
|
||||
defaultVoiceId = VoiceCatalog.defaultVoiceId(voices),
|
||||
)
|
||||
SettingsRepository(dataStore = settingsDataStore, voices = voiceManifest)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,12 +108,11 @@ class LotoApplication : Application() {
|
||||
* so a plain `by lazy` singleton would hand every ViewModel an
|
||||
* already-released, permanently-dead player on a relaunch into the same
|
||||
* cached process. [releaseVoicePlayer] drops the reference on an
|
||||
* explicit exit; the next access here rebuilds a working one.
|
||||
* explicit exit; the next access here rebuilds a working one, on the
|
||||
* configured voice (see [VoicePlayerHolder.followVoice]).
|
||||
*/
|
||||
private val voicePlayerHolder: VoicePlayerHolder by lazy {
|
||||
VoicePlayerHolder {
|
||||
ExoVoicePlayer(this).also { it.voiceId = VoiceCatalog.defaultVoiceId(voices) }
|
||||
}
|
||||
VoicePlayerHolder { ExoVoicePlayer(this) }
|
||||
}
|
||||
val voicePlayer: VoicePlayerApi get() = voicePlayerHolder.value
|
||||
|
||||
@@ -108,6 +121,7 @@ class LotoApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
voiceManifest.start()
|
||||
// Two unrelated jobs on two launches (M4): they used to share one
|
||||
// coroutine with the round-state restore first, so a slow/failed
|
||||
// DataStore read for the round would stall voice-id sync too. Each
|
||||
@@ -115,9 +129,8 @@ class LotoApplication : Application() {
|
||||
// settings/masterStore.state combine() is what makes that race safe
|
||||
// rather than this ordering.
|
||||
appScope.launch { masterStore.restore() }
|
||||
appScope.launch {
|
||||
// Keep the announcer on the configured voice.
|
||||
settingsState.collect { voicePlayer.voiceId = it.voice }
|
||||
}
|
||||
// Keep the announcer — including one rebuilt after a release — on
|
||||
// the configured voice.
|
||||
appScope.launch { voicePlayerHolder.followVoice(settingsState.map { it.voice }) }
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import android.content.Context
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* A bundled announcer voice, mirroring the entries of
|
||||
* `assets/audio/manifest.json` (the same file the web app imports).
|
||||
* A bundled announcer voice, mirroring the entries of the bundled
|
||||
* `manifest.json` (`web/static/audio/manifest.json`, the same file the web
|
||||
* app imports).
|
||||
*/
|
||||
data class Voice(
|
||||
val id: String,
|
||||
@@ -15,13 +16,15 @@ data class Voice(
|
||||
)
|
||||
|
||||
/**
|
||||
* Catalog of the bundled voices. Clips live under
|
||||
* `assets/audio/{voiceId}/{1..90,cho,kinh}.mp3`; the manifest is produced by
|
||||
* `scripts/generate-audio.py` in the web app and shipped verbatim.
|
||||
* Catalog of the bundled voices. `web/static/audio/` is mounted as the
|
||||
* assets root (see `app/build.gradle.kts`), so clips live at
|
||||
* `assets/{voiceId}/{1..90,cho,kinh}.mp3` next to `assets/manifest.json`;
|
||||
* the manifest is produced by `scripts/generate-audio.py` in the web app and
|
||||
* shipped verbatim.
|
||||
*/
|
||||
object VoiceCatalog {
|
||||
|
||||
const val MANIFEST_ASSET_PATH = "audio/manifest.json"
|
||||
const val MANIFEST_ASSET_PATH = "manifest.json"
|
||||
|
||||
/** Clip basename for the "Chờ" announcement. */
|
||||
const val CLIP_CHO = "cho"
|
||||
@@ -79,5 +82,5 @@ object VoiceCatalog {
|
||||
* [clipName] ∈ "1".."90" | [CLIP_CHO] | [CLIP_KINH].
|
||||
*/
|
||||
fun clipPath(voiceId: String, clipName: String): String =
|
||||
"audio/$voiceId/$clipName.mp3"
|
||||
"$voiceId/$clipName.mp3"
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import androidx.media3.exoplayer.ExoPlayer
|
||||
* across calls. Switching the voice takes effect on the next utterance.
|
||||
*/
|
||||
interface VoicePlayerApi {
|
||||
/** The active voice id (folder slug under `assets/audio/`). */
|
||||
/** The active voice id (folder slug under the assets root, i.e. `web/static/audio/`). */
|
||||
var voiceId: String
|
||||
|
||||
/** Play [clipNames] as one gapless sequence, canceling any in-flight one. */
|
||||
@@ -53,7 +53,7 @@ fun waitingClips(n: Int, voiceWaitingNumber: Boolean, modeIsBoth: Boolean): List
|
||||
}
|
||||
|
||||
/**
|
||||
* Media3 ExoPlayer implementation over the bundled `asset:///audio/...`
|
||||
* Media3 ExoPlayer implementation over the bundled `asset:///{voiceId}/...`
|
||||
* clips. Main-thread only (ExoPlayer requirement) — constructed and used
|
||||
* from the UI thread.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.miti99.loto.audio
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Recreatable holder for an app-scoped [VoicePlayerApi]. A plain `by lazy`
|
||||
* singleton is one-shot: once [release] is called the delegate is terminal
|
||||
@@ -13,14 +15,38 @@ package com.miti99.loto.audio
|
||||
* again instead of a dead one, while an explicit exit still actually frees
|
||||
* the previous instance's resources (the original intent behind releasing
|
||||
* at all, rather than merely cancelling).
|
||||
*
|
||||
* [followVoice] owns the configured voice id rather than writing it straight
|
||||
* into one player instance, so a rebuilt player starts on the user's voice
|
||||
* instead of the factory default (the settings flow does not re-emit an
|
||||
* unchanged value just because the player behind it was replaced).
|
||||
*/
|
||||
class VoicePlayerHolder(private val factory: () -> VoicePlayerApi) {
|
||||
|
||||
private var instance: VoicePlayerApi? = null
|
||||
|
||||
/** Latest id from [followVoice]; null = keep the factory's default. */
|
||||
private var voiceId: String? = null
|
||||
|
||||
/** Builds a fresh player on first access and after every [release]. */
|
||||
val value: VoicePlayerApi
|
||||
get() = instance ?: factory().also { instance = it }
|
||||
get() = instance ?: factory().also { player ->
|
||||
voiceId?.let { player.voiceId = it }
|
||||
instance = player
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the current player, and every one rebuilt later, on the latest
|
||||
* id from [voiceIds]. Does not build a player itself: a released holder
|
||||
* just remembers the id for the next [value] access. Suspends for as
|
||||
* long as [voiceIds] does.
|
||||
*/
|
||||
suspend fun followVoice(voiceIds: Flow<String>) {
|
||||
voiceIds.collect { id ->
|
||||
voiceId = id
|
||||
instance?.voiceId = id
|
||||
}
|
||||
}
|
||||
|
||||
/** Release the current player, if any, and drop the reference. */
|
||||
fun release() {
|
||||
|
||||
@@ -10,7 +10,10 @@ import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.miti99.loto.audio.Voice
|
||||
import com.miti99.loto.audio.VoiceCatalog
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -21,13 +24,14 @@ import kotlinx.coroutines.flow.onStart
|
||||
* read and falls back to its own default (the web's per-field `valid*` ??
|
||||
* default pattern) — a bad value never wholesale-resets the rest.
|
||||
*
|
||||
* @param validVoiceIds allowlist from the audio manifest
|
||||
* @param defaultVoiceId first manifest entry
|
||||
* @param voices the bundled voice manifest, parsed off the main thread. Its
|
||||
* ids are the voice allowlist and its first entry the default voice;
|
||||
* [settingsFlow] awaits it, so no settings snapshot is ever published
|
||||
* from a not-yet-loaded manifest.
|
||||
*/
|
||||
class SettingsRepository(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val validVoiceIds: Set<String>,
|
||||
private val defaultVoiceId: String,
|
||||
private val voices: Deferred<List<Voice>>,
|
||||
) {
|
||||
|
||||
private object Keys {
|
||||
@@ -43,8 +47,14 @@ class SettingsRepository(
|
||||
val BOARD_TEXT_SCALE = floatPreferencesKey("boardTextScale")
|
||||
}
|
||||
|
||||
/** Defaults with the manifest-derived voice filled in. */
|
||||
val defaults: Settings = Settings(voice = defaultVoiceId)
|
||||
/**
|
||||
* Placeholder for the window before [settingsFlow]'s first emission.
|
||||
* Must not wait for the manifest, so the voice comes from
|
||||
* [VoiceCatalog.FALLBACK_VOICES], whose first entry matches the shipped
|
||||
* manifest's (pinned by `VoiceCatalogTest`). Loaded settings always
|
||||
* carry the real manifest-derived default.
|
||||
*/
|
||||
val defaults: Settings = Settings(voice = VoiceCatalog.defaultVoiceId(VoiceCatalog.FALLBACK_VOICES))
|
||||
|
||||
// Every read failure — IOException (full disk, revoked storage
|
||||
// permission) or anything else — falls back to defaults instead of
|
||||
@@ -60,7 +70,7 @@ class SettingsRepository(
|
||||
Log.w(TAG, "Failed to read settings; falling back to defaults", it)
|
||||
emit(emptyPreferences())
|
||||
}
|
||||
.map { prefs -> toSettings(prefs) }
|
||||
.map { prefs -> toSettings(prefs, voices.await()) }
|
||||
|
||||
/**
|
||||
* True once [settingsFlow] has produced its first value — either the
|
||||
@@ -76,7 +86,7 @@ class SettingsRepository(
|
||||
*/
|
||||
val loaded: Flow<Boolean> = settingsFlow.map { true }.onStart { emit(false) }
|
||||
|
||||
private fun toSettings(prefs: Preferences): Settings = Settings(
|
||||
private fun toSettings(prefs: Preferences, voices: List<Voice>): Settings = Settings(
|
||||
emptyCellColor = prefs[Keys.EMPTY_CELL_COLOR]
|
||||
?.takeIf { Settings.HEX6.matches(it) }
|
||||
?: Settings.DEFAULT_EMPTY_CELL_COLOR,
|
||||
@@ -90,8 +100,8 @@ class SettingsRepository(
|
||||
voiceEnabledPlayer = prefs[Keys.VOICE_ENABLED_PLAYER] ?: false,
|
||||
voiceWaitingNumber = prefs[Keys.VOICE_WAITING_NUMBER] ?: false,
|
||||
voice = prefs[Keys.VOICE]
|
||||
?.takeIf { validVoiceIds.contains(it) }
|
||||
?: defaultVoiceId,
|
||||
?.takeIf { id -> voices.any { it.id == id } }
|
||||
?: VoiceCatalog.defaultVoiceId(voices),
|
||||
boardTextScale = prefs[Keys.BOARD_TEXT_SCALE]
|
||||
?.takeIf { scale -> Settings.BOARD_TEXT_SCALES.any { it == scale } }
|
||||
?: Settings.DEFAULT_BOARD_TEXT_SCALE,
|
||||
|
||||
@@ -18,6 +18,13 @@ import kotlinx.coroutines.launch
|
||||
* `web/src/lib/MasterPanel.svelte`. Deck data lives in the shared
|
||||
* [MasterStore]; this ViewModel adds the auto-call ticker, voice
|
||||
* announcements, and the countdown reset signal.
|
||||
*
|
||||
* No `onCleared()` cancel of [voicePlayer]: it is an app-scoped singleton
|
||||
* shared with [PlayerBoardViewModel], and `MainActivity.onDestroy()`
|
||||
* already releases it on the same final-exit (`isFinishing`) path. A
|
||||
* per-ViewModel cancel would be redundant, and would let one screen
|
||||
* tearing down cut audio the other is still using if the two ViewModels'
|
||||
* scopes ever diverged.
|
||||
*/
|
||||
class MasterPanelViewModel(
|
||||
private val masterStore: MasterStore,
|
||||
@@ -116,20 +123,6 @@ class MasterPanelViewModel(
|
||||
_foreground.value = foreground
|
||||
if (!foreground) voicePlayer.cancel()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
// L2: voicePlayer is an app-scoped singleton also cancelled by
|
||||
// PlayerBoardViewModel.onCleared(). Both VMs are activity-scoped
|
||||
// (LotoViewModelFactory), so onCleared() only fires together, on a
|
||||
// real finish — MainActivity.onDestroy() already stops/releases the
|
||||
// player unconditionally on that same isFinishing path (L1). A
|
||||
// per-VM cancel() here was therefore redundant *and* fragile: if
|
||||
// either VM's scope ever changed to clear independently of the
|
||||
// other, one screen tearing down could cut audio the other screen
|
||||
// is still using. Not cancelling here relies solely on
|
||||
// MainActivity's guarded release() to stop playback on exit.
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
private data class Quad(
|
||||
|
||||
@@ -61,6 +61,9 @@ data class PlayerUiState(
|
||||
* `web/src/lib/PlayerBoard.svelte`: cell toggling with manual-untick
|
||||
* tracking, master auto-cross replay, chờ/kinh detection with voice + toast,
|
||||
* and round persistence.
|
||||
*
|
||||
* Deliberately leaves [voicePlayer] alone when cleared — see
|
||||
* [MasterPanelViewModel]'s class doc.
|
||||
*/
|
||||
class PlayerBoardViewModel(
|
||||
private val repository: GameStateRepository,
|
||||
@@ -93,7 +96,7 @@ class PlayerBoardViewModel(
|
||||
* Used only for the narrow pre-load window where [settingsOrNull] is
|
||||
* still null but a restored, already-clickable grid can be interacted
|
||||
* with (`onCellClick` → `detectRowEvents`). Production wiring supplies
|
||||
* the manifest-derived defaults; tests that never exercise that window
|
||||
* `SettingsRepository.defaults`; tests that never exercise that window
|
||||
* (i.e. every test whose `settingsOrNull` is a non-null-typed
|
||||
* `StateFlow<Settings>`) never read this.
|
||||
*/
|
||||
@@ -380,15 +383,6 @@ class PlayerBoardViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
// L2: see MasterPanelViewModel.onCleared() — voicePlayer is a
|
||||
// shared singleton; MainActivity.onDestroy() already stops it on
|
||||
// the same isFinishing condition this onCleared() fires under, so
|
||||
// cancelling it here too was redundant and coupled two
|
||||
// independently-clearable owners to one shared resource.
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect newly completed and newly waiting rows, mirroring the web's
|
||||
* two-pass $effect: at most one Kinh modal per change, then toast +
|
||||
|
||||
@@ -18,8 +18,8 @@ import kotlinx.coroutines.launch
|
||||
class SettingsViewModel(
|
||||
private val repository: SettingsRepository,
|
||||
val settings: StateFlow<Settings>,
|
||||
/** Bundled voices for the voice picker. */
|
||||
val voices: List<Voice>,
|
||||
/** Bundled voices for the voice picker; empty until the manifest has loaded. */
|
||||
val voices: StateFlow<List<Voice>>,
|
||||
) : ViewModel() {
|
||||
|
||||
fun setEmptyCellColor(value: String) = update { repository.setEmptyCellColor(value) }
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
package com.miti99.loto.ui
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.database.ContentObserver
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
@@ -10,15 +18,31 @@ import androidx.compose.ui.platform.LocalContext
|
||||
* globally disabled when the animator duration scale is 0 (system
|
||||
* "Remove animations" accessibility setting). Gates decorative pulses,
|
||||
* confetti, and tap haptics — same events the web suppresses.
|
||||
*
|
||||
* Observes the setting while in composition, so toggling it mid-session
|
||||
* takes effect without a restart (the web's media query is live too).
|
||||
*/
|
||||
@Composable
|
||||
fun rememberReducedMotion(): Boolean {
|
||||
val context = LocalContext.current
|
||||
return remember {
|
||||
Settings.Global.getFloat(
|
||||
context.contentResolver,
|
||||
Settings.Global.ANIMATOR_DURATION_SCALE,
|
||||
1f,
|
||||
) == 0f
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
var reduced by remember(resolver) { mutableStateOf(readReducedMotion(resolver)) }
|
||||
DisposableEffect(resolver) {
|
||||
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
reduced = readReducedMotion(resolver)
|
||||
}
|
||||
}
|
||||
resolver.registerContentObserver(
|
||||
Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE),
|
||||
false,
|
||||
observer,
|
||||
)
|
||||
// Catch a toggle that landed between the first read and registering.
|
||||
reduced = readReducedMotion(resolver)
|
||||
onDispose { resolver.unregisterContentObserver(observer) }
|
||||
}
|
||||
return reduced
|
||||
}
|
||||
|
||||
private fun readReducedMotion(resolver: ContentResolver): Boolean =
|
||||
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
||||
@@ -52,6 +52,7 @@ fun SettingsSheet(
|
||||
) {
|
||||
val palette = LotoTheme.palette
|
||||
val settings by viewModel.settings.collectAsState()
|
||||
val voices by viewModel.voices.collectAsState()
|
||||
var confirmReset by remember { mutableStateOf(false) }
|
||||
|
||||
ModalBottomSheet(
|
||||
@@ -215,7 +216,7 @@ fun SettingsSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (viewModel.voices.size > 1) {
|
||||
if (voices.size > 1) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_voice_picker),
|
||||
color = palette.buttonSecondaryText,
|
||||
@@ -224,7 +225,7 @@ fun SettingsSheet(
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 6.dp),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (voice in viewModel.voices) {
|
||||
for (voice in voices) {
|
||||
ChoiceButton(
|
||||
label = voice.label,
|
||||
selected = settings.voice == voice.id,
|
||||
|
||||
@@ -11,13 +11,19 @@ class VoiceCatalogTest {
|
||||
fun `parses the real bundled manifest`() {
|
||||
// The same file Gradle mounts into assets/ — read straight from the
|
||||
// web app's static dir (test working dir is the app module).
|
||||
val manifest = File("../../web/static/audio/manifest.json")
|
||||
val manifest = File(AUDIO_ASSETS_DIR, VoiceCatalog.MANIFEST_ASSET_PATH)
|
||||
assertTrue("expected ${manifest.absolutePath} to exist", manifest.exists())
|
||||
val voices = VoiceCatalog.parse(manifest.readText())
|
||||
assertEquals(listOf("hoai-my", "nam-minh"), voices.map { it.id })
|
||||
assertEquals("vi-VN-HoaiMyNeural", voices[0].edgeName)
|
||||
assertEquals("female", voices[0].gender)
|
||||
assertEquals("Nam Minh (nam)", voices[1].label)
|
||||
// SettingsRepository.defaults uses the fallback default before the
|
||||
// manifest has loaded; it must name the same voice.
|
||||
assertEquals(
|
||||
VoiceCatalog.defaultVoiceId(VoiceCatalog.FALLBACK_VOICES),
|
||||
VoiceCatalog.defaultVoiceId(voices),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -55,8 +61,26 @@ class VoiceCatalogTest {
|
||||
|
||||
@Test
|
||||
fun `clipPath builds the asset-relative mp3 path`() {
|
||||
assertEquals("audio/hoai-my/42.mp3", VoiceCatalog.clipPath("hoai-my", "42"))
|
||||
assertEquals("audio/nam-minh/cho.mp3", VoiceCatalog.clipPath("nam-minh", VoiceCatalog.CLIP_CHO))
|
||||
assertEquals("audio/hoai-my/kinh.mp3", VoiceCatalog.clipPath("hoai-my", VoiceCatalog.CLIP_KINH))
|
||||
assertEquals("hoai-my/42.mp3", VoiceCatalog.clipPath("hoai-my", "42"))
|
||||
assertEquals("nam-minh/cho.mp3", VoiceCatalog.clipPath("nam-minh", VoiceCatalog.CLIP_CHO))
|
||||
assertEquals("hoai-my/kinh.mp3", VoiceCatalog.clipPath("hoai-my", VoiceCatalog.CLIP_KINH))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every clip path resolves inside the mounted assets dir`() {
|
||||
// Pins the build.gradle.kts mount (web/static/audio as the assets
|
||||
// root) to the paths the player requests at runtime.
|
||||
val voices = VoiceCatalog.parse(File(AUDIO_ASSETS_DIR, VoiceCatalog.MANIFEST_ASSET_PATH).readText())
|
||||
val clips = (1..90).map { it.toString() } + VoiceCatalog.CLIP_CHO + VoiceCatalog.CLIP_KINH
|
||||
val missing = voices.flatMap { voice ->
|
||||
clips.map { VoiceCatalog.clipPath(voice.id, it) }
|
||||
.filterNot { File(AUDIO_ASSETS_DIR, it).isFile }
|
||||
}
|
||||
assertEquals(emptyList<String>(), missing)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Mirrors `assets.srcDir` in `app/build.gradle.kts` (test working dir is the app module). */
|
||||
const val AUDIO_ASSETS_DIR = "../../web/static/audio"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.miti99.loto.audio
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotSame
|
||||
@@ -13,8 +18,11 @@ import org.junit.Test
|
||||
* Android can relaunch into the same cached process. These cover the
|
||||
* holder's recreate-after-release contract in isolation, since
|
||||
* `LotoApplication`/`ExoVoicePlayer` need a real Android `Context` and
|
||||
* cannot be constructed in a plain JVM unit test.
|
||||
* cannot be constructed in a plain JVM unit test. The voice-sync glue
|
||||
* `LotoApplication` wires up ([VoicePlayerHolder.followVoice] over the
|
||||
* settings voice) is covered here the same way, with a fake player.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class VoicePlayerHolderTest {
|
||||
|
||||
@Test
|
||||
@@ -57,4 +65,50 @@ class VoicePlayerHolderTest {
|
||||
assertNotSame(first, second)
|
||||
assertFalse(second.released)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `followVoice applies every configured voice to the live player`() = runTest {
|
||||
val holder = VoicePlayerHolder { FakeVoicePlayer() }
|
||||
val player = holder.value
|
||||
val voice = MutableStateFlow("hoai-my")
|
||||
backgroundScope.launch { holder.followVoice(voice) }
|
||||
runCurrent()
|
||||
assertEquals("hoai-my", player.voiceId)
|
||||
|
||||
voice.value = "nam-minh"
|
||||
runCurrent()
|
||||
assertEquals("nam-minh", player.voiceId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a player rebuilt after release starts on the configured voice, not the factory default`() =
|
||||
runTest {
|
||||
val holder = VoicePlayerHolder { FakeVoicePlayer() } // fake defaults to hoai-my
|
||||
holder.value
|
||||
val voice = MutableStateFlow("nam-minh")
|
||||
backgroundScope.launch { holder.followVoice(voice) }
|
||||
runCurrent()
|
||||
|
||||
holder.release()
|
||||
// The settings flow does not re-emit an unchanged voice, so the
|
||||
// rebuilt player must pick it up from the holder itself.
|
||||
assertEquals("nam-minh", holder.value.voiceId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `followVoice does not build a player while none is held`() = runTest {
|
||||
var buildCount = 0
|
||||
val holder = VoicePlayerHolder { buildCount++; FakeVoicePlayer() }
|
||||
holder.value
|
||||
holder.release()
|
||||
val voice = MutableStateFlow("hoai-my")
|
||||
backgroundScope.launch { holder.followVoice(voice) }
|
||||
runCurrent()
|
||||
voice.value = "nam-minh"
|
||||
runCurrent()
|
||||
assertEquals(1, buildCount)
|
||||
|
||||
assertEquals("nam-minh", holder.value.voiceId)
|
||||
assertEquals(2, buildCount)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.miti99.loto.InMemoryDataStore
|
||||
import com.miti99.loto.SlowDataStore
|
||||
import com.miti99.loto.ThrowingDataStore
|
||||
import com.miti99.loto.audio.Voice
|
||||
import com.miti99.loto.audio.VoiceCatalog
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -28,10 +30,10 @@ import org.junit.Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SettingsRepositoryTest {
|
||||
|
||||
private val voiceIds = setOf("hoai-my", "nam-minh")
|
||||
|
||||
private fun repo(store: DataStore<Preferences>) =
|
||||
SettingsRepository(store, voiceIds, defaultVoiceId = "hoai-my")
|
||||
private fun repo(
|
||||
store: DataStore<Preferences>,
|
||||
voices: List<Voice> = VoiceCatalog.FALLBACK_VOICES,
|
||||
) = SettingsRepository(store, CompletableDeferred(voices))
|
||||
|
||||
private fun runWithRepo(
|
||||
block: suspend (SettingsRepository, DataStore<Preferences>) -> Unit,
|
||||
@@ -250,4 +252,38 @@ class SettingsRepositoryTest {
|
||||
runCurrent()
|
||||
assertEquals(listOf(false, true), states)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `settings are not published until the voice manifest has loaded`() = runTest {
|
||||
// The manifest is parsed off the main thread; a snapshot published
|
||||
// before it lands would validate the voice against nothing.
|
||||
val manifest = CompletableDeferred<List<Voice>>()
|
||||
val repository = SettingsRepository(InMemoryDataStore(), manifest)
|
||||
val loaded = mutableListOf<Boolean>()
|
||||
val emitted = mutableListOf<Settings>()
|
||||
backgroundScope.launch { repository.loaded.toList(loaded) }
|
||||
backgroundScope.launch { repository.settingsFlow.toList(emitted) }
|
||||
runCurrent()
|
||||
assertEquals(listOf(false), loaded)
|
||||
assertEquals(emptyList<Settings>(), emitted)
|
||||
|
||||
manifest.complete(VoiceCatalog.FALLBACK_VOICES)
|
||||
runCurrent()
|
||||
assertEquals(listOf(false, true), loaded)
|
||||
assertEquals(listOf("hoai-my"), emitted.map { it.voice })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default and allowed voices come from the loaded manifest`() = runTest {
|
||||
val manifestVoices = listOf(
|
||||
Voice("nam-minh", "vi-VN-NamMinhNeural", "Nam Minh (nam)", "male"),
|
||||
)
|
||||
val store = InMemoryDataStore()
|
||||
val repository = repo(store, manifestVoices)
|
||||
assertEquals("nam-minh", repository.settingsFlow.first().voice)
|
||||
|
||||
// Valid under the fallback list, but not in this manifest.
|
||||
store.edit { it[stringPreferencesKey("voice")] = "hoai-my" }
|
||||
assertEquals("nam-minh", repository.settingsFlow.first().voice)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.miti99.loto.state
|
||||
import com.miti99.loto.InMemoryDataStore
|
||||
import com.miti99.loto.SlowDataStore
|
||||
import com.miti99.loto.audio.FakeVoicePlayer
|
||||
import com.miti99.loto.audio.VoiceCatalog
|
||||
import com.miti99.loto.game.CardGenerator
|
||||
import com.miti99.loto.settings.AppMode
|
||||
import com.miti99.loto.settings.Settings
|
||||
@@ -456,15 +457,15 @@ class PlayerBoardViewModelTest {
|
||||
MasterRoundState(called = (1..90).toList(), remaining = emptyList()),
|
||||
)
|
||||
|
||||
val voiceIds = setOf("hoai-my")
|
||||
val voices = CompletableDeferred(listOf(VoiceCatalog.FALLBACK_VOICES.first()))
|
||||
val settingsBacking = InMemoryDataStore()
|
||||
// Persist the real mode ahead of time (BOTH != the PLAYER
|
||||
// default) via an ungated repository over the same backing store.
|
||||
SettingsRepository(settingsBacking, voiceIds, "hoai-my").setMode(AppMode.BOTH)
|
||||
SettingsRepository(settingsBacking, voices).setMode(AppMode.BOTH)
|
||||
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
val gatedSettingsRepo =
|
||||
SettingsRepository(SlowDataStore(settingsBacking, gate), voiceIds, "hoai-my")
|
||||
SettingsRepository(SlowDataStore(settingsBacking, gate), voices)
|
||||
// Single source of truth (M2), matching LotoApplication's
|
||||
// settingsOrNull wiring: null until the gated read resolves.
|
||||
val settingsOrNull: StateFlow<Settings?> = gatedSettingsRepo.settingsFlow
|
||||
|
||||
Reference in new issue
Block a user