From 098992639da668442faf4428ccaac50118414695 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 24 Jul 2026 03:22:12 -0400 Subject: [PATCH] fix slider not working correctly --- .../data/RoomLibraryRepositoryTest.kt | 68 ++++ .../data/AstraLibraryRepository.kt | 46 ++- .../data/CatalogDatabase.kt | 60 ++- .../astralibraryscanner/data/LibraryModels.kt | 6 +- src/app/(tabs)/library/index.tsx | 29 +- src/components/library/AlphabetRail.tsx | 11 +- src/stores/libraryStore.ts | 370 ++++++++++++++---- 7 files changed, 464 insertions(+), 126 deletions(-) diff --git a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt index 6de27bb..ff2984b 100644 --- a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt +++ b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt @@ -180,6 +180,74 @@ class RoomLibraryRepositoryTest { assertNull(boundedPlaybackWindowStart(0, 0)) } + @Test + fun trackSectionAnchorsOpenOnTheRequestedTitleAndArtistBuckets() = runBlocking { + publish( + "anchors", + listOf( + track("anchors", 0, "//.xX_-=-FLUTE==-Xx.\\\\"), + track("anchors", 1, "Apple").copy( + artist = "Amber", + artistSortKey = SortKeys.forText("Amber"), + ), + track("anchors", 2, "Saturn").copy( + artist = "Sade", + artistSortKey = SortKeys.forText("Sade"), + ), + track("anchors", 3, "Zulu").copy( + artist = "Zero 7", + artistSortKey = SortKeys.forText("Zero 7"), + ), + ), + ) + val dao = catalog.catalogDao() + + assertFalse(dao.getTitleSectionAnchors().any { it.sectionLabel == "X" }) + assertTrue(dao.getTitleSectionAnchors().any { it.sectionLabel == "#" }) + val titleAnchor = dao.getTitleSectionAnchors().single { it.sectionLabel == "S" } + val titlePage = dao.getTitlePage(titleAnchor.sortKey, "", 100) + assertEquals("Saturn", titlePage.first().title) + + val artistAnchor = dao.getArtistSectionAnchorCandidates() + .filter { SortKeys.sectionLabel(it.artist) == "S" } + .minOf(ArtistSectionAnchorCandidate::sortKey) + val artistPage = dao.getArtistOrderPage(artistAnchor, "", 0, 0, "", "", 100) + assertEquals("Sade", artistPage.first().artist) + } + + @Test + fun sectionLabelsUseOnlyTheFirstVisibleCharacter() { + assertEquals("#", SortKeys.sectionLabel("//.xX_-=-FLUTE==-Xx.\\\\")) + assertEquals("#", SortKeys.sectionLabel("! PARTY SIRENS !")) + assertEquals("#", SortKeys.sectionLabel("#iwannadance")) + assertEquals("#", SortKeys.sectionLabel("7 Rings")) + assertEquals("E", SortKeys.sectionLabel("Élan")) + assertEquals("S", SortKeys.sectionLabel(" Saturn")) + assertEquals("#", SortKeys.sectionLabel("東京の夜")) + } + + @Test + fun sectionLabelMigrationCorrectsExistingCatalogRows() = runBlocking { + val dao = catalog.catalogDao() + dao.insertMeta(CatalogMetaEntity(collationVersion = 1, updatedAt = 0)) + dao.putSource(CatalogSourceEntity("local:1", "local", 1, "legacy", 0)) + dao.insertGeneration(ScanGenerationEntity("legacy", "local:1", "active", 0)) + dao.putTracks( + listOf( + track("legacy", 1, "//.xX_-=-FLUTE==-Xx.\\\\").copy(sectionLabel = "X"), + track("legacy", 2, "Xylophone").copy(sectionLabel = "X"), + ), + ) + + dao.migrateSectionLabels(COLLATION_VERSION, 1) + + assertEquals(COLLATION_VERSION, dao.getMeta()?.collationVersion) + assertEquals( + mapOf("//.xX_-=-FLUTE==-Xx.\\\\" to "#", "Xylophone" to "X"), + dao.getActiveTracks(dao.getAllPathsByTitle()).associate { it.title to it.sectionLabel }, + ) + } + @Test fun userSnapshotsRotateRejectDamageAndRestoreTheNewestValidCopy() = runBlocking { val context = ApplicationProvider.getApplicationContext() diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt index 590f5e9..834b747 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt @@ -117,6 +117,7 @@ class AstraLibraryRepository private constructor( val catalogOpen = openCatalogDatabaseWithRecovery() catalogDatabase = catalogOpen val dao = catalogOpen.catalogDao() + val existingMeta = dao.getMeta() dao.insertMeta( CatalogMetaEntity( revision = 0, @@ -124,6 +125,9 @@ class AstraLibraryRepository private constructor( updatedAt = System.currentTimeMillis(), ), ) + if (existingMeta != null && existingMeta.collationVersion < COLLATION_VERSION) { + dao.migrateSectionLabels(COLLATION_VERSION, System.currentTimeMillis()) + } dao.discardAbandonedGenerations() reconcileUserFacts() @@ -2048,7 +2052,7 @@ class AstraLibraryRepository private constructor( "albums" -> { val rows = dao.getAllAlbumSummaries(revision).filter { includeSingles || !it.isSingle } rows.groupBy { row -> - if (sort == "artist") SortKeys.sectionLabel(row.artist) else row.sectionLabel + if (sort == "artist") SortKeys.sectionLabel(row.artist) else SortKeys.sectionLabel(row.album) }.map { (label, section) -> if (sort == "artist") { val first = section.minWith(compareBy({ it.artistSortKey }, { it.nameSortKey }, { it.identityKey })) @@ -2071,7 +2075,7 @@ class AstraLibraryRepository private constructor( val mode = if (groupingMode == "fileTags") "fileTags" else "astra" dao.getAllArtistSummaries(revision, mode) .filter { includeCollaborations || !it.isCollaboration } - .groupBy(ArtistSummaryEntity::sectionLabel) + .groupBy { row -> SortKeys.sectionLabel(row.artist) } .map { (label, section) -> val first = section.minWith(compareBy({ it.nameSortKey }, { it.artistKey })) label to TrackPageCursor( @@ -2082,31 +2086,25 @@ class AstraLibraryRepository private constructor( } } else -> { - dao.getAllActiveTracksForNativeMatching() - .groupBy { row -> - if (sort == "artist") SortKeys.sectionLabel(row.artist) else row.sectionLabel - } - .map { (label, section) -> - val first = if (sort == "artist") { - section.minWith( - compareBy( - { it.artistSortKey }, - { it.albumSortKey }, - { it.discSort }, - { it.trackSort }, - { it.titleSortKey }, - { it.path }, - ), + if (sort == "artist") { + dao.getArtistSectionAnchorCandidates() + .groupBy { candidate -> SortKeys.sectionLabel(candidate.artist) } + .map { (label, section) -> + label to TrackPageCursor( + revision, + "tracks:artist", + text1 = section.minOf(ArtistSectionAnchorCandidate::sortKey), ) - } else { - section.minWith(compareBy({ it.titleSortKey }, { it.path })) - } - label to if (sort == "artist") { - TrackPageCursor(revision, "tracks:artist", text1 = first.artistSortKey) - } else { - TrackPageCursor(revision, "tracks:title", text1 = first.titleSortKey) } + } else { + dao.getTitleSectionAnchors().map { row -> + row.sectionLabel to TrackPageCursor( + revision, + "tracks:title", + text1 = row.sortKey, + ) } + } } } anchors.sortedWith(compareBy> { it.second.text1 }.thenBy { it.first }) diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt index d69ed66..edef062 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt @@ -22,6 +22,17 @@ data class SectionAnchorRow( @androidx.room.ColumnInfo(name = "sort_key") val sortKey: String, ) +data class ArtistSectionAnchorCandidate( + val artist: String, + @androidx.room.ColumnInfo(name = "sort_key") val sortKey: String, +) + +data class TrackSectionLabelCandidate( + val id: Long, + val title: String, + @androidx.room.ColumnInfo(name = "section_label") val sectionLabel: String, +) + data class LibraryLoudnessStatsRow( val lufsCount: Long, val medianLufs: Double?, @@ -50,6 +61,16 @@ interface CatalogDao { @Query("SELECT revision FROM catalog_meta WHERE id = 1") suspend fun getRevision(): Long + @Query( + """ + UPDATE catalog_meta + SET collation_version = :version, + updated_at = :updatedAt + WHERE id = 1 + """, + ) + suspend fun setCollationVersion(version: Int, updatedAt: Long) + @Query("SELECT * FROM catalog_sources WHERE source_key = :sourceKey") suspend fun getSource(sourceKey: String): CatalogSourceEntity? @@ -471,13 +492,30 @@ interface CatalogDao { @Query( """ - SELECT section_label, MIN(artist_sort_key) AS sort_key + SELECT artist, MIN(artist_sort_key) AS sort_key FROM active_tracks - GROUP BY section_label + GROUP BY artist ORDER BY sort_key """, ) - suspend fun getArtistSectionAnchors(): List + suspend fun getArtistSectionAnchorCandidates(): List + + @Query( + """ + SELECT id, title, section_label + FROM tracks + WHERE id > :afterId + ORDER BY id + LIMIT :limit + """, + ) + suspend fun getTrackSectionLabelCandidates( + afterId: Long, + limit: Int, + ): List + + @Query("UPDATE tracks SET section_label = :sectionLabel WHERE id = :trackId") + suspend fun updateTrackSectionLabel(trackId: Long, sectionLabel: String) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun putAlbumSummaries(rows: List) @@ -1027,6 +1065,22 @@ interface CatalogDao { deleteAbandonedGenerationRecords() } + @Transaction + suspend fun migrateSectionLabels(version: Int, updatedAt: Long) { + var afterId = 0L + do { + val rows = getTrackSectionLabelCandidates(afterId, 1_000) + for (row in rows) { + val corrected = SortKeys.sectionLabel(row.title) + if (corrected != row.sectionLabel) { + updateTrackSectionLabel(row.id, corrected) + } + } + afterId = rows.lastOrNull()?.id ?: afterId + } while (rows.size == 1_000) + setCollationVersion(version, updatedAt) + } + @Transaction suspend fun publishGeneration( sourceKey: String, diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/LibraryModels.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/LibraryModels.kt index fd7ea23..27c2aaa 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/LibraryModels.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/LibraryModels.kt @@ -7,7 +7,7 @@ import java.util.Locale import org.json.JSONObject import org.json.JSONArray -const val COLLATION_VERSION = 1 +const val COLLATION_VERSION = 2 const val DEFAULT_PAGE_SIZE = 100 const val MAX_PAGE_SIZE = 200 @@ -59,9 +59,9 @@ object SortKeys { fun sectionLabel(value: String): String { val normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFD) - val first = normalized.firstOrNull { Character.isLetterOrDigit(it) } ?: return "#" + val first = normalized.firstOrNull() ?: return "#" val upper = first.uppercaseChar() - return if (upper in 'A'..'Z' || upper in '0'..'9') upper.toString() else "#" + return if (upper in 'A'..'Z') upper.toString() else "#" } } diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index 1992b76..2e62ab5 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, - useRef, useState } from 'react'; import { @@ -11,7 +10,7 @@ import { StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { FlashList, type FlashListRef } from '@shopify/flash-list'; +import { FlashList } from '@shopify/flash-list'; import { useRouter } from 'expo-router'; import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; @@ -62,10 +61,7 @@ import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort'; -import { RAIL_LETTERS } from '@/lib/letterIndex'; import type { - Album, - Artist, DbTrack } from '@/types/library'; @@ -92,6 +88,7 @@ export default function LibraryScreen() { const loadNextAlbums = useLibraryStore((s) => s.loadNextAlbums); const loadNextArtists = useLibraryStore((s) => s.loadNextArtists); const sectionAnchors = useLibraryStore((s) => s.sectionAnchors); + const sectionJumpRevision = useLibraryStore((s) => s.sectionJumpRevision); const jumpToSection = useLibraryStore((s) => s.jumpToSection); const isScanning = useLibraryStore((s) => s.isScanning); const scanError = useLibraryStore((s) => s.scanError); @@ -107,10 +104,6 @@ export default function LibraryScreen() { const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); const scrollTop = useScrollTopGate(); - const tracksListRef = useRef>(null); - const albumsListRef = useRef>(null); - const artistsListRef = useRef>(null); - const showLibraryStatus = totalTrackCount === 0 && !isScanning && @@ -141,16 +134,10 @@ export default function LibraryScreen() { ); const jumpToLetter = (letter: string) => { - const requestedIndex = RAIL_LETTERS.indexOf(letter); - const anchor = - sectionAnchors.find((entry) => entry.label === letter) ?? - sectionAnchors.find((entry) => RAIL_LETTERS.indexOf(entry.label) >= requestedIndex) ?? - sectionAnchors.at(-1); + const anchor = sectionAnchors.find((entry) => entry.label === letter); if (!anchor) return; - void jumpToSection(anchor.cursor).then(() => { - if (viewMode === 'tracks') tracksListRef.current?.scrollToOffset({ offset: 0, animated: false }); - else if (viewMode === 'albums') albumsListRef.current?.scrollToOffset({ offset: 0, animated: false }); - else if (viewMode === 'artists') artistsListRef.current?.scrollToOffset({ offset: 0, animated: false }); + void jumpToSection(anchor.cursor).then((applied) => { + if (applied) scrollTop.setScrollAtTop(true); }); }; @@ -301,7 +288,7 @@ export default function LibraryScreen() { {viewMode === 'albums' ? ( album.identity_key} @@ -330,7 +317,7 @@ export default function LibraryScreen() { {viewMode === 'artists' ? ( artist.artist} @@ -359,7 +346,7 @@ export default function LibraryScreen() { {viewMode === 'tracks' ? ( String(track.id)} showsVerticalScrollIndicator={false} diff --git a/src/components/library/AlphabetRail.tsx b/src/components/library/AlphabetRail.tsx index 30dfcb2..6c1cbdf 100644 --- a/src/components/library/AlphabetRail.tsx +++ b/src/components/library/AlphabetRail.tsx @@ -41,7 +41,12 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp const bubbleY = useSharedValue(0); const scrubTo = (letter: string) => { + if (!activeLetters.has(letter)) { + setScrubLetter(null); + return; + } setScrubLetter(letter); + playHaptic('frequentStep'); onJumpToLetter(letter); }; const endScrub = () => setScrubLetter(null); @@ -60,7 +65,6 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp ); const letter = RAIL_LETTERS[index]; lastLetter.value = letter; - runOnJS(playHaptic)('frequentStep'); runOnJS(scrubTo)(letter); }) .onUpdate((event) => { @@ -76,7 +80,6 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp const letter = RAIL_LETTERS[index]; if (letter === lastLetter.value) return; lastLetter.value = letter; - runOnJS(playHaptic)('frequentStep'); runOnJS(scrubTo)(letter); }) .onFinalize(() => { @@ -85,8 +88,8 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp runOnJS(endScrub)(); }); return pullSearchRef ? gesture.blocksExternalGesture(pullSearchRef) : gesture; - // eslint-disable-next-line react-hooks/exhaustive-deps -- scrubTo/endScrub capture the latest onJumpToLetter via render closure - }, [lastLetter, bubbleY, railTop, pullSearchRef, onJumpToLetter]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- scrubTo/endScrub capture the latest props via render closure + }, [lastLetter, bubbleY, railTop, pullSearchRef, activeLetters, onJumpToLetter]); const bubbleStyle = useAnimatedStyle(() => ({ transform: [{ translateY: bubbleY.value - BUBBLE_SIZE / 2 }], diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index e252d23..48940e0 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -87,13 +87,14 @@ interface LibraryStore { albumNextCursor: string | null; artistNextCursor: string | null; sectionAnchors: LibrarySectionAnchor[]; + sectionJumpRevision: number; initialize: () => Promise; refresh: () => Promise; loadNextTracks: () => Promise; loadNextAlbums: () => Promise; loadNextArtists: () => Promise; - jumpToSection: (cursor: string) => Promise; + jumpToSection: (cursor: string) => Promise; recordTrackPlayed: (path: string) => Promise; recomputeArtists: () => void; recomputeAlbums: () => void; @@ -122,51 +123,105 @@ function appendWindow( } export const useLibraryStore = create((set, get) => { + const pageGenerations = { + tracks: 0, + albums: 0, + artists: 0, + }; + let anchorGeneration = 0; + let loadingGeneration = 0; + + const beginLoading = () => { + const generation = ++loadingGeneration; + set({ isPageLoading: true }); + return generation; + }; + + const finishLoading = (generation: number) => { + if (generation === loadingGeneration) set({ isPageLoading: false }); + }; + const onProgress = (progress: ScanProgress) => { set({ scanProgress: progress }); void reportScanProgress(progress); }; - const readTrackPage = (cursor: string | null) => - AstraLibraryData.getTrackPage(get().trackSort, cursor, PAGE_SIZE); + const readTrackPage = ( + cursor: string | null, + sort = get().trackSort, + ) => AstraLibraryData.getTrackPage(sort, cursor, PAGE_SIZE); - const readAlbumPage = (cursor: string | null) => + const readAlbumPage = ( + cursor: string | null, + sort = get().albumSort, + includeSingles = useSettingsStore.getState().includeSingles, + ) => AstraLibraryData.getAlbumPage( - get().albumSort, - useSettingsStore.getState().includeSingles, + sort, + includeSingles, cursor, PAGE_SIZE ); - const readArtistPage = (cursor: string | null) => + const readArtistPage = ( + cursor: string | null, + sort = get().artistSort, + groupingMode = useSettingsStore.getState().artistGroupingMode, + includeCollaborations = get().includeCollabArtists, + ) => AstraLibraryData.getArtistPage( - get().artistSort, - useSettingsStore.getState().artistGroupingMode, - get().includeCollabArtists, + sort, + groupingMode, + includeCollaborations, cursor, PAGE_SIZE ); const resetTracks = async () => { - const page = await readTrackPage(null); + const sort = get().trackSort; + const generation = ++pageGenerations.tracks; + const page = await readTrackPage(null, sort); + if (generation !== pageGenerations.tracks || get().trackSort !== sort) return false; set({ tracks: page.items ?? [], trackNextCursor: page.nextCursor ?? null, totalTrackCount: page.totalCount ?? 0, }); + return true; }; const resetAlbums = async () => { - const page = await readAlbumPage(null); + const sort = get().albumSort; + const includeSingles = useSettingsStore.getState().includeSingles; + const generation = ++pageGenerations.albums; + const page = await readAlbumPage(null, sort, includeSingles); + if ( + generation !== pageGenerations.albums || + get().albumSort !== sort || + useSettingsStore.getState().includeSingles !== includeSingles + ) return false; set({ albums: page.items ?? [], albumNextCursor: page.nextCursor ?? null }); + return true; }; const resetArtists = async () => { - const page = await readArtistPage(null); + const sort = get().artistSort; + const groupingMode = useSettingsStore.getState().artistGroupingMode; + const includeCollaborations = get().includeCollabArtists; + const generation = ++pageGenerations.artists; + const page = await readArtistPage(null, sort, groupingMode, includeCollaborations); + if ( + generation !== pageGenerations.artists || + get().artistSort !== sort || + useSettingsStore.getState().artistGroupingMode !== groupingMode || + get().includeCollabArtists !== includeCollaborations + ) return false; set({ artists: page.items ?? [], artistNextCursor: page.nextCursor ?? null }); + return true; }; const resetSectionAnchors = async () => { + const generation = ++anchorGeneration; const state = get(); const sortable = (state.viewMode === 'tracks' && (state.trackSort === 'artist' || state.trackSort === 'title')) || @@ -182,15 +237,31 @@ export const useLibraryStore = create((set, get) => { : state.viewMode === 'albums' ? state.albumSort as 'artist' | 'name' : 'name'; - set({ - sectionAnchors: await AstraLibraryData.getSectionAnchors( - state.viewMode as 'tracks' | 'albums' | 'artists', - sort, - useSettingsStore.getState().includeSingles, - useSettingsStore.getState().artistGroupingMode, - state.includeCollabArtists - ), - }); + const includeSingles = useSettingsStore.getState().includeSingles; + const groupingMode = useSettingsStore.getState().artistGroupingMode; + const anchors = await AstraLibraryData.getSectionAnchors( + state.viewMode as 'tracks' | 'albums' | 'artists', + sort, + includeSingles, + groupingMode, + state.includeCollabArtists + ); + const current = get(); + const currentSort = + current.viewMode === 'tracks' + ? current.trackSort + : current.viewMode === 'albums' + ? current.albumSort + : current.artistSort; + if ( + generation !== anchorGeneration || + current.viewMode !== state.viewMode || + currentSort !== sort || + useSettingsStore.getState().includeSingles !== includeSingles || + useSettingsStore.getState().artistGroupingMode !== groupingMode || + current.includeCollabArtists !== state.includeCollabArtists + ) return; + set({ sectionAnchors: anchors }); }; const runScan = async (scan: () => Promise) => { @@ -235,6 +306,7 @@ export const useLibraryStore = create((set, get) => { albumNextCursor: null, artistNextCursor: null, sectionAnchors: [], + sectionJumpRevision: 0, initialize: () => { if (!initPromise) { @@ -282,14 +354,20 @@ export const useLibraryStore = create((set, get) => { } }); AstraLibraryData.addListener('onCatalogChanged', () => { + anchorGeneration += 1; + set({ sectionAnchors: [] }); void get().refresh(); }); useSettingsStore.subscribe((next, previous) => { if (next.artistGroupingMode !== previous.artistGroupingMode) { + anchorGeneration += 1; + set({ sectionAnchors: [] }); void resetArtists(); void resetSectionAnchors(); } if (next.includeSingles !== previous.includeSingles) { + anchorGeneration += 1; + set({ sectionAnchors: [] }); void resetAlbums(); void resetSectionAnchors(); } @@ -310,7 +388,22 @@ export const useLibraryStore = create((set, get) => { }, refresh: async () => { - const viewMode = get().viewMode; + const stateAtStart = get(); + const viewMode = stateAtStart.viewMode; + const trackSort = stateAtStart.trackSort; + const albumSort = stateAtStart.albumSort; + const artistSort = stateAtStart.artistSort; + const includeSingles = useSettingsStore.getState().includeSingles; + const groupingMode = useSettingsStore.getState().artistGroupingMode; + const includeCollaborations = stateAtStart.includeCollabArtists; + const activeGeneration = + viewMode === 'tracks' + ? ++pageGenerations.tracks + : viewMode === 'albums' + ? ++pageGenerations.albums + : viewMode === 'artists' + ? ++pageGenerations.artists + : null; const [ trackPage, albumPage, @@ -320,36 +413,59 @@ export const useLibraryStore = create((set, get) => { folders, recentlyPlayedTracks, ] = await Promise.all([ - viewMode === 'tracks' ? readTrackPage(null) : Promise.resolve(null), - viewMode === 'albums' ? readAlbumPage(null) : Promise.resolve(null), - viewMode === 'artists' ? readArtistPage(null) : Promise.resolve(null), + viewMode === 'tracks' ? readTrackPage(null, trackSort) : Promise.resolve(null), + viewMode === 'albums' + ? readAlbumPage(null, albumSort, includeSingles) + : Promise.resolve(null), + viewMode === 'artists' + ? readArtistPage(null, artistSort, groupingMode, includeCollaborations) + : Promise.resolve(null), AstraLibraryData.getAlbumPage( 'recently_added', - useSettingsStore.getState().includeSingles, + includeSingles, null, 20 ), AstraLibraryData.getArtistPage( 'name', - useSettingsStore.getState().artistGroupingMode, - get().includeCollabArtists, + groupingMode, + includeCollaborations, null, 50 ), loadFolders(), AstraLibraryData.getRecentlyPlayed(20), ]); + const current = get(); + const canApplyTrackPage = + viewMode === 'tracks' && + current.viewMode === 'tracks' && + current.trackSort === trackSort && + activeGeneration === pageGenerations.tracks; + const canApplyAlbumPage = + viewMode === 'albums' && + current.viewMode === 'albums' && + current.albumSort === albumSort && + useSettingsStore.getState().includeSingles === includeSingles && + activeGeneration === pageGenerations.albums; + const canApplyArtistPage = + viewMode === 'artists' && + current.viewMode === 'artists' && + current.artistSort === artistSort && + useSettingsStore.getState().artistGroupingMode === groupingMode && + current.includeCollabArtists === includeCollaborations && + activeGeneration === pageGenerations.artists; set({ - ...(trackPage ? { + ...(trackPage && canApplyTrackPage ? { tracks: trackPage.items ?? [], trackNextCursor: trackPage.nextCursor ?? null, - totalTrackCount: trackPage.totalCount ?? get().totalTrackCount, + totalTrackCount: trackPage.totalCount ?? current.totalTrackCount, } : {}), - ...(albumPage ? { + ...(albumPage && canApplyAlbumPage ? { albums: albumPage.items ?? [], albumNextCursor: albumPage.nextCursor ?? null, } : {}), - ...(artistPage ? { + ...(artistPage && canApplyArtistPage ? { artists: artistPage.items ?? [], artistNextCursor: artistPage.nextCursor ?? null, } : {}), @@ -362,76 +478,178 @@ export const useLibraryStore = create((set, get) => { }, loadNextTracks: async () => { - const cursor = get().trackNextCursor; - if (!cursor || get().isPageLoading) return; - set({ isPageLoading: true }); + const state = get(); + const cursor = state.trackNextCursor; + if (!cursor || state.isPageLoading) return; + const sort = state.trackSort; + const pageGeneration = pageGenerations.tracks; + const loading = beginLoading(); try { - const page = await readTrackPage(cursor); - if (page.error === 'STALE_REVISION') return resetTracks(); + const page = await readTrackPage(cursor, sort); + if ( + pageGeneration !== pageGenerations.tracks || + get().trackSort !== sort || + get().trackNextCursor !== cursor + ) return; + if (page.error === 'STALE_REVISION') { + await resetTracks(); + return; + } set((state) => ({ tracks: appendWindow(state.tracks, page.items, (track) => track.path), trackNextCursor: page.nextCursor, })); } finally { - set({ isPageLoading: false }); + finishLoading(loading); } }, loadNextAlbums: async () => { - const cursor = get().albumNextCursor; - if (!cursor || get().isPageLoading) return; - set({ isPageLoading: true }); + const state = get(); + const cursor = state.albumNextCursor; + if (!cursor || state.isPageLoading) return; + const sort = state.albumSort; + const includeSingles = useSettingsStore.getState().includeSingles; + const pageGeneration = pageGenerations.albums; + const loading = beginLoading(); try { - const page = await readAlbumPage(cursor); - if (page.error === 'STALE_REVISION') return resetAlbums(); + const page = await readAlbumPage(cursor, sort, includeSingles); + if ( + pageGeneration !== pageGenerations.albums || + get().albumSort !== sort || + useSettingsStore.getState().includeSingles !== includeSingles || + get().albumNextCursor !== cursor + ) return; + if (page.error === 'STALE_REVISION') { + await resetAlbums(); + return; + } set((state) => ({ albums: appendWindow(state.albums, page.items, (album) => album.identity_key), albumNextCursor: page.nextCursor, })); } finally { - set({ isPageLoading: false }); + finishLoading(loading); } }, loadNextArtists: async () => { - const cursor = get().artistNextCursor; - if (!cursor || get().isPageLoading) return; - set({ isPageLoading: true }); + const state = get(); + const cursor = state.artistNextCursor; + if (!cursor || state.isPageLoading) return; + const sort = state.artistSort; + const groupingMode = useSettingsStore.getState().artistGroupingMode; + const includeCollaborations = state.includeCollabArtists; + const pageGeneration = pageGenerations.artists; + const loading = beginLoading(); try { - const page = await readArtistPage(cursor); - if (page.error === 'STALE_REVISION') return resetArtists(); + const page = await readArtistPage(cursor, sort, groupingMode, includeCollaborations); + if ( + pageGeneration !== pageGenerations.artists || + get().artistSort !== sort || + useSettingsStore.getState().artistGroupingMode !== groupingMode || + get().includeCollabArtists !== includeCollaborations || + get().artistNextCursor !== cursor + ) return; + if (page.error === 'STALE_REVISION') { + await resetArtists(); + return; + } set((state) => ({ artists: appendWindow(state.artists, page.items, (artist) => artist.artist), artistNextCursor: page.nextCursor, })); } finally { - set({ isPageLoading: false }); + finishLoading(loading); } }, jumpToSection: async (cursor) => { const state = get(); - set({ isPageLoading: true }); + const viewMode = state.viewMode; + if (viewMode !== 'tracks' && viewMode !== 'albums' && viewMode !== 'artists') return false; + const generation = ++pageGenerations[viewMode]; + const loading = beginLoading(); try { - if (state.viewMode === 'tracks') { - const page = await readTrackPage(cursor); - if (page.error === 'STALE_REVISION') return resetTracks(); - set({ + if (viewMode === 'tracks') { + const sort = state.trackSort; + const page = await readTrackPage(cursor, sort); + if ( + generation !== pageGenerations.tracks || + get().viewMode !== viewMode || + get().trackSort !== sort + ) return false; + if (page.error === 'STALE_REVISION') { + await resetTracks(); + return false; + } + if (page.items.length === 0) { + void resetSectionAnchors(); + return false; + } + set((current) => ({ tracks: page.items, trackNextCursor: page.nextCursor, totalTrackCount: page.totalCount, - }); - } else if (state.viewMode === 'albums') { - const page = await readAlbumPage(cursor); - if (page.error === 'STALE_REVISION') return resetAlbums(); - set({ albums: page.items, albumNextCursor: page.nextCursor }); - } else if (state.viewMode === 'artists') { - const page = await readArtistPage(cursor); - if (page.error === 'STALE_REVISION') return resetArtists(); - set({ artists: page.items, artistNextCursor: page.nextCursor }); + sectionJumpRevision: current.sectionJumpRevision + 1, + })); + } else if (viewMode === 'albums') { + const sort = state.albumSort; + const includeSingles = useSettingsStore.getState().includeSingles; + const page = await readAlbumPage(cursor, sort, includeSingles); + if ( + generation !== pageGenerations.albums || + get().viewMode !== viewMode || + get().albumSort !== sort || + useSettingsStore.getState().includeSingles !== includeSingles + ) return false; + if (page.error === 'STALE_REVISION') { + await resetAlbums(); + return false; + } + if (page.items.length === 0) { + void resetSectionAnchors(); + return false; + } + set((current) => ({ + albums: page.items, + albumNextCursor: page.nextCursor, + sectionJumpRevision: current.sectionJumpRevision + 1, + })); + } else { + const sort = state.artistSort; + const groupingMode = useSettingsStore.getState().artistGroupingMode; + const includeCollaborations = state.includeCollabArtists; + const page = await readArtistPage( + cursor, + sort, + groupingMode, + includeCollaborations, + ); + if ( + generation !== pageGenerations.artists || + get().viewMode !== viewMode || + get().artistSort !== sort || + useSettingsStore.getState().artistGroupingMode !== groupingMode || + get().includeCollabArtists !== includeCollaborations + ) return false; + if (page.error === 'STALE_REVISION') { + await resetArtists(); + return false; + } + if (page.items.length === 0) { + void resetSectionAnchors(); + return false; + } + set((current) => ({ + artists: page.items, + artistNextCursor: page.nextCursor, + sectionJumpRevision: current.sectionJumpRevision + 1, + })); } + return true; } finally { - set({ isPageLoading: false }); + finishLoading(loading); } }, @@ -449,7 +667,8 @@ export const useLibraryStore = create((set, get) => { }, setViewMode: (viewMode) => { - set({ viewMode }); + anchorGeneration += 1; + set({ viewMode, sectionAnchors: [] }); persistSetting(VIEW_MODE_KEY, viewMode); if (viewMode === 'tracks' && get().tracks.length === 0) void resetTracks(); if (viewMode === 'albums' && get().albums.length === 0) void resetAlbums(); @@ -458,28 +677,37 @@ export const useLibraryStore = create((set, get) => { }, setTrackSort: (trackSort) => { - set({ trackSort, tracks: [], trackNextCursor: null }); + anchorGeneration += 1; + set({ trackSort, tracks: [], trackNextCursor: null, sectionAnchors: [] }); persistSetting(TRACK_SORT_KEY, trackSort); void resetTracks(); void resetSectionAnchors(); }, setAlbumSort: (albumSort) => { - set({ albumSort, albums: [], albumNextCursor: null }); + anchorGeneration += 1; + set({ albumSort, albums: [], albumNextCursor: null, sectionAnchors: [] }); persistSetting(ALBUM_SORT_KEY, albumSort); void resetAlbums(); void resetSectionAnchors(); }, setArtistSort: (artistSort) => { - set({ artistSort, artists: [], artistNextCursor: null }); + anchorGeneration += 1; + set({ artistSort, artists: [], artistNextCursor: null, sectionAnchors: [] }); persistSetting(ARTIST_SORT_KEY, artistSort); void resetArtists(); void resetSectionAnchors(); }, setIncludeCollabArtists: (includeCollabArtists) => { - set({ includeCollabArtists, artists: [], artistNextCursor: null }); + anchorGeneration += 1; + set({ + includeCollabArtists, + artists: [], + artistNextCursor: null, + sectionAnchors: [], + }); persistSetting(INCLUDE_COLLAB_ARTISTS_KEY, includeCollabArtists ? 'true' : 'false'); void resetArtists(); void resetSectionAnchors();