fix slider not working correctly

This commit is contained in:
Boof2015
2026-07-24 03:22:12 -04:00
parent 3bcecaf70d
commit 098992639d
7 changed files with 464 additions and 126 deletions
@@ -180,6 +180,74 @@ class RoomLibraryRepositoryTest {
assertNull(boundedPlaybackWindowStart(0, 0)) assertNull(boundedPlaybackWindowStart(0, 0))
} }
@Test
fun trackSectionAnchorsOpenOnTheRequestedTitleAndArtistBuckets() = runBlocking {
publish(
"anchors",
listOf(
track("anchors", 0, "//.xX_-=-FLUTE==-Xx.\\\\"),
track("anchors", 1, "Apple").copy(
artist = "Amber",
artistSortKey = SortKeys.forText("Amber"),
),
track("anchors", 2, "Saturn").copy(
artist = "Sade",
artistSortKey = SortKeys.forText("Sade"),
),
track("anchors", 3, "Zulu").copy(
artist = "Zero 7",
artistSortKey = SortKeys.forText("Zero 7"),
),
),
)
val dao = catalog.catalogDao()
assertFalse(dao.getTitleSectionAnchors().any { it.sectionLabel == "X" })
assertTrue(dao.getTitleSectionAnchors().any { it.sectionLabel == "#" })
val titleAnchor = dao.getTitleSectionAnchors().single { it.sectionLabel == "S" }
val titlePage = dao.getTitlePage(titleAnchor.sortKey, "", 100)
assertEquals("Saturn", titlePage.first().title)
val artistAnchor = dao.getArtistSectionAnchorCandidates()
.filter { SortKeys.sectionLabel(it.artist) == "S" }
.minOf(ArtistSectionAnchorCandidate::sortKey)
val artistPage = dao.getArtistOrderPage(artistAnchor, "", 0, 0, "", "", 100)
assertEquals("Sade", artistPage.first().artist)
}
@Test
fun sectionLabelsUseOnlyTheFirstVisibleCharacter() {
assertEquals("#", SortKeys.sectionLabel("//.xX_-=-FLUTE==-Xx.\\\\"))
assertEquals("#", SortKeys.sectionLabel("! PARTY SIRENS !"))
assertEquals("#", SortKeys.sectionLabel("#iwannadance"))
assertEquals("#", SortKeys.sectionLabel("7 Rings"))
assertEquals("E", SortKeys.sectionLabel("Élan"))
assertEquals("S", SortKeys.sectionLabel(" Saturn"))
assertEquals("#", SortKeys.sectionLabel("東京の夜"))
}
@Test
fun sectionLabelMigrationCorrectsExistingCatalogRows() = runBlocking {
val dao = catalog.catalogDao()
dao.insertMeta(CatalogMetaEntity(collationVersion = 1, updatedAt = 0))
dao.putSource(CatalogSourceEntity("local:1", "local", 1, "legacy", 0))
dao.insertGeneration(ScanGenerationEntity("legacy", "local:1", "active", 0))
dao.putTracks(
listOf(
track("legacy", 1, "//.xX_-=-FLUTE==-Xx.\\\\").copy(sectionLabel = "X"),
track("legacy", 2, "Xylophone").copy(sectionLabel = "X"),
),
)
dao.migrateSectionLabels(COLLATION_VERSION, 1)
assertEquals(COLLATION_VERSION, dao.getMeta()?.collationVersion)
assertEquals(
mapOf("//.xX_-=-FLUTE==-Xx.\\\\" to "#", "Xylophone" to "X"),
dao.getActiveTracks(dao.getAllPathsByTitle()).associate { it.title to it.sectionLabel },
)
}
@Test @Test
fun userSnapshotsRotateRejectDamageAndRestoreTheNewestValidCopy() = runBlocking { fun userSnapshotsRotateRejectDamageAndRestoreTheNewestValidCopy() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
@@ -117,6 +117,7 @@ class AstraLibraryRepository private constructor(
val catalogOpen = openCatalogDatabaseWithRecovery() val catalogOpen = openCatalogDatabaseWithRecovery()
catalogDatabase = catalogOpen catalogDatabase = catalogOpen
val dao = catalogOpen.catalogDao() val dao = catalogOpen.catalogDao()
val existingMeta = dao.getMeta()
dao.insertMeta( dao.insertMeta(
CatalogMetaEntity( CatalogMetaEntity(
revision = 0, revision = 0,
@@ -124,6 +125,9 @@ class AstraLibraryRepository private constructor(
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
), ),
) )
if (existingMeta != null && existingMeta.collationVersion < COLLATION_VERSION) {
dao.migrateSectionLabels(COLLATION_VERSION, System.currentTimeMillis())
}
dao.discardAbandonedGenerations() dao.discardAbandonedGenerations()
reconcileUserFacts() reconcileUserFacts()
@@ -2048,7 +2052,7 @@ class AstraLibraryRepository private constructor(
"albums" -> { "albums" -> {
val rows = dao.getAllAlbumSummaries(revision).filter { includeSingles || !it.isSingle } val rows = dao.getAllAlbumSummaries(revision).filter { includeSingles || !it.isSingle }
rows.groupBy { row -> rows.groupBy { row ->
if (sort == "artist") SortKeys.sectionLabel(row.artist) else row.sectionLabel if (sort == "artist") SortKeys.sectionLabel(row.artist) else SortKeys.sectionLabel(row.album)
}.map { (label, section) -> }.map { (label, section) ->
if (sort == "artist") { if (sort == "artist") {
val first = section.minWith(compareBy<AlbumSummaryEntity>({ it.artistSortKey }, { it.nameSortKey }, { it.identityKey })) val first = section.minWith(compareBy<AlbumSummaryEntity>({ it.artistSortKey }, { it.nameSortKey }, { it.identityKey }))
@@ -2071,7 +2075,7 @@ class AstraLibraryRepository private constructor(
val mode = if (groupingMode == "fileTags") "fileTags" else "astra" val mode = if (groupingMode == "fileTags") "fileTags" else "astra"
dao.getAllArtistSummaries(revision, mode) dao.getAllArtistSummaries(revision, mode)
.filter { includeCollaborations || !it.isCollaboration } .filter { includeCollaborations || !it.isCollaboration }
.groupBy(ArtistSummaryEntity::sectionLabel) .groupBy { row -> SortKeys.sectionLabel(row.artist) }
.map { (label, section) -> .map { (label, section) ->
val first = section.minWith(compareBy<ArtistSummaryEntity>({ it.nameSortKey }, { it.artistKey })) val first = section.minWith(compareBy<ArtistSummaryEntity>({ it.nameSortKey }, { it.artistKey }))
label to TrackPageCursor( label to TrackPageCursor(
@@ -2082,31 +2086,25 @@ class AstraLibraryRepository private constructor(
} }
} }
else -> { else -> {
dao.getAllActiveTracksForNativeMatching() if (sort == "artist") {
.groupBy { row -> dao.getArtistSectionAnchorCandidates()
if (sort == "artist") SortKeys.sectionLabel(row.artist) else row.sectionLabel .groupBy { candidate -> SortKeys.sectionLabel(candidate.artist) }
} .map { (label, section) ->
.map { (label, section) -> label to TrackPageCursor(
val first = if (sort == "artist") { revision,
section.minWith( "tracks:artist",
compareBy<ActiveTrackView>( text1 = section.minOf(ArtistSectionAnchorCandidate::sortKey),
{ it.artistSortKey },
{ it.albumSortKey },
{ it.discSort },
{ it.trackSort },
{ it.titleSortKey },
{ it.path },
),
) )
} else {
section.minWith(compareBy<ActiveTrackView>({ it.titleSortKey }, { it.path }))
}
label to if (sort == "artist") {
TrackPageCursor(revision, "tracks:artist", text1 = first.artistSortKey)
} else {
TrackPageCursor(revision, "tracks:title", text1 = first.titleSortKey)
} }
} else {
dao.getTitleSectionAnchors().map { row ->
row.sectionLabel to TrackPageCursor(
revision,
"tracks:title",
text1 = row.sortKey,
)
} }
}
} }
} }
anchors.sortedWith(compareBy<Pair<String, TrackPageCursor>> { it.second.text1 }.thenBy { it.first }) anchors.sortedWith(compareBy<Pair<String, TrackPageCursor>> { it.second.text1 }.thenBy { it.first })
@@ -22,6 +22,17 @@ data class SectionAnchorRow(
@androidx.room.ColumnInfo(name = "sort_key") val sortKey: String, @androidx.room.ColumnInfo(name = "sort_key") val sortKey: String,
) )
data class ArtistSectionAnchorCandidate(
val artist: String,
@androidx.room.ColumnInfo(name = "sort_key") val sortKey: String,
)
data class TrackSectionLabelCandidate(
val id: Long,
val title: String,
@androidx.room.ColumnInfo(name = "section_label") val sectionLabel: String,
)
data class LibraryLoudnessStatsRow( data class LibraryLoudnessStatsRow(
val lufsCount: Long, val lufsCount: Long,
val medianLufs: Double?, val medianLufs: Double?,
@@ -50,6 +61,16 @@ interface CatalogDao {
@Query("SELECT revision FROM catalog_meta WHERE id = 1") @Query("SELECT revision FROM catalog_meta WHERE id = 1")
suspend fun getRevision(): Long suspend fun getRevision(): Long
@Query(
"""
UPDATE catalog_meta
SET collation_version = :version,
updated_at = :updatedAt
WHERE id = 1
""",
)
suspend fun setCollationVersion(version: Int, updatedAt: Long)
@Query("SELECT * FROM catalog_sources WHERE source_key = :sourceKey") @Query("SELECT * FROM catalog_sources WHERE source_key = :sourceKey")
suspend fun getSource(sourceKey: String): CatalogSourceEntity? suspend fun getSource(sourceKey: String): CatalogSourceEntity?
@@ -471,13 +492,30 @@ interface CatalogDao {
@Query( @Query(
""" """
SELECT section_label, MIN(artist_sort_key) AS sort_key SELECT artist, MIN(artist_sort_key) AS sort_key
FROM active_tracks FROM active_tracks
GROUP BY section_label GROUP BY artist
ORDER BY sort_key ORDER BY sort_key
""", """,
) )
suspend fun getArtistSectionAnchors(): List<SectionAnchorRow> suspend fun getArtistSectionAnchorCandidates(): List<ArtistSectionAnchorCandidate>
@Query(
"""
SELECT id, title, section_label
FROM tracks
WHERE id > :afterId
ORDER BY id
LIMIT :limit
""",
)
suspend fun getTrackSectionLabelCandidates(
afterId: Long,
limit: Int,
): List<TrackSectionLabelCandidate>
@Query("UPDATE tracks SET section_label = :sectionLabel WHERE id = :trackId")
suspend fun updateTrackSectionLabel(trackId: Long, sectionLabel: String)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun putAlbumSummaries(rows: List<AlbumSummaryEntity>) suspend fun putAlbumSummaries(rows: List<AlbumSummaryEntity>)
@@ -1027,6 +1065,22 @@ interface CatalogDao {
deleteAbandonedGenerationRecords() deleteAbandonedGenerationRecords()
} }
@Transaction
suspend fun migrateSectionLabels(version: Int, updatedAt: Long) {
var afterId = 0L
do {
val rows = getTrackSectionLabelCandidates(afterId, 1_000)
for (row in rows) {
val corrected = SortKeys.sectionLabel(row.title)
if (corrected != row.sectionLabel) {
updateTrackSectionLabel(row.id, corrected)
}
}
afterId = rows.lastOrNull()?.id ?: afterId
} while (rows.size == 1_000)
setCollationVersion(version, updatedAt)
}
@Transaction @Transaction
suspend fun publishGeneration( suspend fun publishGeneration(
sourceKey: String, sourceKey: String,
@@ -7,7 +7,7 @@ import java.util.Locale
import org.json.JSONObject import org.json.JSONObject
import org.json.JSONArray import org.json.JSONArray
const val COLLATION_VERSION = 1 const val COLLATION_VERSION = 2
const val DEFAULT_PAGE_SIZE = 100 const val DEFAULT_PAGE_SIZE = 100
const val MAX_PAGE_SIZE = 200 const val MAX_PAGE_SIZE = 200
@@ -59,9 +59,9 @@ object SortKeys {
fun sectionLabel(value: String): String { fun sectionLabel(value: String): String {
val normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFD) val normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFD)
val first = normalized.firstOrNull { Character.isLetterOrDigit(it) } ?: return "#" val first = normalized.firstOrNull() ?: return "#"
val upper = first.uppercaseChar() val upper = first.uppercaseChar()
return if (upper in 'A'..'Z' || upper in '0'..'9') upper.toString() else "#" return if (upper in 'A'..'Z') upper.toString() else "#"
} }
} }
+8 -21
View File
@@ -1,7 +1,6 @@
import { import {
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState useState
} from 'react'; } from 'react';
import { import {
@@ -11,7 +10,7 @@ import {
StyleSheet StyleSheet
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { FlashList, type FlashListRef } from '@shopify/flash-list'; import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen'; import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
@@ -62,10 +61,7 @@ import {
ARTIST_SORT_LABELS, ARTIST_SORT_LABELS,
type ArtistSort type ArtistSort
} from '@/lib/artistSort'; } from '@/lib/artistSort';
import { RAIL_LETTERS } from '@/lib/letterIndex';
import type { import type {
Album,
Artist,
DbTrack DbTrack
} from '@/types/library'; } from '@/types/library';
@@ -92,6 +88,7 @@ export default function LibraryScreen() {
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 sectionAnchors = useLibraryStore((s) => s.sectionAnchors); const sectionAnchors = useLibraryStore((s) => s.sectionAnchors);
const sectionJumpRevision = useLibraryStore((s) => s.sectionJumpRevision);
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);
@@ -107,10 +104,6 @@ export default function LibraryScreen() {
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const scrollTop = useScrollTopGate(); const scrollTop = useScrollTopGate();
const tracksListRef = useRef<FlashListRef<DbTrack>>(null);
const albumsListRef = useRef<FlashListRef<Album>>(null);
const artistsListRef = useRef<FlashListRef<Artist>>(null);
const showLibraryStatus = const showLibraryStatus =
totalTrackCount === 0 && totalTrackCount === 0 &&
!isScanning && !isScanning &&
@@ -141,16 +134,10 @@ export default function LibraryScreen() {
); );
const jumpToLetter = (letter: string) => { const jumpToLetter = (letter: string) => {
const requestedIndex = RAIL_LETTERS.indexOf(letter); const anchor = sectionAnchors.find((entry) => entry.label === letter);
const anchor =
sectionAnchors.find((entry) => entry.label === letter) ??
sectionAnchors.find((entry) => RAIL_LETTERS.indexOf(entry.label) >= requestedIndex) ??
sectionAnchors.at(-1);
if (!anchor) return; if (!anchor) return;
void jumpToSection(anchor.cursor).then(() => { void jumpToSection(anchor.cursor).then((applied) => {
if (viewMode === 'tracks') tracksListRef.current?.scrollToOffset({ offset: 0, animated: false }); if (applied) scrollTop.setScrollAtTop(true);
else if (viewMode === 'albums') albumsListRef.current?.scrollToOffset({ offset: 0, animated: false });
else if (viewMode === 'artists') artistsListRef.current?.scrollToOffset({ offset: 0, animated: false });
}); });
}; };
@@ -301,7 +288,7 @@ export default function LibraryScreen() {
<View style={styles.listArea}> <View style={styles.listArea}>
{viewMode === 'albums' ? ( {viewMode === 'albums' ? (
<FlashList <FlashList
ref={albumsListRef} key={`albums-${albumSort}-${sectionJumpRevision}`}
data={sortedAlbums} data={sortedAlbums}
numColumns={3} numColumns={3}
keyExtractor={(album) => album.identity_key} keyExtractor={(album) => album.identity_key}
@@ -330,7 +317,7 @@ export default function LibraryScreen() {
{viewMode === 'artists' ? ( {viewMode === 'artists' ? (
<FlashList <FlashList
ref={artistsListRef} key={`artists-${artistSort}-${sectionJumpRevision}`}
data={sortedArtists} data={sortedArtists}
numColumns={3} numColumns={3}
keyExtractor={(artist) => artist.artist} keyExtractor={(artist) => artist.artist}
@@ -359,7 +346,7 @@ export default function LibraryScreen() {
{viewMode === 'tracks' ? ( {viewMode === 'tracks' ? (
<FlashList <FlashList
ref={tracksListRef} key={`tracks-${trackSort}-${sectionJumpRevision}`}
data={sortedTracks} data={sortedTracks}
keyExtractor={(track) => String(track.id)} keyExtractor={(track) => String(track.id)}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
+7 -4
View File
@@ -41,7 +41,12 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp
const bubbleY = useSharedValue(0); const bubbleY = useSharedValue(0);
const scrubTo = (letter: string) => { const scrubTo = (letter: string) => {
if (!activeLetters.has(letter)) {
setScrubLetter(null);
return;
}
setScrubLetter(letter); setScrubLetter(letter);
playHaptic('frequentStep');
onJumpToLetter(letter); onJumpToLetter(letter);
}; };
const endScrub = () => setScrubLetter(null); const endScrub = () => setScrubLetter(null);
@@ -60,7 +65,6 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp
); );
const letter = RAIL_LETTERS[index]; const letter = RAIL_LETTERS[index];
lastLetter.value = letter; lastLetter.value = letter;
runOnJS(playHaptic)('frequentStep');
runOnJS(scrubTo)(letter); runOnJS(scrubTo)(letter);
}) })
.onUpdate((event) => { .onUpdate((event) => {
@@ -76,7 +80,6 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp
const letter = RAIL_LETTERS[index]; const letter = RAIL_LETTERS[index];
if (letter === lastLetter.value) return; if (letter === lastLetter.value) return;
lastLetter.value = letter; lastLetter.value = letter;
runOnJS(playHaptic)('frequentStep');
runOnJS(scrubTo)(letter); runOnJS(scrubTo)(letter);
}) })
.onFinalize(() => { .onFinalize(() => {
@@ -85,8 +88,8 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp
runOnJS(endScrub)(); runOnJS(endScrub)();
}); });
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 onJumpToLetter via render closure // eslint-disable-next-line react-hooks/exhaustive-deps -- scrubTo/endScrub capture the latest props via render closure
}, [lastLetter, bubbleY, railTop, pullSearchRef, onJumpToLetter]); }, [lastLetter, bubbleY, railTop, pullSearchRef, activeLetters, onJumpToLetter]);
const bubbleStyle = useAnimatedStyle(() => ({ const bubbleStyle = useAnimatedStyle(() => ({
transform: [{ translateY: bubbleY.value - BUBBLE_SIZE / 2 }], transform: [{ translateY: bubbleY.value - BUBBLE_SIZE / 2 }],
+299 -71
View File
@@ -87,13 +87,14 @@ interface LibraryStore {
albumNextCursor: string | null; albumNextCursor: string | null;
artistNextCursor: string | null; artistNextCursor: string | null;
sectionAnchors: LibrarySectionAnchor[]; sectionAnchors: LibrarySectionAnchor[];
sectionJumpRevision: 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>;
jumpToSection: (cursor: string) => Promise<void>; jumpToSection: (cursor: string) => Promise<boolean>;
recordTrackPlayed: (path: string) => Promise<void>; recordTrackPlayed: (path: string) => Promise<void>;
recomputeArtists: () => void; recomputeArtists: () => void;
recomputeAlbums: () => void; recomputeAlbums: () => void;
@@ -122,51 +123,105 @@ function appendWindow<T>(
} }
export const useLibraryStore = create<LibraryStore>((set, get) => { export const useLibraryStore = create<LibraryStore>((set, get) => {
const pageGenerations = {
tracks: 0,
albums: 0,
artists: 0,
};
let anchorGeneration = 0;
let loadingGeneration = 0;
const beginLoading = () => {
const generation = ++loadingGeneration;
set({ isPageLoading: true });
return generation;
};
const finishLoading = (generation: number) => {
if (generation === loadingGeneration) set({ isPageLoading: false });
};
const onProgress = (progress: ScanProgress) => { const onProgress = (progress: ScanProgress) => {
set({ scanProgress: progress }); set({ scanProgress: progress });
void reportScanProgress(progress); void reportScanProgress(progress);
}; };
const readTrackPage = (cursor: string | null) => const readTrackPage = (
AstraLibraryData.getTrackPage<DbTrack>(get().trackSort, cursor, PAGE_SIZE); cursor: string | null,
sort = get().trackSort,
) => AstraLibraryData.getTrackPage<DbTrack>(sort, cursor, PAGE_SIZE);
const readAlbumPage = (cursor: string | null) => const readAlbumPage = (
cursor: string | null,
sort = get().albumSort,
includeSingles = useSettingsStore.getState().includeSingles,
) =>
AstraLibraryData.getAlbumPage<Album>( AstraLibraryData.getAlbumPage<Album>(
get().albumSort, sort,
useSettingsStore.getState().includeSingles, includeSingles,
cursor, cursor,
PAGE_SIZE PAGE_SIZE
); );
const readArtistPage = (cursor: string | null) => const readArtistPage = (
cursor: string | null,
sort = get().artistSort,
groupingMode = useSettingsStore.getState().artistGroupingMode,
includeCollaborations = get().includeCollabArtists,
) =>
AstraLibraryData.getArtistPage<Artist>( AstraLibraryData.getArtistPage<Artist>(
get().artistSort, sort,
useSettingsStore.getState().artistGroupingMode, groupingMode,
get().includeCollabArtists, includeCollaborations,
cursor, cursor,
PAGE_SIZE PAGE_SIZE
); );
const resetTracks = async () => { const resetTracks = async () => {
const page = await readTrackPage(null); const sort = get().trackSort;
const generation = ++pageGenerations.tracks;
const page = await readTrackPage(null, sort);
if (generation !== pageGenerations.tracks || get().trackSort !== sort) return false;
set({ set({
tracks: page.items ?? [], tracks: page.items ?? [],
trackNextCursor: page.nextCursor ?? null, trackNextCursor: page.nextCursor ?? null,
totalTrackCount: page.totalCount ?? 0, totalTrackCount: page.totalCount ?? 0,
}); });
return true;
}; };
const resetAlbums = async () => { const resetAlbums = async () => {
const page = await readAlbumPage(null); const sort = get().albumSort;
const includeSingles = useSettingsStore.getState().includeSingles;
const generation = ++pageGenerations.albums;
const page = await readAlbumPage(null, sort, includeSingles);
if (
generation !== pageGenerations.albums ||
get().albumSort !== sort ||
useSettingsStore.getState().includeSingles !== includeSingles
) return false;
set({ albums: page.items ?? [], albumNextCursor: page.nextCursor ?? null }); set({ albums: page.items ?? [], albumNextCursor: page.nextCursor ?? null });
return true;
}; };
const resetArtists = async () => { const resetArtists = async () => {
const page = await readArtistPage(null); const sort = get().artistSort;
const groupingMode = useSettingsStore.getState().artistGroupingMode;
const includeCollaborations = get().includeCollabArtists;
const generation = ++pageGenerations.artists;
const page = await readArtistPage(null, sort, groupingMode, includeCollaborations);
if (
generation !== pageGenerations.artists ||
get().artistSort !== sort ||
useSettingsStore.getState().artistGroupingMode !== groupingMode ||
get().includeCollabArtists !== includeCollaborations
) return false;
set({ artists: page.items ?? [], artistNextCursor: page.nextCursor ?? null }); set({ artists: page.items ?? [], artistNextCursor: page.nextCursor ?? null });
return true;
}; };
const resetSectionAnchors = async () => { const resetSectionAnchors = async () => {
const generation = ++anchorGeneration;
const state = get(); const state = get();
const sortable = const sortable =
(state.viewMode === 'tracks' && (state.trackSort === 'artist' || state.trackSort === 'title')) || (state.viewMode === 'tracks' && (state.trackSort === 'artist' || state.trackSort === 'title')) ||
@@ -182,15 +237,31 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
: state.viewMode === 'albums' : state.viewMode === 'albums'
? state.albumSort as 'artist' | 'name' ? state.albumSort as 'artist' | 'name'
: 'name'; : 'name';
set({ const includeSingles = useSettingsStore.getState().includeSingles;
sectionAnchors: await AstraLibraryData.getSectionAnchors( const groupingMode = useSettingsStore.getState().artistGroupingMode;
state.viewMode as 'tracks' | 'albums' | 'artists', const anchors = await AstraLibraryData.getSectionAnchors(
sort, state.viewMode as 'tracks' | 'albums' | 'artists',
useSettingsStore.getState().includeSingles, sort,
useSettingsStore.getState().artistGroupingMode, includeSingles,
state.includeCollabArtists groupingMode,
), state.includeCollabArtists
}); );
const current = get();
const currentSort =
current.viewMode === 'tracks'
? current.trackSort
: current.viewMode === 'albums'
? current.albumSort
: current.artistSort;
if (
generation !== anchorGeneration ||
current.viewMode !== state.viewMode ||
currentSort !== sort ||
useSettingsStore.getState().includeSingles !== includeSingles ||
useSettingsStore.getState().artistGroupingMode !== groupingMode ||
current.includeCollabArtists !== state.includeCollabArtists
) return;
set({ sectionAnchors: anchors });
}; };
const runScan = async (scan: () => Promise<ScanResult | null>) => { const runScan = async (scan: () => Promise<ScanResult | null>) => {
@@ -235,6 +306,7 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
albumNextCursor: null, albumNextCursor: null,
artistNextCursor: null, artistNextCursor: null,
sectionAnchors: [], sectionAnchors: [],
sectionJumpRevision: 0,
initialize: () => { initialize: () => {
if (!initPromise) { if (!initPromise) {
@@ -282,14 +354,20 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
} }
}); });
AstraLibraryData.addListener('onCatalogChanged', () => { AstraLibraryData.addListener('onCatalogChanged', () => {
anchorGeneration += 1;
set({ sectionAnchors: [] });
void get().refresh(); void get().refresh();
}); });
useSettingsStore.subscribe((next, previous) => { useSettingsStore.subscribe((next, previous) => {
if (next.artistGroupingMode !== previous.artistGroupingMode) { if (next.artistGroupingMode !== previous.artistGroupingMode) {
anchorGeneration += 1;
set({ sectionAnchors: [] });
void resetArtists(); void resetArtists();
void resetSectionAnchors(); void resetSectionAnchors();
} }
if (next.includeSingles !== previous.includeSingles) { if (next.includeSingles !== previous.includeSingles) {
anchorGeneration += 1;
set({ sectionAnchors: [] });
void resetAlbums(); void resetAlbums();
void resetSectionAnchors(); void resetSectionAnchors();
} }
@@ -310,7 +388,22 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
}, },
refresh: async () => { refresh: async () => {
const viewMode = get().viewMode; const stateAtStart = get();
const viewMode = stateAtStart.viewMode;
const trackSort = stateAtStart.trackSort;
const albumSort = stateAtStart.albumSort;
const artistSort = stateAtStart.artistSort;
const includeSingles = useSettingsStore.getState().includeSingles;
const groupingMode = useSettingsStore.getState().artistGroupingMode;
const includeCollaborations = stateAtStart.includeCollabArtists;
const activeGeneration =
viewMode === 'tracks'
? ++pageGenerations.tracks
: viewMode === 'albums'
? ++pageGenerations.albums
: viewMode === 'artists'
? ++pageGenerations.artists
: null;
const [ const [
trackPage, trackPage,
albumPage, albumPage,
@@ -320,36 +413,59 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
folders, folders,
recentlyPlayedTracks, recentlyPlayedTracks,
] = await Promise.all([ ] = await Promise.all([
viewMode === 'tracks' ? readTrackPage(null) : Promise.resolve(null), viewMode === 'tracks' ? readTrackPage(null, trackSort) : Promise.resolve(null),
viewMode === 'albums' ? readAlbumPage(null) : Promise.resolve(null), viewMode === 'albums'
viewMode === 'artists' ? readArtistPage(null) : Promise.resolve(null), ? readAlbumPage(null, albumSort, includeSingles)
: Promise.resolve(null),
viewMode === 'artists'
? readArtistPage(null, artistSort, groupingMode, includeCollaborations)
: Promise.resolve(null),
AstraLibraryData.getAlbumPage<Album>( AstraLibraryData.getAlbumPage<Album>(
'recently_added', 'recently_added',
useSettingsStore.getState().includeSingles, includeSingles,
null, null,
20 20
), ),
AstraLibraryData.getArtistPage<Artist>( AstraLibraryData.getArtistPage<Artist>(
'name', 'name',
useSettingsStore.getState().artistGroupingMode, groupingMode,
get().includeCollabArtists, includeCollaborations,
null, null,
50 50
), ),
loadFolders(), loadFolders(),
AstraLibraryData.getRecentlyPlayed<DbTrack>(20), AstraLibraryData.getRecentlyPlayed<DbTrack>(20),
]); ]);
const current = get();
const canApplyTrackPage =
viewMode === 'tracks' &&
current.viewMode === 'tracks' &&
current.trackSort === trackSort &&
activeGeneration === pageGenerations.tracks;
const canApplyAlbumPage =
viewMode === 'albums' &&
current.viewMode === 'albums' &&
current.albumSort === albumSort &&
useSettingsStore.getState().includeSingles === includeSingles &&
activeGeneration === pageGenerations.albums;
const canApplyArtistPage =
viewMode === 'artists' &&
current.viewMode === 'artists' &&
current.artistSort === artistSort &&
useSettingsStore.getState().artistGroupingMode === groupingMode &&
current.includeCollabArtists === includeCollaborations &&
activeGeneration === pageGenerations.artists;
set({ set({
...(trackPage ? { ...(trackPage && canApplyTrackPage ? {
tracks: trackPage.items ?? [], tracks: trackPage.items ?? [],
trackNextCursor: trackPage.nextCursor ?? null, trackNextCursor: trackPage.nextCursor ?? null,
totalTrackCount: trackPage.totalCount ?? get().totalTrackCount, totalTrackCount: trackPage.totalCount ?? current.totalTrackCount,
} : {}), } : {}),
...(albumPage ? { ...(albumPage && canApplyAlbumPage ? {
albums: albumPage.items ?? [], albums: albumPage.items ?? [],
albumNextCursor: albumPage.nextCursor ?? null, albumNextCursor: albumPage.nextCursor ?? null,
} : {}), } : {}),
...(artistPage ? { ...(artistPage && canApplyArtistPage ? {
artists: artistPage.items ?? [], artists: artistPage.items ?? [],
artistNextCursor: artistPage.nextCursor ?? null, artistNextCursor: artistPage.nextCursor ?? null,
} : {}), } : {}),
@@ -362,76 +478,178 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
}, },
loadNextTracks: async () => { loadNextTracks: async () => {
const cursor = get().trackNextCursor; const state = get();
if (!cursor || get().isPageLoading) return; const cursor = state.trackNextCursor;
set({ isPageLoading: true }); if (!cursor || state.isPageLoading) return;
const sort = state.trackSort;
const pageGeneration = pageGenerations.tracks;
const loading = beginLoading();
try { try {
const page = await readTrackPage(cursor); const page = await readTrackPage(cursor, sort);
if (page.error === 'STALE_REVISION') return resetTracks(); if (
pageGeneration !== pageGenerations.tracks ||
get().trackSort !== sort ||
get().trackNextCursor !== cursor
) return;
if (page.error === 'STALE_REVISION') {
await resetTracks();
return;
}
set((state) => ({ set((state) => ({
tracks: appendWindow(state.tracks, page.items, (track) => track.path), tracks: appendWindow(state.tracks, page.items, (track) => track.path),
trackNextCursor: page.nextCursor, trackNextCursor: page.nextCursor,
})); }));
} finally { } finally {
set({ isPageLoading: false }); finishLoading(loading);
} }
}, },
loadNextAlbums: async () => { loadNextAlbums: async () => {
const cursor = get().albumNextCursor; const state = get();
if (!cursor || get().isPageLoading) return; const cursor = state.albumNextCursor;
set({ isPageLoading: true }); if (!cursor || state.isPageLoading) return;
const sort = state.albumSort;
const includeSingles = useSettingsStore.getState().includeSingles;
const pageGeneration = pageGenerations.albums;
const loading = beginLoading();
try { try {
const page = await readAlbumPage(cursor); const page = await readAlbumPage(cursor, sort, includeSingles);
if (page.error === 'STALE_REVISION') return resetAlbums(); if (
pageGeneration !== pageGenerations.albums ||
get().albumSort !== sort ||
useSettingsStore.getState().includeSingles !== includeSingles ||
get().albumNextCursor !== cursor
) return;
if (page.error === 'STALE_REVISION') {
await resetAlbums();
return;
}
set((state) => ({ set((state) => ({
albums: appendWindow(state.albums, page.items, (album) => album.identity_key), albums: appendWindow(state.albums, page.items, (album) => album.identity_key),
albumNextCursor: page.nextCursor, albumNextCursor: page.nextCursor,
})); }));
} finally { } finally {
set({ isPageLoading: false }); finishLoading(loading);
} }
}, },
loadNextArtists: async () => { loadNextArtists: async () => {
const cursor = get().artistNextCursor; const state = get();
if (!cursor || get().isPageLoading) return; const cursor = state.artistNextCursor;
set({ isPageLoading: true }); if (!cursor || state.isPageLoading) return;
const sort = state.artistSort;
const groupingMode = useSettingsStore.getState().artistGroupingMode;
const includeCollaborations = state.includeCollabArtists;
const pageGeneration = pageGenerations.artists;
const loading = beginLoading();
try { try {
const page = await readArtistPage(cursor); const page = await readArtistPage(cursor, sort, groupingMode, includeCollaborations);
if (page.error === 'STALE_REVISION') return resetArtists(); if (
pageGeneration !== pageGenerations.artists ||
get().artistSort !== sort ||
useSettingsStore.getState().artistGroupingMode !== groupingMode ||
get().includeCollabArtists !== includeCollaborations ||
get().artistNextCursor !== cursor
) return;
if (page.error === 'STALE_REVISION') {
await resetArtists();
return;
}
set((state) => ({ set((state) => ({
artists: appendWindow(state.artists, page.items, (artist) => artist.artist), artists: appendWindow(state.artists, page.items, (artist) => artist.artist),
artistNextCursor: page.nextCursor, artistNextCursor: page.nextCursor,
})); }));
} finally { } finally {
set({ isPageLoading: false }); finishLoading(loading);
} }
}, },
jumpToSection: async (cursor) => { jumpToSection: async (cursor) => {
const state = get(); const state = get();
set({ isPageLoading: true }); const viewMode = state.viewMode;
if (viewMode !== 'tracks' && viewMode !== 'albums' && viewMode !== 'artists') return false;
const generation = ++pageGenerations[viewMode];
const loading = beginLoading();
try { try {
if (state.viewMode === 'tracks') { if (viewMode === 'tracks') {
const page = await readTrackPage(cursor); const sort = state.trackSort;
if (page.error === 'STALE_REVISION') return resetTracks(); const page = await readTrackPage(cursor, sort);
set({ if (
generation !== pageGenerations.tracks ||
get().viewMode !== viewMode ||
get().trackSort !== sort
) return false;
if (page.error === 'STALE_REVISION') {
await resetTracks();
return false;
}
if (page.items.length === 0) {
void resetSectionAnchors();
return false;
}
set((current) => ({
tracks: page.items, tracks: page.items,
trackNextCursor: page.nextCursor, trackNextCursor: page.nextCursor,
totalTrackCount: page.totalCount, totalTrackCount: page.totalCount,
}); sectionJumpRevision: current.sectionJumpRevision + 1,
} else if (state.viewMode === 'albums') { }));
const page = await readAlbumPage(cursor); } else if (viewMode === 'albums') {
if (page.error === 'STALE_REVISION') return resetAlbums(); const sort = state.albumSort;
set({ albums: page.items, albumNextCursor: page.nextCursor }); const includeSingles = useSettingsStore.getState().includeSingles;
} else if (state.viewMode === 'artists') { const page = await readAlbumPage(cursor, sort, includeSingles);
const page = await readArtistPage(cursor); if (
if (page.error === 'STALE_REVISION') return resetArtists(); generation !== pageGenerations.albums ||
set({ artists: page.items, artistNextCursor: page.nextCursor }); get().viewMode !== viewMode ||
get().albumSort !== sort ||
useSettingsStore.getState().includeSingles !== includeSingles
) return false;
if (page.error === 'STALE_REVISION') {
await resetAlbums();
return false;
}
if (page.items.length === 0) {
void resetSectionAnchors();
return false;
}
set((current) => ({
albums: page.items,
albumNextCursor: page.nextCursor,
sectionJumpRevision: current.sectionJumpRevision + 1,
}));
} else {
const sort = state.artistSort;
const groupingMode = useSettingsStore.getState().artistGroupingMode;
const includeCollaborations = state.includeCollabArtists;
const page = await readArtistPage(
cursor,
sort,
groupingMode,
includeCollaborations,
);
if (
generation !== pageGenerations.artists ||
get().viewMode !== viewMode ||
get().artistSort !== sort ||
useSettingsStore.getState().artistGroupingMode !== groupingMode ||
get().includeCollabArtists !== includeCollaborations
) return false;
if (page.error === 'STALE_REVISION') {
await resetArtists();
return false;
}
if (page.items.length === 0) {
void resetSectionAnchors();
return false;
}
set((current) => ({
artists: page.items,
artistNextCursor: page.nextCursor,
sectionJumpRevision: current.sectionJumpRevision + 1,
}));
} }
return true;
} finally { } finally {
set({ isPageLoading: false }); finishLoading(loading);
} }
}, },
@@ -449,7 +667,8 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
}, },
setViewMode: (viewMode) => { setViewMode: (viewMode) => {
set({ viewMode }); anchorGeneration += 1;
set({ viewMode, sectionAnchors: [] });
persistSetting(VIEW_MODE_KEY, viewMode); persistSetting(VIEW_MODE_KEY, viewMode);
if (viewMode === 'tracks' && get().tracks.length === 0) void resetTracks(); if (viewMode === 'tracks' && get().tracks.length === 0) void resetTracks();
if (viewMode === 'albums' && get().albums.length === 0) void resetAlbums(); if (viewMode === 'albums' && get().albums.length === 0) void resetAlbums();
@@ -458,28 +677,37 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
}, },
setTrackSort: (trackSort) => { setTrackSort: (trackSort) => {
set({ trackSort, tracks: [], trackNextCursor: null }); anchorGeneration += 1;
set({ trackSort, tracks: [], trackNextCursor: null, sectionAnchors: [] });
persistSetting(TRACK_SORT_KEY, trackSort); persistSetting(TRACK_SORT_KEY, trackSort);
void resetTracks(); void resetTracks();
void resetSectionAnchors(); void resetSectionAnchors();
}, },
setAlbumSort: (albumSort) => { setAlbumSort: (albumSort) => {
set({ albumSort, albums: [], albumNextCursor: null }); anchorGeneration += 1;
set({ albumSort, albums: [], albumNextCursor: null, sectionAnchors: [] });
persistSetting(ALBUM_SORT_KEY, albumSort); persistSetting(ALBUM_SORT_KEY, albumSort);
void resetAlbums(); void resetAlbums();
void resetSectionAnchors(); void resetSectionAnchors();
}, },
setArtistSort: (artistSort) => { setArtistSort: (artistSort) => {
set({ artistSort, artists: [], artistNextCursor: null }); anchorGeneration += 1;
set({ artistSort, artists: [], artistNextCursor: null, sectionAnchors: [] });
persistSetting(ARTIST_SORT_KEY, artistSort); persistSetting(ARTIST_SORT_KEY, artistSort);
void resetArtists(); void resetArtists();
void resetSectionAnchors(); void resetSectionAnchors();
}, },
setIncludeCollabArtists: (includeCollabArtists) => { setIncludeCollabArtists: (includeCollabArtists) => {
set({ includeCollabArtists, artists: [], artistNextCursor: null }); anchorGeneration += 1;
set({
includeCollabArtists,
artists: [],
artistNextCursor: null,
sectionAnchors: [],
});
persistSetting(INCLUDE_COLLAB_ARTISTS_KEY, includeCollabArtists ? 'true' : 'false'); persistSetting(INCLUDE_COLLAB_ARTISTS_KEY, includeCollabArtists ? 'true' : 'false');
void resetArtists(); void resetArtists();
void resetSectionAnchors(); void resetSectionAnchors();