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()) }
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Put("/me/password", h.handleChangePassword)
|
||||
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
||||
authed.Put("/me/timezone", h.handlePutTimezone)
|
||||
authed.Get("/me/normalization", h.handleGetMyNormalization)
|
||||
authed.Put("/me/normalization", h.handlePutMyNormalization)
|
||||
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
||||
authed.Get("/me/subsonic-password", h.handleGetMySubsonicPassword)
|
||||
authed.Post("/me/subsonic-password", h.handleGenerateMySubsonicPassword)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
// GET /api/me/normalization returns the caller's loudness-normalization
|
||||
// preference, or the defaults when they have never set one (M464 #4998).
|
||||
func (h *handlers) handleGetMyNormalization(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, err := library.LoadNormalizationPrefs(r.Context(), dbq.New(h.pool), caller.ID)
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "normalization: load", apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
// PUT /api/me/normalization replaces the caller's preference with the whole
|
||||
// body. Every field is required, so a client replaying a queued change
|
||||
// offline sends the full state it showed the user, and the last write wins.
|
||||
func (h *handlers) handlePutMyNormalization(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body library.NormalizationPrefs
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body"))
|
||||
return
|
||||
}
|
||||
p, err := library.SaveNormalizationPrefs(r.Context(), dbq.New(h.pool), caller.ID, body)
|
||||
if errors.Is(err, library.ErrNormalizationPrefsInvalid) {
|
||||
writeErr(w, apierror.BadRequest("invalid_normalization", err.Error()))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "normalization: save", apierror.InternalMsg("save failed", err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
func TestMyNormalization_GetPut(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "norm1", "pw", false)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/me/normalization", h.handleGetMyNormalization)
|
||||
r.Put("/api/me/normalization", h.handlePutMyNormalization)
|
||||
do := func(method, body string) (int, library.NormalizationPrefs) {
|
||||
t.Helper()
|
||||
req := withUser(httptest.NewRequest(method, "/api/me/normalization", bytes.NewBufferString(body)), user)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
var p library.NormalizationPrefs
|
||||
if rec.Code == http.StatusOK {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
||||
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
}
|
||||
return rec.Code, p
|
||||
}
|
||||
|
||||
if code, p := do(http.MethodGet, ""); code != http.StatusOK || p != library.DefaultNormalizationPrefs {
|
||||
t.Fatalf("GET before any save = %d %+v, want 200 with the defaults", code, p)
|
||||
}
|
||||
|
||||
want := library.NormalizationPrefs{Mode: "album", TargetLUFS: -16, Boost: "limiter"}
|
||||
if code, p := do(http.MethodPut, `{"mode":"album","target_lufs":-16,"boost":"limiter"}`); code != http.StatusOK || p != want {
|
||||
t.Fatalf("PUT = %d %+v, want 200 %+v", code, p, want)
|
||||
}
|
||||
if code, p := do(http.MethodGet, ""); code != http.StatusOK || p != want {
|
||||
t.Errorf("GET after PUT = %d %+v, want %+v", code, p, want)
|
||||
}
|
||||
|
||||
// Bad values and partial bodies are refused, and the stored prefs stand.
|
||||
for _, body := range []string{
|
||||
`{"mode":"loud","target_lufs":-16,"boost":"limiter"}`,
|
||||
`{"mode":"album","target_lufs":-23,"boost":"limiter"}`,
|
||||
`{"mode":"album","target_lufs":-16}`,
|
||||
`not json`,
|
||||
} {
|
||||
if code, _ := do(http.MethodPut, body); code != http.StatusBadRequest {
|
||||
t.Errorf("PUT %s = %d, want 400", body, code)
|
||||
}
|
||||
}
|
||||
if _, p := do(http.MethodGet, ""); p != want {
|
||||
t.Errorf("a refused PUT changed the prefs to %+v", p)
|
||||
}
|
||||
}
|
||||
@@ -784,6 +784,14 @@ type UserInvite struct {
|
||||
RedeemedBy pgtype.UUID
|
||||
}
|
||||
|
||||
type UserNormalizationPref struct {
|
||||
UserID pgtype.UUID
|
||||
Mode string
|
||||
TargetLufs int16
|
||||
Boost string
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type YouMightLikeAlbum struct {
|
||||
UserID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: normalization_prefs.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getNormalizationPrefs = `-- name: GetNormalizationPrefs :one
|
||||
SELECT mode, target_lufs, boost, updated_at
|
||||
FROM user_normalization_prefs
|
||||
WHERE user_id = $1
|
||||
`
|
||||
|
||||
type GetNormalizationPrefsRow struct {
|
||||
Mode string
|
||||
TargetLufs int16
|
||||
Boost string
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
func (q *Queries) GetNormalizationPrefs(ctx context.Context, userID pgtype.UUID) (GetNormalizationPrefsRow, error) {
|
||||
row := q.db.QueryRow(ctx, getNormalizationPrefs, userID)
|
||||
var i GetNormalizationPrefsRow
|
||||
err := row.Scan(
|
||||
&i.Mode,
|
||||
&i.TargetLufs,
|
||||
&i.Boost,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertNormalizationPrefs = `-- name: UpsertNormalizationPrefs :one
|
||||
INSERT INTO user_normalization_prefs (user_id, mode, target_lufs, boost)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
mode = EXCLUDED.mode,
|
||||
target_lufs = EXCLUDED.target_lufs,
|
||||
boost = EXCLUDED.boost,
|
||||
updated_at = now()
|
||||
RETURNING mode, target_lufs, boost, updated_at
|
||||
`
|
||||
|
||||
type UpsertNormalizationPrefsParams struct {
|
||||
UserID pgtype.UUID
|
||||
Mode string
|
||||
TargetLufs int16
|
||||
Boost string
|
||||
}
|
||||
|
||||
type UpsertNormalizationPrefsRow struct {
|
||||
Mode string
|
||||
TargetLufs int16
|
||||
Boost string
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// Whole-row write from either client; the CHECKs in migration 0067 back the
|
||||
// handler's own validation.
|
||||
func (q *Queries) UpsertNormalizationPrefs(ctx context.Context, arg UpsertNormalizationPrefsParams) (UpsertNormalizationPrefsRow, error) {
|
||||
row := q.db.QueryRow(ctx, upsertNormalizationPrefs,
|
||||
arg.UserID,
|
||||
arg.Mode,
|
||||
arg.TargetLufs,
|
||||
arg.Boost,
|
||||
)
|
||||
var i UpsertNormalizationPrefsRow
|
||||
err := row.Scan(
|
||||
&i.Mode,
|
||||
&i.TargetLufs,
|
||||
&i.Boost,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS user_normalization_prefs;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Each user's loudness-normalization preference (M464 #4998). It is kept on
|
||||
-- the server, not per device, so the web player, the Android app and the
|
||||
-- leveled stream for UPnP/Sonos all apply the same one.
|
||||
--
|
||||
-- mode: off, auto (album gain while an album plays in order, otherwise
|
||||
-- track gain), track or album.
|
||||
-- target: the loudness tracks are leveled to, in LUFS.
|
||||
-- boost: headroom raises quiet tracks only as far as their true peak
|
||||
-- allows; limiter raises them all the way and limits the peaks.
|
||||
--
|
||||
-- No row means the defaults, which are the column defaults here and
|
||||
-- library.DefaultNormalizationPrefs in Go (a test holds them equal).
|
||||
CREATE TABLE user_normalization_prefs (
|
||||
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
mode text NOT NULL DEFAULT 'auto'
|
||||
CHECK (mode IN ('off', 'auto', 'track', 'album')),
|
||||
target_lufs smallint NOT NULL DEFAULT -18
|
||||
CHECK (target_lufs IN (-18, -16, -14)),
|
||||
boost text NOT NULL DEFAULT 'headroom'
|
||||
CHECK (boost IN ('headroom', 'limiter')),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- name: GetNormalizationPrefs :one
|
||||
SELECT mode, target_lufs, boost, updated_at
|
||||
FROM user_normalization_prefs
|
||||
WHERE user_id = $1;
|
||||
|
||||
-- name: UpsertNormalizationPrefs :one
|
||||
-- Whole-row write from either client; the CHECKs in migration 0067 back the
|
||||
-- handler's own validation.
|
||||
INSERT INTO user_normalization_prefs (user_id, mode, target_lufs, boost)
|
||||
VALUES (sqlc.arg(user_id), sqlc.arg(mode), sqlc.arg(target_lufs), sqlc.arg(boost))
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
mode = EXCLUDED.mode,
|
||||
target_lufs = EXCLUDED.target_lufs,
|
||||
boost = EXCLUDED.boost,
|
||||
updated_at = now()
|
||||
RETURNING mode, target_lufs, boost, updated_at;
|
||||
@@ -90,9 +90,10 @@ var dataTables = []string{
|
||||
"duplicate_group_members", // M400
|
||||
"duplicate_groups",
|
||||
"duplicate_sweeps",
|
||||
"track_fingerprints", // M400
|
||||
"track_loudness", // M464
|
||||
"album_loudness", // M464
|
||||
"track_fingerprints", // M400
|
||||
"track_loudness", // M464
|
||||
"album_loudness", // M464
|
||||
"user_normalization_prefs", // M464
|
||||
"tracks",
|
||||
"albums",
|
||||
"artists",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// NormalizationPrefs is one user's loudness-normalization preference (M464
|
||||
// #4998). It lives on the server so every player applies the same one: the
|
||||
// web player and the Android app read it, and the leveled stream for
|
||||
// UPnP/Sonos (#5001) renders with it.
|
||||
type NormalizationPrefs struct {
|
||||
// Mode is off, auto, track or album. Auto uses album gain while an album
|
||||
// plays in order and track gain otherwise.
|
||||
Mode string `json:"mode"`
|
||||
// TargetLUFS is the loudness tracks are leveled to.
|
||||
TargetLUFS int16 `json:"target_lufs"`
|
||||
// Boost is how a track quieter than the target is raised: headroom stops
|
||||
// at its true peak, limiter goes all the way and limits the peaks.
|
||||
Boost string `json:"boost"`
|
||||
}
|
||||
|
||||
// The accepted values. Migration 0067's CHECKs hold the same lists.
|
||||
var (
|
||||
NormalizationModes = []string{"off", "auto", "track", "album"}
|
||||
NormalizationTargets = []int16{-18, -16, -14}
|
||||
NormalizationBoosts = []string{"headroom", "limiter"}
|
||||
)
|
||||
|
||||
// DefaultNormalizationPrefs applies to a user who has never chosen. It must
|
||||
// equal migration 0067's column defaults; a test holds them together.
|
||||
var DefaultNormalizationPrefs = NormalizationPrefs{Mode: "auto", TargetLUFS: -18, Boost: "headroom"}
|
||||
|
||||
// ErrNormalizationPrefsInvalid is returned for a value outside the lists.
|
||||
var ErrNormalizationPrefsInvalid = errors.New("normalization preference out of range")
|
||||
|
||||
// Validate reports the first field holding a value the server does not
|
||||
// accept.
|
||||
func (p NormalizationPrefs) Validate() error {
|
||||
switch {
|
||||
case !slices.Contains(NormalizationModes, p.Mode):
|
||||
return fmt.Errorf("%w: mode %q", ErrNormalizationPrefsInvalid, p.Mode)
|
||||
case !slices.Contains(NormalizationTargets, p.TargetLUFS):
|
||||
return fmt.Errorf("%w: target_lufs %d", ErrNormalizationPrefsInvalid, p.TargetLUFS)
|
||||
case !slices.Contains(NormalizationBoosts, p.Boost):
|
||||
return fmt.Errorf("%w: boost %q", ErrNormalizationPrefsInvalid, p.Boost)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadNormalizationPrefs returns the user's preference, or the defaults if
|
||||
// they have never set one.
|
||||
func LoadNormalizationPrefs(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (NormalizationPrefs, error) {
|
||||
row, err := q.GetNormalizationPrefs(ctx, userID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DefaultNormalizationPrefs, nil
|
||||
}
|
||||
if err != nil {
|
||||
return DefaultNormalizationPrefs, err
|
||||
}
|
||||
return NormalizationPrefs{Mode: row.Mode, TargetLUFS: row.TargetLufs, Boost: row.Boost}, nil
|
||||
}
|
||||
|
||||
// SaveNormalizationPrefs validates and stores the user's preference,
|
||||
// returning what was stored.
|
||||
func SaveNormalizationPrefs(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, p NormalizationPrefs) (NormalizationPrefs, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return p, err
|
||||
}
|
||||
row, err := q.UpsertNormalizationPrefs(ctx, dbq.UpsertNormalizationPrefsParams{
|
||||
UserID: userID, Mode: p.Mode, TargetLufs: p.TargetLUFS, Boost: p.Boost,
|
||||
})
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
return NormalizationPrefs{Mode: row.Mode, TargetLUFS: row.TargetLufs, Boost: row.Boost}, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||
)
|
||||
|
||||
func TestNormalizationPrefs_Validate(t *testing.T) {
|
||||
if err := DefaultNormalizationPrefs.Validate(); err != nil {
|
||||
t.Fatalf("the defaults are invalid: %v", err)
|
||||
}
|
||||
for _, p := range []NormalizationPrefs{
|
||||
{Mode: "loud", TargetLUFS: -18, Boost: "headroom"},
|
||||
{Mode: "auto", TargetLUFS: -23, Boost: "headroom"},
|
||||
{Mode: "auto", TargetLUFS: -18, Boost: "max"},
|
||||
// A field left out of a PUT body decodes as its zero value.
|
||||
{Mode: "auto", Boost: "headroom"},
|
||||
} {
|
||||
if err := p.Validate(); !errors.Is(err, ErrNormalizationPrefsInvalid) {
|
||||
t.Errorf("%+v: err = %v, want ErrNormalizationPrefsInvalid", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A user with no row gets the Go defaults, and a stored row reads back the
|
||||
// migration's defaults for any column the insert left out; the two must
|
||||
// agree, or a user would hear a different level after their first save.
|
||||
func TestNormalizationPrefs_DefaultsAndRoundTrip_Integration(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
ctx := context.Background()
|
||||
q := dbq.New(pool)
|
||||
user, err := q.CreateUser(ctx, dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + "norm-prefs", PasswordHash: "x", ApiTokenHash: "norm-prefs-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("user: %v", err)
|
||||
}
|
||||
|
||||
got, err := LoadNormalizationPrefs(ctx, q, user.ID)
|
||||
if err != nil || got != DefaultNormalizationPrefs {
|
||||
t.Fatalf("unset prefs = %+v, %v; want the defaults", got, err)
|
||||
}
|
||||
|
||||
if _, err := pool.Exec(ctx, "INSERT INTO user_normalization_prefs (user_id) VALUES ($1)", user.ID); err != nil {
|
||||
t.Fatalf("insert defaults row: %v", err)
|
||||
}
|
||||
got, err = LoadNormalizationPrefs(ctx, q, user.ID)
|
||||
if err != nil || got != DefaultNormalizationPrefs {
|
||||
t.Errorf("migration defaults = %+v, Go defaults = %+v (err %v)", got, DefaultNormalizationPrefs, err)
|
||||
}
|
||||
|
||||
want := NormalizationPrefs{Mode: "track", TargetLUFS: -14, Boost: "limiter"}
|
||||
if saved, err := SaveNormalizationPrefs(ctx, q, user.ID, want); err != nil || saved != want {
|
||||
t.Fatalf("save = %+v, %v; want %+v", saved, err, want)
|
||||
}
|
||||
if got, _ := LoadNormalizationPrefs(ctx, q, user.ID); got != want {
|
||||
t.Errorf("after save = %+v, want %+v", got, want)
|
||||
}
|
||||
if _, err := SaveNormalizationPrefs(ctx, q, user.ID, NormalizationPrefs{Mode: "x"}); !errors.Is(err, ErrNormalizationPrefsInvalid) {
|
||||
t.Errorf("invalid save: err = %v", err)
|
||||
}
|
||||
if got, _ := LoadNormalizationPrefs(ctx, q, user.ID); got != want {
|
||||
t.Errorf("an invalid save changed the stored prefs to %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { api } from './client';
|
||||
|
||||
// Loudness normalization preference (M464 #4998). One per user, stored on
|
||||
// the server so the web player, the Android app and casts agree.
|
||||
export type NormalizationMode = 'off' | 'auto' | 'track' | 'album';
|
||||
export type NormalizationTarget = -18 | -16 | -14;
|
||||
export type NormalizationBoost = 'headroom' | 'limiter';
|
||||
|
||||
export type NormalizationPrefs = {
|
||||
mode: NormalizationMode;
|
||||
target_lufs: NormalizationTarget;
|
||||
boost: NormalizationBoost;
|
||||
};
|
||||
|
||||
// Must match library.DefaultNormalizationPrefs on the server.
|
||||
export const DEFAULT_NORMALIZATION: NormalizationPrefs = {
|
||||
mode: 'auto',
|
||||
target_lufs: -18,
|
||||
boost: 'headroom'
|
||||
};
|
||||
|
||||
export const NORMALIZATION_MODES: NormalizationMode[] = ['off', 'auto', 'track', 'album'];
|
||||
export const NORMALIZATION_TARGETS: NormalizationTarget[] = [-18, -16, -14];
|
||||
|
||||
export function isNormalizationPrefs(v: unknown): v is NormalizationPrefs {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const p = v as Record<string, unknown>;
|
||||
return (
|
||||
NORMALIZATION_MODES.includes(p.mode as NormalizationMode) &&
|
||||
NORMALIZATION_TARGETS.includes(p.target_lufs as NormalizationTarget) &&
|
||||
(p.boost === 'headroom' || p.boost === 'limiter')
|
||||
);
|
||||
}
|
||||
|
||||
export function getNormalization(): Promise<NormalizationPrefs> {
|
||||
return api.get<NormalizationPrefs>('/api/me/normalization');
|
||||
}
|
||||
|
||||
// The whole preference is sent every time; the server keeps the last write.
|
||||
export function putNormalization(p: NormalizationPrefs): Promise<NormalizationPrefs> {
|
||||
return api.put<NormalizationPrefs>('/api/me/normalization', p);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
NORMALIZATION_MODES,
|
||||
NORMALIZATION_TARGETS,
|
||||
type NormalizationPrefs
|
||||
} from '$lib/api/normalization';
|
||||
import {
|
||||
normalization,
|
||||
loadNormalization,
|
||||
setNormalization
|
||||
} from '$lib/stores/normalization.svelte';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
// Volume leveling (M464 #4998). The choice is the user's, stored on the
|
||||
// server, so it follows them to the Android app and to casts.
|
||||
onMount(() => {
|
||||
void loadNormalization();
|
||||
});
|
||||
|
||||
const modeLabels: Record<string, string> = {
|
||||
off: 'Off',
|
||||
auto: 'Auto',
|
||||
track: 'Track',
|
||||
album: 'Album'
|
||||
};
|
||||
const modeHints: Record<string, string> = {
|
||||
off: 'Tracks play at their mastered volume.',
|
||||
auto: 'Album gain for whole albums in order, track gain otherwise.',
|
||||
track: 'Every track at the same loudness.',
|
||||
album: 'Albums keep their own quiet and loud tracks.'
|
||||
};
|
||||
|
||||
async function change(patch: Partial<NormalizationPrefs>): Promise<void> {
|
||||
try {
|
||||
await setNormalization({ ...normalization.value, ...patch });
|
||||
} catch {
|
||||
pushToast('Could not save volume leveling.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const pill = (on: boolean) =>
|
||||
`flex cursor-pointer items-center rounded border border-border px-3 py-1.5 text-sm ${
|
||||
on ? 'bg-action-secondary text-action-fg' : 'text-text-primary hover:bg-surface-hover'
|
||||
}`;
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="block text-sm text-text-secondary">Volume leveling</legend>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each NORMALIZATION_MODES as m (m)}
|
||||
<label class={pill(normalization.value.mode === m)}>
|
||||
<input
|
||||
type="radio"
|
||||
name="normalization-mode"
|
||||
value={m}
|
||||
checked={normalization.value.mode === m}
|
||||
onchange={() => change({ mode: m })}
|
||||
class="sr-only"
|
||||
/>
|
||||
{modeLabels[m]}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-text-secondary">{modeHints[normalization.value.mode]}</p>
|
||||
</fieldset>
|
||||
|
||||
{#if normalization.value.mode !== 'off'}
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="block text-sm text-text-secondary">Target loudness</legend>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each NORMALIZATION_TARGETS as t (t)}
|
||||
<label class={pill(normalization.value.target_lufs === t)}>
|
||||
<input
|
||||
type="radio"
|
||||
name="normalization-target"
|
||||
value={t}
|
||||
checked={normalization.value.target_lufs === t}
|
||||
onchange={() => change({ target_lufs: t })}
|
||||
class="sr-only"
|
||||
/>
|
||||
<span class="tabular-nums">{t} LUFS</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-text-secondary">Higher is louder; −18 leaves the most headroom.</p>
|
||||
</fieldset>
|
||||
|
||||
<label class="flex cursor-pointer items-center justify-between gap-3">
|
||||
<span>
|
||||
<span class="block text-sm text-text-primary">Boost quiet tracks fully</span>
|
||||
<span class="block text-xs text-text-secondary">A limiter catches the peaks.</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={normalization.value.boost === 'limiter'}
|
||||
onchange={(e) =>
|
||||
change({ boost: (e.currentTarget as HTMLInputElement).checked ? 'limiter' : 'headroom' })}
|
||||
class="h-4 w-4 accent-accent"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
vi.mock('$lib/api/normalization', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('$lib/api/normalization')>()),
|
||||
getNormalization: vi.fn(),
|
||||
putNormalization: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import NormalizationSetting from './NormalizationSetting.svelte';
|
||||
import { getNormalization, putNormalization } from '$lib/api/normalization';
|
||||
import { normalization } from '$lib/stores/normalization.svelte';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('NormalizationSetting', () => {
|
||||
test('shows the server value and saves the whole preference on a change', async () => {
|
||||
vi.mocked(getNormalization).mockResolvedValue({ mode: 'track', target_lufs: -16, boost: 'headroom' });
|
||||
vi.mocked(putNormalization).mockImplementation(async (p) => p);
|
||||
render(NormalizationSetting);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('radio', { name: 'Track' })).toBeChecked());
|
||||
expect(screen.getByRole('radio', { name: '-16 LUFS' })).toBeChecked();
|
||||
|
||||
await fireEvent.click(screen.getByRole('radio', { name: 'Album' }));
|
||||
await waitFor(() =>
|
||||
expect(putNormalization).toHaveBeenCalledWith({ mode: 'album', target_lufs: -16, boost: 'headroom' })
|
||||
);
|
||||
await fireEvent.click(screen.getByRole('checkbox', { name: /boost quiet tracks/i }));
|
||||
await waitFor(() =>
|
||||
expect(putNormalization).toHaveBeenLastCalledWith({ mode: 'album', target_lufs: -16, boost: 'limiter' })
|
||||
);
|
||||
// The last value seen is cached for the player's first track of a session.
|
||||
expect(JSON.parse(localStorage.getItem('minstrel:normalization') ?? 'null')).toEqual({
|
||||
mode: 'album',
|
||||
target_lufs: -16,
|
||||
boost: 'limiter'
|
||||
});
|
||||
});
|
||||
|
||||
test('a failed save puts the previous choice back and says so', async () => {
|
||||
vi.mocked(getNormalization).mockResolvedValue({ mode: 'auto', target_lufs: -18, boost: 'headroom' });
|
||||
vi.mocked(putNormalization).mockRejectedValue(new Error('offline'));
|
||||
render(NormalizationSetting);
|
||||
await waitFor(() => expect(screen.getByRole('radio', { name: 'Auto' })).toBeChecked());
|
||||
|
||||
await fireEvent.click(screen.getByRole('radio', { name: 'Off' }));
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Could not save volume leveling.', 'error'));
|
||||
expect(normalization.value.mode).toBe('auto');
|
||||
expect(screen.getByRole('radio', { name: 'Auto' })).toBeChecked();
|
||||
});
|
||||
|
||||
test('off hides the target and boost controls', async () => {
|
||||
vi.mocked(getNormalization).mockResolvedValue({ mode: 'off', target_lufs: -18, boost: 'headroom' });
|
||||
render(NormalizationSetting);
|
||||
await waitFor(() => expect(screen.getByRole('radio', { name: 'Off' })).toBeChecked());
|
||||
expect(screen.queryByRole('radio', { name: '-18 LUFS' })).toBeNull();
|
||||
expect(screen.queryByRole('checkbox')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as storage from '$lib/util/safeLocalStorage';
|
||||
import {
|
||||
DEFAULT_NORMALIZATION,
|
||||
getNormalization,
|
||||
isNormalizationPrefs,
|
||||
putNormalization,
|
||||
type NormalizationPrefs
|
||||
} from '$lib/api/normalization';
|
||||
|
||||
// The signed-in user's normalization preference. The server holds it; this
|
||||
// keeps the last value seen in localStorage too, so the player levels the
|
||||
// first track of a session before the fetch returns.
|
||||
const STORAGE_KEY = 'minstrel:normalization';
|
||||
|
||||
function readCached(): NormalizationPrefs {
|
||||
try {
|
||||
const v: unknown = JSON.parse(storage.read(STORAGE_KEY) ?? 'null');
|
||||
return isNormalizationPrefs(v) ? v : DEFAULT_NORMALIZATION;
|
||||
} catch {
|
||||
return DEFAULT_NORMALIZATION;
|
||||
}
|
||||
}
|
||||
|
||||
let _prefs = $state<NormalizationPrefs>(readCached());
|
||||
// Bumped by every change made here. A load or a save that resolves after a
|
||||
// newer change must not overwrite it.
|
||||
let _generation = 0;
|
||||
|
||||
export const normalization = {
|
||||
get value(): NormalizationPrefs {
|
||||
return _prefs;
|
||||
}
|
||||
};
|
||||
|
||||
function commit(p: NormalizationPrefs): void {
|
||||
_prefs = p;
|
||||
storage.write(STORAGE_KEY, JSON.stringify(p));
|
||||
}
|
||||
|
||||
/** Refreshes from the server; on failure the cached value stands. */
|
||||
export async function loadNormalization(): Promise<void> {
|
||||
const gen = _generation;
|
||||
try {
|
||||
const p = await getNormalization();
|
||||
if (gen === _generation) commit(p);
|
||||
} catch {
|
||||
// Offline or signed out: keep what we had.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies [next] at once, then saves it. If the save fails the previous
|
||||
* value is restored and the error rethrown for the caller to report.
|
||||
*/
|
||||
export async function setNormalization(next: NormalizationPrefs): Promise<void> {
|
||||
const prev = _prefs;
|
||||
const gen = ++_generation;
|
||||
commit(next);
|
||||
try {
|
||||
const saved = await putNormalization(next);
|
||||
if (gen === _generation) commit(saved);
|
||||
} catch (err) {
|
||||
if (gen === _generation) commit(prev);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
|
||||
import ServerVersion from '$lib/components/ServerVersion.svelte';
|
||||
import ActiveSessions from '$lib/components/ActiveSessions.svelte';
|
||||
import NormalizationSetting from '$lib/components/NormalizationSetting.svelte';
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -341,6 +342,8 @@
|
||||
0 = off · most albums sound best at 0.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NormalizationSetting />
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded border border-border bg-surface p-4">
|
||||
|
||||
@@ -25,6 +25,16 @@ vi.mock('$lib/api/listenbrainz', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// The Volume leveling setting loads on mount; keep it off the network.
|
||||
vi.mock('$lib/api/normalization', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('$lib/api/normalization')>();
|
||||
return {
|
||||
...mod,
|
||||
getNormalization: vi.fn().mockResolvedValue(mod.DEFAULT_NORMALIZATION),
|
||||
putNormalization: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/metrics', () => ({
|
||||
createRecommendationMetricsQuery: () => ({
|
||||
subscribe: (run: (v: unknown) => void) => {
|
||||
|
||||
@@ -11,6 +11,16 @@ vi.mock('$lib/api/listenbrainz', () => ({
|
||||
setListenBrainzEnabled: vi.fn()
|
||||
}));
|
||||
|
||||
// The Volume leveling setting loads on mount; keep it off the network.
|
||||
vi.mock('$lib/api/normalization', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('$lib/api/normalization')>();
|
||||
return {
|
||||
...mod,
|
||||
getNormalization: vi.fn().mockResolvedValue(mod.DEFAULT_NORMALIZATION),
|
||||
putNormalization: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/me', () => ({
|
||||
updateProfile: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
|
||||
Reference in New Issue
Block a user