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