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:
1 parent
3ceba8a365
commit
845f125d76
7 files changed
+64
-5
No files matched your search
@@ -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
|
- **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
|
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.
|
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**
|
- **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),
|
(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
|
two-way synced with the tab bar, matching the existing Erkunden swipe. On Now Playing the cover's
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ private fun LibrarySearchContent(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn {
|
LazyColumn(state = vm.listState) {
|
||||||
val artists = state.searchResult?.artist ?: emptyList()
|
val artists = state.searchResult?.artist ?: emptyList()
|
||||||
val albums = state.searchResult?.album ?: emptyList()
|
val albums = state.searchResult?.album ?: emptyList()
|
||||||
val songs = state.searchResult?.song ?: emptyList()
|
val songs = state.searchResult?.song ?: emptyList()
|
||||||
@@ -461,7 +461,7 @@ private fun GlobalExploreContent(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn {
|
LazyColumn(state = vm.searchListState) {
|
||||||
if (state.error != null) {
|
if (state.error != null) {
|
||||||
item { Text(state.error, color = MaterialTheme.colorScheme.error,
|
item { Text(state.error, color = MaterialTheme.colorScheme.error,
|
||||||
modifier = Modifier.padding(16.dp)) }
|
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) }
|
LaunchedEffect(state.albumSort) { albumGridState.scrollToItem(0) }
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
state = albumGridState,
|
state = albumGridState,
|
||||||
@@ -189,7 +189,7 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LibraryTab.ARTISTS -> {
|
LibraryTab.ARTISTS -> {
|
||||||
val listState = rememberLazyListState()
|
val listState = vm.artistsListState // hoisted → survives artist drill-in/back
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
|
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
|
||||||
// Map letter → flat LazyColumn index of its header item
|
// 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.ItunesItem
|
||||||
import com.novasonic.app.api.model.ItunesRssEntry
|
import com.novasonic.app.api.model.ItunesRssEntry
|
||||||
import com.novasonic.app.api.model.Song
|
import com.novasonic.app.api.model.Song
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import com.novasonic.app.api.model.artworkHd
|
import com.novasonic.app.api.model.artworkHd
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
import com.novasonic.app.data.NotificationStore
|
import com.novasonic.app.data.NotificationStore
|
||||||
@@ -76,6 +77,9 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
|
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
|
||||||
val state: StateFlow<ExploreUiState> = _state
|
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
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.novasonic.app.viewmodel
|
package com.novasonic.app.viewmodel
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.novasonic.app.api.model.Album
|
import com.novasonic.app.api.model.Album
|
||||||
@@ -34,6 +36,12 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
|
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
|
||||||
val state: StateFlow<LibraryUiState> = _state
|
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() }
|
init { loadPlaylists() }
|
||||||
|
|
||||||
fun setAlbumSort(sort: AlbumSort) {
|
fun setAlbumSort(sort: AlbumSort) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.novasonic.app.viewmodel
|
package com.novasonic.app.viewmodel
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.novasonic.app.api.model.ArtistOverride
|
import com.novasonic.app.api.model.ArtistOverride
|
||||||
@@ -28,6 +29,9 @@ class SearchViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
private val _state = MutableStateFlow(SearchUiState())
|
private val _state = MutableStateFlow(SearchUiState())
|
||||||
val state: StateFlow<SearchUiState> = _state
|
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
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|||||||
+35
-1
@@ -8,6 +8,7 @@ import { loadHomeData, loadAlbumRow, loadArtists, loadAlbums, loadGenreDetail }
|
|||||||
import { loadArtistDetail, loadArtistAllSongs, loadAlbumDetail, loadDeezerAlbumDetail, loadPartialAlbumDetail } from './detail.js';
|
import { loadArtistDetail, loadArtistAllSongs, loadAlbumDetail, loadDeezerAlbumDetail, loadPartialAlbumDetail } from './detail.js';
|
||||||
import { loadPlaylists, loadPlaylistDetail, loadStarred, loadRandom } from './playlists.js';
|
import { loadPlaylists, loadPlaylistDetail, loadStarred, loadRandom } from './playlists.js';
|
||||||
import { loadNewReleases, loadRecommendations } from './discover.js';
|
import { loadNewReleases, loadRecommendations } from './discover.js';
|
||||||
|
import { onHeaderSearch } from './search.js';
|
||||||
import { invalidateApiCache } from '../api.js';
|
import { invalidateApiCache } from '../api.js';
|
||||||
|
|
||||||
// ─── NAVIGATION HISTORY ───
|
// ─── NAVIGATION HISTORY ───
|
||||||
@@ -59,13 +60,39 @@ export function refreshCurrentView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function navigate(page, data=null) {
|
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();
|
closeHeaderSearch();
|
||||||
|
|
||||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue'));
|
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';
|
const cls = ['discover','new-releases','recommendations'].includes(page) ? 'active-blue' : 'active';
|
||||||
document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls));
|
document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls));
|
||||||
const pageEl = document.getElementById(`page-${page}`);
|
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();
|
closeSidebar();
|
||||||
if(['discover','new-releases','recommendations','random'].includes(page)) switchNavMode('discover');
|
if(['discover','new-releases','recommendations','random'].includes(page)) switchNavMode('discover');
|
||||||
else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library');
|
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;
|
const idx = (state && typeof state.nsIdx === 'number') ? state.nsIdx : 0;
|
||||||
if(idx < 0 || idx >= S._navStack.length) return;
|
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;
|
S._navCursor = idx;
|
||||||
const entry = S._navStack[idx];
|
const entry = S._navStack[idx];
|
||||||
_navSave();
|
_navSave();
|
||||||
|
|||||||
Reference in new issue
Block a user