diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt index c15f849..c40576e 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt @@ -2,6 +2,8 @@ package expo.modules.astralibraryscanner import android.content.Context import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.media.AudioFormat import android.media.MediaCodec import android.media.MediaExtractor @@ -29,6 +31,7 @@ import kotlinx.coroutines.withContext import java.io.File import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap +import kotlin.math.roundToInt class FileRequest : Record { @Field val uri: String = "" @@ -36,6 +39,8 @@ class FileRequest : Record { } class AstraLibraryScannerModule : Module() { + private val artworkThumbSize = 128 + // Cover-art hashes memoized per cover URI for the duration of one scan // (cleared on each listAudioFiles call) so an album folder's cover.jpg is // read and hashed once, not once per track. @@ -75,6 +80,14 @@ class AstraLibraryScannerModule : Module() { artworkDir().absolutePath } + Function("getArtworkThumbDirPath") { + artworkThumbDir().absolutePath + } + + AsyncFunction("ensureArtworkThumbnails") Coroutine { hashes: List -> + withContext(Dispatchers.IO) { ensureArtworkThumbnails(hashes) } + } + Function("getPersistedTreeUris") { requireContext().contentResolver.persistedUriPermissions .filter { it.isReadPermission } @@ -111,6 +124,9 @@ class AstraLibraryScannerModule : Module() { private fun artworkDir(): File = File(requireContext().filesDir, "artwork").apply { if (!exists()) mkdirs() } + private fun artworkThumbDir(): File = + File(requireContext().filesDir, "artwork-thumbs").apply { if (!exists()) mkdirs() } + // --------------------------------------------------------------------------- // Directory walk // --------------------------------------------------------------------------- @@ -487,15 +503,115 @@ class AstraLibraryScannerModule : Module() { val fileName = md5Hex(bytes) + sniffImageExtension(bytes) val target = File(artworkDir(), fileName) if (!target.exists()) { - val temp = File(artworkDir(), "$fileName.tmp-${Thread.currentThread().id}") + val temp = File(artworkDir(), "$fileName.tmp-${System.nanoTime()}") temp.writeBytes(bytes) if (!temp.renameTo(target)) { temp.delete() } } + writeArtworkThumbnailFromBytes(bytes, fileName) return fileName } + private fun ensureArtworkThumbnails(hashes: List): Int { + var generated = 0 + val seen = mutableSetOf() + for (hash in hashes) { + val cleanHash = hash.trim() + if (cleanHash.isEmpty() || !seen.add(cleanHash)) continue + + val thumb = File(artworkThumbDir(), artworkThumbFileName(cleanHash)) + if (thumb.exists()) continue + + val source = File(artworkDir(), cleanHash) + if (!source.exists()) continue + + val bitmap = decodeSampledBitmap(source) ?: continue + if (writeThumbnail(bitmap, thumb)) generated += 1 + } + return generated + } + + private fun writeArtworkThumbnailFromBytes(bytes: ByteArray, artworkHash: String): Boolean { + val thumb = File(artworkThumbDir(), artworkThumbFileName(artworkHash)) + if (thumb.exists()) return false + val bitmap = decodeSampledBitmap(bytes) ?: return false + return writeThumbnail(bitmap, thumb) + } + + private fun artworkThumbFileName(artworkHash: String): String { + val stem = artworkHash.substringBeforeLast('.', artworkHash) + return "$stem.jpg" + } + + private fun decodeSampledBitmap(source: File): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(source.absolutePath, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + val options = BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight) + inPreferredConfig = Bitmap.Config.RGB_565 + } + return BitmapFactory.decodeFile(source.absolutePath, options) + } + + private fun decodeSampledBitmap(bytes: ByteArray): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + val options = BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight) + inPreferredConfig = Bitmap.Config.RGB_565 + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + } + + private fun calculateInSampleSize(width: Int, height: Int): Int { + var sample = 1 + val largest = max(width, height) + val decodeBound = artworkThumbSize * 2 + while (largest / sample > decodeBound) { + sample *= 2 + } + return sample + } + + private fun writeThumbnail(bitmap: Bitmap, target: File): Boolean { + val thumb = scaleThumbnail(bitmap) + val temp = File(artworkThumbDir(), "${target.name}.tmp-${System.nanoTime()}") + var wrote = false + try { + temp.outputStream().use { out -> + wrote = thumb.compress(Bitmap.CompressFormat.JPEG, 84, out) + } + if (!wrote || target.exists()) { + temp.delete() + return false + } + if (!temp.renameTo(target)) { + temp.delete() + return false + } + return true + } finally { + if (temp.exists() && !wrote) temp.delete() + if (thumb !== bitmap && !bitmap.isRecycled) bitmap.recycle() + if (!thumb.isRecycled) thumb.recycle() + } + } + + private fun scaleThumbnail(bitmap: Bitmap): Bitmap { + val largest = max(bitmap.width, bitmap.height) + if (largest <= artworkThumbSize) return bitmap + + val scale = artworkThumbSize.toFloat() / largest + val width = max(1, (bitmap.width * scale).roundToInt()) + val height = max(1, (bitmap.height * scale).roundToInt()) + return Bitmap.createScaledBitmap(bitmap, width, height, true) + } + private fun md5Hex(bytes: ByteArray): String = MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) } diff --git a/modules/astra-library-scanner/index.ts b/modules/astra-library-scanner/index.ts index 304aeab..7905e4a 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -61,6 +61,8 @@ declare class AstraLibraryScannerModuleType extends NativeModule; getArtworkDirPath(): string; + getArtworkThumbDirPath(): string; + ensureArtworkThumbnails(hashes: string[]): Promise; getPersistedTreeUris(): string[]; takePersistableUriPermission(uri: string): Promise; releasePersistedUriPermission(uri: string): Promise; diff --git a/src/components/library/TrackRow.tsx b/src/components/library/TrackRow.tsx index ca4e809..5f26115 100644 --- a/src/components/library/TrackRow.tsx +++ b/src/components/library/TrackRow.tsx @@ -1,10 +1,17 @@ +import { useState } from 'react'; import { View, Pressable, StyleSheet } from 'react-native'; +import { Image } from 'expo-image'; import { Text } from '@/components/Text'; +import { AstraLogo } from '@/components/AstraLogo'; import { FormatBadges } from '@/components/FormatBadge'; -import { colors, spacing } from '@/theme'; +import { colors, radius, spacing } from '@/theme'; import { formatDuration } from '@/lib/format'; +import { artworkThumbUri } from '@/library/artwork'; import type { DbTrack } from '@/types/library'; +const ART_SIZE = 44; +const ROW_MIN_HEIGHT = ART_SIZE + (spacing.sm + 2) * 2; + export function TrackRow({ track, onPress, @@ -20,6 +27,12 @@ export function TrackRow({ showArtist?: boolean; active?: boolean; }) { + const artworkHash = track.artwork_hash; + const [failedArtworkHash, setFailedArtworkHash] = useState(null); + + const thumbUri = + artworkHash && failedArtworkHash !== artworkHash ? artworkThumbUri(artworkHash) : null; + return ( - {track.track_number != null && !showArtist ? ( + + {thumbUri ? ( + setFailedArtworkHash(artworkHash)} + /> + ) : ( + + )} + + + {!showArtist ? ( - {track.track_number} + {track.track_number ?? ''} ) : null} @@ -68,19 +98,38 @@ const styles = StyleSheet.create({ row: { flexDirection: 'row', alignItems: 'center', + minHeight: ROW_MIN_HEIGHT, paddingVertical: spacing.sm + 2, gap: spacing.md, borderBottomColor: colors.glassBorder, borderBottomWidth: StyleSheet.hairlineWidth, }, + art: { + width: ART_SIZE, + height: ART_SIZE, + flexShrink: 0, + borderRadius: radius.sm, + backgroundColor: colors.bgTertiary, + borderColor: colors.glassBorder, + borderWidth: StyleSheet.hairlineWidth, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + artImage: { + width: '100%', + height: '100%', + }, trackNumber: { width: 24, + flexShrink: 0, fontSize: 12, color: colors.textTertiary, textAlign: 'right', }, meta: { flex: 1, + minWidth: 0, gap: 2, }, title: { @@ -93,7 +142,10 @@ const styles = StyleSheet.create({ marginTop: 2, }, duration: { + minWidth: 42, + flexShrink: 0, fontSize: 12, color: colors.textTertiary, + textAlign: 'right', }, }); diff --git a/src/library/artwork.ts b/src/library/artwork.ts index 27f916f..c801189 100644 --- a/src/library/artwork.ts +++ b/src/library/artwork.ts @@ -4,10 +4,57 @@ import { AstraLibraryScanner } from '../../modules/astra-library-scanner'; let artworkDir: string | null = null; +let artworkThumbDir: string | null = null; -export function artworkUri(hash: string): string { +type ArtworkThumbScanner = { + getArtworkThumbDirPath?: () => string; + ensureArtworkThumbnails?: (hashes: string[]) => Promise; +}; + +function getArtworkDir(): string { if (!artworkDir) { artworkDir = AstraLibraryScanner.getArtworkDirPath(); } - return `file://${artworkDir}/${hash}`; + return artworkDir; +} + +function fallbackArtworkThumbDir(): string { + const dir = getArtworkDir(); + return dir.endsWith('/artwork') ? `${dir.slice(0, -'/artwork'.length)}/artwork-thumbs` : `${dir}-thumbs`; +} + +function getArtworkThumbDir(): string { + if (!artworkThumbDir) { + const scanner = AstraLibraryScanner as unknown as ArtworkThumbScanner; + artworkThumbDir = scanner.getArtworkThumbDirPath?.() ?? fallbackArtworkThumbDir(); + } + return artworkThumbDir; +} + +function artworkThumbFileName(hash: string): string { + const dot = hash.lastIndexOf('.'); + const stem = dot > 0 ? hash.slice(0, dot) : hash; + return `${stem}.jpg`; +} + +export function artworkUri(hash: string): string { + return `file://${getArtworkDir()}/${hash}`; +} + +export function artworkThumbUri(hash: string): string { + return `file://${getArtworkThumbDir()}/${artworkThumbFileName(hash)}`; +} + +export async function ensureArtworkThumbnails( + hashes: readonly (string | null | undefined)[] +): Promise { + const unique = new Set(); + for (const hash of hashes) { + const cleanHash = hash?.trim(); + if (cleanHash) unique.add(cleanHash); + } + if (unique.size === 0) return 0; + + const scanner = AstraLibraryScanner as unknown as ArtworkThumbScanner; + return scanner.ensureArtworkThumbnails?.([...unique]) ?? 0; } diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index a9a6ab8..7b60294 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library'; import { openLibraryDb } from '@/db/database'; import { getAlbums, getAllTracks, getTrackCount } from '@/db/queries'; +import { ensureArtworkThumbnails } from '@/library/artwork'; import { buildArtistList } from '@/library/artistGrouping'; import { addFolderViaPicker, @@ -123,6 +124,11 @@ export const useLibraryStore = create((set, get) => { loadFolders(), getTrackCount(db), ]); + try { + await ensureArtworkThumbnails(tracks.map((track) => track.artwork_hash)); + } catch { + // Missing thumbnails should not prevent the library itself from loading. + } // The artist list is derived in JS so it can honor the grouping mode. const artists = buildArtistList(tracks, useSettingsStore.getState().artistGroupingMode); set({ tracks, albums, artists, folders, totalTrackCount });