feat: remember scroll + search position on Back (web + android)

Going into an album/artist and back now restores the scroll position and
keeps the typed query, instead of resetting to the top.

- Web: navigation.js snapshots each history entry's #main-content scroll and
  any open local (header) search, restoring them on Back/Forward; a genuine
  new navigation still starts at the top.
- Android: the Alben/Künstler lists (LibraryViewModel) and the global/local
  search-results lists (ExploreViewModel/SearchViewModel) hoist their
  LazyListState/LazyGridState, so scroll survives a drill-in/back for the
  tab's lifetime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-09-02 00:06:46 +02:00
1 parent 3ceba8a365
commit 845f125d76
7 files changed
+64 -5

No files matched your search

@@ -317,7 +317,7 @@ private fun LibrarySearchContent(
return
}
LazyColumn {
LazyColumn(state = vm.listState) {
val artists = state.searchResult?.artist ?: emptyList()
val albums = state.searchResult?.album ?: emptyList()
val songs = state.searchResult?.song ?: emptyList()
@@ -461,7 +461,7 @@ private fun GlobalExploreContent(
return
}
LazyColumn {
LazyColumn(state = vm.searchListState) {
if (state.error != null) {
item { Text(state.error, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp)) }
@@ -166,7 +166,7 @@ fun LibraryScreen(
)
}
}
val albumGridState = rememberLazyGridState()
val albumGridState = vm.albumsGridState // hoisted → survives album drill-in/back
LaunchedEffect(state.albumSort) { albumGridState.scrollToItem(0) }
LazyVerticalGrid(
state = albumGridState,
@@ -189,7 +189,7 @@ fun LibraryScreen(
}
LibraryTab.ARTISTS -> {
val listState = rememberLazyListState()
val listState = vm.artistsListState // hoisted → survives artist drill-in/back
val scope = rememberCoroutineScope()
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
// Map letter → flat LazyColumn index of its header item
@@ -10,6 +10,7 @@ import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.api.model.ItunesRssEntry
import com.novasonic.app.api.model.Song
import androidx.compose.foundation.lazy.LazyListState
import com.novasonic.app.api.model.artworkHd
import com.novasonic.app.app
import com.novasonic.app.data.NotificationStore
@@ -76,6 +77,9 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
val state: StateFlow<ExploreUiState> = _state
// Global-search results scroll — hoisted so it survives an album/artist drill-in and back.
val searchListState = LazyListState()
private var searchJob: Job? = null
init {
@@ -1,6 +1,8 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Album
@@ -34,6 +36,12 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
val state: StateFlow<LibraryUiState> = _state
// Scroll positions live in the VM so they survive drilling into an album/artist and back (the
// Library back-stack entry, hence this VM, is retained). A full teardown of the tab → fresh VM
// → back at the top, as expected.
val albumsGridState = LazyGridState()
val artistsListState = LazyListState()
init { loadPlaylists() }
fun setAlbumSort(sort: AlbumSort) {
@@ -1,6 +1,7 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.compose.foundation.lazy.LazyListState
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.ArtistOverride
@@ -28,6 +29,9 @@ class SearchViewModel(app: Application) : AndroidViewModel(app) {
private val _state = MutableStateFlow(SearchUiState())
val state: StateFlow<SearchUiState> = _state
// Results scroll — hoisted so it survives a drill-in/back within the search.
val listState = LazyListState()
private var searchJob: Job? = null
init {