feat: per-user volume leveling preference, synced across devices (M464 #4998)
release / govulncheck (push) Successful in 22s
release / go (push) Successful in 1m17s
release / web (push) Failing after 1m5s
release / android (push) Failing after 1m51s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m12s
release / Build signed APK (releases and dev) (push) Canceled after 3m21s
release / govulncheck (push) Successful in 22s
release / go (push) Successful in 1m17s
release / web (push) Failing after 1m5s
release / android (push) Failing after 1m51s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m12s
release / Build signed APK (releases and dev) (push) Canceled after 3m21s
Mode (off, auto, track, album), target (-18, -16, -14 LUFS) and boost (within headroom, or fully with a limiter), stored per user on the server so the web player, the Android app and casts apply the same one. - Server: user_normalization_prefs (migration 0067), GET/PUT /api/me/normalization; a whole-body PUT, validated, last write wins. - Web: Settings > Playback > Volume leveling. Saves at once, restores the old choice if the save fails, and caches the value for the player. - Android: Settings card. The device keeps a copy for offline playback (Room v9 with an explicit migration, so the upgrade wipes nothing). Writes are offline-first: shown at once, PUT best effort, queued on failure (NORMALIZATION_SET, collapsed to the newest). A refresh never overwrites a change still queued. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.api.endpoints
|
||||
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
|
||||
import com.fabledsword.minstrel.models.wire.MyProfileWire
|
||||
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import retrofit2.http.Body
|
||||
@@ -59,6 +60,14 @@ interface MeApi {
|
||||
*/
|
||||
@PUT("api/me/listenbrainz")
|
||||
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
|
||||
|
||||
/** The caller's loudness-normalization preference, or the defaults if never set. */
|
||||
@GET("api/me/normalization")
|
||||
suspend fun getNormalization(): NormalizationPrefs
|
||||
|
||||
/** Replaces the whole preference; returns what the server stored. */
|
||||
@PUT("api/me/normalization")
|
||||
suspend fun putNormalization(@Body body: NormalizationPrefs): NormalizationPrefs
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.fabledsword.minstrel.cache.audiocache.CacheSettings
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
@@ -77,6 +78,9 @@ class AuthStore @Inject constructor(
|
||||
private val diagnosticsOptOutState = MutableStateFlow(false)
|
||||
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
||||
|
||||
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
|
||||
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// Serialises every cookie persist with the one-time hydration, so a
|
||||
@@ -99,6 +103,7 @@ class AuthStore @Inject constructor(
|
||||
clientIdState.value = row?.clientId
|
||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||
normalizationState.value = decodeNormalization(row?.normalizationJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +115,13 @@ class AuthStore @Inject constructor(
|
||||
}.getOrDefault(CacheSettings.DEFAULT)
|
||||
}
|
||||
|
||||
private fun decodeNormalization(raw: String?): NormalizationPrefs {
|
||||
if (raw.isNullOrEmpty()) return NormalizationPrefs.DEFAULT
|
||||
return runCatching {
|
||||
json.decodeFromString(NormalizationPrefs.serializer(), raw)
|
||||
}.getOrDefault(NormalizationPrefs.DEFAULT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends until the stored session cookie has been loaded into
|
||||
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
|
||||
@@ -187,6 +199,12 @@ class AuthStore @Inject constructor(
|
||||
scope.launch { persistDiagnosticsOptOut(value) }
|
||||
}
|
||||
|
||||
fun setNormalization(value: NormalizationPrefs) {
|
||||
normalizationState.value = value
|
||||
val encoded = json.encodeToString(NormalizationPrefs.serializer(), value)
|
||||
scope.launch { persistNormalization(encoded) }
|
||||
}
|
||||
|
||||
private suspend fun persistLegacyCookie(value: String?) {
|
||||
if (dao.get() == null) {
|
||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||
@@ -243,6 +261,14 @@ class AuthStore @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistNormalization(json: String) {
|
||||
if (dao.get() == null) {
|
||||
dao.upsert(currentEntity().copy(normalizationJson = json))
|
||||
} else {
|
||||
dao.setNormalizationJson(json)
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||
id = ROW_ID,
|
||||
// Never copied into the row: the cookie lives in the vault, and
|
||||
@@ -257,6 +283,10 @@ class AuthStore @Inject constructor(
|
||||
cacheSettingsState.value,
|
||||
),
|
||||
diagnosticsOptOut = diagnosticsOptOutState.value,
|
||||
normalizationJson = json.encodeToString(
|
||||
NormalizationPrefs.serializer(),
|
||||
normalizationState.value,
|
||||
),
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
+15
-1
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.cache.db
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
@@ -65,13 +67,18 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||
AuthSessionEntity::class,
|
||||
DiagnosticEventEntity::class,
|
||||
],
|
||||
// v9: + auth_session.normalizationJson, the loudness-normalization
|
||||
// preference (M464 #4998). The first schema step with an explicit
|
||||
// Migration (MIGRATION_8_9): a destructive rebuild would also wipe this
|
||||
// row — the server address and theme — and the queued offline writes,
|
||||
// which is too much to lose for one added column.
|
||||
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
|
||||
// so cache-first surfaces stop offering files that cannot stream.
|
||||
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
||||
// which is exactly right here: the next sync refills every row with the
|
||||
// new column populated, so there is nothing to migrate by hand.
|
||||
version = 8,
|
||||
version = 9,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(MinstrelTypeConverters::class)
|
||||
@@ -92,3 +99,10 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun authSessionDao(): AuthSessionDao
|
||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||
}
|
||||
|
||||
/** v8 → v9: add the nullable normalization preference column (#4998). */
|
||||
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ object DatabaseModule {
|
||||
// launch, so users lose only the unsynced mutation queue
|
||||
// (acceptable while we're iterating). Replace with explicit
|
||||
// Migration entries before the first tagged release.
|
||||
.addMigrations(MIGRATION_8_9)
|
||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||
.build()
|
||||
|
||||
|
||||
+4
@@ -46,4 +46,8 @@ interface AuthSessionDao {
|
||||
/** Partial update: change only the per-device diagnostics opt-out. */
|
||||
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
|
||||
suspend fun setDiagnosticsOptOut(optOut: Boolean)
|
||||
|
||||
/** Partial update: change only the serialized normalization preference. */
|
||||
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
|
||||
suspend fun setNormalizationJson(json: String?)
|
||||
}
|
||||
|
||||
+4
@@ -31,4 +31,8 @@ interface CachedMutationDao {
|
||||
|
||||
@Query("DELETE FROM cached_mutations")
|
||||
suspend fun clear()
|
||||
|
||||
/** Whether a write of [kind] is still waiting to be replayed. */
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM cached_mutations WHERE kind = :kind)")
|
||||
suspend fun hasPending(kind: String): Boolean
|
||||
}
|
||||
|
||||
+6
@@ -43,4 +43,10 @@ data class AuthSessionEntity(
|
||||
* choice lives here. Default false = honor the account flag.
|
||||
*/
|
||||
val diagnosticsOptOut: Boolean = false,
|
||||
/**
|
||||
* JSON-encoded NormalizationPrefs (settings/data), the last value seen
|
||||
* from the server or set here (M464 #4998). Null = never fetched; the
|
||||
* defaults apply. Kept so offline playback still levels.
|
||||
*/
|
||||
val normalizationJson: String? = null,
|
||||
)
|
||||
|
||||
+12
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
@@ -41,6 +42,11 @@ object MutationKind {
|
||||
// an undo collapses to the latest intent instead of replaying as two
|
||||
// opposed calls whose order decides the outcome.
|
||||
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
||||
|
||||
// M464 #4998 loudness-normalization preference. The payload is the whole
|
||||
// preference, a target state like the toggles above, so queued changes
|
||||
// collapse to the last one and an older one can never be replayed last.
|
||||
const val NORMALIZATION_SET: String = "normalization_set"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,6 +183,12 @@ class MutationQueue @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
/** Queues the user's whole normalization preference for replay. */
|
||||
suspend fun enqueueNormalizationSet(prefs: NormalizationPrefs): Long = insertUserDriven(
|
||||
MutationKind.NORMALIZATION_SET,
|
||||
json.encodeToString(NormalizationPrefs.serializer(), prefs),
|
||||
)
|
||||
|
||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||
MutationKind.REQUEST_CANCEL,
|
||||
json.encodeToString(
|
||||
|
||||
+21
-1
@@ -7,6 +7,7 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
|
||||
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
||||
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||
@@ -22,6 +23,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -79,6 +81,7 @@ class MutationReplayer @Inject constructor(
|
||||
private val eventsApi: EventsApi = retrofit.create()
|
||||
private val requestsApi: RequestsApi = retrofit.create()
|
||||
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
||||
private val meApi: MeApi = retrofit.create()
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
@@ -166,6 +169,7 @@ class MutationReplayer @Inject constructor(
|
||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
|
||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||
else -> Outcome.DROP
|
||||
}
|
||||
@@ -279,6 +283,16 @@ class MutationReplayer @Inject constructor(
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the queued normalization preference. The device already shows
|
||||
* it, so the server's echo is not written back: a change made since the
|
||||
* row was queued would be a newer row, and the collapse keeps only that.
|
||||
*/
|
||||
private suspend fun dispatchNormalizationSet(payload: String): Outcome {
|
||||
meApi.putNormalization(json.decodeFromString(NormalizationPrefs.serializer(), payload))
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||
playbackErrorsApi.report(
|
||||
@@ -303,7 +317,8 @@ class MutationReplayer @Inject constructor(
|
||||
/**
|
||||
* Row ids of desired-state toggles superseded by a later toggle for the same
|
||||
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
||||
* than an action — like-toggles and suggestion snoozes (#2374) — because
|
||||
* than an action — like-toggles, suggestion snoozes (#2374) and the
|
||||
* normalization preference (#4998) — because
|
||||
* replaying a stale one last would invert the final state.
|
||||
*
|
||||
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
||||
@@ -340,5 +355,10 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
|
||||
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||
|
||||
// One preference per user, so every normalization row shares one key.
|
||||
MutationKind.NORMALIZATION_SET -> runCatching {
|
||||
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
|
||||
}.getOrNull()?.let { row.kind }
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.fabledsword.minstrel.settings.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The user's loudness-normalization preference (M464 #4998). The server
|
||||
* holds it, so this device, the web player and casts all level the same
|
||||
* way; a copy is kept on the device so offline playback still applies it.
|
||||
* Field names and values match `GET/PUT /api/me/normalization`.
|
||||
*
|
||||
* No constructor defaults, on purpose: the app's Json leaves out any field
|
||||
* equal to its default (encodeDefaults is off), and the server refuses a PUT
|
||||
* missing a field. Without defaults every field is always sent.
|
||||
*/
|
||||
@Serializable
|
||||
data class NormalizationPrefs(
|
||||
val mode: NormalizationMode,
|
||||
@SerialName("target_lufs") val targetLufs: Int,
|
||||
val boost: NormalizationBoost,
|
||||
) {
|
||||
companion object {
|
||||
/** The targets the server accepts, quietest first. */
|
||||
val TARGETS: List<Int> = listOf(-18, -16, -14)
|
||||
|
||||
/** Must match library.DefaultNormalizationPrefs on the server. */
|
||||
val DEFAULT: NormalizationPrefs = NormalizationPrefs(
|
||||
mode = NormalizationMode.AUTO,
|
||||
targetLufs = -18,
|
||||
boost = NormalizationBoost.HEADROOM,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class NormalizationMode {
|
||||
/** Tracks play at their mastered volume. */
|
||||
@SerialName("off") OFF,
|
||||
|
||||
/** Album gain while an album plays in order, track gain otherwise. */
|
||||
@SerialName("auto") AUTO,
|
||||
|
||||
@SerialName("track") TRACK,
|
||||
|
||||
@SerialName("album") ALBUM,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class NormalizationBoost {
|
||||
/** Quiet tracks are raised only as far as their true peak allows. */
|
||||
@SerialName("headroom") HEADROOM,
|
||||
|
||||
/** Quiet tracks are raised all the way and a limiter holds the peaks. */
|
||||
@SerialName("limiter") LIMITER,
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.fabledsword.minstrel.settings.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationKind
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The user's loudness-normalization preference (M464 #4998). The device's
|
||||
* copy lives in [AuthStore] so playback reads it offline; the server's copy
|
||||
* is what other devices and casts use.
|
||||
*
|
||||
* Writes follow rule 100: the device shows the change at once, the PUT is
|
||||
* best effort, and a failed PUT is queued for the MutationReplayer.
|
||||
*/
|
||||
@Singleton
|
||||
class NormalizationRepository @Inject constructor(
|
||||
retrofit: Retrofit,
|
||||
private val authStore: AuthStore,
|
||||
private val mutationQueue: MutationQueue,
|
||||
private val mutationDao: CachedMutationDao,
|
||||
) {
|
||||
private val api: MeApi = retrofit.create()
|
||||
|
||||
val prefs: StateFlow<NormalizationPrefs> = authStore.normalization
|
||||
|
||||
/**
|
||||
* Takes the server's value, unless a change made here is still queued:
|
||||
* the server has not seen it yet, and taking its older value would undo
|
||||
* the change on screen until the replay landed. Throws on a network
|
||||
* failure; the device's copy then stands.
|
||||
*/
|
||||
suspend fun refresh() {
|
||||
val server = api.getNormalization()
|
||||
if (mutationDao.hasPending(MutationKind.NORMALIZATION_SET)) return
|
||||
authStore.setNormalization(server)
|
||||
}
|
||||
|
||||
suspend fun set(next: NormalizationPrefs) {
|
||||
authStore.setNormalization(next)
|
||||
// An earlier change still queued would be replayed after this PUT
|
||||
// and undo it. Queue behind it instead: the replayer sends only the
|
||||
// newest queued preference.
|
||||
if (mutationDao.hasPending(MutationKind.NORMALIZATION_SET)) {
|
||||
mutationQueue.enqueueNormalizationSet(next)
|
||||
return
|
||||
}
|
||||
try {
|
||||
api.putNormalization(next)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.i(e, "normalization: PUT failed; queued for replay")
|
||||
mutationQueue.enqueueNormalizationSet(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.fabledsword.minstrel.settings.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
|
||||
/**
|
||||
* Volume leveling (M464 #4998). The choice is stored on the server, so the
|
||||
* web player and casts follow it too.
|
||||
*/
|
||||
@Composable
|
||||
fun NormalizationCard(viewModel: NormalizationViewModel = hiltViewModel()) {
|
||||
val prefs by viewModel.prefs.collectAsStateWithLifecycle()
|
||||
NormalizationCardContent(prefs = prefs, onChange = viewModel::update)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun NormalizationCardContent(
|
||||
prefs: NormalizationPrefs,
|
||||
onChange: (NormalizationPrefs) -> Unit,
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Volume leveling",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Hint(modeHint(prefs.mode))
|
||||
val modes = NormalizationMode.entries
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
modes.forEachIndexed { index, mode ->
|
||||
SegmentedButton(
|
||||
selected = mode == prefs.mode,
|
||||
onClick = { onChange(prefs.copy(mode = mode)) },
|
||||
shape = SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = modes.size,
|
||||
),
|
||||
) { Text(modeLabel(mode)) }
|
||||
}
|
||||
}
|
||||
if (prefs.mode != NormalizationMode.OFF) {
|
||||
TargetRow(prefs = prefs, onChange = onChange)
|
||||
BoostRow(prefs = prefs, onChange = onChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TargetRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
|
||||
Text(
|
||||
text = "Target loudness",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
val targets = NormalizationPrefs.TARGETS
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
targets.forEachIndexed { index, lufs ->
|
||||
SegmentedButton(
|
||||
selected = lufs == prefs.targetLufs,
|
||||
onClick = { onChange(prefs.copy(targetLufs = lufs)) },
|
||||
shape = SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = targets.size,
|
||||
),
|
||||
) { Text("$lufs LUFS") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoostRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Boost quiet tracks fully",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Hint("A limiter catches the peaks.")
|
||||
}
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Switch(
|
||||
checked = prefs.boost == NormalizationBoost.LIMITER,
|
||||
onCheckedChange = { on ->
|
||||
val boost = if (on) NormalizationBoost.LIMITER else NormalizationBoost.HEADROOM
|
||||
onChange(prefs.copy(boost = boost))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Hint(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
private fun modeLabel(mode: NormalizationMode): String = when (mode) {
|
||||
NormalizationMode.OFF -> "Off"
|
||||
NormalizationMode.AUTO -> "Auto"
|
||||
NormalizationMode.TRACK -> "Track"
|
||||
NormalizationMode.ALBUM -> "Album"
|
||||
}
|
||||
|
||||
private fun modeHint(mode: NormalizationMode): String = when (mode) {
|
||||
NormalizationMode.OFF -> "Tracks play at their mastered volume."
|
||||
NormalizationMode.AUTO -> "Album gain for whole albums, track gain otherwise."
|
||||
NormalizationMode.TRACK -> "Every track at the same loudness."
|
||||
NormalizationMode.ALBUM -> "Albums keep their own quiet and loud tracks."
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.fabledsword.minstrel.settings.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Backs the Volume leveling card. Shows the device's copy straight away and
|
||||
* refreshes it from the server on open, so a change made on the web shows
|
||||
* here too.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class NormalizationViewModel @Inject constructor(
|
||||
private val repository: NormalizationRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val prefs: StateFlow<NormalizationPrefs> = repository.prefs
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.refresh()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
// Offline: the device's copy stands.
|
||||
Timber.i(e, "normalization: refresh failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun update(next: NormalizationPrefs) {
|
||||
viewModelScope.launch { repository.set(next) }
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,7 @@ private fun SettingsList(
|
||||
onToggle = onToggleDiagnostics,
|
||||
)
|
||||
}
|
||||
NormalizationCard()
|
||||
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
||||
StorageCard()
|
||||
AboutCard()
|
||||
|
||||
Vendored
+24
@@ -1,6 +1,8 @@
|
||||
package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -129,4 +131,26 @@ class SupersededToggleIdsTest {
|
||||
fun `an empty queue collapses nothing`() {
|
||||
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
|
||||
}
|
||||
|
||||
private fun normalizationRow(id: Long, mode: NormalizationMode) = CachedMutationEntity(
|
||||
id = id,
|
||||
kind = MutationKind.NORMALIZATION_SET,
|
||||
payload = json.encodeToString(
|
||||
NormalizationPrefs.serializer(),
|
||||
NormalizationPrefs.DEFAULT.copy(mode = mode),
|
||||
),
|
||||
)
|
||||
|
||||
// There is one preference per user, so any two queued changes to it
|
||||
// collapse, and only the newest is sent (#4998).
|
||||
@Test
|
||||
fun `only the newest queued normalization change survives`() {
|
||||
val rows = listOf(
|
||||
normalizationRow(1, NormalizationMode.TRACK),
|
||||
snoozeRow(2, "mb-a", desiredSnoozed = true),
|
||||
normalizationRow(3, NormalizationMode.OFF),
|
||||
normalizationRow(4, NormalizationMode.ALBUM),
|
||||
)
|
||||
assertEquals(setOf(1L, 3L), supersededToggleIds(rows, json))
|
||||
}
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.fabledsword.minstrel.settings.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationKind
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import retrofit2.Retrofit
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* The normalization preference is written offline-first (rule 100): the
|
||||
* device shows the change at once, and a change the server has not seen
|
||||
* yet is never overwritten or reordered.
|
||||
*/
|
||||
class NormalizationRepositoryTest {
|
||||
private val api: MeApi = mockk()
|
||||
private val authStore: AuthStore = mockk(relaxed = true) {
|
||||
every { normalization } returns MutableStateFlow(NormalizationPrefs.DEFAULT)
|
||||
}
|
||||
private val queue: MutationQueue = mockk(relaxed = true)
|
||||
private val dao: CachedMutationDao = mockk()
|
||||
private val retrofit: Retrofit = mockk {
|
||||
every { create(MeApi::class.java) } returns api
|
||||
}
|
||||
private val repo = NormalizationRepository(retrofit, authStore, queue, dao)
|
||||
|
||||
private val album = NormalizationPrefs.DEFAULT.copy(mode = NormalizationMode.ALBUM)
|
||||
|
||||
@Test
|
||||
fun `a change is shown at once and sent`() = runTest {
|
||||
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
|
||||
coEvery { api.putNormalization(album) } returns album
|
||||
|
||||
repo.set(album)
|
||||
|
||||
verify { authStore.setNormalization(album) }
|
||||
coVerify { api.putNormalization(album) }
|
||||
coVerify(exactly = 0) { queue.enqueueNormalizationSet(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a change that cannot reach the server is queued`() = runTest {
|
||||
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
|
||||
coEvery { api.putNormalization(album) } throws IOException("offline")
|
||||
|
||||
repo.set(album)
|
||||
|
||||
verify { authStore.setNormalization(album) }
|
||||
coVerify { queue.enqueueNormalizationSet(album) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a change behind a queued one queues too, so the older one cannot land last`() = runTest {
|
||||
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns true
|
||||
|
||||
repo.set(album)
|
||||
|
||||
coVerify(exactly = 0) { api.putNormalization(any()) }
|
||||
coVerify { queue.enqueueNormalizationSet(album) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh takes the server value`() = runTest {
|
||||
coEvery { api.getNormalization() } returns album
|
||||
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
|
||||
|
||||
repo.refresh()
|
||||
|
||||
verify { authStore.setNormalization(album) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh leaves a queued change on screen`() = runTest {
|
||||
coEvery { api.getNormalization() } returns NormalizationPrefs.DEFAULT
|
||||
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns true
|
||||
|
||||
repo.refresh()
|
||||
|
||||
verify(exactly = 0) { authStore.setNormalization(any()) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user