mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-16 08:10:43 +02:00
m3, ui/ux, and more
This commit is contained in:
+168
@@ -3,12 +3,16 @@ package expo.modules.astralibraryscanner
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
import expo.modules.kotlin.exception.Exceptions
|
||||
import expo.modules.kotlin.functions.Coroutine
|
||||
import expo.modules.kotlin.modules.Module
|
||||
@@ -37,6 +41,9 @@ class AstraLibraryScannerModule : Module() {
|
||||
// read and hashed once, not once per track.
|
||||
private val coverHashMemo = ConcurrentHashMap<String, String>()
|
||||
|
||||
// Waveform decode is whole-file and CPU-heavy; throttle concurrent decodes.
|
||||
private val waveformSemaphore = Semaphore(2)
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("AstraLibraryScanner")
|
||||
|
||||
@@ -55,6 +62,15 @@ class AstraLibraryScannerModule : Module() {
|
||||
}
|
||||
}
|
||||
|
||||
// Offline waveform peaks for the seek bar: full PCM decode -> RMS per bin,
|
||||
// normalized to [0,1]. Heavy (whole-file decode), so cap concurrency and
|
||||
// run lazily per track on the JS side; results are cached in SQLite there.
|
||||
AsyncFunction("extractWaveform") Coroutine { uri: String, bins: Int ->
|
||||
waveformSemaphore.withPermit {
|
||||
withContext(Dispatchers.IO) { extractWaveform(uri, if (bins > 0) bins else 512) }
|
||||
}
|
||||
}
|
||||
|
||||
Function("getArtworkDirPath") {
|
||||
artworkDir().absolutePath
|
||||
}
|
||||
@@ -267,6 +283,158 @@ class AstraLibraryScannerModule : Module() {
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Waveform peaks (offline RMS bins)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Decodes the whole file to PCM and accumulates RMS energy per bin (mirrors
|
||||
// desktop waveformExtractor.extractWaveformPeaks), normalized to [0,1]. Returns
|
||||
// an empty array on any failure (caller falls back to a flat seek bar).
|
||||
private fun extractWaveform(uriStr: String, bins: Int): FloatArray {
|
||||
val context = requireContext()
|
||||
val uri = Uri.parse(uriStr)
|
||||
val extractor = MediaExtractor()
|
||||
var codec: MediaCodec? = null
|
||||
try {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
|
||||
var trackFormat: MediaFormat? = null
|
||||
var trackIndex = -1
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val f = extractor.getTrackFormat(i)
|
||||
if (f.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true) {
|
||||
trackFormat = f; trackIndex = i; break
|
||||
}
|
||||
}
|
||||
val format = trackFormat ?: return FloatArray(0)
|
||||
extractor.selectTrack(trackIndex)
|
||||
|
||||
val sampleRate =
|
||||
if (format.containsKey(MediaFormat.KEY_SAMPLE_RATE)) format.getInteger(MediaFormat.KEY_SAMPLE_RATE) else 44100
|
||||
val durationUs =
|
||||
if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0L
|
||||
val totalFrames = max(1L, (durationUs / 1_000_000.0 * sampleRate).toLong())
|
||||
var channelCount =
|
||||
if (format.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) else 2
|
||||
var pcmFloat = false
|
||||
|
||||
val sumSquares = DoubleArray(bins)
|
||||
val counts = LongArray(bins)
|
||||
|
||||
codec = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)!!)
|
||||
codec.configure(format, null, null, 0)
|
||||
codec.start()
|
||||
|
||||
val info = MediaCodec.BufferInfo()
|
||||
var sawInputEOS = false
|
||||
var sawOutputEOS = false
|
||||
var frame = 0L
|
||||
|
||||
while (!sawOutputEOS) {
|
||||
if (!sawInputEOS) {
|
||||
val inIndex = codec.dequeueInputBuffer(10_000)
|
||||
if (inIndex >= 0) {
|
||||
val inBuf = codec.getInputBuffer(inIndex)!!
|
||||
val size = extractor.readSampleData(inBuf, 0)
|
||||
if (size < 0) {
|
||||
codec.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
sawInputEOS = true
|
||||
} else {
|
||||
codec.queueInputBuffer(inIndex, 0, size, extractor.sampleTime, 0)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val outIndex = codec.dequeueOutputBuffer(info, 10_000)
|
||||
if (outIndex >= 0) {
|
||||
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) sawOutputEOS = true
|
||||
if (info.size > 0) {
|
||||
val out = codec.getOutputBuffer(outIndex)!!
|
||||
out.position(info.offset)
|
||||
out.limit(info.offset + info.size)
|
||||
out.order(ByteOrder.nativeOrder())
|
||||
frame = accumulate(out, pcmFloat, channelCount, bins, totalFrames, frame, sumSquares, counts)
|
||||
}
|
||||
codec.releaseOutputBuffer(outIndex, false)
|
||||
} else if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
val nf = codec.outputFormat
|
||||
if (nf.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) channelCount = nf.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
if (nf.containsKey(MediaFormat.KEY_PCM_ENCODING)) {
|
||||
pcmFloat = nf.getInteger(MediaFormat.KEY_PCM_ENCODING) == AudioFormat.ENCODING_PCM_FLOAT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val peaks = FloatArray(bins)
|
||||
var globalMax = 0.0
|
||||
for (i in 0 until bins) {
|
||||
if (counts[i] > 0) {
|
||||
val rms = sqrt(sumSquares[i] / counts[i])
|
||||
peaks[i] = rms.toFloat()
|
||||
if (rms > globalMax) globalMax = rms
|
||||
}
|
||||
}
|
||||
if (globalMax > 0) {
|
||||
for (i in 0 until bins) peaks[i] = (peaks[i] / globalMax).toFloat()
|
||||
}
|
||||
return peaks
|
||||
} catch (_: Throwable) {
|
||||
return FloatArray(0)
|
||||
} finally {
|
||||
try { codec?.stop() } catch (_: Throwable) {}
|
||||
try { codec?.release() } catch (_: Throwable) {}
|
||||
try { extractor.release() } catch (_: Throwable) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Folds one decoded PCM buffer into the per-bin RMS accumulators. Handles
|
||||
// 16-bit (default) and float PCM. Returns the updated running frame index.
|
||||
private fun accumulate(
|
||||
out: java.nio.ByteBuffer,
|
||||
pcmFloat: Boolean,
|
||||
channelCount: Int,
|
||||
bins: Int,
|
||||
totalFrames: Long,
|
||||
startFrame: Long,
|
||||
sumSquares: DoubleArray,
|
||||
counts: LongArray
|
||||
): Long {
|
||||
var frame = startFrame
|
||||
if (pcmFloat) {
|
||||
val fb = out.asFloatBuffer()
|
||||
val n = fb.remaining()
|
||||
var k = 0
|
||||
while (k < n) {
|
||||
val bin = ((frame.toDouble() / totalFrames) * bins).toInt().coerceIn(0, bins - 1)
|
||||
var c = 0
|
||||
while (c < channelCount && k < n) {
|
||||
val s = fb.get(k).toDouble()
|
||||
sumSquares[bin] += s * s
|
||||
k++; c++
|
||||
}
|
||||
counts[bin] += c.toLong()
|
||||
frame++
|
||||
}
|
||||
} else {
|
||||
val sb = out.asShortBuffer()
|
||||
val n = sb.remaining()
|
||||
var k = 0
|
||||
while (k < n) {
|
||||
val bin = ((frame.toDouble() / totalFrames) * bins).toInt().coerceIn(0, bins - 1)
|
||||
var c = 0
|
||||
while (c < channelCount && k < n) {
|
||||
val s = sb.get(k) / 32768.0
|
||||
sumSquares[bin] += s * s
|
||||
k++; c++
|
||||
}
|
||||
counts[bin] += c.toLong()
|
||||
frame++
|
||||
}
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
private fun readBitsPerSample(format: MediaFormat): Int? {
|
||||
// The framework FLAC/WAV extractors expose "bits-per-sample"; other codecs
|
||||
// may expose a PCM encoding instead. Both are best-effort.
|
||||
|
||||
Reference in New Issue
Block a user