mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
cover art on track listings
This commit is contained in:
+117
-1
@@ -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) }
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user