From 9fba2aa33d99f05883d225f5da86745ed18aaecb Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 20:49:16 +0600 Subject: [PATCH] feat(window): implement window state persistence and restoration --- .../org/spotube/spotube/core/db/Database.kt | 7 + .../spotube/spotube/core/di/Modules.jvm.kt | 3 + .../core/window/WindowStateRepository.kt | 89 ++++++++++++ .../kotlin/org/spotube/spotube/main.kt | 131 +++++++++++++++--- .../core/window/WindowStateRepositoryTest.kt | 85 ++++++++++++ 5 files changed, 294 insertions(+), 21 deletions(-) create mode 100644 composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/window/WindowStateRepository.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/window/WindowStateRepositoryTest.kt diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt index d8319f8a..272aa8fe 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt @@ -77,6 +77,12 @@ class Database(val paths: Paths) { ) } + val windowStateDataStore: DataStore by lazy { + PreferenceDataStoreFactory.createWithPath( + produceFile = { "${paths.getApplicationDataDirPath()}/spotube_window.preferences_pb".toPath() } + ) + } + private companion object { /** Preference files written by previous launches; their presence means an upgrade. */ val EXISTING_INSTALL_PROBE_FILES = listOf( @@ -95,4 +101,5 @@ object DatabaseKeys { val AUDIO_PLAYER_QUEUE_STATE_KEY = stringPreferencesKey("audio_player_queue_state") val LOCAL_MEDIA_CACHE_STATE_KEY = stringPreferencesKey("local_media_cache_state") val LOCAL_MEDIA_LAST_SCAN_AT_KEY = longPreferencesKey("local_media_last_scan_at") + val WINDOW_STATE_KEY = stringPreferencesKey("window_state") } diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt index d668c914..bc7fc177 100644 --- a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt @@ -30,9 +30,12 @@ import org.koin.core.module.dsl.createdAtStart import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.withOptions import org.koin.dsl.module +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.window.WindowStateRepository actual val platformModules = module { singleOf(::Paths) + single { WindowStateRepository(get()) } single { AudioPlayer(Unit) } single { JvmLocalMediaDiscoveryService() } single { JvmShareService() } diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/window/WindowStateRepository.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/window/WindowStateRepository.kt new file mode 100644 index 00000000..6bddb90a --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/window/WindowStateRepository.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +package org.spotube.spotube.core.window + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.db.DatabaseKeys + +/** + * What survives a restart: the last *normal* (floating) size, and whether the window was + * maximized. The size is kept while maximized so un-maximizing after a restart restores it. + */ +@Serializable +data class PersistedWindowState( + val widthDp: Float, + val heightDp: Float, + val maximized: Boolean +) { + val size: DpSize get() = DpSize(widthDp.dp, heightDp.dp) + val placement: WindowPlacement + get() = if (maximized) WindowPlacement.Maximized else WindowPlacement.Floating + + companion object { + fun of(size: DpSize, placement: WindowPlacement) = PersistedWindowState( + widthDp = size.width.value, + heightDp = size.height.value, + maximized = placement == WindowPlacement.Maximized) + } +} + +class WindowStateRepository internal constructor(private val dataStore: DataStore) { + constructor(database: Database) : this(database.windowStateDataStore) + + private val json = Json { ignoreUnknownKeys = true } + + /** Returns null when nothing was saved yet or the stored value is unreadable. */ + suspend fun load(): PersistedWindowState? = runCatching { + val raw = dataStore.data.first()[DatabaseKeys.WINDOW_STATE_KEY] ?: return null + json.decodeFromString(raw) + }.getOrNull() + + suspend fun save(state: PersistedWindowState) { + runCatching { + dataStore.edit { prefs -> + prefs[DatabaseKeys.WINDOW_STATE_KEY] = json.encodeToString(PersistedWindowState.serializer(), state) + } + } + } +} + +/** + * Keeps a restored [size] usable: never smaller than [min], and never bigger than the + * available screen ([maxWidth] x [maxHeight]) so a size saved on a larger monitor can't open + * off-screen. A null max means the screen size is unknown, so only [min] is enforced. + */ +fun DpSize.coerceForScreen( + min: DpSize, + maxWidth: Dp?, + maxHeight: Dp? +): DpSize { + val width = width.coerceAtLeast(min.width).let { if (maxWidth != null) it.coerceAtMost(maxOf(maxWidth, min.width)) else it } + val height = height.coerceAtLeast(min.height).let { if (maxHeight != null) it.coerceAtMost(maxOf(maxHeight, min.height)) else it } + return DpSize(width, height) +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt index 32cb1500..4a3148cd 100644 --- a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt @@ -18,18 +18,37 @@ package org.spotube.spotube import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.unit.dp import androidx.compose.ui.window.rememberWindowState -import com.sun.jna.Library -import com.sun.jna.Native +import dev.nucleusframework.application.DecoratedWindow +import dev.nucleusframework.application.NucleusBackend +import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.window.tao.TaoScreenGeometry +import io.github.vinceglb.filekit.FileKit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import org.koin.core.component.KoinComponent +import org.koin.core.component.get import org.spotube.spotube.core.di.initKoin -import org.spotube.spotube.core.deeplink.ExternalUriHandler +import org.spotube.spotube.core.i18n.AppLocaleProvider import org.spotube.spotube.core.newpipe.NewPipeDownloader import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.core.systemtray.SystemTray @@ -37,19 +56,11 @@ import org.spotube.spotube.core.systemtray.SystemTrayService import org.spotube.spotube.core.ui.component.LocalApplicationScope import org.spotube.spotube.core.ui.component.LocalWindowScope import org.spotube.spotube.core.ui.component.LocalWindowState -import org.spotube.spotube.core.i18n.AppLocaleProvider +import org.spotube.spotube.core.window.PersistedWindowState +import org.spotube.spotube.core.window.WindowStateRepository +import org.spotube.spotube.core.window.coerceForScreen import org.spotube.spotube.modules.settings.SettingsProvider import org.spotube.spotube.modules.settings.SupportedLanguages -import dev.nucleusframework.application.DecoratedWindow -import dev.nucleusframework.application.NucleusBackend -import dev.nucleusframework.application.nucleusApplication -import io.github.vinceglb.filekit.FileKit -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import org.koin.core.component.KoinComponent -import org.koin.core.component.get object KoinPathsProvider : KoinComponent { val paths: Paths get() = get() @@ -58,9 +69,65 @@ object KoinPathsProvider : KoinComponent { private object KoinServicesProvider : KoinComponent { val systemTrayService: SystemTrayService get() = get() val settingsProvider: SettingsProvider get() = get() + val windowStateRepository: WindowStateRepository get() = get() } -@OptIn(ExperimentalComposeUiApi::class) +private const val WINDOW_SCREEN_FRACTION = 0.85f +private const val WINDOW_ASPECT_RATIO = 16f / 10f +private val MAX_WINDOW_WIDTH = 1440.dp +private val MAX_WINDOW_HEIGHT = 960.dp +private val MIN_WINDOW_SIZE = DpSize(800.dp, 500.dp) +private const val WINDOW_STATE_SAVE_DEBOUNCE_MS = 500L + +/** Largest [WINDOW_ASPECT_RATIO] size that fits inside [maxWidth] x [maxHeight]. */ +private fun fitAspectRatio(maxWidth: Dp, maxHeight: Dp): DpSize { + val width = minOf(maxWidth, maxHeight * WINDOW_ASPECT_RATIO) + return DpSize(width, width / WINDOW_ASPECT_RATIO) +} + +/** + * A 16:10 window that fits in 85% of the primary monitor's work area (screen minus + * taskbar/dock), capped at 1440x960. + * + * Uses Nucleus' Tao screen geometry rather than AWT, which doesn't exist on the Tao runtime. + * The work area is in physical pixels, so it's divided by the monitor scale factor to get dp. + * Falls back to the cap box when the platform bridge can't report a monitor. + */ +private fun initialWindowSize(): DpSize { + val workArea = availableScreenSize() + ?: return fitAspectRatio(MAX_WINDOW_WIDTH, MAX_WINDOW_HEIGHT) + + return fitAspectRatio( + maxWidth = (workArea.width * WINDOW_SCREEN_FRACTION).coerceAtMost(MAX_WINDOW_WIDTH), + maxHeight = (workArea.height * WINDOW_SCREEN_FRACTION).coerceAtMost(MAX_WINDOW_HEIGHT)) +} + +/** The primary monitor's work area in dp, or null when the platform bridge can't report it. */ +private fun availableScreenSize(): DpSize? { + val workArea = runCatching { TaoScreenGeometry.primaryMonitorWorkAreaPx() }.getOrNull() + if (workArea == null || workArea.size < 4 || workArea[2] <= 0 || workArea[3] <= 0) return null + + val scale = runCatching { TaoScreenGeometry.primaryMonitorScaleFactor() } + .getOrDefault(1f) + .takeIf { it > 0f } ?: 1f + + return DpSize((workArea[2] / scale).dp, (workArea[3] / scale).dp) +} + +/** Saved size if there is one (clamped to this screen), otherwise the default for a first launch. */ +private fun restoreWindowState(saved: PersistedWindowState?): PersistedWindowState { + if (saved == null || saved.widthDp <= 0f || saved.heightDp <= 0f) { + return PersistedWindowState.of(initialWindowSize(), WindowPlacement.Floating) + } + val screen = availableScreenSize() + val size = saved.size.coerceForScreen( + min = MIN_WINDOW_SIZE, + maxWidth = screen?.width, + maxHeight = screen?.height) + return PersistedWindowState.of(size, saved.placement) +} + +@OptIn(ExperimentalComposeUiApi::class, FlowPreview::class) fun main() { FileKit.init(appId = "org.spotube.spotube") initKoin() @@ -68,10 +135,28 @@ fun main() { val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) nucleusApplication(backend = NucleusBackend.Tao) { - val windowState = rememberWindowState( - width = 1080.dp, - height = 720.dp - ) + val windowStateRepository = remember { KoinServicesProvider.windowStateRepository } + // Needed before the window is created, so the one-time read at startup is blocking + val restored = remember { restoreWindowState(runBlocking { windowStateRepository.load() }) } + val windowState = rememberWindowState(placement = restored.placement, size = restored.size) + + // The size to restore into when un-maximizing; only floating windows update it + var normalSize by remember { mutableStateOf(restored.size) } + val currentWindowState = { PersistedWindowState.of(normalSize, windowState.placement) } + // Exit paths can't wait on a coroutine that is about to be cancelled + val persistWindowStateBlocking = { runBlocking { windowStateRepository.save(currentWindowState()) } } + + LaunchedEffect(windowState) { + snapshotFlow { windowState.placement to windowState.size } + .onEach { (placement, size) -> + if (placement == WindowPlacement.Floating && size.isSpecified && + size.width > 0.dp && size.height > 0.dp) { + normalSize = size + } + } + .debounce(WINDOW_STATE_SAVE_DEBOUNCE_MS) + .collect { windowStateRepository.save(currentWindowState()) } + } val settingsProvider = KoinServicesProvider.settingsProvider val settings by settingsProvider.settingsState.collectAsState(initial = null) @@ -83,6 +168,7 @@ fun main() { val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible } val onExit = { + persistWindowStateBlocking() trayService.close() appScope.cancel() exitApplication() @@ -96,7 +182,8 @@ fun main() { SystemTray( isWindowVisible = isWindowVisible, onToggleWindowVisibility = onToggleWindowVisibility, - onExit = onExit) + onExit = onExit + ) } } @@ -106,13 +193,15 @@ fun main() { if (minimizeToTray) { isWindowVisible = false } else { + persistWindowStateBlocking() trayService.close() appScope.cancel() exitApplication() } }, title = "Spotube", - visible = isWindowVisible) { + visible = isWindowVisible + ) { CompositionLocalProvider( LocalApplicationScope provides this@nucleusApplication, LocalWindowScope provides this@DecoratedWindow, diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/window/WindowStateRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/window/WindowStateRepositoryTest.kt new file mode 100644 index 00000000..6a8ded1d --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/window/WindowStateRepositoryTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.window + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import kotlinx.coroutines.runBlocking +import okio.Path.Companion.toPath +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WindowStateRepositoryTest { + private fun repository(): WindowStateRepository { + val dir = Files.createTempDirectory("window-state-test").toFile() + val file = File(dir, "window.preferences_pb") + return WindowStateRepository( + PreferenceDataStoreFactory.createWithPath(produceFile = { file.absolutePath.toPath() })) + } + + @Test + fun missingValueLoadsNull() = runBlocking { + assertNull(repository().load()) + } + + @Test + fun roundTripsNormalState() = runBlocking { + val store = repository() + store.save(PersistedWindowState.of(DpSize(1200.dp, 750.dp), WindowPlacement.Floating)) + + val loaded = store.load()!! + assertEquals(DpSize(1200.dp, 750.dp), loaded.size) + assertEquals(WindowPlacement.Floating, loaded.placement) + } + + @Test + fun roundTripsMaximizedStateKeepingNormalSize() = runBlocking { + val store = repository() + store.save(PersistedWindowState.of(DpSize(1100.dp, 700.dp), WindowPlacement.Maximized)) + + val loaded = store.load()!! + assertEquals(WindowPlacement.Maximized, loaded.placement) + assertEquals(DpSize(1100.dp, 700.dp), loaded.size) + } + + @Test + fun oversizedSavedSizeIsClampedToScreen() { + val clamped = DpSize(3000.dp, 2000.dp) + .coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = 1800.dp, maxHeight = 1000.dp) + assertEquals(DpSize(1800.dp, 1000.dp), clamped) + } + + @Test + fun tinySavedSizeIsRaisedToMinimum() { + val clamped = DpSize(100.dp, 100.dp) + .coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = 1800.dp, maxHeight = 1000.dp) + assertEquals(DpSize(800.dp, 500.dp), clamped) + } + + @Test + fun unknownScreenOnlyEnforcesMinimum() { + val clamped = DpSize(3000.dp, 2000.dp) + .coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = null, maxHeight = null) + assertEquals(DpSize(3000.dp, 2000.dp), clamped) + } +}