From b26d60b7350120916feff39b6666e061b9265a2d Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 11 May 2026 03:44:39 -0700 Subject: [PATCH] Changed the visualizer bg color to pure black and will be releasing Android next. --- Cargo.toml | 1 + ...otlin-compiler-13414114603619315478.salive | 0 .../org/elseif/yrxtals/LibraryController.kt | 81 +++++++++++++------ .../java/org/elseif/yrxtals/NativeBridge.kt | 1 + scripts/android/debug.sh | 41 ++++++++-- src/android.rs | 45 +++++++++++ src/decoder.rs | 41 +++++++++- src/ui/app.rs | 24 +++++- src/ui/theme.rs | 6 +- 9 files changed, 202 insertions(+), 38 deletions(-) create mode 100644 android/.kotlin/sessions/kotlin-compiler-13414114603619315478.salive diff --git a/Cargo.toml b/Cargo.toml index 759460e..6974ce4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/android/.kotlin/sessions/kotlin-compiler-13414114603619315478.salive b/android/.kotlin/sessions/kotlin-compiler-13414114603619315478.salive new file mode 100644 index 0000000..e69de29 diff --git a/android/app/src/main/java/org/elseif/yrxtals/LibraryController.kt b/android/app/src/main/java/org/elseif/yrxtals/LibraryController.kt index 32ebbb9..0870dae 100644 --- a/android/app/src/main/java/org/elseif/yrxtals/LibraryController.kt +++ b/android/app/src/main/java/org/elseif/yrxtals/LibraryController.kt @@ -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) { val handle = view?.viewportHandle ?: run { Log.w(TAG, "handlePickedAudio: no viewportHandle") @@ -95,32 +97,35 @@ class LibraryController(private val activity: ComponentActivity) { val titles = arrayOfNulls(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 + } } diff --git a/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt b/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt index 0f0572d..4655180 100644 --- a/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt +++ b/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt @@ -24,6 +24,7 @@ object NativeBridge { external fun viewportApplyPickedFiles(handle: Long, paths: Array) external fun viewportSetLibraryProgress(handle: Long, current: Int, total: Int) + external fun viewportSetCoordinatingMessage(handle: Long, msg: String?) external fun viewportSetPendingTitles(handle: Long, titles: Array, trackNumbers: IntArray) external fun viewportSetTrackPath(handle: Long, idx: Int, path: String) external fun viewportSetTrackArt(handle: Long, idx: Int, bytes: ByteArray) diff --git a/scripts/android/debug.sh b/scripts/android/debug.sh index a88092e..0ef748e 100755 --- a/scripts/android/debug.sh +++ b/scripts/android/debug.sh @@ -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 diff --git a/src/android.rs b/src/android.rs index 11a522f..e701d14 100644 --- a/src/android.rs +++ b/src/android.rs @@ -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; diff --git a/src/decoder.rs b/src/decoder.rs index 83e8c91..b9e70a7 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -49,6 +49,13 @@ pub fn decode_file(path: &Path) -> Result { #[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 { .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 { 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 { 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", diff --git a/src/ui/app.rs b/src/ui/app.rs index fdd7341..dd63182 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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, + /// 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); } } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 847b91e..e698b94 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -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);