mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(window): implement window state persistence and restoration
This commit is contained in:
parent
9db35e092b
commit
9fba2aa33d
@ -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 {
|
||||
/** 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")
|
||||
}
|
||||
|
||||
@ -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<Database>()) }
|
||||
single<AudioPlayerInterface> { AudioPlayer(Unit) }
|
||||
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
|
||||
single<ShareService> { JvmShareService() }
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user