mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 04:30:54 +02:00
artist image search
This commit is contained in:
@@ -2,3 +2,6 @@
|
|||||||
# protected GitHub release environment and in an ignored local .env file.
|
# protected GitHub release environment and in an ignored local .env file.
|
||||||
EXPO_PUBLIC_LASTFM_API_KEY=
|
EXPO_PUBLIC_LASTFM_API_KEY=
|
||||||
EXPO_PUBLIC_LASTFM_SHARED_SECRET=
|
EXPO_PUBLIC_LASTFM_SHARED_SECRET=
|
||||||
|
# Set to false to disable Deezer lookups without rolling back user data.
|
||||||
|
# Production releases still require Deezer approval/terms review.
|
||||||
|
EXPO_PUBLIC_DEEZER_ARTIST_IMAGES_ENABLED=true
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
# Privacy Policy — Astra
|
# Privacy Policy — Astra
|
||||||
|
|
||||||
**Last updated: July 15, 2026**
|
**Last updated: July 30, 2026**
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ Most Astra data stays on your device. Optional features connect only when you ch
|
|||||||
|
|
||||||
## Data stored on your device
|
## Data stored on your device
|
||||||
|
|
||||||
Astra stores your selected library folders, indexed music metadata, playlists, playback history, preferences, cached artwork and lyrics, and optional service configuration on your device.
|
Astra stores your selected library folders, indexed music metadata, playlists, playback history, preferences, cached album and artist artwork, lyrics, and optional service configuration on your device. Artist images downloaded from a provider or chosen from a local file remain available offline until Astra's app data is cleared or the app is uninstalled.
|
||||||
|
|
||||||
Passwords, session keys, and paired-desktop control tokens are stored using Android-backed secure storage. Other app data is stored in Astra's local database and files.
|
Passwords, session keys, and paired-desktop control tokens are stored using Android-backed secure storage. Other app data is stored in Astra's local database and files.
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ Passwords, session keys, and paired-desktop control tokens are stored using Andr
|
|||||||
Depending on the features you enable, Astra may send data to the following destinations:
|
Depending on the features you enable, Astra may send data to the following destinations:
|
||||||
|
|
||||||
- **Lyrics providers:** Track title, artist, album, and duration may be sent to LRCLIB or XLRCDB to find lyrics.
|
- **Lyrics providers:** Track title, artist, album, and duration may be sent to LRCLIB or XLRCDB to find lyrics.
|
||||||
|
- **Deezer artist images:** If you enable automatic artist images or manually search Deezer, Astra sends the artist name to Deezer. The selected image and its provider identifier are stored locally. Automatic downloads default to Wi-Fi or Ethernet and can be disabled in Settings.
|
||||||
- **Remote music servers:** If you add a Subsonic-compatible or Jellyfin server, Astra sends connection details, credentials, catalog requests, and playback requests directly to the server you configured.
|
- **Remote music servers:** If you add a Subsonic-compatible or Jellyfin server, Astra sends connection details, credentials, catalog requests, and playback requests directly to the server you configured.
|
||||||
- **Scrobbling services:** If you enable Last.fm-compatible scrobbling, ListenBrainz, or another configured destination, Astra sends track and playback information to that service.
|
- **Scrobbling services:** If you enable Last.fm-compatible scrobbling, ListenBrainz, or another configured destination, Astra sends track and playback information to that service.
|
||||||
- **Paired Astra Desktop:** If you pair a desktop, Astra exchanges remote-control, library-sync, playlist, favorite, queue, and playback data directly with that paired desktop.
|
- **Paired Astra Desktop:** If you pair a desktop, Astra exchanges remote-control, library-sync, playlist, favorite, queue, and playback data directly with that paired desktop.
|
||||||
@@ -34,7 +35,7 @@ Astra may request:
|
|||||||
|
|
||||||
- **Folder and file access** through Android's system picker, so you can choose music folders and import or export supported files.
|
- **Folder and file access** through Android's system picker, so you can choose music folders and import or export supported files.
|
||||||
- **Camera access** only when you open a QR-code scanner for desktop pairing, EQ presets, or Signal sharing.
|
- **Camera access** only when you open a QR-code scanner for desktop pairing, EQ presets, or Signal sharing.
|
||||||
- **Notification access** for playback controls, library scans, and paired-desktop sessions.
|
- **Notification access** for playback controls, library scans, and paired-desktop sessions. Scan notification access is requested only after you tap its explained permission button; scans can still run if it is skipped or denied.
|
||||||
- **Local-network and internet access** for optional servers, scrobbling, lyrics, sharing, and desktop features.
|
- **Local-network and internet access** for optional servers, scrobbling, lyrics, sharing, and desktop features.
|
||||||
|
|
||||||
Astra does not request microphone access.
|
Astra does not request microphone access.
|
||||||
|
|||||||
+1168
File diff suppressed because it is too large
Load Diff
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package expo.modules.astralibraryscanner
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ArtistArtworkImportTest {
|
||||||
|
private lateinit var root: File
|
||||||
|
private lateinit var artwork: File
|
||||||
|
private lateinit var thumbnails: File
|
||||||
|
private lateinit var cache: ImportedArtworkCache
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
root = File(context.cacheDir, "artist-artwork-import-test").apply {
|
||||||
|
deleteRecursively()
|
||||||
|
mkdirs()
|
||||||
|
}
|
||||||
|
artwork = File(root, "artwork")
|
||||||
|
thumbnails = File(root, "thumbnails")
|
||||||
|
cache = ImportedArtworkCache(artwork, thumbnails)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun cleanUp() {
|
||||||
|
root.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun supportedImagesAreContentAddressedReusedAndThumbnailed() {
|
||||||
|
val formats = listOf(
|
||||||
|
Bitmap.CompressFormat.JPEG to ".jpg",
|
||||||
|
Bitmap.CompressFormat.PNG to ".png",
|
||||||
|
Bitmap.CompressFormat.WEBP_LOSSLESS to ".webp",
|
||||||
|
)
|
||||||
|
for ((format, extension) in formats) {
|
||||||
|
val bytes = imageBytes(format)
|
||||||
|
val first = cache.cache(bytes)
|
||||||
|
val modifiedAt = File(artwork, first).lastModified()
|
||||||
|
val second = cache.cache(bytes)
|
||||||
|
|
||||||
|
assertEquals(first, second)
|
||||||
|
assertTrue(first.endsWith(extension))
|
||||||
|
assertTrue(File(artwork, first).isFile)
|
||||||
|
assertEquals(modifiedAt, File(artwork, second).lastModified())
|
||||||
|
assertTrue(File(thumbnails, "${first.substringBeforeLast('.')}.jpg").isFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyCorruptAndOversizedInputsAreRejectedWithoutPublishingFiles() {
|
||||||
|
assertFails { cache.cache(byteArrayOf()) }
|
||||||
|
assertFails { cache.cache("not an image".toByteArray()) }
|
||||||
|
assertFails {
|
||||||
|
readImportedArtworkBytes(
|
||||||
|
ByteArrayInputStream(ByteArray(33)),
|
||||||
|
maximumBytes = 32,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertTrue(artwork.listFiles().isNullOrEmpty())
|
||||||
|
assertTrue(thumbnails.listFiles().isNullOrEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun imageBytes(format: Bitmap.CompressFormat): ByteArray {
|
||||||
|
val bitmap = Bitmap.createBitmap(320, 200, Bitmap.Config.ARGB_8888)
|
||||||
|
return try {
|
||||||
|
ByteArrayOutputStream().use { output ->
|
||||||
|
assertTrue(bitmap.compress(format, 90, output))
|
||||||
|
output.toByteArray()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bitmap.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertFails(block: () -> Unit) {
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
fail("Expected image import to fail")
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
// Expected validation failure.
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
// Expected validation failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -368,6 +368,8 @@ class RoomLibraryRepositoryTest {
|
|||||||
|
|
||||||
val valid = snapshots.newestValid()
|
val valid = snapshots.newestValid()
|
||||||
assertTrue(valid != null)
|
assertTrue(valid != null)
|
||||||
|
// A pre-feature v1 snapshot has no artistImages property.
|
||||||
|
valid?.payload?.remove("artistImages")
|
||||||
val replacement = Room.inMemoryDatabaseBuilder(context, AstraUserDatabase::class.java)
|
val replacement = Room.inMemoryDatabaseBuilder(context, AstraUserDatabase::class.java)
|
||||||
.allowMainThreadQueries()
|
.allowMainThreadQueries()
|
||||||
.build()
|
.build()
|
||||||
@@ -380,6 +382,91 @@ class RoomLibraryRepositoryTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun artistImageSnapshotRoundTripPreservesManualAndAutomaticLayers() = runBlocking {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val snapshotDirectory = context.filesDir.resolve("astra-user-snapshots")
|
||||||
|
snapshotDirectory.deleteRecursively()
|
||||||
|
val snapshots = UserSnapshotStore(context)
|
||||||
|
user.userDao().putArtistImage(
|
||||||
|
ArtistImageEntity(
|
||||||
|
groupingMode = "fileTags",
|
||||||
|
artistKey = "björk",
|
||||||
|
artistName = "Björk",
|
||||||
|
manualImageHash = "manual.webp",
|
||||||
|
automaticImageHash = "deezer.jpg",
|
||||||
|
automaticProvider = "deezer",
|
||||||
|
automaticSourceId = "42",
|
||||||
|
lookupStatus = "transient_error",
|
||||||
|
retryCount = 2,
|
||||||
|
lastAttemptAt = 10,
|
||||||
|
nextRetryAt = 30,
|
||||||
|
updatedAt = 20,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
snapshots.write(user)
|
||||||
|
|
||||||
|
val replacement = Room.inMemoryDatabaseBuilder(context, AstraUserDatabase::class.java)
|
||||||
|
.allowMainThreadQueries()
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
snapshots.restore(replacement, requireNotNull(snapshots.newestValid()))
|
||||||
|
val restored = replacement.userDao().getArtistImage("fileTags", "björk")
|
||||||
|
assertEquals("manual.webp", restored?.manualImageHash)
|
||||||
|
assertEquals("deezer.jpg", restored?.automaticImageHash)
|
||||||
|
assertEquals("42", restored?.automaticSourceId)
|
||||||
|
assertEquals("transient_error", restored?.lookupStatus)
|
||||||
|
assertEquals(2, restored?.retryCount)
|
||||||
|
assertEquals(30L, restored?.nextRetryAt)
|
||||||
|
} finally {
|
||||||
|
replacement.close()
|
||||||
|
snapshotDirectory.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun artistArtworkBridgeUsesManualThenDeezerThenTrack() {
|
||||||
|
val summary = ArtistSummaryEntity(
|
||||||
|
revision = 1,
|
||||||
|
artistKey = "björk",
|
||||||
|
artist = "Björk",
|
||||||
|
groupingMode = "astra",
|
||||||
|
trackCount = 2,
|
||||||
|
primaryTrackCount = 2,
|
||||||
|
albumCount = 2,
|
||||||
|
artworkHash = "track.jpg",
|
||||||
|
nameSortKey = "bjork",
|
||||||
|
sectionLabel = "B",
|
||||||
|
isCollaboration = false,
|
||||||
|
artworkHashesJson = """["track.jpg","other.jpg"]""",
|
||||||
|
)
|
||||||
|
val automatic = ArtistImageEntity(
|
||||||
|
groupingMode = "astra",
|
||||||
|
artistKey = "björk",
|
||||||
|
artistName = "Björk",
|
||||||
|
automaticImageHash = "deezer.jpg",
|
||||||
|
automaticProvider = "deezer",
|
||||||
|
lookupStatus = "found",
|
||||||
|
updatedAt = 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
val automaticMap = summary.toBridgeMap(automatic)
|
||||||
|
assertEquals("deezer.jpg", automaticMap["artwork_hash"])
|
||||||
|
assertEquals(listOf("deezer.jpg"), automaticMap["artwork_hashes"])
|
||||||
|
assertEquals("deezer", automaticMap["artwork_source"])
|
||||||
|
|
||||||
|
val manualMap = summary.toBridgeMap(
|
||||||
|
automatic.copy(manualImageHash = "manual.webp"),
|
||||||
|
)
|
||||||
|
assertEquals("manual.webp", manualMap["artwork_hash"])
|
||||||
|
assertEquals(listOf("manual.webp"), manualMap["artwork_hashes"])
|
||||||
|
assertEquals("manual", manualMap["artwork_source"])
|
||||||
|
|
||||||
|
val trackMap = summary.toBridgeMap()
|
||||||
|
assertEquals("track.jpg", trackMap["artwork_hash"])
|
||||||
|
assertEquals("track", trackMap["artwork_source"])
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun dynamicRulesUseBoundArgumentsAndEscapeWildcards() = runBlocking {
|
fun dynamicRulesUseBoundArgumentsAndEscapeWildcards() = runBlocking {
|
||||||
publish(
|
publish(
|
||||||
|
|||||||
+41
@@ -181,6 +181,47 @@ class UserMigrationTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun artistImageV4MigrationPreservesUserDataAndCreatesRetryIndex() {
|
||||||
|
helper.createDatabase(TEST_DATABASE, 3).apply {
|
||||||
|
execSQL("INSERT INTO settings (`key`, value) VALUES ('theme_base', 'dark')")
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
val database = helper.runMigrationsAndValidate(
|
||||||
|
TEST_DATABASE,
|
||||||
|
4,
|
||||||
|
true,
|
||||||
|
USER_MIGRATION_3_4,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("dark", database.singleString("SELECT value FROM settings WHERE `key` = 'theme_base'"))
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO artist_images (
|
||||||
|
grouping_mode, artist_key, artist_name, manual_image_hash,
|
||||||
|
automatic_image_hash, automatic_provider, automatic_source_id,
|
||||||
|
lookup_status, retry_count, last_attempt_at, next_retry_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'astra', 'björk', 'Björk', 'manual.jpg',
|
||||||
|
'deezer.jpg', 'deezer', '42',
|
||||||
|
'found', 0, 10, NULL, 20
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
assertEquals(1, database.singleInt("SELECT COUNT(*) FROM artist_images"))
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
database.singleInt(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM sqlite_master
|
||||||
|
WHERE type = 'index'
|
||||||
|
AND name = 'index_artist_images_lookup_status_next_retry_at'
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun androidx.sqlite.db.SupportSQLiteDatabase.singleString(query: String): String =
|
private fun androidx.sqlite.db.SupportSQLiteDatabase.singleString(query: String): String =
|
||||||
this.query(query).use { cursor ->
|
this.query(query).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
package expo.modules.astralibraryscanner
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
internal const val MAX_IMPORTED_ARTWORK_BYTES = 12 * 1024 * 1024
|
||||||
|
|
||||||
|
internal fun readImportedArtworkBytes(
|
||||||
|
input: InputStream,
|
||||||
|
maximumBytes: Int = MAX_IMPORTED_ARTWORK_BYTES,
|
||||||
|
): ByteArray {
|
||||||
|
val output = ByteArrayOutputStream(minOf(maximumBytes, 64 * 1024))
|
||||||
|
val buffer = ByteArray(32 * 1024)
|
||||||
|
var total = 0
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
total += read
|
||||||
|
require(total <= maximumBytes) { "Choose an image smaller than 12 MB" }
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
return output.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class ImportedArtworkCache(
|
||||||
|
private val artworkDirectory: File,
|
||||||
|
private val thumbnailDirectory: File,
|
||||||
|
private val thumbnailSize: Int = 128,
|
||||||
|
) {
|
||||||
|
fun cache(bytes: ByteArray): String {
|
||||||
|
require(bytes.isNotEmpty()) { "The selected image is empty" }
|
||||||
|
val extension = supportedExtension(bytes)
|
||||||
|
?: error("Choose a JPEG, PNG, or WebP image")
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
|
require(bounds.outWidth > 0 && bounds.outHeight > 0) {
|
||||||
|
"The selected file is not a valid image"
|
||||||
|
}
|
||||||
|
require(bounds.outWidth <= 16_384 && bounds.outHeight <= 16_384) {
|
||||||
|
"The selected image dimensions are too large"
|
||||||
|
}
|
||||||
|
|
||||||
|
artworkDirectory.mkdirs()
|
||||||
|
thumbnailDirectory.mkdirs()
|
||||||
|
val fileName = md5Hex(bytes) + extension
|
||||||
|
publishAtomically(bytes, File(artworkDirectory, fileName))
|
||||||
|
publishThumbnail(bytes, fileName)
|
||||||
|
return fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishAtomically(bytes: ByteArray, target: File) {
|
||||||
|
if (target.isFile) return
|
||||||
|
val temporary = File(artworkDirectory, "${target.name}.tmp-${System.nanoTime()}")
|
||||||
|
try {
|
||||||
|
FileOutputStream(temporary).use { output ->
|
||||||
|
output.write(bytes)
|
||||||
|
output.fd.sync()
|
||||||
|
}
|
||||||
|
if (!temporary.renameTo(target) && !target.isFile) {
|
||||||
|
error("The image could not be cached")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (temporary.exists()) temporary.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishThumbnail(bytes: ByteArray, artworkHash: String) {
|
||||||
|
val target = File(
|
||||||
|
thumbnailDirectory,
|
||||||
|
"${artworkHash.substringBeforeLast('.', artworkHash)}.jpg",
|
||||||
|
)
|
||||||
|
if (target.isFile) return
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
var sample = 1
|
||||||
|
while (max(bounds.outWidth, bounds.outHeight) / sample > thumbnailSize * 2) sample *= 2
|
||||||
|
inSampleSize = sample
|
||||||
|
inPreferredConfig = Bitmap.Config.RGB_565
|
||||||
|
}
|
||||||
|
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||||
|
?: error("The selected file is not a valid image")
|
||||||
|
val largest = max(decoded.width, decoded.height)
|
||||||
|
val thumbnail = if (largest <= thumbnailSize) {
|
||||||
|
decoded
|
||||||
|
} else {
|
||||||
|
val scale = thumbnailSize.toFloat() / largest
|
||||||
|
Bitmap.createScaledBitmap(
|
||||||
|
decoded,
|
||||||
|
max(1, (decoded.width * scale).roundToInt()),
|
||||||
|
max(1, (decoded.height * scale).roundToInt()),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val temporary = File(thumbnailDirectory, "${target.name}.tmp-${System.nanoTime()}")
|
||||||
|
try {
|
||||||
|
FileOutputStream(temporary).use { output ->
|
||||||
|
require(thumbnail.compress(Bitmap.CompressFormat.JPEG, 84, output)) {
|
||||||
|
"The artist image thumbnail could not be created"
|
||||||
|
}
|
||||||
|
output.fd.sync()
|
||||||
|
}
|
||||||
|
if (!temporary.renameTo(target) && !target.isFile) {
|
||||||
|
error("The artist image thumbnail could not be cached")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (temporary.exists()) temporary.delete()
|
||||||
|
if (thumbnail !== decoded && !decoded.isRecycled) decoded.recycle()
|
||||||
|
if (!thumbnail.isRecycled) thumbnail.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun supportedExtension(bytes: ByteArray): String? = when {
|
||||||
|
bytes.size >= 2 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() -> ".jpg"
|
||||||
|
bytes.size >= 8 &&
|
||||||
|
bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() &&
|
||||||
|
bytes[2] == 0x4E.toByte() && bytes[3] == 0x47.toByte() -> ".png"
|
||||||
|
bytes.size >= 12 &&
|
||||||
|
bytes[0] == 'R'.code.toByte() && bytes[1] == 'I'.code.toByte() &&
|
||||||
|
bytes[2] == 'F'.code.toByte() && bytes[3] == 'F'.code.toByte() &&
|
||||||
|
bytes[8] == 'W'.code.toByte() && bytes[9] == 'E'.code.toByte() &&
|
||||||
|
bytes[10] == 'B'.code.toByte() && bytes[11] == 'P'.code.toByte() -> ".webp"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun md5Hex(bytes: ByteArray): String =
|
||||||
|
MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
+66
@@ -25,6 +25,7 @@ class AstraLibraryDataModule : Module() {
|
|||||||
"onLibraryStatus",
|
"onLibraryStatus",
|
||||||
"onScanProgress",
|
"onScanProgress",
|
||||||
"onCatalogChanged",
|
"onCatalogChanged",
|
||||||
|
"onArtistImagesChanged",
|
||||||
)
|
)
|
||||||
|
|
||||||
OnCreate {
|
OnCreate {
|
||||||
@@ -467,6 +468,71 @@ class AstraLibraryDataModule : Module() {
|
|||||||
repository().getArtistAlbums(artistKey, groupingMode, offset, limit)
|
repository().getArtistAlbums(artistKey, groupingMode, offset, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getPendingArtistImageLookups") Coroutine { limit: Int, now: Double ->
|
||||||
|
repository().getPendingArtistImageLookups(limit, now.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("clearArtistImageLookupFailures").Coroutine<Double> {
|
||||||
|
repository().clearArtistImageLookupFailures().toDouble()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getArtistImageStats") Coroutine { groupingMode: String, now: Double ->
|
||||||
|
repository().getArtistImageStats(groupingMode, now.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getArtistImageState") Coroutine { artistKey: String, groupingMode: String ->
|
||||||
|
repository().getArtistImageState(artistKey, groupingMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("recordArtistImageLookup") Coroutine {
|
||||||
|
artistKey: String,
|
||||||
|
artistName: String,
|
||||||
|
groupingMode: String,
|
||||||
|
values: Map<String, Any?>,
|
||||||
|
->
|
||||||
|
repository().recordArtistImageLookup(
|
||||||
|
artistKey = artistKey,
|
||||||
|
artistName = artistName,
|
||||||
|
groupingMode = groupingMode,
|
||||||
|
status = values["status"] as? String ?: "transient_error",
|
||||||
|
automaticImageHash = values["automaticImageHash"] as? String,
|
||||||
|
provider = values["provider"] as? String,
|
||||||
|
sourceId = values["sourceId"] as? String,
|
||||||
|
attemptedAt = (values["attemptedAt"] as? Number)?.toLong() ?: System.currentTimeMillis(),
|
||||||
|
nextRetryAt = (values["nextRetryAt"] as? Number)?.toLong(),
|
||||||
|
clearManual = values["clearManual"] as? Boolean ?: false,
|
||||||
|
)
|
||||||
|
sendEvent(
|
||||||
|
"onArtistImagesChanged",
|
||||||
|
mapOf("artistKey" to artistKey, "groupingMode" to groupingMode),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setManualArtistImage") Coroutine {
|
||||||
|
artistKey: String,
|
||||||
|
artistName: String,
|
||||||
|
groupingMode: String,
|
||||||
|
artworkHash: String,
|
||||||
|
->
|
||||||
|
repository().setManualArtistImage(artistKey, artistName, groupingMode, artworkHash)
|
||||||
|
sendEvent(
|
||||||
|
"onArtistImagesChanged",
|
||||||
|
mapOf("artistKey" to artistKey, "groupingMode" to groupingMode),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("clearManualArtistImage") Coroutine {
|
||||||
|
artistKey: String,
|
||||||
|
artistName: String,
|
||||||
|
groupingMode: String,
|
||||||
|
->
|
||||||
|
repository().clearManualArtistImage(artistKey, artistName, groupingMode)
|
||||||
|
sendEvent(
|
||||||
|
"onArtistImagesChanged",
|
||||||
|
mapOf("artistKey" to artistKey, "groupingMode" to groupingMode),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
AsyncFunction("searchTracks") Coroutine { query: String, limit: Int ->
|
AsyncFunction("searchTracks") Coroutine { query: String, limit: Int ->
|
||||||
repository().searchTracks(query, limit)
|
repository().searchTracks(query, limit)
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-4
@@ -49,6 +49,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -266,6 +267,10 @@ class AstraLibraryScannerModule : Module() {
|
|||||||
withContext(Dispatchers.IO) { ensureArtworkThumbnails(hashes) }
|
withContext(Dispatchers.IO) { ensureArtworkThumbnails(hashes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AsyncFunction("cacheArtworkFromUri") Coroutine { uri: String ->
|
||||||
|
withContext(Dispatchers.IO) { cacheArtworkFromUri(uri) }
|
||||||
|
}
|
||||||
|
|
||||||
Function("getPersistedTreeUris") {
|
Function("getPersistedTreeUris") {
|
||||||
requireContext().contentResolver.persistedUriPermissions
|
requireContext().contentResolver.persistedUriPermissions
|
||||||
.filter { it.isReadPermission }
|
.filter { it.isReadPermission }
|
||||||
@@ -308,6 +313,20 @@ class AstraLibraryScannerModule : Module() {
|
|||||||
Function("stopScanService") {
|
Function("stopScanService") {
|
||||||
ScanForegroundService.stop(requireContext())
|
ScanForegroundService.stop(requireContext())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wait that still elapses while Astra is backgrounded.
|
||||||
|
*
|
||||||
|
* React Native drives `setTimeout` from a Choreographer frame callback that
|
||||||
|
* `JavaTimerManager.onHostPause()` removes, so JS timers simply stop firing
|
||||||
|
* once the activity is paused — a foreground service keeps the process alive
|
||||||
|
* but does not bring them back. Work that must pace itself across a
|
||||||
|
* backgrounded stretch has to wait on native instead, because a promise
|
||||||
|
* resolved from here reaches the JS thread without the frame callback.
|
||||||
|
*/
|
||||||
|
AsyncFunction("backgroundDelay") Coroutine { milliseconds: Int ->
|
||||||
|
delay(milliseconds.toLong().coerceIn(0L, 60_000L))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requireContext(): Context =
|
private fun requireContext(): Context =
|
||||||
@@ -1431,6 +1450,18 @@ class AstraLibraryScannerModule : Module() {
|
|||||||
return fileName
|
return fileName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun cacheArtworkFromUri(rawUri: String): String {
|
||||||
|
val uri = Uri.parse(rawUri.trim().ifEmpty { error("An image URI is required") })
|
||||||
|
val bytes = requireContext().contentResolver.openInputStream(uri)
|
||||||
|
?.use(::readImportedArtworkBytes)
|
||||||
|
?: error("The selected image could not be opened")
|
||||||
|
return ImportedArtworkCache(
|
||||||
|
artworkDirectory = artworkDir(),
|
||||||
|
thumbnailDirectory = artworkThumbDir(),
|
||||||
|
thumbnailSize = artworkThumbSize,
|
||||||
|
).cache(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
private fun ensureArtworkThumbnails(hashes: List<String>): Int {
|
private fun ensureArtworkThumbnails(hashes: List<String>): Int {
|
||||||
var generated = 0
|
var generated = 0
|
||||||
val seen = mutableSetOf<String>()
|
val seen = mutableSetOf<String>()
|
||||||
@@ -1533,11 +1564,19 @@ class AstraLibraryScannerModule : Module() {
|
|||||||
private fun md5Hex(bytes: ByteArray): String =
|
private fun md5Hex(bytes: ByteArray): String =
|
||||||
MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) }
|
MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
private fun sniffImageExtension(bytes: ByteArray): String = when {
|
private fun sniffSupportedImageExtension(bytes: ByteArray): String? = when {
|
||||||
bytes.size >= 2 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() -> ".jpg"
|
bytes.size >= 2 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() -> ".jpg"
|
||||||
bytes.size >= 4 && bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() -> ".png"
|
bytes.size >= 8 &&
|
||||||
bytes.size >= 12 && bytes[8] == 'W'.code.toByte() && bytes[9] == 'E'.code.toByte() &&
|
bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() &&
|
||||||
|
bytes[2] == 0x4E.toByte() && bytes[3] == 0x47.toByte() -> ".png"
|
||||||
|
bytes.size >= 12 &&
|
||||||
|
bytes[0] == 'R'.code.toByte() && bytes[1] == 'I'.code.toByte() &&
|
||||||
|
bytes[2] == 'F'.code.toByte() && bytes[3] == 'F'.code.toByte() &&
|
||||||
|
bytes[8] == 'W'.code.toByte() && bytes[9] == 'E'.code.toByte() &&
|
||||||
bytes[10] == 'B'.code.toByte() && bytes[11] == 'P'.code.toByte() -> ".webp"
|
bytes[10] == 'B'.code.toByte() && bytes[11] == 'P'.code.toByte() -> ".webp"
|
||||||
else -> ".jpg"
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sniffImageExtension(bytes: ByteArray): String =
|
||||||
|
sniffSupportedImageExtension(bytes) ?: ".jpg"
|
||||||
}
|
}
|
||||||
|
|||||||
+309
-5
@@ -2047,7 +2047,7 @@ class AstraLibraryRepository private constructor(
|
|||||||
}
|
}
|
||||||
val next = rows.lastOrNull()?.let { row -> artistCursor(revision, kind, sort, row).encode() }
|
val next = rows.lastOrNull()?.let { row -> artistCursor(revision, kind, sort, row).encode() }
|
||||||
mapOf(
|
mapOf(
|
||||||
"items" to rows.map(ArtistSummaryEntity::toBridgeMap),
|
"items" to bridgeArtistSummaries(rows),
|
||||||
"nextCursor" to next,
|
"nextCursor" to next,
|
||||||
"previousCursor" to null,
|
"previousCursor" to null,
|
||||||
"totalCount" to dao.countArtists(revision, mode, includeCollaborations).toDouble(),
|
"totalCount" to dao.countArtists(revision, mode, includeCollaborations).toDouble(),
|
||||||
@@ -2085,7 +2085,7 @@ class AstraLibraryRepository private constructor(
|
|||||||
?.lastOrNull()
|
?.lastOrNull()
|
||||||
?.let { row -> artistCursor(revision, kind, sort, row).encode() }
|
?.let { row -> artistCursor(revision, kind, sort, row).encode() }
|
||||||
mapOf(
|
mapOf(
|
||||||
"items" to descending.reversed().map(ArtistSummaryEntity::toBridgeMap),
|
"items" to bridgeArtistSummaries(descending.reversed()),
|
||||||
"nextCursor" to null,
|
"nextCursor" to null,
|
||||||
"previousCursor" to previous,
|
"previousCursor" to previous,
|
||||||
"totalCount" to dao.countArtists(revision, mode, includeCollaborations).toDouble(),
|
"totalCount" to dao.countArtists(revision, mode, includeCollaborations).toDouble(),
|
||||||
@@ -2187,7 +2187,9 @@ class AstraLibraryRepository private constructor(
|
|||||||
).encode()
|
).encode()
|
||||||
}
|
}
|
||||||
mapOf(
|
mapOf(
|
||||||
"summary" to dao.getArtistSummary(revision, mode, normalizedArtistKey)?.toBridgeMap(),
|
"summary" to bridgeArtistSummary(
|
||||||
|
dao.getArtistSummary(revision, mode, normalizedArtistKey),
|
||||||
|
),
|
||||||
"items" to rows.map(ActiveTrackView::toBridgeMap),
|
"items" to rows.map(ActiveTrackView::toBridgeMap),
|
||||||
"nextCursor" to next,
|
"nextCursor" to next,
|
||||||
"previousCursor" to null,
|
"previousCursor" to null,
|
||||||
@@ -2269,10 +2271,312 @@ class AstraLibraryRepository private constructor(
|
|||||||
mapOf(
|
mapOf(
|
||||||
"tracks" to tracks.map(ActiveTrackView::toBridgeMap),
|
"tracks" to tracks.map(ActiveTrackView::toBridgeMap),
|
||||||
"albums" to albums.map(AlbumSummaryEntity::toBridgeMap),
|
"albums" to albums.map(AlbumSummaryEntity::toBridgeMap),
|
||||||
"artists" to artists.map(ArtistSummaryEntity::toBridgeMap),
|
"artists" to bridgeArtistSummaries(artists),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single rule for "this artist still needs a lookup". `not_found` and
|
||||||
|
* `found` are both terminal here — see [clearArtistImageLookupFailures] for
|
||||||
|
* how unmatched artists become eligible again.
|
||||||
|
*/
|
||||||
|
private fun isArtistImagePending(image: ArtistImageEntity?, now: Long): Boolean =
|
||||||
|
image == null ||
|
||||||
|
image.lookupStatus == "never" ||
|
||||||
|
(image.lookupStatus == "transient_error" && (image.nextRetryAt ?: 0L) <= now)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both numbers the artist-image UI needs, from one pass over the catalog.
|
||||||
|
*
|
||||||
|
* `pending` spans both grouping modes because the sweep does, and is counted
|
||||||
|
* by normalized name rather than by row: the caller batches same-name artists
|
||||||
|
* into one provider request, and each artist is listed under both modes, so a
|
||||||
|
* row count would roughly double the real amount of work.
|
||||||
|
*
|
||||||
|
* `missing` is scoped to [groupingMode] instead, because it is shown to the
|
||||||
|
* user and has to match the artist list they are actually looking at. It
|
||||||
|
* counts artists with no portrait from any source, so it includes the ones
|
||||||
|
* already written off as `not_found` — those are exactly the candidates for a
|
||||||
|
* retry sweep, and `pending` deliberately excludes them.
|
||||||
|
*/
|
||||||
|
suspend fun getArtistImageStats(
|
||||||
|
groupingMode: String,
|
||||||
|
now: Long,
|
||||||
|
): Map<String, Any?> = withCatalogRecovery { database ->
|
||||||
|
val revision = database.catalogDao().getRevision()
|
||||||
|
val mode = normalizeGroupingMode(groupingMode)
|
||||||
|
val byMode = listOf("astra", "fileTags").associateWith { entry ->
|
||||||
|
database.catalogDao().getAllArtistSummaries(revision, entry)
|
||||||
|
}
|
||||||
|
if (byMode.values.all { it.isEmpty() }) {
|
||||||
|
return@withCatalogRecovery mapOf("pending" to 0, "missing" to 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
val images = requireUser().userDao().getAllArtistImages()
|
||||||
|
.associateBy { artistImageMapKey(it.groupingMode, it.artistKey) }
|
||||||
|
fun named(summaries: List<ArtistSummaryEntity>, keep: (ArtistSummaryEntity) -> Boolean): Int =
|
||||||
|
summaries.asSequence()
|
||||||
|
.filterNot { it.artist.equals("Unknown Artist", ignoreCase = true) }
|
||||||
|
.filter(keep)
|
||||||
|
.map { normalizeArtistKey(it.artist) }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
.count()
|
||||||
|
|
||||||
|
val pending = named(byMode.values.flatten()) { summary ->
|
||||||
|
isArtistImagePending(
|
||||||
|
images[artistImageMapKey(summary.groupingMode, summary.artistKey)],
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val missing = named(byMode[mode].orEmpty()) { summary ->
|
||||||
|
val image = images[artistImageMapKey(summary.groupingMode, summary.artistKey)]
|
||||||
|
image?.manualImageHash == null && image?.automaticImageHash == null
|
||||||
|
}
|
||||||
|
mapOf("pending" to pending, "missing" to missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getPendingArtistImageLookups(
|
||||||
|
requestedLimit: Int,
|
||||||
|
now: Long,
|
||||||
|
): List<Map<String, Any?>> = withCatalogRecovery { database ->
|
||||||
|
val revision = database.catalogDao().getRevision()
|
||||||
|
val summaries = listOf("astra", "fileTags").flatMap { mode ->
|
||||||
|
database.catalogDao().getAllArtistSummaries(revision, mode)
|
||||||
|
}
|
||||||
|
if (summaries.isEmpty()) return@withCatalogRecovery emptyList()
|
||||||
|
|
||||||
|
// Initial backfills can contain tens of thousands of artists; avoid an
|
||||||
|
// SQLite IN clause large enough to exceed the device's bind-variable cap.
|
||||||
|
val images = requireUser().userDao().getAllArtistImages()
|
||||||
|
.associateBy { artistImageMapKey(it.groupingMode, it.artistKey) }
|
||||||
|
val pending = summaries.asSequence()
|
||||||
|
.filterNot { it.artist.equals("Unknown Artist", ignoreCase = true) }
|
||||||
|
.filter { summary ->
|
||||||
|
isArtistImagePending(
|
||||||
|
images[artistImageMapKey(summary.groupingMode, summary.artistKey)],
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedWith(
|
||||||
|
compareBy<ArtistSummaryEntity>(
|
||||||
|
ArtistSummaryEntity::nameSortKey,
|
||||||
|
ArtistSummaryEntity::artist,
|
||||||
|
ArtistSummaryEntity::groupingMode,
|
||||||
|
ArtistSummaryEntity::artistKey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
val limit = requestedLimit.coerceIn(1, 500)
|
||||||
|
val boundaryArtist = pending.getOrNull(limit - 1)?.artist?.let(::normalizeArtistKey)
|
||||||
|
(pending.take(limit) + pending.drop(limit).takeWhile { summary ->
|
||||||
|
normalizeArtistKey(summary.artist) == boundaryArtist
|
||||||
|
}).asSequence()
|
||||||
|
.map { summary ->
|
||||||
|
mapOf(
|
||||||
|
"groupingMode" to summary.groupingMode,
|
||||||
|
"artistKey" to summary.artistKey,
|
||||||
|
"artistName" to summary.artist,
|
||||||
|
"retryCount" to (
|
||||||
|
images[artistImageMapKey(summary.groupingMode, summary.artistKey)]?.retryCount ?: 0
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getArtistImageState(
|
||||||
|
artistKey: String,
|
||||||
|
groupingMode: String,
|
||||||
|
): Map<String, Any?> {
|
||||||
|
initialize()
|
||||||
|
val mode = normalizeGroupingMode(groupingMode)
|
||||||
|
val key = normalizeArtistKey(artistKey)
|
||||||
|
val image = requireUser().userDao().getArtistImage(mode, key)
|
||||||
|
return image?.toBridgeMap() ?: mapOf(
|
||||||
|
"groupingMode" to mode,
|
||||||
|
"artistKey" to key,
|
||||||
|
"manualImageHash" to null,
|
||||||
|
"automaticImageHash" to null,
|
||||||
|
"automaticProvider" to null,
|
||||||
|
"automaticSourceId" to null,
|
||||||
|
"lookupStatus" to "never",
|
||||||
|
"retryCount" to 0,
|
||||||
|
"lastAttemptAt" to null,
|
||||||
|
"nextRetryAt" to null,
|
||||||
|
"updatedAt" to null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes previously unmatched artists eligible for lookup again, returning how
|
||||||
|
* many were re-queued. `not_found` is otherwise terminal — without this a
|
||||||
|
* provider outage, or a bug in the provider client, permanently poisons every
|
||||||
|
* artist it touched. Scans call this so "rescan" also means "re-check the
|
||||||
|
* artists that came back empty".
|
||||||
|
*/
|
||||||
|
suspend fun clearArtistImageLookupFailures(): Int {
|
||||||
|
initialize()
|
||||||
|
val cleared = requireUser().userDao().deleteFailedArtistImageLookups()
|
||||||
|
if (cleared > 0) scheduleSnapshot()
|
||||||
|
return cleared
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun recordArtistImageLookup(
|
||||||
|
artistKey: String,
|
||||||
|
artistName: String,
|
||||||
|
groupingMode: String,
|
||||||
|
status: String,
|
||||||
|
automaticImageHash: String?,
|
||||||
|
provider: String?,
|
||||||
|
sourceId: String?,
|
||||||
|
attemptedAt: Long,
|
||||||
|
nextRetryAt: Long?,
|
||||||
|
clearManual: Boolean,
|
||||||
|
) {
|
||||||
|
initialize()
|
||||||
|
val mode = normalizeGroupingMode(groupingMode)
|
||||||
|
val key = normalizeArtistKey(artistKey)
|
||||||
|
val safeStatus = when (status) {
|
||||||
|
"found", "not_found", "transient_error" -> status
|
||||||
|
else -> error("Invalid artist image lookup status")
|
||||||
|
}
|
||||||
|
if (safeStatus == "found" && automaticImageHash.isNullOrBlank()) {
|
||||||
|
error("A found artist image requires a cached artwork hash")
|
||||||
|
}
|
||||||
|
val dao = requireUser().userDao()
|
||||||
|
val existing = dao.getArtistImage(mode, key)
|
||||||
|
dao.putArtistImage(
|
||||||
|
ArtistImageEntity(
|
||||||
|
groupingMode = mode,
|
||||||
|
artistKey = key,
|
||||||
|
artistName = artistName.trim().ifEmpty { existing?.artistName ?: artistKey },
|
||||||
|
manualImageHash = if (clearManual) null else existing?.manualImageHash,
|
||||||
|
automaticImageHash = when (safeStatus) {
|
||||||
|
"found" -> automaticImageHash
|
||||||
|
"not_found" -> null
|
||||||
|
else -> existing?.automaticImageHash
|
||||||
|
},
|
||||||
|
automaticProvider = when (safeStatus) {
|
||||||
|
"found" -> provider
|
||||||
|
"not_found" -> null
|
||||||
|
else -> existing?.automaticProvider
|
||||||
|
},
|
||||||
|
automaticSourceId = when (safeStatus) {
|
||||||
|
"found" -> sourceId
|
||||||
|
"not_found" -> null
|
||||||
|
else -> existing?.automaticSourceId
|
||||||
|
},
|
||||||
|
lookupStatus = safeStatus,
|
||||||
|
retryCount = if (safeStatus == "transient_error") {
|
||||||
|
(existing?.retryCount ?: 0) + 1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
lastAttemptAt = attemptedAt,
|
||||||
|
nextRetryAt = if (safeStatus == "transient_error") nextRetryAt else null,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
scheduleSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setManualArtistImage(
|
||||||
|
artistKey: String,
|
||||||
|
artistName: String,
|
||||||
|
groupingMode: String,
|
||||||
|
artworkHash: String,
|
||||||
|
) {
|
||||||
|
initialize()
|
||||||
|
require(artworkHash.isNotBlank()) { "A cached artwork hash is required" }
|
||||||
|
val mode = normalizeGroupingMode(groupingMode)
|
||||||
|
val key = normalizeArtistKey(artistKey)
|
||||||
|
val dao = requireUser().userDao()
|
||||||
|
val existing = dao.getArtistImage(mode, key)
|
||||||
|
dao.putArtistImage(
|
||||||
|
(existing ?: ArtistImageEntity(
|
||||||
|
groupingMode = mode,
|
||||||
|
artistKey = key,
|
||||||
|
artistName = artistName.trim().ifEmpty { artistKey },
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
)).copy(
|
||||||
|
artistName = artistName.trim().ifEmpty { existing?.artistName ?: artistKey },
|
||||||
|
manualImageHash = artworkHash,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
scheduleSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearManualArtistImage(
|
||||||
|
artistKey: String,
|
||||||
|
artistName: String,
|
||||||
|
groupingMode: String,
|
||||||
|
) {
|
||||||
|
initialize()
|
||||||
|
val mode = normalizeGroupingMode(groupingMode)
|
||||||
|
val key = normalizeArtistKey(artistKey)
|
||||||
|
val dao = requireUser().userDao()
|
||||||
|
val existing = dao.getArtistImage(mode, key) ?: return
|
||||||
|
dao.putArtistImage(
|
||||||
|
existing.copy(
|
||||||
|
artistName = artistName.trim().ifEmpty { existing.artistName },
|
||||||
|
manualImageHash = null,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
scheduleSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun bridgeArtistSummary(row: ArtistSummaryEntity?): Map<String, Any?>? =
|
||||||
|
row?.let { summary ->
|
||||||
|
val image = requireUser().userDao().getArtistImage(summary.groupingMode, summary.artistKey)
|
||||||
|
summary.toBridgeMap(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun bridgeArtistSummaries(
|
||||||
|
rows: List<ArtistSummaryEntity>,
|
||||||
|
): List<Map<String, Any?>> {
|
||||||
|
if (rows.isEmpty()) return emptyList()
|
||||||
|
val images = loadArtistImages(rows)
|
||||||
|
return rows.map { summary ->
|
||||||
|
summary.toBridgeMap(images[artistImageMapKey(summary.groupingMode, summary.artistKey)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadArtistImages(
|
||||||
|
rows: List<ArtistSummaryEntity>,
|
||||||
|
): Map<String, ArtistImageEntity> {
|
||||||
|
val dao = requireUser().userDao()
|
||||||
|
return rows
|
||||||
|
.groupBy(ArtistSummaryEntity::groupingMode)
|
||||||
|
.flatMap { (mode, summaries) ->
|
||||||
|
dao.getArtistImages(mode, summaries.map(ArtistSummaryEntity::artistKey).distinct())
|
||||||
|
}
|
||||||
|
.associateBy { artistImageMapKey(it.groupingMode, it.artistKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ArtistImageEntity.toBridgeMap(): Map<String, Any?> = mapOf(
|
||||||
|
"groupingMode" to groupingMode,
|
||||||
|
"artistKey" to artistKey,
|
||||||
|
"artistName" to artistName,
|
||||||
|
"manualImageHash" to manualImageHash,
|
||||||
|
"automaticImageHash" to automaticImageHash,
|
||||||
|
"automaticProvider" to automaticProvider,
|
||||||
|
"automaticSourceId" to automaticSourceId,
|
||||||
|
"lookupStatus" to lookupStatus,
|
||||||
|
"retryCount" to retryCount,
|
||||||
|
"lastAttemptAt" to lastAttemptAt?.toDouble(),
|
||||||
|
"nextRetryAt" to nextRetryAt?.toDouble(),
|
||||||
|
"updatedAt" to updatedAt.toDouble(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun artistImageMapKey(groupingMode: String, artistKey: String): String =
|
||||||
|
"$groupingMode\u0000$artistKey"
|
||||||
|
|
||||||
|
private fun normalizeGroupingMode(value: String): String =
|
||||||
|
if (value == "fileTags") "fileTags" else "astra"
|
||||||
|
|
||||||
suspend fun matchSignal(
|
suspend fun matchSignal(
|
||||||
title: String,
|
title: String,
|
||||||
artist: String,
|
artist: String,
|
||||||
@@ -2919,7 +3223,7 @@ class AstraLibraryRepository private constructor(
|
|||||||
private fun buildUserDatabase(): AstraUserDatabase =
|
private fun buildUserDatabase(): AstraUserDatabase =
|
||||||
Room.databaseBuilder(applicationContext, AstraUserDatabase::class.java, USER_DB_NAME)
|
Room.databaseBuilder(applicationContext, AstraUserDatabase::class.java, USER_DB_NAME)
|
||||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||||
.addMigrations(USER_MIGRATION_1_2, USER_MIGRATION_2_3)
|
.addMigrations(USER_MIGRATION_1_2, USER_MIGRATION_2_3, USER_MIGRATION_3_4)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private fun buildCatalogDatabase(): AstraCatalogDatabase =
|
private fun buildCatalogDatabase(): AstraCatalogDatabase =
|
||||||
|
|||||||
+33
-15
@@ -188,21 +188,39 @@ fun AlbumSummaryEntity.toBridgeMap(): Map<String, Any?> = mapOf(
|
|||||||
"latest_added_at" to latestAddedAt.toDouble(),
|
"latest_added_at" to latestAddedAt.toDouble(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ArtistSummaryEntity.toBridgeMap(): Map<String, Any?> = mapOf(
|
fun ArtistSummaryEntity.toBridgeMap(): Map<String, Any?> = toBridgeMap(null)
|
||||||
"artist" to artist,
|
|
||||||
"track_count" to trackCount.toDouble(),
|
fun ArtistSummaryEntity.toBridgeMap(image: ArtistImageEntity?): Map<String, Any?> {
|
||||||
"primary_track_count" to primaryTrackCount.toDouble(),
|
val portraitHash = image?.manualImageHash ?: image?.automaticImageHash
|
||||||
"album_count" to albumCount.toDouble(),
|
val resolvedHash = portraitHash ?: artworkHash
|
||||||
"artwork_hash" to artworkHash,
|
val resolvedHashes = if (portraitHash != null) {
|
||||||
"source_type" to sourceType,
|
listOf(portraitHash)
|
||||||
"source_id" to sourceId?.toDouble(),
|
} else {
|
||||||
"artwork_source_id" to artworkSourceId,
|
runCatching {
|
||||||
"is_collaboration" to isCollaboration,
|
val array = JSONArray(artworkHashesJson)
|
||||||
"artwork_hashes" to runCatching {
|
List(array.length()) { index -> array.getString(index) }
|
||||||
val array = JSONArray(artworkHashesJson)
|
}.getOrDefault(emptyList())
|
||||||
List(array.length()) { index -> array.getString(index) }
|
}
|
||||||
}.getOrDefault(emptyList<String>()),
|
val artworkSource = when {
|
||||||
)
|
image?.manualImageHash != null -> "manual"
|
||||||
|
image?.automaticImageHash != null -> "deezer"
|
||||||
|
artworkHash != null -> "track"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
return mapOf(
|
||||||
|
"artist" to artist,
|
||||||
|
"track_count" to trackCount.toDouble(),
|
||||||
|
"primary_track_count" to primaryTrackCount.toDouble(),
|
||||||
|
"album_count" to albumCount.toDouble(),
|
||||||
|
"artwork_hash" to resolvedHash,
|
||||||
|
"source_type" to sourceType,
|
||||||
|
"source_id" to sourceId?.toDouble(),
|
||||||
|
"artwork_source_id" to artworkSourceId,
|
||||||
|
"is_collaboration" to isCollaboration,
|
||||||
|
"artwork_hashes" to resolvedHashes,
|
||||||
|
"artwork_source" to artworkSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun RemoteSourceEntity.toBridgeMap(): Map<String, Any?> = mapOf(
|
fun RemoteSourceEntity.toBridgeMap(): Map<String, Any?> = mapOf(
|
||||||
"id" to id.toDouble(),
|
"id" to id.toDouble(),
|
||||||
|
|||||||
+55
-1
@@ -39,6 +39,31 @@ interface UserDao {
|
|||||||
@Query("SELECT * FROM settings ORDER BY key")
|
@Query("SELECT * FROM settings ORDER BY key")
|
||||||
suspend fun snapshotSettings(): List<SettingEntity>
|
suspend fun snapshotSettings(): List<SettingEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM artist_images ORDER BY grouping_mode, artist_key")
|
||||||
|
suspend fun getAllArtistImages(): List<ArtistImageEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM artist_images WHERE grouping_mode = :groupingMode AND artist_key IN (:artistKeys)")
|
||||||
|
suspend fun getArtistImages(groupingMode: String, artistKeys: List<String>): List<ArtistImageEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM artist_images WHERE grouping_mode = :groupingMode AND artist_key = :artistKey LIMIT 1")
|
||||||
|
suspend fun getArtistImage(groupingMode: String, artistKey: String): ArtistImageEntity?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops rows that record nothing but "the provider had no match", so those
|
||||||
|
* artists are pending again. Rows holding a manual image are kept: they
|
||||||
|
* already have a portrait and never need an automatic one.
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"DELETE FROM artist_images WHERE lookup_status = 'not_found' AND manual_image_hash IS NULL",
|
||||||
|
)
|
||||||
|
suspend fun deleteFailedArtistImageLookups(): Int
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun putArtistImage(image: ArtistImageEntity)
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun putArtistImages(images: List<ArtistImageEntity>)
|
||||||
|
|
||||||
@Query("SELECT * FROM folders ORDER BY added_at, id")
|
@Query("SELECT * FROM folders ORDER BY added_at, id")
|
||||||
suspend fun getFolders(): List<FolderEntity>
|
suspend fun getFolders(): List<FolderEntity>
|
||||||
|
|
||||||
@@ -779,8 +804,9 @@ interface UserDao {
|
|||||||
PlaybackQueueEntryEntity::class,
|
PlaybackQueueEntryEntity::class,
|
||||||
PlaybackOriginalQueueEntryEntity::class,
|
PlaybackOriginalQueueEntryEntity::class,
|
||||||
SnapshotMetadataEntity::class,
|
SnapshotMetadataEntity::class,
|
||||||
|
ArtistImageEntity::class,
|
||||||
],
|
],
|
||||||
version = 3,
|
version = 4,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
abstract class AstraUserDatabase : RoomDatabase() {
|
abstract class AstraUserDatabase : RoomDatabase() {
|
||||||
@@ -1011,3 +1037,31 @@ internal val USER_MIGRATION_2_3 = object : Migration(2, 3) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal val USER_MIGRATION_3_4 = object : Migration(3, 4) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `artist_images` (
|
||||||
|
`grouping_mode` TEXT NOT NULL,
|
||||||
|
`artist_key` TEXT NOT NULL,
|
||||||
|
`artist_name` TEXT NOT NULL,
|
||||||
|
`manual_image_hash` TEXT,
|
||||||
|
`automatic_image_hash` TEXT,
|
||||||
|
`automatic_provider` TEXT,
|
||||||
|
`automatic_source_id` TEXT,
|
||||||
|
`lookup_status` TEXT NOT NULL,
|
||||||
|
`retry_count` INTEGER NOT NULL,
|
||||||
|
`last_attempt_at` INTEGER,
|
||||||
|
`next_retry_at` INTEGER,
|
||||||
|
`updated_at` INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(`grouping_mode`, `artist_key`)
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
database.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artist_images_lookup_status_next_retry_at` " +
|
||||||
|
"ON `artist_images` (`lookup_status`, `next_retry_at`)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+20
@@ -283,3 +283,23 @@ data class SnapshotMetadataEntity(
|
|||||||
@PrimaryKey val id: Int = 1,
|
@PrimaryKey val id: Int = 1,
|
||||||
@ColumnInfo(name = "last_snapshot_at") val lastSnapshotAt: Long,
|
@ColumnInfo(name = "last_snapshot_at") val lastSnapshotAt: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "artist_images",
|
||||||
|
primaryKeys = ["grouping_mode", "artist_key"],
|
||||||
|
indices = [Index(value = ["lookup_status", "next_retry_at"])],
|
||||||
|
)
|
||||||
|
data class ArtistImageEntity(
|
||||||
|
@ColumnInfo(name = "grouping_mode") val groupingMode: String,
|
||||||
|
@ColumnInfo(name = "artist_key") val artistKey: String,
|
||||||
|
@ColumnInfo(name = "artist_name") val artistName: String,
|
||||||
|
@ColumnInfo(name = "manual_image_hash") val manualImageHash: String? = null,
|
||||||
|
@ColumnInfo(name = "automatic_image_hash") val automaticImageHash: String? = null,
|
||||||
|
@ColumnInfo(name = "automatic_provider") val automaticProvider: String? = null,
|
||||||
|
@ColumnInfo(name = "automatic_source_id") val automaticSourceId: String? = null,
|
||||||
|
@ColumnInfo(name = "lookup_status") val lookupStatus: String = "never",
|
||||||
|
@ColumnInfo(name = "retry_count") val retryCount: Int = 0,
|
||||||
|
@ColumnInfo(name = "last_attempt_at") val lastAttemptAt: Long? = null,
|
||||||
|
@ColumnInfo(name = "next_retry_at") val nextRetryAt: Long? = null,
|
||||||
|
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||||
|
)
|
||||||
|
|||||||
+31
@@ -39,6 +39,7 @@ class UserSnapshotStore(
|
|||||||
json.put("pendingFavorites", dao.getPendingFavorites().toJsonArray { it.toJson() })
|
json.put("pendingFavorites", dao.getPendingFavorites().toJsonArray { it.toJson() })
|
||||||
json.put("playlistTombstones", dao.getPlaylistTombstones().toJsonArray { it.toJson() })
|
json.put("playlistTombstones", dao.getPlaylistTombstones().toJsonArray { it.toJson() })
|
||||||
json.put("playlistSyncStates", dao.getPlaylistSyncStates().toJsonArray { it.toJson() })
|
json.put("playlistSyncStates", dao.getPlaylistSyncStates().toJsonArray { it.toJson() })
|
||||||
|
json.put("artistImages", dao.getAllArtistImages().toJsonArray { it.toJson() })
|
||||||
val sessions = dao.getPlaybackSessions()
|
val sessions = dao.getPlaybackSessions()
|
||||||
json.put("playbackSessions", sessions.toJsonArray { it.toJson() })
|
json.put("playbackSessions", sessions.toJsonArray { it.toJson() })
|
||||||
json.put(
|
json.put(
|
||||||
@@ -102,6 +103,7 @@ class UserSnapshotStore(
|
|||||||
dao.putPendingFavorites(payload.array("pendingFavorites").mapObjects(::pendingFavoriteFromJson))
|
dao.putPendingFavorites(payload.array("pendingFavorites").mapObjects(::pendingFavoriteFromJson))
|
||||||
dao.putPlaylistTombstones(payload.array("playlistTombstones").mapObjects(::playlistTombstoneFromJson))
|
dao.putPlaylistTombstones(payload.array("playlistTombstones").mapObjects(::playlistTombstoneFromJson))
|
||||||
dao.putPlaylistSyncStates(payload.array("playlistSyncStates").mapObjects(::playlistSyncStateFromJson))
|
dao.putPlaylistSyncStates(payload.array("playlistSyncStates").mapObjects(::playlistSyncStateFromJson))
|
||||||
|
dao.putArtistImages(payload.array("artistImages").mapObjects(::artistImageFromJson))
|
||||||
val sessions = payload.array("playbackSessions").mapObjects(::playbackSessionFromJson)
|
val sessions = payload.array("playbackSessions").mapObjects(::playbackSessionFromJson)
|
||||||
if (sessions.isNotEmpty()) {
|
if (sessions.isNotEmpty()) {
|
||||||
sessions.forEach { dao.putPlaybackSession(it) }
|
sessions.forEach { dao.putPlaybackSession(it) }
|
||||||
@@ -328,6 +330,35 @@ private fun playlistSyncStateFromJson(json: JSONObject) = PlaylistSyncStateEntit
|
|||||||
remoteUpdatedAt = json.getLong("remoteUpdatedAt"),
|
remoteUpdatedAt = json.getLong("remoteUpdatedAt"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun ArtistImageEntity.toJson() = JSONObject()
|
||||||
|
.put("groupingMode", groupingMode)
|
||||||
|
.put("artistKey", artistKey)
|
||||||
|
.put("artistName", artistName)
|
||||||
|
.putNullable("manualImageHash", manualImageHash)
|
||||||
|
.putNullable("automaticImageHash", automaticImageHash)
|
||||||
|
.putNullable("automaticProvider", automaticProvider)
|
||||||
|
.putNullable("automaticSourceId", automaticSourceId)
|
||||||
|
.put("lookupStatus", lookupStatus)
|
||||||
|
.put("retryCount", retryCount)
|
||||||
|
.putNullable("lastAttemptAt", lastAttemptAt)
|
||||||
|
.putNullable("nextRetryAt", nextRetryAt)
|
||||||
|
.put("updatedAt", updatedAt)
|
||||||
|
|
||||||
|
private fun artistImageFromJson(json: JSONObject) = ArtistImageEntity(
|
||||||
|
groupingMode = json.getString("groupingMode"),
|
||||||
|
artistKey = json.getString("artistKey"),
|
||||||
|
artistName = json.getString("artistName"),
|
||||||
|
manualImageHash = json.nullableString("manualImageHash"),
|
||||||
|
automaticImageHash = json.nullableString("automaticImageHash"),
|
||||||
|
automaticProvider = json.nullableString("automaticProvider"),
|
||||||
|
automaticSourceId = json.nullableString("automaticSourceId"),
|
||||||
|
lookupStatus = json.optString("lookupStatus", "never"),
|
||||||
|
retryCount = json.optInt("retryCount", 0),
|
||||||
|
lastAttemptAt = json.nullableLong("lastAttemptAt"),
|
||||||
|
nextRetryAt = json.nullableLong("nextRetryAt"),
|
||||||
|
updatedAt = json.optLong("updatedAt", 0),
|
||||||
|
)
|
||||||
|
|
||||||
private fun PlaybackSessionEntity.toJson() = JSONObject()
|
private fun PlaybackSessionEntity.toJson() = JSONObject()
|
||||||
.put("id", id)
|
.put("id", id)
|
||||||
.put("contextJson", contextJson)
|
.put("contextJson", contextJson)
|
||||||
|
|||||||
@@ -181,6 +181,13 @@ declare class AstraLibraryScannerModuleType extends NativeModule<AstraLibrarySca
|
|||||||
getArtworkDirPath(): string;
|
getArtworkDirPath(): string;
|
||||||
getArtworkThumbDirPath(): string;
|
getArtworkThumbDirPath(): string;
|
||||||
ensureArtworkThumbnails(hashes: string[]): Promise<number>;
|
ensureArtworkThumbnails(hashes: string[]): Promise<number>;
|
||||||
|
/** Validate and content-addressably cache a JPEG, PNG, or WebP URI plus thumbnail. */
|
||||||
|
cacheArtworkFromUri(uri: string): Promise<string>;
|
||||||
|
/**
|
||||||
|
* A wait that still elapses while the app is backgrounded, unlike `setTimeout`
|
||||||
|
* — React Native stops firing JS timers once the activity pauses.
|
||||||
|
*/
|
||||||
|
backgroundDelay(milliseconds: number): Promise<void>;
|
||||||
getPersistedTreeUris(): string[];
|
getPersistedTreeUris(): string[];
|
||||||
takePersistableUriPermission(uri: string): Promise<boolean>;
|
takePersistableUriPermission(uri: string): Promise<boolean>;
|
||||||
releasePersistedUriPermission(uri: string): Promise<void>;
|
releasePersistedUriPermission(uri: string): Promise<void>;
|
||||||
@@ -296,6 +303,28 @@ export interface NativeLibraryLoudnessStats {
|
|||||||
medianRgTrackDb: number | null;
|
medianRgTrackDb: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NativeArtistImageLookupTarget {
|
||||||
|
groupingMode: 'astra' | 'fileTags';
|
||||||
|
artistKey: string;
|
||||||
|
artistName: string;
|
||||||
|
retryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeArtistImageState {
|
||||||
|
groupingMode: 'astra' | 'fileTags';
|
||||||
|
artistKey: string;
|
||||||
|
artistName?: string;
|
||||||
|
manualImageHash: string | null;
|
||||||
|
automaticImageHash: string | null;
|
||||||
|
automaticProvider: 'deezer' | null;
|
||||||
|
automaticSourceId: string | null;
|
||||||
|
lookupStatus: 'never' | 'found' | 'not_found' | 'transient_error';
|
||||||
|
retryCount: number;
|
||||||
|
lastAttemptAt: number | null;
|
||||||
|
nextRetryAt: number | null;
|
||||||
|
updatedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
type AstraLibraryDataEvents = {
|
type AstraLibraryDataEvents = {
|
||||||
onLibraryStatus: (event: LibraryStatusSnapshot) => void;
|
onLibraryStatus: (event: LibraryStatusSnapshot) => void;
|
||||||
onScanProgress: (event: {
|
onScanProgress: (event: {
|
||||||
@@ -306,6 +335,10 @@ type AstraLibraryDataEvents = {
|
|||||||
folderName: string;
|
folderName: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
onCatalogChanged: (event: { catalogRevision: string }) => void;
|
onCatalogChanged: (event: { catalogRevision: string }) => void;
|
||||||
|
onArtistImagesChanged: (event: {
|
||||||
|
artistKey: string;
|
||||||
|
groupingMode: 'astra' | 'fileTags';
|
||||||
|
}) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
declare class AstraLibraryDataModuleType extends NativeModule<AstraLibraryDataEvents> {
|
declare class AstraLibraryDataModuleType extends NativeModule<AstraLibraryDataEvents> {
|
||||||
@@ -505,6 +538,53 @@ declare class AstraLibraryDataModuleType extends NativeModule<AstraLibraryDataEv
|
|||||||
totalCount: number;
|
totalCount: number;
|
||||||
catalogRevision: string;
|
catalogRevision: string;
|
||||||
}>;
|
}>;
|
||||||
|
getPendingArtistImageLookups(
|
||||||
|
limit: number,
|
||||||
|
now: number
|
||||||
|
): Promise<NativeArtistImageLookupTarget[]>;
|
||||||
|
/**
|
||||||
|
* Re-queues artists a provider previously had no match for, returning how many
|
||||||
|
* became pending. `not_found` is otherwise terminal.
|
||||||
|
*/
|
||||||
|
clearArtistImageLookupFailures(): Promise<number>;
|
||||||
|
/**
|
||||||
|
* `pending` = distinct artists awaiting a lookup across both grouping modes
|
||||||
|
* (the denominator for sweep progress). `missing` = artists in `groupingMode`
|
||||||
|
* with no portrait at all, including ones already written off as not_found.
|
||||||
|
*/
|
||||||
|
getArtistImageStats(
|
||||||
|
groupingMode: 'astra' | 'fileTags',
|
||||||
|
now: number
|
||||||
|
): Promise<{ pending: number; missing: number }>;
|
||||||
|
getArtistImageState(
|
||||||
|
artistKey: string,
|
||||||
|
groupingMode: 'astra' | 'fileTags'
|
||||||
|
): Promise<NativeArtistImageState>;
|
||||||
|
recordArtistImageLookup(
|
||||||
|
artistKey: string,
|
||||||
|
artistName: string,
|
||||||
|
groupingMode: 'astra' | 'fileTags',
|
||||||
|
values: {
|
||||||
|
status: 'found' | 'not_found' | 'transient_error';
|
||||||
|
automaticImageHash?: string | null;
|
||||||
|
provider?: 'deezer' | null;
|
||||||
|
sourceId?: string | null;
|
||||||
|
attemptedAt: number;
|
||||||
|
nextRetryAt?: number | null;
|
||||||
|
clearManual?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<void>;
|
||||||
|
setManualArtistImage(
|
||||||
|
artistKey: string,
|
||||||
|
artistName: string,
|
||||||
|
groupingMode: 'astra' | 'fileTags',
|
||||||
|
artworkHash: string
|
||||||
|
): Promise<void>;
|
||||||
|
clearManualArtistImage(
|
||||||
|
artistKey: string,
|
||||||
|
artistName: string,
|
||||||
|
groupingMode: 'astra' | 'fileTags'
|
||||||
|
): Promise<void>;
|
||||||
searchTracks<T>(query: string, limit: number): Promise<T[]>;
|
searchTracks<T>(query: string, limit: number): Promise<T[]>;
|
||||||
searchLibrary<TTrack, TAlbum, TArtist>(
|
searchLibrary<TTrack, TAlbum, TArtist>(
|
||||||
query: string,
|
query: string,
|
||||||
|
|||||||
Generated
+11
@@ -31,6 +31,7 @@
|
|||||||
"expo-haptics": "~56.0.3",
|
"expo-haptics": "~56.0.3",
|
||||||
"expo-image": "~56.0.9",
|
"expo-image": "~56.0.9",
|
||||||
"expo-linking": "~56.0.11",
|
"expo-linking": "~56.0.11",
|
||||||
|
"expo-network": "~56.0.5",
|
||||||
"expo-router": "~56.2.6",
|
"expo-router": "~56.2.6",
|
||||||
"expo-secure-store": "^56.0.4",
|
"expo-secure-store": "^56.0.4",
|
||||||
"expo-sharing": "~56.0.21",
|
"expo-sharing": "~56.0.21",
|
||||||
@@ -6434,6 +6435,16 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-network": {
|
||||||
|
"version": "56.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-network/-/expo-network-56.0.5.tgz",
|
||||||
|
"integrity": "sha512-zmuyO95jayDY9jyUfOAlNp9XXJrJaAOkBXXLy0TS/nh2kppj7CHirRPkQ/tf0rsxhIL3AEd9nsRTiPtNsGT9Lw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-router": {
|
"node_modules/expo-router": {
|
||||||
"version": "56.2.6",
|
"version": "56.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.6.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"expo-haptics": "~56.0.3",
|
"expo-haptics": "~56.0.3",
|
||||||
"expo-image": "~56.0.9",
|
"expo-image": "~56.0.9",
|
||||||
"expo-linking": "~56.0.11",
|
"expo-linking": "~56.0.11",
|
||||||
|
"expo-network": "~56.0.5",
|
||||||
"expo-router": "~56.2.6",
|
"expo-router": "~56.2.6",
|
||||||
"expo-secure-store": "^56.0.4",
|
"expo-secure-store": "^56.0.4",
|
||||||
"expo-sharing": "~56.0.21",
|
"expo-sharing": "~56.0.21",
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts",
|
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts",
|
||||||
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
|
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
|
||||||
"test:artist-grouping": "node --experimental-strip-types --test src/library/artistGrouping.test.mts src/audio/artistCreditTransport.test.mts",
|
"test:artist-grouping": "node --experimental-strip-types --test src/library/artistGrouping.test.mts src/audio/artistCreditTransport.test.mts",
|
||||||
|
"test:artist-images": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/services/artistImages/deezer.test.mts src/library/artistImagePolicy.test.mts",
|
||||||
"test:waveform-math": "node --experimental-strip-types --test src/scope/waveformMath.test.mts",
|
"test:waveform-math": "node --experimental-strip-types --test src/scope/waveformMath.test.mts",
|
||||||
"test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/services/desktopSyncPolicy.test.mts src/shared/sync/conflictPreview.test.mts",
|
"test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/services/desktopSyncPolicy.test.mts src/shared/sync/conflictPreview.test.mts",
|
||||||
"test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts",
|
"test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { FlashList } from '@shopify/flash-list';
|
import { FlashList } from '@shopify/flash-list';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
@@ -19,6 +20,9 @@ import { Text } from '@/components/Text';
|
|||||||
import { AstraLogo } from '@/components/AstraLogo';
|
import { AstraLogo } from '@/components/AstraLogo';
|
||||||
import { TrackRow } from '@/components/library/TrackRow';
|
import { TrackRow } from '@/components/library/TrackRow';
|
||||||
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
||||||
|
import { ArtistImageSearchSheet } from '@/components/library/ArtistImageSearchSheet';
|
||||||
|
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
|
||||||
|
import { showAppDialog } from '@/components/dialogs/AppDialog';
|
||||||
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
|
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
|
||||||
import {
|
import {
|
||||||
fontSize,
|
fontSize,
|
||||||
@@ -43,6 +47,13 @@ import {
|
|||||||
} from '@/library/nativePages';
|
} from '@/library/nativePages';
|
||||||
import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack';
|
import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack';
|
||||||
import type { DbTrack } from '@/types/library';
|
import type { DbTrack } from '@/types/library';
|
||||||
|
import type { DeezerArtistCandidate } from '@/types/artistImages';
|
||||||
|
import { normalizeKey } from '@/shared/library/albumGrouping';
|
||||||
|
import {
|
||||||
|
resetLocalArtistImage,
|
||||||
|
selectDeezerArtistImage,
|
||||||
|
selectLocalArtistImage,
|
||||||
|
} from '@/library/artistImageLookup';
|
||||||
|
|
||||||
type IconName = ComponentProps<typeof Ionicons>['name'];
|
type IconName = ComponentProps<typeof Ionicons>['name'];
|
||||||
type ArtistSectionTarget = 'songs' | 'albums' | 'appearances';
|
type ArtistSectionTarget = 'songs' | 'albums' | 'appearances';
|
||||||
@@ -79,6 +90,9 @@ export default function ArtistScreen() {
|
|||||||
const detailGroupingMode = credit === '1' ? 'astra' : groupingMode;
|
const detailGroupingMode = credit === '1' ? 'astra' : groupingMode;
|
||||||
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
|
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
|
||||||
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
|
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
|
||||||
|
const [imageMenuOpen, setImageMenuOpen] = useState(false);
|
||||||
|
const [imageSearchOpen, setImageSearchOpen] = useState(false);
|
||||||
|
const [imageBusy, setImageBusy] = useState(false);
|
||||||
|
|
||||||
const allPage = useNativeArtistDetail(name, detailGroupingMode, 'all');
|
const allPage = useNativeArtistDetail(name, detailGroupingMode, 'all');
|
||||||
const songsPage = useNativeArtistDetail(name, detailGroupingMode, 'songs');
|
const songsPage = useNativeArtistDetail(name, detailGroupingMode, 'songs');
|
||||||
@@ -211,6 +225,78 @@ export default function ArtistScreen() {
|
|||||||
|
|
||||||
const backdropHash = detail.artworkHashes[0] ?? null;
|
const backdropHash = detail.artworkHashes[0] ?? null;
|
||||||
const disabled = detail.playbackTracks.length === 0;
|
const disabled = detail.playbackTracks.length === 0;
|
||||||
|
const artistKey = normalizeKey(name);
|
||||||
|
|
||||||
|
const reportImageError = (message: string) => {
|
||||||
|
showAppDialog({
|
||||||
|
title: 'Artist image unchanged',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseLocalImage = async () => {
|
||||||
|
setImageMenuOpen(false);
|
||||||
|
try {
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
type: ['image/jpeg', 'image/png', 'image/webp'],
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
});
|
||||||
|
if (result.canceled || !result.assets[0]) return;
|
||||||
|
setImageBusy(true);
|
||||||
|
await selectLocalArtistImage(
|
||||||
|
artistKey,
|
||||||
|
name,
|
||||||
|
detailGroupingMode,
|
||||||
|
result.assets[0].uri
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
reportImageError('Choose a valid JPEG, PNG, or WebP image smaller than 12 MB.');
|
||||||
|
} finally {
|
||||||
|
setImageBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseDeezerImage = async (candidate: DeezerArtistCandidate) => {
|
||||||
|
await selectDeezerArtistImage(artistKey, name, detailGroupingMode, candidate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetImage = async () => {
|
||||||
|
setImageMenuOpen(false);
|
||||||
|
setImageBusy(true);
|
||||||
|
try {
|
||||||
|
await resetLocalArtistImage(artistKey, name, detailGroupingMode);
|
||||||
|
} catch {
|
||||||
|
reportImageError('Astra could not reset this artist image. Try again.');
|
||||||
|
} finally {
|
||||||
|
setImageBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageActions: ActionSheetItem[] = [
|
||||||
|
{
|
||||||
|
key: 'search-deezer',
|
||||||
|
label: 'Search Deezer',
|
||||||
|
icon: 'search-outline',
|
||||||
|
onPress: () => {
|
||||||
|
setImageMenuOpen(false);
|
||||||
|
setImageSearchOpen(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'choose-local',
|
||||||
|
label: imageBusy ? 'Choosing local image…' : 'Choose local image',
|
||||||
|
icon: 'image-outline',
|
||||||
|
onPress: () => void chooseLocalImage(),
|
||||||
|
},
|
||||||
|
...(allPage.summary?.artwork_source === 'manual'
|
||||||
|
? [{
|
||||||
|
key: 'reset',
|
||||||
|
label: 'Reset to automatic',
|
||||||
|
icon: 'refresh-outline' as const,
|
||||||
|
onPress: () => void resetImage(),
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen padded={false} style={styles.screen}>
|
<Screen padded={false} style={styles.screen}>
|
||||||
@@ -260,6 +346,8 @@ export default function ArtistScreen() {
|
|||||||
backLabel={backLabel}
|
backLabel={backLabel}
|
||||||
onPlay={playArtist}
|
onPlay={playArtist}
|
||||||
onShuffle={shuffleArtist}
|
onShuffle={shuffleArtist}
|
||||||
|
onMore={() => setImageMenuOpen(true)}
|
||||||
|
moreAccessibilityLabel="Artist image options"
|
||||||
scrollY={scrollY}
|
scrollY={scrollY}
|
||||||
heroFaded={heroFaded}
|
heroFaded={heroFaded}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
@@ -267,6 +355,19 @@ export default function ArtistScreen() {
|
|||||||
onHeroBlockLayout={onHeroBlockLayout}
|
onHeroBlockLayout={onHeroBlockLayout}
|
||||||
/>
|
/>
|
||||||
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
|
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
|
||||||
|
<ActionSheet
|
||||||
|
visible={imageMenuOpen}
|
||||||
|
title={`${name} image`}
|
||||||
|
items={imageActions}
|
||||||
|
onClose={() => setImageMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
{imageSearchOpen ? (
|
||||||
|
<ArtistImageSearchSheet
|
||||||
|
artistName={name}
|
||||||
|
onClose={() => setImageSearchOpen(false)}
|
||||||
|
onSelect={chooseDeezerImage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -44,13 +44,16 @@ import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
|
|||||||
import { SyncConflictPrompt } from '@/components/sync/SyncConflictPrompt';
|
import { SyncConflictPrompt } from '@/components/sync/SyncConflictPrompt';
|
||||||
import { useThemeStore } from '@/stores/themeStore';
|
import { useThemeStore } from '@/stores/themeStore';
|
||||||
import { useOnboardingStore } from '@/stores/onboardingStore';
|
import { useOnboardingStore } from '@/stores/onboardingStore';
|
||||||
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
|
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
|
||||||
|
import { ArtistImageDisclosurePrompt } from '@/components/onboarding/ArtistImageDisclosurePrompt';
|
||||||
import { useTheme } from '@/theme/themed';
|
import { useTheme } from '@/theme/themed';
|
||||||
import { SessionLifecycle } from '@/session/SessionLifecycle';
|
import { SessionLifecycle } from '@/session/SessionLifecycle';
|
||||||
import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore';
|
import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore';
|
||||||
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
|
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
import { AppDialogHost } from '@/components/dialogs/AppDialog';
|
import { AppDialogHost } from '@/components/dialogs/AppDialog';
|
||||||
|
import { startArtistImageLookupCoordinator } from '@/library/artistImageLookup';
|
||||||
|
|
||||||
// Anchor the root stack at the tabs so a deep link straight to a top-level route
|
// Anchor the root stack at the tabs so a deep link straight to a top-level route
|
||||||
// (the widget's `recently-played`, the notification-click redirect) builds
|
// (the widget's `recently-played`, the notification-click redirect) builds
|
||||||
@@ -349,6 +352,7 @@ export default function RootLayout() {
|
|||||||
// Library tab + playback adapters get data immediately. EQ + audio settings load
|
// Library tab + playback adapters get data immediately. EQ + audio settings load
|
||||||
// alongside so the native EQ/gain reflect persisted prefs from the first play.
|
// alongside so the native EQ/gain reflect persisted prefs from the first play.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
startArtistImageLookupCoordinator();
|
||||||
useThemeStore
|
useThemeStore
|
||||||
.getState()
|
.getState()
|
||||||
.load()
|
.load()
|
||||||
@@ -448,6 +452,7 @@ export default function RootLayout() {
|
|||||||
<NowPlayingHost />
|
<NowPlayingHost />
|
||||||
<QuickSearchOverlay />
|
<QuickSearchOverlay />
|
||||||
<SyncConflictPrompt />
|
<SyncConflictPrompt />
|
||||||
|
<ArtistImageDisclosurePrompt />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
// First-run gate: opaque full-screen wizard over the (hidden) navigator.
|
// First-run gate: opaque full-screen wizard over the (hidden) navigator.
|
||||||
@@ -455,7 +460,10 @@ export default function RootLayout() {
|
|||||||
<View style={StyleSheet.absoluteFill}>
|
<View style={StyleSheet.absoluteFill}>
|
||||||
<OnboardingFlow
|
<OnboardingFlow
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
void useOnboardingStore.getState().markComplete();
|
void (async () => {
|
||||||
|
await useSettingsStore.getState().acknowledgeArtistImageDisclosure();
|
||||||
|
await useOnboardingStore.getState().markComplete();
|
||||||
|
})();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Linking,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet';
|
||||||
|
import { Text } from '@/components/Text';
|
||||||
|
import { radius, spacing } from '@/theme';
|
||||||
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||||
|
import { useRipple } from '@/theme/ripple';
|
||||||
|
import { searchArtistImageCandidates } from '@/library/artistImageLookup';
|
||||||
|
import type { DeezerArtistCandidate } from '@/types/artistImages';
|
||||||
|
|
||||||
|
export function ArtistImageSearchSheet({
|
||||||
|
artistName,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
artistName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (candidate: DeezerArtistCandidate) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const styles = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const ripple = useRipple();
|
||||||
|
const [query, setQuery] = useState(artistName);
|
||||||
|
const [candidates, setCandidates] = useState<DeezerArtistCandidate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const search = async (value = query) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await searchArtistImageCandidates(trimmed);
|
||||||
|
if (result.status === 'transient_error') {
|
||||||
|
setCandidates([]);
|
||||||
|
setError(result.message);
|
||||||
|
} else {
|
||||||
|
setCandidates(result.candidates);
|
||||||
|
if (result.candidates.length === 0) {
|
||||||
|
setError('No artist images matched that search.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => void search(artistName));
|
||||||
|
// Run once with the artist name used to open this sheet.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [artistName]);
|
||||||
|
|
||||||
|
const choose = async (candidate: DeezerArtistCandidate) => {
|
||||||
|
if (selectedId) return;
|
||||||
|
setSelectedId(candidate.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onSelect(candidate);
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
setError('That image could not be downloaded. Check your connection and try again.');
|
||||||
|
setSelectedId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeezerLink = async (url: string) => {
|
||||||
|
try {
|
||||||
|
await Linking.openURL(url);
|
||||||
|
} catch {
|
||||||
|
setError('Astra could not open the Deezer link.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppSheet onClose={onClose} scrollable>
|
||||||
|
<AppSheetTitle
|
||||||
|
title="Search Deezer"
|
||||||
|
subtitle="Choose the artist—not an album cover"
|
||||||
|
/>
|
||||||
|
<View style={styles.searchRow}>
|
||||||
|
<BottomSheetTextInput
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
onSubmitEditing={() => void search()}
|
||||||
|
placeholder="Artist name"
|
||||||
|
placeholderTextColor={colors.textTertiary}
|
||||||
|
returnKeyType="search"
|
||||||
|
autoCapitalize="words"
|
||||||
|
style={styles.input}
|
||||||
|
accessibilityLabel="Deezer artist search"
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
style={styles.searchButton}
|
||||||
|
onPress={() => void search()}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Search Deezer"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.accentTextStrong} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="search" size={20} color={colors.accentTextStrong} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<View style={styles.message}>
|
||||||
|
<Ionicons name="information-circle-outline" size={18} color={colors.textSecondary} />
|
||||||
|
<Text variant="caption" color={colors.textSecondary} style={styles.messageText}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.results}>
|
||||||
|
{candidates.map((candidate) => (
|
||||||
|
<Pressable
|
||||||
|
key={candidate.id}
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
style={styles.candidate}
|
||||||
|
onPress={() => void choose(candidate)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Use Deezer image for ${candidate.name}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: candidate.imageUrl }}
|
||||||
|
style={styles.thumbnail}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={100}
|
||||||
|
/>
|
||||||
|
<View style={styles.candidateText}>
|
||||||
|
<Text variant="body" numberOfLines={1}>{candidate.name}</Text>
|
||||||
|
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
|
||||||
|
{formatFans(candidate.fanCount)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{candidate.linkUrl ? (
|
||||||
|
<Pressable
|
||||||
|
style={styles.providerLink}
|
||||||
|
hitSlop={8}
|
||||||
|
onPress={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void openDeezerLink(candidate.linkUrl!);
|
||||||
|
}}
|
||||||
|
accessibilityRole="link"
|
||||||
|
accessibilityLabel={`Open ${candidate.name} on Deezer`}
|
||||||
|
>
|
||||||
|
<Ionicons name="open-outline" size={17} color={colors.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
{selectedId === candidate.id ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.accent} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={styles.attribution}
|
||||||
|
onPress={() => void openDeezerLink('https://www.deezer.com/')}
|
||||||
|
accessibilityRole="link"
|
||||||
|
>
|
||||||
|
<Text variant="caption" color={colors.textSecondary}>Images and artist data from Deezer</Text>
|
||||||
|
<Ionicons name="open-outline" size={14} color={colors.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
</AppSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFans(count: number): string {
|
||||||
|
return `${new Intl.NumberFormat().format(count)} Deezer fans`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = createThemedStyles((colors) => ({
|
||||||
|
searchRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: spacing.sm,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
backgroundColor: colors.bgTertiary,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
fontFamily: 'Inter_400Regular',
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
searchButton: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: colors.accentGlow,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.bgTertiary,
|
||||||
|
},
|
||||||
|
messageText: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
results: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
candidate: {
|
||||||
|
minHeight: 76,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: colors.glassBorder,
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
width: 58,
|
||||||
|
height: 58,
|
||||||
|
borderRadius: radius.pill,
|
||||||
|
backgroundColor: colors.bgTertiary,
|
||||||
|
},
|
||||||
|
candidateText: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: 3,
|
||||||
|
},
|
||||||
|
providerLink: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: radius.pill,
|
||||||
|
},
|
||||||
|
attribution: {
|
||||||
|
minHeight: 48,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Text } from '@/components/Text';
|
||||||
|
import { radius, spacing } from '@/theme';
|
||||||
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||||
|
import { useRipple } from '@/theme/ripple';
|
||||||
|
import { useArtistImageStore } from '@/stores/artistImageStore';
|
||||||
|
import { requeueMissingArtistImages } from '@/library/artistImageLookup';
|
||||||
|
|
||||||
|
const n = (value: number) => value.toLocaleString();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live sweep progress, or — when idle — how many artists still have no portrait
|
||||||
|
* plus a way to look again. The retry exists because `not_found` is terminal in
|
||||||
|
* the pending query: without it, re-checking those artists would mean rescanning
|
||||||
|
* the whole library.
|
||||||
|
*/
|
||||||
|
export function ArtistImageSweepStatus({ enabled }: { enabled: boolean }) {
|
||||||
|
const styles = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const ripple = useRipple();
|
||||||
|
const running = useArtistImageStore((s) => s.running);
|
||||||
|
const processed = useArtistImageStore((s) => s.processed);
|
||||||
|
const total = useArtistImageStore((s) => s.total);
|
||||||
|
const missing = useArtistImageStore((s) => s.missing);
|
||||||
|
const refreshMissing = useArtistImageStore((s) => s.refreshMissing);
|
||||||
|
const [retrying, setRetrying] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshMissing();
|
||||||
|
}, [refreshMissing]);
|
||||||
|
|
||||||
|
if (!enabled) return null;
|
||||||
|
|
||||||
|
if (running) {
|
||||||
|
// Clamped: the denominator is counted once up front and normalizes names
|
||||||
|
// slightly differently than the grouping does, so it can drift by a few.
|
||||||
|
const fraction = total > 0 ? Math.min(processed / total, 1) : 0;
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
|
||||||
|
{total > 0
|
||||||
|
? `Looking up artist images… ${n(processed)} of ${n(total)}`
|
||||||
|
: 'Looking up artist images…'}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.track}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.fill,
|
||||||
|
fraction > 0 ? { width: `${fraction * 100}%` } : styles.fillIndeterminate,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing <= 0) return null;
|
||||||
|
|
||||||
|
const retry = async () => {
|
||||||
|
if (retrying) return;
|
||||||
|
setRetrying(true);
|
||||||
|
try {
|
||||||
|
await requeueMissingArtistImages();
|
||||||
|
} finally {
|
||||||
|
setRetrying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text variant="caption" color={colors.textSecondary}>
|
||||||
|
{missing === 1 ? '1 artist has no image' : `${n(missing)} artists have no image`}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
style={styles.button}
|
||||||
|
disabled={retrying}
|
||||||
|
onPress={() => void retry()}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Look for missing artist images now"
|
||||||
|
>
|
||||||
|
{retrying ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.accentTextStrong} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="refresh" size={16} color={colors.accentTextStrong} />
|
||||||
|
)}
|
||||||
|
<Text variant="label" color={colors.accentTextStrong}>
|
||||||
|
Look for missing images
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = createThemedStyles((colors) => ({
|
||||||
|
container: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
height: 2,
|
||||||
|
backgroundColor: colors.glassBorder,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
height: 2,
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
},
|
||||||
|
fillIndeterminate: {
|
||||||
|
width: '100%',
|
||||||
|
opacity: 0.35,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
minHeight: 44,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
backgroundColor: colors.accentGlow,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default ArtistImageSweepStatus;
|
||||||
@@ -128,6 +128,7 @@ export function CollapsingHeader({
|
|||||||
onBack,
|
onBack,
|
||||||
backLabel,
|
backLabel,
|
||||||
onMore,
|
onMore,
|
||||||
|
moreAccessibilityLabel = 'More options',
|
||||||
onPlay,
|
onPlay,
|
||||||
onShuffle,
|
onShuffle,
|
||||||
scrollY,
|
scrollY,
|
||||||
@@ -149,6 +150,7 @@ export function CollapsingHeader({
|
|||||||
/** Names the screen `onBack` returns to; must track the real action. */
|
/** Names the screen `onBack` returns to; must track the real action. */
|
||||||
backLabel: string;
|
backLabel: string;
|
||||||
onMore?: () => void;
|
onMore?: () => void;
|
||||||
|
moreAccessibilityLabel?: string;
|
||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
onShuffle: () => void;
|
onShuffle: () => void;
|
||||||
scrollY: SharedValue<number>;
|
scrollY: SharedValue<number>;
|
||||||
@@ -327,7 +329,7 @@ export function CollapsingHeader({
|
|||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={[styles.moreButton, { top: barCenterY - 16, right: spacing.md }]}
|
style={[styles.moreButton, { top: barCenterY - 16, right: spacing.md }]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Playlist options"
|
accessibilityLabel={moreAccessibilityLabel}
|
||||||
>
|
>
|
||||||
<Ionicons name="ellipsis-horizontal" size={22} color={colors.textPrimary} />
|
<Ionicons name="ellipsis-horizontal" size={22} color={colors.textPrimary} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
type StyleProp,
|
||||||
|
type ViewStyle,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Text } from '@/components/Text';
|
||||||
|
import { radius, spacing } from '@/theme';
|
||||||
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||||
|
import { useRipple } from '@/theme/ripple';
|
||||||
|
import { useScanNotificationPermission } from '@/library/useScanNotificationPermission';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dense settings-list form of the notification permission, rendered only while
|
||||||
|
* there is still something to ask for. A permanently-satisfied "Allowed" row is
|
||||||
|
* noise in a settings list, so the card removes itself once the permission is
|
||||||
|
* held (or was never required) — including the surrounding spacing, which is why
|
||||||
|
* the caller passes `style` instead of wrapping this in its own View.
|
||||||
|
*
|
||||||
|
* The onboarding wizard deliberately does not reuse this: it has a whole page to
|
||||||
|
* fill, so it renders its own layout over the same
|
||||||
|
* `useScanNotificationPermission` state.
|
||||||
|
*/
|
||||||
|
export function ScanNotificationPermissionCard({
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}) {
|
||||||
|
const styles = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const ripple = useRipple();
|
||||||
|
const { state, granted, denied, working, resolve } = useScanNotificationPermission();
|
||||||
|
|
||||||
|
// Also hidden while the first check is in flight, so the row never appears
|
||||||
|
// just to vanish a frame later on an already-granted device.
|
||||||
|
if (state === null || granted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.card, style]}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.icon}>
|
||||||
|
<Ionicons name="notifications-outline" size={20} color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.copy}>
|
||||||
|
<Text variant="body">Scan progress notification</Text>
|
||||||
|
<Text variant="caption" color={colors.textSecondary}>
|
||||||
|
The temporary notification shows progress and lets Android keep a scan running
|
||||||
|
after you leave Astra. Scans still work if you skip it.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
style={styles.button}
|
||||||
|
disabled={working}
|
||||||
|
onPress={resolve}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
{working ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.accentTextStrong} />
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name={denied ? 'settings-outline' : 'notifications-outline'}
|
||||||
|
size={18}
|
||||||
|
color={colors.accentTextStrong}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text variant="label" color={colors.accentTextStrong}>
|
||||||
|
{denied ? 'Open Settings' : 'Allow scan notifications'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{denied ? (
|
||||||
|
<Text variant="caption" color={colors.textTertiary}>
|
||||||
|
Notification permission is denied. You can enable it in Android settings.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = createThemedStyles((colors) => ({
|
||||||
|
card: {
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
backgroundColor: colors.bgSecondary,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: radius.pill,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: colors.accentGlow,
|
||||||
|
},
|
||||||
|
copy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
minHeight: 44,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.accentGlow,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ActivityIndicator, Modal, Pressable, StyleSheet, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Text } from '@/components/Text';
|
||||||
|
import { SegmentedControl } from '@/components/SegmentedControl';
|
||||||
|
import { radius, spacing } from '@/theme';
|
||||||
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||||
|
import { useRipple } from '@/theme/ripple';
|
||||||
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
|
import type { ArtistImageAutoPolicy } from '@/types/artistImages';
|
||||||
|
|
||||||
|
export function ArtistImageDisclosurePrompt() {
|
||||||
|
const loaded = useSettingsStore((s) => s.loaded);
|
||||||
|
const seen = useSettingsStore((s) => s.artistImageDisclosureSeen);
|
||||||
|
const policy = useSettingsStore((s) => s.artistImageAutoPolicy);
|
||||||
|
if (!loaded || seen) return null;
|
||||||
|
return <ArtistImageDisclosureContent initialPolicy={policy} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtistImageDisclosureContent({
|
||||||
|
initialPolicy,
|
||||||
|
}: {
|
||||||
|
initialPolicy: ArtistImageAutoPolicy;
|
||||||
|
}) {
|
||||||
|
const styles = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const ripple = useRipple();
|
||||||
|
const [policy, setPolicy] = useState<ArtistImageAutoPolicy>(initialPolicy);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const continueSetup = async () => {
|
||||||
|
if (saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await useSettingsStore.getState().setArtistImageAutoPolicy(policy);
|
||||||
|
await useSettingsStore.getState().acknowledgeArtistImageDisclosure();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
statusBarTranslucent
|
||||||
|
onRequestClose={() => undefined}
|
||||||
|
>
|
||||||
|
<View style={styles.backdrop}>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.icon}>
|
||||||
|
<Ionicons name="person-circle-outline" size={28} color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.copy}>
|
||||||
|
<Text variant="heading">Set up artist images</Text>
|
||||||
|
<Text variant="body" color={colors.textSecondary}>
|
||||||
|
Astra can send artist names to Deezer, then store selected portraits locally
|
||||||
|
so they still appear offline. Existing album art remains the fallback.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<SegmentedControl
|
||||||
|
segments={[
|
||||||
|
{ key: 'wifi', label: 'Wi-Fi' },
|
||||||
|
{ key: 'any', label: 'Any network' },
|
||||||
|
{ key: 'off', label: 'Off' },
|
||||||
|
]}
|
||||||
|
value={policy}
|
||||||
|
onChange={(value) => setPolicy(value as ArtistImageAutoPolicy)}
|
||||||
|
/>
|
||||||
|
<Text variant="caption" color={colors.textTertiary}>
|
||||||
|
Ethernet is included in Wi-Fi mode. You can change this later in Settings › Library,
|
||||||
|
and manual searches work while automatic downloads are off.
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
style={styles.button}
|
||||||
|
onPress={() => void continueSetup()}
|
||||||
|
disabled={saving}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.bgPrimary} />
|
||||||
|
) : (
|
||||||
|
<Text variant="label" color={colors.bgPrimary}>Continue</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = createThemedStyles((colors) => ({
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
backgroundColor: colors.backdrop,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
gap: spacing.lg,
|
||||||
|
padding: spacing.xl,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
backgroundColor: colors.bgSecondary,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: radius.pill,
|
||||||
|
backgroundColor: colors.accentGlow,
|
||||||
|
},
|
||||||
|
copy: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
minHeight: 50,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import { useEffect, type ComponentProps, type ReactNode } from 'react';
|
||||||
|
import { Pressable, StyleSheet, View } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Text } from '@/components/Text';
|
||||||
|
import { StepHeader } from '@/components/onboarding/StepHeader';
|
||||||
|
import { radius, spacing } from '@/theme';
|
||||||
|
import { motion } from '@/theme/motion';
|
||||||
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||||
|
import { useRipple } from '@/theme/ripple';
|
||||||
|
import { playHaptic } from '@/lib/haptics';
|
||||||
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
|
import type { ArtistImageAutoPolicy } from '@/types/artistImages';
|
||||||
|
|
||||||
|
type IoniconName = ComponentProps<typeof Ionicons>['name'];
|
||||||
|
|
||||||
|
const POLICY_OPTIONS: {
|
||||||
|
policy: ArtistImageAutoPolicy;
|
||||||
|
icon: IoniconName;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
policy: 'wifi',
|
||||||
|
icon: 'wifi-outline',
|
||||||
|
title: 'Wi-Fi or Ethernet',
|
||||||
|
description: 'Recommended — never uses mobile data.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
policy: 'any',
|
||||||
|
icon: 'cellular-outline',
|
||||||
|
title: 'Any network',
|
||||||
|
description: 'Includes mobile data.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
policy: 'off',
|
||||||
|
icon: 'remove-circle-outline',
|
||||||
|
title: 'Off',
|
||||||
|
description: 'Nothing is sent. Manual search still works.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard page for the Deezer artist-image disclosure. Unlike the settings row
|
||||||
|
* this flattens the toggle-plus-segmented-control into three equal-weight cards:
|
||||||
|
* the network choice is the same decision as opting in, so hiding it behind a
|
||||||
|
* switch made a consent screen read as two unrelated controls.
|
||||||
|
*
|
||||||
|
* The current policy is preselected (unlike the scope-style step, which stays
|
||||||
|
* unset to avoid biasing taste feedback) — on a consent page the user needs to
|
||||||
|
* see what will actually happen if they just tap Continue.
|
||||||
|
*/
|
||||||
|
export function ArtistImageStep() {
|
||||||
|
const styles = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const policy = useSettingsStore((s) => s.artistImageAutoPolicy);
|
||||||
|
const setPolicy = useSettingsStore((s) => s.setArtistImageAutoPolicy);
|
||||||
|
|
||||||
|
const choose = (next: ArtistImageAutoPolicy) => {
|
||||||
|
if (next === policy) return;
|
||||||
|
playHaptic('selection');
|
||||||
|
void setPolicy(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.stepBody}>
|
||||||
|
<StepHeader
|
||||||
|
icon="person-circle-outline"
|
||||||
|
title="Artist portraits"
|
||||||
|
subtitle="Astra can look up artist photos on Deezer and store them on your device, so they show up offline too."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PortraitPreview enabled={policy !== 'off'} />
|
||||||
|
|
||||||
|
<View style={styles.options}>
|
||||||
|
{POLICY_OPTIONS.map((option) => (
|
||||||
|
<PolicyCard
|
||||||
|
key={option.policy}
|
||||||
|
icon={option.icon}
|
||||||
|
title={option.title}
|
||||||
|
description={option.description}
|
||||||
|
selected={option.policy === policy}
|
||||||
|
onPress={() => choose(option.policy)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text variant="caption" color={colors.textTertiary} style={styles.footnote}>
|
||||||
|
Only the artist name is sent. Change this anytime in Settings › Library.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side-by-side sketch of an artist tile with a portrait vs. the album-art
|
||||||
|
* fallback. The highlight follows the choice below — picking "Off" moves it to
|
||||||
|
* the fallback tile, so the cards and the preview always agree.
|
||||||
|
*/
|
||||||
|
function PortraitPreview({ enabled }: { enabled: boolean }) {
|
||||||
|
const styles = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
return (
|
||||||
|
<View style={styles.preview}>
|
||||||
|
<PreviewTile active={enabled} label="With portraits">
|
||||||
|
<Ionicons
|
||||||
|
name="person"
|
||||||
|
size={22}
|
||||||
|
color={enabled ? colors.accent : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
</PreviewTile>
|
||||||
|
<PreviewTile active={!enabled} label="Album art only">
|
||||||
|
<View style={styles.previewAlbumSquare} />
|
||||||
|
</PreviewTile>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two stacked circle layers cross-faded by opacity rather than an animated
|
||||||
|
* border/background colour — the repo's established way to move a highlight
|
||||||
|
* without handing colours to a worklet.
|
||||||
|
*/
|
||||||
|
function PreviewTile({
|
||||||
|
active,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const styles = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const progress = useSharedValue(active ? 1 : 0);
|
||||||
|
useEffect(() => {
|
||||||
|
progress.value = withTiming(active ? 1 : 0, motion.snap);
|
||||||
|
}, [active, progress]);
|
||||||
|
const tileStyle = useAnimatedStyle(() => ({ opacity: 0.42 + progress.value * 0.58 }));
|
||||||
|
const accentStyle = useAnimatedStyle(() => ({ opacity: progress.value }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.previewItem, tileStyle]}>
|
||||||
|
<View style={styles.previewCircleWrap}>
|
||||||
|
<View style={[styles.previewCircleLayer, styles.previewCircleNeutral]} />
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.previewCircleLayer, styles.previewCircleAccent, accentStyle]}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
<View style={styles.previewNameLine} />
|
||||||
|
<Text variant="caption" color={active ? colors.textSecondary : colors.textTertiary}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyCard({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
selected,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
icon: IoniconName;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
selected: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
const styles = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const ripple = useRipple();
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
style={[styles.card, selected && styles.cardSelected]}
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityState={{ selected }}
|
||||||
|
accessibilityLabel={`${title}. ${description}`}
|
||||||
|
>
|
||||||
|
<View style={[styles.cardIcon, selected && styles.cardIconSelected]}>
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={20}
|
||||||
|
color={selected ? colors.accentTextStrong : colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.cardCopy}>
|
||||||
|
<Text variant="label" color={selected ? colors.accentTextStrong : colors.textPrimary}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.textSecondary}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{/* Always occupies its slot. Rendering the checkmark only when selected
|
||||||
|
narrowed the copy column on tap, which re-wrapped the longer
|
||||||
|
descriptions and changed the height of the whole page. */}
|
||||||
|
<View style={styles.cardCheck}>
|
||||||
|
{selected ? (
|
||||||
|
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.cardCheckEmpty} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = createThemedStyles((colors) => ({
|
||||||
|
stepBody: {
|
||||||
|
width: '100%',
|
||||||
|
gap: spacing.lg,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.xl,
|
||||||
|
},
|
||||||
|
previewItem: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
previewCircleWrap: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
previewCircleLayer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
borderRadius: 28,
|
||||||
|
},
|
||||||
|
previewCircleNeutral: {
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
backgroundColor: colors.bgTertiary,
|
||||||
|
},
|
||||||
|
previewCircleAccent: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.accent,
|
||||||
|
backgroundColor: colors.accentGlow,
|
||||||
|
},
|
||||||
|
previewAlbumSquare: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: colors.textTertiary,
|
||||||
|
},
|
||||||
|
previewNameLine: {
|
||||||
|
width: 34,
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
backgroundColor: colors.glassBorder,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.md,
|
||||||
|
minHeight: 68,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
backgroundColor: colors.glassBg,
|
||||||
|
},
|
||||||
|
cardSelected: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.accent,
|
||||||
|
backgroundColor: colors.accentGlow,
|
||||||
|
},
|
||||||
|
cardIcon: {
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 19,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: colors.bgTertiary,
|
||||||
|
},
|
||||||
|
cardIconSelected: {
|
||||||
|
backgroundColor: colors.bgSecondary,
|
||||||
|
},
|
||||||
|
cardCopy: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
cardCheck: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
cardCheckEmpty: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
},
|
||||||
|
footnote: {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default ArtistImageStep;
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Text } from '@/components/Text';
|
||||||
|
import { StepHeader } from '@/components/onboarding/StepHeader';
|
||||||
|
import { radius, spacing } from '@/theme';
|
||||||
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||||
|
import { useRipple } from '@/theme/ripple';
|
||||||
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
|
import type { ScanNotificationPermission } from '@/library/useScanNotificationPermission';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard page for the POST_NOTIFICATIONS grant. Deliberately not the settings
|
||||||
|
* `ScanNotificationPermissionCard`: with a whole page to work with, the ask gets
|
||||||
|
* a sketch of the actual notification and one unmistakable action instead of a
|
||||||
|
* dense list row. The permission state is owned by OnboardingFlow so the footer
|
||||||
|
* button can say "Continue" vs "Skip for now" from the same source of truth.
|
||||||
|
*/
|
||||||
|
export function NotificationStep({
|
||||||
|
permission,
|
||||||
|
}: {
|
||||||
|
permission: ScanNotificationPermission;
|
||||||
|
}) {
|
||||||
|
const styles = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const ripple = useRipple();
|
||||||
|
const isScanning = useLibraryStore((s) => s.isScanning);
|
||||||
|
const { state, granted, denied, working, resolve } = permission;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.stepBody}>
|
||||||
|
<StepHeader
|
||||||
|
icon="notifications-outline"
|
||||||
|
title="Keep scans running"
|
||||||
|
subtitle={
|
||||||
|
isScanning
|
||||||
|
? 'Your scan is running now. Android needs permission to show its progress and keep it going after you leave Astra.'
|
||||||
|
: 'Astra shows a temporary progress notification while scanning. It is what lets Android keep a scan running after you leave the app.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NotificationSketch />
|
||||||
|
|
||||||
|
{state === null ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.accent} />
|
||||||
|
) : granted ? (
|
||||||
|
<View style={styles.grantedRow}>
|
||||||
|
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
|
||||||
|
<Text variant="body" color={colors.textPrimary}>
|
||||||
|
{state === 'not_required'
|
||||||
|
? 'No permission needed on this Android version'
|
||||||
|
: 'Notifications allowed'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Pressable
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
style={styles.button}
|
||||||
|
disabled={working}
|
||||||
|
onPress={resolve}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={denied ? 'Open Android settings' : 'Allow scan notifications'}
|
||||||
|
>
|
||||||
|
{working ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.accentTextStrong} />
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name={denied ? 'settings-outline' : 'notifications-outline'}
|
||||||
|
size={19}
|
||||||
|
color={colors.accentTextStrong}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text variant="label" color={colors.accentTextStrong}>
|
||||||
|
{denied ? 'Open Android settings' : 'Allow notifications'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text variant="caption" color={colors.textTertiary} style={styles.footnote}>
|
||||||
|
{denied
|
||||||
|
? 'Notifications are currently blocked for Astra. Scans still work — they just stop early if Android needs the memory.'
|
||||||
|
: 'Optional. Scans still work without it, but Android may stop a long scan once you leave the app.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Miniature of the real scan notification. Static numbers on purpose — a live
|
||||||
|
* counter here would compete with the actual ScanBanner above the page.
|
||||||
|
*/
|
||||||
|
function NotificationSketch() {
|
||||||
|
const styles = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
return (
|
||||||
|
<View style={styles.sketch}>
|
||||||
|
<View style={styles.sketchHeader}>
|
||||||
|
<View style={styles.sketchAppIcon}>
|
||||||
|
<Ionicons name="musical-note" size={11} color={colors.bgPrimary} />
|
||||||
|
</View>
|
||||||
|
<Text variant="caption" color={colors.textSecondary}>
|
||||||
|
Astra
|
||||||
|
</Text>
|
||||||
|
<View style={styles.sketchSeparator} />
|
||||||
|
<Text variant="caption" color={colors.textTertiary}>
|
||||||
|
now
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text variant="body" numberOfLines={1}>
|
||||||
|
Scanning your library
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
|
||||||
|
1,204 of 3,180 tracks
|
||||||
|
</Text>
|
||||||
|
<View style={styles.sketchTrack}>
|
||||||
|
<View style={styles.sketchFill} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = createThemedStyles((colors) => ({
|
||||||
|
stepBody: {
|
||||||
|
width: '100%',
|
||||||
|
gap: spacing.lg,
|
||||||
|
},
|
||||||
|
sketch: {
|
||||||
|
gap: spacing.xs,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
backgroundColor: colors.bgSecondary,
|
||||||
|
},
|
||||||
|
sketchHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
sketchAppIcon: {
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 5,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
},
|
||||||
|
sketchSeparator: {
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
backgroundColor: colors.textTertiary,
|
||||||
|
},
|
||||||
|
sketchTrack: {
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: colors.bgTertiary,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
sketchFill: {
|
||||||
|
width: '38%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
minHeight: 52,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.accentGlow,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
grantedRow: {
|
||||||
|
minHeight: 52,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
backgroundColor: colors.glassBg,
|
||||||
|
},
|
||||||
|
footnote: {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default NotificationStep;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, type ComponentProps } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -23,6 +23,9 @@ import { Text } from '@/components/Text';
|
|||||||
import { ScanProgress } from '@/components/library/ScanProgress';
|
import { ScanProgress } from '@/components/library/ScanProgress';
|
||||||
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
|
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
|
||||||
import { ScopeStyleCards } from '@/components/settings/ScopeStyleCards';
|
import { ScopeStyleCards } from '@/components/settings/ScopeStyleCards';
|
||||||
|
import { StepHeader } from '@/components/onboarding/StepHeader';
|
||||||
|
import { NotificationStep } from '@/components/onboarding/NotificationStep';
|
||||||
|
import { ArtistImageStep } from '@/components/onboarding/ArtistImageStep';
|
||||||
import { formatFolderCount, formatTrackCount } from '@/components/settings/SettingsPanels';
|
import { formatFolderCount, formatTrackCount } from '@/components/settings/SettingsPanels';
|
||||||
import { radius, spacing } from '@/theme';
|
import { radius, spacing } from '@/theme';
|
||||||
import { motion } from '@/theme/motion';
|
import { motion } from '@/theme/motion';
|
||||||
@@ -30,14 +33,33 @@ import { createThemedStyles, useColors } from '@/theme/themed';
|
|||||||
import { useRipple } from '@/theme/ripple';
|
import { useRipple } from '@/theme/ripple';
|
||||||
import { playHaptic } from '@/lib/haptics';
|
import { playHaptic } from '@/lib/haptics';
|
||||||
import type { BaseThemeId } from '@/theme/resolve';
|
import type { BaseThemeId } from '@/theme/resolve';
|
||||||
|
import { useScanNotificationPermission } from '@/library/useScanNotificationPermission';
|
||||||
import { useLibraryStore } from '@/stores/libraryStore';
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
import { useSettingsStore, type NowPlayingScopeStyle } from '@/stores/settingsStore';
|
import { useSettingsStore, type NowPlayingScopeStyle } from '@/stores/settingsStore';
|
||||||
import { useThemeStore } from '@/stores/themeStore';
|
import { useThemeStore } from '@/stores/themeStore';
|
||||||
|
|
||||||
type IoniconName = ComponentProps<typeof Ionicons>['name'];
|
type StepId =
|
||||||
type StepId = 'welcome' | 'library' | 'theme' | 'player' | 'done';
|
| 'welcome'
|
||||||
|
| 'library'
|
||||||
|
| 'notifications'
|
||||||
|
| 'artistImages'
|
||||||
|
| 'theme'
|
||||||
|
| 'player'
|
||||||
|
| 'done';
|
||||||
|
|
||||||
const STEP_ORDER: StepId[] = ['welcome', 'library', 'theme', 'player', 'done'];
|
// Folders first so the notification ask lands with a visible reason ("your scan
|
||||||
|
// is running"), then the Deezer consent. Each is its own page: they are three
|
||||||
|
// unrelated decisions and stacking them made the library step's real action —
|
||||||
|
// picking a folder — the third thing on screen.
|
||||||
|
const STEP_ORDER: StepId[] = [
|
||||||
|
'welcome',
|
||||||
|
'library',
|
||||||
|
'notifications',
|
||||||
|
'artistImages',
|
||||||
|
'theme',
|
||||||
|
'player',
|
||||||
|
'done',
|
||||||
|
];
|
||||||
|
|
||||||
const WIZARD_THEME_OPTIONS: { id: BaseThemeId; title: string }[] = [
|
const WIZARD_THEME_OPTIONS: { id: BaseThemeId; title: string }[] = [
|
||||||
{ id: 'system', title: 'System' },
|
{ id: 'system', title: 'System' },
|
||||||
@@ -63,6 +85,9 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
|
|||||||
const step = STEP_ORDER[stepIndex];
|
const step = STEP_ORDER[stepIndex];
|
||||||
const foldersCount = useLibraryStore((s) => s.folders.length);
|
const foldersCount = useLibraryStore((s) => s.folders.length);
|
||||||
const isScanning = useLibraryStore((s) => s.isScanning);
|
const isScanning = useLibraryStore((s) => s.isScanning);
|
||||||
|
// Owned here so the footer label can distinguish "Continue" from "Skip for
|
||||||
|
// now" using the same state the notification step renders.
|
||||||
|
const notificationPermission = useScanNotificationPermission();
|
||||||
// Deliberately unset until tapped: preselecting a card would bias the
|
// Deliberately unset until tapped: preselecting a card would bias the
|
||||||
// pre-release style feedback. Skipping through keeps the store default.
|
// pre-release style feedback. Skipping through keeps the store default.
|
||||||
const [scopeStyleChoice, setScopeStyleChoice] = useState<NowPlayingScopeStyle | null>(null);
|
const [scopeStyleChoice, setScopeStyleChoice] = useState<NowPlayingScopeStyle | null>(null);
|
||||||
@@ -77,7 +102,7 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
|
|||||||
};
|
};
|
||||||
const goBack = () => setStepIndex((i) => Math.max(0, i - 1));
|
const goBack = () => setStepIndex((i) => Math.max(0, i - 1));
|
||||||
|
|
||||||
const canGoBack = step === 'library' || step === 'theme' || step === 'player';
|
const canGoBack = stepIndex > 0 && step !== 'done';
|
||||||
const primaryLabel =
|
const primaryLabel =
|
||||||
step === 'welcome'
|
step === 'welcome'
|
||||||
? 'Get started'
|
? 'Get started'
|
||||||
@@ -88,9 +113,14 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
|
|||||||
foldersCount > 0 || isScanning
|
foldersCount > 0 || isScanning
|
||||||
? 'Continue'
|
? 'Continue'
|
||||||
: 'Skip for now'
|
: 'Skip for now'
|
||||||
: step === 'theme' || step === 'player'
|
: step === 'notifications'
|
||||||
? 'Continue'
|
? // The grant is optional, so moving on without it really is skipping.
|
||||||
: 'Start listening';
|
notificationPermission.granted
|
||||||
|
? 'Continue'
|
||||||
|
: 'Skip for now'
|
||||||
|
: step === 'artistImages' || step === 'theme' || step === 'player'
|
||||||
|
? 'Continue'
|
||||||
|
: 'Start listening';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
@@ -123,6 +153,10 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
|
|||||||
<Animated.View key={step} entering={FadeIn.duration(220)} style={styles.stepWrap}>
|
<Animated.View key={step} entering={FadeIn.duration(220)} style={styles.stepWrap}>
|
||||||
{step === 'welcome' ? <WelcomeStep /> : null}
|
{step === 'welcome' ? <WelcomeStep /> : null}
|
||||||
{step === 'library' ? <LibraryStep /> : null}
|
{step === 'library' ? <LibraryStep /> : null}
|
||||||
|
{step === 'notifications' ? (
|
||||||
|
<NotificationStep permission={notificationPermission} />
|
||||||
|
) : null}
|
||||||
|
{step === 'artistImages' ? <ArtistImageStep /> : null}
|
||||||
{step === 'theme' ? <ThemeStep /> : null}
|
{step === 'theme' ? <ThemeStep /> : null}
|
||||||
{step === 'player' ? (
|
{step === 'player' ? (
|
||||||
<PlayerStep choice={scopeStyleChoice} onChoose={chooseScopeStyle} />
|
<PlayerStep choice={scopeStyleChoice} onChoose={chooseScopeStyle} />
|
||||||
@@ -203,6 +237,7 @@ function LibraryStep() {
|
|||||||
title="Add your music"
|
title="Add your music"
|
||||||
subtitle="Point Astra at the folders where your music lives. It scans them into your library — files on disk are never modified."
|
subtitle="Point Astra at the folders where your music lives. It scans them into your library — files on disk are never modified."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Pressable android_ripple={ripple.bounded}
|
<Pressable android_ripple={ripple.bounded}
|
||||||
style={[styles.choiceButton, isScanning && styles.disabled]}
|
style={[styles.choiceButton, isScanning && styles.disabled]}
|
||||||
disabled={isScanning}
|
disabled={isScanning}
|
||||||
@@ -336,32 +371,6 @@ function DoneStep() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StepHeader({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
}: {
|
|
||||||
icon: IoniconName;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
}) {
|
|
||||||
const styles = useStyles();
|
|
||||||
const colors = useColors();
|
|
||||||
return (
|
|
||||||
<View style={styles.stepHeader}>
|
|
||||||
<View style={styles.stepIconWrap}>
|
|
||||||
<Ionicons name={icon} size={26} color={colors.accent} />
|
|
||||||
</View>
|
|
||||||
<Text variant="heading" style={styles.centeredTitle}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<Text variant="body" color={colors.textSecondary} style={styles.centeredSubtitle}>
|
|
||||||
{subtitle}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Subtle "still scanning" pill shown at the top of steps after the library step. */
|
/** Subtle "still scanning" pill shown at the top of steps after the library step. */
|
||||||
function ScanBanner() {
|
function ScanBanner() {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
@@ -496,22 +505,6 @@ const useStyles = createThemedStyles((colors) => ({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
gap: spacing.lg,
|
gap: spacing.lg,
|
||||||
},
|
},
|
||||||
stepHeader: {
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: spacing.sm,
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
stepIconWrap: {
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 28,
|
|
||||||
backgroundColor: colors.glassBg,
|
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderColor: colors.glassBorder,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
choiceButton: {
|
choiceButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
import { StyleSheet, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Text } from '@/components/Text';
|
||||||
|
import { spacing } from '@/theme';
|
||||||
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||||
|
|
||||||
|
type IoniconName = ComponentProps<typeof Ionicons>['name'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared masthead for every wizard page: medallion icon, title, and the one-line
|
||||||
|
* reason the page exists. Lives outside OnboardingFlow so the individual step
|
||||||
|
* files can use it without importing their own parent.
|
||||||
|
*/
|
||||||
|
export function StepHeader({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
icon: IoniconName;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) {
|
||||||
|
const styles = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
return (
|
||||||
|
<View style={styles.stepHeader}>
|
||||||
|
<View style={styles.stepIconWrap}>
|
||||||
|
<Ionicons name={icon} size={26} color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
<Text variant="heading" style={styles.title}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text variant="body" color={colors.textSecondary} style={styles.subtitle}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = createThemedStyles((colors) => ({
|
||||||
|
stepHeader: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
stepIconWrap: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: colors.glassBg,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: 340,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default StepHeader;
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { EQSlider } from '@/components/eq/EQSlider';
|
import { EQSlider } from '@/components/eq/EQSlider';
|
||||||
import { ScanProgress } from '@/components/library/ScanProgress';
|
import { ScanProgress } from '@/components/library/ScanProgress';
|
||||||
|
import { ScanNotificationPermissionCard } from '@/components/library/ScanNotificationPermissionCard';
|
||||||
|
import { ArtistImageSweepStatus } from '@/components/library/ArtistImageSweepStatus';
|
||||||
import { SegmentedControl } from '@/components/SegmentedControl';
|
import { SegmentedControl } from '@/components/SegmentedControl';
|
||||||
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
|
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
|
||||||
import { ScopeStyleCards } from '@/components/settings/ScopeStyleCards';
|
import { ScopeStyleCards } from '@/components/settings/ScopeStyleCards';
|
||||||
@@ -393,11 +395,51 @@ export function LibrarySettingsPanel() {
|
|||||||
const setIncludeSingles = useSettingsStore((s) => s.setIncludeSingles);
|
const setIncludeSingles = useSettingsStore((s) => s.setIncludeSingles);
|
||||||
const includeCollabArtists = useLibraryStore((s) => s.includeCollabArtists);
|
const includeCollabArtists = useLibraryStore((s) => s.includeCollabArtists);
|
||||||
const setIncludeCollabArtists = useLibraryStore((s) => s.setIncludeCollabArtists);
|
const setIncludeCollabArtists = useLibraryStore((s) => s.setIncludeCollabArtists);
|
||||||
|
const artistImageAutoPolicy = useSettingsStore((s) => s.artistImageAutoPolicy);
|
||||||
|
const setArtistImageAutoPolicy = useSettingsStore((s) => s.setArtistImageAutoPolicy);
|
||||||
|
const artistImagesEnabled = artistImageAutoPolicy !== 'off';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSectionLabel>LOCAL FOLDERS</SettingsSectionLabel>
|
<SettingsSectionLabel>LOCAL FOLDERS</SettingsSectionLabel>
|
||||||
<LibraryFoldersSettings />
|
<LibraryFoldersSettings />
|
||||||
|
{/* Renders nothing once the permission is granted — the style carries the
|
||||||
|
spacing so no empty gap is left behind. */}
|
||||||
|
<ScanNotificationPermissionCard style={styles.cardSpacing} />
|
||||||
|
|
||||||
|
<SettingsSectionLabel spaced>ARTIST IMAGES</SettingsSectionLabel>
|
||||||
|
<SettingsCard>
|
||||||
|
<SettingsToggleRow
|
||||||
|
title="Automatic artist images"
|
||||||
|
description="Send artist names to Deezer and cache selected images locally for offline use."
|
||||||
|
value={artistImagesEnabled}
|
||||||
|
onValueChange={(enabled) =>
|
||||||
|
void setArtistImageAutoPolicy(enabled ? 'wifi' : 'off')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{artistImagesEnabled ? (
|
||||||
|
<View style={styles.indent}>
|
||||||
|
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
|
||||||
|
Download network
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
segments={[
|
||||||
|
{ key: 'wifi', label: 'Wi-Fi / Ethernet' },
|
||||||
|
{ key: 'any', label: 'Any network' },
|
||||||
|
]}
|
||||||
|
value={artistImageAutoPolicy}
|
||||||
|
onChange={(value) =>
|
||||||
|
void setArtistImageAutoPolicy(value === 'any' ? 'any' : 'wifi')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text variant="caption" color={colors.textTertiary} style={styles.settingNote}>
|
||||||
|
Manual Deezer searches remain available from an artist’s menu.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<ArtistImageSweepStatus enabled={artistImagesEnabled} />
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
<SettingsSectionLabel spaced>LIBRARY VIEW</SettingsSectionLabel>
|
<SettingsSectionLabel spaced>LIBRARY VIEW</SettingsSectionLabel>
|
||||||
<Text variant="body" style={styles.settingTitle}>
|
<Text variant="body" style={styles.settingTitle}>
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ export function buildArtistList(tracks: readonly ArtistTrackLike[], mode: Artist
|
|||||||
track_count,
|
track_count,
|
||||||
primary_track_count,
|
primary_track_count,
|
||||||
artwork_hash,
|
artwork_hash,
|
||||||
|
artwork_source: artwork_hash ? 'track' as const : null,
|
||||||
album_count: albumKeys.size,
|
album_count: albumKeys.size,
|
||||||
artwork_hashes,
|
artwork_hashes,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import * as Network from 'expo-network';
|
||||||
|
import type { DeezerArtistCandidate } from '@/types/artistImages';
|
||||||
|
import {
|
||||||
|
AstraLibraryData,
|
||||||
|
AstraLibraryScanner,
|
||||||
|
type NativeArtistImageLookupTarget,
|
||||||
|
} from '../../modules/astra-library-scanner';
|
||||||
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
|
import { useArtistImageStore } from '@/stores/artistImageStore';
|
||||||
|
import { endServiceFor, reportServiceProgress } from '@/library/scanService';
|
||||||
|
import {
|
||||||
|
canAutomaticallyDownloadArtistImages,
|
||||||
|
artistImageRetryBackoff,
|
||||||
|
groupArtistImageTargetsByName,
|
||||||
|
} from './artistImagePolicy';
|
||||||
|
import {
|
||||||
|
DEEZER_ARTIST_IMAGES_ENABLED,
|
||||||
|
pickAutomaticDeezerCandidate,
|
||||||
|
searchDeezerArtists,
|
||||||
|
} from '@/services/artistImages/deezer';
|
||||||
|
import { cacheRemoteArtistImage } from '@/services/artistImages/cache';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
const CACHE_RETRY_MS = 30 * 60 * 1000;
|
||||||
|
// Deezer allows roughly 50 requests per 5 seconds and answers a breach with a
|
||||||
|
// 429 that parks the whole queue for six hours. Spacing requests keeps a
|
||||||
|
// full-library sweep — which is exactly what a rescan triggers — under that.
|
||||||
|
const REQUEST_SPACING_MS = 150;
|
||||||
|
// Below this, a sweep finishes in a few seconds and a notification would be
|
||||||
|
// pure noise — adding one album should not light up the shade. Larger sweeps
|
||||||
|
// take minutes and need the foreground service to survive backgrounding.
|
||||||
|
const NOTIFICATION_THRESHOLD = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native-backed so the sweep keeps pacing itself while Astra is backgrounded.
|
||||||
|
* `setTimeout` stops firing the moment the activity pauses (React Native drops
|
||||||
|
* the Choreographer callback that drives timers), which stalled the sweep at the
|
||||||
|
* first gap between artists even with the foreground service holding the process
|
||||||
|
* open. Falls back to a JS timer if the native build predates the method.
|
||||||
|
*/
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
if (typeof AstraLibraryScanner.backgroundDelay === 'function') {
|
||||||
|
return AstraLibraryScanner.backgroundDelay(ms);
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = (value: number) => value.toLocaleString();
|
||||||
|
|
||||||
|
function publishSweepProgress(announced: boolean): void {
|
||||||
|
if (!announced) return;
|
||||||
|
const { processed, total } = useArtistImageStore.getState();
|
||||||
|
reportServiceProgress('artistImages', {
|
||||||
|
title: 'Finding artist images',
|
||||||
|
text: total > 0 ? `${n(processed)} of ${n(total)} artists` : 'Looking up artists…',
|
||||||
|
subText: null,
|
||||||
|
current: processed,
|
||||||
|
total,
|
||||||
|
indeterminate: total <= 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let started = false;
|
||||||
|
let running = false;
|
||||||
|
let runAgain = false;
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let activeAutomaticLookup: AbortController | null = null;
|
||||||
|
|
||||||
|
async function networkAllowsAutomaticDownloads(): Promise<boolean> {
|
||||||
|
if (!DEEZER_ARTIST_IMAGES_ENABLED) return false;
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
|
if (!settings.loaded) return false;
|
||||||
|
const network = await Network.getNetworkStateAsync();
|
||||||
|
return canAutomaticallyDownloadArtistImages(
|
||||||
|
settings.artistImageAutoPolicy,
|
||||||
|
settings.artistImageDisclosureSeen,
|
||||||
|
network
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRetryTimer(delayMs: number): void {
|
||||||
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
|
retryTimer = setTimeout(() => {
|
||||||
|
retryTimer = null;
|
||||||
|
scheduleArtistImageLookups();
|
||||||
|
}, Math.min(delayMs, 24 * 60 * 60 * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistLookup(
|
||||||
|
targets: NativeArtistImageLookupTarget[],
|
||||||
|
values: Parameters<typeof AstraLibraryData.recordArtistImageLookup>[3]
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all(
|
||||||
|
targets.map((target) =>
|
||||||
|
AstraLibraryData.recordArtistImageLookup(
|
||||||
|
target.artistKey,
|
||||||
|
target.artistName,
|
||||||
|
target.groupingMode,
|
||||||
|
values
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processTargetGroup(
|
||||||
|
targets: NativeArtistImageLookupTarget[]
|
||||||
|
): Promise<'continue' | 'pause'> {
|
||||||
|
const attemptedAt = Date.now();
|
||||||
|
activeAutomaticLookup = new AbortController();
|
||||||
|
const result = await searchDeezerArtists(
|
||||||
|
targets[0].artistName,
|
||||||
|
activeAutomaticLookup.signal
|
||||||
|
);
|
||||||
|
activeAutomaticLookup = null;
|
||||||
|
if (result.status === 'transient_error') {
|
||||||
|
if (result.code === 'cancelled') return 'pause';
|
||||||
|
const retryAfterMs = artistImageRetryBackoff(
|
||||||
|
result.retryAfterMs,
|
||||||
|
targets.map((target) => target.retryCount ?? 0)
|
||||||
|
);
|
||||||
|
const nextRetryAt = attemptedAt + retryAfterMs;
|
||||||
|
await persistLookup(targets, {
|
||||||
|
status: 'transient_error',
|
||||||
|
attemptedAt,
|
||||||
|
nextRetryAt,
|
||||||
|
});
|
||||||
|
setRetryTimer(retryAfterMs);
|
||||||
|
return 'pause';
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = pickAutomaticDeezerCandidate(
|
||||||
|
targets[0].artistName,
|
||||||
|
result.candidates
|
||||||
|
);
|
||||||
|
if (!candidate) {
|
||||||
|
await persistLookup(targets, { status: 'not_found', attemptedAt });
|
||||||
|
return 'continue';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await networkAllowsAutomaticDownloads())) return 'pause';
|
||||||
|
const automaticImageHash = await cacheRemoteArtistImage(candidate.imageUrl);
|
||||||
|
if (!(await networkAllowsAutomaticDownloads())) return 'pause';
|
||||||
|
await persistLookup(targets, {
|
||||||
|
status: 'found',
|
||||||
|
attemptedAt,
|
||||||
|
automaticImageHash,
|
||||||
|
provider: 'deezer',
|
||||||
|
sourceId: candidate.id,
|
||||||
|
});
|
||||||
|
return 'continue';
|
||||||
|
} catch {
|
||||||
|
const retryAfterMs = artistImageRetryBackoff(
|
||||||
|
CACHE_RETRY_MS,
|
||||||
|
targets.map((target) => target.retryCount ?? 0)
|
||||||
|
);
|
||||||
|
await persistLookup(targets, {
|
||||||
|
status: 'transient_error',
|
||||||
|
attemptedAt,
|
||||||
|
nextRetryAt: attemptedAt + retryAfterMs,
|
||||||
|
});
|
||||||
|
setRetryTimer(retryAfterMs);
|
||||||
|
return 'pause';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drainArtistImageQueue(): Promise<void> {
|
||||||
|
if (running) {
|
||||||
|
runAgain = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
running = true;
|
||||||
|
let started = false;
|
||||||
|
let announced = false;
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
runAgain = false;
|
||||||
|
if (!(await networkAllowsAutomaticDownloads())) return;
|
||||||
|
const targets = await AstraLibraryData.getPendingArtistImageLookups(
|
||||||
|
PAGE_SIZE,
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
|
||||||
|
if (!started) {
|
||||||
|
started = true;
|
||||||
|
// Counted once for the whole sweep: re-counting per page would shrink
|
||||||
|
// the denominator as the queue drains and the bar would never advance.
|
||||||
|
const { pending } = await AstraLibraryData.getArtistImageStats(
|
||||||
|
useSettingsStore.getState().artistGroupingMode,
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
useArtistImageStore.getState().beginSweep(pending);
|
||||||
|
announced = pending >= NOTIFICATION_THRESHOLD;
|
||||||
|
publishSweepProgress(announced);
|
||||||
|
}
|
||||||
|
|
||||||
|
let first = true;
|
||||||
|
for (const group of groupArtistImageTargetsByName(targets)) {
|
||||||
|
if (!(await networkAllowsAutomaticDownloads())) return;
|
||||||
|
// Between groups only: never delays the first lookup after a change.
|
||||||
|
if (!first) await delay(REQUEST_SPACING_MS);
|
||||||
|
first = false;
|
||||||
|
if ((await processTargetGroup(group)) === 'pause') return;
|
||||||
|
// One group is one provider request, so this matches the denominator.
|
||||||
|
useArtistImageStore.getState().advanceSweep();
|
||||||
|
publishSweepProgress(announced);
|
||||||
|
}
|
||||||
|
runAgain = targets.length >= PAGE_SIZE;
|
||||||
|
} while (runAgain);
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
if (started) {
|
||||||
|
useArtistImageStore.getState().endSweep();
|
||||||
|
// No-op when the sweep stayed under the threshold and never claimed it.
|
||||||
|
endServiceFor('artistImages');
|
||||||
|
}
|
||||||
|
if (runAgain) scheduleArtistImageLookups();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleArtistImageLookups(): void {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
debounceTimer = null;
|
||||||
|
void drainArtistImageQueue();
|
||||||
|
}, 750);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startArtistImageLookupCoordinator(): void {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
AstraLibraryData.addListener('onCatalogChanged', scheduleArtistImageLookups);
|
||||||
|
Network.addNetworkStateListener((network) => {
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
|
if (
|
||||||
|
!canAutomaticallyDownloadArtistImages(
|
||||||
|
settings.artistImageAutoPolicy,
|
||||||
|
settings.artistImageDisclosureSeen,
|
||||||
|
network
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
activeAutomaticLookup?.abort();
|
||||||
|
}
|
||||||
|
scheduleArtistImageLookups();
|
||||||
|
});
|
||||||
|
useSettingsStore.subscribe((state, previous) => {
|
||||||
|
if (
|
||||||
|
state.artistImageAutoPolicy !== previous.artistImageAutoPolicy ||
|
||||||
|
state.artistImageDisclosureSeen !== previous.artistImageDisclosureSeen ||
|
||||||
|
state.loaded !== previous.loaded
|
||||||
|
) {
|
||||||
|
void networkAllowsAutomaticDownloads().then((allowed) => {
|
||||||
|
if (!allowed) activeAutomaticLookup?.abort();
|
||||||
|
});
|
||||||
|
scheduleArtistImageLookups();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scheduleArtistImageLookups();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchArtistImageCandidates(query: string) {
|
||||||
|
return searchDeezerArtists(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes artists a provider previously had no match for eligible again, then
|
||||||
|
* kicks the queue. Called when a scan finishes so "rescan" also re-checks the
|
||||||
|
* artists that came back empty — `not_found` is terminal in the pending query,
|
||||||
|
* so nothing else ever revisits them.
|
||||||
|
*/
|
||||||
|
export async function requeueMissingArtistImages(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const cleared = await AstraLibraryData.clearArtistImageLookupFailures();
|
||||||
|
if (cleared > 0) scheduleArtistImageLookups();
|
||||||
|
} catch (error) {
|
||||||
|
// A scan must never fail because the retry sweep could not be queued.
|
||||||
|
console.warn('[artistImages] could not re-queue missing artist images', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectDeezerArtistImage(
|
||||||
|
artistKey: string,
|
||||||
|
artistName: string,
|
||||||
|
groupingMode: 'astra' | 'fileTags',
|
||||||
|
candidate: DeezerArtistCandidate
|
||||||
|
): Promise<void> {
|
||||||
|
const automaticImageHash = await cacheRemoteArtistImage(candidate.imageUrl);
|
||||||
|
await AstraLibraryData.recordArtistImageLookup(
|
||||||
|
artistKey,
|
||||||
|
artistName,
|
||||||
|
groupingMode,
|
||||||
|
{
|
||||||
|
status: 'found',
|
||||||
|
automaticImageHash,
|
||||||
|
provider: 'deezer',
|
||||||
|
sourceId: candidate.id,
|
||||||
|
attemptedAt: Date.now(),
|
||||||
|
clearManual: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectLocalArtistImage(
|
||||||
|
artistKey: string,
|
||||||
|
artistName: string,
|
||||||
|
groupingMode: 'astra' | 'fileTags',
|
||||||
|
uri: string
|
||||||
|
): Promise<void> {
|
||||||
|
const artworkHash = await AstraLibraryScanner.cacheArtworkFromUri(uri);
|
||||||
|
await AstraLibraryData.setManualArtistImage(
|
||||||
|
artistKey,
|
||||||
|
artistName,
|
||||||
|
groupingMode,
|
||||||
|
artworkHash
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetLocalArtistImage(
|
||||||
|
artistKey: string,
|
||||||
|
artistName: string,
|
||||||
|
groupingMode: 'astra' | 'fileTags'
|
||||||
|
): Promise<void> {
|
||||||
|
await AstraLibraryData.clearManualArtistImage(artistKey, artistName, groupingMode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
canAutomaticallyDownloadArtistImages,
|
||||||
|
artistImageRetryBackoff,
|
||||||
|
groupArtistImageTargetsByName,
|
||||||
|
} from './artistImagePolicy.ts';
|
||||||
|
|
||||||
|
const network = (
|
||||||
|
type: 'WIFI' | 'ETHERNET' | 'CELLULAR' | 'NONE',
|
||||||
|
isConnected = true,
|
||||||
|
isInternetReachable: boolean | null = true
|
||||||
|
) => ({ type, isConnected, isInternetReachable }) as never;
|
||||||
|
|
||||||
|
test('automatic policy stays blocked before disclosure and while off', () => {
|
||||||
|
assert.equal(canAutomaticallyDownloadArtistImages('wifi', false, network('WIFI')), false);
|
||||||
|
assert.equal(canAutomaticallyDownloadArtistImages('off', true, network('WIFI')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Wi-Fi policy permits Wi-Fi and Ethernet but not cellular', () => {
|
||||||
|
assert.equal(canAutomaticallyDownloadArtistImages('wifi', true, network('WIFI')), true);
|
||||||
|
assert.equal(canAutomaticallyDownloadArtistImages('wifi', true, network('ETHERNET')), true);
|
||||||
|
assert.equal(canAutomaticallyDownloadArtistImages('wifi', true, network('CELLULAR')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('any-network policy still pauses offline or without reachable internet', () => {
|
||||||
|
assert.equal(canAutomaticallyDownloadArtistImages('any', true, network('CELLULAR')), true);
|
||||||
|
assert.equal(canAutomaticallyDownloadArtistImages('any', true, network('NONE', false)), false);
|
||||||
|
assert.equal(
|
||||||
|
canAutomaticallyDownloadArtistImages('any', true, network('WIFI', true, false)),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same normalized artist is deduplicated across grouping modes', () => {
|
||||||
|
const groups = groupArtistImageTargetsByName([
|
||||||
|
{ groupingMode: 'astra', artistKey: 'björk', artistName: 'Björk' },
|
||||||
|
{ groupingMode: 'fileTags', artistKey: 'bjork', artistName: 'BJORK' },
|
||||||
|
{ groupingMode: 'astra', artistKey: 'radiohead', artistName: 'Radiohead' },
|
||||||
|
]);
|
||||||
|
assert.equal(groups.length, 2);
|
||||||
|
assert.equal(groups[0].length, 2);
|
||||||
|
assert.equal(groups[1].length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transient retries back off exponentially and cap at one day', () => {
|
||||||
|
const halfHour = 30 * 60 * 1000;
|
||||||
|
assert.equal(artistImageRetryBackoff(halfHour, [0]), halfHour);
|
||||||
|
assert.equal(artistImageRetryBackoff(halfHour, [1]), halfHour * 2);
|
||||||
|
assert.equal(artistImageRetryBackoff(halfHour, [3, 2]), halfHour * 8);
|
||||||
|
assert.equal(artistImageRetryBackoff(6 * 60 * 60 * 1000, [4]), 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { NetworkState, NetworkStateType } from 'expo-network';
|
||||||
|
import type {
|
||||||
|
ArtistImageAutoPolicy,
|
||||||
|
ArtistImageLookupTarget,
|
||||||
|
} from '../types/artistImages.ts';
|
||||||
|
import { normalizeArtistImageMatchName } from '../services/artistImages/deezer.ts';
|
||||||
|
|
||||||
|
export function canAutomaticallyDownloadArtistImages(
|
||||||
|
policy: ArtistImageAutoPolicy,
|
||||||
|
disclosureSeen: boolean,
|
||||||
|
network: Pick<NetworkState, 'isConnected' | 'isInternetReachable' | 'type'>
|
||||||
|
): boolean {
|
||||||
|
if (!disclosureSeen || policy === 'off') return false;
|
||||||
|
if (!network.isConnected || network.isInternetReachable === false) return false;
|
||||||
|
if (policy === 'any') return true;
|
||||||
|
return (
|
||||||
|
network.type === ('WIFI' as NetworkStateType) ||
|
||||||
|
network.type === ('ETHERNET' as NetworkStateType)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupArtistImageTargetsByName<T extends ArtistImageLookupTarget>(
|
||||||
|
targets: T[]
|
||||||
|
): T[][] {
|
||||||
|
const groups = new Map<string, T[]>();
|
||||||
|
for (const target of targets) {
|
||||||
|
const key = normalizeArtistImageMatchName(target.artistName);
|
||||||
|
if (!key) continue;
|
||||||
|
const group = groups.get(key);
|
||||||
|
if (group) group.push(target);
|
||||||
|
else groups.set(key, [target]);
|
||||||
|
}
|
||||||
|
return [...groups.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function artistImageRetryBackoff(
|
||||||
|
baseMs: number,
|
||||||
|
retryCounts: number[],
|
||||||
|
maximumMs = 24 * 60 * 60 * 1000
|
||||||
|
): number {
|
||||||
|
const retryCount = Math.max(0, ...retryCounts);
|
||||||
|
return Math.min(baseMs * 2 ** Math.min(retryCount, 5), maximumMs);
|
||||||
|
}
|
||||||
@@ -111,9 +111,23 @@ export function useNativeArtistDetail(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => void reset());
|
queueMicrotask(() => void reset());
|
||||||
const subscription = AstraLibraryData.addListener('onCatalogChanged', () => void reset());
|
const catalogSubscription = AstraLibraryData.addListener(
|
||||||
return () => subscription.remove();
|
'onCatalogChanged',
|
||||||
}, [reset]);
|
() => void reset()
|
||||||
|
);
|
||||||
|
const imageSubscription = AstraLibraryData.addListener(
|
||||||
|
'onArtistImagesChanged',
|
||||||
|
(event) => {
|
||||||
|
if (event.artistKey === artistKey && event.groupingMode === groupingMode) {
|
||||||
|
void reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
catalogSubscription.remove();
|
||||||
|
imageSubscription.remove();
|
||||||
|
};
|
||||||
|
}, [artistKey, groupingMode, reset]);
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (!cursor || loading) return;
|
if (!cursor || loading) return;
|
||||||
|
|||||||
+94
-29
@@ -4,37 +4,67 @@
|
|||||||
// This starts the FGS on the first progress tick and tears it down when the scan ends.
|
// This starts the FGS on the first progress tick and tears it down when the scan ends.
|
||||||
// All no-ops on non-Android and on native binaries built before the FGS methods existed.
|
// All no-ops on non-Android and on native binaries built before the FGS methods existed.
|
||||||
|
|
||||||
import { PermissionsAndroid, Platform } from 'react-native';
|
import { Linking, PermissionsAndroid, Platform } from 'react-native';
|
||||||
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
|
import { AstraLibraryData, AstraLibraryScanner } from '../../modules/astra-library-scanner';
|
||||||
import type { ScanProgress } from './scanner';
|
import type { ScanProgress } from './scanner';
|
||||||
|
|
||||||
const supported =
|
const supported =
|
||||||
Platform.OS === 'android' &&
|
Platform.OS === 'android' &&
|
||||||
typeof (AstraLibraryScanner as { startScanService?: unknown }).startScanService === 'function';
|
typeof (AstraLibraryScanner as { startScanService?: unknown }).startScanService === 'function';
|
||||||
|
|
||||||
// runScan guarantees scans never overlap, so single-scan module state is safe.
|
|
||||||
let active = false;
|
|
||||||
let notifPermRequested = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST_NOTIFICATIONS is Android 13+ (API 33); PermissionsAndroid resolves it granted
|
* Who currently needs the foreground service. A scan is no longer the only
|
||||||
* automatically below that. Requested contextually on the first scan. The FGS +
|
* producer — the artist-image sweep runs on the same JS thread and starts right
|
||||||
* wakelock still keep the scan alive without it — only the visible notification needs it.
|
* after a scan finishes — so ownership is ref-counted: the service starts when
|
||||||
|
* the set becomes non-empty and stops only when the last owner releases it.
|
||||||
|
* A plain boolean here would let either side tear down the other's keepalive.
|
||||||
*/
|
*/
|
||||||
async function ensureNotificationPermission(): Promise<void> {
|
type ServiceOwner = 'scan' | 'artistImages';
|
||||||
if (notifPermRequested) return;
|
const owners = new Set<ServiceOwner>();
|
||||||
notifPermRequested = true;
|
const latest = new Map<ServiceOwner, ScanNotification>();
|
||||||
|
|
||||||
|
const NOTIFICATION_PERMISSION_REQUESTED_KEY = 'scan_notification_permission_requested';
|
||||||
|
|
||||||
|
export type ScanNotificationPermissionState =
|
||||||
|
| 'not_required'
|
||||||
|
| 'prompt'
|
||||||
|
| 'granted'
|
||||||
|
| 'denied';
|
||||||
|
|
||||||
|
function notificationPermission(): Parameters<typeof PermissionsAndroid.check>[0] | null {
|
||||||
|
if (Platform.OS !== 'android' || Number(Platform.Version) < 33) return null;
|
||||||
const permission = (PermissionsAndroid.PERMISSIONS as Record<string, string | undefined>)
|
const permission = (PermissionsAndroid.PERMISSIONS as Record<string, string | undefined>)
|
||||||
.POST_NOTIFICATIONS;
|
.POST_NOTIFICATIONS;
|
||||||
if (!permission) return;
|
return permission
|
||||||
|
? (permission as Parameters<typeof PermissionsAndroid.check>[0])
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getScanNotificationPermissionState(): Promise<ScanNotificationPermissionState> {
|
||||||
|
const permission = notificationPermission();
|
||||||
|
if (!permission) return 'not_required';
|
||||||
|
if (await PermissionsAndroid.check(permission)) return 'granted';
|
||||||
|
const values = await AstraLibraryData.getSettings([NOTIFICATION_PERMISSION_REQUESTED_KEY]);
|
||||||
|
return values[NOTIFICATION_PERMISSION_REQUESTED_KEY] === '1' ? 'denied' : 'prompt';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestScanNotificationPermission(): Promise<ScanNotificationPermissionState> {
|
||||||
|
const permission = notificationPermission();
|
||||||
|
if (!permission) return 'not_required';
|
||||||
|
await AstraLibraryData.setSettings({ [NOTIFICATION_PERMISSION_REQUESTED_KEY]: '1' });
|
||||||
try {
|
try {
|
||||||
await PermissionsAndroid.request(permission as Parameters<typeof PermissionsAndroid.request>[0]);
|
const result = await PermissionsAndroid.request(permission);
|
||||||
|
return result === PermissionsAndroid.RESULTS.GRANTED ? 'granted' : 'denied';
|
||||||
} catch {
|
} catch {
|
||||||
// Denied/unavailable — the scan still runs, the notification just won't show.
|
return 'denied';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScanNotification {
|
export async function openScanNotificationSettings(): Promise<void> {
|
||||||
|
await Linking.openSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanNotification {
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
subText: string | null;
|
subText: string | null;
|
||||||
@@ -77,22 +107,57 @@ function notificationFor(progress: ScanProgress): ScanNotification {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Report a scan progress tick — starts the FGS on the first call, updates it after. */
|
/**
|
||||||
export async function reportScanProgress(progress: ScanProgress): Promise<void> {
|
* Publish a progress tick for one owner — starts the service on the first call,
|
||||||
|
* updates the notification after. A scan outranks the sweep when both are live:
|
||||||
|
* it is the operation the user just asked for, and it finishes sooner.
|
||||||
|
*/
|
||||||
|
export function reportServiceProgress(
|
||||||
|
owner: ServiceOwner,
|
||||||
|
notification: ScanNotification
|
||||||
|
): void {
|
||||||
if (!supported) return;
|
if (!supported) return;
|
||||||
const { title, text, subText, current, total, indeterminate } = notificationFor(progress);
|
const starting = owners.size === 0;
|
||||||
if (!active) {
|
owners.add(owner);
|
||||||
active = true;
|
latest.set(owner, notification);
|
||||||
await ensureNotificationPermission();
|
if (starting) {
|
||||||
if (!active) return; // scan ended while we awaited the permission dialog
|
AstraLibraryScanner.startScanService(notification.title, notification.text);
|
||||||
AstraLibraryScanner.startScanService(title, text);
|
|
||||||
}
|
}
|
||||||
AstraLibraryScanner.updateScanNotification(title, text, subText, current, total, indeterminate);
|
publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tear down the scan foreground service when a scan finishes (or errors). */
|
/** Release one owner's claim; the service stops once nobody holds it. */
|
||||||
export function endScanService(): void {
|
export function endServiceFor(owner: ServiceOwner): void {
|
||||||
if (!supported || !active) return;
|
if (!supported || !owners.has(owner)) return;
|
||||||
active = false;
|
owners.delete(owner);
|
||||||
|
latest.delete(owner);
|
||||||
|
if (owners.size > 0) {
|
||||||
|
publish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
AstraLibraryScanner.stopScanService();
|
AstraLibraryScanner.stopScanService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function publish(): void {
|
||||||
|
const owner: ServiceOwner = owners.has('scan') ? 'scan' : 'artistImages';
|
||||||
|
const next = latest.get(owner);
|
||||||
|
if (!next) return;
|
||||||
|
AstraLibraryScanner.updateScanNotification(
|
||||||
|
next.title,
|
||||||
|
next.text,
|
||||||
|
next.subText,
|
||||||
|
next.current,
|
||||||
|
next.total,
|
||||||
|
next.indeterminate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Report a scan progress tick — starts the FGS on the first call, updates it after. */
|
||||||
|
export async function reportScanProgress(progress: ScanProgress): Promise<void> {
|
||||||
|
reportServiceProgress('scan', notificationFor(progress));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear down the scan's claim on the foreground service when a scan finishes (or errors). */
|
||||||
|
export function endScanService(): void {
|
||||||
|
endServiceFor('scan');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useAppForeground } from '@/lib/useAppForeground';
|
||||||
|
import {
|
||||||
|
getScanNotificationPermissionState,
|
||||||
|
openScanNotificationSettings,
|
||||||
|
requestScanNotificationPermission,
|
||||||
|
type ScanNotificationPermissionState,
|
||||||
|
} from '@/library/scanService';
|
||||||
|
|
||||||
|
export interface ScanNotificationPermission {
|
||||||
|
/** null until the first native check resolves. */
|
||||||
|
state: ScanNotificationPermissionState | null;
|
||||||
|
/** True once the permission is held, or when the OS never required it. */
|
||||||
|
granted: boolean;
|
||||||
|
/** Already refused once — `resolve` opens Android settings instead of re-asking. */
|
||||||
|
denied: boolean;
|
||||||
|
working: boolean;
|
||||||
|
/** Requests the permission, or opens Android settings once it has been denied. */
|
||||||
|
resolve: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared POST_NOTIFICATIONS state for the scan progress notification, used by
|
||||||
|
* both the settings row and the onboarding step. Re-checks on every return to
|
||||||
|
* the foreground because the grant can be flipped in Android settings while
|
||||||
|
* Astra is backgrounded.
|
||||||
|
*/
|
||||||
|
export function useScanNotificationPermission(): ScanNotificationPermission {
|
||||||
|
const [state, setState] = useState<ScanNotificationPermissionState | null>(null);
|
||||||
|
const [working, setWorking] = useState(false);
|
||||||
|
const foreground = useAppForeground();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!foreground) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void getScanNotificationPermissionState().then((next) => {
|
||||||
|
if (!cancelled) setState(next);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [foreground]);
|
||||||
|
|
||||||
|
const denied = state === 'denied';
|
||||||
|
const resolve = useCallback(() => {
|
||||||
|
if (working) return;
|
||||||
|
if (denied) {
|
||||||
|
void openScanNotificationSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWorking(true);
|
||||||
|
void requestScanNotificationPermission()
|
||||||
|
.then(setState)
|
||||||
|
.finally(() => setWorking(false));
|
||||||
|
}, [denied, working]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
granted: state === 'granted' || state === 'not_required',
|
||||||
|
denied,
|
||||||
|
working,
|
||||||
|
resolve,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
cacheDirectory,
|
||||||
|
deleteAsync,
|
||||||
|
downloadAsync,
|
||||||
|
} from 'expo-file-system/legacy';
|
||||||
|
import { AstraLibraryScanner } from '../../../modules/astra-library-scanner';
|
||||||
|
|
||||||
|
export async function cacheRemoteArtistImage(imageUrl: string): Promise<string> {
|
||||||
|
if (!cacheDirectory) throw new Error('The image cache is unavailable.');
|
||||||
|
const temporaryUri =
|
||||||
|
`${cacheDirectory}artist-image-${Date.now()}-` +
|
||||||
|
`${Math.random().toString(36).slice(2)}.download`;
|
||||||
|
try {
|
||||||
|
const result = await downloadAsync(imageUrl, temporaryUri);
|
||||||
|
if (result.status < 200 || result.status >= 300) {
|
||||||
|
throw new Error(`Image download failed (${result.status}).`);
|
||||||
|
}
|
||||||
|
return await AstraLibraryScanner.cacheArtworkFromUri(result.uri);
|
||||||
|
} finally {
|
||||||
|
await deleteAsync(temporaryUri, { idempotent: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
normalizeArtistImageMatchName,
|
||||||
|
parseDeezerArtistPayload,
|
||||||
|
pickAutomaticDeezerCandidate,
|
||||||
|
searchDeezerArtists,
|
||||||
|
} from './deezer.ts';
|
||||||
|
import type { DeezerArtistCandidate } from '../../types/artistImages.ts';
|
||||||
|
|
||||||
|
function candidate(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
fanCount: number
|
||||||
|
): DeezerArtistCandidate {
|
||||||
|
return {
|
||||||
|
provider: 'deezer',
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
fanCount,
|
||||||
|
albumCount: 1,
|
||||||
|
imageUrl: `https://example.test/${id}.jpg`,
|
||||||
|
linkUrl: `https://www.deezer.com/artist/${id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('normalizes Unicode, punctuation, case, and whitespace for exact matching', () => {
|
||||||
|
assert.equal(normalizeArtistImageMatchName(' Sigur Rós! '), 'sigur ros');
|
||||||
|
assert.equal(normalizeArtistImageMatchName('BOA'), 'boa');
|
||||||
|
assert.equal(normalizeArtistImageMatchName('AC/DC'), 'ac dc');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every real Deezer CDN URL ends in the "-000000-80-0-0" transform suffix, so
|
||||||
|
// these fixtures must keep it — inventing shorter URLs hides placeholder-filter
|
||||||
|
// bugs that would drop 100% of live results.
|
||||||
|
const REAL_IMAGE_URL =
|
||||||
|
'https://cdn-images.dzcdn.net/images/artist/96b688020014a21cb80a0268b90287f5/1000x1000-000000-80-0-0.jpg';
|
||||||
|
const EMPTY_HASH_IMAGE_URL =
|
||||||
|
'https://cdn-images.dzcdn.net/images/artist//1000x1000-000000-80-0-0.jpg';
|
||||||
|
const ZERO_BYTE_HASH_IMAGE_URL =
|
||||||
|
'https://cdn-images.dzcdn.net/images/artist/d41d8cd98f00b204e9800998ecf8427e/1000x1000-000000-80-0-0.jpg';
|
||||||
|
|
||||||
|
test('accepts valid Deezer artists and drops corrupt or placeholder images', () => {
|
||||||
|
const parsed = parseDeezerArtistPayload({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: 'Artist',
|
||||||
|
picture_xl: REAL_IMAGE_URL,
|
||||||
|
link: 'https://www.deezer.com/artist/7',
|
||||||
|
nb_fan: 120,
|
||||||
|
nb_album: 4,
|
||||||
|
},
|
||||||
|
{ id: 8, name: 'Empty Hash', picture_xl: EMPTY_HASH_IMAGE_URL },
|
||||||
|
{ id: 11, name: 'Zero Byte Hash', picture_xl: ZERO_BYTE_HASH_IMAGE_URL },
|
||||||
|
{ id: 9, name: '', picture_xl: 'https://cdn.test/9.jpg' },
|
||||||
|
{ id: 10, name: 'Unsafe', picture_xl: 'http://cdn.test/10.jpg' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.deepEqual(parsed, [
|
||||||
|
{
|
||||||
|
provider: 'deezer',
|
||||||
|
id: '7',
|
||||||
|
name: 'Artist',
|
||||||
|
imageUrl: REAL_IMAGE_URL,
|
||||||
|
linkUrl: 'https://www.deezer.com/artist/7',
|
||||||
|
fanCount: 120,
|
||||||
|
albumCount: 4,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(parseDeezerArtistPayload({ nope: [] }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('automatic selection only accepts normalized exact names', () => {
|
||||||
|
const candidates = [
|
||||||
|
candidate('1', 'The National Tribute', 9_000),
|
||||||
|
candidate('2', 'The National', 200),
|
||||||
|
];
|
||||||
|
assert.equal(pickAutomaticDeezerCandidate('The National', candidates)?.id, '2');
|
||||||
|
assert.equal(pickAutomaticDeezerCandidate('National', candidates), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same-name matches rank by fans, then stable numeric id', () => {
|
||||||
|
const candidates = [
|
||||||
|
candidate('40', 'Björk', 50),
|
||||||
|
candidate('7', 'Bjork', 100),
|
||||||
|
candidate('3', 'BJÖRK', 100),
|
||||||
|
];
|
||||||
|
assert.equal(pickAutomaticDeezerCandidate('Björk', candidates)?.id, '3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty results are terminal while rate limits remain retryable', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
try {
|
||||||
|
globalThis.fetch = async () =>
|
||||||
|
new Response(JSON.stringify({ data: [] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
assert.deepEqual(await searchDeezerArtists('Nobody'), {
|
||||||
|
status: 'success',
|
||||||
|
candidates: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = async () => new Response('', { status: 429 });
|
||||||
|
const limited = await searchDeezerArtists('Somebody');
|
||||||
|
assert.equal(limited.status, 'transient_error');
|
||||||
|
if (limited.status === 'transient_error') {
|
||||||
|
assert.equal(limited.code, 'rate_limited');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an automatic request can be cancelled without becoming a failed lookup', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
try {
|
||||||
|
globalThis.fetch = (_input, init) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener('abort', () => {
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const controller = new AbortController();
|
||||||
|
const request = searchDeezerArtists('Pause Me', controller.signal);
|
||||||
|
controller.abort();
|
||||||
|
const result = await request;
|
||||||
|
assert.equal(result.status, 'transient_error');
|
||||||
|
if (result.status === 'transient_error') assert.equal(result.code, 'cancelled');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type {
|
||||||
|
DeezerArtistCandidate,
|
||||||
|
DeezerSearchResult,
|
||||||
|
} from '@/types/artistImages';
|
||||||
|
|
||||||
|
const DEEZER_ARTIST_SEARCH_URL = 'https://api.deezer.com/search/artist';
|
||||||
|
const DEFAULT_RETRY_MS = 30 * 60 * 1000;
|
||||||
|
const RATE_LIMIT_RETRY_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const REQUEST_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
|
export const DEEZER_ARTIST_IMAGES_ENABLED =
|
||||||
|
process.env.EXPO_PUBLIC_DEEZER_ARTIST_IMAGES_ENABLED !== 'false';
|
||||||
|
|
||||||
|
export function normalizeArtistImageMatchName(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/\p{M}+/gu, '')
|
||||||
|
.toLocaleLowerCase('en-US')
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteCount(value: unknown): number {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||||
|
? Math.floor(value)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeHttpsUrl(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
return parsed.protocol === 'https:' ? parsed.toString() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deezer serves a grey silhouette when an artist has no photo: the hash path
|
||||||
|
// segment is empty, all zeroes, or the md5 of zero bytes. Do NOT match on the
|
||||||
|
// trailing "-000000-80-0-0" transform suffix — every real CDN URL carries it.
|
||||||
|
const DEEZER_PLACEHOLDER_IMAGE =
|
||||||
|
/\/artist\/(?:\/|0+\/|d41d8cd98f00b204e9800998ecf8427e\/)/i;
|
||||||
|
|
||||||
|
function isPlaceholderImage(url: string): boolean {
|
||||||
|
return DEEZER_PLACEHOLDER_IMAGE.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDeezerArtistPayload(payload: unknown): DeezerArtistCandidate[] | null {
|
||||||
|
if (typeof payload !== 'object' || payload === null) return null;
|
||||||
|
const data = (payload as { data?: unknown }).data;
|
||||||
|
if (!Array.isArray(data)) return null;
|
||||||
|
|
||||||
|
const candidates: DeezerArtistCandidate[] = [];
|
||||||
|
for (const raw of data) {
|
||||||
|
if (typeof raw !== 'object' || raw === null) continue;
|
||||||
|
const item = raw as Record<string, unknown>;
|
||||||
|
const id =
|
||||||
|
typeof item.id === 'number' || typeof item.id === 'string'
|
||||||
|
? String(item.id).trim()
|
||||||
|
: '';
|
||||||
|
const name = typeof item.name === 'string' ? item.name.trim() : '';
|
||||||
|
const imageUrl =
|
||||||
|
safeHttpsUrl(item.picture_xl) ??
|
||||||
|
safeHttpsUrl(item.picture_big) ??
|
||||||
|
safeHttpsUrl(item.picture_medium);
|
||||||
|
if (!id || !name || !imageUrl || isPlaceholderImage(imageUrl)) continue;
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
provider: 'deezer',
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
imageUrl,
|
||||||
|
linkUrl: safeHttpsUrl(item.link),
|
||||||
|
fanCount: finiteCount(item.nb_fan),
|
||||||
|
albumCount: finiteCount(item.nb_album),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickAutomaticDeezerCandidate(
|
||||||
|
artistName: string,
|
||||||
|
candidates: DeezerArtistCandidate[]
|
||||||
|
): DeezerArtistCandidate | null {
|
||||||
|
const target = normalizeArtistImageMatchName(artistName);
|
||||||
|
if (!target) return null;
|
||||||
|
return (
|
||||||
|
candidates
|
||||||
|
.filter((candidate) => normalizeArtistImageMatchName(candidate.name) === target)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (right.fanCount !== left.fanCount) return right.fanCount - left.fanCount;
|
||||||
|
const leftNumeric = Number(left.id);
|
||||||
|
const rightNumeric = Number(right.id);
|
||||||
|
if (Number.isFinite(leftNumeric) && Number.isFinite(rightNumeric)) {
|
||||||
|
return leftNumeric - rightNumeric;
|
||||||
|
}
|
||||||
|
return left.id.localeCompare(right.id);
|
||||||
|
})[0] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchDeezerArtists(
|
||||||
|
query: string,
|
||||||
|
externalSignal?: AbortSignal
|
||||||
|
): Promise<DeezerSearchResult> {
|
||||||
|
if (!DEEZER_ARTIST_IMAGES_ENABLED) {
|
||||||
|
return {
|
||||||
|
status: 'transient_error',
|
||||||
|
code: 'provider',
|
||||||
|
message: 'Deezer artist images are disabled in this build.',
|
||||||
|
retryAfterMs: RATE_LIMIT_RETRY_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const normalizedQuery = query.trim();
|
||||||
|
if (!normalizedQuery) return { status: 'success', candidates: [] };
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timedOut = false;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
controller.abort();
|
||||||
|
}, REQUEST_TIMEOUT_MS);
|
||||||
|
const cancel = () => controller.abort();
|
||||||
|
externalSignal?.addEventListener('abort', cancel, { once: true });
|
||||||
|
if (externalSignal?.aborted) controller.abort();
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${DEEZER_ARTIST_SEARCH_URL}?q=${encodeURIComponent(normalizedQuery)}&limit=20`,
|
||||||
|
{
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: controller.signal,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.status === 429) {
|
||||||
|
return {
|
||||||
|
status: 'transient_error',
|
||||||
|
code: 'rate_limited',
|
||||||
|
message: 'Deezer is temporarily rate limiting searches. Try again later.',
|
||||||
|
retryAfterMs: RATE_LIMIT_RETRY_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
status: 'transient_error',
|
||||||
|
code: 'provider',
|
||||||
|
message: 'Deezer is temporarily unavailable.',
|
||||||
|
retryAfterMs: DEFAULT_RETRY_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = parseDeezerArtistPayload(await response.json());
|
||||||
|
if (candidates === null) {
|
||||||
|
return {
|
||||||
|
status: 'transient_error',
|
||||||
|
code: 'invalid_response',
|
||||||
|
message: 'Deezer returned an unexpected response.',
|
||||||
|
retryAfterMs: DEFAULT_RETRY_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: 'success', candidates };
|
||||||
|
} catch (error) {
|
||||||
|
const cancelled =
|
||||||
|
error instanceof Error &&
|
||||||
|
error.name === 'AbortError' &&
|
||||||
|
externalSignal?.aborted === true &&
|
||||||
|
!timedOut;
|
||||||
|
return {
|
||||||
|
status: 'transient_error',
|
||||||
|
code: cancelled ? 'cancelled' : timedOut ? 'timeout' : 'offline',
|
||||||
|
message: cancelled
|
||||||
|
? 'The Deezer search was paused.'
|
||||||
|
: timedOut
|
||||||
|
? 'The Deezer search timed out. Try again.'
|
||||||
|
: 'Connect to the internet and try the Deezer search again.',
|
||||||
|
retryAfterMs: cancelled ? 0 : DEFAULT_RETRY_MS,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
externalSignal?.removeEventListener('abort', cancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { AstraLibraryData } from '../../modules/astra-library-scanner';
|
||||||
|
import { useSettingsStore } from './settingsStore';
|
||||||
|
|
||||||
|
interface ArtistImageState {
|
||||||
|
/** True while a sweep is draining the queue. */
|
||||||
|
running: boolean;
|
||||||
|
/** Artists resolved so far in the current sweep — one per provider request. */
|
||||||
|
processed: number;
|
||||||
|
/** Artists queued when the sweep started. 0 means "not counted yet". */
|
||||||
|
total: number;
|
||||||
|
/** Artists with no portrait from any source, refreshed when a sweep settles. */
|
||||||
|
missing: number;
|
||||||
|
beginSweep: (total: number) => void;
|
||||||
|
advanceSweep: (by?: number) => void;
|
||||||
|
endSweep: () => void;
|
||||||
|
refreshMissing: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable progress for the artist-image sweep. The coordinator itself is a
|
||||||
|
* plain module (it runs without any React tree mounted), so it pushes into this
|
||||||
|
* store rather than owning the state — Settings just subscribes.
|
||||||
|
*/
|
||||||
|
export const useArtistImageStore = create<ArtistImageState>((set, get) => ({
|
||||||
|
running: false,
|
||||||
|
processed: 0,
|
||||||
|
total: 0,
|
||||||
|
missing: 0,
|
||||||
|
|
||||||
|
beginSweep: (total) => set({ running: true, processed: 0, total }),
|
||||||
|
|
||||||
|
advanceSweep: (by = 1) => set((state) => ({ processed: state.processed + by })),
|
||||||
|
|
||||||
|
endSweep: () => {
|
||||||
|
set({ running: false, processed: 0, total: 0 });
|
||||||
|
void get().refreshMissing();
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshMissing: async () => {
|
||||||
|
try {
|
||||||
|
const stats = await AstraLibraryData.getArtistImageStats(
|
||||||
|
useSettingsStore.getState().artistGroupingMode,
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
set({ missing: stats.missing });
|
||||||
|
} catch {
|
||||||
|
// A stale count is not worth surfacing an error for.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type ScanResult,
|
type ScanResult,
|
||||||
} from '@/library/scanner';
|
} from '@/library/scanner';
|
||||||
import { endScanService, reportScanProgress } from '@/library/scanService';
|
import { endScanService, reportScanProgress } from '@/library/scanService';
|
||||||
|
import { requeueMissingArtistImages } from '@/library/artistImageLookup';
|
||||||
import { ALBUM_SORT_LABELS, type AlbumSort } from '@/lib/albumSort';
|
import { ALBUM_SORT_LABELS, type AlbumSort } from '@/lib/albumSort';
|
||||||
import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort';
|
import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort';
|
||||||
import { TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort';
|
import { TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort';
|
||||||
@@ -191,6 +192,7 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
|||||||
artists: 0,
|
artists: 0,
|
||||||
};
|
};
|
||||||
let anchorGeneration = 0;
|
let anchorGeneration = 0;
|
||||||
|
let artistImageRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
// Re-entrancy guards, per list and per direction, so a backward refill, a forward
|
// Re-entrancy guards, per list and per direction, so a backward refill, a forward
|
||||||
// page and a different view's load never block one another — the single shared flag
|
// page and a different view's load never block one another — the single shared flag
|
||||||
@@ -391,6 +393,10 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
|||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await get().refresh();
|
await get().refresh();
|
||||||
|
// A scan is the user asking Astra to look at their library again, so it
|
||||||
|
// also re-opens artist-image lookups that previously found no match.
|
||||||
|
// New artists queue on their own; these would never retry otherwise.
|
||||||
|
await requeueMissingArtistImages();
|
||||||
} finally {
|
} finally {
|
||||||
if (activeScanCancellation === cancellation) activeScanCancellation = null;
|
if (activeScanCancellation === cancellation) activeScanCancellation = null;
|
||||||
set({
|
set({
|
||||||
@@ -490,6 +496,13 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
|||||||
set({ sectionAnchors: [] });
|
set({ sectionAnchors: [] });
|
||||||
void get().refresh();
|
void get().refresh();
|
||||||
});
|
});
|
||||||
|
AstraLibraryData.addListener('onArtistImagesChanged', () => {
|
||||||
|
if (artistImageRefreshTimer) clearTimeout(artistImageRefreshTimer);
|
||||||
|
artistImageRefreshTimer = setTimeout(() => {
|
||||||
|
artistImageRefreshTimer = null;
|
||||||
|
void get().refresh();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
useSettingsStore.subscribe((next, previous) => {
|
useSettingsStore.subscribe((next, previous) => {
|
||||||
if (next.artistGroupingMode !== previous.artistGroupingMode) {
|
if (next.artistGroupingMode !== previous.artistGroupingMode) {
|
||||||
anchorGeneration += 1;
|
anchorGeneration += 1;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
resumeListeningHistoryTracking,
|
resumeListeningHistoryTracking,
|
||||||
} from '@/audio/listeningHistoryTracker';
|
} from '@/audio/listeningHistoryTracker';
|
||||||
import { notifyListeningHistoryChanged } from '@/listeningStats/events';
|
import { notifyListeningHistoryChanged } from '@/listeningStats/events';
|
||||||
|
import type { ArtistImageAutoPolicy } from '@/types/artistImages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persisted app preferences. SQLite (settings table) is the source of truth — this
|
* Persisted app preferences. SQLite (settings table) is the source of truth — this
|
||||||
@@ -29,6 +30,8 @@ const LYRICS_VISIBLE_KEY = 'lyrics_visible';
|
|||||||
const NOW_PLAYING_COMPANION_KEY = 'now_playing_companion';
|
const NOW_PLAYING_COMPANION_KEY = 'now_playing_companion';
|
||||||
const HOME_GREETING_TEXT_MODE_KEY = 'home_greeting_text_mode';
|
const HOME_GREETING_TEXT_MODE_KEY = 'home_greeting_text_mode';
|
||||||
const LISTENING_HISTORY_ENABLED_KEY = 'listening_history_enabled';
|
const LISTENING_HISTORY_ENABLED_KEY = 'listening_history_enabled';
|
||||||
|
const ARTIST_IMAGE_AUTO_POLICY_KEY = 'artist_image_auto_policy';
|
||||||
|
const ARTIST_IMAGE_DISCLOSURE_KEY = 'artist_image_disclosure_seen';
|
||||||
|
|
||||||
/** Which visualizer the now-playing scope stage shows. */
|
/** Which visualizer the now-playing scope stage shows. */
|
||||||
export type ScopeMode = 'spectrum' | 'scope';
|
export type ScopeMode = 'spectrum' | 'scope';
|
||||||
@@ -56,6 +59,10 @@ function parseBoolean(value: string | null): boolean {
|
|||||||
return value === 'true';
|
return value === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseArtistImageAutoPolicy(value: string | null): ArtistImageAutoPolicy {
|
||||||
|
return value === 'off' || value === 'any' ? value : 'wifi';
|
||||||
|
}
|
||||||
|
|
||||||
interface SettingsStore {
|
interface SettingsStore {
|
||||||
artistGroupingMode: ArtistGroupingMode;
|
artistGroupingMode: ArtistGroupingMode;
|
||||||
/** Show 1-track albums in the Albums view (desktop parity default: hidden). */
|
/** Show 1-track albums in the Albums view (desktop parity default: hidden). */
|
||||||
@@ -68,6 +75,8 @@ interface SettingsStore {
|
|||||||
nowPlayingCompanion: NowPlayingCompanion;
|
nowPlayingCompanion: NowPlayingCompanion;
|
||||||
homeGreetingTextMode: HomeGreetingTextMode;
|
homeGreetingTextMode: HomeGreetingTextMode;
|
||||||
listeningHistoryEnabled: boolean;
|
listeningHistoryEnabled: boolean;
|
||||||
|
artistImageAutoPolicy: ArtistImageAutoPolicy;
|
||||||
|
artistImageDisclosureSeen: boolean;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
load: () => Promise<void>;
|
load: () => Promise<void>;
|
||||||
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
|
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
|
||||||
@@ -79,6 +88,8 @@ interface SettingsStore {
|
|||||||
setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise<void>;
|
setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise<void>;
|
||||||
setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise<void>;
|
setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise<void>;
|
||||||
setListeningHistoryEnabled: (enabled: boolean) => Promise<void>;
|
setListeningHistoryEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
setArtistImageAutoPolicy: (policy: ArtistImageAutoPolicy) => Promise<void>;
|
||||||
|
acknowledgeArtistImageDisclosure: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||||
@@ -91,6 +102,8 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|||||||
nowPlayingCompanion: 'queue',
|
nowPlayingCompanion: 'queue',
|
||||||
homeGreetingTextMode: 'messages',
|
homeGreetingTextMode: 'messages',
|
||||||
listeningHistoryEnabled: true,
|
listeningHistoryEnabled: true,
|
||||||
|
artistImageAutoPolicy: 'wifi',
|
||||||
|
artistImageDisclosureSeen: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
|
||||||
load: async () => {
|
load: async () => {
|
||||||
@@ -106,6 +119,8 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|||||||
NOW_PLAYING_COMPANION_KEY,
|
NOW_PLAYING_COMPANION_KEY,
|
||||||
HOME_GREETING_TEXT_MODE_KEY,
|
HOME_GREETING_TEXT_MODE_KEY,
|
||||||
LISTENING_HISTORY_ENABLED_KEY,
|
LISTENING_HISTORY_ENABLED_KEY,
|
||||||
|
ARTIST_IMAGE_AUTO_POLICY_KEY,
|
||||||
|
ARTIST_IMAGE_DISCLOSURE_KEY,
|
||||||
]);
|
]);
|
||||||
const grouping = values[ARTIST_GROUPING_KEY] ?? null;
|
const grouping = values[ARTIST_GROUPING_KEY] ?? null;
|
||||||
const includeSingles = values[INCLUDE_SINGLES_KEY] ?? null;
|
const includeSingles = values[INCLUDE_SINGLES_KEY] ?? null;
|
||||||
@@ -116,6 +131,10 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|||||||
const nowPlayingCompanion = values[NOW_PLAYING_COMPANION_KEY] ?? null;
|
const nowPlayingCompanion = values[NOW_PLAYING_COMPANION_KEY] ?? null;
|
||||||
const homeGreetingTextMode = values[HOME_GREETING_TEXT_MODE_KEY] ?? null;
|
const homeGreetingTextMode = values[HOME_GREETING_TEXT_MODE_KEY] ?? null;
|
||||||
const listeningHistoryEnabled = values[LISTENING_HISTORY_ENABLED_KEY] !== '0';
|
const listeningHistoryEnabled = values[LISTENING_HISTORY_ENABLED_KEY] !== '0';
|
||||||
|
const artistImageAutoPolicy = parseArtistImageAutoPolicy(
|
||||||
|
values[ARTIST_IMAGE_AUTO_POLICY_KEY] ?? null
|
||||||
|
);
|
||||||
|
const artistImageDisclosureSeen = values[ARTIST_IMAGE_DISCLOSURE_KEY] === '1';
|
||||||
set({
|
set({
|
||||||
artistGroupingMode: parseGroupingMode(grouping),
|
artistGroupingMode: parseGroupingMode(grouping),
|
||||||
includeSingles: parseBoolean(includeSingles),
|
includeSingles: parseBoolean(includeSingles),
|
||||||
@@ -126,6 +145,8 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|||||||
nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion),
|
nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion),
|
||||||
homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode),
|
homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode),
|
||||||
listeningHistoryEnabled,
|
listeningHistoryEnabled,
|
||||||
|
artistImageAutoPolicy,
|
||||||
|
artistImageDisclosureSeen,
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -194,4 +215,22 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setArtistImageAutoPolicy: async (policy) => {
|
||||||
|
const previous = get().artistImageAutoPolicy;
|
||||||
|
if (previous === policy) return;
|
||||||
|
set({ artistImageAutoPolicy: policy });
|
||||||
|
try {
|
||||||
|
await AstraLibraryData.setSettings({ [ARTIST_IMAGE_AUTO_POLICY_KEY]: policy });
|
||||||
|
} catch (error) {
|
||||||
|
set({ artistImageAutoPolicy: previous });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
acknowledgeArtistImageDisclosure: async () => {
|
||||||
|
if (get().artistImageDisclosureSeen) return;
|
||||||
|
await AstraLibraryData.setSettings({ [ARTIST_IMAGE_DISCLOSURE_KEY]: '1' });
|
||||||
|
set({ artistImageDisclosureSeen: true });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export type ArtistImageAutoPolicy = 'off' | 'wifi' | 'any';
|
||||||
|
export type ArtistGroupingMode = 'astra' | 'fileTags';
|
||||||
|
|
||||||
|
export interface DeezerArtistCandidate {
|
||||||
|
provider: 'deezer';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
imageUrl: string;
|
||||||
|
linkUrl: string | null;
|
||||||
|
fanCount: number;
|
||||||
|
albumCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistImageLookupTarget {
|
||||||
|
groupingMode: ArtistGroupingMode;
|
||||||
|
artistKey: string;
|
||||||
|
artistName: string;
|
||||||
|
retryCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersistedArtistImageState {
|
||||||
|
groupingMode: ArtistGroupingMode;
|
||||||
|
artistKey: string;
|
||||||
|
artistName?: string;
|
||||||
|
manualImageHash: string | null;
|
||||||
|
automaticImageHash: string | null;
|
||||||
|
automaticProvider: 'deezer' | null;
|
||||||
|
automaticSourceId: string | null;
|
||||||
|
lookupStatus: 'never' | 'found' | 'not_found' | 'transient_error';
|
||||||
|
retryCount: number;
|
||||||
|
lastAttemptAt: number | null;
|
||||||
|
nextRetryAt: number | null;
|
||||||
|
updatedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeezerSearchResult =
|
||||||
|
| { status: 'success'; candidates: DeezerArtistCandidate[] }
|
||||||
|
| {
|
||||||
|
status: 'transient_error';
|
||||||
|
message: string;
|
||||||
|
retryAfterMs: number;
|
||||||
|
code:
|
||||||
|
| 'offline'
|
||||||
|
| 'timeout'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'rate_limited'
|
||||||
|
| 'provider'
|
||||||
|
| 'invalid_response';
|
||||||
|
};
|
||||||
@@ -88,6 +88,7 @@ export interface Artist {
|
|||||||
/** Tracks where this artist is the resolved primary browse artist. */
|
/** Tracks where this artist is the resolved primary browse artist. */
|
||||||
primary_track_count: number;
|
primary_track_count: number;
|
||||||
artwork_hash: string | null;
|
artwork_hash: string | null;
|
||||||
|
artwork_source: 'manual' | 'deezer' | 'track' | null;
|
||||||
album_count: number;
|
album_count: number;
|
||||||
/** Primary hash first, then one distinct cover per further album (max 4) — grid mosaic. */
|
/** Primary hash first, then one distinct cover per further album (max 4) — grid mosaic. */
|
||||||
artwork_hashes: string[];
|
artwork_hashes: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user