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