Skip to content

Commit 86b76e1

Browse files
committed
Add sound effects, improve feedback systems, and fix download bugs
New features: - Sound feedback system with 14 customizable UI sounds - Settings > Sounds section for per-action sound customization Improvements: - Haptic intensity control (Light/Medium/Strong) - Unified Y button for favorites across all game views - Focus follows game when Home screen re-sorts after download - Smoother d-pad navigation timing (200ms repeat rate) - Download start/complete/error audio cues - Clearer download error messages (404, auth, server errors) - Auto-clear completed downloads on startup Bug fixes: - Fix re-download blocked after deleting game files - Fix 404 download errors retrying endlessly - Fix volume preview playing at wrong level - Fix Game Detail Y button not triggering favorite toggle
1 parent 99a3d03 commit 86b76e1

45 files changed

Lines changed: 2382 additions & 409 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/schemas/com.nendo.argosy.data.local.ALauncherDatabase/9.json

Lines changed: 644 additions & 0 deletions
Large diffs are not rendered by default.

app/src/main/kotlin/com/nendo/argosy/data/download/DownloadManager.kt

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import com.nendo.argosy.data.local.entity.DownloadQueueEntity
99
import com.nendo.argosy.data.model.GameSource
1010
import com.nendo.argosy.data.preferences.UserPreferencesRepository
1111
import com.nendo.argosy.data.remote.romm.RomMRepository
12+
import com.nendo.argosy.ui.input.SoundFeedbackManager
13+
import com.nendo.argosy.ui.input.SoundType
1214
import com.nendo.argosy.data.remote.romm.RomMResult
1315
import dagger.hilt.android.qualifiers.ApplicationContext
1416
import kotlinx.coroutines.CancellationException
@@ -86,7 +88,8 @@ class DownloadManager @Inject constructor(
8688
private val gameDao: GameDao,
8789
private val downloadQueueDao: DownloadQueueDao,
8890
private val romMRepository: RomMRepository,
89-
private val preferencesRepository: UserPreferencesRepository
91+
private val preferencesRepository: UserPreferencesRepository,
92+
private val soundManager: SoundFeedbackManager
9093
) {
9194
private val _state = MutableStateFlow(DownloadQueueState())
9295
val state: StateFlow<DownloadQueueState> = _state.asStateFlow()
@@ -255,6 +258,7 @@ class DownloadManager @Inject constructor(
255258
}
256259

257260
Log.d(TAG, "processQueue: starting ${next.gameTitle}")
261+
soundManager.play(SoundType.DOWNLOAD_START)
258262

259263
_state.value = _state.value.copy(
260264
activeDownloads = _state.value.activeDownloads + next.copy(state = DownloadState.DOWNLOADING),
@@ -270,13 +274,15 @@ class DownloadManager @Inject constructor(
270274
val finalProgress = when (result) {
271275
is DownloadResult.Success -> {
272276
downloadQueueDao.updateState(next.id, DownloadState.COMPLETED.name)
277+
soundManager.play(SoundType.DOWNLOAD_COMPLETE)
273278
next.copy(
274279
state = DownloadState.COMPLETED,
275280
bytesDownloaded = result.bytesWritten
276281
)
277282
}
278283
is DownloadResult.Failure -> {
279284
downloadQueueDao.updateState(next.id, DownloadState.FAILED.name, result.reason)
285+
soundManager.play(SoundType.ERROR)
280286
next.copy(
281287
state = DownloadState.FAILED,
282288
errorReason = result.reason
@@ -500,9 +506,21 @@ class DownloadManager @Inject constructor(
500506
val failed = downloadQueueDao.getFailedDownloads()
501507
if (failed.isEmpty()) return
502508

503-
Log.d(TAG, "retryFailedDownloads: retrying ${failed.size} failed downloads")
509+
val retryable = failed.filter { entity ->
510+
val reason = entity.errorReason ?: ""
511+
!reason.contains("not found", ignoreCase = true) &&
512+
!reason.contains("HTTP 400", ignoreCase = true) &&
513+
!reason.contains("HTTP 404", ignoreCase = true)
514+
}
515+
516+
if (retryable.isEmpty()) {
517+
Log.d(TAG, "retryFailedDownloads: ${failed.size} failed but none retryable (permanent errors)")
518+
return
519+
}
520+
521+
Log.d(TAG, "retryFailedDownloads: retrying ${retryable.size} of ${failed.size} failed downloads")
504522

505-
for (entity in failed) {
523+
for (entity in retryable) {
506524
downloadQueueDao.updateState(entity.id, DownloadState.QUEUED.name, null)
507525
}
508526

app/src/main/kotlin/com/nendo/argosy/data/local/ALauncherDatabase.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import com.nendo.argosy.data.local.entity.PlatformEntity
2525
PendingSyncEntity::class,
2626
DownloadQueueEntity::class
2727
],
28-
version = 8,
28+
version = 9,
2929
exportSchema = true
3030
)
3131
@TypeConverters(Converters::class)
@@ -129,5 +129,13 @@ abstract class ALauncherDatabase : RoomDatabase() {
129129
db.execSQL("CREATE INDEX IF NOT EXISTS index_download_queue_state ON download_queue(state)")
130130
}
131131
}
132+
133+
val MIGRATION_8_9 = object : Migration(8, 9) {
134+
override fun migrate(db: SupportSQLiteDatabase) {
135+
db.execSQL("ALTER TABLE games ADD COLUMN completion INTEGER NOT NULL DEFAULT 0")
136+
db.execSQL("ALTER TABLE games ADD COLUMN backlogged INTEGER NOT NULL DEFAULT 0")
137+
db.execSQL("ALTER TABLE games ADD COLUMN nowPlaying INTEGER NOT NULL DEFAULT 0")
138+
}
139+
}
132140
}
133141
}

app/src/main/kotlin/com/nendo/argosy/data/local/dao/GameDao.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,4 +186,13 @@ interface GameDao {
186186

187187
@Query("UPDATE games SET userDifficulty = :difficulty WHERE id = :gameId")
188188
suspend fun updateUserDifficulty(gameId: Long, difficulty: Int)
189+
190+
@Query("UPDATE games SET completion = :completion WHERE id = :gameId")
191+
suspend fun updateCompletion(gameId: Long, completion: Int)
192+
193+
@Query("UPDATE games SET backlogged = :backlogged WHERE id = :gameId")
194+
suspend fun updateBacklogged(gameId: Long, backlogged: Boolean)
195+
196+
@Query("UPDATE games SET nowPlaying = :nowPlaying WHERE id = :gameId")
197+
suspend fun updateNowPlaying(gameId: Long, nowPlaying: Boolean)
189198
}

app/src/main/kotlin/com/nendo/argosy/data/local/entity/GameEntity.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ data class GameEntity(
6161

6262
val userRating: Int = 0,
6363
val userDifficulty: Int = 0,
64+
val completion: Int = 0,
65+
val backlogged: Boolean = false,
66+
val nowPlaying: Boolean = false,
6467

6568
val isFavorite: Boolean = false,
6669
val isHidden: Boolean = false,

app/src/main/kotlin/com/nendo/argosy/data/preferences/UserPreferencesRepository.kt

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
77
import androidx.datastore.preferences.core.edit
88
import androidx.datastore.preferences.core.intPreferencesKey
99
import androidx.datastore.preferences.core.stringPreferencesKey
10+
import com.nendo.argosy.ui.input.SoundConfig
11+
import com.nendo.argosy.ui.input.SoundType
1012
import kotlinx.coroutines.flow.Flow
1113
import kotlinx.coroutines.flow.map
1214
import java.time.Instant
@@ -32,6 +34,9 @@ class UserPreferencesRepository @Inject constructor(
3234
val TERTIARY_COLOR = intPreferencesKey("tertiary_color")
3335

3436
val HAPTIC_ENABLED = booleanPreferencesKey("haptic_enabled")
37+
val HAPTIC_INTENSITY = stringPreferencesKey("haptic_intensity")
38+
val SOUND_ENABLED = booleanPreferencesKey("sound_enabled")
39+
val SOUND_VOLUME = intPreferencesKey("sound_volume")
3540
val NINTENDO_BUTTON_LAYOUT = booleanPreferencesKey("nintendo_button_layout")
3641
val SWAP_START_SELECT = booleanPreferencesKey("swap_start_select")
3742
val ANIMATION_SPEED = stringPreferencesKey("animation_speed")
@@ -50,6 +55,7 @@ class UserPreferencesRepository @Inject constructor(
5055
val APP_ORDER = stringPreferencesKey("app_order")
5156
val MAX_CONCURRENT_DOWNLOADS = intPreferencesKey("max_concurrent_downloads")
5257
val UI_DENSITY = stringPreferencesKey("ui_density")
58+
val SOUND_CONFIGS = stringPreferencesKey("sound_configs")
5359
}
5460

5561
val userPreferences: Flow<UserPreferences> = dataStore.data.map { prefs ->
@@ -68,6 +74,9 @@ class UserPreferencesRepository @Inject constructor(
6874
secondaryColor = prefs[Keys.SECONDARY_COLOR],
6975
tertiaryColor = prefs[Keys.TERTIARY_COLOR],
7076
hapticEnabled = prefs[Keys.HAPTIC_ENABLED] ?: true,
77+
hapticIntensity = HapticIntensity.fromString(prefs[Keys.HAPTIC_INTENSITY]),
78+
soundEnabled = prefs[Keys.SOUND_ENABLED] ?: false,
79+
soundVolume = prefs[Keys.SOUND_VOLUME] ?: 40,
7180
nintendoButtonLayout = prefs[Keys.NINTENDO_BUTTON_LAYOUT] ?: false,
7281
swapStartSelect = prefs[Keys.SWAP_START_SELECT] ?: false,
7382
animationSpeed = AnimationSpeed.fromString(prefs[Keys.ANIMATION_SPEED]),
@@ -102,10 +111,39 @@ class UserPreferencesRepository @Inject constructor(
102111
?.filter { it.isNotBlank() }
103112
?: emptyList(),
104113
maxConcurrentDownloads = prefs[Keys.MAX_CONCURRENT_DOWNLOADS] ?: 1,
105-
uiDensity = UiDensity.fromString(prefs[Keys.UI_DENSITY])
114+
uiDensity = UiDensity.fromString(prefs[Keys.UI_DENSITY]),
115+
soundConfigs = parseSoundConfigs(prefs[Keys.SOUND_CONFIGS])
106116
)
107117
}
108118

119+
private fun parseSoundConfigs(raw: String?): Map<SoundType, SoundConfig> {
120+
if (raw.isNullOrBlank()) return emptyMap()
121+
return raw.split(";")
122+
.mapNotNull { entry ->
123+
val parts = entry.split("=", limit = 2)
124+
if (parts.size != 2) return@mapNotNull null
125+
val soundType = try { SoundType.valueOf(parts[0]) } catch (e: Exception) { return@mapNotNull null }
126+
val value = parts[1]
127+
val config = when {
128+
value.startsWith("custom:") -> SoundConfig(customFilePath = value.removePrefix("custom:"))
129+
else -> SoundConfig(presetName = value)
130+
}
131+
soundType to config
132+
}
133+
.toMap()
134+
}
135+
136+
private fun serializeSoundConfigs(configs: Map<SoundType, SoundConfig>): String {
137+
return configs.entries.joinToString(";") { (type, config) ->
138+
val value = when {
139+
config.customFilePath != null -> "custom:${config.customFilePath}"
140+
config.presetName != null -> config.presetName
141+
else -> return@joinToString ""
142+
}
143+
"${type.name}=$value"
144+
}
145+
}
146+
109147
val preferences: Flow<UserPreferences> = userPreferences
110148

111149
suspend fun setFirstRunComplete() {
@@ -167,6 +205,24 @@ class UserPreferencesRepository @Inject constructor(
167205
}
168206
}
169207

208+
suspend fun setHapticIntensity(intensity: HapticIntensity) {
209+
dataStore.edit { prefs ->
210+
prefs[Keys.HAPTIC_INTENSITY] = intensity.name
211+
}
212+
}
213+
214+
suspend fun setSoundEnabled(enabled: Boolean) {
215+
dataStore.edit { prefs ->
216+
prefs[Keys.SOUND_ENABLED] = enabled
217+
}
218+
}
219+
220+
suspend fun setSoundVolume(volume: Int) {
221+
dataStore.edit { prefs ->
222+
prefs[Keys.SOUND_VOLUME] = volume.coerceIn(0, 100)
223+
}
224+
}
225+
170226
suspend fun setNintendoButtonLayout(enabled: Boolean) {
171227
dataStore.edit { prefs ->
172228
prefs[Keys.NINTENDO_BUTTON_LAYOUT] = enabled
@@ -274,6 +330,32 @@ class UserPreferencesRepository @Inject constructor(
274330
prefs[Keys.UI_DENSITY] = density.name
275331
}
276332
}
333+
334+
suspend fun setSoundConfigs(configs: Map<SoundType, SoundConfig>) {
335+
dataStore.edit { prefs ->
336+
if (configs.isEmpty()) {
337+
prefs.remove(Keys.SOUND_CONFIGS)
338+
} else {
339+
prefs[Keys.SOUND_CONFIGS] = serializeSoundConfigs(configs)
340+
}
341+
}
342+
}
343+
344+
suspend fun setSoundConfig(type: SoundType, config: SoundConfig?) {
345+
dataStore.edit { prefs ->
346+
val current = parseSoundConfigs(prefs[Keys.SOUND_CONFIGS])
347+
val updated = if (config != null) {
348+
current + (type to config)
349+
} else {
350+
current - type
351+
}
352+
if (updated.isEmpty()) {
353+
prefs.remove(Keys.SOUND_CONFIGS)
354+
} else {
355+
prefs[Keys.SOUND_CONFIGS] = serializeSoundConfigs(updated)
356+
}
357+
}
358+
}
277359
}
278360

279361
data class UserPreferences(
@@ -287,6 +369,9 @@ data class UserPreferences(
287369
val secondaryColor: Int? = null,
288370
val tertiaryColor: Int? = null,
289371
val hapticEnabled: Boolean = true,
372+
val hapticIntensity: HapticIntensity = HapticIntensity.MEDIUM,
373+
val soundEnabled: Boolean = false,
374+
val soundVolume: Int = 40,
290375
val nintendoButtonLayout: Boolean = false,
291376
val swapStartSelect: Boolean = false,
292377
val animationSpeed: AnimationSpeed = AnimationSpeed.NORMAL,
@@ -297,7 +382,8 @@ data class UserPreferences(
297382
val visibleSystemApps: Set<String> = emptySet(),
298383
val appOrder: List<String> = emptyList(),
299384
val maxConcurrentDownloads: Int = 1,
300-
val uiDensity: UiDensity = UiDensity.NORMAL
385+
val uiDensity: UiDensity = UiDensity.NORMAL,
386+
val soundConfigs: Map<SoundType, SoundConfig> = emptyMap()
301387
)
302388

303389
enum class ThemeMode {
@@ -326,3 +412,14 @@ enum class UiDensity {
326412
entries.find { it.name == value } ?: NORMAL
327413
}
328414
}
415+
416+
enum class HapticIntensity(val amplitude: Int) {
417+
LOW(50),
418+
MEDIUM(128),
419+
HIGH(255);
420+
421+
companion object {
422+
fun fromString(value: String?): HapticIntensity =
423+
entries.find { it.name == value } ?: MEDIUM
424+
}
425+
}

app/src/main/kotlin/com/nendo/argosy/data/remote/romm/RomMModels.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,10 @@ data class RomMRomUser(
141141
@JsonClass(generateAdapter = true)
142142
data class RomMUserPropsUpdateData(
143143
@Json(name = "rating") val rating: Int? = null,
144-
@Json(name = "difficulty") val difficulty: Int? = null
144+
@Json(name = "difficulty") val difficulty: Int? = null,
145+
@Json(name = "completion") val completion: Int? = null,
146+
@Json(name = "backlogged") val backlogged: Boolean? = null,
147+
@Json(name = "now_playing") val nowPlaying: Boolean? = null
145148
)
146149

147150
@JsonClass(generateAdapter = true)

app/src/main/kotlin/com/nendo/argosy/data/remote/romm/RomMRepository.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,9 @@ class RomMRepository @Inject constructor(
396396
franchises = rom.metadatum?.franchises?.joinToString(","),
397397
userRating = rom.romUser?.rating ?: existing?.userRating ?: 0,
398398
userDifficulty = rom.romUser?.difficulty ?: existing?.userDifficulty ?: 0,
399+
completion = rom.romUser?.completion ?: existing?.completion ?: 0,
400+
backlogged = rom.romUser?.backlogged ?: existing?.backlogged ?: false,
401+
nowPlaying = rom.romUser?.nowPlaying ?: existing?.nowPlaying ?: false,
399402
isFavorite = existing?.isFavorite ?: false,
400403
isHidden = existing?.isHidden ?: false,
401404
playCount = existing?.playCount ?: 0,
@@ -525,8 +528,16 @@ class RomMRepository @Inject constructor(
525528
RomMResult.Error("Empty response body")
526529
}
527530
} else {
528-
Log.e(TAG, "downloadRom: failed with code=${response.code()}")
529-
RomMResult.Error("Download failed", response.code())
531+
val code = response.code()
532+
Log.e(TAG, "downloadRom: failed with code=$code")
533+
val message = when (code) {
534+
400 -> "Bad request - try resyncing (HTTP 400)"
535+
401, 403 -> "Authentication failed (HTTP $code)"
536+
404 -> "ROM not found on server - try resyncing"
537+
500, 502, 503 -> "Server error (HTTP $code)"
538+
else -> "Download failed (HTTP $code)"
539+
}
540+
RomMResult.Error(message, code)
530541
}
531542
} catch (e: Exception) {
532543
Log.e(TAG, "downloadRom: exception", e)

app/src/main/kotlin/com/nendo/argosy/di/DatabaseModule.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ object DatabaseModule {
3434
ALauncherDatabase.MIGRATION_4_5,
3535
ALauncherDatabase.MIGRATION_5_6,
3636
ALauncherDatabase.MIGRATION_6_7,
37-
ALauncherDatabase.MIGRATION_7_8
37+
ALauncherDatabase.MIGRATION_7_8,
38+
ALauncherDatabase.MIGRATION_8_9
3839
)
3940
.build()
4041
}

app/src/main/kotlin/com/nendo/argosy/domain/usecase/game/DeleteGameUseCase.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.nendo.argosy.domain.usecase.game
22

3+
import com.nendo.argosy.data.local.dao.DownloadQueueDao
34
import com.nendo.argosy.data.local.dao.GameDao
45
import com.nendo.argosy.data.repository.GameRepository
56
import kotlinx.coroutines.CoroutineScope
@@ -11,7 +12,8 @@ import javax.inject.Inject
1112

1213
class DeleteGameUseCase @Inject constructor(
1314
private val gameDao: GameDao,
14-
private val gameRepository: GameRepository
15+
private val gameRepository: GameRepository,
16+
private val downloadQueueDao: DownloadQueueDao
1517
) {
1618
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
1719

@@ -20,6 +22,7 @@ class DeleteGameUseCase @Inject constructor(
2022
val path = game.localPath ?: return false
2123

2224
gameRepository.clearLocalPath(gameId)
25+
downloadQueueDao.deleteByGameId(gameId)
2326

2427
scope.launch {
2528
try {

0 commit comments

Comments
 (0)