add library sort ordering

This commit is contained in:
Boof2015
2026-08-11 21:09:00 -04:00
parent 23b8aa3d94
commit 7941cc2ae6
17 changed files with 1356 additions and 149 deletions
@@ -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<ActiveTrackView> { it.titleSortKey }.thenBy { it.path })
val paged = mutableListOf<ActiveTrackView>()
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 @Test
fun structuredArtistCreditsPreserveNamesContainingPunctuation() = runBlocking { fun structuredArtistCreditsPreserveNamesContainingPunctuation() = runBlocking {
val artistNames = listOf("Earth, Wind & Fire", "The Emotions") val artistNames = listOf("Earth, Wind & Fire", "The Emotions")
@@ -965,6 +1171,25 @@ class RoomLibraryRepositoryTest {
sectionLabel = SortKeys.sectionLabel(title), 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 { private companion object {
/** 26 letters x 10 tracks. */ /** 26 letters x 10 tracks. */
const val ALPHABET_SEED_SIZE = 260 const val ALPHABET_SEED_SIZE = 260
@@ -81,11 +81,12 @@ class AstraLibraryDataModule : Module() {
AsyncFunction("getTrackPage") Coroutine { AsyncFunction("getTrackPage") Coroutine {
sort: String, sort: String,
direction: String,
cursor: String?, cursor: String?,
limit: Int, limit: Int,
-> ->
try { try {
repository().getTrackPage(sort, cursor, limit) repository().getTrackPage(sort, direction, cursor, limit)
} catch (_: StaleRevisionException) { } catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION") mapOf("error" to "STALE_REVISION")
} }
@@ -93,11 +94,12 @@ class AstraLibraryDataModule : Module() {
AsyncFunction("getTrackPageBefore") Coroutine { AsyncFunction("getTrackPageBefore") Coroutine {
sort: String, sort: String,
direction: String,
cursor: String?, cursor: String?,
limit: Int, limit: Int,
-> ->
try { try {
repository().getTrackPageBefore(sort, cursor, limit) repository().getTrackPageBefore(sort, direction, cursor, limit)
} catch (_: StaleRevisionException) { } catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION") mapOf("error" to "STALE_REVISION")
} }
@@ -385,12 +387,13 @@ class AstraLibraryDataModule : Module() {
AsyncFunction("getAlbumPage") Coroutine { AsyncFunction("getAlbumPage") Coroutine {
sort: String, sort: String,
direction: String,
includeSingles: Boolean, includeSingles: Boolean,
cursor: String?, cursor: String?,
limit: Int, limit: Int,
-> ->
try { try {
repository().getAlbumPage(sort, includeSingles, cursor, limit) repository().getAlbumPage(sort, direction, includeSingles, cursor, limit)
} catch (_: StaleRevisionException) { } catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION") mapOf("error" to "STALE_REVISION")
} }
@@ -398,12 +401,13 @@ class AstraLibraryDataModule : Module() {
AsyncFunction("getAlbumPageBefore") Coroutine { AsyncFunction("getAlbumPageBefore") Coroutine {
sort: String, sort: String,
direction: String,
includeSingles: Boolean, includeSingles: Boolean,
cursor: String?, cursor: String?,
limit: Int, limit: Int,
-> ->
try { try {
repository().getAlbumPageBefore(sort, includeSingles, cursor, limit) repository().getAlbumPageBefore(sort, direction, includeSingles, cursor, limit)
} catch (_: StaleRevisionException) { } catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION") mapOf("error" to "STALE_REVISION")
} }
@@ -411,13 +415,14 @@ class AstraLibraryDataModule : Module() {
AsyncFunction("getArtistPage") Coroutine { AsyncFunction("getArtistPage") Coroutine {
sort: String, sort: String,
direction: String,
groupingMode: String, groupingMode: String,
includeCollaborations: Boolean, includeCollaborations: Boolean,
cursor: String?, cursor: String?,
limit: Int, limit: Int,
-> ->
try { try {
repository().getArtistPage(sort, groupingMode, includeCollaborations, cursor, limit) repository().getArtistPage(sort, direction, groupingMode, includeCollaborations, cursor, limit)
} catch (_: StaleRevisionException) { } catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION") mapOf("error" to "STALE_REVISION")
} }
@@ -425,13 +430,21 @@ class AstraLibraryDataModule : Module() {
AsyncFunction("getArtistPageBefore") Coroutine { AsyncFunction("getArtistPageBefore") Coroutine {
sort: String, sort: String,
direction: String,
groupingMode: String, groupingMode: String,
includeCollaborations: Boolean, includeCollaborations: Boolean,
cursor: String?, cursor: String?,
limit: Int, limit: Int,
-> ->
try { try {
repository().getArtistPageBefore(sort, groupingMode, includeCollaborations, cursor, limit) repository().getArtistPageBefore(
sort,
direction,
groupingMode,
includeCollaborations,
cursor,
limit,
)
} catch (_: StaleRevisionException) { } catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION") mapOf("error" to "STALE_REVISION")
} }
@@ -560,6 +573,7 @@ class AstraLibraryDataModule : Module() {
AsyncFunction("getSectionAnchors") Coroutine { AsyncFunction("getSectionAnchors") Coroutine {
kind: String, kind: String,
sort: String, sort: String,
direction: String,
includeSingles: Boolean, includeSingles: Boolean,
groupingMode: String, groupingMode: String,
includeCollaborations: Boolean, includeCollaborations: Boolean,
@@ -567,6 +581,7 @@ class AstraLibraryDataModule : Module() {
repository().getSectionAnchors( repository().getSectionAnchors(
kind, kind,
sort, sort,
direction,
includeSingles, includeSingles,
groupingMode, groupingMode,
includeCollaborations, includeCollaborations,
@@ -635,41 +635,43 @@ class AstraLibraryRepository private constructor(
suspend fun getTrackPage( suspend fun getTrackPage(
sort: String, sort: String,
directionRaw: String,
cursorRaw: String?, cursorRaw: String?,
requestedLimit: Int, requestedLimit: Int,
): Map<String, Any?> = withCatalogRecovery { database -> ): Map<String, Any?> = withCatalogRecovery { database ->
initialize() initialize()
val dao = database.catalogDao() val dao = database.catalogDao()
val revision = dao.getRevision() 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 limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val rows = when (sort) { val rows = when (sort) {
"artist" -> dao.getArtistOrderPage( "artist" -> (if (direction == "desc") dao::getArtistOrderPageDescending else dao::getArtistOrderPage)(
afterArtistKey = cursor?.text1, cursor?.text1,
afterAlbumKey = cursor?.text2.orEmpty(), cursor?.text2.orEmpty(),
afterDisc = cursor?.number1?.toInt() ?: 0, cursor?.number1?.toInt() ?: 0,
afterTrack = cursor?.number2?.toInt() ?: 0, cursor?.number2?.toInt() ?: 0,
afterTitleKey = cursor?.let(::cursorTitleKey).orEmpty(), cursor?.let(::cursorTitleKey).orEmpty(),
afterPath = cursor?.let { cursorPath(it) }.orEmpty(), cursor?.let { cursorPath(it) }.orEmpty(),
limit = limit, limit,
) )
"recently_added" -> dao.getRecentlyAddedPage( "recently_added" -> (if (direction == "asc") dao::getRecentlyAddedPageAscending else dao::getRecentlyAddedPage)(
afterAddedAt = cursor?.number1, cursor?.number1,
afterPath = cursor?.text1.orEmpty(), cursor?.text1.orEmpty(),
limit = limit, limit,
) )
"duration" -> dao.getDurationPage( "duration" -> (if (direction == "asc") dao::getDurationPageAscending else dao::getDurationPage)(
afterDuration = cursor?.decimal1, cursor?.decimal1,
afterPath = cursor?.text1.orEmpty(), cursor?.text1.orEmpty(),
limit = limit, limit,
) )
else -> dao.getTitlePage( else -> (if (direction == "desc") dao::getTitlePageDescending else dao::getTitlePage)(
afterTitleKey = cursor?.text1, cursor?.text1,
afterPath = cursor?.text2.orEmpty(), cursor?.text2.orEmpty(),
limit = limit, 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( mapOf(
"items" to rows.map(ActiveTrackView::toBridgeMap), "items" to rows.map(ActiveTrackView::toBridgeMap),
"nextCursor" to next, "nextCursor" to next,
@@ -693,36 +695,46 @@ class AstraLibraryRepository private constructor(
*/ */
suspend fun getTrackPageBefore( suspend fun getTrackPageBefore(
sort: String, sort: String,
directionRaw: String,
cursorRaw: String?, cursorRaw: String?,
requestedLimit: Int, requestedLimit: Int,
): Map<String, Any?> = withCatalogRecovery { database -> ): Map<String, Any?> = withCatalogRecovery { database ->
initialize() initialize()
val dao = database.catalogDao() val dao = database.catalogDao()
val revision = dao.getRevision() 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 limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val descending = when { val descending = when {
cursor == null -> emptyList() cursor == null -> emptyList()
sort == "artist" -> dao.getArtistOrderPageBefore( sort == "artist" -> (if (direction == "desc") {
beforeArtistKey = cursor.text1.orEmpty(), dao::getArtistOrderPageBeforeDescending
beforeAlbumKey = cursor.text2.orEmpty(), } else {
beforeDisc = cursor.number1?.toInt() ?: 0, dao::getArtistOrderPageBefore
beforeTrack = cursor.number2?.toInt() ?: 0, })(
beforeTitleKey = cursorTitleKey(cursor), cursor.text1.orEmpty(),
beforePath = cursorPath(cursor), cursor.text2.orEmpty(),
limit = limit, cursor.number1?.toInt() ?: 0,
cursor.number2?.toInt() ?: 0,
cursorTitleKey(cursor),
cursorPath(cursor),
limit,
) )
sort == "title" -> dao.getTitlePageBefore( sort == "title" -> (if (direction == "desc") {
beforeTitleKey = cursor.text1.orEmpty(), dao::getTitlePageBeforeDescending
beforePath = cursor.text2.orEmpty(), } else {
limit = limit, dao::getTitlePageBefore
})(
cursor.text1.orEmpty(),
cursor.text2.orEmpty(),
limit,
) )
else -> emptyList() else -> emptyList()
} }
// The DESC result's last row is the topmost one — the cursor for the page above this one. // 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 } val previous = descending.takeIf { it.size == limit }
?.lastOrNull() ?.lastOrNull()
?.let { row -> trackCursor(revision, sort, row).encode() } ?.let { row -> trackCursor(revision, sort, direction, row).encode() }
mapOf( mapOf(
"items" to descending.reversed().map(ActiveTrackView::toBridgeMap), "items" to descending.reversed().map(ActiveTrackView::toBridgeMap),
"nextCursor" to null, "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. */ /** 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) { when (sort) {
"artist" -> TrackPageCursor( "artist" -> TrackPageCursor(
revision = revision, revision = revision,
kind = "tracks:$sort", kind = "tracks:$sort:$direction",
text1 = row.artistSortKey, text1 = row.artistSortKey,
text2 = row.albumSortKey, text2 = row.albumSortKey,
text3 = "${row.titleSortKey}\u0000${row.path}", text3 = "${row.titleSortKey}\u0000${row.path}",
@@ -748,19 +765,19 @@ class AstraLibraryRepository private constructor(
) )
"recently_added" -> TrackPageCursor( "recently_added" -> TrackPageCursor(
revision = revision, revision = revision,
kind = "tracks:$sort", kind = "tracks:$sort:$direction",
text1 = row.path, text1 = row.path,
number1 = row.addedAt, number1 = row.addedAt,
) )
"duration" -> TrackPageCursor( "duration" -> TrackPageCursor(
revision = revision, revision = revision,
kind = "tracks:$sort", kind = "tracks:$sort:$direction",
text1 = row.path, text1 = row.path,
decimal1 = row.duration, decimal1 = row.duration,
) )
else -> TrackPageCursor( else -> TrackPageCursor(
revision = revision, revision = revision,
kind = "tracks:$sort", kind = "tracks:$sort:$direction",
text1 = row.titleSortKey, text1 = row.titleSortKey,
text2 = row.path, text2 = row.path,
) )
@@ -1882,17 +1899,19 @@ class AstraLibraryRepository private constructor(
suspend fun getAlbumPage( suspend fun getAlbumPage(
sort: String, sort: String,
directionRaw: String,
includeSingles: Boolean, includeSingles: Boolean,
cursorRaw: String?, cursorRaw: String?,
requestedLimit: Int, requestedLimit: Int,
): Map<String, Any?> = withCatalogRecovery { database -> ): Map<String, Any?> = withCatalogRecovery { database ->
val dao = database.catalogDao() val dao = database.catalogDao()
val revision = dao.getRevision() 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 cursor = validateCursor(cursorRaw, revision, kind)
val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val rows = when (sort) { val rows = when (sort) {
"artist" -> dao.getAlbumArtistPage( "artist" -> (if (direction == "desc") dao::getAlbumArtistPageDescending else dao::getAlbumArtistPage)(
revision, revision,
includeSingles, includeSingles,
cursor?.text1, cursor?.text1,
@@ -1900,22 +1919,23 @@ class AstraLibraryRepository private constructor(
cursor?.text3.orEmpty(), cursor?.text3.orEmpty(),
limit, limit,
) )
"recently_added" -> dao.getAlbumRecentPage( "recently_added" -> (if (direction == "asc") dao::getAlbumRecentPageAscending else dao::getAlbumRecentPage)(
revision, revision,
includeSingles, includeSingles,
cursor?.number1, cursor?.number1,
cursor?.text1.orEmpty(), cursor?.text1.orEmpty(),
limit, limit,
) )
"year" -> dao.getAlbumYearPage( "year" -> (if (direction == "asc") dao::getAlbumYearPageAscending else dao::getAlbumYearPage)(
revision, revision,
includeSingles, includeSingles,
cursor?.number1?.toInt(), cursor?.number1?.toInt() ?: 0,
cursor?.number2?.toInt(),
cursor?.text1.orEmpty(), cursor?.text1.orEmpty(),
cursor?.text2.orEmpty(), cursor?.text2.orEmpty(),
limit, limit,
) )
else -> dao.getAlbumNamePage( else -> (if (direction == "desc") dao::getAlbumNamePageDescending else dao::getAlbumNamePage)(
revision, revision,
includeSingles, includeSingles,
cursor?.text1, cursor?.text1,
@@ -1936,18 +1956,24 @@ class AstraLibraryRepository private constructor(
/** Backward twin of [getAlbumPage]; see [getTrackPageBefore] for the contract. */ /** Backward twin of [getAlbumPage]; see [getTrackPageBefore] for the contract. */
suspend fun getAlbumPageBefore( suspend fun getAlbumPageBefore(
sort: String, sort: String,
directionRaw: String,
includeSingles: Boolean, includeSingles: Boolean,
cursorRaw: String?, cursorRaw: String?,
requestedLimit: Int, requestedLimit: Int,
): Map<String, Any?> = withCatalogRecovery { database -> ): Map<String, Any?> = withCatalogRecovery { database ->
val dao = database.catalogDao() val dao = database.catalogDao()
val revision = dao.getRevision() 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 cursor = validateCursor(cursorRaw, revision, kind)
val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val descending = when { val descending = when {
cursor == null -> emptyList() cursor == null -> emptyList()
sort == "artist" -> dao.getAlbumArtistPageBefore( sort == "artist" -> (if (direction == "desc") {
dao::getAlbumArtistPageBeforeDescending
} else {
dao::getAlbumArtistPageBefore
})(
revision, revision,
includeSingles, includeSingles,
cursor.text1.orEmpty(), cursor.text1.orEmpty(),
@@ -1955,7 +1981,11 @@ class AstraLibraryRepository private constructor(
cursor.text3.orEmpty(), cursor.text3.orEmpty(),
limit, limit,
) )
sort == "name" -> dao.getAlbumNamePageBefore( sort == "name" -> (if (direction == "desc") {
dao::getAlbumNamePageBeforeDescending
} else {
dao::getAlbumNamePageBefore
})(
revision, revision,
includeSingles, includeSingles,
cursor.text1.orEmpty(), cursor.text1.orEmpty(),
@@ -2003,6 +2033,7 @@ class AstraLibraryRepository private constructor(
text1 = row.nameSortKey, text1 = row.nameSortKey,
text2 = row.identityKey, text2 = row.identityKey,
number1 = (row.year ?: 0).toLong(), number1 = (row.year ?: 0).toLong(),
number2 = if (row.year == null) 1 else 0,
) )
else -> TrackPageCursor( else -> TrackPageCursor(
revision, revision,
@@ -2014,6 +2045,7 @@ class AstraLibraryRepository private constructor(
suspend fun getArtistPage( suspend fun getArtistPage(
sort: String, sort: String,
directionRaw: String,
groupingMode: String, groupingMode: String,
includeCollaborations: Boolean, includeCollaborations: Boolean,
cursorRaw: String?, cursorRaw: String?,
@@ -2022,11 +2054,12 @@ class AstraLibraryRepository private constructor(
val dao = database.catalogDao() val dao = database.catalogDao()
val revision = dao.getRevision() val revision = dao.getRevision()
val mode = if (groupingMode == "fileTags") "fileTags" else "astra" 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 cursor = validateCursor(cursorRaw, revision, kind)
val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val rows = if (sort == "track_count") { val rows = if (sort == "track_count") {
dao.getArtistCountPage( (if (direction == "asc") dao::getArtistCountPageAscending else dao::getArtistCountPage)(
revision, revision,
mode, mode,
includeCollaborations, includeCollaborations,
@@ -2036,7 +2069,7 @@ class AstraLibraryRepository private constructor(
limit, limit,
) )
} else { } else {
dao.getArtistNamePage( (if (direction == "desc") dao::getArtistNamePageDescending else dao::getArtistNamePage)(
revision, revision,
mode, mode,
includeCollaborations, includeCollaborations,
@@ -2058,6 +2091,7 @@ class AstraLibraryRepository private constructor(
/** Backward twin of [getArtistPage]; see [getTrackPageBefore] for the contract. */ /** Backward twin of [getArtistPage]; see [getTrackPageBefore] for the contract. */
suspend fun getArtistPageBefore( suspend fun getArtistPageBefore(
sort: String, sort: String,
directionRaw: String,
groupingMode: String, groupingMode: String,
includeCollaborations: Boolean, includeCollaborations: Boolean,
cursorRaw: String?, cursorRaw: String?,
@@ -2066,13 +2100,18 @@ class AstraLibraryRepository private constructor(
val dao = database.catalogDao() val dao = database.catalogDao()
val revision = dao.getRevision() val revision = dao.getRevision()
val mode = if (groupingMode == "fileTags") "fileTags" else "astra" 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 cursor = validateCursor(cursorRaw, revision, kind)
val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val descending = if (cursor == null || sort == "track_count") { val descending = if (cursor == null || sort == "track_count") {
emptyList() emptyList()
} else { } else {
dao.getArtistNamePageBefore( (if (direction == "desc") {
dao::getArtistNamePageBeforeDescending
} else {
dao::getArtistNamePageBefore
})(
revision, revision,
mode, mode,
includeCollaborations, includeCollaborations,
@@ -2689,6 +2728,7 @@ class AstraLibraryRepository private constructor(
suspend fun getSectionAnchors( suspend fun getSectionAnchors(
kind: String, kind: String,
sort: String, sort: String,
directionRaw: String,
includeSingles: Boolean, includeSingles: Boolean,
groupingMode: String, groupingMode: String,
includeCollaborations: Boolean, includeCollaborations: Boolean,
@@ -2696,6 +2736,7 @@ class AstraLibraryRepository private constructor(
withCatalogRecovery { database -> withCatalogRecovery { database ->
val dao = database.catalogDao() val dao = database.catalogDao()
val revision = dao.getRevision() val revision = dao.getRevision()
val direction = normalizeSortDirection(directionRaw)
val anchors: List<Pair<String, TrackPageCursor>> = when (kind) { val anchors: List<Pair<String, TrackPageCursor>> = when (kind) {
"albums" -> { "albums" -> {
val rows = dao.getAllAlbumSummaries(revision).filter { includeSingles || !it.isSingle } 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) if (sort == "artist") SortKeys.sectionLabel(row.artist) else SortKeys.sectionLabel(row.album)
}.map { (label, section) -> }.map { (label, section) ->
if (sort == "artist") { if (sort == "artist") {
val first = section.minWith(compareBy<AlbumSummaryEntity>({ it.artistSortKey }, { it.nameSortKey }, { it.identityKey })) val first = section.sortedWith(
if (direction == "desc") {
compareByDescending<AlbumSummaryEntity> { it.artistSortKey }
.thenBy { it.nameSortKey }
.thenBy { it.identityKey }
} else {
compareBy<AlbumSummaryEntity>({ it.artistSortKey }, { it.nameSortKey }, { it.identityKey })
},
).first()
label to TrackPageCursor( label to TrackPageCursor(
revision, revision,
"albums:artist:${if (includeSingles) 1 else 0}", "albums:artist:$direction:${if (includeSingles) 1 else 0}",
text1 = first.artistSortKey, text1 = first.artistSortKey,
) )
} else { } else {
val first = section.minWith(compareBy<AlbumSummaryEntity>({ it.nameSortKey }, { it.identityKey })) val first = section.sortedWith(
if (direction == "desc") {
compareByDescending<AlbumSummaryEntity> { it.nameSortKey }
.thenBy { it.identityKey }
} else {
compareBy<AlbumSummaryEntity>({ it.nameSortKey }, { it.identityKey })
},
).first()
label to TrackPageCursor( label to TrackPageCursor(
revision, revision,
"albums:name:${if (includeSingles) 1 else 0}", "albums:name:$direction:${if (includeSingles) 1 else 0}",
text1 = first.nameSortKey, text1 = first.nameSortKey,
) )
} }
@@ -2725,10 +2781,17 @@ class AstraLibraryRepository private constructor(
.filter { includeCollaborations || !it.isCollaboration } .filter { includeCollaborations || !it.isCollaboration }
.groupBy { row -> SortKeys.sectionLabel(row.artist) } .groupBy { row -> SortKeys.sectionLabel(row.artist) }
.map { (label, section) -> .map { (label, section) ->
val first = section.minWith(compareBy<ArtistSummaryEntity>({ it.nameSortKey }, { it.artistKey })) val first = section.sortedWith(
if (direction == "desc") {
compareByDescending<ArtistSummaryEntity> { it.nameSortKey }
.thenBy { it.artistKey }
} else {
compareBy<ArtistSummaryEntity>({ it.nameSortKey }, { it.artistKey })
},
).first()
label to TrackPageCursor( label to TrackPageCursor(
revision, revision,
"artists:name:$mode:${if (includeCollaborations) 1 else 0}", "artists:name:$direction:$mode:${if (includeCollaborations) 1 else 0}",
text1 = first.nameSortKey, text1 = first.nameSortKey,
) )
} }
@@ -2740,22 +2803,38 @@ class AstraLibraryRepository private constructor(
.map { (label, section) -> .map { (label, section) ->
label to TrackPageCursor( label to TrackPageCursor(
revision, revision,
"tracks:artist", "tracks:artist:$direction",
text1 = section.minOf(ArtistSectionAnchorCandidate::sortKey), text1 = if (direction == "desc") {
section.maxOf(ArtistSectionAnchorCandidate::sortKey)
} else {
section.minOf(ArtistSectionAnchorCandidate::sortKey)
},
) )
} }
} else { } else {
dao.getTitleSectionAnchors().map { row -> val rows = if (direction == "desc") {
dao.getTitleSectionAnchorsDescending()
} else {
dao.getTitleSectionAnchors()
}
rows.map { row ->
row.sectionLabel to TrackPageCursor( row.sectionLabel to TrackPageCursor(
revision, revision,
"tracks:title", "tracks:title:$direction",
text1 = row.sortKey, text1 = row.sortKey,
) )
} }
} }
} }
} }
anchors.sortedWith(compareBy<Pair<String, TrackPageCursor>> { it.second.text1 }.thenBy { it.first }) val anchorComparator = compareBy<Pair<String, TrackPageCursor>> { it.second.text1 }
.thenBy { it.first }
val orderedAnchors = if (direction == "desc") {
anchors.sortedWith(anchorComparator.reversed())
} else {
anchors.sortedWith(anchorComparator)
}
orderedAnchors
.map { (label, cursor) -> .map { (label, cursor) ->
mapOf( mapOf(
"label" to label, "label" to label,
@@ -2842,11 +2921,33 @@ class AstraLibraryRepository private constructor(
"manual" -> (context["paths"] as? List<*>) "manual" -> (context["paths"] as? List<*>)
?.mapNotNull { it as? String } ?.mapNotNull { it as? String }
.orEmpty() .orEmpty()
else -> when (context["sort"] as? String) { else -> {
"artist" -> catalogDao.getAllPathsByArtist() val sort = context["sort"] as? String ?: "title"
"recently_added" -> catalogDao.getAllPathsByRecentlyAdded() val direction = (context["direction"] as? String)
"duration" -> catalogDao.getAllPathsByDuration() ?.takeIf { it == "asc" || it == "desc" }
else -> catalogDao.getAllPathsByTitle() ?: 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 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 = private fun cursorPath(cursor: TrackPageCursor): String =
cursor.text3?.substringAfter('\u0000', "") ?: "" cursor.text3?.substringAfter('\u0000', "") ?: ""
@@ -213,6 +213,9 @@ interface CatalogDao {
@Query("SELECT path FROM active_tracks ORDER BY title_sort_key, path") @Query("SELECT path FROM active_tracks ORDER BY title_sort_key, path")
suspend fun getAllPathsByTitle(): List<String> suspend fun getAllPathsByTitle(): List<String>
@Query("SELECT path FROM active_tracks ORDER BY title_sort_key DESC, path")
suspend fun getAllPathsByTitleDescending(): List<String>
@Query( @Query(
""" """
SELECT path FROM active_tracks SELECT path FROM active_tracks
@@ -221,12 +224,26 @@ interface CatalogDao {
) )
suspend fun getAllPathsByArtist(): List<String> suspend fun getAllPathsByArtist(): List<String>
@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<String>
@Query("SELECT path FROM active_tracks ORDER BY added_at DESC, path") @Query("SELECT path FROM active_tracks ORDER BY added_at DESC, path")
suspend fun getAllPathsByRecentlyAdded(): List<String> suspend fun getAllPathsByRecentlyAdded(): List<String>
@Query("SELECT path FROM active_tracks ORDER BY added_at, path")
suspend fun getAllPathsByRecentlyAddedAscending(): List<String>
@Query("SELECT path FROM active_tracks ORDER BY duration DESC, path") @Query("SELECT path FROM active_tracks ORDER BY duration DESC, path")
suspend fun getAllPathsByDuration(): List<String> suspend fun getAllPathsByDuration(): List<String>
@Query("SELECT path FROM active_tracks ORDER BY duration, path")
suspend fun getAllPathsByDurationAscending(): List<String>
@Query( @Query(
""" """
SELECT path FROM active_tracks SELECT path FROM active_tracks
@@ -364,6 +381,22 @@ interface CatalogDao {
limit: Int, limit: Int,
): List<ActiveTrackView> ): List<ActiveTrackView>
@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<ActiveTrackView>
/** /**
* Mirror of [getTitlePage] walking backwards. Rows come out DESC the caller * Mirror of [getTitlePage] walking backwards. Rows come out DESC the caller
* reverses them so `items` is ascending like every other page. Backward paging * reverses them so `items` is ascending like every other page. Backward paging
@@ -384,6 +417,21 @@ interface CatalogDao {
limit: Int, limit: Int,
): List<ActiveTrackView> ): List<ActiveTrackView>
@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<ActiveTrackView>
@Query( @Query(
""" """
SELECT * FROM active_tracks SELECT * FROM active_tracks
@@ -411,6 +459,33 @@ interface CatalogDao {
limit: Int, limit: Int,
): List<ActiveTrackView> ): List<ActiveTrackView>
@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<ActiveTrackView>
/** Mirror of [getArtistOrderPage] walking backwards; rows come out DESC. */ /** Mirror of [getArtistOrderPage] walking backwards; rows come out DESC. */
@Query( @Query(
""" """
@@ -439,6 +514,33 @@ interface CatalogDao {
limit: Int, limit: Int,
): List<ActiveTrackView> ): List<ActiveTrackView>
@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<ActiveTrackView>
@Query( @Query(
""" """
SELECT * FROM active_tracks SELECT * FROM active_tracks
@@ -455,6 +557,22 @@ interface CatalogDao {
limit: Int, limit: Int,
): List<ActiveTrackView> ): List<ActiveTrackView>
@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<ActiveTrackView>
@Query( @Query(
""" """
SELECT * FROM active_tracks SELECT * FROM active_tracks
@@ -471,6 +589,22 @@ interface CatalogDao {
limit: Int, limit: Int,
): List<ActiveTrackView> ): List<ActiveTrackView>
@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<ActiveTrackView>
@Query( @Query(
""" """
SELECT * FROM active_tracks SELECT * FROM active_tracks
@@ -542,6 +676,16 @@ interface CatalogDao {
) )
suspend fun getTitleSectionAnchors(): List<SectionAnchorRow> suspend fun getTitleSectionAnchors(): List<SectionAnchorRow>
@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<SectionAnchorRow>
@Query( @Query(
""" """
SELECT artist, MIN(artist_sort_key) AS sort_key SELECT artist, MIN(artist_sort_key) AS sort_key
@@ -595,6 +739,26 @@ interface CatalogDao {
limit: Int, limit: Int,
): List<AlbumSummaryEntity> ): List<AlbumSummaryEntity>
@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<AlbumSummaryEntity>
/** Mirror of [getAlbumNamePage] walking backwards; rows come out DESC. */ /** Mirror of [getAlbumNamePage] walking backwards; rows come out DESC. */
@Query( @Query(
""" """
@@ -615,6 +779,25 @@ interface CatalogDao {
limit: Int, limit: Int,
): List<AlbumSummaryEntity> ): List<AlbumSummaryEntity>
@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<AlbumSummaryEntity>
@Query( @Query(
""" """
SELECT * FROM album_summaries SELECT * FROM album_summaries
@@ -638,6 +821,29 @@ interface CatalogDao {
limit: Int, limit: Int,
): List<AlbumSummaryEntity> ): List<AlbumSummaryEntity>
@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<AlbumSummaryEntity>
/** Mirror of [getAlbumArtistPage] walking backwards; rows come out DESC. */ /** Mirror of [getAlbumArtistPage] walking backwards; rows come out DESC. */
@Query( @Query(
""" """
@@ -661,6 +867,28 @@ interface CatalogDao {
limit: Int, limit: Int,
): List<AlbumSummaryEntity> ): List<AlbumSummaryEntity>
@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<AlbumSummaryEntity>
@Query( @Query(
""" """
SELECT * FROM album_summaries SELECT * FROM album_summaries
@@ -686,12 +914,36 @@ interface CatalogDao {
SELECT * FROM album_summaries SELECT * FROM album_summaries
WHERE revision = :revision WHERE revision = :revision
AND (:includeSingles OR is_single = 0) AND (:includeSingles OR is_single = 0)
AND (:afterYear IS NULL AND (:afterAddedAt IS NULL
OR COALESCE(year, 0) < :afterYear OR latest_added_at > :afterAddedAt
OR (COALESCE(year, 0) = :afterYear AND name_sort_key > :afterNameKey) OR (latest_added_at = :afterAddedAt AND identity_key > :afterId))
OR (COALESCE(year, 0) = :afterYear AND name_sort_key = :afterNameKey ORDER BY latest_added_at, identity_key
LIMIT :limit
""",
)
suspend fun getAlbumRecentPageAscending(
revision: Long,
includeSingles: Boolean,
afterAddedAt: Long?,
afterId: String,
limit: Int,
): List<AlbumSummaryEntity>
@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)) 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 LIMIT :limit
""", """,
) )
@@ -699,6 +951,35 @@ interface CatalogDao {
revision: Long, revision: Long,
includeSingles: Boolean, includeSingles: Boolean,
afterYear: Int?, afterYear: Int?,
afterUnknown: Int?,
afterNameKey: String,
afterId: String,
limit: Int,
): List<AlbumSummaryEntity>
@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, afterNameKey: String,
afterId: String, afterId: String,
limit: Int, limit: Int,
@@ -747,6 +1028,28 @@ interface CatalogDao {
limit: Int, limit: Int,
): List<ArtistSummaryEntity> ): List<ArtistSummaryEntity>
@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<ArtistSummaryEntity>
/** Mirror of [getArtistNamePage] walking backwards; rows come out DESC. */ /** Mirror of [getArtistNamePage] walking backwards; rows come out DESC. */
@Query( @Query(
""" """
@@ -769,6 +1072,27 @@ interface CatalogDao {
limit: Int, limit: Int,
): List<ArtistSummaryEntity> ): List<ArtistSummaryEntity>
@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<ArtistSummaryEntity>
@Query( @Query(
""" """
SELECT * FROM artist_summaries SELECT * FROM artist_summaries
@@ -793,6 +1117,30 @@ interface CatalogDao {
limit: Int, limit: Int,
): List<ArtistSummaryEntity> ): List<ArtistSummaryEntity>
@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<ArtistSummaryEntity>
@Query( @Query(
""" """
SELECT * FROM artist_summaries SELECT * FROM artist_summaries
+12 -1
View File
@@ -240,7 +240,11 @@ export interface NativePage<T> {
} }
export type LibraryQuery = 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: 'album'; albumKey: string }
| { | {
kind: 'artist'; kind: 'artist';
@@ -362,6 +366,7 @@ declare class AstraLibraryDataModuleType extends NativeModule<AstraLibraryDataEv
removeFolder(folderId: number): Promise<void>; removeFolder(folderId: number): Promise<void>;
getTrackPage<T>( getTrackPage<T>(
sort: 'artist' | 'title' | 'recently_added' | 'duration', sort: 'artist' | 'title' | 'recently_added' | 'duration',
direction: 'asc' | 'desc',
cursor: string | null, cursor: string | null,
limit: number limit: number
): Promise<NativePage<T>>; ): Promise<NativePage<T>>;
@@ -372,6 +377,7 @@ declare class AstraLibraryDataModuleType extends NativeModule<AstraLibraryDataEv
*/ */
getTrackPageBefore<T>( getTrackPageBefore<T>(
sort: 'artist' | 'title', sort: 'artist' | 'title',
direction: 'asc' | 'desc',
cursor: string | null, cursor: string | null,
limit: number limit: number
): Promise<NativePage<T>>; ): Promise<NativePage<T>>;
@@ -489,6 +495,7 @@ declare class AstraLibraryDataModuleType extends NativeModule<AstraLibraryDataEv
clearDesktopSyncBaselines(): Promise<void>; clearDesktopSyncBaselines(): Promise<void>;
getAlbumPage<T>( getAlbumPage<T>(
sort: 'artist' | 'name' | 'recently_added' | 'year', sort: 'artist' | 'name' | 'recently_added' | 'year',
direction: 'asc' | 'desc',
includeSingles: boolean, includeSingles: boolean,
cursor: string | null, cursor: string | null,
limit: number limit: number
@@ -496,12 +503,14 @@ declare class AstraLibraryDataModuleType extends NativeModule<AstraLibraryDataEv
/** Backward twin of `getAlbumPage`; see `getTrackPageBefore`. */ /** Backward twin of `getAlbumPage`; see `getTrackPageBefore`. */
getAlbumPageBefore<T>( getAlbumPageBefore<T>(
sort: 'artist' | 'name', sort: 'artist' | 'name',
direction: 'asc' | 'desc',
includeSingles: boolean, includeSingles: boolean,
cursor: string | null, cursor: string | null,
limit: number limit: number
): Promise<NativePage<T>>; ): Promise<NativePage<T>>;
getArtistPage<T>( getArtistPage<T>(
sort: 'name' | 'track_count', sort: 'name' | 'track_count',
direction: 'asc' | 'desc',
groupingMode: 'astra' | 'fileTags', groupingMode: 'astra' | 'fileTags',
includeCollaborations: boolean, includeCollaborations: boolean,
cursor: string | null, cursor: string | null,
@@ -510,6 +519,7 @@ declare class AstraLibraryDataModuleType extends NativeModule<AstraLibraryDataEv
/** Backward twin of `getArtistPage`; see `getTrackPageBefore`. */ /** Backward twin of `getArtistPage`; see `getTrackPageBefore`. */
getArtistPageBefore<T>( getArtistPageBefore<T>(
sort: 'name', sort: 'name',
direction: 'asc' | 'desc',
groupingMode: 'astra' | 'fileTags', groupingMode: 'astra' | 'fileTags',
includeCollaborations: boolean, includeCollaborations: boolean,
cursor: string | null, cursor: string | null,
@@ -604,6 +614,7 @@ declare class AstraLibraryDataModuleType extends NativeModule<AstraLibraryDataEv
getSectionAnchors( getSectionAnchors(
kind: 'tracks' | 'albums' | 'artists', kind: 'tracks' | 'albums' | 'artists',
sort: 'artist' | 'title' | 'name', sort: 'artist' | 'title' | 'name',
direction: 'asc' | 'desc',
includeSingles: boolean, includeSingles: boolean,
groupingMode: 'astra' | 'fileTags', groupingMode: 'astra' | 'fileTags',
includeCollaborations: boolean includeCollaborations: boolean
+1
View File
@@ -95,6 +95,7 @@
"test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs", "test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs",
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/navigation/shellLayout.test.mts src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/homeLibraryNavigation.test.mts src/navigation/tabTransition.test.mts src/navigation/statsTabState.test.mts src/navigation/tabStackReset.test.mts src/navigation/tabReselect.test.mts src/navigation/scrollToTopBehavior.test.mts src/library/libraryWindowTop.test.mts src/library/libraryViewMode.test.mts src/components/selectionSlideMath.test.mts src/components/topFadeMath.test.mts src/components/screenHeaderLayout.test.mts", "test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/navigation/shellLayout.test.mts src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/homeLibraryNavigation.test.mts src/navigation/tabTransition.test.mts src/navigation/statsTabState.test.mts src/navigation/tabStackReset.test.mts src/navigation/tabReselect.test.mts src/navigation/scrollToTopBehavior.test.mts src/library/libraryWindowTop.test.mts src/library/libraryViewMode.test.mts src/components/selectionSlideMath.test.mts src/components/topFadeMath.test.mts src/components/screenHeaderLayout.test.mts",
"test:library-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/libraryLayout.test.mts src/library/libraryViewPresentation.test.mts src/components/library/detailHeroLayout.test.mts", "test:library-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/libraryLayout.test.mts src/library/libraryViewPresentation.test.mts src/components/library/detailHeroLayout.test.mts",
"test:library-sort": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/librarySortDirection.test.mts",
"test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts", "test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts",
"test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts", "test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts",
"test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts src/home/homeLayout.test.mts", "test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts src/home/homeLayout.test.mts",
+70 -16
View File
@@ -30,6 +30,7 @@ import {
useScreenHeader, useScreenHeader,
} from '@/components/ScreenHeader'; } from '@/components/ScreenHeader';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { SegmentedControl } from '@/components/SegmentedControl';
import { ViewModeSwitcher } from '@/components/library/ViewModeSwitcher'; import { ViewModeSwitcher } from '@/components/library/ViewModeSwitcher';
import { AlbumGridItem } from '@/components/library/AlbumGridItem'; import { AlbumGridItem } from '@/components/library/AlbumGridItem';
import { ArtistGridItem } from '@/components/library/ArtistGridItem'; import { ArtistGridItem } from '@/components/library/ArtistGridItem';
@@ -80,6 +81,7 @@ import {
libraryContextBottomClearance, libraryContextBottomClearance,
libraryContextBarVisible, libraryContextBarVisible,
libraryContextOverlayHeight, libraryContextOverlayHeight,
libraryRailBottomClearance,
libraryContextScrimHeight, libraryContextScrimHeight,
} from '@/library/libraryViewPresentation'; } from '@/library/libraryViewPresentation';
import { import {
@@ -109,6 +111,10 @@ import {
ARTIST_SORT_LABELS, ARTIST_SORT_LABELS,
type ArtistSort type ArtistSort
} from '@/lib/artistSort'; } from '@/lib/artistSort';
import {
SORT_DIRECTION_LABELS,
type SortDirection,
} from '@/lib/sortDirection';
import { import {
LIBRARY_LAYOUT_OPTIONS, LIBRARY_LAYOUT_OPTIONS,
libraryGridColumns, libraryGridColumns,
@@ -176,10 +182,16 @@ export default function LibraryScreen() {
const tracks = useLibraryStore((s) => s.tracks); const tracks = useLibraryStore((s) => s.tracks);
const trackSort = useLibraryStore((s) => s.trackSort); const trackSort = useLibraryStore((s) => s.trackSort);
const setTrackSort = useLibraryStore((s) => s.setTrackSort); 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 albumSort = useLibraryStore((s) => s.albumSort);
const setAlbumSort = useLibraryStore((s) => s.setAlbumSort); 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 artistSort = useLibraryStore((s) => s.artistSort);
const setArtistSort = useLibraryStore((s) => s.setArtistSort); 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 albumLayout = useLibraryStore((s) => s.albumLayout);
const setAlbumLayout = useLibraryStore((s) => s.setAlbumLayout); const setAlbumLayout = useLibraryStore((s) => s.setAlbumLayout);
const artistLayout = useLibraryStore((s) => s.artistLayout); 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. // Tap index is within sortedTracks so the tapped row is the track that plays.
const playAllFrom = (index: number) => { const playAllFrom = (index: number) => {
void playLibraryQuery({ kind: 'library', sort: trackSort }, { void playLibraryQuery({
kind: 'library',
sort: trackSort,
direction: trackSortDirection,
}, {
anchorPath: sortedTracks[index]?.path, anchorPath: sortedTracks[index]?.path,
source: { kind: 'library', label: 'Library' }, source: { kind: 'library', label: 'Library' },
}); });
@@ -502,6 +518,18 @@ export default function LibraryScreen() {
: viewMode === 'albums' : viewMode === 'albums'
? ALBUM_SORT_LABELS[albumSort] ? ALBUM_SORT_LABELS[albumSort]
: ARTIST_SORT_LABELS[artistSort]; : 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 = const sortSheetLabel =
viewMode === 'tracks' ? 'SORT TRACKS BY' : viewMode === 'albums' ? 'SORT ALBUMS BY' : 'SORT ARTISTS BY'; viewMode === 'tracks' ? 'SORT TRACKS BY' : viewMode === 'albums' ? 'SORT ALBUMS BY' : 'SORT ARTISTS BY';
const sortItems = const sortItems =
@@ -542,11 +570,11 @@ export default function LibraryScreen() {
const surfaceHeadIdentity = const surfaceHeadIdentity =
viewMode === 'albums' viewMode === 'albums'
? `albums:${albumSort}:${albumLayout}:${albumColumns}` ? `albums:${albumSort}:${albumSortDirection}:${albumLayout}:${albumColumns}`
: viewMode === 'artists' : viewMode === 'artists'
? `artists:${artistSort}:${artistLayout}:${artistColumns}` ? `artists:${artistSort}:${artistSortDirection}:${artistLayout}:${artistColumns}`
: viewMode === 'tracks' : viewMode === 'tracks'
? `tracks:${trackSort}` ? `tracks:${trackSort}:${trackSortDirection}`
: viewMode; : viewMode;
const listMountIdentity = `${surfaceHeadIdentity}:${sectionJumpRevision}`; const listMountIdentity = `${surfaceHeadIdentity}:${sectionJumpRevision}`;
const activeListMountIdentity = useRef(listMountIdentity); const activeListMountIdentity = useRef(listMountIdentity);
@@ -738,10 +766,14 @@ export default function LibraryScreen() {
style={styles.sortTrigger} style={styles.sortTrigger}
onPress={() => setSortSheetOpen(true)} onPress={() => setSortSheetOpen(true)}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Sort by ${sortLabel}`} accessibilityLabel={`Sort by ${sortLabel}, ${sortDirectionLabel}`}
> >
<Ionicons name="swap-vertical" size={14} color={colors.textSecondary} /> <Ionicons
<Text variant="label">{sortLabel}</Text> name={sortDirection === 'asc' ? 'arrow-up' : 'arrow-down'}
size={14}
color={colors.textSecondary}
/>
<Text variant="label">{sortLabel} · {sortDirectionLabel}</Text>
</AppPressable> </AppPressable>
{activeLayout && activeLayoutLabel ? ( {activeLayout && activeLayoutLabel ? (
<AppPressable feedback="control" <AppPressable feedback="control"
@@ -787,7 +819,7 @@ export default function LibraryScreen() {
{viewMode === 'albums' ? ( {viewMode === 'albums' ? (
<ReanimatedFlashList <ReanimatedFlashList
ref={albumListRef} ref={albumListRef}
key={`albums-${albumSort}-${albumLayout}-${albumColumns}-${sectionJumpRevision}`} key={`albums-${albumSort}-${albumSortDirection}-${albumLayout}-${albumColumns}-${sectionJumpRevision}`}
data={sortedAlbums} data={sortedAlbums}
numColumns={albumColumns} numColumns={albumColumns}
keyExtractor={(album) => album.identity_key} keyExtractor={(album) => album.identity_key}
@@ -841,7 +873,7 @@ export default function LibraryScreen() {
{viewMode === 'artists' ? ( {viewMode === 'artists' ? (
<ReanimatedFlashList <ReanimatedFlashList
ref={artistListRef} ref={artistListRef}
key={`artists-${artistSort}-${artistLayout}-${artistColumns}-${sectionJumpRevision}`} key={`artists-${artistSort}-${artistSortDirection}-${artistLayout}-${artistColumns}-${sectionJumpRevision}`}
data={sortedArtists} data={sortedArtists}
numColumns={artistColumns} numColumns={artistColumns}
keyExtractor={(artist) => artist.artist} keyExtractor={(artist) => artist.artist}
@@ -895,7 +927,7 @@ export default function LibraryScreen() {
{viewMode === 'tracks' ? ( {viewMode === 'tracks' ? (
<ReanimatedFlashList <ReanimatedFlashList
ref={trackListRef} ref={trackListRef}
key={`tracks-${trackSort}-${sectionJumpRevision}`} key={`tracks-${trackSort}-${trackSortDirection}-${sectionJumpRevision}`}
data={sortedTracks} data={sortedTracks}
keyExtractor={(track) => String(track.id)} keyExtractor={(track) => String(track.id)}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
@@ -966,11 +998,18 @@ export default function LibraryScreen() {
// off its right edge, so it needs a box that matches the part of // off its right edge, so it needs a box that matches the part of
// the list a finger can actually reach. // the list a finger can actually reach.
<View <View
style={[styles.railArea, { top: header.contentPaddingTop }]} style={[
styles.railArea,
{
top: header.contentPaddingTop,
bottom: libraryRailBottomClearance(phoneContextBar, contextOverlayHeight),
},
]}
pointerEvents="box-none" pointerEvents="box-none"
> >
<AlphabetRail <AlphabetRail
activeLetters={railLetters} activeLetters={railLetters}
direction={sortDirection}
onJumpToLetter={jumpToLetter} onJumpToLetter={jumpToLetter}
onScrubEnd={flushJump} onScrubEnd={flushJump}
/> />
@@ -1015,7 +1054,7 @@ export default function LibraryScreen() {
: viewMode === 'tracks' : viewMode === 'tracks'
? { ? {
icon: 'swap-vertical', icon: 'swap-vertical',
label: `Sort Tracks, currently ${sortLabel}`, label: `Sort Tracks, currently ${sortLabel}, ${sortDirectionLabel}`,
onPress: () => setSortSheetOpen(true), onPress: () => setSortSheetOpen(true),
} }
: viewMode === 'playlists' : viewMode === 'playlists'
@@ -1069,12 +1108,18 @@ export default function LibraryScreen() {
key={key} key={key}
label={label} label={label}
selected={selected} selected={selected}
onPress={() => { onPress={onSelect}
onSelect();
setSortSheetOpen(false);
}}
/> />
))} ))}
<AppSheetSection label="DIRECTION" />
<SegmentedControl
value={sortDirection}
segments={[
{ key: 'asc', label: 'Ascending' },
{ key: 'desc', label: 'Descending' },
]}
onChange={(direction) => setSortDirection(direction === 'desc' ? 'desc' : 'asc')}
/>
</AppSheet> </AppSheet>
) : null} ) : null}
{layoutSheetOpen && activeLayout ? ( {layoutSheetOpen && activeLayout ? (
@@ -1107,6 +1152,15 @@ export default function LibraryScreen() {
onPress={onSelect} onPress={onSelect}
/> />
))} ))}
<AppSheetSection label="DIRECTION" />
<SegmentedControl
value={sortDirection}
segments={[
{ key: 'asc', label: 'Ascending' },
{ key: 'desc', label: 'Descending' },
]}
onChange={(direction) => setSortDirection(direction === 'desc' ? 'desc' : 'asc')}
/>
<AppSheetSection label={layoutSheetLabel} /> <AppSheetSection label={layoutSheetLabel} />
{LIBRARY_LAYOUT_OPTIONS.map((option) => ( {LIBRARY_LAYOUT_OPTIONS.map((option) => (
<AppSheetItem <AppSheetItem
+14 -6
View File
@@ -9,7 +9,8 @@ import { createThemedStyles } from '@/theme/themed';
import { rgbaFromHex } from '@/theme/colorUtils'; import { rgbaFromHex } from '@/theme/colorUtils';
import { playHaptic } from '@/lib/haptics'; import { playHaptic } from '@/lib/haptics';
import { usePullSearchGestureRef } from '@/components/search/PullSearchGesture'; import { usePullSearchGestureRef } from '@/components/search/PullSearchGesture';
import { RAIL_LETTERS } from '@/lib/letterIndex'; import { RAIL_LETTERS, railLettersForDirection } from '@/lib/letterIndex';
import type { SortDirection } from '@/lib/sortDirection';
const CELL_HEIGHT = 17; const CELL_HEIGHT = 17;
const RAIL_PAD = spacing.xs; const RAIL_PAD = spacing.xs;
@@ -19,6 +20,7 @@ const BUBBLE_SIZE = 52;
interface AlphabetRailProps { interface AlphabetRailProps {
/** Letters present in the current list — the rest render dimmed. */ /** Letters present in the current list — the rest render dimmed. */
activeLetters: ReadonlySet<string>; activeLetters: ReadonlySet<string>;
direction: SortDirection;
onJumpToLetter: (letter: string) => void; onJumpToLetter: (letter: string) => void;
/** /**
* Fired when the finger lifts. The screen debounces `onJumpToLetter` so a fast * 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 * changes on a letter-cross). Blocks the pull-to-search gesture so a scrub at
* scroll-top never arms the search indicator. * 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 styles = useStyles();
const pullSearchRef = usePullSearchGestureRef(); const pullSearchRef = usePullSearchGestureRef();
const [scrubLetter, setScrubLetter] = useState<string | null>(null); const [scrubLetter, setScrubLetter] = useState<string | null>(null);
const railLetters = railLettersForDirection(direction);
const lastLetter = useSharedValue(''); const lastLetter = useSharedValue('');
// Rail's top offset inside the (vertically-centered) wrap + the finger's Y // 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. // within the rail, so the bubble can be placed in wrap-space.
@@ -72,7 +80,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter, onScrubEnd }: Alph
0, 0,
Math.min(RAIL_LETTERS.length - 1, Math.floor((y - RAIL_PAD) / CELL_HEIGHT)) 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; lastLetter.value = letter;
runOnJS(scrubTo)(letter); runOnJS(scrubTo)(letter);
}) })
@@ -86,7 +94,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter, onScrubEnd }: Alph
0, 0,
Math.min(RAIL_LETTERS.length - 1, Math.floor((y - RAIL_PAD) / CELL_HEIGHT)) 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; if (letter === lastLetter.value) return;
lastLetter.value = letter; lastLetter.value = letter;
runOnJS(scrubTo)(letter); runOnJS(scrubTo)(letter);
@@ -98,7 +106,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter, onScrubEnd }: Alph
}); });
return pullSearchRef ? gesture.blocksExternalGesture(pullSearchRef) : gesture; return pullSearchRef ? gesture.blocksExternalGesture(pullSearchRef) : gesture;
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrubTo/endScrub capture the latest props via render closure // 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(() => ({ const bubbleStyle = useAnimatedStyle(() => ({
transform: [{ translateY: bubbleY.value - BUBBLE_SIZE / 2 }], transform: [{ translateY: bubbleY.value - BUBBLE_SIZE / 2 }],
@@ -119,7 +127,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter, onScrubEnd }: Alph
) : null} ) : null}
<GestureDetector gesture={pan}> <GestureDetector gesture={pan}>
<View style={styles.rail} hitSlop={{ left: 12, right: 8 }} onLayout={onRailLayout}> <View style={styles.rail} hitSlop={{ left: 12, right: 8 }} onLayout={onRailLayout}>
{RAIL_LETTERS.map((letter) => { {railLetters.map((letter) => {
const present = activeLetters.has(letter); const present = activeLetters.has(letter);
const scrubbing = letter === scrubLetter; const scrubbing = letter === scrubLetter;
return ( return (
+34 -8
View File
@@ -1,4 +1,5 @@
import type { Album } from '@/types/library'; import type { Album } from '@/types/library';
import { compareDirected, type SortDirection } from './sortDirection.ts';
export type AlbumSort = 'artist' | 'name' | 'recently_added' | 'year'; export type AlbumSort = 'artist' | 'name' | 'recently_added' | 'year';
@@ -9,22 +10,47 @@ export const ALBUM_SORT_LABELS: Record<AlbumSort, string> = {
year: 'Year', year: 'Year',
}; };
/** 'artist' is buildAlbumList's native order (artist → album); others sort a copy. */ export const ALBUM_SORT_LEGACY_DIRECTIONS: Record<AlbumSort, SortDirection> = {
export function sortAlbums(albums: Album[], sort: AlbumSort): Album[] { 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) { switch (sort) {
case 'artist': 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': 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': 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': case 'year':
// Newest first, unknown years last, name tiebreak. // Unknown years stay last in either direction.
return [...albums].sort((a, b) => { 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 (a.year == null) return 1;
if (b.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);
}); });
} }
} }
+18 -4
View File
@@ -1,4 +1,5 @@
import type { Artist } from '@/types/library'; import type { Artist } from '@/types/library';
import { compareDirected, type SortDirection } from './sortDirection.ts';
export type ArtistSort = 'name' | 'track_count'; export type ArtistSort = 'name' | 'track_count';
@@ -7,14 +8,27 @@ export const ARTIST_SORT_LABELS: Record<ArtistSort, string> = {
track_count: 'Track count', track_count: 'Track count',
}; };
/** 'name' is buildArtistList's native order; track count sorts a copy, most first. */ export const ARTIST_SORT_LEGACY_DIRECTIONS: Record<ArtistSort, SortDirection> = {
export function sortArtists(artists: Artist[], sort: ArtistSort): Artist[] { 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) { switch (sort) {
case 'name': case 'name':
return artists; return [...artists].sort((a, b) =>
compareDirected(a.artist, b.artist, direction, (left, right) => left.localeCompare(right))
);
case 'track_count': case 'track_count':
return [...artists].sort( 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)
); );
} }
} }
+6
View File
@@ -7,6 +7,12 @@ export const RAIL_LETTERS: readonly string[] = [
...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i)), ...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 { export interface LetterIndexEntry {
letter: string; letter: string;
firstIndex: number; firstIndex: number;
+139
View File
@@ -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<DbTrack> & Pick<DbTrack, 'path' | 'title'>): DbTrack {
return {
artist: '',
album: '',
disc_number: null,
track_number: null,
duration: 0,
added_at: 0,
...values,
} as DbTrack;
}
function album(values: Partial<Album> & Pick<Album, 'identity_key' | 'album'>): 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());
});
+22
View File
@@ -0,0 +1,22 @@
export type SortDirection = 'asc' | 'desc';
export const SORT_DIRECTION_LABELS: Record<SortDirection, string> = {
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<T>(
left: T,
right: T,
direction: SortDirection,
compare: (a: T, b: T) => number,
): number {
const result = compare(left, right);
return direction === 'desc' ? -result : result;
}
+34 -6
View File
@@ -1,4 +1,5 @@
import type { DbTrack } from '@/types/library'; import type { DbTrack } from '@/types/library';
import { compareDirected, type SortDirection } from './sortDirection.ts';
export type TrackSort = 'artist' | 'title' | 'recently_added' | 'duration'; export type TrackSort = 'artist' | 'title' | 'recently_added' | 'duration';
@@ -9,16 +10,43 @@ export const TRACK_SORT_LABELS: Record<TrackSort, string> = {
duration: 'Duration', duration: 'Duration',
}; };
/** 'artist' is the DB's native order (getAllTracks); others sort a copy. */ export const TRACK_SORT_LEGACY_DIRECTIONS: Record<TrackSort, SortDirection> = {
export function sortTracks(tracks: DbTrack[], sort: TrackSort): DbTrack[] { 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) { switch (sort) {
case 'artist': 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': 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': 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': 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)
);
} }
} }
@@ -6,6 +6,7 @@ import {
libraryContextBarVisible, libraryContextBarVisible,
libraryContextBottomClearance, libraryContextBottomClearance,
libraryContextOverlayHeight, libraryContextOverlayHeight,
libraryRailBottomClearance,
libraryContextScrimHeight, libraryContextScrimHeight,
libraryDockSectionWidth, libraryDockSectionWidth,
libraryDockShowsActiveLabel, 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); 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', () => { test('mini-player visibility mirrors target fallback semantics', () => {
assert.equal(effectiveMiniPlayerVisible({ assert.equal(effectiveMiniPlayerVisible({
selectedTarget: 'phone', selectedTarget: 'phone',
+8
View File
@@ -118,6 +118,14 @@ export function libraryContextOverlayHeight(bottomClearance: number): number {
LIBRARY_CONTEXT_BAR_HEIGHT; 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, * Starts the shared bottom fade above the command bar instead of at its edge,
* so rows disappear gradually behind both pieces of floating chrome. * so rows disappear gradually behind both pieces of floating chrome.
+214 -26
View File
@@ -18,9 +18,22 @@ import {
} from '@/library/scanner'; } from '@/library/scanner';
import { endScanService, reportScanProgress } from '@/library/scanService'; import { endScanService, reportScanProgress } from '@/library/scanService';
import { requeueMissingArtistImages } from '@/library/artistImageLookup'; import { requeueMissingArtistImages } from '@/library/artistImageLookup';
import { ALBUM_SORT_LABELS, type AlbumSort } from '@/lib/albumSort'; import {
import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort'; ALBUM_SORT_LABELS,
import { TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort'; 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 { import {
DEFAULT_LIBRARY_LAYOUT, DEFAULT_LIBRARY_LAYOUT,
parseLibraryLayout, parseLibraryLayout,
@@ -35,6 +48,9 @@ const VIEW_MODE_KEY = 'library_view_mode';
const TRACK_SORT_KEY = 'library_track_sort'; const TRACK_SORT_KEY = 'library_track_sort';
const ALBUM_SORT_KEY = 'library_album_sort'; const ALBUM_SORT_KEY = 'library_album_sort';
const ARTIST_SORT_KEY = 'library_artist_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 ALBUM_LAYOUT_KEY = 'library_album_layout';
const ARTIST_LAYOUT_KEY = 'library_artist_layout'; const ARTIST_LAYOUT_KEY = 'library_artist_layout';
const INCLUDE_COLLAB_ARTISTS_KEY = 'library_include_collab_artists'; const INCLUDE_COLLAB_ARTISTS_KEY = 'library_include_collab_artists';
@@ -92,6 +108,9 @@ interface LibraryStore {
trackSort: TrackSort; trackSort: TrackSort;
albumSort: AlbumSort; albumSort: AlbumSort;
artistSort: ArtistSort; artistSort: ArtistSort;
trackSortDirection: SortDirection;
albumSortDirection: SortDirection;
artistSortDirection: SortDirection;
albumLayout: LibraryLayout; albumLayout: LibraryLayout;
artistLayout: LibraryLayout; artistLayout: LibraryLayout;
includeCollabArtists: boolean; includeCollabArtists: boolean;
@@ -133,6 +152,9 @@ interface LibraryStore {
setTrackSort: (sort: TrackSort) => void; setTrackSort: (sort: TrackSort) => void;
setAlbumSort: (sort: AlbumSort) => void; setAlbumSort: (sort: AlbumSort) => void;
setArtistSort: (sort: ArtistSort) => void; setArtistSort: (sort: ArtistSort) => void;
setTrackSortDirection: (direction: SortDirection) => void;
setAlbumSortDirection: (direction: SortDirection) => void;
setArtistSortDirection: (direction: SortDirection) => void;
setAlbumLayout: (layout: LibraryLayout) => void; setAlbumLayout: (layout: LibraryLayout) => void;
setArtistLayout: (layout: LibraryLayout) => void; setArtistLayout: (layout: LibraryLayout) => void;
setIncludeCollabArtists: (include: boolean) => void; setIncludeCollabArtists: (include: boolean) => void;
@@ -214,15 +236,18 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const readTrackPage = ( const readTrackPage = (
cursor: string | null, cursor: string | null,
sort = get().trackSort, sort = get().trackSort,
) => AstraLibraryData.getTrackPage<DbTrack>(sort, cursor, PAGE_SIZE); direction = get().trackSortDirection,
) => AstraLibraryData.getTrackPage<DbTrack>(sort, direction, cursor, PAGE_SIZE);
const readAlbumPage = ( const readAlbumPage = (
cursor: string | null, cursor: string | null,
sort = get().albumSort, sort = get().albumSort,
direction = get().albumSortDirection,
includeSingles = useSettingsStore.getState().includeSingles, includeSingles = useSettingsStore.getState().includeSingles,
) => ) =>
AstraLibraryData.getAlbumPage<Album>( AstraLibraryData.getAlbumPage<Album>(
sort, sort,
direction,
includeSingles, includeSingles,
cursor, cursor,
PAGE_SIZE PAGE_SIZE
@@ -231,11 +256,13 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const readArtistPage = ( const readArtistPage = (
cursor: string | null, cursor: string | null,
sort = get().artistSort, sort = get().artistSort,
direction = get().artistSortDirection,
groupingMode = useSettingsStore.getState().artistGroupingMode, groupingMode = useSettingsStore.getState().artistGroupingMode,
includeCollaborations = get().includeCollabArtists, includeCollaborations = get().includeCollabArtists,
) => ) =>
AstraLibraryData.getArtistPage<Artist>( AstraLibraryData.getArtistPage<Artist>(
sort, sort,
direction,
groupingMode, groupingMode,
includeCollaborations, includeCollaborations,
cursor, cursor,
@@ -247,21 +274,25 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const readTrackPageBefore = ( const readTrackPageBefore = (
cursor: string, cursor: string,
sort: 'artist' | 'title', sort: 'artist' | 'title',
) => AstraLibraryData.getTrackPageBefore<DbTrack>(sort, cursor, PAGE_SIZE); direction = get().trackSortDirection,
) => AstraLibraryData.getTrackPageBefore<DbTrack>(sort, direction, cursor, PAGE_SIZE);
const readAlbumPageBefore = ( const readAlbumPageBefore = (
cursor: string, cursor: string,
sort: 'artist' | 'name', sort: 'artist' | 'name',
direction = get().albumSortDirection,
includeSingles = useSettingsStore.getState().includeSingles, includeSingles = useSettingsStore.getState().includeSingles,
) => AstraLibraryData.getAlbumPageBefore<Album>(sort, includeSingles, cursor, PAGE_SIZE); ) => AstraLibraryData.getAlbumPageBefore<Album>(sort, direction, includeSingles, cursor, PAGE_SIZE);
const readArtistPageBefore = ( const readArtistPageBefore = (
cursor: string, cursor: string,
direction = get().artistSortDirection,
groupingMode = useSettingsStore.getState().artistGroupingMode, groupingMode = useSettingsStore.getState().artistGroupingMode,
includeCollaborations = get().includeCollabArtists, includeCollaborations = get().includeCollabArtists,
) => ) =>
AstraLibraryData.getArtistPageBefore<Artist>( AstraLibraryData.getArtistPageBefore<Artist>(
'name', 'name',
direction,
groupingMode, groupingMode,
includeCollaborations, includeCollaborations,
cursor, cursor,
@@ -280,9 +311,14 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
// PAGE_SIZE change would break it silently, leaving the list parked mid-catalog. // PAGE_SIZE change would break it silently, leaving the list parked mid-catalog.
const resetTracks = async (forceRemount = false) => { const resetTracks = async (forceRemount = false) => {
const sort = get().trackSort; const sort = get().trackSort;
const direction = get().trackSortDirection;
const generation = ++pageGenerations.tracks; const generation = ++pageGenerations.tracks;
const page = await readTrackPage(null, sort); const page = await readTrackPage(null, sort, direction);
if (generation !== pageGenerations.tracks || get().trackSort !== sort) return false; if (
generation !== pageGenerations.tracks ||
get().trackSort !== sort ||
get().trackSortDirection !== direction
) return false;
const items = page.items ?? []; const items = page.items ?? [];
set((current) => ({ set((current) => ({
tracks: items, tracks: items,
@@ -297,12 +333,14 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const resetAlbums = async (forceRemount = false) => { const resetAlbums = async (forceRemount = false) => {
const sort = get().albumSort; const sort = get().albumSort;
const direction = get().albumSortDirection;
const includeSingles = useSettingsStore.getState().includeSingles; const includeSingles = useSettingsStore.getState().includeSingles;
const generation = ++pageGenerations.albums; const generation = ++pageGenerations.albums;
const page = await readAlbumPage(null, sort, includeSingles); const page = await readAlbumPage(null, sort, direction, includeSingles);
if ( if (
generation !== pageGenerations.albums || generation !== pageGenerations.albums ||
get().albumSort !== sort || get().albumSort !== sort ||
get().albumSortDirection !== direction ||
useSettingsStore.getState().includeSingles !== includeSingles useSettingsStore.getState().includeSingles !== includeSingles
) return false; ) return false;
const items = page.items ?? []; const items = page.items ?? [];
@@ -318,13 +356,21 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const resetArtists = async (forceRemount = false) => { const resetArtists = async (forceRemount = false) => {
const sort = get().artistSort; const sort = get().artistSort;
const direction = get().artistSortDirection;
const groupingMode = useSettingsStore.getState().artistGroupingMode; const groupingMode = useSettingsStore.getState().artistGroupingMode;
const includeCollaborations = get().includeCollabArtists; const includeCollaborations = get().includeCollabArtists;
const generation = ++pageGenerations.artists; const generation = ++pageGenerations.artists;
const page = await readArtistPage(null, sort, groupingMode, includeCollaborations); const page = await readArtistPage(
null,
sort,
direction,
groupingMode,
includeCollaborations,
);
if ( if (
generation !== pageGenerations.artists || generation !== pageGenerations.artists ||
get().artistSort !== sort || get().artistSort !== sort ||
get().artistSortDirection !== direction ||
useSettingsStore.getState().artistGroupingMode !== groupingMode || useSettingsStore.getState().artistGroupingMode !== groupingMode ||
get().includeCollabArtists !== includeCollaborations get().includeCollabArtists !== includeCollaborations
) return false; ) return false;
@@ -356,11 +402,18 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
: state.viewMode === 'albums' : state.viewMode === 'albums'
? state.albumSort as 'artist' | 'name' ? state.albumSort as 'artist' | 'name'
: 'name'; : 'name';
const direction =
state.viewMode === 'tracks'
? state.trackSortDirection
: state.viewMode === 'albums'
? state.albumSortDirection
: state.artistSortDirection;
const includeSingles = useSettingsStore.getState().includeSingles; const includeSingles = useSettingsStore.getState().includeSingles;
const groupingMode = useSettingsStore.getState().artistGroupingMode; const groupingMode = useSettingsStore.getState().artistGroupingMode;
const anchors = await AstraLibraryData.getSectionAnchors( const anchors = await AstraLibraryData.getSectionAnchors(
state.viewMode as 'tracks' | 'albums' | 'artists', state.viewMode as 'tracks' | 'albums' | 'artists',
sort, sort,
direction,
includeSingles, includeSingles,
groupingMode, groupingMode,
state.includeCollabArtists state.includeCollabArtists
@@ -372,10 +425,17 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
: current.viewMode === 'albums' : current.viewMode === 'albums'
? current.albumSort ? current.albumSort
: current.artistSort; : current.artistSort;
const currentDirection =
current.viewMode === 'tracks'
? current.trackSortDirection
: current.viewMode === 'albums'
? current.albumSortDirection
: current.artistSortDirection;
if ( if (
generation !== anchorGeneration || generation !== anchorGeneration ||
current.viewMode !== state.viewMode || current.viewMode !== state.viewMode ||
currentSort !== sort || currentSort !== sort ||
currentDirection !== direction ||
useSettingsStore.getState().includeSingles !== includeSingles || useSettingsStore.getState().includeSingles !== includeSingles ||
useSettingsStore.getState().artistGroupingMode !== groupingMode || useSettingsStore.getState().artistGroupingMode !== groupingMode ||
current.includeCollabArtists !== state.includeCollabArtists current.includeCollabArtists !== state.includeCollabArtists
@@ -432,6 +492,9 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
trackSort: 'title', trackSort: 'title',
albumSort: 'name', albumSort: 'name',
artistSort: 'name', artistSort: 'name',
trackSortDirection: 'asc',
albumSortDirection: 'asc',
artistSortDirection: 'asc',
albumLayout: DEFAULT_LIBRARY_LAYOUT, albumLayout: DEFAULT_LIBRARY_LAYOUT,
artistLayout: DEFAULT_LIBRARY_LAYOUT, artistLayout: DEFAULT_LIBRARY_LAYOUT,
includeCollabArtists: false, includeCollabArtists: false,
@@ -468,6 +531,9 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
TRACK_SORT_KEY, TRACK_SORT_KEY,
ALBUM_SORT_KEY, ALBUM_SORT_KEY,
ARTIST_SORT_KEY, ARTIST_SORT_KEY,
TRACK_SORT_DIRECTION_KEY,
ALBUM_SORT_DIRECTION_KEY,
ARTIST_SORT_DIRECTION_KEY,
ALBUM_LAYOUT_KEY, ALBUM_LAYOUT_KEY,
ARTIST_LAYOUT_KEY, ARTIST_LAYOUT_KEY,
INCLUDE_COLLAB_ARTISTS_KEY, INCLUDE_COLLAB_ARTISTS_KEY,
@@ -476,15 +542,42 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const trackSort = parseTrackSort(values[TRACK_SORT_KEY] ?? null); const trackSort = parseTrackSort(values[TRACK_SORT_KEY] ?? null);
const albumSort = parseAlbumSort(values[ALBUM_SORT_KEY] ?? null); const albumSort = parseAlbumSort(values[ALBUM_SORT_KEY] ?? null);
const artistSort = parseArtistSort(values[ARTIST_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({ set({
...(viewMode ? { viewMode } : {}), ...(viewMode ? { viewMode } : {}),
...(trackSort ? { trackSort } : {}), ...(trackSort ? { trackSort } : {}),
...(albumSort ? { albumSort } : {}), ...(albumSort ? { albumSort } : {}),
...(artistSort ? { artistSort } : {}), ...(artistSort ? { artistSort } : {}),
trackSortDirection,
albumSortDirection,
artistSortDirection,
albumLayout: parseLibraryLayout(values[ALBUM_LAYOUT_KEY] ?? null), albumLayout: parseLibraryLayout(values[ALBUM_LAYOUT_KEY] ?? null),
artistLayout: parseLibraryLayout(values[ARTIST_LAYOUT_KEY] ?? null), artistLayout: parseLibraryLayout(values[ARTIST_LAYOUT_KEY] ?? null),
includeCollabArtists: values[INCLUDE_COLLAB_ARTISTS_KEY] === 'true', 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) { if (!nativeSubscriptionsInstalled) {
nativeSubscriptionsInstalled = true; nativeSubscriptionsInstalled = true;
@@ -548,6 +641,9 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const trackSort = stateAtStart.trackSort; const trackSort = stateAtStart.trackSort;
const albumSort = stateAtStart.albumSort; const albumSort = stateAtStart.albumSort;
const artistSort = stateAtStart.artistSort; const artistSort = stateAtStart.artistSort;
const trackSortDirection = stateAtStart.trackSortDirection;
const albumSortDirection = stateAtStart.albumSortDirection;
const artistSortDirection = stateAtStart.artistSortDirection;
const includeSingles = useSettingsStore.getState().includeSingles; const includeSingles = useSettingsStore.getState().includeSingles;
const groupingMode = useSettingsStore.getState().artistGroupingMode; const groupingMode = useSettingsStore.getState().artistGroupingMode;
const includeCollaborations = stateAtStart.includeCollabArtists; const includeCollaborations = stateAtStart.includeCollabArtists;
@@ -568,21 +664,31 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
folders, folders,
recentlyPlayedTracks, recentlyPlayedTracks,
] = await Promise.all([ ] = await Promise.all([
viewMode === 'tracks' ? readTrackPage(null, trackSort) : Promise.resolve(null), viewMode === 'tracks'
? readTrackPage(null, trackSort, trackSortDirection)
: Promise.resolve(null),
viewMode === 'albums' viewMode === 'albums'
? readAlbumPage(null, albumSort, includeSingles) ? readAlbumPage(null, albumSort, albumSortDirection, includeSingles)
: Promise.resolve(null), : Promise.resolve(null),
viewMode === 'artists' viewMode === 'artists'
? readArtistPage(null, artistSort, groupingMode, includeCollaborations) ? readArtistPage(
null,
artistSort,
artistSortDirection,
groupingMode,
includeCollaborations,
)
: Promise.resolve(null), : Promise.resolve(null),
AstraLibraryData.getAlbumPage<Album>( AstraLibraryData.getAlbumPage<Album>(
'recently_added', 'recently_added',
'desc',
includeSingles, includeSingles,
null, null,
20 20
), ),
AstraLibraryData.getArtistPage<Artist>( AstraLibraryData.getArtistPage<Artist>(
'name', 'name',
'asc',
groupingMode, groupingMode,
includeCollaborations, includeCollaborations,
null, null,
@@ -596,17 +702,20 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
viewMode === 'tracks' && viewMode === 'tracks' &&
current.viewMode === 'tracks' && current.viewMode === 'tracks' &&
current.trackSort === trackSort && current.trackSort === trackSort &&
current.trackSortDirection === trackSortDirection &&
activeGeneration === pageGenerations.tracks; activeGeneration === pageGenerations.tracks;
const canApplyAlbumPage = const canApplyAlbumPage =
viewMode === 'albums' && viewMode === 'albums' &&
current.viewMode === 'albums' && current.viewMode === 'albums' &&
current.albumSort === albumSort && current.albumSort === albumSort &&
current.albumSortDirection === albumSortDirection &&
useSettingsStore.getState().includeSingles === includeSingles && useSettingsStore.getState().includeSingles === includeSingles &&
activeGeneration === pageGenerations.albums; activeGeneration === pageGenerations.albums;
const canApplyArtistPage = const canApplyArtistPage =
viewMode === 'artists' && viewMode === 'artists' &&
current.viewMode === 'artists' && current.viewMode === 'artists' &&
current.artistSort === artistSort && current.artistSort === artistSort &&
current.artistSortDirection === artistSortDirection &&
useSettingsStore.getState().artistGroupingMode === groupingMode && useSettingsStore.getState().artistGroupingMode === groupingMode &&
current.includeCollabArtists === includeCollaborations && current.includeCollabArtists === includeCollaborations &&
activeGeneration === pageGenerations.artists; activeGeneration === pageGenerations.artists;
@@ -647,13 +756,15 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const cursor = state.trackNextCursor; const cursor = state.trackNextCursor;
if (!cursor || forwardBusy.tracks) return; if (!cursor || forwardBusy.tracks) return;
const sort = state.trackSort; const sort = state.trackSort;
const direction = state.trackSortDirection;
const pageGeneration = pageGenerations.tracks; const pageGeneration = pageGenerations.tracks;
forwardBusy.tracks = true; forwardBusy.tracks = true;
try { try {
const page = await readTrackPage(cursor, sort); const page = await readTrackPage(cursor, sort, direction);
if ( if (
pageGeneration !== pageGenerations.tracks || pageGeneration !== pageGenerations.tracks ||
get().trackSort !== sort || get().trackSort !== sort ||
get().trackSortDirection !== direction ||
get().trackNextCursor !== cursor get().trackNextCursor !== cursor
) return; ) return;
if (page.error === 'STALE_REVISION') { if (page.error === 'STALE_REVISION') {
@@ -674,14 +785,16 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const cursor = state.albumNextCursor; const cursor = state.albumNextCursor;
if (!cursor || forwardBusy.albums) return; if (!cursor || forwardBusy.albums) return;
const sort = state.albumSort; const sort = state.albumSort;
const direction = state.albumSortDirection;
const includeSingles = useSettingsStore.getState().includeSingles; const includeSingles = useSettingsStore.getState().includeSingles;
const pageGeneration = pageGenerations.albums; const pageGeneration = pageGenerations.albums;
forwardBusy.albums = true; forwardBusy.albums = true;
try { try {
const page = await readAlbumPage(cursor, sort, includeSingles); const page = await readAlbumPage(cursor, sort, direction, includeSingles);
if ( if (
pageGeneration !== pageGenerations.albums || pageGeneration !== pageGenerations.albums ||
get().albumSort !== sort || get().albumSort !== sort ||
get().albumSortDirection !== direction ||
useSettingsStore.getState().includeSingles !== includeSingles || useSettingsStore.getState().includeSingles !== includeSingles ||
get().albumNextCursor !== cursor get().albumNextCursor !== cursor
) return; ) return;
@@ -703,15 +816,23 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const cursor = state.artistNextCursor; const cursor = state.artistNextCursor;
if (!cursor || forwardBusy.artists) return; if (!cursor || forwardBusy.artists) return;
const sort = state.artistSort; const sort = state.artistSort;
const direction = state.artistSortDirection;
const groupingMode = useSettingsStore.getState().artistGroupingMode; const groupingMode = useSettingsStore.getState().artistGroupingMode;
const includeCollaborations = state.includeCollabArtists; const includeCollaborations = state.includeCollabArtists;
const pageGeneration = pageGenerations.artists; const pageGeneration = pageGenerations.artists;
forwardBusy.artists = true; forwardBusy.artists = true;
try { try {
const page = await readArtistPage(cursor, sort, groupingMode, includeCollaborations); const page = await readArtistPage(
cursor,
sort,
direction,
groupingMode,
includeCollaborations,
);
if ( if (
pageGeneration !== pageGenerations.artists || pageGeneration !== pageGenerations.artists ||
get().artistSort !== sort || get().artistSort !== sort ||
get().artistSortDirection !== direction ||
useSettingsStore.getState().artistGroupingMode !== groupingMode || useSettingsStore.getState().artistGroupingMode !== groupingMode ||
get().includeCollabArtists !== includeCollaborations || get().includeCollabArtists !== includeCollaborations ||
get().artistNextCursor !== cursor get().artistNextCursor !== cursor
@@ -733,14 +854,16 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const state = get(); const state = get();
const cursor = state.trackPrevCursor; const cursor = state.trackPrevCursor;
const sort = backwardTrackSort(state.trackSort); const sort = backwardTrackSort(state.trackSort);
const direction = state.trackSortDirection;
if (!cursor || !sort || backwardBusy.tracks) return; if (!cursor || !sort || backwardBusy.tracks) return;
const pageGeneration = pageGenerations.tracks; const pageGeneration = pageGenerations.tracks;
backwardBusy.tracks = true; backwardBusy.tracks = true;
try { try {
const page = await readTrackPageBefore(cursor, sort); const page = await readTrackPageBefore(cursor, sort, direction);
if ( if (
pageGeneration !== pageGenerations.tracks || pageGeneration !== pageGenerations.tracks ||
get().trackSort !== sort || get().trackSort !== sort ||
get().trackSortDirection !== direction ||
get().trackPrevCursor !== cursor get().trackPrevCursor !== cursor
) return; ) return;
if (page.error === 'STALE_REVISION') { if (page.error === 'STALE_REVISION') {
@@ -760,15 +883,17 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const state = get(); const state = get();
const cursor = state.albumPrevCursor; const cursor = state.albumPrevCursor;
const sort = backwardAlbumSort(state.albumSort); const sort = backwardAlbumSort(state.albumSort);
const direction = state.albumSortDirection;
if (!cursor || !sort || backwardBusy.albums) return; if (!cursor || !sort || backwardBusy.albums) return;
const includeSingles = useSettingsStore.getState().includeSingles; const includeSingles = useSettingsStore.getState().includeSingles;
const pageGeneration = pageGenerations.albums; const pageGeneration = pageGenerations.albums;
backwardBusy.albums = true; backwardBusy.albums = true;
try { try {
const page = await readAlbumPageBefore(cursor, sort, includeSingles); const page = await readAlbumPageBefore(cursor, sort, direction, includeSingles);
if ( if (
pageGeneration !== pageGenerations.albums || pageGeneration !== pageGenerations.albums ||
get().albumSort !== sort || get().albumSort !== sort ||
get().albumSortDirection !== direction ||
useSettingsStore.getState().includeSingles !== includeSingles || useSettingsStore.getState().includeSingles !== includeSingles ||
get().albumPrevCursor !== cursor get().albumPrevCursor !== cursor
) return; ) return;
@@ -789,15 +914,22 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const state = get(); const state = get();
const cursor = state.artistPrevCursor; const cursor = state.artistPrevCursor;
if (!cursor || state.artistSort !== 'name' || backwardBusy.artists) return; if (!cursor || state.artistSort !== 'name' || backwardBusy.artists) return;
const direction = state.artistSortDirection;
const groupingMode = useSettingsStore.getState().artistGroupingMode; const groupingMode = useSettingsStore.getState().artistGroupingMode;
const includeCollaborations = state.includeCollabArtists; const includeCollaborations = state.includeCollabArtists;
const pageGeneration = pageGenerations.artists; const pageGeneration = pageGenerations.artists;
backwardBusy.artists = true; backwardBusy.artists = true;
try { try {
const page = await readArtistPageBefore(cursor, groupingMode, includeCollaborations); const page = await readArtistPageBefore(
cursor,
direction,
groupingMode,
includeCollaborations,
);
if ( if (
pageGeneration !== pageGenerations.artists || pageGeneration !== pageGenerations.artists ||
get().artistSort !== 'name' || get().artistSort !== 'name' ||
get().artistSortDirection !== direction ||
useSettingsStore.getState().artistGroupingMode !== groupingMode || useSettingsStore.getState().artistGroupingMode !== groupingMode ||
get().includeCollabArtists !== includeCollaborations || get().includeCollabArtists !== includeCollaborations ||
get().artistPrevCursor !== cursor get().artistPrevCursor !== cursor
@@ -827,15 +959,19 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const generation = ++pageGenerations[viewMode]; const generation = ++pageGenerations[viewMode];
if (viewMode === 'tracks') { if (viewMode === 'tracks') {
const sort = state.trackSort; const sort = state.trackSort;
const direction = state.trackSortDirection;
const backwardSort = backwardTrackSort(sort); const backwardSort = backwardTrackSort(sort);
const [page, before] = await Promise.all([ const [page, before] = await Promise.all([
readTrackPage(cursor, sort), readTrackPage(cursor, sort, direction),
backwardSort ? readTrackPageBefore(cursor, backwardSort) : Promise.resolve(null), backwardSort
? readTrackPageBefore(cursor, backwardSort, direction)
: Promise.resolve(null),
]); ]);
if ( if (
generation !== pageGenerations.tracks || generation !== pageGenerations.tracks ||
get().viewMode !== viewMode || get().viewMode !== viewMode ||
get().trackSort !== sort get().trackSort !== sort ||
get().trackSortDirection !== direction
) return false; ) return false;
if (page.error === 'STALE_REVISION') { if (page.error === 'STALE_REVISION') {
await resetTracks(); await resetTracks();
@@ -856,18 +992,20 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
})); }));
} else if (viewMode === 'albums') { } else if (viewMode === 'albums') {
const sort = state.albumSort; const sort = state.albumSort;
const direction = state.albumSortDirection;
const includeSingles = useSettingsStore.getState().includeSingles; const includeSingles = useSettingsStore.getState().includeSingles;
const backwardSort = backwardAlbumSort(sort); const backwardSort = backwardAlbumSort(sort);
const [page, before] = await Promise.all([ const [page, before] = await Promise.all([
readAlbumPage(cursor, sort, includeSingles), readAlbumPage(cursor, sort, direction, includeSingles),
backwardSort backwardSort
? readAlbumPageBefore(cursor, backwardSort, includeSingles) ? readAlbumPageBefore(cursor, backwardSort, direction, includeSingles)
: Promise.resolve(null), : Promise.resolve(null),
]); ]);
if ( if (
generation !== pageGenerations.albums || generation !== pageGenerations.albums ||
get().viewMode !== viewMode || get().viewMode !== viewMode ||
get().albumSort !== sort || get().albumSort !== sort ||
get().albumSortDirection !== direction ||
useSettingsStore.getState().includeSingles !== includeSingles useSettingsStore.getState().includeSingles !== includeSingles
) return false; ) return false;
if (page.error === 'STALE_REVISION') { if (page.error === 'STALE_REVISION') {
@@ -888,18 +1026,20 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
})); }));
} else { } else {
const sort = state.artistSort; const sort = state.artistSort;
const direction = state.artistSortDirection;
const groupingMode = useSettingsStore.getState().artistGroupingMode; const groupingMode = useSettingsStore.getState().artistGroupingMode;
const includeCollaborations = state.includeCollabArtists; const includeCollaborations = state.includeCollabArtists;
const [page, before] = await Promise.all([ const [page, before] = await Promise.all([
readArtistPage(cursor, sort, groupingMode, includeCollaborations), readArtistPage(cursor, sort, direction, groupingMode, includeCollaborations),
sort === 'name' sort === 'name'
? readArtistPageBefore(cursor, groupingMode, includeCollaborations) ? readArtistPageBefore(cursor, direction, groupingMode, includeCollaborations)
: Promise.resolve(null), : Promise.resolve(null),
]); ]);
if ( if (
generation !== pageGenerations.artists || generation !== pageGenerations.artists ||
get().viewMode !== viewMode || get().viewMode !== viewMode ||
get().artistSort !== sort || get().artistSort !== sort ||
get().artistSortDirection !== direction ||
useSettingsStore.getState().artistGroupingMode !== groupingMode || useSettingsStore.getState().artistGroupingMode !== groupingMode ||
get().includeCollabArtists !== includeCollaborations get().includeCollabArtists !== includeCollaborations
) return false; ) return false;
@@ -1048,6 +1188,54 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
void resetSectionAnchors(); 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) => { setAlbumLayout: (albumLayout) => {
if (get().albumLayout === albumLayout) return; if (get().albumLayout === albumLayout) return;
const state = get(); const state = get();