Changed the visualizer bg color to pure black and will be releasing Android next.

This commit is contained in:
jess 2026-05-11 03:44:39 -07:00
parent 464aad571a
commit b26d60b735
9 changed files with 202 additions and 38 deletions

View File

@ -66,6 +66,7 @@ ndk = "0.9"
ndk-context = "0.1"
android_logger = "0.14"
log = "0.4"
libc = "0.2"
[profile.release]
lto = "thin"

View File

@ -6,6 +6,7 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.os.ParcelFileDescriptor
import android.provider.OpenableColumns
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
@ -13,6 +14,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.ComponentActivity
import androidx.documentfile.provider.DocumentFile
import java.io.File
import java.io.InputStream
import java.util.UUID
import java.util.concurrent.Executors
@ -83,7 +85,7 @@ class LibraryController(private val activity: ComponentActivity) {
}
}
/// extracts metadata, seeds the sidebar, copies bytes to cache, and pushes paths and art to rust.
/// extracts metadata, seeds the sidebar, copies bytes to cache, and pushes paths to rust for lofty-side art extraction.
private fun handlePickedAudio(uris: List<Uri>) {
val handle = view?.viewportHandle ?: run {
Log.w(TAG, "handlePickedAudio: no viewportHandle")
@ -95,32 +97,35 @@ class LibraryController(private val activity: ComponentActivity) {
val titles = arrayOfNulls<String>(total)
val trackNumbers = IntArray(total)
main.post {
NativeBridge.viewportSetCoordinatingMessage(handle, "Reading tags from $total track${if (total == 1) "" else "s"}...")
}
io.execute {
val metas = uris.map { extractMeta(it) }
for (i in 0 until total) {
titles[i] = metas[i].title.ifEmpty { displayName(uris[i]) ?: "Track ${i + 1}" }
trackNumbers[i] = metas[i].track
val meta = extractMeta(uris[i])
titles[i] = meta.title.ifEmpty { displayName(uris[i]) ?: "Track ${i + 1}" }
trackNumbers[i] = meta.track
}
main.post {
NativeBridge.viewportSetPendingTitles(handle, titles.map { it ?: "" }.toTypedArray(), trackNumbers)
NativeBridge.viewportSetLibraryProgress(handle, 0, total)
}
for (i in 0 until total) {
val art = metas[i].art
if (art != null && art.isNotEmpty()) {
main.post { NativeBridge.viewportSetTrackArt(handle, i, art) }
}
NativeBridge.viewportSetCoordinatingMessage(handle, "Caching 1 of $total...")
}
var done = 0
for (i in 0 until total) {
val cachePath = copyToCache(uris[i])
done += 1
val current = done
main.post {
if (cachePath != null) {
NativeBridge.viewportSetTrackPath(handle, i, cachePath)
}
NativeBridge.viewportSetLibraryProgress(handle, done, total)
if (done == total) {
NativeBridge.viewportSetLibraryProgress(handle, current, total)
if (current < total) {
NativeBridge.viewportSetCoordinatingMessage(handle, "Caching ${current + 1} of $total...")
} else {
NativeBridge.viewportSetCoordinatingMessage(handle, null)
NativeBridge.viewportSetLibraryProgress(handle, 0, 0)
}
}
@ -128,8 +133,9 @@ class LibraryController(private val activity: ComponentActivity) {
}
}
private data class TrackMeta(val title: String, val track: Int, val art: ByteArray?)
private data class TrackMeta(val title: String, val track: Int)
/// pulls title and track-number via MediaMetadataRetriever, catching OOM so a single huge ID3v2 frame can't kill the worker.
private fun extractMeta(uri: Uri): TrackMeta {
val r = MediaMetadataRetriever()
return try {
@ -137,13 +143,12 @@ class LibraryController(private val activity: ComponentActivity) {
val title = r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE).orEmpty()
val trackStr = r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER)
val track = trackStr?.substringBefore('/')?.toIntOrNull() ?: 0
val art = r.embeddedPicture
TrackMeta(title, track, art)
} catch (e: Exception) {
Log.w(TAG, "extractMeta failed for $uri: ${e.message}")
TrackMeta("", 0, null)
TrackMeta(title, track)
} catch (t: Throwable) {
Log.w(TAG, "extractMeta failed for $uri: ${t.javaClass.simpleName}: ${t.message}")
TrackMeta("", 0)
} finally {
try { r.release() } catch (_: Exception) {}
try { r.release() } catch (_: Throwable) {}
}
}
@ -157,17 +162,47 @@ class LibraryController(private val activity: ComponentActivity) {
}
private fun copyToCache(uri: Uri): String? {
val ext = displayName(uri)?.substringAfterLast('.', "")?.takeIf { it.isNotEmpty() } ?: "audio"
val mime = activity.contentResolver.getType(uri)
val ext = displayName(uri)?.substringAfterLast('.', "")?.takeIf { it.isNotEmpty() }
?: extFromMime(mime)
?: "audio"
val out = File(activity.cacheDir, "${UUID.randomUUID()}.$ext")
return try {
activity.contentResolver.openInputStream(uri)?.use { input ->
val written = openContentStream(uri).use { input ->
out.outputStream().use { output -> input.copyTo(output) }
}
Log.i(TAG, "copyToCache wrote ${out.length()} bytes to ${out.absolutePath}")
if (written <= 0L || out.length() <= 0L) {
Log.w(TAG, "copyToCache: copied $written bytes for $uri (mime=$mime ext=$ext)")
out.delete()
return null
}
Log.i(TAG, "copyToCache wrote $written bytes (mime=$mime) to ${out.absolutePath}")
out.absolutePath
} catch (e: Exception) {
Log.e(TAG, "copyToCache failed for $uri: ${e.message}")
Log.e(TAG, "copyToCache failed for $uri (mime=$mime): ${e.message}")
out.delete()
null
}
}
/// opens the URI as a stream, falling back to a ParcelFileDescriptor when openInputStream returns null.
private fun openContentStream(uri: Uri): InputStream {
val resolver = activity.contentResolver
resolver.openInputStream(uri)?.let { return it }
val pfd = resolver.openFileDescriptor(uri, "r")
?: throw java.io.IOException("openFileDescriptor returned null for $uri")
return ParcelFileDescriptor.AutoCloseInputStream(pfd)
}
/// maps an audio MIME type to the canonical file extension symphonia and lofty expect.
private fun extFromMime(mime: String?): String? = when (mime?.lowercase()) {
"audio/mpeg", "audio/mp3" -> "mp3"
"audio/mp4", "audio/m4a", "audio/x-m4a", "audio/aac" -> "m4a"
"audio/flac", "audio/x-flac" -> "flac"
"audio/ogg", "audio/vorbis", "application/ogg" -> "ogg"
"audio/opus" -> "opus"
"audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave" -> "wav"
"audio/aiff", "audio/x-aiff" -> "aiff"
else -> null
}
}

View File

@ -24,6 +24,7 @@ object NativeBridge {
external fun viewportApplyPickedFiles(handle: Long, paths: Array<String>)
external fun viewportSetLibraryProgress(handle: Long, current: Int, total: Int)
external fun viewportSetCoordinatingMessage(handle: Long, msg: String?)
external fun viewportSetPendingTitles(handle: Long, titles: Array<String>, trackNumbers: IntArray)
external fun viewportSetTrackPath(handle: Long, idx: Int, path: String)
external fun viewportSetTrackArt(handle: Long, idx: Int, bytes: ByteArray)

View File

@ -1,14 +1,22 @@
#!/usr/bin/env bash
# installs the apk, launches the activity, and tails logcat filtered to YrXtals and native crash channels.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/_env.sh"
# _env.sh enables `set -e`; turn it off so a single non-zero adb call doesn't kill the script.
set +e
set -u
set -o pipefail
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
"$SCRIPT_DIR/install.sh" release-debug
install_rc=$?
if [[ $install_rc -ne 0 ]]; then
echo "install failed with rc=$install_rc" >&2
exit $install_rc
fi
if [[ -z "${ANDROID_TARGET:-}" ]]; then
echo "no device selected." >&2
@ -17,14 +25,31 @@ fi
PKG="org.elseif.yrxtals"
adb -s "$ANDROID_TARGET" logcat -c || true
adb -s "$ANDROID_TARGET" shell am start -n "$PKG/.MainActivity" >/dev/null
echo "→ adb logcat -c (clear)"
adb -s "$ANDROID_TARGET" logcat -c >/dev/null 2>&1
echo "→ adb shell am force-stop $PKG"
adb -s "$ANDROID_TARGET" shell am force-stop "$PKG" >/dev/null 2>&1
echo "→ adb shell am start -n $PKG/.MainActivity"
adb -s "$ANDROID_TARGET" shell am start -n "$PKG/.MainActivity"
start_rc=$?
echo "am start rc=$start_rc"
PID="$(adb -s "$ANDROID_TARGET" shell pidof "$PKG" | tr -d '\r')"
echo "running pid=$PID on $ANDROID_TARGET — tailing logcat (Ctrl+C to stop)"
# poll up to 5s for the pid; pidof races the activity start.
PID=""
for _ in 1 2 3 4 5 6 7 8 9 10; do
PID="$(adb -s "$ANDROID_TARGET" shell pidof "$PKG" 2>/dev/null | tr -d '\r')"
[[ -n "$PID" ]] && break
sleep 0.5
done
echo "running pid='$PID' on $ANDROID_TARGET — tailing logcat (Ctrl+C to stop)"
# tags: YrXtals (kotlin), yr_crystals + yr_crystals.io (rust + stdio redirect), crash channels.
FILTER=( YrXtals:V yr_crystals:V yr_crystals.io:V AndroidRuntime:E libc:F DEBUG:F '*:S' )
if [[ -n "$PID" ]]; then
exec adb -s "$ANDROID_TARGET" logcat --pid="$PID" YrXtals:V yr_crystals:V AndroidRuntime:E libc:F DEBUG:F '*:S'
exec adb -s "$ANDROID_TARGET" logcat --pid="$PID" "${FILTER[@]}"
else
exec adb -s "$ANDROID_TARGET" logcat YrXtals:V yr_crystals:V AndroidRuntime:E libc:F DEBUG:F '*:S'
echo "pidof returned empty; tailing unfiltered by pid" >&2
exec adb -s "$ANDROID_TARGET" logcat "${FILTER[@]}"
fi

View File

@ -39,6 +39,7 @@ pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_initContext<'a>(
.with_max_level(log::LevelFilter::Debug)
.with_tag("yr_crystals"),
);
redirect_stdio_to_logcat();
let vm = match env.get_java_vm() {
Ok(vm) => vm,
Err(_) => return,
@ -242,6 +243,23 @@ pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetLibraryPr
av.inner.set_library_progress(current.max(0) as u32, total.max(0) as u32);
}
/// shows or clears the modal coordinator-wait overlay; null/empty message hides the overlay.
#[unsafe(no_mangle)]
pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetCoordinatingMessage<'a>(
mut env: JNIEnv<'a>,
_class: JClass<'a>,
handle: jlong,
msg: JString<'a>,
) {
let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return };
let s = if msg.is_null() {
None
} else {
env.get_string(&msg).ok().map(String::from).filter(|s| !s.is_empty())
};
av.inner.set_coordinating_message(s);
}
/// seeds the sidebar with placeholder rows of titles plus track-number tags ahead of export completion.
#[unsafe(no_mangle)]
pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetPendingTitles<'a>(
@ -327,6 +345,33 @@ pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingP
av.inner.take_pending_pick() as jint
}
/// dup2s stdout and stderr onto a pipe and pumps each line into logcat under the `yr_crystals.io` tag.
fn redirect_stdio_to_logcat() {
use std::io::{BufRead, BufReader};
use std::os::fd::FromRawFd;
let mut fds: [libc::c_int; 2] = [0, 0];
let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
if rc != 0 {
return;
}
let (rfd, wfd) = (fds[0], fds[1]);
unsafe {
libc::dup2(wfd, libc::STDOUT_FILENO);
libc::dup2(wfd, libc::STDERR_FILENO);
libc::close(wfd);
}
std::thread::Builder::new()
.name("yr_crystals.stdio_to_logcat".into())
.spawn(move || {
let file = unsafe { std::fs::File::from_raw_fd(rfd) };
let reader = BufReader::new(file);
for line in reader.lines().flatten() {
log::info!(target: "yr_crystals.io", "{}", line);
}
})
.ok();
}
/// nullability check on a JObject through its raw pointer.
trait JObjectExt {
fn is_null(&self) -> bool;

View File

@ -49,6 +49,13 @@ pub fn decode_file(path: &Path) -> Result<TrackData, DecodeError> {
#[cfg(debug_assertions)]
let _t0 = std::time::Instant::now();
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
eprintln!(
"yr_crystals[decode] open {:?} ({} bytes)",
path.file_name().unwrap_or_default(),
file_size,
);
let file = File::open(path)?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
@ -71,8 +78,13 @@ pub fn decode_file(path: &Path) -> Result<TrackData, DecodeError> {
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.ok_or(DecodeError::NoTrack)?;
let track_id = track.id;
let codec_short = track.codec_params.codec;
let probed_rate = track.codec_params.sample_rate;
let probed_channels = track.codec_params.channels.map(|c| c.count());
eprintln!(
"yr_crystals[decode] probed codec={:?} rate={:?} ch={:?}",
codec_short, probed_rate, probed_channels,
);
let mut decoder =
symphonia::default::get_codecs().make(&track.codec_params, &DecoderOptions::default())?;
@ -82,23 +94,38 @@ pub fn decode_file(path: &Path) -> Result<TrackData, DecodeError> {
let mut actual_sample_rate = probed_rate.unwrap_or(48_000);
let mut actual_channels = probed_channels.unwrap_or(2).max(1);
let mut packet_count: u64 = 0;
let mut decode_errors: u64 = 0;
loop {
let packet = match format.next_packet() {
Ok(p) => p,
Err(SymError::IoError(e)) if e.kind() == ErrorKind::UnexpectedEof => break,
Err(SymError::ResetRequired) => break,
Err(e) => return Err(e.into()),
Err(e) => {
eprintln!("yr_crystals[decode] next_packet err: {e}");
return Err(e.into());
}
};
if packet.track_id() != track_id {
continue;
}
packet_count += 1;
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(SymError::DecodeError(_)) => continue,
Err(e) => return Err(e.into()),
Err(SymError::DecodeError(msg)) => {
decode_errors += 1;
if decode_errors <= 3 {
eprintln!("yr_crystals[decode] frame decode err #{decode_errors}: {msg}");
}
continue;
}
Err(e) => {
eprintln!("yr_crystals[decode] fatal decode err: {e}");
return Err(e.into());
}
};
let spec = *decoded.spec();
@ -121,6 +148,14 @@ pub fn decode_file(path: &Path) -> Result<TrackData, DecodeError> {
append_stereo(&mut pcm, buf.samples(), actual_channels);
}
eprintln!(
"yr_crystals[decode] done: {} packets, {} decode-errs, {} stereo frames, sr={}",
packet_count,
decode_errors,
pcm.len() / 2,
actual_sample_rate,
);
#[cfg(debug_assertions)]
eprintln!(
"yr_crystals[dbg] decode_file({:?}) took {:?} — {} stereo frames @ {} Hz",

View File

@ -41,6 +41,9 @@ pub struct App {
/// modal status message shown over the UI while waiting on iOS file-coordinator caching.
pub coordinating_message: Option<String>,
/// running count of decode failures since the last successful track load.
pub consecutive_failures: usize,
/// settings panel scroll offset mirrored from the on_scroll callback.
pub settings_scroll: AbsoluteOffset,
@ -174,6 +177,7 @@ impl App {
track_loading: false,
library_progress: None,
coordinating_message: None,
consecutive_failures: 0,
settings_scroll: AbsoluteOffset::default(),
restore_sidebar_scroll: false,
restore_settings_scroll: false,
@ -428,6 +432,9 @@ impl App {
self.track_loading = false;
match result {
Ok(td) => {
if td.is_valid() {
self.consecutive_failures = 0;
}
if let Some(eng) = &self.engine {
eng.load(td.clone(), self.current_decode_id);
if self.playing {
@ -438,7 +445,10 @@ impl App {
}
self.worker.set_track(td);
}
Err(e) => eprintln!("yr_crystals: decode failed: {e}"),
Err(e) => {
eprintln!("yr_crystals: decode failed: {e}");
self.note_failure_and_skip();
}
}
}
}
@ -473,6 +483,17 @@ impl App {
self.load_index(next);
}
/// counts a decode failure and skips past the failed track, halting once the whole library has cycled.
fn note_failure_and_skip(&mut self) {
self.consecutive_failures = self.consecutive_failures.saturating_add(1);
if self.library.tracks.len() <= 1 || self.consecutive_failures >= self.library.tracks.len() {
self.playing = false;
if let Some(eng) = &self.engine { eng.pause(); }
return;
}
self.advance_to_next();
}
/// re-sorts the library on arrival of track-number tags and preserves the current selection.
#[allow(dead_code)]
fn resort_library(&mut self) {
@ -533,6 +554,7 @@ impl App {
if idx < self.library.tracks.len() {
self.selected_track = Some(idx);
self.playing = true;
self.consecutive_failures = 0;
self.load_index(idx);
}
}

View File

@ -4,9 +4,9 @@ use iced_wgpu::core::Color;
pub mod palette {
use super::Color;
const BG_C: Color = Color::from_rgb(0.06, 0.06, 0.08);
const SIDEBAR_C: Color = Color::from_rgb(0.09, 0.09, 0.12);
const BORDER_C: Color = Color::from_rgb(0.18, 0.18, 0.22);
const BG_C: Color = Color::from_rgb(0.0, 0.0, 0.0);
const SIDEBAR_C: Color = Color::from_rgb(0.04, 0.04, 0.05);
const BORDER_C: Color = Color::from_rgb(0.10, 0.10, 0.12);
const TEXT_C: Color = Color::from_rgb(0.92, 0.92, 0.95);
const TEXT_DIM_C: Color = Color::from_rgb(0.55, 0.55, 0.62);
const ACCENT_C: Color = Color::from_rgb(0.78, 0.62, 0.95);