feat(window): implement window state persistence and restoration

This commit is contained in:
Kingkor Roy Tirtho 2026-10-16 20:49:16 +06:00
parent 9db35e092b
commit 9fba2aa33d
5 changed files with 294 additions and 21 deletions

View File

@ -77,6 +77,12 @@ class Database(val paths: Paths) {
) )
} }
val windowStateDataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.createWithPath(
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_window.preferences_pb".toPath() }
)
}
private companion object { private companion object {
/** Preference files written by previous launches; their presence means an upgrade. */ /** Preference files written by previous launches; their presence means an upgrade. */
val EXISTING_INSTALL_PROBE_FILES = listOf( val EXISTING_INSTALL_PROBE_FILES = listOf(
@ -95,4 +101,5 @@ object DatabaseKeys {
val AUDIO_PLAYER_QUEUE_STATE_KEY = stringPreferencesKey("audio_player_queue_state") 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_CACHE_STATE_KEY = stringPreferencesKey("local_media_cache_state")
val LOCAL_MEDIA_LAST_SCAN_AT_KEY = longPreferencesKey("local_media_last_scan_at") val LOCAL_MEDIA_LAST_SCAN_AT_KEY = longPreferencesKey("local_media_last_scan_at")
val WINDOW_STATE_KEY = stringPreferencesKey("window_state")
} }

View File

@ -30,9 +30,12 @@ import org.koin.core.module.dsl.createdAtStart
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.withOptions import org.koin.core.module.dsl.withOptions
import org.koin.dsl.module import org.koin.dsl.module
import org.spotube.spotube.core.db.Database
import org.spotube.spotube.core.window.WindowStateRepository
actual val platformModules = module { actual val platformModules = module {
singleOf(::Paths) singleOf(::Paths)
single { WindowStateRepository(get<Database>()) }
single<AudioPlayerInterface> { AudioPlayer(Unit) } single<AudioPlayerInterface> { AudioPlayer(Unit) }
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() } single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
single<ShareService> { JvmShareService() } single<ShareService> { JvmShareService() }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Preferences>) {
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<PersistedWindowState>(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)
}

View File

@ -18,18 +18,37 @@
package org.spotube.spotube package org.spotube.spotube
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi 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.unit.dp
import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.window.rememberWindowState
import com.sun.jna.Library import dev.nucleusframework.application.DecoratedWindow
import com.sun.jna.Native 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.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.newpipe.NewPipeDownloader
import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.core.paths.Paths
import org.spotube.spotube.core.systemtray.SystemTray 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.LocalApplicationScope
import org.spotube.spotube.core.ui.component.LocalWindowScope import org.spotube.spotube.core.ui.component.LocalWindowScope
import org.spotube.spotube.core.ui.component.LocalWindowState 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.SettingsProvider
import org.spotube.spotube.modules.settings.SupportedLanguages 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 { object KoinPathsProvider : KoinComponent {
val paths: Paths get() = get() val paths: Paths get() = get()
@ -58,9 +69,65 @@ object KoinPathsProvider : KoinComponent {
private object KoinServicesProvider : KoinComponent { private object KoinServicesProvider : KoinComponent {
val systemTrayService: SystemTrayService get() = get() val systemTrayService: SystemTrayService get() = get()
val settingsProvider: SettingsProvider 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() { fun main() {
FileKit.init(appId = "org.spotube.spotube") FileKit.init(appId = "org.spotube.spotube")
initKoin() initKoin()
@ -68,10 +135,28 @@ fun main() {
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
nucleusApplication(backend = NucleusBackend.Tao) { nucleusApplication(backend = NucleusBackend.Tao) {
val windowState = rememberWindowState( val windowStateRepository = remember { KoinServicesProvider.windowStateRepository }
width = 1080.dp, // Needed before the window is created, so the one-time read at startup is blocking
height = 720.dp 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 settingsProvider = KoinServicesProvider.settingsProvider
val settings by settingsProvider.settingsState.collectAsState(initial = null) val settings by settingsProvider.settingsState.collectAsState(initial = null)
@ -83,6 +168,7 @@ fun main() {
val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible } val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible }
val onExit = { val onExit = {
persistWindowStateBlocking()
trayService.close() trayService.close()
appScope.cancel() appScope.cancel()
exitApplication() exitApplication()
@ -96,7 +182,8 @@ fun main() {
SystemTray( SystemTray(
isWindowVisible = isWindowVisible, isWindowVisible = isWindowVisible,
onToggleWindowVisibility = onToggleWindowVisibility, onToggleWindowVisibility = onToggleWindowVisibility,
onExit = onExit) onExit = onExit
)
} }
} }
@ -106,13 +193,15 @@ fun main() {
if (minimizeToTray) { if (minimizeToTray) {
isWindowVisible = false isWindowVisible = false
} else { } else {
persistWindowStateBlocking()
trayService.close() trayService.close()
appScope.cancel() appScope.cancel()
exitApplication() exitApplication()
} }
}, },
title = "Spotube", title = "Spotube",
visible = isWindowVisible) { visible = isWindowVisible
) {
CompositionLocalProvider( CompositionLocalProvider(
LocalApplicationScope provides this@nucleusApplication, LocalApplicationScope provides this@nucleusApplication,
LocalWindowScope provides this@DecoratedWindow, LocalWindowScope provides this@DecoratedWindow,

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}