android: the phone, on the real core — the mockup shell, playback, the media card, the catalog, the resolver, Save, the dev flavour

The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.

Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.

The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.

The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
This commit is contained in:
KeelrLeek committed 2026-09-13 09:30:06 -05:00
1 parent 972dd103cb
commit bb828ff2dc
102 files changed
+12258 -830

No files matched your search

+22 -3
View File
@@ -43,8 +43,6 @@ realfft = "3"
# XSPF playlist import (playlist_formats.rs). quick-xml never resolves DTDs
# or external entities, which is exactly the safety posture the importer wants.
quick-xml = "0.37"
# LRCLIB lyrics client (lyrics.rs). Already in the tree via the metadata crate.
reqwest = { version = "0.12", features = ["json"] }
keyring = { version = "3", features = ["windows-native"] }
sysinfo = "0.33"
soulseek-proto = { path = "../crates/soulseek-proto" }
@@ -56,8 +54,29 @@ tagger = { path = "../crates/tagger" }
metadata = { path = "../crates/metadata" }
library = { path = "../crates/library" }
audio-formats = { path = "../crates/audio-formats" }
tauri-plugin-updater = "2"
# The phone's lock-screen card and background playback (a Kotlin foreground
# service). Registered everywhere so the permission set resolves on every
# platform; on the desktop its commands answer "not here".
tauri-plugin-media-session = { path = "../crates/tauri-plugin-media-session" }
tauri-plugin-storage = { path = "../crates/tauri-plugin-storage" }
semver = "1"
tauri-plugin-opener = "2.5.5"
# LRCLIB lyrics client (lyrics.rs). Already in the tree via the metadata crate.
# Desktop keeps the platform TLS (schannel on Windows); Android has no OpenSSL
# to build against, so it takes rustls with the bundled roots instead.
[target.'cfg(not(target_os = "android"))'.dependencies]
reqwest = { version = "0.12", features = ["json"] }
# Release checks and the silent install are a desktop story; the phone gets
# its builds from the store or a sideload, and the plugin is desktop-only.
tauri-plugin-updater = "2"
[target.'cfg(target_os = "android")'.dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
# JNI_OnLoad (android_glue.rs): hands the JavaVM and the app Context to
# ndk-context, which cpal's AAudio backend reads and tao 0.35 never fills.
jni = "0.21"
ndk-context = "0.1"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61.3", features = [
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# The detour build (ANDROID.md): cargo → strip → copy → gradle. Run from anywhere.
set -euo pipefail
cd /f/Coding/SoulStream
source src-tauri/android/env.sh
echo "== cargo $(date +%T)"
cargo build --lib -p app --target aarch64-linux-android --features tauri/custom-protocol 2>&1 | grep -v "^\s*Compiling\|^\s*Fresh"
# the grep above hides cargo's exit code; a failed compile must not package
# the previous library as if it were this one
test "${PIPESTATUS[0]}" -eq 0 || { echo "cargo failed — not packaging"; exit 1; }
SO=target/aarch64-linux-android/debug/libapp_lib.so
test -f "$SO" || { echo "no .so built"; exit 1; }
echo "== strip $(date +%T)"
DEST=src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a
mkdir -p "$DEST"
cp "$SO" "$DEST/libapp_lib.so"
"$BIN/llvm-strip.exe" --strip-debug "$DEST/libapp_lib.so"
ls -la "$DEST/libapp_lib.so"
echo "== gradle $(date +%T)"
cd src-tauri/gen/android
./gradlew.bat assembleArm64Debug -x rustBuildArm64Debug -q 2>&1 | grep -v "^$" | tail -20 || true
cd /f/Coding/SoulStream
APK=src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk
ls -la "$APK"
echo "== done $(date +%T)"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# frontend → detour build → install on the connected phone → launch → screenshot
set -euo pipefail
cd /f/Coding/SoulStream
export MSYS_NO_PATHCONV=1
ADB="$(cygpath -u "$LOCALAPPDATA")/Android/Sdk/platform-tools/adb.exe"
echo "== vite $(date +%T)"
npx vite build 2>&1 | grep -i "error\|built in" || true
bash src-tauri/android/build.sh
APK=src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk
echo "== install $(date +%T)"
"$ADB" install -r "$APK" 2>&1 | tail -2
# the media card is a notification; a debug install grants the permission up
# front so the first play never stops on the system's dialog
"$ADB" shell pm grant com.soulstream.app android.permission.POST_NOTIFICATIONS 2>/dev/null || true
"$ADB" shell am force-stop com.soulstream.app
# launch and screenshot only when the phone is ours to drive: SoulStream or
# the launcher on screen. Never take the screen from another app.
FOCUS=$("$ADB" shell dumpsys window 2>/dev/null | grep -a "mCurrentFocus" | head -1)
if echo "$FOCUS" | grep -qi "soulstream\|launcher"; then
"$ADB" shell am start -n com.soulstream.app/.MainActivity >/dev/null
sleep 5
OUT="${SS_SHOT_DIR:-$(cygpath -u "$TEMP")}"
"$ADB" exec-out screencap -p > "$OUT/px-1.png"
echo "== shot $(date +%T) -> $OUT/px-1.png"
else
echo "== installed; not launched (the phone is in use: $FOCUS)"
fi
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# The DEV flavour (ANDROID.md → "Iterating without cargo"): the detour build
# WITHOUT tauri's custom-protocol feature, so the library embeds the dev URL
# (http://localhost:1421) instead of dist/. On the phone, `adb reverse` makes
# the phone's localhost:1421 THIS machine's Vite dev server over the USB
# cable — no LAN address, no firewall hole, hot reload — and cargo runs only
# when Rust changes. Both flavours share target/: switching flavours
# recompiles tauri and the app crate once (~3 min), the leaf crates stay
# fresh — cheaper than a second 15 GB cache on this drive.
#
# bash src-tauri/android/dev.sh # build (when Rust changed) + install + tunnel + launch
# bash src-tauri/android/dev.sh --no-build # reinstall the last dev library, re-tunnel, launch
# bash src-tauri/android/dev.sh --build-only # just the library (the phone is busy or absent)
# bash src-tauri/android/deploy.sh # the embedded build again (what the phone keeps)
#
# Then start the "frontend" dev server (npm run dev / the Browser pane's
# "frontend" launch config). The phone needs it running — and the cable.
set -euo pipefail
cd /f/Coding/SoulStream
export MSYS_NO_PATHCONV=1
source src-tauri/android/env.sh
if [ "${1:-}" != "--no-build" ]; then
echo "== cargo (dev flavour) $(date +%T)"
cargo build --lib -p app --target aarch64-linux-android 2>&1 | grep -v "^\s*Compiling\|^\s*Fresh"
test "${PIPESTATUS[0]}" -eq 0 || { echo "cargo failed — not packaging"; exit 1; }
[ "${1:-}" = "--build-only" ] && { echo "== built $(date +%T)"; exit 0; }
fi
SO=target/aarch64-linux-android/debug/libapp_lib.so
test -f "$SO" || { echo "no dev .so built"; exit 1; }
echo "== strip $(date +%T)"
DEST=src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a
mkdir -p "$DEST"
cp "$SO" "$DEST/libapp_lib.so"
"$BIN/llvm-strip.exe" --strip-debug "$DEST/libapp_lib.so"
echo "== gradle $(date +%T)"
cd src-tauri/gen/android
./gradlew.bat assembleArm64Debug -x rustBuildArm64Debug -q 2>&1 | grep -v "^$" | tail -20 || true
cd /f/Coding/SoulStream
APK=src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk
echo "== install $(date +%T)"
"$ADB" install -r "$APK" 2>&1 | tail -1
"$ADB" shell pm grant com.soulstream.app android.permission.POST_NOTIFICATIONS 2>/dev/null || true
# the tunnel: the phone's localhost:1421 → this machine's 1421 (Vite)
"$ADB" reverse tcp:1421 tcp:1421
"$ADB" shell am force-stop com.soulstream.app
FOCUS=$("$ADB" shell dumpsys window 2>/dev/null | grep -a "mCurrentFocus" | head -1)
if echo "$FOCUS" | grep -qi "soulstream\|launcher"; then
"$ADB" shell am start -n com.soulstream.app/.MainActivity >/dev/null
echo "== launched $(date +%T) — serve the frontend on 1421 (npm run dev) if it is not up"
else
echo "== installed; not launched (the phone is in use: $FOCUS)"
fi
+23
View File
@@ -0,0 +1,23 @@
# The Android toolchain, for the detour scripts (build.sh, dev.sh). Sourced,
# never run. A bash session started before the user variables were set does
# not see them, so everything is spelled out here.
SDK="$(cygpath -u "$LOCALAPPDATA")/Android/Sdk"
NDKV=28.2.13676358
BIN="$SDK/ndk/$NDKV/toolchains/llvm/prebuilt/windows-x86_64/bin"
export ANDROID_HOME="$(cygpath -w "$SDK")"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export NDK_HOME="$(cygpath -w "$SDK/ndk/$NDKV")"
export ANDROID_NDK_HOME="$NDK_HOME"
export ANDROID_NDK_ROOT="$NDK_HOME"
export JAVA_HOME="C:\Program Files\Java\jdk-17"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$(cygpath -w "$BIN/aarch64-linux-android26-clang.cmd")"
export CC_aarch64_linux_android="$(cygpath -w "$BIN/aarch64-linux-android26-clang.cmd")"
export CXX_aarch64_linux_android="$(cygpath -w "$BIN/aarch64-linux-android26-clang++.cmd")"
export AR_aarch64_linux_android="$(cygpath -w "$BIN/llvm-ar.exe")"
export PATH="$SDK/platform-tools:$PATH"
# tauri-build regenerates gen/android/tauri.settings.gradle and
# app/tauri.build.gradle.kts — the lines that include every plugin's Android
# module — ONLY when the CLI's variable names the project. Without it a new
# plugin (the media session) compiled fine and never reached the APK.
export TAURI_ANDROID_PROJECT_PATH="$(cygpath -w "$PWD/src-tauri/gen/android")"
ADB="$SDK/platform-tools/adb.exe"
+4 -1
View File
@@ -19,6 +19,9 @@
"core:window:allow-set-focus",
"core:window:allow-set-size",
"core:window:allow-set-always-on-top",
"dialog:allow-open"
"dialog:allow-open",
"media-session:default",
"opener:default",
"storage:default"
]
}
+56
View File
@@ -0,0 +1,56 @@
//! Android's JNI hand-off.
//!
//! cpal's AAudio backend (and anything else built on the `ndk` crates) asks
//! the `ndk-context` crate for the JavaVM and an Android Context. Somebody has
//! to put them there; tao 0.37 does, but the tao this Tauri resolves (0.35)
//! keeps its own copy and never publishes it, so the audio thread panicked on
//! its first breath with "android context was not initialized".
//!
//! `JNI_OnLoad` runs the moment `System.loadLibrary("app_lib")` loads us,
//! before any activity exists. The Application object is reachable then
//! through `ActivityThread.currentApplication()`, which is what every
//! "give me a Context from native code" library relies on. Nothing else in
//! the tree defines `JNI_OnLoad` (checked: wry 0.55, tao 0.35), so the export
//! is ours to make.
use jni::objects::JObject;
use jni::sys::{jint, JavaVM as RawJavaVm, JNI_VERSION_1_6};
use jni::JavaVM;
#[no_mangle]
pub extern "system" fn JNI_OnLoad(vm: *mut RawJavaVm, _reserved: *mut std::ffi::c_void) -> jint {
let Ok(jvm) = (unsafe { JavaVM::from_raw(vm) }) else {
return JNI_VERSION_1_6;
};
// The loading thread is a Java thread, already attached.
let Ok(mut env) = jvm.get_env() else {
return JNI_VERSION_1_6;
};
let app: Option<JObject> = env
.call_static_method(
"android/app/ActivityThread",
"currentApplication",
"()Landroid/app/Application;",
&[],
)
.and_then(|v| v.l())
.ok()
.filter(|o| !o.is_null());
match app.and_then(|a| env.new_global_ref(&a).ok()) {
Some(global) => {
unsafe {
ndk_context::initialize_android_context(
vm.cast(),
global.as_obj().as_raw().cast(),
);
}
// The context lives as long as the process; the global ref must too.
std::mem::forget(global);
log::info!("android: JNI context published for the audio backend");
}
None => {
eprintln!("android: could not reach ActivityThread.currentApplication(); audio output will be unavailable");
}
}
JNI_VERSION_1_6
}
+40 -15
View File
@@ -284,11 +284,22 @@ pub fn forget_credentials(app: tauri::AppHandle) -> Result<(), String> {
/// Open a native folder picker; returns the chosen path or null.
#[tauri::command]
pub async fn pick_folder(app: tauri::AppHandle) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog().file().pick_folder(move |path| {
let _ = tx.send(path.map(|p| p.to_string()));
});
let (tx, rx) = tokio::sync::oneshot::channel::<Option<String>>();
// A folder picker is a desktop dialog; Android hands folders over through
// the Storage Access Framework, which is its own story (the mockup's
// Import step 1). Until then the phone answers "nothing picked".
#[cfg(desktop)]
{
use tauri_plugin_dialog::DialogExt;
app.dialog().file().pick_folder(move |path| {
let _ = tx.send(path.map(|p| p.to_string()));
});
}
#[cfg(mobile)]
{
let _ = app;
let _ = tx.send(None);
}
Ok(rx.await.unwrap_or(None))
}
@@ -5369,6 +5380,7 @@ pub async fn mini_close(app: tauri::AppHandle) -> Result<(), String> {
/// modal move/size loop, and a maximize/restore (Kyle photographed the
/// desktop punching through a maximized window on 2026-08-28 — the accent
/// did not survive the transition on its own).
#[cfg(windows)]
pub(crate) static GLASS_ON: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
@@ -5378,12 +5390,14 @@ pub(crate) static GLASS_ON: std::sync::atomic::AtomicBool =
/// swaps down to it for the length of a gesture and back up on release
/// (see thumbbar.rs). Same struct, one field different.
#[derive(Clone, Copy, PartialEq)]
#[cfg(windows)]
pub(crate) enum Accent {
Off,
Blur,
Acrylic,
}
#[cfg(windows)]
pub(crate) fn apply_accent(hwnd: *mut std::ffi::c_void, accent: Accent) -> Result<(), String> {
#[repr(C)]
struct AccentPolicy {
@@ -5451,6 +5465,7 @@ pub(crate) fn apply_accent(hwnd: *mut std::ffi::c_void, accent: Accent) -> Resul
/// (2026-08-29). So transitions are now explicitly forced ON (clearing any
/// disable a previous build left on this window), and the discrete bracket
/// in thumbbar.rs + GROUND_DISCRETE in glass.js covers the detach window.
#[cfg(windows)]
fn set_transitions_disabled(hwnd: *mut std::ffi::c_void, disabled: bool) {
#[allow(non_snake_case)]
#[link(name = "kernel32")]
@@ -5481,17 +5496,27 @@ fn set_transitions_disabled(hwnd: *mut std::ffi::c_void, disabled: bool) {
#[tauri::command]
pub fn window_glass(window: tauri::WebviewWindow, on: bool) -> Result<(), String> {
// Clear any system backdrop a previous toggle/build may have applied.
let _ = window.set_effects(None::<tauri::utils::config::WindowEffectsConfig>);
let hwnd = window.hwnd().map_err(|e| e.to_string())?.0 as *mut std::ffi::c_void;
// The mini calls this too (window_glass acts on the CALLING window), and
// the subclass re-asserting from thumbbar.rs is on the MAIN window only —
// so only the main window's state is tracked.
if window.label() == "main" {
GLASS_ON.store(on, std::sync::atomic::Ordering::Relaxed);
// The glass is the Windows compositor's acrylic; a phone's ground is the
// artwork itself (see mobile.css), so there is nothing to apply here.
#[cfg(not(windows))]
{
let _ = (window, on);
return Ok(());
}
#[cfg(windows)]
{
// Clear any system backdrop a previous toggle/build may have applied.
let _ = window.set_effects(None::<tauri::utils::config::WindowEffectsConfig>);
let hwnd = window.hwnd().map_err(|e| e.to_string())?.0 as *mut std::ffi::c_void;
// The mini calls this too (window_glass acts on the CALLING window), and
// the subclass re-asserting from thumbbar.rs is on the MAIN window only —
// so only the main window's state is tracked.
if window.label() == "main" {
GLASS_ON.store(on, std::sync::atomic::Ordering::Relaxed);
}
set_transitions_disabled(hwnd, false);
apply_accent(hwnd, if on { Accent::Acrylic } else { Accent::Off })
}
set_transitions_disabled(hwnd, false);
apply_accent(hwnd, if on { Accent::Acrylic } else { Accent::Off })
}
#[cfg(test)]
+100
View File
@@ -329,6 +329,16 @@ pub fn download_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf {
return PathBuf::from(d);
}
}
// Android's public Music folder is not a folder an app may write to by
// path (scoped storage), so the phone's library lives under the app's own
// data directory until the folder-picker story (SAF) lands. Everything
// the library keeps about itself travels with it there, as on the desktop.
#[cfg(target_os = "android")]
{
if let Ok(dir) = app.path().app_data_dir() {
return dir.join("Library");
}
}
let music = app
.path()
.audio_dir()
@@ -494,6 +504,54 @@ pub fn library_layout(cfg: &AppConfig) -> &str {
}
// ---- password storage (Windows Credential Manager) ----
//
// Android has no credential manager the keyring crate speaks to, so the
// phone keeps its secrets in a file under the app's private data directory —
// which is what Android's own account storage amounts to for an app: sealed
// per package, wiped with the app, never on a shared drive. The directory
// is remembered at start-up (`remember_secrets_dir`), because the password
// helpers have no app handle of their own.
#[cfg(target_os = "android")]
static SECRETS_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// Called once at start-up with the app data directory.
pub fn remember_secrets_dir(app: &tauri::AppHandle) {
#[cfg(target_os = "android")]
{
if let Ok(dir) = crate::paths::app_data(app) {
let _ = SECRETS_DIR.set(dir);
}
}
#[cfg(not(target_os = "android"))]
{
let _ = app;
}
}
#[cfg(target_os = "android")]
fn secrets_path() -> Option<PathBuf> {
let dir = SECRETS_DIR.get()?;
std::fs::create_dir_all(dir).ok()?;
Some(dir.join("secrets.json"))
}
#[cfg(target_os = "android")]
fn secrets_read() -> std::collections::BTreeMap<String, String> {
secrets_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
#[cfg(target_os = "android")]
fn secrets_write(map: &std::collections::BTreeMap<String, String>) {
if let (Some(p), Ok(json)) = (secrets_path(), serde_json::to_string(map)) {
if let Err(e) = std::fs::write(&p, json) {
log::warn!("failed to write secrets: {e}");
}
}
}
fn entry(username: &str) -> Option<keyring::Entry> {
keyring::Entry::new(KEYRING_SERVICE, username).ok()
@@ -505,6 +563,14 @@ fn entry(username: &str) -> Option<keyring::Entry> {
const UPDATE_TOKEN_USER: &str = "updater-token";
pub fn save_update_token(token: &str) {
#[cfg(target_os = "android")]
{
let mut m = secrets_read();
m.insert(UPDATE_TOKEN_USER.to_string(), token.to_string());
secrets_write(&m);
return;
}
#[allow(unreachable_code)]
if let Some(e) = entry(UPDATE_TOKEN_USER) {
if let Err(err) = e.set_password(token) {
log::warn!("failed to store the update token in credential manager: {err}");
@@ -513,6 +579,11 @@ pub fn save_update_token(token: &str) {
}
pub fn load_update_token() -> Option<String> {
#[cfg(target_os = "android")]
{
return secrets_read().get(UPDATE_TOKEN_USER).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
}
#[allow(unreachable_code)]
entry(UPDATE_TOKEN_USER)
.and_then(|e| e.get_password().ok())
.map(|t| t.trim().to_string())
@@ -520,12 +591,28 @@ pub fn load_update_token() -> Option<String> {
}
pub fn clear_update_token() {
#[cfg(target_os = "android")]
{
let mut m = secrets_read();
m.remove(UPDATE_TOKEN_USER);
secrets_write(&m);
return;
}
#[allow(unreachable_code)]
if let Some(e) = entry(UPDATE_TOKEN_USER) {
let _ = e.delete_credential();
}
}
pub fn save_password(username: &str, password: &str) {
#[cfg(target_os = "android")]
{
let mut m = secrets_read();
m.insert(format!("slsk:{username}"), password.to_string());
secrets_write(&m);
return;
}
#[allow(unreachable_code)]
if let Some(e) = entry(username) {
if let Err(err) = e.set_password(password) {
log::warn!("failed to store password in credential manager: {err}");
@@ -534,6 +621,11 @@ pub fn save_password(username: &str, password: &str) {
}
pub fn load_password(username: &str) -> Option<String> {
#[cfg(target_os = "android")]
{
return secrets_read().get(&format!("slsk:{username}")).cloned();
}
#[allow(unreachable_code)]
if let Some(pw) = entry(username).and_then(|e| e.get_password().ok()) {
return Some(pw);
}
@@ -550,6 +642,14 @@ pub fn has_password(username: &str) -> bool {
}
pub fn delete_password(username: &str) {
#[cfg(target_os = "android")]
{
let mut m = secrets_read();
m.remove(&format!("slsk:{username}"));
secrets_write(&m);
return;
}
#[allow(unreachable_code)]
if let Some(e) = entry(username) {
let _ = e.delete_credential();
}
+26
View File
@@ -265,6 +265,7 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS
}
/// Private commit ("reserved") of one process, bytes. 0 if unreadable.
#[cfg(windows)]
fn private_commit(pid: u32) -> u64 {
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::ProcessStatus::{
@@ -297,6 +298,7 @@ fn private_commit(pid: u32) -> u64 {
/// number Task Manager labels "GPU". Instances are per-process-per-engine;
/// summing the 3D ones gives machine-wide utilization. First sample after
/// opening reads as None (PDH rate counters need two collections).
#[cfg(windows)]
struct GpuCounter {
query: windows::Win32::System::Performance::PDH_HQUERY,
counter: windows::Win32::System::Performance::PDH_HCOUNTER,
@@ -304,8 +306,10 @@ struct GpuCounter {
// PDH handles are plain registry-ish handles; the query is only touched
// under the DiagState mutex.
#[cfg(windows)]
unsafe impl Send for GpuCounter {}
#[cfg(windows)]
impl GpuCounter {
fn open() -> Option<GpuCounter> {
use windows::core::w;
@@ -383,6 +387,7 @@ impl GpuCounter {
}
/// Parse the pid out of a "pid_1234_luid_…" counter-instance name.
#[cfg(windows)]
unsafe fn instance_pid(name: windows::core::PWSTR) -> Option<u32> {
let s = name.to_string().ok()?;
let rest = s.strip_prefix("pid_")?;
@@ -390,6 +395,7 @@ unsafe fn instance_pid(name: windows::core::PWSTR) -> Option<u32> {
digits.parse().ok()
}
#[cfg(windows)]
impl Drop for GpuCounter {
fn drop(&mut self) {
use windows::Win32::System::Performance::PdhCloseQuery;
@@ -398,3 +404,23 @@ impl Drop for GpuCounter {
}
}
}
// Off Windows there is no PDH and no process-memory API of this shape: the
// overlay's private-commit column reads 0 and the GPU column stays None.
#[cfg(not(windows))]
fn private_commit(_pid: u32) -> u64 {
0
}
#[cfg(not(windows))]
struct GpuCounter;
#[cfg(not(windows))]
impl GpuCounter {
fn open() -> Option<GpuCounter> {
None
}
fn sample<T: ?Sized>(&mut self, _tree: &T) -> Option<(f32, f32)> {
None
}
}
+31
View File
@@ -1,3 +1,7 @@
/// Publishes the JavaVM and the app Context to the `ndk-context` crate at
/// library load, which cpal's AAudio backend needs and tao 0.35 does not do.
#[cfg(target_os = "android")]
mod android_glue;
mod commands;
mod community;
mod config;
@@ -12,6 +16,12 @@ mod inbox;
mod ledger;
mod hero_detector;
mod loudness;
/// The CTC aligner and the vocal separator run through Windows ML; off
/// Windows their stand-ins keep the names and answer "not here" honestly.
#[cfg(windows)]
mod lyric_align;
#[cfg(not(windows))]
#[path = "lyric_align_stub.rs"]
mod lyric_align;
mod lyric_prep;
mod lyrics;
@@ -34,7 +44,17 @@ mod overlap;
mod holdings;
mod state;
mod thumbbar;
/// Release checks and in-place installs are a desktop story; the phone's
/// stand-in keeps the same names so nothing else needs a cfg.
#[cfg(desktop)]
mod updater;
#[cfg(mobile)]
#[path = "updater_mobile.rs"]
mod updater;
#[cfg(windows)]
mod vocal_sep;
#[cfg(not(windows))]
#[path = "vocal_sep_stub.rs"]
mod vocal_sep;
mod youtube_playlist;
mod youtube_setlist;
@@ -56,9 +76,19 @@ pub fn run() {
.build(),
)?;
app.handle().plugin(tauri_plugin_dialog::init())?;
app.handle().plugin(tauri_plugin_media_session::init())?;
// Links out of the app (an artist's socials on the phone): the
// official opener — an Android intent there, the desktop's own
// `open_external` stays for the desktop views.
app.handle().plugin(tauri_plugin_opener::init())?;
// The library folder on the phone: the picker and all-files access.
app.handle().plugin(tauri_plugin_storage::init())?;
#[cfg(desktop)]
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
// Must run before anything touches the app-data dir.
config::migrate_app_data(app.handle());
// The phone keeps its secrets in the app data dir; the helpers need its path.
config::remember_secrets_dir(app.handle());
// Prepare the small local vision model while the user is still on
// the app shell, so a first artist visit pays only warm inference.
hero_detector::warm_up(app.handle().clone());
@@ -245,6 +275,7 @@ pub fn run() {
ytdlp::deno_download,
playlists::playlist_export,
playlists::library_track_search,
playlists::library_track_index,
lyrics::lyrics_get,
lyrics::lyrics_search,
lyrics::lyrics_select,
+56
View File
@@ -0,0 +1,56 @@
//! Lyric alignment, off Windows. The aligner (lyric_align.rs) runs the CTC
//! model through Windows ML, so on Android the model is never present, never
//! downloaded, and a passage never aligns — lyric_prep's own fallback (the
//! line estimate) carries the lyrics instead. This file stands in for the
//! module (`#[path]` in lib.rs) with the same names, so lyric_prep, vocal_sep
//! and the command list need no cfg of their own.
use std::path::PathBuf;
pub(crate) fn models_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = crate::paths::app_data(app)
.map_err(|e| e.to_string())?
.join("lyric-models");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
pub fn model_present(_app: &tauri::AppHandle) -> bool {
false
}
#[tauri::command]
pub fn lyric_model_status(app: tauri::AppHandle) -> serde_json::Value {
serde_json::json!({
"present": false,
"bytes": 0,
"aligner": crate::lyric_prep::active_aligner(&app),
"downloadBytes": 0,
"unsupported": "word alignment runs on the desktop for now",
})
}
#[tauri::command]
pub async fn lyric_model_download(_app: tauri::AppHandle) -> Result<(), String> {
Err("word alignment runs on the desktop for now (Windows ML); not on this platform".into())
}
pub struct AlignedWord {
pub start_ms: i64,
pub end_ms: i64,
}
pub struct AlignedLine {
pub words: Vec<AlignedWord>,
pub confidence: f32,
}
pub fn align_passage(
_app: &tauri::AppHandle,
_pcm: &[f32],
_lines: &[Vec<String>],
_floors_ms: &[i64],
_ceils_ms: &[i64],
) -> Result<Vec<AlignedLine>, String> {
Err("aligner unavailable on this platform".into())
}
+27 -6
View File
@@ -699,11 +699,18 @@ pub async fn playlist_import_folder(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
) -> Result<Option<ImportDraft>, String> {
use tauri_plugin_dialog::DialogExt;
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog().file().pick_folder(move |path| {
let _ = tx.send(path.map(|p| p.to_string()));
});
let (tx, rx) = tokio::sync::oneshot::channel::<Option<String>>();
#[cfg(desktop)]
{
use tauri_plugin_dialog::DialogExt;
app.dialog().file().pick_folder(move |path| {
let _ = tx.send(path.map(|p| p.to_string()));
});
}
#[cfg(mobile)]
{
let _ = tx.send(None);
}
let Some(root) = rx.await.unwrap_or(None) else {
return Ok(None);
};
@@ -1503,6 +1510,19 @@ fn norm_text(s: &str) -> String {
.collect()
}
/// Every track the library knows, in index order — the phone's Tracks tab
/// reads it once and filters on the device.
#[tauri::command]
pub async fn library_track_index(
lib: State<'_, LibraryState>,
) -> Result<Vec<library::TrackIndexRow>, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.track_index())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
/// Local-library track search for the unresolved-row repair panel ("Find
/// match…"). Word-wise normalized substring match over artist/title/album,
/// title hits first. Read-only, bounded to 30 rows.
@@ -1590,7 +1610,8 @@ pub async fn playlist_export(
c => c,
})
.collect();
let (tx, rx) = tokio::sync::oneshot::channel();
let (tx, rx) = tokio::sync::oneshot::channel::<Option<String>>();
// save_file exists on mobile too (the system's create-document sheet)
app.dialog()
.file()
.set_file_name(format!("{safe_name}.{ext}"))
+83
View File
@@ -0,0 +1,83 @@
//! The updater, on a phone: nothing. The phone gets its builds from a store or
//! a sideload, `tauri-plugin-updater` is desktop-only, and the desktop module
//! (updater.rs) leans on it in every function. This file stands in for that
//! module on mobile (`#[path]` in lib.rs) so the command list, the managed
//! state and `init` keep the same names and the rest of the app never needs a
//! `cfg`. Every command answers honestly: there is no update channel here.
use serde::Serialize;
use tauri::{AppHandle, State};
#[derive(Default)]
pub struct UpdaterState;
#[derive(Serialize, Clone)]
pub struct Status {
pub version: String,
pub channel: String,
pub auto_check: bool,
pub endpoint_stable: String,
pub endpoint_nightly: String,
pub default_stable: String,
pub default_nightly: String,
pub has_token: bool,
pub source_kind: String,
pub available: Option<()>,
pub checking: bool,
pub installing: bool,
pub last_check: Option<i64>,
pub last_error: Option<String>,
pub progress: Option<()>,
pub dev_build: bool,
}
fn status(app: &AppHandle) -> Status {
Status {
version: app.package_info().version.to_string(),
channel: "mobile".into(),
auto_check: false,
endpoint_stable: String::new(),
endpoint_nightly: String::new(),
default_stable: String::new(),
default_nightly: String::new(),
has_token: false,
source_kind: "none".into(),
available: None,
checking: false,
installing: false,
last_check: None,
last_error: None,
progress: None,
dev_build: cfg!(debug_assertions),
}
}
pub fn init(_app: &AppHandle) {}
#[tauri::command]
pub fn updater_status(app: AppHandle) -> Status {
status(&app)
}
#[tauri::command]
pub async fn updater_check(app: AppHandle) -> Result<Status, String> {
Ok(status(&app))
}
#[tauri::command]
pub fn updater_set(
app: AppHandle,
_state: State<'_, UpdaterState>,
_channel: Option<String>,
_auto_check: Option<bool>,
_endpoint_stable: Option<String>,
_endpoint_nightly: Option<String>,
_token: Option<String>,
) -> Status {
status(&app)
}
#[tauri::command]
pub async fn updater_install(_app: AppHandle) -> Result<(), String> {
Err("updates are not installed in place on this platform".into())
}
+31
View File
@@ -0,0 +1,31 @@
//! Vocal separation, off Windows. The separator (vocal_sep.rs) runs the MDX
//! model through Windows ML; on Android there is no engine, so the model is
//! never present and a separation always fails soft — lyric_prep falls back
//! to the band-passed mixture, exactly as it does on a desktop without the
//! model. Same names as the real module (`#[path]` in lib.rs).
pub fn model_present(_app: &tauri::AppHandle) -> bool {
false
}
#[tauri::command]
pub fn vocal_sep_status(_app: tauri::AppHandle) -> serde_json::Value {
serde_json::json!({
"present": false,
"bytes": 0,
"downloadBytes": 0,
"unsupported": "vocal separation runs on the desktop for now",
})
}
#[tauri::command]
pub async fn vocal_sep_download(_app: tauri::AppHandle) -> Result<(), String> {
Err("vocal separation runs on the desktop for now (Windows ML); not on this platform".into())
}
pub fn separate_vocals_16k(
_app: &tauri::AppHandle,
_path: &std::path::Path,
) -> Result<Vec<f32>, String> {
Err("separation model unavailable on this platform".into())
}
+15 -12
View File
@@ -33,8 +33,19 @@ const RELEASE_URL: &str =
const DENO_RELEASE_URL: &str =
"https://github.com/denoland/deno/releases/latest/download/deno-x86_64-pc-windows-msvc.zip";
/// Windows: no console window for spawned children.
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// Never flash a console for a helper process on Windows; a no-op elsewhere.
fn quiet(cmd: &mut std::process::Command) -> &mut std::process::Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
}
fn tools_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = crate::paths::app_data(app)
.map_err(|e| e.to_string())?
@@ -61,10 +72,7 @@ pub fn locate(app: &tauri::AppHandle) -> Option<PathBuf> {
}
fn tool_version(exe: &PathBuf) -> Option<String> {
use std::os::windows::process::CommandExt;
let out = std::process::Command::new(exe)
.arg("--version")
.creation_flags(CREATE_NO_WINDOW)
let out = quiet(std::process::Command::new(exe).arg("--version"))
.stdin(Stdio::null())
.output()
.ok()?;
@@ -142,10 +150,7 @@ pub async fn ytdlp_download(app: tauri::AppHandle) -> Result<serde_json::Value,
}
fn deno_version(exe: &PathBuf) -> Option<String> {
use std::os::windows::process::CommandExt;
let out = std::process::Command::new(exe)
.arg("--version")
.creation_flags(CREATE_NO_WINDOW)
let out = quiet(std::process::Command::new(exe).arg("--version"))
.stdin(Stdio::null())
.output()
.ok()?;
@@ -327,7 +332,6 @@ pub fn fetch_audio_blocking(
cancel: &std::sync::atomic::AtomicBool,
) -> Result<PathBuf, String> {
use std::io::BufRead;
use std::os::windows::process::CommandExt;
if let Some(hit) = cached_audio(app, video_id) {
return Ok(hit);
}
@@ -338,7 +342,7 @@ pub fn fetch_audio_blocking(
)?;
let dir = audio_cache_dir(app)?;
let template = dir.join("%(id)s.%(ext)s");
let mut child = std::process::Command::new(&exe)
let mut child = quiet(std::process::Command::new(&exe)
.args([
"--no-playlist",
"--newline",
@@ -350,8 +354,7 @@ pub fn fetch_audio_blocking(
"-o",
&template.to_string_lossy(),
&format!("https://www.youtube.com/watch?v={video_id}"),
])
.creation_flags(CREATE_NO_WINDOW)
]))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
+18
View File
@@ -0,0 +1,18 @@
{
"app": {
"windows": [
{
"label": "main",
"title": "SoulStream",
"url": "mobile.html"
}
]
},
"bundle": {
"resources": [],
"createUpdaterArtifacts": false,
"android": {
"minSdkVersion": 26
}
}
}