From 7941cc2ae64e5b206cbce7902e927062abfb5a07 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:09:00 -0400 Subject: [PATCH] add library sort ordering --- .../data/RoomLibraryRepositoryTest.kt | 225 +++++++++++ .../AstraLibraryDataModule.kt | 27 +- .../data/AstraLibraryRepository.kt | 249 ++++++++---- .../data/CatalogDatabase.kt | 358 +++++++++++++++++- modules/astra-library-scanner/index.ts | 13 +- package.json | 1 + src/app/(tabs)/library/index.tsx | 86 ++++- src/components/library/AlphabetRail.tsx | 20 +- src/lib/albumSort.ts | 42 +- src/lib/artistSort.ts | 22 +- src/lib/letterIndex.ts | 6 + src/lib/librarySortDirection.test.mts | 139 +++++++ src/lib/sortDirection.ts | 22 ++ src/lib/trackSort.ts | 40 +- src/library/libraryViewPresentation.test.mts | 7 + src/library/libraryViewPresentation.ts | 8 + src/stores/libraryStore.ts | 240 ++++++++++-- 17 files changed, 1356 insertions(+), 149 deletions(-) create mode 100644 src/lib/librarySortDirection.test.mts create mode 100644 src/lib/sortDirection.ts 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 9469770..0c82bc8 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 @@ -645,6 +645,212 @@ class RoomLibraryRepositoryTest { ) } + @Test + fun descendingTitlePagesAndAnchorsRemainGapFree() = runBlocking { + publish("g1", seedAlphabet()) + val dao = catalog.catalogDao() + val expected = dao.getTitlePage(null, "", ALPHABET_SEED_SIZE) + .sortedWith(compareByDescending { it.titleSortKey }.thenBy { it.path }) + + val paged = mutableListOf() + var afterKey: String? = null + var afterPath = "" + while (true) { + val page = dao.getTitlePageDescending(afterKey, afterPath, 37) + if (page.isEmpty()) break + paged += page + afterKey = page.last().titleSortKey + afterPath = page.last().path + } + assertEquals(expected.map { it.path }, paged.map { it.path }) + + val anchor = dao.getTitleSectionAnchorsDescending().first { it.sectionLabel == "F" } + val at = dao.getTitlePageDescending(anchor.sortKey, "", 40) + val above = dao.getTitlePageBeforeDescending(anchor.sortKey, "", 40) + assertEquals("F", SortKeys.sectionLabel(at.first().title)) + assertTrue(above.all { SortKeys.sectionLabel(it.title) != "F" }) + val anchorIndex = expected.indexOfFirst { it.path == at.first().path } + assertEquals( + expected.subList(anchorIndex - above.size, anchorIndex).map { it.path }, + above.reversed().map { it.path }, + ) + assertTrue( + dao.getTitlePageBeforeDescending(expected.first().titleSortKey, expected.first().path, 40) + .isEmpty(), + ) + } + + @Test + fun trackDirectionsReverseOnlyThePrimaryField() = runBlocking { + val rows = listOf( + track("g1", 1, "Zulu Beta").copy( + artist = "Zulu", + artistSortKey = SortKeys.forText("Zulu"), + album = "Beta", + albumSortKey = SortKeys.forText("Beta"), + addedAt = 30, + duration = 300.0, + ), + track("g1", 2, "Zulu Alpha").copy( + artist = "Zulu", + artistSortKey = SortKeys.forText("Zulu"), + album = "Alpha", + albumSortKey = SortKeys.forText("Alpha"), + addedAt = 10, + duration = 100.0, + ), + track("g1", 3, "Alpha Gamma").copy( + artist = "Alpha", + artistSortKey = SortKeys.forText("Alpha"), + album = "Gamma", + albumSortKey = SortKeys.forText("Gamma"), + addedAt = 20, + duration = 200.0, + ), + ) + publish("g1", rows) + val dao = catalog.catalogDao() + assertEquals( + listOf(rows[1].path, rows[0].path, rows[2].path), + dao.getArtistOrderPageDescending(null, "", 0, 0, "", "", 10).map { it.path }, + ) + assertEquals( + listOf(rows[1].path, rows[2].path, rows[0].path), + dao.getRecentlyAddedPageAscending(null, "", 10).map { it.path }, + ) + assertEquals( + listOf(rows[1].path, rows[2].path, rows[0].path), + dao.getDurationPageAscending(null, "", 10).map { it.path }, + ) + assertEquals( + dao.getArtistOrderPageDescending(null, "", 0, 0, "", "", 10).map { it.path }, + dao.getAllPathsByArtistDescending(), + ) + assertEquals( + dao.getTitlePageDescending(null, "", 10).map { it.path }, + dao.getAllPathsByTitleDescending(), + ) + assertEquals( + dao.getRecentlyAddedPageAscending(null, "", 10).map { it.path }, + dao.getAllPathsByRecentlyAddedAscending(), + ) + assertEquals( + dao.getDurationPageAscending(null, "", 10).map { it.path }, + dao.getAllPathsByDurationAscending(), + ) + } + + @Test + fun albumAndArtistSummaryQueriesSupportBothDirections() = runBlocking { + val dao = catalog.catalogDao() + val revision = 7L + dao.putAlbumSummaries( + listOf( + AlbumSummaryEntity( + revision = revision, + identityKey = "z-beta", + album = "Beta", + artist = "Zulu", + year = 2020, + trackCount = 1, + totalDuration = 1.0, + latestAddedAt = 30, + nameSortKey = SortKeys.forText("Beta"), + artistSortKey = SortKeys.forText("Zulu"), + sectionLabel = "B", + isSingle = false, + ), + AlbumSummaryEntity( + revision = revision, + identityKey = "z-alpha", + album = "Alpha", + artist = "Zulu", + year = null, + trackCount = 1, + totalDuration = 1.0, + latestAddedAt = 10, + nameSortKey = SortKeys.forText("Alpha"), + artistSortKey = SortKeys.forText("Zulu"), + sectionLabel = "A", + isSingle = false, + ), + AlbumSummaryEntity( + revision = revision, + identityKey = "a-gamma", + album = "Gamma", + artist = "Alpha", + year = 1990, + trackCount = 1, + totalDuration = 1.0, + latestAddedAt = 20, + nameSortKey = SortKeys.forText("Gamma"), + artistSortKey = SortKeys.forText("Alpha"), + sectionLabel = "G", + isSingle = false, + ), + ), + ) + assertEquals( + listOf("z-alpha", "z-beta", "a-gamma"), + dao.getAlbumArtistPageDescending(revision, true, null, "", "", 10) + .map { it.identityKey }, + ) + assertEquals( + listOf("a-gamma", "z-beta", "z-alpha"), + dao.getAlbumNamePageDescending(revision, true, null, "", 10) + .map { it.identityKey }, + ) + assertEquals( + listOf("a-gamma"), + dao.getAlbumNamePageBeforeDescending( + revision, + true, + SortKeys.forText("Beta"), + "z-beta", + 10, + ).map { it.identityKey }, + ) + assertEquals( + listOf("a-gamma", "z-beta", "z-alpha"), + dao.getAlbumYearPageAscending(revision, true, 0, null, "", "", 10) + .map { it.identityKey }, + ) + assertEquals( + listOf("z-alpha", "a-gamma", "z-beta"), + dao.getAlbumRecentPageAscending(revision, true, null, "", 10) + .map { it.identityKey }, + ) + + dao.putArtistSummaries( + listOf( + artistSummary(revision, "zulu", "Zulu", 2), + artistSummary(revision, "alpha", "Alpha", 2), + artistSummary(revision, "beta", "Beta", 1), + ), + ) + assertEquals( + listOf("Zulu", "Beta", "Alpha"), + dao.getArtistNamePageDescending(revision, "astra", true, null, "", 10) + .map { it.artist }, + ) + assertEquals( + listOf("Zulu"), + dao.getArtistNamePageBeforeDescending( + revision, + "astra", + true, + SortKeys.forText("Beta"), + "beta", + 10, + ).map { it.artist }, + ) + assertEquals( + listOf("Beta", "Alpha", "Zulu"), + dao.getArtistCountPageAscending(revision, "astra", true, null, "", "", 10) + .map { it.artist }, + ) + } + @Test fun structuredArtistCreditsPreserveNamesContainingPunctuation() = runBlocking { val artistNames = listOf("Earth, Wind & Fire", "The Emotions") @@ -965,6 +1171,25 @@ class RoomLibraryRepositoryTest { sectionLabel = SortKeys.sectionLabel(title), ) + private fun artistSummary( + revision: Long, + key: String, + name: String, + count: Long, + ): ArtistSummaryEntity = ArtistSummaryEntity( + revision = revision, + artistKey = key, + artist = name, + groupingMode = "astra", + trackCount = count, + primaryTrackCount = count, + albumCount = 1, + nameSortKey = SortKeys.forText(name), + sectionLabel = SortKeys.sectionLabel(name), + isCollaboration = false, + artworkHashesJson = "[]", + ) + 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 f56e7e0..1ee5b95 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 @@ -81,11 +81,12 @@ class AstraLibraryDataModule : Module() { AsyncFunction("getTrackPage") Coroutine { sort: String, + direction: String, cursor: String?, limit: Int, -> try { - repository().getTrackPage(sort, cursor, limit) + repository().getTrackPage(sort, direction, cursor, limit) } catch (_: StaleRevisionException) { mapOf("error" to "STALE_REVISION") } @@ -93,11 +94,12 @@ class AstraLibraryDataModule : Module() { AsyncFunction("getTrackPageBefore") Coroutine { sort: String, + direction: String, cursor: String?, limit: Int, -> try { - repository().getTrackPageBefore(sort, cursor, limit) + repository().getTrackPageBefore(sort, direction, cursor, limit) } catch (_: StaleRevisionException) { mapOf("error" to "STALE_REVISION") } @@ -385,12 +387,13 @@ class AstraLibraryDataModule : Module() { AsyncFunction("getAlbumPage") Coroutine { sort: String, + direction: String, includeSingles: Boolean, cursor: String?, limit: Int, -> try { - repository().getAlbumPage(sort, includeSingles, cursor, limit) + repository().getAlbumPage(sort, direction, includeSingles, cursor, limit) } catch (_: StaleRevisionException) { mapOf("error" to "STALE_REVISION") } @@ -398,12 +401,13 @@ class AstraLibraryDataModule : Module() { AsyncFunction("getAlbumPageBefore") Coroutine { sort: String, + direction: String, includeSingles: Boolean, cursor: String?, limit: Int, -> try { - repository().getAlbumPageBefore(sort, includeSingles, cursor, limit) + repository().getAlbumPageBefore(sort, direction, includeSingles, cursor, limit) } catch (_: StaleRevisionException) { mapOf("error" to "STALE_REVISION") } @@ -411,13 +415,14 @@ class AstraLibraryDataModule : Module() { AsyncFunction("getArtistPage") Coroutine { sort: String, + direction: String, groupingMode: String, includeCollaborations: Boolean, cursor: String?, limit: Int, -> try { - repository().getArtistPage(sort, groupingMode, includeCollaborations, cursor, limit) + repository().getArtistPage(sort, direction, groupingMode, includeCollaborations, cursor, limit) } catch (_: StaleRevisionException) { mapOf("error" to "STALE_REVISION") } @@ -425,13 +430,21 @@ class AstraLibraryDataModule : Module() { AsyncFunction("getArtistPageBefore") Coroutine { sort: String, + direction: String, groupingMode: String, includeCollaborations: Boolean, cursor: String?, limit: Int, -> try { - repository().getArtistPageBefore(sort, groupingMode, includeCollaborations, cursor, limit) + repository().getArtistPageBefore( + sort, + direction, + groupingMode, + includeCollaborations, + cursor, + limit, + ) } catch (_: StaleRevisionException) { mapOf("error" to "STALE_REVISION") } @@ -560,6 +573,7 @@ class AstraLibraryDataModule : Module() { AsyncFunction("getSectionAnchors") Coroutine { kind: String, sort: String, + direction: String, includeSingles: Boolean, groupingMode: String, includeCollaborations: Boolean, @@ -567,6 +581,7 @@ class AstraLibraryDataModule : Module() { repository().getSectionAnchors( kind, sort, + direction, includeSingles, groupingMode, includeCollaborations, 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 dd270c8..e33da65 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 @@ -635,41 +635,43 @@ class AstraLibraryRepository private constructor( suspend fun getTrackPage( sort: String, + directionRaw: 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 direction = normalizeSortDirection(directionRaw) + val cursor = validateCursor(cursorRaw, revision, "tracks:$sort:$direction") val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) val rows = when (sort) { - "artist" -> dao.getArtistOrderPage( - afterArtistKey = cursor?.text1, - afterAlbumKey = cursor?.text2.orEmpty(), - afterDisc = cursor?.number1?.toInt() ?: 0, - afterTrack = cursor?.number2?.toInt() ?: 0, - afterTitleKey = cursor?.let(::cursorTitleKey).orEmpty(), - afterPath = cursor?.let { cursorPath(it) }.orEmpty(), - limit = limit, + "artist" -> (if (direction == "desc") dao::getArtistOrderPageDescending else dao::getArtistOrderPage)( + cursor?.text1, + cursor?.text2.orEmpty(), + cursor?.number1?.toInt() ?: 0, + cursor?.number2?.toInt() ?: 0, + cursor?.let(::cursorTitleKey).orEmpty(), + cursor?.let { cursorPath(it) }.orEmpty(), + limit, ) - "recently_added" -> dao.getRecentlyAddedPage( - afterAddedAt = cursor?.number1, - afterPath = cursor?.text1.orEmpty(), - limit = limit, + "recently_added" -> (if (direction == "asc") dao::getRecentlyAddedPageAscending else dao::getRecentlyAddedPage)( + cursor?.number1, + cursor?.text1.orEmpty(), + limit, ) - "duration" -> dao.getDurationPage( - afterDuration = cursor?.decimal1, - afterPath = cursor?.text1.orEmpty(), - limit = limit, + "duration" -> (if (direction == "asc") dao::getDurationPageAscending else dao::getDurationPage)( + cursor?.decimal1, + cursor?.text1.orEmpty(), + limit, ) - else -> dao.getTitlePage( - afterTitleKey = cursor?.text1, - afterPath = cursor?.text2.orEmpty(), - limit = limit, + else -> (if (direction == "desc") dao::getTitlePageDescending else dao::getTitlePage)( + cursor?.text1, + cursor?.text2.orEmpty(), + limit, ) } - val next = rows.lastOrNull()?.let { row -> trackCursor(revision, sort, row).encode() } + val next = rows.lastOrNull()?.let { row -> trackCursor(revision, sort, direction, row).encode() } mapOf( "items" to rows.map(ActiveTrackView::toBridgeMap), "nextCursor" to next, @@ -693,36 +695,46 @@ class AstraLibraryRepository private constructor( */ suspend fun getTrackPageBefore( sort: String, + directionRaw: 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 direction = normalizeSortDirection(directionRaw) + val cursor = validateCursor(cursorRaw, revision, "tracks:$sort:$direction") 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 == "artist" -> (if (direction == "desc") { + dao::getArtistOrderPageBeforeDescending + } else { + dao::getArtistOrderPageBefore + })( + cursor.text1.orEmpty(), + cursor.text2.orEmpty(), + cursor.number1?.toInt() ?: 0, + cursor.number2?.toInt() ?: 0, + cursorTitleKey(cursor), + cursorPath(cursor), + limit, ) - sort == "title" -> dao.getTitlePageBefore( - beforeTitleKey = cursor.text1.orEmpty(), - beforePath = cursor.text2.orEmpty(), - limit = limit, + sort == "title" -> (if (direction == "desc") { + dao::getTitlePageBeforeDescending + } else { + dao::getTitlePageBefore + })( + cursor.text1.orEmpty(), + cursor.text2.orEmpty(), + 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() } + ?.let { row -> trackCursor(revision, sort, direction, row).encode() } mapOf( "items" to descending.reversed().map(ActiveTrackView::toBridgeMap), "nextCursor" to null, @@ -733,11 +745,16 @@ class AstraLibraryRepository private constructor( } /** Shared by the forward and backward track pages so the two can never disagree. */ - private fun trackCursor(revision: Long, sort: String, row: ActiveTrackView): TrackPageCursor = + private fun trackCursor( + revision: Long, + sort: String, + direction: String, + row: ActiveTrackView, + ): TrackPageCursor = when (sort) { "artist" -> TrackPageCursor( revision = revision, - kind = "tracks:$sort", + kind = "tracks:$sort:$direction", text1 = row.artistSortKey, text2 = row.albumSortKey, text3 = "${row.titleSortKey}\u0000${row.path}", @@ -748,19 +765,19 @@ class AstraLibraryRepository private constructor( ) "recently_added" -> TrackPageCursor( revision = revision, - kind = "tracks:$sort", + kind = "tracks:$sort:$direction", text1 = row.path, number1 = row.addedAt, ) "duration" -> TrackPageCursor( revision = revision, - kind = "tracks:$sort", + kind = "tracks:$sort:$direction", text1 = row.path, decimal1 = row.duration, ) else -> TrackPageCursor( revision = revision, - kind = "tracks:$sort", + kind = "tracks:$sort:$direction", text1 = row.titleSortKey, text2 = row.path, ) @@ -1882,17 +1899,19 @@ class AstraLibraryRepository private constructor( suspend fun getAlbumPage( sort: String, + directionRaw: 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 direction = normalizeSortDirection(directionRaw) + val kind = "albums:$sort:$direction:${if (includeSingles) 1 else 0}" val cursor = validateCursor(cursorRaw, revision, kind) val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) val rows = when (sort) { - "artist" -> dao.getAlbumArtistPage( + "artist" -> (if (direction == "desc") dao::getAlbumArtistPageDescending else dao::getAlbumArtistPage)( revision, includeSingles, cursor?.text1, @@ -1900,22 +1919,23 @@ class AstraLibraryRepository private constructor( cursor?.text3.orEmpty(), limit, ) - "recently_added" -> dao.getAlbumRecentPage( + "recently_added" -> (if (direction == "asc") dao::getAlbumRecentPageAscending else dao::getAlbumRecentPage)( revision, includeSingles, cursor?.number1, cursor?.text1.orEmpty(), limit, ) - "year" -> dao.getAlbumYearPage( + "year" -> (if (direction == "asc") dao::getAlbumYearPageAscending else dao::getAlbumYearPage)( revision, includeSingles, - cursor?.number1?.toInt(), + cursor?.number1?.toInt() ?: 0, + cursor?.number2?.toInt(), cursor?.text1.orEmpty(), cursor?.text2.orEmpty(), limit, ) - else -> dao.getAlbumNamePage( + else -> (if (direction == "desc") dao::getAlbumNamePageDescending else dao::getAlbumNamePage)( revision, includeSingles, cursor?.text1, @@ -1936,18 +1956,24 @@ class AstraLibraryRepository private constructor( /** Backward twin of [getAlbumPage]; see [getTrackPageBefore] for the contract. */ suspend fun getAlbumPageBefore( sort: String, + directionRaw: 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 direction = normalizeSortDirection(directionRaw) + val kind = "albums:$sort:$direction:${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( + sort == "artist" -> (if (direction == "desc") { + dao::getAlbumArtistPageBeforeDescending + } else { + dao::getAlbumArtistPageBefore + })( revision, includeSingles, cursor.text1.orEmpty(), @@ -1955,7 +1981,11 @@ class AstraLibraryRepository private constructor( cursor.text3.orEmpty(), limit, ) - sort == "name" -> dao.getAlbumNamePageBefore( + sort == "name" -> (if (direction == "desc") { + dao::getAlbumNamePageBeforeDescending + } else { + dao::getAlbumNamePageBefore + })( revision, includeSingles, cursor.text1.orEmpty(), @@ -2003,6 +2033,7 @@ class AstraLibraryRepository private constructor( text1 = row.nameSortKey, text2 = row.identityKey, number1 = (row.year ?: 0).toLong(), + number2 = if (row.year == null) 1 else 0, ) else -> TrackPageCursor( revision, @@ -2014,6 +2045,7 @@ class AstraLibraryRepository private constructor( suspend fun getArtistPage( sort: String, + directionRaw: String, groupingMode: String, includeCollaborations: Boolean, cursorRaw: String?, @@ -2022,11 +2054,12 @@ class AstraLibraryRepository private constructor( 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 direction = normalizeSortDirection(directionRaw) + val kind = "artists:$sort:$direction:$mode:${if (includeCollaborations) 1 else 0}" val cursor = validateCursor(cursorRaw, revision, kind) val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) val rows = if (sort == "track_count") { - dao.getArtistCountPage( + (if (direction == "asc") dao::getArtistCountPageAscending else dao::getArtistCountPage)( revision, mode, includeCollaborations, @@ -2036,7 +2069,7 @@ class AstraLibraryRepository private constructor( limit, ) } else { - dao.getArtistNamePage( + (if (direction == "desc") dao::getArtistNamePageDescending else dao::getArtistNamePage)( revision, mode, includeCollaborations, @@ -2058,6 +2091,7 @@ class AstraLibraryRepository private constructor( /** Backward twin of [getArtistPage]; see [getTrackPageBefore] for the contract. */ suspend fun getArtistPageBefore( sort: String, + directionRaw: String, groupingMode: String, includeCollaborations: Boolean, cursorRaw: String?, @@ -2066,13 +2100,18 @@ class AstraLibraryRepository private constructor( 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 direction = normalizeSortDirection(directionRaw) + val kind = "artists:$sort:$direction:$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( + (if (direction == "desc") { + dao::getArtistNamePageBeforeDescending + } else { + dao::getArtistNamePageBefore + })( revision, mode, includeCollaborations, @@ -2689,6 +2728,7 @@ class AstraLibraryRepository private constructor( suspend fun getSectionAnchors( kind: String, sort: String, + directionRaw: String, includeSingles: Boolean, groupingMode: String, includeCollaborations: Boolean, @@ -2696,6 +2736,7 @@ class AstraLibraryRepository private constructor( withCatalogRecovery { database -> val dao = database.catalogDao() val revision = dao.getRevision() + val direction = normalizeSortDirection(directionRaw) val anchors: List> = when (kind) { "albums" -> { val rows = dao.getAllAlbumSummaries(revision).filter { includeSingles || !it.isSingle } @@ -2703,17 +2744,32 @@ class AstraLibraryRepository private constructor( 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 })) + val first = section.sortedWith( + if (direction == "desc") { + compareByDescending { it.artistSortKey } + .thenBy { it.nameSortKey } + .thenBy { it.identityKey } + } else { + compareBy({ it.artistSortKey }, { it.nameSortKey }, { it.identityKey }) + }, + ).first() label to TrackPageCursor( revision, - "albums:artist:${if (includeSingles) 1 else 0}", + "albums:artist:$direction:${if (includeSingles) 1 else 0}", text1 = first.artistSortKey, ) } else { - val first = section.minWith(compareBy({ it.nameSortKey }, { it.identityKey })) + val first = section.sortedWith( + if (direction == "desc") { + compareByDescending { it.nameSortKey } + .thenBy { it.identityKey } + } else { + compareBy({ it.nameSortKey }, { it.identityKey }) + }, + ).first() label to TrackPageCursor( revision, - "albums:name:${if (includeSingles) 1 else 0}", + "albums:name:$direction:${if (includeSingles) 1 else 0}", text1 = first.nameSortKey, ) } @@ -2725,10 +2781,17 @@ class AstraLibraryRepository private constructor( .filter { includeCollaborations || !it.isCollaboration } .groupBy { row -> SortKeys.sectionLabel(row.artist) } .map { (label, section) -> - val first = section.minWith(compareBy({ it.nameSortKey }, { it.artistKey })) + val first = section.sortedWith( + if (direction == "desc") { + compareByDescending { it.nameSortKey } + .thenBy { it.artistKey } + } else { + compareBy({ it.nameSortKey }, { it.artistKey }) + }, + ).first() label to TrackPageCursor( revision, - "artists:name:$mode:${if (includeCollaborations) 1 else 0}", + "artists:name:$direction:$mode:${if (includeCollaborations) 1 else 0}", text1 = first.nameSortKey, ) } @@ -2740,22 +2803,38 @@ class AstraLibraryRepository private constructor( .map { (label, section) -> label to TrackPageCursor( revision, - "tracks:artist", - text1 = section.minOf(ArtistSectionAnchorCandidate::sortKey), + "tracks:artist:$direction", + text1 = if (direction == "desc") { + section.maxOf(ArtistSectionAnchorCandidate::sortKey) + } else { + section.minOf(ArtistSectionAnchorCandidate::sortKey) + }, ) } } else { - dao.getTitleSectionAnchors().map { row -> + val rows = if (direction == "desc") { + dao.getTitleSectionAnchorsDescending() + } else { + dao.getTitleSectionAnchors() + } + rows.map { row -> row.sectionLabel to TrackPageCursor( revision, - "tracks:title", + "tracks:title:$direction", text1 = row.sortKey, ) } } } } - anchors.sortedWith(compareBy> { it.second.text1 }.thenBy { it.first }) + val anchorComparator = compareBy> { it.second.text1 } + .thenBy { it.first } + val orderedAnchors = if (direction == "desc") { + anchors.sortedWith(anchorComparator.reversed()) + } else { + anchors.sortedWith(anchorComparator) + } + orderedAnchors .map { (label, cursor) -> mapOf( "label" to label, @@ -2842,11 +2921,33 @@ class AstraLibraryRepository private constructor( "manual" -> (context["paths"] as? List<*>) ?.mapNotNull { it as? String } .orEmpty() - else -> when (context["sort"] as? String) { - "artist" -> catalogDao.getAllPathsByArtist() - "recently_added" -> catalogDao.getAllPathsByRecentlyAdded() - "duration" -> catalogDao.getAllPathsByDuration() - else -> catalogDao.getAllPathsByTitle() + else -> { + val sort = context["sort"] as? String ?: "title" + val direction = (context["direction"] as? String) + ?.takeIf { it == "asc" || it == "desc" } + ?: legacyTrackSortDirection(sort) + when (sort) { + "artist" -> if (direction == "desc") { + catalogDao.getAllPathsByArtistDescending() + } else { + catalogDao.getAllPathsByArtist() + } + "recently_added" -> if (direction == "asc") { + catalogDao.getAllPathsByRecentlyAddedAscending() + } else { + catalogDao.getAllPathsByRecentlyAdded() + } + "duration" -> if (direction == "asc") { + catalogDao.getAllPathsByDurationAscending() + } else { + catalogDao.getAllPathsByDuration() + } + else -> if (direction == "desc") { + catalogDao.getAllPathsByTitleDescending() + } else { + catalogDao.getAllPathsByTitle() + } + } } } @@ -2954,6 +3055,12 @@ class AstraLibraryRepository private constructor( return cursor } + private fun normalizeSortDirection(value: String): String = + if (value == "desc") "desc" else "asc" + + private fun legacyTrackSortDirection(sort: String): String = + if (sort == "recently_added" || sort == "duration") "desc" else "asc" + private fun cursorPath(cursor: TrackPageCursor): String = cursor.text3?.substringAfter('\u0000', "") ?: "" 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 ecb3a9a..92a8da9 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 @@ -213,6 +213,9 @@ interface CatalogDao { @Query("SELECT path FROM active_tracks ORDER BY title_sort_key, path") suspend fun getAllPathsByTitle(): List + @Query("SELECT path FROM active_tracks ORDER BY title_sort_key DESC, path") + suspend fun getAllPathsByTitleDescending(): List + @Query( """ SELECT path FROM active_tracks @@ -221,12 +224,26 @@ interface CatalogDao { ) suspend fun getAllPathsByArtist(): List + @Query( + """ + SELECT path FROM active_tracks + ORDER BY artist_sort_key DESC, album_sort_key, disc_sort, track_sort, title_sort_key, path + """, + ) + suspend fun getAllPathsByArtistDescending(): List + @Query("SELECT path FROM active_tracks ORDER BY added_at DESC, path") suspend fun getAllPathsByRecentlyAdded(): List + @Query("SELECT path FROM active_tracks ORDER BY added_at, path") + suspend fun getAllPathsByRecentlyAddedAscending(): List + @Query("SELECT path FROM active_tracks ORDER BY duration DESC, path") suspend fun getAllPathsByDuration(): List + @Query("SELECT path FROM active_tracks ORDER BY duration, path") + suspend fun getAllPathsByDurationAscending(): List + @Query( """ SELECT path FROM active_tracks @@ -364,6 +381,22 @@ interface CatalogDao { limit: Int, ): List + @Query( + """ + SELECT * FROM active_tracks + WHERE (:afterTitleKey IS NULL + OR title_sort_key < :afterTitleKey + OR (title_sort_key = :afterTitleKey AND path > :afterPath)) + ORDER BY title_sort_key DESC, path + LIMIT :limit + """, + ) + suspend fun getTitlePageDescending( + afterTitleKey: String?, + afterPath: String, + 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 @@ -384,6 +417,21 @@ interface CatalogDao { limit: Int, ): List + @Query( + """ + SELECT * FROM active_tracks + WHERE title_sort_key > :beforeTitleKey + OR (title_sort_key = :beforeTitleKey AND path < :beforePath) + ORDER BY title_sort_key, path DESC + LIMIT :limit + """, + ) + suspend fun getTitlePageBeforeDescending( + beforeTitleKey: String, + beforePath: String, + limit: Int, + ): List + @Query( """ SELECT * FROM active_tracks @@ -411,6 +459,33 @@ interface CatalogDao { limit: Int, ): List + @Query( + """ + SELECT * FROM active_tracks + WHERE (:afterArtistKey IS NULL + OR artist_sort_key < :afterArtistKey + OR (artist_sort_key = :afterArtistKey AND album_sort_key > :afterAlbumKey) + OR (artist_sort_key = :afterArtistKey AND album_sort_key = :afterAlbumKey AND disc_sort > :afterDisc) + OR (artist_sort_key = :afterArtistKey AND album_sort_key = :afterAlbumKey AND disc_sort = :afterDisc + AND track_sort > :afterTrack) + OR (artist_sort_key = :afterArtistKey AND album_sort_key = :afterAlbumKey AND disc_sort = :afterDisc + AND track_sort = :afterTrack AND title_sort_key > :afterTitleKey) + OR (artist_sort_key = :afterArtistKey AND album_sort_key = :afterAlbumKey AND disc_sort = :afterDisc + AND track_sort = :afterTrack AND title_sort_key = :afterTitleKey AND path > :afterPath)) + ORDER BY artist_sort_key DESC, album_sort_key, disc_sort, track_sort, title_sort_key, path + LIMIT :limit + """, + ) + suspend fun getArtistOrderPageDescending( + afterArtistKey: String?, + afterAlbumKey: String, + afterDisc: Int, + afterTrack: Int, + afterTitleKey: String, + afterPath: String, + limit: Int, + ): List + /** Mirror of [getArtistOrderPage] walking backwards; rows come out DESC. */ @Query( """ @@ -439,6 +514,33 @@ interface CatalogDao { limit: Int, ): List + @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, album_sort_key DESC, disc_sort DESC, track_sort DESC, + title_sort_key DESC, path DESC + LIMIT :limit + """, + ) + suspend fun getArtistOrderPageBeforeDescending( + beforeArtistKey: String, + beforeAlbumKey: String, + beforeDisc: Int, + beforeTrack: Int, + beforeTitleKey: String, + beforePath: String, + limit: Int, + ): List + @Query( """ SELECT * FROM active_tracks @@ -455,6 +557,22 @@ interface CatalogDao { limit: Int, ): List + @Query( + """ + SELECT * FROM active_tracks + WHERE (:afterAddedAt IS NULL + OR added_at > :afterAddedAt + OR (added_at = :afterAddedAt AND path > :afterPath)) + ORDER BY added_at, path + LIMIT :limit + """, + ) + suspend fun getRecentlyAddedPageAscending( + afterAddedAt: Long?, + afterPath: String, + limit: Int, + ): List + @Query( """ SELECT * FROM active_tracks @@ -471,6 +589,22 @@ interface CatalogDao { limit: Int, ): List + @Query( + """ + SELECT * FROM active_tracks + WHERE (:afterDuration IS NULL + OR duration > :afterDuration + OR (duration = :afterDuration AND path > :afterPath)) + ORDER BY duration, path + LIMIT :limit + """, + ) + suspend fun getDurationPageAscending( + afterDuration: Double?, + afterPath: String, + limit: Int, + ): List + @Query( """ SELECT * FROM active_tracks @@ -542,6 +676,16 @@ interface CatalogDao { ) suspend fun getTitleSectionAnchors(): List + @Query( + """ + SELECT section_label, MAX(title_sort_key) AS sort_key + FROM active_tracks + GROUP BY section_label + ORDER BY sort_key DESC + """, + ) + suspend fun getTitleSectionAnchorsDescending(): List + @Query( """ SELECT artist, MIN(artist_sort_key) AS sort_key @@ -595,6 +739,26 @@ interface CatalogDao { limit: Int, ): List + @Query( + """ + SELECT * FROM album_summaries + WHERE revision = :revision + AND (:includeSingles OR is_single = 0) + AND (:afterKey IS NULL + OR name_sort_key < :afterKey + OR (name_sort_key = :afterKey AND identity_key > :afterId)) + ORDER BY name_sort_key DESC, identity_key + LIMIT :limit + """, + ) + suspend fun getAlbumNamePageDescending( + revision: Long, + includeSingles: Boolean, + afterKey: String?, + afterId: String, + limit: Int, + ): List + /** Mirror of [getAlbumNamePage] walking backwards; rows come out DESC. */ @Query( """ @@ -615,6 +779,25 @@ interface CatalogDao { limit: Int, ): List + @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, identity_key DESC + LIMIT :limit + """, + ) + suspend fun getAlbumNamePageBeforeDescending( + revision: Long, + includeSingles: Boolean, + beforeKey: String, + beforeId: String, + limit: Int, + ): List + @Query( """ SELECT * FROM album_summaries @@ -638,6 +821,29 @@ interface CatalogDao { limit: Int, ): List + @Query( + """ + SELECT * FROM album_summaries + WHERE revision = :revision + AND (:includeSingles OR is_single = 0) + AND (:afterArtistKey IS NULL + OR artist_sort_key < :afterArtistKey + OR (artist_sort_key = :afterArtistKey AND name_sort_key > :afterNameKey) + OR (artist_sort_key = :afterArtistKey AND name_sort_key = :afterNameKey + AND identity_key > :afterId)) + ORDER BY artist_sort_key DESC, name_sort_key, identity_key + LIMIT :limit + """, + ) + suspend fun getAlbumArtistPageDescending( + revision: Long, + includeSingles: Boolean, + afterArtistKey: String?, + afterNameKey: String, + afterId: String, + limit: Int, + ): List + /** Mirror of [getAlbumArtistPage] walking backwards; rows come out DESC. */ @Query( """ @@ -661,6 +867,28 @@ interface CatalogDao { limit: Int, ): List + @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, name_sort_key DESC, identity_key DESC + LIMIT :limit + """, + ) + suspend fun getAlbumArtistPageBeforeDescending( + revision: Long, + includeSingles: Boolean, + beforeArtistKey: String, + beforeNameKey: String, + beforeId: String, + limit: Int, + ): List + @Query( """ SELECT * FROM album_summaries @@ -686,12 +914,36 @@ interface CatalogDao { SELECT * FROM album_summaries WHERE revision = :revision AND (:includeSingles OR is_single = 0) - AND (:afterYear IS NULL - OR COALESCE(year, 0) < :afterYear - OR (COALESCE(year, 0) = :afterYear AND name_sort_key > :afterNameKey) - OR (COALESCE(year, 0) = :afterYear AND name_sort_key = :afterNameKey + AND (:afterAddedAt IS NULL + OR latest_added_at > :afterAddedAt + OR (latest_added_at = :afterAddedAt AND identity_key > :afterId)) + ORDER BY latest_added_at, identity_key + LIMIT :limit + """, + ) + suspend fun getAlbumRecentPageAscending( + revision: Long, + includeSingles: Boolean, + afterAddedAt: Long?, + afterId: String, + limit: Int, + ): List + + @Query( + """ + SELECT * FROM album_summaries + WHERE revision = :revision + AND (:includeSingles OR is_single = 0) + AND (:afterUnknown IS NULL + OR CASE WHEN year IS NULL THEN 1 ELSE 0 END > :afterUnknown + OR (CASE WHEN year IS NULL THEN 1 ELSE 0 END = :afterUnknown + AND COALESCE(year, 0) < :afterYear) + OR (CASE WHEN year IS NULL THEN 1 ELSE 0 END = :afterUnknown + AND COALESCE(year, 0) = :afterYear AND name_sort_key > :afterNameKey) + OR (CASE WHEN year IS NULL THEN 1 ELSE 0 END = :afterUnknown + AND COALESCE(year, 0) = :afterYear AND name_sort_key = :afterNameKey AND identity_key > :afterId)) - ORDER BY COALESCE(year, 0) DESC, name_sort_key, identity_key + ORDER BY CASE WHEN year IS NULL THEN 1 ELSE 0 END, year DESC, name_sort_key, identity_key LIMIT :limit """, ) @@ -699,6 +951,35 @@ interface CatalogDao { revision: Long, includeSingles: Boolean, afterYear: Int?, + afterUnknown: Int?, + afterNameKey: String, + afterId: String, + limit: Int, + ): List + + @Query( + """ + SELECT * FROM album_summaries + WHERE revision = :revision + AND (:includeSingles OR is_single = 0) + AND (:afterUnknown IS NULL + OR CASE WHEN year IS NULL THEN 1 ELSE 0 END > :afterUnknown + OR (CASE WHEN year IS NULL THEN 1 ELSE 0 END = :afterUnknown + AND COALESCE(year, 0) > :afterYear) + OR (CASE WHEN year IS NULL THEN 1 ELSE 0 END = :afterUnknown + AND COALESCE(year, 0) = :afterYear AND name_sort_key > :afterNameKey) + OR (CASE WHEN year IS NULL THEN 1 ELSE 0 END = :afterUnknown + AND COALESCE(year, 0) = :afterYear AND name_sort_key = :afterNameKey + AND identity_key > :afterId)) + ORDER BY CASE WHEN year IS NULL THEN 1 ELSE 0 END, year, name_sort_key, identity_key + LIMIT :limit + """, + ) + suspend fun getAlbumYearPageAscending( + revision: Long, + includeSingles: Boolean, + afterYear: Int?, + afterUnknown: Int?, afterNameKey: String, afterId: String, limit: Int, @@ -747,6 +1028,28 @@ interface CatalogDao { limit: Int, ): List + @Query( + """ + SELECT * FROM artist_summaries + WHERE revision = :revision + AND grouping_mode = :groupingMode + AND (:includeCollaborations OR is_collaboration = 0) + AND (:afterKey IS NULL + OR name_sort_key < :afterKey + OR (name_sort_key = :afterKey AND artist_key > :afterId)) + ORDER BY name_sort_key DESC, artist_key + LIMIT :limit + """, + ) + suspend fun getArtistNamePageDescending( + revision: Long, + groupingMode: String, + includeCollaborations: Boolean, + afterKey: String?, + afterId: String, + limit: Int, + ): List + /** Mirror of [getArtistNamePage] walking backwards; rows come out DESC. */ @Query( """ @@ -769,6 +1072,27 @@ interface CatalogDao { limit: Int, ): List + @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, artist_key DESC + LIMIT :limit + """, + ) + suspend fun getArtistNamePageBeforeDescending( + revision: Long, + groupingMode: String, + includeCollaborations: Boolean, + beforeKey: String, + beforeId: String, + limit: Int, + ): List + @Query( """ SELECT * FROM artist_summaries @@ -793,6 +1117,30 @@ interface CatalogDao { limit: Int, ): List + @Query( + """ + SELECT * FROM artist_summaries + WHERE revision = :revision + AND grouping_mode = :groupingMode + AND (:includeCollaborations OR is_collaboration = 0) + AND (:afterCount IS NULL + OR track_count > :afterCount + OR (track_count = :afterCount AND name_sort_key > :afterNameKey) + OR (track_count = :afterCount AND name_sort_key = :afterNameKey AND artist_key > :afterId)) + ORDER BY track_count, name_sort_key, artist_key + LIMIT :limit + """, + ) + suspend fun getArtistCountPageAscending( + revision: Long, + groupingMode: String, + includeCollaborations: Boolean, + afterCount: Long?, + afterNameKey: String, + afterId: 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 dc9e28f..9f26027 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -240,7 +240,11 @@ export interface NativePage { } export type LibraryQuery = - | { kind: 'library'; sort: 'artist' | 'title' | 'recently_added' | 'duration' } + | { + kind: 'library'; + sort: 'artist' | 'title' | 'recently_added' | 'duration'; + direction: 'asc' | 'desc'; + } | { kind: 'album'; albumKey: string } | { kind: 'artist'; @@ -362,6 +366,7 @@ declare class AstraLibraryDataModuleType extends NativeModule; getTrackPage( sort: 'artist' | 'title' | 'recently_added' | 'duration', + direction: 'asc' | 'desc', cursor: string | null, limit: number ): Promise>; @@ -372,6 +377,7 @@ declare class AstraLibraryDataModuleType extends NativeModule( sort: 'artist' | 'title', + direction: 'asc' | 'desc', cursor: string | null, limit: number ): Promise>; @@ -489,6 +495,7 @@ declare class AstraLibraryDataModuleType extends NativeModule; getAlbumPage( sort: 'artist' | 'name' | 'recently_added' | 'year', + direction: 'asc' | 'desc', includeSingles: boolean, cursor: string | null, limit: number @@ -496,12 +503,14 @@ declare class AstraLibraryDataModuleType extends NativeModule( sort: 'artist' | 'name', + direction: 'asc' | 'desc', includeSingles: boolean, cursor: string | null, limit: number ): Promise>; getArtistPage( sort: 'name' | 'track_count', + direction: 'asc' | 'desc', groupingMode: 'astra' | 'fileTags', includeCollaborations: boolean, cursor: string | null, @@ -510,6 +519,7 @@ declare class AstraLibraryDataModuleType extends NativeModule( sort: 'name', + direction: 'asc' | 'desc', groupingMode: 'astra' | 'fileTags', includeCollaborations: boolean, cursor: string | null, @@ -604,6 +614,7 @@ declare class AstraLibraryDataModuleType extends NativeModule s.tracks); const trackSort = useLibraryStore((s) => s.trackSort); const setTrackSort = useLibraryStore((s) => s.setTrackSort); + const trackSortDirection = useLibraryStore((s) => s.trackSortDirection); + const setTrackSortDirection = useLibraryStore((s) => s.setTrackSortDirection); const albumSort = useLibraryStore((s) => s.albumSort); const setAlbumSort = useLibraryStore((s) => s.setAlbumSort); + const albumSortDirection = useLibraryStore((s) => s.albumSortDirection); + const setAlbumSortDirection = useLibraryStore((s) => s.setAlbumSortDirection); const artistSort = useLibraryStore((s) => s.artistSort); const setArtistSort = useLibraryStore((s) => s.setArtistSort); + const artistSortDirection = useLibraryStore((s) => s.artistSortDirection); + const setArtistSortDirection = useLibraryStore((s) => s.setArtistSortDirection); const albumLayout = useLibraryStore((s) => s.albumLayout); const setAlbumLayout = useLibraryStore((s) => s.setAlbumLayout); const artistLayout = useLibraryStore((s) => s.artistLayout); @@ -303,7 +315,11 @@ export default function LibraryScreen() { // Tap index is within sortedTracks so the tapped row is the track that plays. const playAllFrom = (index: number) => { - void playLibraryQuery({ kind: 'library', sort: trackSort }, { + void playLibraryQuery({ + kind: 'library', + sort: trackSort, + direction: trackSortDirection, + }, { anchorPath: sortedTracks[index]?.path, source: { kind: 'library', label: 'Library' }, }); @@ -502,6 +518,18 @@ export default function LibraryScreen() { : viewMode === 'albums' ? ALBUM_SORT_LABELS[albumSort] : ARTIST_SORT_LABELS[artistSort]; + const sortDirection: SortDirection = + viewMode === 'tracks' + ? trackSortDirection + : viewMode === 'albums' + ? albumSortDirection + : artistSortDirection; + const sortDirectionLabel = SORT_DIRECTION_LABELS[sortDirection]; + const setSortDirection = (direction: SortDirection) => { + if (viewMode === 'tracks') setTrackSortDirection(direction); + if (viewMode === 'albums') setAlbumSortDirection(direction); + if (viewMode === 'artists') setArtistSortDirection(direction); + }; const sortSheetLabel = viewMode === 'tracks' ? 'SORT TRACKS BY' : viewMode === 'albums' ? 'SORT ALBUMS BY' : 'SORT ARTISTS BY'; const sortItems = @@ -542,11 +570,11 @@ export default function LibraryScreen() { const surfaceHeadIdentity = viewMode === 'albums' - ? `albums:${albumSort}:${albumLayout}:${albumColumns}` + ? `albums:${albumSort}:${albumSortDirection}:${albumLayout}:${albumColumns}` : viewMode === 'artists' - ? `artists:${artistSort}:${artistLayout}:${artistColumns}` + ? `artists:${artistSort}:${artistSortDirection}:${artistLayout}:${artistColumns}` : viewMode === 'tracks' - ? `tracks:${trackSort}` + ? `tracks:${trackSort}:${trackSortDirection}` : viewMode; const listMountIdentity = `${surfaceHeadIdentity}:${sectionJumpRevision}`; const activeListMountIdentity = useRef(listMountIdentity); @@ -738,10 +766,14 @@ export default function LibraryScreen() { style={styles.sortTrigger} onPress={() => setSortSheetOpen(true)} accessibilityRole="button" - accessibilityLabel={`Sort by ${sortLabel}`} + accessibilityLabel={`Sort by ${sortLabel}, ${sortDirectionLabel}`} > - - {sortLabel} + + {sortLabel} · {sortDirectionLabel} {activeLayout && activeLayoutLabel ? ( album.identity_key} @@ -841,7 +873,7 @@ export default function LibraryScreen() { {viewMode === 'artists' ? ( artist.artist} @@ -895,7 +927,7 @@ export default function LibraryScreen() { {viewMode === 'tracks' ? ( String(track.id)} showsVerticalScrollIndicator={false} @@ -966,11 +998,18 @@ export default function LibraryScreen() { // off its right edge, so it needs a box that matches the part of // the list a finger can actually reach. @@ -1015,7 +1054,7 @@ export default function LibraryScreen() { : viewMode === 'tracks' ? { icon: 'swap-vertical', - label: `Sort Tracks, currently ${sortLabel}`, + label: `Sort Tracks, currently ${sortLabel}, ${sortDirectionLabel}`, onPress: () => setSortSheetOpen(true), } : viewMode === 'playlists' @@ -1069,12 +1108,18 @@ export default function LibraryScreen() { key={key} label={label} selected={selected} - onPress={() => { - onSelect(); - setSortSheetOpen(false); - }} + onPress={onSelect} /> ))} + + setSortDirection(direction === 'desc' ? 'desc' : 'asc')} + /> ) : null} {layoutSheetOpen && activeLayout ? ( @@ -1107,6 +1152,15 @@ export default function LibraryScreen() { onPress={onSelect} /> ))} + + setSortDirection(direction === 'desc' ? 'desc' : 'asc')} + /> {LIBRARY_LAYOUT_OPTIONS.map((option) => ( ; + direction: SortDirection; onJumpToLetter: (letter: string) => void; /** * Fired when the finger lifts. The screen debounces `onJumpToLetter` so a fast @@ -36,10 +38,16 @@ 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, onScrubEnd }: AlphabetRailProps) { +export function AlphabetRail({ + activeLetters, + direction, + onJumpToLetter, + onScrubEnd, +}: AlphabetRailProps) { const styles = useStyles(); const pullSearchRef = usePullSearchGestureRef(); const [scrubLetter, setScrubLetter] = useState(null); + const railLetters = railLettersForDirection(direction); const lastLetter = useSharedValue(''); // Rail's top offset inside the (vertically-centered) wrap + the finger's Y // within the rail, so the bubble can be placed in wrap-space. @@ -72,7 +80,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter, onScrubEnd }: Alph 0, Math.min(RAIL_LETTERS.length - 1, Math.floor((y - RAIL_PAD) / CELL_HEIGHT)) ); - const letter = RAIL_LETTERS[index]; + const letter = railLetters[index]; lastLetter.value = letter; runOnJS(scrubTo)(letter); }) @@ -86,7 +94,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter, onScrubEnd }: Alph 0, Math.min(RAIL_LETTERS.length - 1, Math.floor((y - RAIL_PAD) / CELL_HEIGHT)) ); - const letter = RAIL_LETTERS[index]; + const letter = railLetters[index]; if (letter === lastLetter.value) return; lastLetter.value = letter; runOnJS(scrubTo)(letter); @@ -98,7 +106,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter, onScrubEnd }: Alph }); 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, onScrubEnd]); + }, [lastLetter, bubbleY, railTop, pullSearchRef, activeLetters, railLetters, onJumpToLetter, onScrubEnd]); const bubbleStyle = useAnimatedStyle(() => ({ transform: [{ translateY: bubbleY.value - BUBBLE_SIZE / 2 }], @@ -119,7 +127,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter, onScrubEnd }: Alph ) : null} - {RAIL_LETTERS.map((letter) => { + {railLetters.map((letter) => { const present = activeLetters.has(letter); const scrubbing = letter === scrubLetter; return ( diff --git a/src/lib/albumSort.ts b/src/lib/albumSort.ts index 4ec1580..844c3e5 100644 --- a/src/lib/albumSort.ts +++ b/src/lib/albumSort.ts @@ -1,4 +1,5 @@ import type { Album } from '@/types/library'; +import { compareDirected, type SortDirection } from './sortDirection.ts'; export type AlbumSort = 'artist' | 'name' | 'recently_added' | 'year'; @@ -9,22 +10,47 @@ export const ALBUM_SORT_LABELS: Record = { year: 'Year', }; -/** 'artist' is buildAlbumList's native order (artist → album); others sort a copy. */ -export function sortAlbums(albums: Album[], sort: AlbumSort): Album[] { +export const ALBUM_SORT_LEGACY_DIRECTIONS: Record = { + artist: 'asc', + name: 'asc', + recently_added: 'desc', + year: 'desc', +}; + +/** Mirrors the native primary-direction/forward-tiebreak ordering. */ +export function sortAlbums( + albums: Album[], + sort: AlbumSort, + direction: SortDirection = ALBUM_SORT_LEGACY_DIRECTIONS[sort], +): Album[] { switch (sort) { case 'artist': - return albums; + return [...albums].sort((a, b) => + compareDirected(a.artist, b.artist, direction, (left, right) => left.localeCompare(right)) || + a.album.localeCompare(b.album) || + a.identity_key.localeCompare(b.identity_key) + ); case 'name': - return [...albums].sort((a, b) => a.album.localeCompare(b.album)); + return [...albums].sort((a, b) => + compareDirected(a.album, b.album, direction, (left, right) => left.localeCompare(right)) || + a.identity_key.localeCompare(b.identity_key) + ); case 'recently_added': - return [...albums].sort((a, b) => b.latest_added_at - a.latest_added_at); + return [...albums].sort((a, b) => + compareDirected(a.latest_added_at, b.latest_added_at, direction, (left, right) => left - right) || + a.identity_key.localeCompare(b.identity_key) + ); case 'year': - // Newest first, unknown years last, name tiebreak. + // Unknown years stay last in either direction. return [...albums].sort((a, b) => { - if (a.year == null && b.year == null) return a.album.localeCompare(b.album); + if (a.year == null && b.year == null) { + return a.album.localeCompare(b.album) || a.identity_key.localeCompare(b.identity_key); + } if (a.year == null) return 1; if (b.year == null) return -1; - return b.year - a.year || a.album.localeCompare(b.album); + return compareDirected(a.year, b.year, direction, (left, right) => left - right) || + a.album.localeCompare(b.album) || + a.identity_key.localeCompare(b.identity_key); }); } } diff --git a/src/lib/artistSort.ts b/src/lib/artistSort.ts index 843e285..c252c62 100644 --- a/src/lib/artistSort.ts +++ b/src/lib/artistSort.ts @@ -1,4 +1,5 @@ import type { Artist } from '@/types/library'; +import { compareDirected, type SortDirection } from './sortDirection.ts'; export type ArtistSort = 'name' | 'track_count'; @@ -7,14 +8,27 @@ export const ARTIST_SORT_LABELS: Record = { track_count: 'Track count', }; -/** 'name' is buildArtistList's native order; track count sorts a copy, most first. */ -export function sortArtists(artists: Artist[], sort: ArtistSort): Artist[] { +export const ARTIST_SORT_LEGACY_DIRECTIONS: Record = { + name: 'asc', + track_count: 'desc', +}; + +/** Mirrors the native primary-direction/forward-tiebreak ordering. */ +export function sortArtists( + artists: Artist[], + sort: ArtistSort, + direction: SortDirection = ARTIST_SORT_LEGACY_DIRECTIONS[sort], +): Artist[] { switch (sort) { case 'name': - return artists; + return [...artists].sort((a, b) => + compareDirected(a.artist, b.artist, direction, (left, right) => left.localeCompare(right)) + ); case 'track_count': return [...artists].sort( - (a, b) => b.track_count - a.track_count || a.artist.localeCompare(b.artist) + (a, b) => + compareDirected(a.track_count, b.track_count, direction, (left, right) => left - right) || + a.artist.localeCompare(b.artist) ); } } diff --git a/src/lib/letterIndex.ts b/src/lib/letterIndex.ts index 8ac6d39..408352a 100644 --- a/src/lib/letterIndex.ts +++ b/src/lib/letterIndex.ts @@ -7,6 +7,12 @@ export const RAIL_LETTERS: readonly string[] = [ ...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i)), ]; +const DESCENDING_RAIL_LETTERS: readonly string[] = [...RAIL_LETTERS].reverse(); + +export function railLettersForDirection(direction: 'asc' | 'desc'): readonly string[] { + return direction === 'desc' ? DESCENDING_RAIL_LETTERS : RAIL_LETTERS; +} + export interface LetterIndexEntry { letter: string; firstIndex: number; diff --git a/src/lib/librarySortDirection.test.mts b/src/lib/librarySortDirection.test.mts new file mode 100644 index 0000000..fb4e57d --- /dev/null +++ b/src/lib/librarySortDirection.test.mts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { sortTracks, TRACK_SORT_LEGACY_DIRECTIONS } from './trackSort.ts'; +import { sortAlbums, ALBUM_SORT_LEGACY_DIRECTIONS } from './albumSort.ts'; +import { sortArtists, ARTIST_SORT_LEGACY_DIRECTIONS } from './artistSort.ts'; +import { railLettersForDirection } from './letterIndex.ts'; +import { parseSortDirection } from './sortDirection.ts'; +import type { Album, Artist, DbTrack } from '../types/library.ts'; + +function track(values: Partial & Pick): DbTrack { + return { + artist: '', + album: '', + disc_number: null, + track_number: null, + duration: 0, + added_at: 0, + ...values, + } as DbTrack; +} + +function album(values: Partial & Pick): Album { + return { + artist: '', + year: null, + latest_added_at: 0, + ...values, + } as Album; +} + +function artist(name: string, count: number): Artist { + return { artist: name, track_count: count } as Artist; +} + +test('missing persisted directions preserve each legacy field order', () => { + assert.deepEqual(TRACK_SORT_LEGACY_DIRECTIONS, { + artist: 'asc', + title: 'asc', + recently_added: 'desc', + duration: 'desc', + }); + assert.deepEqual(ALBUM_SORT_LEGACY_DIRECTIONS, { + artist: 'asc', + name: 'asc', + recently_added: 'desc', + year: 'desc', + }); + assert.deepEqual(ARTIST_SORT_LEGACY_DIRECTIONS, { name: 'asc', track_count: 'desc' }); + assert.equal(parseSortDirection('asc'), 'asc'); + assert.equal(parseSortDirection('desc'), 'desc'); + assert.equal(parseSortDirection('sideways'), null); +}); + +test('track primary fields reverse while stable tiebreakers stay forward', () => { + const tracks = [ + track({ path: 'z-2', title: 'Second', artist: 'Zulu', album: 'Beta', track_number: 2 }), + track({ path: 'z-1', title: 'First', artist: 'Zulu', album: 'Alpha', track_number: 1 }), + track({ path: 'a-1', title: 'Third', artist: 'Alpha', album: 'Gamma', track_number: 1 }), + ]; + + assert.deepEqual(sortTracks(tracks, 'artist', 'desc').map((item) => item.path), [ + 'z-1', + 'z-2', + 'a-1', + ]); + assert.deepEqual(sortTracks(tracks, 'title', 'asc').map((item) => item.title), [ + 'First', + 'Second', + 'Third', + ]); + assert.deepEqual(sortTracks(tracks, 'title', 'desc').map((item) => item.title), [ + 'Third', + 'Second', + 'First', + ]); +}); + +test('numeric track fields support both directions', () => { + const tracks = [ + track({ path: 'b', title: 'B', added_at: 20, duration: 100 }), + track({ path: 'a', title: 'A', added_at: 10, duration: 200 }), + ]; + assert.deepEqual(sortTracks(tracks, 'recently_added', 'asc').map((item) => item.path), ['a', 'b']); + assert.deepEqual(sortTracks(tracks, 'recently_added', 'desc').map((item) => item.path), ['b', 'a']); + assert.deepEqual(sortTracks(tracks, 'duration', 'asc').map((item) => item.path), ['b', 'a']); + assert.deepEqual(sortTracks(tracks, 'duration', 'desc').map((item) => item.path), ['a', 'b']); +}); + +test('album directions preserve natural tiebreakers and keep unknown years last', () => { + const albums = [ + album({ identity_key: 'z-b', album: 'Beta', artist: 'Zulu', year: 2020, latest_added_at: 30 }), + album({ identity_key: 'z-a', album: 'Alpha', artist: 'Zulu', year: null, latest_added_at: 10 }), + album({ identity_key: 'a', album: 'Gamma', artist: 'Alpha', year: 1990, latest_added_at: 20 }), + ]; + assert.deepEqual(sortAlbums(albums, 'artist', 'desc').map((item) => item.identity_key), [ + 'z-a', + 'z-b', + 'a', + ]); + assert.deepEqual(sortAlbums(albums, 'year', 'asc').map((item) => item.identity_key), [ + 'a', + 'z-b', + 'z-a', + ]); + assert.deepEqual(sortAlbums(albums, 'year', 'desc').map((item) => item.identity_key), [ + 'z-b', + 'a', + 'z-a', + ]); +}); + +test('artist name and count sorts reverse only their primary field', () => { + const artists = [artist('Zulu', 2), artist('Alpha', 2), artist('Beta', 1)]; + assert.deepEqual(sortArtists(artists, 'name', 'desc').map((item) => item.artist), [ + 'Zulu', + 'Beta', + 'Alpha', + ]); + assert.deepEqual(sortArtists(artists, 'track_count', 'desc').map((item) => item.artist), [ + 'Alpha', + 'Zulu', + 'Beta', + ]); + assert.deepEqual(sortArtists(artists, 'track_count', 'asc').map((item) => item.artist), [ + 'Beta', + 'Alpha', + 'Zulu', + ]); +}); + +test('descending alphabet rail is a complete visual flip', () => { + const ascending = railLettersForDirection('asc'); + const descending = railLettersForDirection('desc'); + assert.equal(ascending[0], '#'); + assert.equal(ascending.at(-1), 'Z'); + assert.equal(descending[0], 'Z'); + assert.equal(descending.at(-1), '#'); + assert.deepEqual(descending, [...ascending].reverse()); +}); diff --git a/src/lib/sortDirection.ts b/src/lib/sortDirection.ts new file mode 100644 index 0000000..7a5c268 --- /dev/null +++ b/src/lib/sortDirection.ts @@ -0,0 +1,22 @@ +export type SortDirection = 'asc' | 'desc'; + +export const SORT_DIRECTION_LABELS: Record = { + asc: 'Ascending', + desc: 'Descending', +}; + +export const SORT_DIRECTIONS: readonly SortDirection[] = ['asc', 'desc']; + +export function parseSortDirection(value: string | null): SortDirection | null { + return value === 'asc' || value === 'desc' ? value : null; +} + +export function compareDirected( + left: T, + right: T, + direction: SortDirection, + compare: (a: T, b: T) => number, +): number { + const result = compare(left, right); + return direction === 'desc' ? -result : result; +} diff --git a/src/lib/trackSort.ts b/src/lib/trackSort.ts index 9f6e6a5..e43f1e8 100644 --- a/src/lib/trackSort.ts +++ b/src/lib/trackSort.ts @@ -1,4 +1,5 @@ import type { DbTrack } from '@/types/library'; +import { compareDirected, type SortDirection } from './sortDirection.ts'; export type TrackSort = 'artist' | 'title' | 'recently_added' | 'duration'; @@ -9,16 +10,43 @@ export const TRACK_SORT_LABELS: Record = { duration: 'Duration', }; -/** 'artist' is the DB's native order (getAllTracks); others sort a copy. */ -export function sortTracks(tracks: DbTrack[], sort: TrackSort): DbTrack[] { +export const TRACK_SORT_LEGACY_DIRECTIONS: Record = { + artist: 'asc', + title: 'asc', + recently_added: 'desc', + duration: 'desc', +}; + +/** Mirrors the native primary-direction/forward-tiebreak ordering. */ +export function sortTracks( + tracks: DbTrack[], + sort: TrackSort, + direction: SortDirection = TRACK_SORT_LEGACY_DIRECTIONS[sort], +): DbTrack[] { switch (sort) { case 'artist': - return tracks; + return [...tracks].sort((a, b) => + compareDirected(a.artist, b.artist, direction, (left, right) => left.localeCompare(right)) || + a.album.localeCompare(b.album) || + (a.disc_number ?? 0) - (b.disc_number ?? 0) || + (a.track_number ?? 0) - (b.track_number ?? 0) || + a.title.localeCompare(b.title) || + a.path.localeCompare(b.path) + ); case 'title': - return [...tracks].sort((a, b) => a.title.localeCompare(b.title)); + return [...tracks].sort((a, b) => + compareDirected(a.title, b.title, direction, (left, right) => left.localeCompare(right)) || + a.path.localeCompare(b.path) + ); case 'recently_added': - return [...tracks].sort((a, b) => b.added_at - a.added_at); + return [...tracks].sort((a, b) => + compareDirected(a.added_at, b.added_at, direction, (left, right) => left - right) || + a.path.localeCompare(b.path) + ); case 'duration': - return [...tracks].sort((a, b) => b.duration - a.duration); + return [...tracks].sort((a, b) => + compareDirected(a.duration, b.duration, direction, (left, right) => left - right) || + a.path.localeCompare(b.path) + ); } } diff --git a/src/library/libraryViewPresentation.test.mts b/src/library/libraryViewPresentation.test.mts index 3850138..5aa9176 100644 --- a/src/library/libraryViewPresentation.test.mts +++ b/src/library/libraryViewPresentation.test.mts @@ -6,6 +6,7 @@ import { libraryContextBarVisible, libraryContextBottomClearance, libraryContextOverlayHeight, + libraryRailBottomClearance, libraryContextScrimHeight, libraryDockSectionWidth, libraryDockShowsActiveLabel, @@ -114,6 +115,12 @@ test('the floating bar reserves its end-of-list runway while the fade starts abo assert.equal(libraryContextScrimHeight(8), 116); }); +test('the alphabet rail clears phone Library chrome but keeps the wide-screen boundary', () => { + assert.equal(libraryRailBottomClearance(true, 136), 136); + assert.equal(libraryRailBottomClearance(true, -1), 0); + assert.equal(libraryRailBottomClearance(false, 136), 0); +}); + test('mini-player visibility mirrors target fallback semantics', () => { assert.equal(effectiveMiniPlayerVisible({ selectedTarget: 'phone', diff --git a/src/library/libraryViewPresentation.ts b/src/library/libraryViewPresentation.ts index 78e63e7..785c21f 100644 --- a/src/library/libraryViewPresentation.ts +++ b/src/library/libraryViewPresentation.ts @@ -118,6 +118,14 @@ export function libraryContextOverlayHeight(bottomClearance: number): number { LIBRARY_CONTEXT_BAR_HEIGHT; } +/** Keep the A-Z gesture surface above phone-only floating Library chrome. */ +export function libraryRailBottomClearance( + phoneContextBar: boolean, + contextOverlayHeight: number, +): number { + return phoneContextBar ? Math.max(0, contextOverlayHeight) : 0; +} + /** * Starts the shared bottom fade above the command bar instead of at its edge, * so rows disappear gradually behind both pieces of floating chrome. diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index 0d892a4..b2f1f8b 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -18,9 +18,22 @@ import { } from '@/library/scanner'; import { endScanService, reportScanProgress } from '@/library/scanService'; import { requeueMissingArtistImages } from '@/library/artistImageLookup'; -import { ALBUM_SORT_LABELS, type AlbumSort } from '@/lib/albumSort'; -import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort'; -import { TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort'; +import { + ALBUM_SORT_LABELS, + ALBUM_SORT_LEGACY_DIRECTIONS, + type AlbumSort, +} from '@/lib/albumSort'; +import { + ARTIST_SORT_LABELS, + ARTIST_SORT_LEGACY_DIRECTIONS, + type ArtistSort, +} from '@/lib/artistSort'; +import { + TRACK_SORT_LABELS, + TRACK_SORT_LEGACY_DIRECTIONS, + type TrackSort, +} from '@/lib/trackSort'; +import { parseSortDirection, type SortDirection } from '@/lib/sortDirection'; import { DEFAULT_LIBRARY_LAYOUT, parseLibraryLayout, @@ -35,6 +48,9 @@ const VIEW_MODE_KEY = 'library_view_mode'; const TRACK_SORT_KEY = 'library_track_sort'; const ALBUM_SORT_KEY = 'library_album_sort'; const ARTIST_SORT_KEY = 'library_artist_sort'; +const TRACK_SORT_DIRECTION_KEY = 'library_track_sort_direction'; +const ALBUM_SORT_DIRECTION_KEY = 'library_album_sort_direction'; +const ARTIST_SORT_DIRECTION_KEY = 'library_artist_sort_direction'; const ALBUM_LAYOUT_KEY = 'library_album_layout'; const ARTIST_LAYOUT_KEY = 'library_artist_layout'; const INCLUDE_COLLAB_ARTISTS_KEY = 'library_include_collab_artists'; @@ -92,6 +108,9 @@ interface LibraryStore { trackSort: TrackSort; albumSort: AlbumSort; artistSort: ArtistSort; + trackSortDirection: SortDirection; + albumSortDirection: SortDirection; + artistSortDirection: SortDirection; albumLayout: LibraryLayout; artistLayout: LibraryLayout; includeCollabArtists: boolean; @@ -133,6 +152,9 @@ interface LibraryStore { setTrackSort: (sort: TrackSort) => void; setAlbumSort: (sort: AlbumSort) => void; setArtistSort: (sort: ArtistSort) => void; + setTrackSortDirection: (direction: SortDirection) => void; + setAlbumSortDirection: (direction: SortDirection) => void; + setArtistSortDirection: (direction: SortDirection) => void; setAlbumLayout: (layout: LibraryLayout) => void; setArtistLayout: (layout: LibraryLayout) => void; setIncludeCollabArtists: (include: boolean) => void; @@ -214,15 +236,18 @@ export const useLibraryStore = create((set, get) => { const readTrackPage = ( cursor: string | null, sort = get().trackSort, - ) => AstraLibraryData.getTrackPage(sort, cursor, PAGE_SIZE); + direction = get().trackSortDirection, + ) => AstraLibraryData.getTrackPage(sort, direction, cursor, PAGE_SIZE); const readAlbumPage = ( cursor: string | null, sort = get().albumSort, + direction = get().albumSortDirection, includeSingles = useSettingsStore.getState().includeSingles, ) => AstraLibraryData.getAlbumPage( sort, + direction, includeSingles, cursor, PAGE_SIZE @@ -231,11 +256,13 @@ export const useLibraryStore = create((set, get) => { const readArtistPage = ( cursor: string | null, sort = get().artistSort, + direction = get().artistSortDirection, groupingMode = useSettingsStore.getState().artistGroupingMode, includeCollaborations = get().includeCollabArtists, ) => AstraLibraryData.getArtistPage( sort, + direction, groupingMode, includeCollaborations, cursor, @@ -247,21 +274,25 @@ export const useLibraryStore = create((set, get) => { const readTrackPageBefore = ( cursor: string, sort: 'artist' | 'title', - ) => AstraLibraryData.getTrackPageBefore(sort, cursor, PAGE_SIZE); + direction = get().trackSortDirection, + ) => AstraLibraryData.getTrackPageBefore(sort, direction, cursor, PAGE_SIZE); const readAlbumPageBefore = ( cursor: string, sort: 'artist' | 'name', + direction = get().albumSortDirection, includeSingles = useSettingsStore.getState().includeSingles, - ) => AstraLibraryData.getAlbumPageBefore(sort, includeSingles, cursor, PAGE_SIZE); + ) => AstraLibraryData.getAlbumPageBefore(sort, direction, includeSingles, cursor, PAGE_SIZE); const readArtistPageBefore = ( cursor: string, + direction = get().artistSortDirection, groupingMode = useSettingsStore.getState().artistGroupingMode, includeCollaborations = get().includeCollabArtists, ) => AstraLibraryData.getArtistPageBefore( 'name', + direction, groupingMode, includeCollaborations, cursor, @@ -280,9 +311,14 @@ export const useLibraryStore = create((set, get) => { // PAGE_SIZE change would break it silently, leaving the list parked mid-catalog. const resetTracks = async (forceRemount = false) => { const sort = get().trackSort; + const direction = get().trackSortDirection; const generation = ++pageGenerations.tracks; - const page = await readTrackPage(null, sort); - if (generation !== pageGenerations.tracks || get().trackSort !== sort) return false; + const page = await readTrackPage(null, sort, direction); + if ( + generation !== pageGenerations.tracks || + get().trackSort !== sort || + get().trackSortDirection !== direction + ) return false; const items = page.items ?? []; set((current) => ({ tracks: items, @@ -297,12 +333,14 @@ export const useLibraryStore = create((set, get) => { const resetAlbums = async (forceRemount = false) => { const sort = get().albumSort; + const direction = get().albumSortDirection; const includeSingles = useSettingsStore.getState().includeSingles; const generation = ++pageGenerations.albums; - const page = await readAlbumPage(null, sort, includeSingles); + const page = await readAlbumPage(null, sort, direction, includeSingles); if ( generation !== pageGenerations.albums || get().albumSort !== sort || + get().albumSortDirection !== direction || useSettingsStore.getState().includeSingles !== includeSingles ) return false; const items = page.items ?? []; @@ -318,13 +356,21 @@ export const useLibraryStore = create((set, get) => { const resetArtists = async (forceRemount = false) => { const sort = get().artistSort; + const direction = get().artistSortDirection; const groupingMode = useSettingsStore.getState().artistGroupingMode; const includeCollaborations = get().includeCollabArtists; const generation = ++pageGenerations.artists; - const page = await readArtistPage(null, sort, groupingMode, includeCollaborations); + const page = await readArtistPage( + null, + sort, + direction, + groupingMode, + includeCollaborations, + ); if ( generation !== pageGenerations.artists || get().artistSort !== sort || + get().artistSortDirection !== direction || useSettingsStore.getState().artistGroupingMode !== groupingMode || get().includeCollabArtists !== includeCollaborations ) return false; @@ -356,11 +402,18 @@ export const useLibraryStore = create((set, get) => { : state.viewMode === 'albums' ? state.albumSort as 'artist' | 'name' : 'name'; + const direction = + state.viewMode === 'tracks' + ? state.trackSortDirection + : state.viewMode === 'albums' + ? state.albumSortDirection + : state.artistSortDirection; const includeSingles = useSettingsStore.getState().includeSingles; const groupingMode = useSettingsStore.getState().artistGroupingMode; const anchors = await AstraLibraryData.getSectionAnchors( state.viewMode as 'tracks' | 'albums' | 'artists', sort, + direction, includeSingles, groupingMode, state.includeCollabArtists @@ -372,10 +425,17 @@ export const useLibraryStore = create((set, get) => { : current.viewMode === 'albums' ? current.albumSort : current.artistSort; + const currentDirection = + current.viewMode === 'tracks' + ? current.trackSortDirection + : current.viewMode === 'albums' + ? current.albumSortDirection + : current.artistSortDirection; if ( generation !== anchorGeneration || current.viewMode !== state.viewMode || currentSort !== sort || + currentDirection !== direction || useSettingsStore.getState().includeSingles !== includeSingles || useSettingsStore.getState().artistGroupingMode !== groupingMode || current.includeCollabArtists !== state.includeCollabArtists @@ -432,6 +492,9 @@ export const useLibraryStore = create((set, get) => { trackSort: 'title', albumSort: 'name', artistSort: 'name', + trackSortDirection: 'asc', + albumSortDirection: 'asc', + artistSortDirection: 'asc', albumLayout: DEFAULT_LIBRARY_LAYOUT, artistLayout: DEFAULT_LIBRARY_LAYOUT, includeCollabArtists: false, @@ -468,6 +531,9 @@ export const useLibraryStore = create((set, get) => { TRACK_SORT_KEY, ALBUM_SORT_KEY, ARTIST_SORT_KEY, + TRACK_SORT_DIRECTION_KEY, + ALBUM_SORT_DIRECTION_KEY, + ARTIST_SORT_DIRECTION_KEY, ALBUM_LAYOUT_KEY, ARTIST_LAYOUT_KEY, INCLUDE_COLLAB_ARTISTS_KEY, @@ -476,15 +542,42 @@ export const useLibraryStore = create((set, get) => { const trackSort = parseTrackSort(values[TRACK_SORT_KEY] ?? null); const albumSort = parseAlbumSort(values[ALBUM_SORT_KEY] ?? null); const artistSort = parseArtistSort(values[ARTIST_SORT_KEY] ?? null); + const restoredTrackSort = trackSort ?? get().trackSort; + const restoredAlbumSort = albumSort ?? get().albumSort; + const restoredArtistSort = artistSort ?? get().artistSort; + const trackSortDirection = + parseSortDirection(values[TRACK_SORT_DIRECTION_KEY] ?? null) ?? + TRACK_SORT_LEGACY_DIRECTIONS[restoredTrackSort]; + const albumSortDirection = + parseSortDirection(values[ALBUM_SORT_DIRECTION_KEY] ?? null) ?? + ALBUM_SORT_LEGACY_DIRECTIONS[restoredAlbumSort]; + const artistSortDirection = + parseSortDirection(values[ARTIST_SORT_DIRECTION_KEY] ?? null) ?? + ARTIST_SORT_LEGACY_DIRECTIONS[restoredArtistSort]; set({ ...(viewMode ? { viewMode } : {}), ...(trackSort ? { trackSort } : {}), ...(albumSort ? { albumSort } : {}), ...(artistSort ? { artistSort } : {}), + trackSortDirection, + albumSortDirection, + artistSortDirection, albumLayout: parseLibraryLayout(values[ALBUM_LAYOUT_KEY] ?? null), artistLayout: parseLibraryLayout(values[ARTIST_LAYOUT_KEY] ?? null), includeCollabArtists: values[INCLUDE_COLLAB_ARTISTS_KEY] === 'true', }); + // Direction keys were introduced after sort-field persistence. Write + // the derived legacy direction once so subsequent field changes keep + // the per-view preference instead of re-deriving it. + if (!parseSortDirection(values[TRACK_SORT_DIRECTION_KEY] ?? null)) { + persistSetting(TRACK_SORT_DIRECTION_KEY, trackSortDirection); + } + if (!parseSortDirection(values[ALBUM_SORT_DIRECTION_KEY] ?? null)) { + persistSetting(ALBUM_SORT_DIRECTION_KEY, albumSortDirection); + } + if (!parseSortDirection(values[ARTIST_SORT_DIRECTION_KEY] ?? null)) { + persistSetting(ARTIST_SORT_DIRECTION_KEY, artistSortDirection); + } if (!nativeSubscriptionsInstalled) { nativeSubscriptionsInstalled = true; @@ -548,6 +641,9 @@ export const useLibraryStore = create((set, get) => { const trackSort = stateAtStart.trackSort; const albumSort = stateAtStart.albumSort; const artistSort = stateAtStart.artistSort; + const trackSortDirection = stateAtStart.trackSortDirection; + const albumSortDirection = stateAtStart.albumSortDirection; + const artistSortDirection = stateAtStart.artistSortDirection; const includeSingles = useSettingsStore.getState().includeSingles; const groupingMode = useSettingsStore.getState().artistGroupingMode; const includeCollaborations = stateAtStart.includeCollabArtists; @@ -568,21 +664,31 @@ export const useLibraryStore = create((set, get) => { folders, recentlyPlayedTracks, ] = await Promise.all([ - viewMode === 'tracks' ? readTrackPage(null, trackSort) : Promise.resolve(null), + viewMode === 'tracks' + ? readTrackPage(null, trackSort, trackSortDirection) + : Promise.resolve(null), viewMode === 'albums' - ? readAlbumPage(null, albumSort, includeSingles) + ? readAlbumPage(null, albumSort, albumSortDirection, includeSingles) : Promise.resolve(null), viewMode === 'artists' - ? readArtistPage(null, artistSort, groupingMode, includeCollaborations) + ? readArtistPage( + null, + artistSort, + artistSortDirection, + groupingMode, + includeCollaborations, + ) : Promise.resolve(null), AstraLibraryData.getAlbumPage( 'recently_added', + 'desc', includeSingles, null, 20 ), AstraLibraryData.getArtistPage( 'name', + 'asc', groupingMode, includeCollaborations, null, @@ -596,17 +702,20 @@ export const useLibraryStore = create((set, get) => { viewMode === 'tracks' && current.viewMode === 'tracks' && current.trackSort === trackSort && + current.trackSortDirection === trackSortDirection && activeGeneration === pageGenerations.tracks; const canApplyAlbumPage = viewMode === 'albums' && current.viewMode === 'albums' && current.albumSort === albumSort && + current.albumSortDirection === albumSortDirection && useSettingsStore.getState().includeSingles === includeSingles && activeGeneration === pageGenerations.albums; const canApplyArtistPage = viewMode === 'artists' && current.viewMode === 'artists' && current.artistSort === artistSort && + current.artistSortDirection === artistSortDirection && useSettingsStore.getState().artistGroupingMode === groupingMode && current.includeCollabArtists === includeCollaborations && activeGeneration === pageGenerations.artists; @@ -647,13 +756,15 @@ export const useLibraryStore = create((set, get) => { const cursor = state.trackNextCursor; if (!cursor || forwardBusy.tracks) return; const sort = state.trackSort; + const direction = state.trackSortDirection; const pageGeneration = pageGenerations.tracks; forwardBusy.tracks = true; try { - const page = await readTrackPage(cursor, sort); + const page = await readTrackPage(cursor, sort, direction); if ( pageGeneration !== pageGenerations.tracks || get().trackSort !== sort || + get().trackSortDirection !== direction || get().trackNextCursor !== cursor ) return; if (page.error === 'STALE_REVISION') { @@ -674,14 +785,16 @@ export const useLibraryStore = create((set, get) => { const cursor = state.albumNextCursor; if (!cursor || forwardBusy.albums) return; const sort = state.albumSort; + const direction = state.albumSortDirection; const includeSingles = useSettingsStore.getState().includeSingles; const pageGeneration = pageGenerations.albums; forwardBusy.albums = true; try { - const page = await readAlbumPage(cursor, sort, includeSingles); + const page = await readAlbumPage(cursor, sort, direction, includeSingles); if ( pageGeneration !== pageGenerations.albums || get().albumSort !== sort || + get().albumSortDirection !== direction || useSettingsStore.getState().includeSingles !== includeSingles || get().albumNextCursor !== cursor ) return; @@ -703,15 +816,23 @@ export const useLibraryStore = create((set, get) => { const cursor = state.artistNextCursor; if (!cursor || forwardBusy.artists) return; const sort = state.artistSort; + const direction = state.artistSortDirection; const groupingMode = useSettingsStore.getState().artistGroupingMode; const includeCollaborations = state.includeCollabArtists; const pageGeneration = pageGenerations.artists; forwardBusy.artists = true; try { - const page = await readArtistPage(cursor, sort, groupingMode, includeCollaborations); + const page = await readArtistPage( + cursor, + sort, + direction, + groupingMode, + includeCollaborations, + ); if ( pageGeneration !== pageGenerations.artists || get().artistSort !== sort || + get().artistSortDirection !== direction || useSettingsStore.getState().artistGroupingMode !== groupingMode || get().includeCollabArtists !== includeCollaborations || get().artistNextCursor !== cursor @@ -733,14 +854,16 @@ export const useLibraryStore = create((set, get) => { const state = get(); const cursor = state.trackPrevCursor; const sort = backwardTrackSort(state.trackSort); + const direction = state.trackSortDirection; if (!cursor || !sort || backwardBusy.tracks) return; const pageGeneration = pageGenerations.tracks; backwardBusy.tracks = true; try { - const page = await readTrackPageBefore(cursor, sort); + const page = await readTrackPageBefore(cursor, sort, direction); if ( pageGeneration !== pageGenerations.tracks || get().trackSort !== sort || + get().trackSortDirection !== direction || get().trackPrevCursor !== cursor ) return; if (page.error === 'STALE_REVISION') { @@ -760,15 +883,17 @@ export const useLibraryStore = create((set, get) => { const state = get(); const cursor = state.albumPrevCursor; const sort = backwardAlbumSort(state.albumSort); + const direction = state.albumSortDirection; 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); + const page = await readAlbumPageBefore(cursor, sort, direction, includeSingles); if ( pageGeneration !== pageGenerations.albums || get().albumSort !== sort || + get().albumSortDirection !== direction || useSettingsStore.getState().includeSingles !== includeSingles || get().albumPrevCursor !== cursor ) return; @@ -789,15 +914,22 @@ export const useLibraryStore = create((set, get) => { const state = get(); const cursor = state.artistPrevCursor; if (!cursor || state.artistSort !== 'name' || backwardBusy.artists) return; + const direction = state.artistSortDirection; const groupingMode = useSettingsStore.getState().artistGroupingMode; const includeCollaborations = state.includeCollabArtists; const pageGeneration = pageGenerations.artists; backwardBusy.artists = true; try { - const page = await readArtistPageBefore(cursor, groupingMode, includeCollaborations); + const page = await readArtistPageBefore( + cursor, + direction, + groupingMode, + includeCollaborations, + ); if ( pageGeneration !== pageGenerations.artists || get().artistSort !== 'name' || + get().artistSortDirection !== direction || useSettingsStore.getState().artistGroupingMode !== groupingMode || get().includeCollabArtists !== includeCollaborations || get().artistPrevCursor !== cursor @@ -827,15 +959,19 @@ export const useLibraryStore = create((set, get) => { const generation = ++pageGenerations[viewMode]; if (viewMode === 'tracks') { const sort = state.trackSort; + const direction = state.trackSortDirection; const backwardSort = backwardTrackSort(sort); const [page, before] = await Promise.all([ - readTrackPage(cursor, sort), - backwardSort ? readTrackPageBefore(cursor, backwardSort) : Promise.resolve(null), + readTrackPage(cursor, sort, direction), + backwardSort + ? readTrackPageBefore(cursor, backwardSort, direction) + : Promise.resolve(null), ]); if ( generation !== pageGenerations.tracks || get().viewMode !== viewMode || - get().trackSort !== sort + get().trackSort !== sort || + get().trackSortDirection !== direction ) return false; if (page.error === 'STALE_REVISION') { await resetTracks(); @@ -856,18 +992,20 @@ export const useLibraryStore = create((set, get) => { })); } else if (viewMode === 'albums') { const sort = state.albumSort; + const direction = state.albumSortDirection; const includeSingles = useSettingsStore.getState().includeSingles; const backwardSort = backwardAlbumSort(sort); const [page, before] = await Promise.all([ - readAlbumPage(cursor, sort, includeSingles), + readAlbumPage(cursor, sort, direction, includeSingles), backwardSort - ? readAlbumPageBefore(cursor, backwardSort, includeSingles) + ? readAlbumPageBefore(cursor, backwardSort, direction, includeSingles) : Promise.resolve(null), ]); if ( generation !== pageGenerations.albums || get().viewMode !== viewMode || get().albumSort !== sort || + get().albumSortDirection !== direction || useSettingsStore.getState().includeSingles !== includeSingles ) return false; if (page.error === 'STALE_REVISION') { @@ -888,18 +1026,20 @@ export const useLibraryStore = create((set, get) => { })); } else { const sort = state.artistSort; + const direction = state.artistSortDirection; const groupingMode = useSettingsStore.getState().artistGroupingMode; const includeCollaborations = state.includeCollabArtists; const [page, before] = await Promise.all([ - readArtistPage(cursor, sort, groupingMode, includeCollaborations), + readArtistPage(cursor, sort, direction, groupingMode, includeCollaborations), sort === 'name' - ? readArtistPageBefore(cursor, groupingMode, includeCollaborations) + ? readArtistPageBefore(cursor, direction, groupingMode, includeCollaborations) : Promise.resolve(null), ]); if ( generation !== pageGenerations.artists || get().viewMode !== viewMode || get().artistSort !== sort || + get().artistSortDirection !== direction || useSettingsStore.getState().artistGroupingMode !== groupingMode || get().includeCollabArtists !== includeCollaborations ) return false; @@ -1048,6 +1188,54 @@ export const useLibraryStore = create((set, get) => { void resetSectionAnchors(); }, + setTrackSortDirection: (trackSortDirection) => { + if (get().trackSortDirection === trackSortDirection) return; + anchorGeneration += 1; + set({ + trackSortDirection, + tracks: [], + trackNextCursor: null, + trackPrevCursor: null, + jumpAnchorIndex: 0, + sectionAnchors: [], + }); + persistSetting(TRACK_SORT_DIRECTION_KEY, trackSortDirection); + void resetTracks(true); + void resetSectionAnchors(); + }, + + setAlbumSortDirection: (albumSortDirection) => { + if (get().albumSortDirection === albumSortDirection) return; + anchorGeneration += 1; + set({ + albumSortDirection, + albums: [], + albumNextCursor: null, + albumPrevCursor: null, + jumpAnchorIndex: 0, + sectionAnchors: [], + }); + persistSetting(ALBUM_SORT_DIRECTION_KEY, albumSortDirection); + void resetAlbums(true); + void resetSectionAnchors(); + }, + + setArtistSortDirection: (artistSortDirection) => { + if (get().artistSortDirection === artistSortDirection) return; + anchorGeneration += 1; + set({ + artistSortDirection, + artists: [], + artistNextCursor: null, + artistPrevCursor: null, + jumpAnchorIndex: 0, + sectionAnchors: [], + }); + persistSetting(ARTIST_SORT_DIRECTION_KEY, artistSortDirection); + void resetArtists(true); + void resetSectionAnchors(); + }, + setAlbumLayout: (albumLayout) => { if (get().albumLayout === albumLayout) return; const state = get();