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 ff2984b..f7e8996 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 @@ -381,6 +381,95 @@ class RoomLibraryRepositoryTest { assertTrue(first.map { it.path }.intersect(second.map { it.path }.toSet()).isEmpty()) } + /** + * Walking backwards has to reproduce the forward ordering exactly — an off-by-one in + * the reversed tie-break tuple would either duplicate a row or silently skip one when + * the list refills upwards after an A-Z jump. + */ + @Test + fun backwardTitlePagesMirrorForwardPages() = runBlocking { + publish("g1", seedAlphabet()) + val dao = catalog.catalogDao() + + val forward = mutableListOf() + var afterKey: String? = null + var afterPath = "" + while (true) { + val page = dao.getTitlePage(afterKey, afterPath, 40) + if (page.isEmpty()) break + forward += page + afterKey = page.last().titleSortKey + afterPath = page.last().path + } + assertEquals(ALPHABET_SEED_SIZE, forward.size) + + val backward = mutableListOf() + var beforeKey = forward.last().titleSortKey + var beforePath = forward.last().path + while (true) { + val page = dao.getTitlePageBefore(beforeKey, beforePath, 40) + if (page.isEmpty()) break + backward += page + beforeKey = page.last().titleSortKey + beforePath = page.last().path + } + // Backward pages are exclusive of the anchor row and come back descending. + assertEquals( + forward.dropLast(1).map { it.path }.reversed(), + backward.map { it.path }, + ) + // Nothing above the very first row. + assertTrue(dao.getTitlePageBefore(forward.first().titleSortKey, forward.first().path, 40).isEmpty()) + } + + /** + * The exact shape a rail jump uses: a section anchor carries only the leading sort key + * with an empty path tie-break, so the forward page starts at the letter and the + * backward page must be the slice immediately above it — no overlap, no gap. + */ + @Test + fun sectionAnchorSplitsCatalogWithoutOverlapOrGap() = runBlocking { + publish("g1", seedAlphabet()) + val dao = catalog.catalogDao() + + val all = mutableListOf() + var afterKey: String? = null + var afterPath = "" + while (true) { + val page = dao.getTitlePage(afterKey, afterPath, 100) + if (page.isEmpty()) break + all += page + afterKey = page.last().titleSortKey + afterPath = page.last().path + } + + val anchor = dao.getTitleSectionAnchors().first { it.sectionLabel == "F" } + val at = dao.getTitlePage(anchor.sortKey, "", 40) + val above = dao.getTitlePageBefore(anchor.sortKey, "", 40) + + assertEquals("F", SortKeys.sectionLabel(at.first().title)) + assertTrue(above.all { SortKeys.sectionLabel(it.title) != "F" }) + + val anchorIndex = all.indexOfFirst { it.path == at.first().path } + assertEquals(40, above.size) + assertEquals( + all.subList(anchorIndex - above.size, anchorIndex).map { it.path }, + above.reversed().map { it.path }, + ) + } + + /** 10 tracks under each of A-Z, so every section has rows above and below it. */ + private fun seedAlphabet(): List = + (0 until ALPHABET_SEED_SIZE).map { index -> + val letter = 'A' + (index / 10) + track( + generation = "g1", + index = index, + title = "$letter${"%03d".format(index)} Song", + path = "content://track/$index.flac", + ) + } + private suspend fun publish( generation: String, tracks: List, @@ -442,4 +531,9 @@ class RoomLibraryRepositoryTest { trackSort = index, sectionLabel = SortKeys.sectionLabel(title), ) + + private companion object { + /** 26 letters x 10 tracks. */ + const val ALPHABET_SEED_SIZE = 260 + } } diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt index 6217266..8280476 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt @@ -90,6 +90,18 @@ class AstraLibraryDataModule : Module() { } } + AsyncFunction("getTrackPageBefore") Coroutine { + sort: String, + cursor: String?, + limit: Int, + -> + try { + repository().getTrackPageBefore(sort, cursor, limit) + } catch (_: StaleRevisionException) { + mapOf("error" to "STALE_REVISION") + } + } + AsyncFunction("getTrack") Coroutine { path: String -> repository().getTrack(path) } @@ -367,6 +379,19 @@ class AstraLibraryDataModule : Module() { } } + AsyncFunction("getAlbumPageBefore") Coroutine { + sort: String, + includeSingles: Boolean, + cursor: String?, + limit: Int, + -> + try { + repository().getAlbumPageBefore(sort, includeSingles, cursor, limit) + } catch (_: StaleRevisionException) { + mapOf("error" to "STALE_REVISION") + } + } + AsyncFunction("getArtistPage") Coroutine { sort: String, groupingMode: String, @@ -381,6 +406,20 @@ class AstraLibraryDataModule : Module() { } } + AsyncFunction("getArtistPageBefore") Coroutine { + sort: String, + groupingMode: String, + includeCollaborations: Boolean, + cursor: String?, + limit: Int, + -> + try { + repository().getArtistPageBefore(sort, groupingMode, includeCollaborations, cursor, limit) + } catch (_: StaleRevisionException) { + mapOf("error" to "STALE_REVISION") + } + } + AsyncFunction("getAlbumDetail") Coroutine { albumKey: String, cursor: String?, limit: Int -> try { repository().getAlbumDetail(albumKey, cursor, limit) 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 834b747..4b407a9 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 @@ -596,39 +596,7 @@ class AstraLibraryRepository private constructor( limit = limit, ) } - val next = rows.lastOrNull()?.let { row -> - when (sort) { - "artist" -> TrackPageCursor( - revision = revision, - kind = "tracks:$sort", - text1 = row.artistSortKey, - text2 = row.albumSortKey, - text3 = "${row.titleSortKey}\u0000${row.path}", - number1 = row.discSort.toLong(), - number2 = row.trackSort.toLong(), - // Artist cursor needs one extra string. Encode the path alongside the - // title key with a NUL delimiter; SAF paths cannot contain NUL. - ) - "recently_added" -> TrackPageCursor( - revision = revision, - kind = "tracks:$sort", - text1 = row.path, - number1 = row.addedAt, - ) - "duration" -> TrackPageCursor( - revision = revision, - kind = "tracks:$sort", - text1 = row.path, - decimal1 = row.duration, - ) - else -> TrackPageCursor( - revision = revision, - kind = "tracks:$sort", - text1 = row.titleSortKey, - text2 = row.path, - ) - }.encode() - } + val next = rows.lastOrNull()?.let { row -> trackCursor(revision, sort, row).encode() } mapOf( "items" to rows.map(ActiveTrackView::toBridgeMap), "nextCursor" to next, @@ -638,6 +606,93 @@ class AstraLibraryRepository private constructor( ) } + /** + * The page immediately *above* [cursorRaw] — the backward twin of [getTrackPage]. + * An A-Z rail jump drops the list into the middle of the catalog; this is what the + * rows above it are fetched with when the user scrolls back up. Items come back + * ascending, like every other page. + * + * `previousCursor` is non-null only on a full page: a short one means we walked off + * the head of the catalog, so the caller knows to stop asking. + * + * Only the sorts the rail is offered for (`title`, `artist`) can be walked backwards; + * anything else returns an empty page. + */ + suspend fun getTrackPageBefore( + sort: String, + cursorRaw: String?, + requestedLimit: Int, + ): Map = withCatalogRecovery { database -> + initialize() + val dao = database.catalogDao() + val revision = dao.getRevision() + val cursor = validateCursor(cursorRaw, revision, "tracks:$sort") + val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) + val descending = when { + cursor == null -> emptyList() + sort == "artist" -> dao.getArtistOrderPageBefore( + beforeArtistKey = cursor.text1.orEmpty(), + beforeAlbumKey = cursor.text2.orEmpty(), + beforeDisc = cursor.number1?.toInt() ?: 0, + beforeTrack = cursor.number2?.toInt() ?: 0, + beforeTitleKey = cursorTitleKey(cursor), + beforePath = cursorPath(cursor), + limit = limit, + ) + sort == "title" -> dao.getTitlePageBefore( + beforeTitleKey = cursor.text1.orEmpty(), + beforePath = cursor.text2.orEmpty(), + limit = limit, + ) + else -> emptyList() + } + // The DESC result's last row is the topmost one — the cursor for the page above this one. + val previous = descending.takeIf { it.size == limit } + ?.lastOrNull() + ?.let { row -> trackCursor(revision, sort, row).encode() } + mapOf( + "items" to descending.reversed().map(ActiveTrackView::toBridgeMap), + "nextCursor" to null, + "previousCursor" to previous, + "totalCount" to dao.countActiveTracks().toDouble(), + "catalogRevision" to revision.toString(), + ) + } + + /** Shared by the forward and backward track pages so the two can never disagree. */ + private fun trackCursor(revision: Long, sort: String, row: ActiveTrackView): TrackPageCursor = + when (sort) { + "artist" -> TrackPageCursor( + revision = revision, + kind = "tracks:$sort", + text1 = row.artistSortKey, + text2 = row.albumSortKey, + text3 = "${row.titleSortKey}\u0000${row.path}", + number1 = row.discSort.toLong(), + number2 = row.trackSort.toLong(), + // Artist cursor needs one extra string. Encode the path alongside the + // title key with a NUL delimiter; SAF paths cannot contain NUL. + ) + "recently_added" -> TrackPageCursor( + revision = revision, + kind = "tracks:$sort", + text1 = row.path, + number1 = row.addedAt, + ) + "duration" -> TrackPageCursor( + revision = revision, + kind = "tracks:$sort", + text1 = row.path, + decimal1 = row.duration, + ) + else -> TrackPageCursor( + revision = revision, + kind = "tracks:$sort", + text1 = row.titleSortKey, + text2 = row.path, + ) + } + suspend fun getTrack(path: String): Map? = withCatalogRecovery { database -> database.catalogDao().getActiveTrack(path)?.toBridgeMap() } @@ -1674,36 +1729,7 @@ class AstraLibraryRepository private constructor( limit, ) } - val next = rows.lastOrNull()?.let { row -> - when (sort) { - "artist" -> TrackPageCursor( - revision, - kind, - text1 = row.artistSortKey, - text2 = row.nameSortKey, - text3 = row.identityKey, - ) - "recently_added" -> TrackPageCursor( - revision, - kind, - text1 = row.identityKey, - number1 = row.latestAddedAt, - ) - "year" -> TrackPageCursor( - revision, - kind, - text1 = row.nameSortKey, - text2 = row.identityKey, - number1 = (row.year ?: 0).toLong(), - ) - else -> TrackPageCursor( - revision, - kind, - text1 = row.nameSortKey, - text2 = row.identityKey, - ) - }.encode() - } + val next = rows.lastOrNull()?.let { row -> albumCursor(revision, kind, sort, row).encode() } mapOf( "items" to rows.map(AlbumSummaryEntity::toBridgeMap), "nextCursor" to next, @@ -1713,6 +1739,85 @@ class AstraLibraryRepository private constructor( ) } + /** Backward twin of [getAlbumPage]; see [getTrackPageBefore] for the contract. */ + suspend fun getAlbumPageBefore( + sort: String, + includeSingles: Boolean, + cursorRaw: String?, + requestedLimit: Int, + ): Map = withCatalogRecovery { database -> + val dao = database.catalogDao() + val revision = dao.getRevision() + val kind = "albums:$sort:${if (includeSingles) 1 else 0}" + val cursor = validateCursor(cursorRaw, revision, kind) + val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) + val descending = when { + cursor == null -> emptyList() + sort == "artist" -> dao.getAlbumArtistPageBefore( + revision, + includeSingles, + cursor.text1.orEmpty(), + cursor.text2.orEmpty(), + cursor.text3.orEmpty(), + limit, + ) + sort == "name" -> dao.getAlbumNamePageBefore( + revision, + includeSingles, + cursor.text1.orEmpty(), + cursor.text2.orEmpty(), + limit, + ) + else -> emptyList() + } + val previous = descending.takeIf { it.size == limit } + ?.lastOrNull() + ?.let { row -> albumCursor(revision, kind, sort, row).encode() } + mapOf( + "items" to descending.reversed().map(AlbumSummaryEntity::toBridgeMap), + "nextCursor" to null, + "previousCursor" to previous, + "totalCount" to dao.countAlbums(revision, includeSingles).toDouble(), + "catalogRevision" to revision.toString(), + ) + } + + /** Shared by the forward and backward album pages so the two can never disagree. */ + private fun albumCursor( + revision: Long, + kind: String, + sort: String, + row: AlbumSummaryEntity, + ): TrackPageCursor = + when (sort) { + "artist" -> TrackPageCursor( + revision, + kind, + text1 = row.artistSortKey, + text2 = row.nameSortKey, + text3 = row.identityKey, + ) + "recently_added" -> TrackPageCursor( + revision, + kind, + text1 = row.identityKey, + number1 = row.latestAddedAt, + ) + "year" -> TrackPageCursor( + revision, + kind, + text1 = row.nameSortKey, + text2 = row.identityKey, + number1 = (row.year ?: 0).toLong(), + ) + else -> TrackPageCursor( + revision, + kind, + text1 = row.nameSortKey, + text2 = row.identityKey, + ) + } + suspend fun getArtistPage( sort: String, groupingMode: String, @@ -1746,15 +1851,7 @@ class AstraLibraryRepository private constructor( limit, ) } - val next = rows.lastOrNull()?.let { row -> - TrackPageCursor( - revision, - kind, - text1 = row.nameSortKey, - text2 = row.artistKey, - number1 = if (sort == "track_count") row.trackCount else null, - ).encode() - } + val next = rows.lastOrNull()?.let { row -> artistCursor(revision, kind, sort, row).encode() } mapOf( "items" to rows.map(ArtistSummaryEntity::toBridgeMap), "nextCursor" to next, @@ -1764,6 +1861,59 @@ class AstraLibraryRepository private constructor( ) } + /** Backward twin of [getArtistPage]; see [getTrackPageBefore] for the contract. */ + suspend fun getArtistPageBefore( + sort: String, + groupingMode: String, + includeCollaborations: Boolean, + cursorRaw: String?, + requestedLimit: Int, + ): Map = withCatalogRecovery { database -> + val dao = database.catalogDao() + val revision = dao.getRevision() + val mode = if (groupingMode == "fileTags") "fileTags" else "astra" + val kind = "artists:$sort:$mode:${if (includeCollaborations) 1 else 0}" + val cursor = validateCursor(cursorRaw, revision, kind) + val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) + val descending = if (cursor == null || sort == "track_count") { + emptyList() + } else { + dao.getArtistNamePageBefore( + revision, + mode, + includeCollaborations, + cursor.text1.orEmpty(), + cursor.text2.orEmpty(), + limit, + ) + } + val previous = descending.takeIf { it.size == limit } + ?.lastOrNull() + ?.let { row -> artistCursor(revision, kind, sort, row).encode() } + mapOf( + "items" to descending.reversed().map(ArtistSummaryEntity::toBridgeMap), + "nextCursor" to null, + "previousCursor" to previous, + "totalCount" to dao.countArtists(revision, mode, includeCollaborations).toDouble(), + "catalogRevision" to revision.toString(), + ) + } + + /** Shared by the forward and backward artist pages so the two can never disagree. */ + private fun artistCursor( + revision: Long, + kind: String, + sort: String, + row: ArtistSummaryEntity, + ): TrackPageCursor = + TrackPageCursor( + revision, + kind, + text1 = row.nameSortKey, + text2 = row.artistKey, + number1 = if (sort == "track_count") row.trackCount else null, + ) + suspend fun getAlbumDetail( albumKey: String, cursorRaw: String?, 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 edef062..9fb1dd8 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 @@ -360,6 +360,26 @@ interface CatalogDao { limit: Int, ): List + /** + * Mirror of [getTitlePage] walking backwards. Rows come out DESC — the caller + * reverses them so `items` is ascending like every other page. Backward paging + * always has a cursor (there is nothing before the head), so no NULL branch. + */ + @Query( + """ + SELECT * FROM active_tracks + WHERE title_sort_key < :beforeTitleKey + OR (title_sort_key = :beforeTitleKey AND path < :beforePath) + ORDER BY title_sort_key DESC, path DESC + LIMIT :limit + """, + ) + suspend fun getTitlePageBefore( + beforeTitleKey: String, + beforePath: String, + limit: Int, + ): List + @Query( """ SELECT * FROM active_tracks @@ -387,6 +407,34 @@ interface CatalogDao { limit: Int, ): List + /** Mirror of [getArtistOrderPage] walking backwards; rows come out DESC. */ + @Query( + """ + SELECT * FROM active_tracks + WHERE artist_sort_key < :beforeArtistKey + OR (artist_sort_key = :beforeArtistKey AND album_sort_key < :beforeAlbumKey) + OR (artist_sort_key = :beforeArtistKey AND album_sort_key = :beforeAlbumKey AND disc_sort < :beforeDisc) + OR (artist_sort_key = :beforeArtistKey AND album_sort_key = :beforeAlbumKey AND disc_sort = :beforeDisc + AND track_sort < :beforeTrack) + OR (artist_sort_key = :beforeArtistKey AND album_sort_key = :beforeAlbumKey AND disc_sort = :beforeDisc + AND track_sort = :beforeTrack AND title_sort_key < :beforeTitleKey) + OR (artist_sort_key = :beforeArtistKey AND album_sort_key = :beforeAlbumKey AND disc_sort = :beforeDisc + AND track_sort = :beforeTrack AND title_sort_key = :beforeTitleKey AND path < :beforePath) + ORDER BY artist_sort_key DESC, album_sort_key DESC, disc_sort DESC, track_sort DESC, + title_sort_key DESC, path DESC + LIMIT :limit + """, + ) + suspend fun getArtistOrderPageBefore( + beforeArtistKey: String, + beforeAlbumKey: String, + beforeDisc: Int, + beforeTrack: Int, + beforeTitleKey: String, + beforePath: String, + limit: Int, + ): List + @Query( """ SELECT * FROM active_tracks @@ -543,6 +591,26 @@ interface CatalogDao { limit: Int, ): List + /** Mirror of [getAlbumNamePage] walking backwards; rows come out DESC. */ + @Query( + """ + SELECT * FROM album_summaries + WHERE revision = :revision + AND (:includeSingles OR is_single = 0) + AND (name_sort_key < :beforeKey + OR (name_sort_key = :beforeKey AND identity_key < :beforeId)) + ORDER BY name_sort_key DESC, identity_key DESC + LIMIT :limit + """, + ) + suspend fun getAlbumNamePageBefore( + revision: Long, + includeSingles: Boolean, + beforeKey: String, + beforeId: String, + limit: Int, + ): List + @Query( """ SELECT * FROM album_summaries @@ -566,6 +634,29 @@ interface CatalogDao { limit: Int, ): List + /** Mirror of [getAlbumArtistPage] walking backwards; rows come out DESC. */ + @Query( + """ + SELECT * FROM album_summaries + WHERE revision = :revision + AND (:includeSingles OR is_single = 0) + AND (artist_sort_key < :beforeArtistKey + OR (artist_sort_key = :beforeArtistKey AND name_sort_key < :beforeNameKey) + OR (artist_sort_key = :beforeArtistKey AND name_sort_key = :beforeNameKey + AND identity_key < :beforeId)) + ORDER BY artist_sort_key DESC, name_sort_key DESC, identity_key DESC + LIMIT :limit + """, + ) + suspend fun getAlbumArtistPageBefore( + revision: Long, + includeSingles: Boolean, + beforeArtistKey: String, + beforeNameKey: String, + beforeId: String, + limit: Int, + ): List + @Query( """ SELECT * FROM album_summaries @@ -652,6 +743,28 @@ interface CatalogDao { limit: Int, ): List + /** Mirror of [getArtistNamePage] walking backwards; rows come out DESC. */ + @Query( + """ + SELECT * FROM artist_summaries + WHERE revision = :revision + AND grouping_mode = :groupingMode + AND (:includeCollaborations OR is_collaboration = 0) + AND (name_sort_key < :beforeKey + OR (name_sort_key = :beforeKey AND artist_key < :beforeId)) + ORDER BY name_sort_key DESC, artist_key DESC + LIMIT :limit + """, + ) + suspend fun getArtistNamePageBefore( + revision: Long, + groupingMode: String, + includeCollaborations: Boolean, + beforeKey: String, + beforeId: String, + limit: Int, + ): List + @Query( """ SELECT * FROM artist_summaries diff --git a/modules/astra-library-scanner/index.ts b/modules/astra-library-scanner/index.ts index 9a6dad3..708e78f 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -288,6 +288,16 @@ declare class AstraLibraryDataModuleType extends NativeModule>; + /** + * The page immediately above `cursor` — how the lists refill upwards after an A-Z + * jump lands mid-catalog. Items come back ascending; `previousCursor` is null once + * there is nothing left above. Only the rail's sorts can be walked backwards. + */ + getTrackPageBefore( + sort: 'artist' | 'title', + cursor: string | null, + limit: number + ): Promise>; getTrack(path: string): Promise; getTrackLoudness(paths: string[]): Promise; setTrackLoudness(path: string, lufs: number | null, samplePeak: number | null): Promise; @@ -402,6 +412,13 @@ declare class AstraLibraryDataModuleType extends NativeModule>; + /** Backward twin of `getAlbumPage`; see `getTrackPageBefore`. */ + getAlbumPageBefore( + sort: 'artist' | 'name', + includeSingles: boolean, + cursor: string | null, + limit: number + ): Promise>; getArtistPage( sort: 'name' | 'track_count', groupingMode: 'astra' | 'fileTags', @@ -409,6 +426,14 @@ declare class AstraLibraryDataModuleType extends NativeModule>; + /** Backward twin of `getArtistPage`; see `getTrackPageBefore`. */ + getArtistPageBefore( + sort: 'name', + groupingMode: 'astra' | 'fileTags', + includeCollaborations: boolean, + cursor: string | null, + limit: number + ): Promise>; getAlbumDetail>( albumKey: string, cursor: string | null, diff --git a/src/app/(tabs)/library/album/[key].tsx b/src/app/(tabs)/library/album/[key].tsx index 792b233..dc5e286 100644 --- a/src/app/(tabs)/library/album/[key].tsx +++ b/src/app/(tabs)/library/album/[key].tsx @@ -112,7 +112,7 @@ export default function AlbumScreen() { onScroll={onScroll} scrollEventThrottle={scrollEventThrottle} onEndReached={() => void loadMore()} - onEndReachedThreshold={0.6} + onEndReachedThreshold={2} contentContainerStyle={{ paddingTop: insets.top + expandedHeight, paddingHorizontal: spacing.lg, diff --git a/src/app/(tabs)/library/artist/[name].tsx b/src/app/(tabs)/library/artist/[name].tsx index cfc051c..39909c3 100644 --- a/src/app/(tabs)/library/artist/[name].tsx +++ b/src/app/(tabs)/library/artist/[name].tsx @@ -221,6 +221,16 @@ export default function ArtistScreen() { renderItem={renderItem} onScroll={onScroll} scrollEventThrottle={scrollEventThrottle} + // This screen composes four paged sources into one list and had no paging + // trigger at all, so anything past the first page was unreachable. Each + // loadMore no-ops once its own cursor runs out. + onEndReached={() => { + void allPage.loadMore(); + void songsPage.loadMore(); + void appearancesPage.loadMore(); + void albumsPage.loadMore(); + }} + onEndReachedThreshold={2} contentContainerStyle={{ paddingTop: insets.top + expandedHeight, paddingHorizontal: spacing.lg, diff --git a/src/app/(tabs)/library/artist/[name]/albums.tsx b/src/app/(tabs)/library/artist/[name]/albums.tsx index 8b1e775..4dcc639 100644 --- a/src/app/(tabs)/library/artist/[name]/albums.tsx +++ b/src/app/(tabs)/library/artist/[name]/albums.tsx @@ -51,7 +51,7 @@ export default function ArtistAlbumsScreen() { keyExtractor={(album) => album.identity_key} showsVerticalScrollIndicator={false} onEndReached={() => void page.loadMore()} - onEndReachedThreshold={0.6} + onEndReachedThreshold={2} renderItem={({ item }) => ( String(track.id)} showsVerticalScrollIndicator={false} onEndReached={() => void loadMore()} - onEndReachedThreshold={0.6} + onEndReachedThreshold={2} renderItem={({ item, index }) => ( String(track.id)} showsVerticalScrollIndicator={false} onEndReached={() => void loadMore()} - onEndReachedThreshold={0.6} + onEndReachedThreshold={2} renderItem={({ item, index }) => ( s.loadNextTracks); const loadNextAlbums = useLibraryStore((s) => s.loadNextAlbums); const loadNextArtists = useLibraryStore((s) => s.loadNextArtists); + const loadPreviousTracks = useLibraryStore((s) => s.loadPreviousTracks); + const loadPreviousAlbums = useLibraryStore((s) => s.loadPreviousAlbums); + const loadPreviousArtists = useLibraryStore((s) => s.loadPreviousArtists); + const trackNextCursor = useLibraryStore((s) => s.trackNextCursor); + const albumNextCursor = useLibraryStore((s) => s.albumNextCursor); + const artistNextCursor = useLibraryStore((s) => s.artistNextCursor); const sectionAnchors = useLibraryStore((s) => s.sectionAnchors); const sectionJumpRevision = useLibraryStore((s) => s.sectionJumpRevision); + const jumpAnchorIndex = useLibraryStore((s) => s.jumpAnchorIndex); const jumpToSection = useLibraryStore((s) => s.jumpToSection); const isScanning = useLibraryStore((s) => s.isScanning); const scanError = useLibraryStore((s) => s.scanError); @@ -127,19 +145,64 @@ export default function LibraryScreen() { }; const openSearch = () => openQuickSearch(); + // Sits at the end of the loaded window whenever more pages exist, so a fling that + // outruns the loader stops on a spinner rather than on what looks like the end of + // the list. Gated on the cursor rather than on an in-flight flag so it does not + // blink between pages. + const listFooter = useMemo( + () => ( + + + + ), + [colors.textTertiary] + ); + const railVisible = sectionAnchors.length > 1; const railLetters = useMemo( () => new Set(sectionAnchors.map((entry) => entry.label)), [sectionAnchors] ); - const jumpToLetter = (letter: string) => { - const anchor = sectionAnchors.find((entry) => entry.label === letter); + // A jump refills the window and remounts the list, so firing one per letter crossed + // made a fast scrub ~27 rebuilds. The bubble and haptic still track every letter + // (they live in the rail); only the jump itself waits for the finger to settle, and + // lifting off flushes it immediately. + const pendingJump = useRef<{ letter: string; timer: ReturnType } | null>(null); + + const runJump = useCallback((letter: string) => { + const anchor = useLibraryStore.getState().sectionAnchors.find((entry) => entry.label === letter); if (!anchor) return; void jumpToSection(anchor.cursor).then((applied) => { - if (applied) scrollTop.setScrollAtTop(true); + // A jump usually lands with a page of rows above it, so the list is not at true + // top and pull-to-search must stay disarmed. + if (applied) scrollTop.setScrollAtTop(useLibraryStore.getState().jumpAnchorIndex === 0); }); - }; + }, [jumpToSection, scrollTop]); + + const jumpToLetter = useCallback((letter: string) => { + if (pendingJump.current) clearTimeout(pendingJump.current.timer); + pendingJump.current = { + letter, + timer: setTimeout(() => { + pendingJump.current = null; + runJump(letter); + }, JUMP_DEBOUNCE_MS), + }; + }, [runJump]); + + const flushJump = useCallback(() => { + const pending = pendingJump.current; + if (!pending) return; + clearTimeout(pending.timer); + pendingJump.current = null; + runJump(pending.letter); + }, [runJump]); + + useEffect(() => () => { + if (pendingJump.current) clearTimeout(pendingJump.current.timer); + pendingJump.current = null; + }, []); // Multi-select (tracks view): long-press arms it, batch actions live in the // bottom bar, selection order follows the current display order. @@ -303,8 +366,12 @@ export default function LibraryScreen() { renderScrollComponent={PullSearchScrollView} onScroll={scrollTop.onScroll} scrollEventThrottle={scrollTop.scrollEventThrottle} + initialScrollIndex={jumpAnchorIndex} onEndReached={() => void loadNextAlbums()} - onEndReachedThreshold={0.6} + onEndReachedThreshold={END_REACHED_THRESHOLD} + onStartReached={() => void loadPreviousAlbums()} + onStartReachedThreshold={START_REACHED_THRESHOLD} + ListFooterComponent={albumNextCursor ? listFooter : null} renderItem={({ item }) => ( void loadNextArtists()} - onEndReachedThreshold={0.6} + onEndReachedThreshold={END_REACHED_THRESHOLD} + onStartReached={() => void loadPreviousArtists()} + onStartReachedThreshold={START_REACHED_THRESHOLD} + ListFooterComponent={artistNextCursor ? listFooter : null} renderItem={({ item }) => ( void loadNextTracks()} - onEndReachedThreshold={0.6} + onEndReachedThreshold={END_REACHED_THRESHOLD} + onStartReached={() => void loadPreviousTracks()} + onStartReachedThreshold={START_REACHED_THRESHOLD} + ListFooterComponent={trackNextCursor ? listFooter : null} extraData={selectMode ? selectedIds : undefined} renderItem={({ item, index }) => ( + ) : null} @@ -481,4 +560,8 @@ const styles = StyleSheet.create({ listArea: { flex: 1, }, + listFooter: { + paddingVertical: spacing.lg, + alignItems: 'center', + }, }); diff --git a/src/app/(tabs)/library/playlist/[id].tsx b/src/app/(tabs)/library/playlist/[id].tsx index 6fc6335..6505705 100644 --- a/src/app/(tabs)/library/playlist/[id].tsx +++ b/src/app/(tabs)/library/playlist/[id].tsx @@ -284,7 +284,7 @@ export default function PlaylistScreen() { onEndReached={() => { if (!isFavorites) void loadNextEntries(); }} - onEndReachedThreshold={0.6} + onEndReachedThreshold={2} contentContainerStyle={{ paddingTop: insets.top + expandedHeight, paddingHorizontal: spacing.lg, diff --git a/src/components/library/AlphabetRail.tsx b/src/components/library/AlphabetRail.tsx index 6c1cbdf..293bd2c 100644 --- a/src/components/library/AlphabetRail.tsx +++ b/src/components/library/AlphabetRail.tsx @@ -20,6 +20,12 @@ interface AlphabetRailProps { /** Letters present in the current list — the rest render dimmed. */ activeLetters: ReadonlySet; onJumpToLetter: (letter: string) => void; + /** + * Fired when the finger lifts. The screen debounces `onJumpToLetter` so a fast + * scrub does not rebuild the list once per letter crossed; this is its cue to + * commit the last letter immediately instead of waiting out the debounce. + */ + onScrubEnd?: () => void; } /** @@ -30,7 +36,7 @@ interface AlphabetRailProps { * changes on a letter-cross). Blocks the pull-to-search gesture so a scrub at * scroll-top never arms the search indicator. */ -export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProps) { +export function AlphabetRail({ activeLetters, onJumpToLetter, onScrubEnd }: AlphabetRailProps) { const styles = useStyles(); const pullSearchRef = usePullSearchGestureRef(); const [scrubLetter, setScrubLetter] = useState(null); @@ -49,7 +55,10 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp playHaptic('frequentStep'); onJumpToLetter(letter); }; - const endScrub = () => setScrubLetter(null); + const endScrub = () => { + setScrubLetter(null); + onScrubEnd?.(); + }; const pan = useMemo(() => { const gesture = Gesture.Pan() @@ -89,7 +98,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp }); return pullSearchRef ? gesture.blocksExternalGesture(pullSearchRef) : gesture; // eslint-disable-next-line react-hooks/exhaustive-deps -- scrubTo/endScrub capture the latest props via render closure - }, [lastLetter, bubbleY, railTop, pullSearchRef, activeLetters, onJumpToLetter]); + }, [lastLetter, bubbleY, railTop, pullSearchRef, activeLetters, onJumpToLetter, onScrubEnd]); const bubbleStyle = useAnimatedStyle(() => ({ transform: [{ translateY: bubbleY.value - BUBBLE_SIZE / 2 }], diff --git a/src/library/nativePages.ts b/src/library/nativePages.ts index 3376161..a72386e 100644 --- a/src/library/nativePages.ts +++ b/src/library/nativePages.ts @@ -4,8 +4,7 @@ import { normalizeKey } from '@/shared/library/albumGrouping'; import type { ArtistGroupingMode } from '@/library/artistGrouping'; import type { Album, Artist, DbTrack } from '@/types/library'; -const DETAIL_PAGE_SIZE = 100; -const MAX_DETAIL_ITEMS = 500; +const DETAIL_PAGE_SIZE = 200; export type NativeAlbumSummary = Album & { total_duration?: number }; @@ -17,12 +16,12 @@ interface PagedDetail { loadMore: () => Promise; } +// Grows without a cap: trimming the head shrank content height mid-scroll, which +// read as the list snapping to the bottom, and these lists have no way to page back +// upwards. See the same note on appendWindow in libraryStore. function appendTracks(current: DbTrack[], incoming: DbTrack[]): DbTrack[] { const paths = new Set(current.map((track) => track.path)); - const merged = [...current, ...incoming.filter((track) => !paths.has(track.path))]; - return merged.length > MAX_DETAIL_ITEMS - ? merged.slice(merged.length - MAX_DETAIL_ITEMS) - : merged; + return [...current, ...incoming.filter((track) => !paths.has(track.path))]; } export function useNativeAlbumDetail(albumKey: string): PagedDetail { @@ -191,8 +190,7 @@ export function useNativeArtistAlbums( ); setItems((current) => { const known = new Set(current.map((album) => album.identity_key)); - const merged = [...current, ...page.items.filter((album) => !known.has(album.identity_key))]; - return merged.slice(-MAX_DETAIL_ITEMS); + return [...current, ...page.items.filter((album) => !known.has(album.identity_key))]; }); setTotalCount(page.totalCount); setNextOffset(page.nextOffset); diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index 48940e0..c50ec74 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -26,8 +26,7 @@ const TRACK_SORT_KEY = 'library_track_sort'; const ALBUM_SORT_KEY = 'library_album_sort'; const ARTIST_SORT_KEY = 'library_artist_sort'; const INCLUDE_COLLAB_ARTISTS_KEY = 'library_include_collab_artists'; -const PAGE_SIZE = 100; -const MAX_WINDOW_ITEMS = PAGE_SIZE * 5; +const PAGE_SIZE = 200; const VIEW_MODES: readonly ViewMode[] = ['tracks', 'albums', 'artists', 'playlists', 'folders']; @@ -80,20 +79,32 @@ interface LibraryStore { artistSort: ArtistSort; includeCollabArtists: boolean; isScanning: boolean; - isPageLoading: boolean; scanProgress: ScanProgressState; scanError: string | null; trackNextCursor: string | null; albumNextCursor: string | null; artistNextCursor: string | null; + /** Non-null only after a rail jump left rows above the loaded window. */ + trackPrevCursor: string | null; + albumPrevCursor: string | null; + artistPrevCursor: string | null; sectionAnchors: LibrarySectionAnchor[]; sectionJumpRevision: number; + /** + * Where the jumped-to letter sits in the freshly built window — fed to the list's + * `initialScrollIndex` on the remount that `sectionJumpRevision` triggers, so the + * letter lands at the top with a page of rows already above it to scroll back into. + */ + jumpAnchorIndex: number; initialize: () => Promise; refresh: () => Promise; loadNextTracks: () => Promise; loadNextAlbums: () => Promise; loadNextArtists: () => Promise; + loadPreviousTracks: () => Promise; + loadPreviousAlbums: () => Promise; + loadPreviousArtists: () => Promise; jumpToSection: (cursor: string) => Promise; recordTrackPlayed: (path: string) => Promise; recomputeArtists: () => void; @@ -112,14 +123,44 @@ interface LibraryStore { let initPromise: Promise | null = null; let nativeSubscriptionsInstalled = false; +// These grow without a cap on purpose. A sliding window that dropped items off the +// head shrank the content height mid-scroll, which read as the list flinging itself +// to the bottom (worst on the 3-column grids, where dropping a page is not a whole +// number of rows), and left rows above unreachable. Worst case is the whole catalog +// in memory — what the pre-Room build did unconditionally — and only for a list you +// actually scrolled end to end. function appendWindow( current: T[], incoming: T[], key: (item: T) => string ): T[] { const known = new Set(current.map(key)); - const merged = [...current, ...incoming.filter((item) => !known.has(key(item)))]; - return merged.length > MAX_WINDOW_ITEMS ? merged.slice(merged.length - MAX_WINDOW_ITEMS) : merged; + return [...current, ...incoming.filter((item) => !known.has(key(item)))]; +} + +function prependWindow( + current: T[], + incoming: T[], + key: (item: T) => string +): T[] { + const known = new Set(current.map(key)); + return [...incoming.filter((item) => !known.has(key(item))), ...current]; +} + +/** + * Dropping back to page 1 under a live scroll offset leaves the list looking at an + * offset past the new content end, which lands the user at the bottom of a list they + * were reading the middle of. Bumping the revision remounts it (the revision is part + * of the list's key) so it comes back at the top instead. + */ +function remountIfShorter( + current: { sectionJumpRevision: number }, + previousLength: number, + nextLength: number +): { sectionJumpRevision: number } | Record { + return previousLength > nextLength + ? { sectionJumpRevision: current.sectionJumpRevision + 1 } + : {}; } export const useLibraryStore = create((set, get) => { @@ -129,17 +170,15 @@ export const useLibraryStore = create((set, get) => { 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 }); - }; + // Re-entrancy guards, per list and per direction, so a backward refill, a forward + // page and a different view's load never block one another — the single shared flag + // this replaced serialised all three lists and made outrunning the loader likelier. + // A jump needs no guard: it bumps the list's generation, which voids anything already + // in flight. + type ListKey = 'tracks' | 'albums' | 'artists'; + const forwardBusy: Record = { tracks: false, albums: false, artists: false }; + const backwardBusy: Record = { tracks: false, albums: false, artists: false }; const onProgress = (progress: ScanProgress) => { set({ scanProgress: progress }); @@ -177,16 +216,52 @@ export const useLibraryStore = create((set, get) => { PAGE_SIZE ); + // Backward readers only exist for the sorts the A-Z rail is offered for, which are + // the only sorts a jump can leave rows above the window in. + const readTrackPageBefore = ( + cursor: string, + sort: 'artist' | 'title', + ) => AstraLibraryData.getTrackPageBefore(sort, cursor, PAGE_SIZE); + + const readAlbumPageBefore = ( + cursor: string, + sort: 'artist' | 'name', + includeSingles = useSettingsStore.getState().includeSingles, + ) => AstraLibraryData.getAlbumPageBefore(sort, includeSingles, cursor, PAGE_SIZE); + + const readArtistPageBefore = ( + cursor: string, + groupingMode = useSettingsStore.getState().artistGroupingMode, + includeCollaborations = get().includeCollabArtists, + ) => + AstraLibraryData.getArtistPageBefore( + 'name', + groupingMode, + includeCollaborations, + cursor, + PAGE_SIZE + ); + + const backwardTrackSort = (sort: TrackSort): 'artist' | 'title' | null => + sort === 'artist' || sort === 'title' ? sort : null; + + const backwardAlbumSort = (sort: AlbumSort): 'artist' | 'name' | null => + sort === 'artist' || sort === 'name' ? sort : null; + const resetTracks = async () => { 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 ?? [], + const items = page.items ?? []; + set((current) => ({ + tracks: items, trackNextCursor: page.nextCursor ?? null, + trackPrevCursor: null, totalTrackCount: page.totalCount ?? 0, - }); + jumpAnchorIndex: 0, + ...remountIfShorter(current, current.tracks.length, items.length), + })); return true; }; @@ -200,7 +275,14 @@ export const useLibraryStore = create((set, get) => { get().albumSort !== sort || useSettingsStore.getState().includeSingles !== includeSingles ) return false; - set({ albums: page.items ?? [], albumNextCursor: page.nextCursor ?? null }); + const items = page.items ?? []; + set((current) => ({ + albums: items, + albumNextCursor: page.nextCursor ?? null, + albumPrevCursor: null, + jumpAnchorIndex: 0, + ...remountIfShorter(current, current.albums.length, items.length), + })); return true; }; @@ -216,7 +298,14 @@ export const useLibraryStore = create((set, get) => { useSettingsStore.getState().artistGroupingMode !== groupingMode || get().includeCollabArtists !== includeCollaborations ) return false; - set({ artists: page.items ?? [], artistNextCursor: page.nextCursor ?? null }); + const items = page.items ?? []; + set((current) => ({ + artists: items, + artistNextCursor: page.nextCursor ?? null, + artistPrevCursor: null, + jumpAnchorIndex: 0, + ...remountIfShorter(current, current.artists.length, items.length), + })); return true; }; @@ -299,14 +388,17 @@ export const useLibraryStore = create((set, get) => { artistSort: 'name', includeCollabArtists: false, isScanning: false, - isPageLoading: false, scanProgress: { ...IDLE_PROGRESS }, scanError: null, trackNextCursor: null, albumNextCursor: null, artistNextCursor: null, + trackPrevCursor: null, + albumPrevCursor: null, + artistPrevCursor: null, sectionAnchors: [], sectionJumpRevision: 0, + jumpAnchorIndex: 0, initialize: () => { if (!initPromise) { @@ -455,20 +547,30 @@ export const useLibraryStore = create((set, get) => { useSettingsStore.getState().artistGroupingMode === groupingMode && current.includeCollabArtists === includeCollaborations && activeGeneration === pageGenerations.artists; + const collapsesWindow = + (!!trackPage && canApplyTrackPage && current.tracks.length > (trackPage.items?.length ?? 0)) || + (!!albumPage && canApplyAlbumPage && current.albums.length > (albumPage.items?.length ?? 0)) || + (!!artistPage && canApplyArtistPage && current.artists.length > (artistPage.items?.length ?? 0)); set({ ...(trackPage && canApplyTrackPage ? { tracks: trackPage.items ?? [], trackNextCursor: trackPage.nextCursor ?? null, + trackPrevCursor: null, totalTrackCount: trackPage.totalCount ?? current.totalTrackCount, } : {}), ...(albumPage && canApplyAlbumPage ? { albums: albumPage.items ?? [], albumNextCursor: albumPage.nextCursor ?? null, + albumPrevCursor: null, } : {}), ...(artistPage && canApplyArtistPage ? { artists: artistPage.items ?? [], artistNextCursor: artistPage.nextCursor ?? null, + artistPrevCursor: null, } : {}), + ...(collapsesWindow + ? { jumpAnchorIndex: 0, sectionJumpRevision: current.sectionJumpRevision + 1 } + : {}), homeAlbums: homeAlbumPage.items ?? [], homeArtists: homeArtistPage.items ?? [], folders, @@ -480,10 +582,10 @@ export const useLibraryStore = create((set, get) => { loadNextTracks: async () => { const state = get(); const cursor = state.trackNextCursor; - if (!cursor || state.isPageLoading) return; + if (!cursor || forwardBusy.tracks) return; const sort = state.trackSort; const pageGeneration = pageGenerations.tracks; - const loading = beginLoading(); + forwardBusy.tracks = true; try { const page = await readTrackPage(cursor, sort); if ( @@ -500,18 +602,18 @@ export const useLibraryStore = create((set, get) => { trackNextCursor: page.nextCursor, })); } finally { - finishLoading(loading); + forwardBusy.tracks = false; } }, loadNextAlbums: async () => { const state = get(); const cursor = state.albumNextCursor; - if (!cursor || state.isPageLoading) return; + if (!cursor || forwardBusy.albums) return; const sort = state.albumSort; const includeSingles = useSettingsStore.getState().includeSingles; const pageGeneration = pageGenerations.albums; - const loading = beginLoading(); + forwardBusy.albums = true; try { const page = await readAlbumPage(cursor, sort, includeSingles); if ( @@ -529,19 +631,19 @@ export const useLibraryStore = create((set, get) => { albumNextCursor: page.nextCursor, })); } finally { - finishLoading(loading); + forwardBusy.albums = false; } }, loadNextArtists: async () => { const state = get(); const cursor = state.artistNextCursor; - if (!cursor || state.isPageLoading) return; + if (!cursor || forwardBusy.artists) return; const sort = state.artistSort; const groupingMode = useSettingsStore.getState().artistGroupingMode; const includeCollaborations = state.includeCollabArtists; const pageGeneration = pageGenerations.artists; - const loading = beginLoading(); + forwardBusy.artists = true; try { const page = await readArtistPage(cursor, sort, groupingMode, includeCollaborations); if ( @@ -560,97 +662,202 @@ export const useLibraryStore = create((set, get) => { artistNextCursor: page.nextCursor, })); } finally { - finishLoading(loading); + forwardBusy.artists = false; } }, + loadPreviousTracks: async () => { + const state = get(); + const cursor = state.trackPrevCursor; + const sort = backwardTrackSort(state.trackSort); + if (!cursor || !sort || backwardBusy.tracks) return; + const pageGeneration = pageGenerations.tracks; + backwardBusy.tracks = true; + try { + const page = await readTrackPageBefore(cursor, sort); + if ( + pageGeneration !== pageGenerations.tracks || + get().trackSort !== sort || + get().trackPrevCursor !== cursor + ) return; + if (page.error === 'STALE_REVISION') { + await resetTracks(); + return; + } + set((current) => ({ + tracks: prependWindow(current.tracks, page.items, (track) => track.path), + trackPrevCursor: page.previousCursor, + })); + } finally { + backwardBusy.tracks = false; + } + }, + + loadPreviousAlbums: async () => { + const state = get(); + const cursor = state.albumPrevCursor; + const sort = backwardAlbumSort(state.albumSort); + if (!cursor || !sort || backwardBusy.albums) return; + const includeSingles = useSettingsStore.getState().includeSingles; + const pageGeneration = pageGenerations.albums; + backwardBusy.albums = true; + try { + const page = await readAlbumPageBefore(cursor, sort, includeSingles); + if ( + pageGeneration !== pageGenerations.albums || + get().albumSort !== sort || + useSettingsStore.getState().includeSingles !== includeSingles || + get().albumPrevCursor !== cursor + ) return; + if (page.error === 'STALE_REVISION') { + await resetAlbums(); + return; + } + set((current) => ({ + albums: prependWindow(current.albums, page.items, (album) => album.identity_key), + albumPrevCursor: page.previousCursor, + })); + } finally { + backwardBusy.albums = false; + } + }, + + loadPreviousArtists: async () => { + const state = get(); + const cursor = state.artistPrevCursor; + if (!cursor || state.artistSort !== 'name' || backwardBusy.artists) return; + const groupingMode = useSettingsStore.getState().artistGroupingMode; + const includeCollaborations = state.includeCollabArtists; + const pageGeneration = pageGenerations.artists; + backwardBusy.artists = true; + try { + const page = await readArtistPageBefore(cursor, groupingMode, includeCollaborations); + if ( + pageGeneration !== pageGenerations.artists || + get().artistSort !== 'name' || + useSettingsStore.getState().artistGroupingMode !== groupingMode || + get().includeCollabArtists !== includeCollaborations || + get().artistPrevCursor !== cursor + ) return; + if (page.error === 'STALE_REVISION') { + await resetArtists(); + return; + } + set((current) => ({ + artists: prependWindow(current.artists, page.items, (artist) => artist.artist), + artistPrevCursor: page.previousCursor, + })); + } finally { + backwardBusy.artists = false; + } + }, + + // A jump rebuilds the window around the letter: the letter's own page, plus the + // page immediately above it. Without that page above, the list would remount with + // the letter as row 0 and there would be nothing to scroll back up into — and + // `onStartReached` would fire the moment the list mounted at offset 0, cascading + // backwards to the head of the catalog. jumpToSection: async (cursor) => { const state = get(); const viewMode = state.viewMode; if (viewMode !== 'tracks' && viewMode !== 'albums' && viewMode !== 'artists') return false; const generation = ++pageGenerations[viewMode]; - const loading = beginLoading(); - try { - 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, - 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, - })); + if (viewMode === 'tracks') { + const sort = state.trackSort; + const backwardSort = backwardTrackSort(sort); + const [page, before] = await Promise.all([ + readTrackPage(cursor, sort), + backwardSort ? readTrackPageBefore(cursor, backwardSort) : Promise.resolve(null), + ]); + if ( + generation !== pageGenerations.tracks || + get().viewMode !== viewMode || + get().trackSort !== sort + ) return false; + if (page.error === 'STALE_REVISION') { + await resetTracks(); + return false; } - return true; - } finally { - finishLoading(loading); + if (page.items.length === 0) { + void resetSectionAnchors(); + return false; + } + const above = before && !before.error ? before : null; + set((current) => ({ + tracks: [...(above?.items ?? []), ...page.items], + trackNextCursor: page.nextCursor, + trackPrevCursor: above?.previousCursor ?? null, + totalTrackCount: page.totalCount, + jumpAnchorIndex: above?.items.length ?? 0, + sectionJumpRevision: current.sectionJumpRevision + 1, + })); + } else if (viewMode === 'albums') { + const sort = state.albumSort; + const includeSingles = useSettingsStore.getState().includeSingles; + const backwardSort = backwardAlbumSort(sort); + const [page, before] = await Promise.all([ + readAlbumPage(cursor, sort, includeSingles), + backwardSort + ? readAlbumPageBefore(cursor, backwardSort, includeSingles) + : Promise.resolve(null), + ]); + 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; + } + const above = before && !before.error ? before : null; + set((current) => ({ + albums: [...(above?.items ?? []), ...page.items], + albumNextCursor: page.nextCursor, + albumPrevCursor: above?.previousCursor ?? null, + jumpAnchorIndex: above?.items.length ?? 0, + sectionJumpRevision: current.sectionJumpRevision + 1, + })); + } else { + const sort = state.artistSort; + const groupingMode = useSettingsStore.getState().artistGroupingMode; + const includeCollaborations = state.includeCollabArtists; + const [page, before] = await Promise.all([ + readArtistPage(cursor, sort, groupingMode, includeCollaborations), + sort === 'name' + ? readArtistPageBefore(cursor, groupingMode, includeCollaborations) + : Promise.resolve(null), + ]); + 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; + } + const above = before && !before.error ? before : null; + set((current) => ({ + artists: [...(above?.items ?? []), ...page.items], + artistNextCursor: page.nextCursor, + artistPrevCursor: above?.previousCursor ?? null, + jumpAnchorIndex: above?.items.length ?? 0, + sectionJumpRevision: current.sectionJumpRevision + 1, + })); } + return true; }, recordTrackPlayed: async (path) => { @@ -678,7 +885,14 @@ export const useLibraryStore = create((set, get) => { setTrackSort: (trackSort) => { anchorGeneration += 1; - set({ trackSort, tracks: [], trackNextCursor: null, sectionAnchors: [] }); + set({ + trackSort, + tracks: [], + trackNextCursor: null, + trackPrevCursor: null, + jumpAnchorIndex: 0, + sectionAnchors: [], + }); persistSetting(TRACK_SORT_KEY, trackSort); void resetTracks(); void resetSectionAnchors(); @@ -686,7 +900,14 @@ export const useLibraryStore = create((set, get) => { setAlbumSort: (albumSort) => { anchorGeneration += 1; - set({ albumSort, albums: [], albumNextCursor: null, sectionAnchors: [] }); + set({ + albumSort, + albums: [], + albumNextCursor: null, + albumPrevCursor: null, + jumpAnchorIndex: 0, + sectionAnchors: [], + }); persistSetting(ALBUM_SORT_KEY, albumSort); void resetAlbums(); void resetSectionAnchors(); @@ -694,7 +915,14 @@ export const useLibraryStore = create((set, get) => { setArtistSort: (artistSort) => { anchorGeneration += 1; - set({ artistSort, artists: [], artistNextCursor: null, sectionAnchors: [] }); + set({ + artistSort, + artists: [], + artistNextCursor: null, + artistPrevCursor: null, + jumpAnchorIndex: 0, + sectionAnchors: [], + }); persistSetting(ARTIST_SORT_KEY, artistSort); void resetArtists(); void resetSectionAnchors(); @@ -706,6 +934,8 @@ export const useLibraryStore = create((set, get) => { includeCollabArtists, artists: [], artistNextCursor: null, + artistPrevCursor: null, + jumpAnchorIndex: 0, sectionAnchors: [], }); persistSetting(INCLUDE_COLLAB_ARTISTS_KEY, includeCollabArtists ? 'true' : 'false');