From 845f125d7673d704793aa2128a7df56b49fcf552 Mon Sep 17 00:00:00 2001 From: Honey Date: Wed, 2 Sep 2026 00:06:46 +0200 Subject: [PATCH] feat: remember scroll + search position on Back (web + android) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 9 +++++ .../novasonic/app/ui/screen/ExploreScreen.kt | 4 +-- .../novasonic/app/ui/screen/LibraryScreen.kt | 4 +-- .../app/viewmodel/ExploreViewModel.kt | 4 +++ .../app/viewmodel/LibraryViewModel.kt | 8 +++++ .../app/viewmodel/SearchViewModel.kt | 4 +++ src/views/navigation.js | 36 ++++++++++++++++++- 7 files changed, 64 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ea78f..afae851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,15 @@ like Android), concert events become **reliable across web/Android**, several An - **30s preview in the importer (Android)** — each resolved track now has a ▶/⏹ button that plays its Deezer/Spotify preview clip through the same app-level preview bar as Explore (library playback pauses; the row highlights while playing). Parity with the web importer. +- **Scroll & search position remembered on Back (Web + Android)** — going into an album/artist and + back now returns you to the same scroll spot with your typed query still there, instead of jumping + to the top. Web: each history entry's scroll (and the open local/header search) is snapshotted and + restored on Back/Forward; a genuine new navigation still starts at the top. Android: the Alben / + Künstler lists and the global/local search-results scroll are hoisted into their ViewModels, so + they survive a drill-in/back for as long as you're in that tab. +- **Partial album — Play / Shuffle + track menu (Android)** — an incomplete album's downloaded + tracks now have **Abspielen** and **Zufall** buttons and a per-row 3-dot menu, so you can still + play/shuffle what you have while the rest downloads. - **Swipe between tabs (Android)** — a left/right swipe now switches tabs in **Now Playing** (Player ↔ Lyrics ↔ Queue) and in the **Bibliothek** (Playlists ↔ Alben ↔ Künstler ↔ Offline), two-way synced with the tab bar, matching the existing Erkunden swipe. On Now Playing the cover's diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt index de5f62a..fd22249 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt @@ -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)) } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt index 2c9a53c..b0c37af 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt index 9d607f2..0a2eeb0 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt @@ -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 = _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 { diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt index 39d7d73..3d81018 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt @@ -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 = _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) { diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SearchViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SearchViewModel.kt index 07d5611..88da6d0 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SearchViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SearchViewModel.kt @@ -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 = _state + // Results scroll — hoisted so it survives a drill-in/back within the search. + val listState = LazyListState() + private var searchJob: Job? = null init { diff --git a/src/views/navigation.js b/src/views/navigation.js index 2edd777..ce26bd3 100644 --- a/src/views/navigation.js +++ b/src/views/navigation.js @@ -8,6 +8,7 @@ import { loadHomeData, loadAlbumRow, loadArtists, loadAlbums, loadGenreDetail } import { loadArtistDetail, loadArtistAllSongs, loadAlbumDetail, loadDeezerAlbumDetail, loadPartialAlbumDetail } from './detail.js'; import { loadPlaylists, loadPlaylistDetail, loadStarred, loadRandom } from './playlists.js'; import { loadNewReleases, loadRecommendations } from './discover.js'; +import { onHeaderSearch } from './search.js'; import { invalidateApiCache } from '../api.js'; // ─── NAVIGATION HISTORY ─── @@ -59,13 +60,39 @@ export function refreshCurrentView() { } export function navigate(page, data=null) { + // Scroll + search memory: remember where each history entry was scrolled AND any open local + // (header) search, so Back/Forward returns you to the same spot with the same query. The global + // search page keeps its query/results on its own (its DOM persists). A genuine new navigation + // still starts at the top with a clean header search. On a genuine nav we snapshot the OUTGOING + // view here (cursor still points at it); on Back/Forward the snapshot is taken in _navOnPopState. + const _main = document.getElementById('main-content'); + const _hsInput = document.getElementById('header-search-input'); + S._scrollByIdx = S._scrollByIdx || {}; + S._hsByIdx = S._hsByIdx || {}; + if(!S._navJumping) { + if(_main) S._scrollByIdx[S._navCursor] = _main.scrollTop; + S._hsByIdx[S._navCursor] = _hsInput?.value?.trim() ? _hsInput.value : ''; + } closeHeaderSearch(); + document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue')); const cls = ['discover','new-releases','recommendations'].includes(page) ? 'active-blue' : 'active'; document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls)); const pageEl = document.getElementById(`page-${page}`); - if(pageEl) { pageEl.classList.add('active'); document.getElementById('main-content').scrollTop = 0; } + if(pageEl) { + pageEl.classList.add('active'); + if(S._navJumping) { + // Back/Forward → restore this entry's saved scroll (rAF so the shown page has laid out) and + // re-open the local (header) search if one was active here. + const y = S._scrollByIdx[S._navCursor] || 0; + if(_main) requestAnimationFrame(() => { _main.scrollTop = y; }); + const hsq = S._hsByIdx[S._navCursor]; + if(hsq && _hsInput) { _hsInput.value = hsq; onHeaderSearch(hsq); } + } else if(_main) { + _main.scrollTop = 0; + } + } closeSidebar(); if(['discover','new-releases','recommendations','random'].includes(page)) switchNavMode('discover'); else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library'); @@ -133,6 +160,13 @@ export function _navOnPopState(state) { } const idx = (state && typeof state.nsIdx === 'number') ? state.nsIdx : 0; if(idx < 0 || idx >= S._navStack.length) return; + // Snapshot the OUTGOING view's scroll + any open local search before we move the cursor. + const _main = document.getElementById('main-content'); + const _hsInput = document.getElementById('header-search-input'); + S._scrollByIdx = S._scrollByIdx || {}; + S._hsByIdx = S._hsByIdx || {}; + if(_main) S._scrollByIdx[S._navCursor] = _main.scrollTop; + S._hsByIdx[S._navCursor] = _hsInput?.value?.trim() ? _hsInput.value : ''; S._navCursor = idx; const entry = S._navStack[idx]; _navSave();