cover art on track listings

This commit is contained in:
Boof2015
2026-06-17 19:37:55 -04:00
parent c1aad57548
commit 97522ea5cf
5 changed files with 229 additions and 6 deletions
@@ -2,6 +2,8 @@ package expo.modules.astralibraryscanner
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.AudioFormat import android.media.AudioFormat
import android.media.MediaCodec import android.media.MediaCodec
import android.media.MediaExtractor import android.media.MediaExtractor
@@ -29,6 +31,7 @@ import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.math.roundToInt
class FileRequest : Record { class FileRequest : Record {
@Field val uri: String = "" @Field val uri: String = ""
@@ -36,6 +39,8 @@ class FileRequest : Record {
} }
class AstraLibraryScannerModule : Module() { class AstraLibraryScannerModule : Module() {
private val artworkThumbSize = 128
// Cover-art hashes memoized per cover URI for the duration of one scan // 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 // (cleared on each listAudioFiles call) so an album folder's cover.jpg is
// read and hashed once, not once per track. // read and hashed once, not once per track.
@@ -75,6 +80,14 @@ class AstraLibraryScannerModule : Module() {
artworkDir().absolutePath artworkDir().absolutePath
} }
Function("getArtworkThumbDirPath") {
artworkThumbDir().absolutePath
}
AsyncFunction("ensureArtworkThumbnails") Coroutine { hashes: List<String> ->
withContext(Dispatchers.IO) { ensureArtworkThumbnails(hashes) }
}
Function("getPersistedTreeUris") { Function("getPersistedTreeUris") {
requireContext().contentResolver.persistedUriPermissions requireContext().contentResolver.persistedUriPermissions
.filter { it.isReadPermission } .filter { it.isReadPermission }
@@ -111,6 +124,9 @@ class AstraLibraryScannerModule : Module() {
private fun artworkDir(): File = private fun artworkDir(): File =
File(requireContext().filesDir, "artwork").apply { if (!exists()) mkdirs() } File(requireContext().filesDir, "artwork").apply { if (!exists()) mkdirs() }
private fun artworkThumbDir(): File =
File(requireContext().filesDir, "artwork-thumbs").apply { if (!exists()) mkdirs() }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Directory walk // Directory walk
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -487,15 +503,115 @@ class AstraLibraryScannerModule : Module() {
val fileName = md5Hex(bytes) + sniffImageExtension(bytes) val fileName = md5Hex(bytes) + sniffImageExtension(bytes)
val target = File(artworkDir(), fileName) val target = File(artworkDir(), fileName)
if (!target.exists()) { if (!target.exists()) {
val temp = File(artworkDir(), "$fileName.tmp-${Thread.currentThread().id}") val temp = File(artworkDir(), "$fileName.tmp-${System.nanoTime()}")
temp.writeBytes(bytes) temp.writeBytes(bytes)
if (!temp.renameTo(target)) { if (!temp.renameTo(target)) {
temp.delete() temp.delete()
} }
} }
writeArtworkThumbnailFromBytes(bytes, fileName)
return fileName return fileName
} }
private fun ensureArtworkThumbnails(hashes: List<String>): Int {
var generated = 0
val seen = mutableSetOf<String>()
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 = private fun md5Hex(bytes: ByteArray): String =
MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) } MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) }
+2
View File
@@ -61,6 +61,8 @@ declare class AstraLibraryScannerModuleType extends NativeModule<AstraLibrarySca
*/ */
extractWaveform(uri: string, bins: number): Promise<number[]>; extractWaveform(uri: string, bins: number): Promise<number[]>;
getArtworkDirPath(): string; getArtworkDirPath(): string;
getArtworkThumbDirPath(): string;
ensureArtworkThumbnails(hashes: string[]): Promise<number>;
getPersistedTreeUris(): string[]; getPersistedTreeUris(): string[];
takePersistableUriPermission(uri: string): Promise<boolean>; takePersistableUriPermission(uri: string): Promise<boolean>;
releasePersistedUriPermission(uri: string): Promise<void>; releasePersistedUriPermission(uri: string): Promise<void>;
+55 -3
View File
@@ -1,10 +1,17 @@
import { useState } from 'react';
import { View, Pressable, StyleSheet } from 'react-native'; import { View, Pressable, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge'; import { FormatBadges } from '@/components/FormatBadge';
import { colors, spacing } from '@/theme'; import { colors, radius, spacing } from '@/theme';
import { formatDuration } from '@/lib/format'; import { formatDuration } from '@/lib/format';
import { artworkThumbUri } from '@/library/artwork';
import type { DbTrack } from '@/types/library'; import type { DbTrack } from '@/types/library';
const ART_SIZE = 44;
const ROW_MIN_HEIGHT = ART_SIZE + (spacing.sm + 2) * 2;
export function TrackRow({ export function TrackRow({
track, track,
onPress, onPress,
@@ -20,6 +27,12 @@ export function TrackRow({
showArtist?: boolean; showArtist?: boolean;
active?: boolean; active?: boolean;
}) { }) {
const artworkHash = track.artwork_hash;
const [failedArtworkHash, setFailedArtworkHash] = useState<string | null>(null);
const thumbUri =
artworkHash && failedArtworkHash !== artworkHash ? artworkThumbUri(artworkHash) : null;
return ( return (
<Pressable <Pressable
style={styles.row} style={styles.row}
@@ -27,9 +40,26 @@ export function TrackRow({
onLongPress={onLongPress} onLongPress={onLongPress}
accessibilityRole="button" accessibilityRole="button"
> >
{track.track_number != null && !showArtist ? ( <View style={styles.art}>
{thumbUri ? (
<Image
source={{ uri: thumbUri }}
style={styles.artImage}
contentFit="cover"
cachePolicy="memory-disk"
recyclingKey={artworkHash}
transition={null}
allowDownscaling
onError={() => setFailedArtworkHash(artworkHash)}
/>
) : (
<AstraLogo size={18} />
)}
</View>
{!showArtist ? (
<Text variant="mono" style={styles.trackNumber}> <Text variant="mono" style={styles.trackNumber}>
{track.track_number} {track.track_number ?? ''}
</Text> </Text>
) : null} ) : null}
@@ -68,19 +98,38 @@ const styles = StyleSheet.create({
row: { row: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
minHeight: ROW_MIN_HEIGHT,
paddingVertical: spacing.sm + 2, paddingVertical: spacing.sm + 2,
gap: spacing.md, gap: spacing.md,
borderBottomColor: colors.glassBorder, borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth, 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: { trackNumber: {
width: 24, width: 24,
flexShrink: 0,
fontSize: 12, fontSize: 12,
color: colors.textTertiary, color: colors.textTertiary,
textAlign: 'right', textAlign: 'right',
}, },
meta: { meta: {
flex: 1, flex: 1,
minWidth: 0,
gap: 2, gap: 2,
}, },
title: { title: {
@@ -93,7 +142,10 @@ const styles = StyleSheet.create({
marginTop: 2, marginTop: 2,
}, },
duration: { duration: {
minWidth: 42,
flexShrink: 0,
fontSize: 12, fontSize: 12,
color: colors.textTertiary, color: colors.textTertiary,
textAlign: 'right',
}, },
}); });
+49 -2
View File
@@ -4,10 +4,57 @@
import { AstraLibraryScanner } from '../../modules/astra-library-scanner'; import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
let artworkDir: string | null = null; let artworkDir: string | null = null;
let artworkThumbDir: string | null = null;
export function artworkUri(hash: string): string { type ArtworkThumbScanner = {
getArtworkThumbDirPath?: () => string;
ensureArtworkThumbnails?: (hashes: string[]) => Promise<number>;
};
function getArtworkDir(): string {
if (!artworkDir) { if (!artworkDir) {
artworkDir = AstraLibraryScanner.getArtworkDirPath(); 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<number> {
const unique = new Set<string>();
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;
} }
+6
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library'; import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library';
import { openLibraryDb } from '@/db/database'; import { openLibraryDb } from '@/db/database';
import { getAlbums, getAllTracks, getTrackCount } from '@/db/queries'; import { getAlbums, getAllTracks, getTrackCount } from '@/db/queries';
import { ensureArtworkThumbnails } from '@/library/artwork';
import { buildArtistList } from '@/library/artistGrouping'; import { buildArtistList } from '@/library/artistGrouping';
import { import {
addFolderViaPicker, addFolderViaPicker,
@@ -123,6 +124,11 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
loadFolders(), loadFolders(),
getTrackCount(db), 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. // The artist list is derived in JS so it can honor the grouping mode.
const artists = buildArtistList(tracks, useSettingsStore.getState().artistGroupingMode); const artists = buildArtistList(tracks, useSettingsStore.getState().artistGroupingMode);
set({ tracks, albums, artists, folders, totalTrackCount }); set({ tracks, albums, artists, folders, totalTrackCount });