new: persist preferences across sessions

This commit is contained in:
2026-07-03 13:48:29 +02:00
parent beb1973978
commit d2283ae347
12 changed files with 212 additions and 24 deletions
+20
View File
@@ -3,7 +3,27 @@ plugins {
} }
kotlin { kotlin {
// Adding custom `dependsOn` edges below disables the automatic default hierarchy, so re-apply it
// explicitly to keep the standard commonMain/appleMain/iosMain/webMain wiring.
applyDefaultHierarchyTemplate()
androidLibrary { androidLibrary {
namespace = "fr.ajaury.gwenedeg.preferences" namespace = "fr.ajaury.gwenedeg.preferences"
} }
sourceSets {
// Persistence via DataStore for every target that has an artifact for it (Android, iOS,
// Desktop). Web (JS/Wasm) has no DataStore artifact, so it keeps commonMain's in-memory
// implementation.
val dataStoreMain by creating {
dependsOn(commonMain.get())
dependencies {
implementation(libs.androidx.datastore.preferences.core)
implementation(libs.okio)
}
}
androidMain.get().dependsOn(dataStoreMain)
jvmMain.get().dependsOn(dataStoreMain)
iosMain.get().dependsOn(dataStoreMain)
}
} }
@@ -0,0 +1,20 @@
package fr.ajaury.gwenedeg.preferences.di
import android.content.Context
import fr.ajaury.gwenedeg.preferences.data.DATA_STORE_FILE_NAME
import fr.ajaury.gwenedeg.preferences.data.DataStorePreferencesRepository
import fr.ajaury.gwenedeg.preferences.data.createPreferencesDataStore
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import org.koin.core.module.Module
import org.koin.dsl.module
actual val preferencesModule: Module = module {
single<PreferencesRepository> {
val context = get<Context>()
DataStorePreferencesRepository(
createPreferencesDataStore {
context.filesDir.resolve(DATA_STORE_FILE_NAME).absolutePath
},
)
}
}
@@ -8,22 +8,26 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
internal class PreferencesRepositoryImpl : PreferencesRepository { /**
* In-memory [PreferencesRepository] used on platforms without a DataStore artifact (web). Values do
* not survive an app restart / page reload.
*/
internal class InMemoryPreferencesRepository : PreferencesRepository {
private val subtitleState = MutableStateFlow(SubtitlePreferences()) private val subtitleState = MutableStateFlow(SubtitlePreferences())
override val subtitlePreferences: StateFlow<SubtitlePreferences> = subtitleState.asStateFlow() override val subtitlePreferences: StateFlow<SubtitlePreferences> = subtitleState.asStateFlow()
private val playbackState = MutableStateFlow(PlaybackPreferences()) private val playbackState = MutableStateFlow(PlaybackPreferences())
override val playbackPreferences: StateFlow<PlaybackPreferences> = playbackState.asStateFlow() override val playbackPreferences: StateFlow<PlaybackPreferences> = playbackState.asStateFlow()
override fun setShowTranscription(enabled: Boolean) { override suspend fun setShowTranscription(enabled: Boolean) {
subtitleState.update { it.copy(showTranscription = enabled) } subtitleState.update { it.copy(showTranscription = enabled) }
} }
override fun setShowTranslation(enabled: Boolean) { override suspend fun setShowTranslation(enabled: Boolean) {
subtitleState.update { it.copy(showTranslation = enabled) } subtitleState.update { it.copy(showTranslation = enabled) }
} }
override fun setPlaybackSpeed(speed: Float) { override suspend fun setPlaybackSpeed(speed: Float) {
playbackState.update { it.copy(speed = speed) } playbackState.update { it.copy(speed = speed) }
} }
} }
@@ -1,12 +1,9 @@
package fr.ajaury.gwenedeg.preferences.di package fr.ajaury.gwenedeg.preferences.di
import fr.ajaury.gwenedeg.preferences.data.PreferencesRepositoryImpl
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import org.koin.core.module.Module import org.koin.core.module.Module
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val preferencesModule: Module = module { /**
singleOf(::PreferencesRepositoryImpl) { bind<PreferencesRepository>() } * Binds [fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository]. Persisted with DataStore on
} * every target that ships it (Android, iOS, Desktop); web falls back to an in-memory implementation.
*/
expect val preferencesModule: Module
@@ -2,16 +2,16 @@ package fr.ajaury.gwenedeg.preferences.domain
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.Flow
interface PreferencesRepository { interface PreferencesRepository {
val subtitlePreferences: StateFlow<SubtitlePreferences> val subtitlePreferences: Flow<SubtitlePreferences>
val playbackPreferences: StateFlow<PlaybackPreferences> val playbackPreferences: Flow<PlaybackPreferences>
fun setShowTranscription(enabled: Boolean) suspend fun setShowTranscription(enabled: Boolean)
fun setShowTranslation(enabled: Boolean) suspend fun setShowTranslation(enabled: Boolean)
fun setPlaybackSpeed(speed: Float) suspend fun setPlaybackSpeed(speed: Float)
} }
@@ -0,0 +1,59 @@
package fr.ajaury.gwenedeg.preferences.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
/**
* [PreferencesRepository] persisting to a Preferences [DataStore]. Reads are exposed as hot
* [StateFlow]s seeded with the defaults; writes are fire-and-forget on a background scope.
*/
internal class DataStorePreferencesRepository(
private val dataStore: DataStore<Preferences>,
) : PreferencesRepository {
override val subtitlePreferences: Flow<SubtitlePreferences> =
dataStore.data
.map { it.toSubtitlePreferences() }
override val playbackPreferences: Flow<PlaybackPreferences> =
dataStore.data
.map { it.toPlaybackPreferences() }
override suspend fun setShowTranscription(enabled: Boolean) = edit { it[ShowTranscriptionKey] = enabled }
override suspend fun setShowTranslation(enabled: Boolean) = edit { it[ShowTranslationKey] = enabled }
override suspend fun setPlaybackSpeed(speed: Float) = edit { it[PlaybackSpeedKey] = speed }
private suspend fun edit(transform: (MutablePreferences) -> Unit) {
dataStore.edit(transform)
}
private fun Preferences.toSubtitlePreferences(): SubtitlePreferences {
val defaults = SubtitlePreferences()
return SubtitlePreferences(
showTranscription = this[ShowTranscriptionKey] ?: defaults.showTranscription,
showTranslation = this[ShowTranslationKey] ?: defaults.showTranslation,
)
}
private fun Preferences.toPlaybackPreferences(): PlaybackPreferences =
PlaybackPreferences(
speed = this[PlaybackSpeedKey] ?: PlaybackPreferences.DEFAULT_SPEED,
)
private companion object {
val ShowTranscriptionKey = booleanPreferencesKey("show_transcription")
val ShowTranslationKey = booleanPreferencesKey("show_translation")
val PlaybackSpeedKey = floatPreferencesKey("playback_speed")
}
}
@@ -0,0 +1,12 @@
package fr.ajaury.gwenedeg.preferences.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import okio.Path.Companion.toPath
internal const val DATA_STORE_FILE_NAME = "gwenedeg.preferences_pb"
/** Builds a Preferences [DataStore] backed by the file at [producePath]. */
internal fun createPreferencesDataStore(producePath: () -> String): DataStore<Preferences> =
PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() })
@@ -0,0 +1,32 @@
@file:OptIn(ExperimentalForeignApi::class)
package fr.ajaury.gwenedeg.preferences.di
import fr.ajaury.gwenedeg.preferences.data.DATA_STORE_FILE_NAME
import fr.ajaury.gwenedeg.preferences.data.DataStorePreferencesRepository
import fr.ajaury.gwenedeg.preferences.data.createPreferencesDataStore
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import kotlinx.cinterop.ExperimentalForeignApi
import org.koin.core.module.Module
import org.koin.dsl.module
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
actual val preferencesModule: Module = module {
single<PreferencesRepository> {
DataStorePreferencesRepository(
createPreferencesDataStore {
val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
val path = requireNotNull(documentDirectory?.path) { "Missing documents directory" }
"$path/$DATA_STORE_FILE_NAME"
},
)
}
}
@@ -0,0 +1,20 @@
package fr.ajaury.gwenedeg.preferences.di
import fr.ajaury.gwenedeg.preferences.data.DATA_STORE_FILE_NAME
import fr.ajaury.gwenedeg.preferences.data.DataStorePreferencesRepository
import fr.ajaury.gwenedeg.preferences.data.createPreferencesDataStore
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import org.koin.core.module.Module
import org.koin.dsl.module
import java.io.File
actual val preferencesModule: Module = module {
single<PreferencesRepository> {
DataStorePreferencesRepository(
createPreferencesDataStore {
val directory = File(System.getProperty("user.home"), ".gwenedeg").apply { mkdirs() }
File(directory, DATA_STORE_FILE_NAME).absolutePath
},
)
}
}
@@ -0,0 +1,12 @@
package fr.ajaury.gwenedeg.preferences.di
import fr.ajaury.gwenedeg.preferences.data.InMemoryPreferencesRepository
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import org.koin.core.module.Module
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
actual val preferencesModule: Module = module {
singleOf(::InMemoryPreferencesRepository) { bind<PreferencesRepository>() }
}
+4
View File
@@ -19,6 +19,8 @@ material3 = "1.11.0-alpha07"
koin = "4.2.1" koin = "4.2.1"
kermit = "2.1.0" kermit = "2.1.0"
vorbisspi = "1.0.3.3" vorbisspi = "1.0.3.3"
datastore = "1.1.1"
okio = "3.9.1"
[libraries] [libraries]
# Gradle plugin artifacts, used by build-logic convention plugins only # Gradle plugin artifacts, used by build-logic convention plugins only
@@ -28,6 +30,8 @@ compose-gradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin",
composeCompiler-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } composeCompiler-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
@@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -77,8 +78,9 @@ class PlayerViewModel(
} }
} }
private fun applyCurrentSpeed() { private suspend fun applyCurrentSpeed() {
playbackRepository.setSpeed(preferencesRepository.playbackPreferences.value.speed) val playbackPreferences = preferencesRepository.playbackPreferences.firstOrNull() ?: return
playbackRepository.setSpeed(playbackPreferences.speed)
} }
fun performMainPlayAction() { fun performMainPlayAction() {
@@ -111,16 +113,22 @@ class PlayerViewModel(
PlaybackPreferences.MIN_SPEED, PlaybackPreferences.MIN_SPEED,
PlaybackPreferences.MAX_SPEED, PlaybackPreferences.MAX_SPEED,
) )
preferencesRepository.setPlaybackSpeed(clampedSpeed) viewModelScope.launch {
playbackRepository.setSpeed(clampedSpeed) preferencesRepository.setPlaybackSpeed(clampedSpeed)
playbackRepository.setSpeed(clampedSpeed)
}
} }
fun setShowTranscription(enabled: Boolean) { fun setShowTranscription(enabled: Boolean) {
preferencesRepository.setShowTranscription(enabled) viewModelScope.launch {
preferencesRepository.setShowTranscription(enabled)
}
} }
fun setShowTranslation(enabled: Boolean) { fun setShowTranslation(enabled: Boolean) {
preferencesRepository.setShowTranslation(enabled) viewModelScope.launch {
preferencesRepository.setShowTranslation(enabled)
}
} }
fun stop() { fun stop() {