From d2283ae34748d73137f288ca42cd9289633db4a4 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 3 Jul 2026 13:48:29 +0200 Subject: [PATCH] new: persist preferences across sessions --- data/preferences/build.gradle.kts | 20 +++++++ .../di/PreferencesModule.android.kt | 20 +++++++ ...pl.kt => InMemoryPreferencesRepository.kt} | 12 ++-- .../preferences/di/PreferencesModule.kt | 13 ++-- .../domain/PreferencesRepository.kt | 12 ++-- .../data/DataStorePreferencesRepository.kt | 59 +++++++++++++++++++ .../preferences/data/PreferencesDataStore.kt | 12 ++++ .../preferences/di/PreferencesModule.ios.kt | 32 ++++++++++ .../preferences/di/PreferencesModule.jvm.kt | 20 +++++++ .../preferences/di/PreferencesModule.web.kt | 12 ++++ gradle/libs.versions.toml | 4 ++ .../player/ui/viewmodel/PlayerViewModel.kt | 20 +++++-- 12 files changed, 212 insertions(+), 24 deletions(-) create mode 100644 data/preferences/src/androidMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.android.kt rename data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/{PreferencesRepositoryImpl.kt => InMemoryPreferencesRepository.kt} (70%) create mode 100644 data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/DataStorePreferencesRepository.kt create mode 100644 data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesDataStore.kt create mode 100644 data/preferences/src/iosMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.ios.kt create mode 100644 data/preferences/src/jvmMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.jvm.kt create mode 100644 data/preferences/src/webMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.web.kt diff --git a/data/preferences/build.gradle.kts b/data/preferences/build.gradle.kts index 316efa4..ee8ff28 100644 --- a/data/preferences/build.gradle.kts +++ b/data/preferences/build.gradle.kts @@ -3,7 +3,27 @@ plugins { } 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 { 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) + } } diff --git a/data/preferences/src/androidMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.android.kt b/data/preferences/src/androidMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.android.kt new file mode 100644 index 0000000..fd524c2 --- /dev/null +++ b/data/preferences/src/androidMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.android.kt @@ -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 { + val context = get() + DataStorePreferencesRepository( + createPreferencesDataStore { + context.filesDir.resolve(DATA_STORE_FILE_NAME).absolutePath + }, + ) + } +} diff --git a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesRepositoryImpl.kt b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/InMemoryPreferencesRepository.kt similarity index 70% rename from data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesRepositoryImpl.kt rename to data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/InMemoryPreferencesRepository.kt index 0f593c6..a960cc6 100644 --- a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesRepositoryImpl.kt +++ b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/InMemoryPreferencesRepository.kt @@ -8,22 +8,26 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow 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()) override val subtitlePreferences: StateFlow = subtitleState.asStateFlow() private val playbackState = MutableStateFlow(PlaybackPreferences()) override val playbackPreferences: StateFlow = playbackState.asStateFlow() - override fun setShowTranscription(enabled: Boolean) { + override suspend fun setShowTranscription(enabled: Boolean) { subtitleState.update { it.copy(showTranscription = enabled) } } - override fun setShowTranslation(enabled: Boolean) { + override suspend fun setShowTranslation(enabled: Boolean) { subtitleState.update { it.copy(showTranslation = enabled) } } - override fun setPlaybackSpeed(speed: Float) { + override suspend fun setPlaybackSpeed(speed: Float) { playbackState.update { it.copy(speed = speed) } } } diff --git a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.kt b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.kt index 620c924..c55f349 100644 --- a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.kt +++ b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.kt @@ -1,12 +1,9 @@ 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.dsl.bind -import org.koin.core.module.dsl.singleOf -import org.koin.dsl.module -val preferencesModule: Module = module { - singleOf(::PreferencesRepositoryImpl) { bind() } -} +/** + * 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 diff --git a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt index 2fdf95b..8a452c8 100644 --- a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt +++ b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt @@ -2,16 +2,16 @@ package fr.ajaury.gwenedeg.preferences.domain import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.Flow interface PreferencesRepository { - val subtitlePreferences: StateFlow + val subtitlePreferences: Flow - val playbackPreferences: StateFlow + val playbackPreferences: Flow - 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) } diff --git a/data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/DataStorePreferencesRepository.kt b/data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/DataStorePreferencesRepository.kt new file mode 100644 index 0000000..f74a676 --- /dev/null +++ b/data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/DataStorePreferencesRepository.kt @@ -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, +) : PreferencesRepository { + override val subtitlePreferences: Flow = + dataStore.data + .map { it.toSubtitlePreferences() } + + override val playbackPreferences: Flow = + 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") + } +} diff --git a/data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesDataStore.kt b/data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesDataStore.kt new file mode 100644 index 0000000..8a19f55 --- /dev/null +++ b/data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesDataStore.kt @@ -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 = + PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() }) diff --git a/data/preferences/src/iosMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.ios.kt b/data/preferences/src/iosMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.ios.kt new file mode 100644 index 0000000..48c1743 --- /dev/null +++ b/data/preferences/src/iosMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.ios.kt @@ -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 { + 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" + }, + ) + } +} diff --git a/data/preferences/src/jvmMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.jvm.kt b/data/preferences/src/jvmMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.jvm.kt new file mode 100644 index 0000000..ac74012 --- /dev/null +++ b/data/preferences/src/jvmMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.jvm.kt @@ -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 { + DataStorePreferencesRepository( + createPreferencesDataStore { + val directory = File(System.getProperty("user.home"), ".gwenedeg").apply { mkdirs() } + File(directory, DATA_STORE_FILE_NAME).absolutePath + }, + ) + } +} diff --git a/data/preferences/src/webMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.web.kt b/data/preferences/src/webMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.web.kt new file mode 100644 index 0000000..bf94d33 --- /dev/null +++ b/data/preferences/src/webMain/kotlin/fr/ajaury/gwenedeg/preferences/di/PreferencesModule.web.kt @@ -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() } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 85b84de..446c4bf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,8 @@ material3 = "1.11.0-alpha07" koin = "4.2.1" kermit = "2.1.0" vorbisspi = "1.0.3.3" +datastore = "1.1.1" +okio = "3.9.1" [libraries] # 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" } 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-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt index 3c25203..03c097d 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -77,8 +78,9 @@ class PlayerViewModel( } } - private fun applyCurrentSpeed() { - playbackRepository.setSpeed(preferencesRepository.playbackPreferences.value.speed) + private suspend fun applyCurrentSpeed() { + val playbackPreferences = preferencesRepository.playbackPreferences.firstOrNull() ?: return + playbackRepository.setSpeed(playbackPreferences.speed) } fun performMainPlayAction() { @@ -111,16 +113,22 @@ class PlayerViewModel( PlaybackPreferences.MIN_SPEED, PlaybackPreferences.MAX_SPEED, ) - preferencesRepository.setPlaybackSpeed(clampedSpeed) - playbackRepository.setSpeed(clampedSpeed) + viewModelScope.launch { + preferencesRepository.setPlaybackSpeed(clampedSpeed) + playbackRepository.setSpeed(clampedSpeed) + } } fun setShowTranscription(enabled: Boolean) { - preferencesRepository.setShowTranscription(enabled) + viewModelScope.launch { + preferencesRepository.setShowTranscription(enabled) + } } fun setShowTranslation(enabled: Boolean) { - preferencesRepository.setShowTranslation(enabled) + viewModelScope.launch { + preferencesRepository.setShowTranslation(enabled) + } } fun stop() {