Added helpers, fix discord button label overflow, cursor pointer, seek throttle, and more.
+ - find button discord RPC now links to YouTube Music search
- Call backend only on drag end to prevent seek throttle
- All button now should has pointer now
- Discord button label overflow
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
index b9d8e71..6d36e3f 100755
--- a/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -7,6 +7,7 @@ import Foundation
import audio_service
import audio_session
+import device_info_plus
import file_picker
import open_file_mac
import screen_retriever_macos
@@ -17,6 +18,7 @@ import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
+ DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
diff --git a/pubspec.lock b/pubspec.lock
index 77899b9..7b79501 100755
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -272,6 +272,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.12"
+ device_info_plus:
+ dependency: "direct main"
+ description:
+ name: device_info_plus
+ sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
+ url: "https://pub.dev"
+ source: hosted
+ version: "13.1.0"
+ device_info_plus_platform_interface:
+ dependency: transitive
+ description:
+ name: device_info_plus_platform_interface
+ sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
+ url: "https://pub.dev"
+ source: hosted
+ version: "8.1.0"
drift:
dependency: transitive
description:
@@ -304,6 +320,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
+ ffi_leak_tracker:
+ dependency: transitive
+ description:
+ name: ffi_leak_tracker
+ sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.1.2"
file:
dependency: transitive
description:
@@ -316,10 +340,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
- sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387
+ sha256: "51093fc49e4d58935998fb112593c23eda571d14b488388bd41d5d2ba332b26a"
url: "https://pub.dev"
source: hosted
- version: "11.0.2"
+ version: "12.0.0-beta.3"
fixnum:
dependency: transitive
description:
@@ -750,6 +774,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
+ permission_handler:
+ dependency: "direct main"
+ description:
+ name: permission_handler
+ sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
+ url: "https://pub.dev"
+ source: hosted
+ version: "12.0.1"
+ permission_handler_android:
+ dependency: transitive
+ description:
+ name: permission_handler_android
+ sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
+ url: "https://pub.dev"
+ source: hosted
+ version: "13.0.1"
+ permission_handler_apple:
+ dependency: transitive
+ description:
+ name: permission_handler_apple
+ sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
+ url: "https://pub.dev"
+ source: hosted
+ version: "9.4.7"
+ permission_handler_html:
+ dependency: transitive
+ description:
+ name: permission_handler_html
+ sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.1.3+5"
+ permission_handler_platform_interface:
+ dependency: transitive
+ description:
+ name: permission_handler_platform_interface
+ sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.3.0"
+ permission_handler_windows:
+ dependency: transitive
+ description:
+ name: permission_handler_windows
+ sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.2.1"
petitparser:
dependency: transitive
description:
@@ -1263,10 +1335,18 @@ packages:
dependency: transitive
description:
name: win32
- sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
+ sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9
url: "https://pub.dev"
source: hosted
- version: "5.15.0"
+ version: "6.2.0"
+ win32_registry:
+ dependency: transitive
+ description:
+ name: win32_registry
+ sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.3"
window_manager:
dependency: "direct main"
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 00e727e..db0d74e 100755
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -25,7 +25,7 @@ dependencies:
json_annotation: ^4.9.0
# File picking
- file_picker: ^11.0.2
+ file_picker: ^12.0.0-beta.3
# Audio focus / media keys (mobile)
audio_service: ^0.18.13
@@ -38,6 +38,8 @@ dependencies:
http: ^1.6.0
crypto: ^3.0.7
open_file: ^3.5.11
+ device_info_plus: ^13.1.0
+ permission_handler: ^12.0.1
dev_dependencies:
flutter_test:
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index e516cbb..53938d8 100755
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -116,9 +116,11 @@ dependencies = [
"discord-presence",
"flutter_rust_bridge",
"image",
+ "jni",
"lofty",
"log",
"lru",
+ "ndk-context",
"ringbuf",
"rubato",
"symphonia",
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index 05ddab6..b812a60 100755
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -39,6 +39,10 @@ biquad = "0.6.0"
image = "0.25.10"
lru = "0.18.0"
+[target.'cfg(target_os = "android")'.dependencies]
+ndk-context = "0.1"
+jni = { version = "0.21", default-features = false }
+
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.62", features = [
"Win32_Media_Audio",
diff --git a/rust/src/audio_engine.rs b/rust/src/audio_engine.rs
index 6433082..a32ae5e 100755
--- a/rust/src/audio_engine.rs
+++ b/rust/src/audio_engine.rs
@@ -175,9 +175,20 @@ impl AudioEngine {
eq: Arc::new(Mutex::new(Equalizer::new(sr, ch))),
decode_thread_died: Arc::new(AtomicBool::new(false)),
};
- ENGINE
- .set(Arc::new(Mutex::new(engine)))
- .map_err(|_| anyhow!("Already initialized"))
+ let arc = Arc::new(Mutex::new(engine));
+ if ENGINE.set(arc.clone()).is_err() {
+ if let Some(existing) = ENGINE.get() {
+ let mut e = existing.lock().unwrap();
+ let new_e = arc.lock().unwrap();
+ unsafe {
+ let new_ptr = &*new_e as *const AudioEngine as *mut AudioEngine;
+ let old_ptr = &mut *e as *mut AudioEngine;
+ std::ptr::swap(old_ptr, new_ptr);
+ }
+ logger::info_audio("AudioEngine re-initialized in place");
+ }
+ }
+ Ok(())
}
// Accessors
diff --git a/rust/src/discord_rpc.rs b/rust/src/discord_rpc.rs
index a831e96..af0d1ea 100755
--- a/rust/src/discord_rpc.rs
+++ b/rust/src/discord_rpc.rs
@@ -104,6 +104,33 @@ pub fn update_playing(
let find_artist = truncate(title, 27);
+ let base = "https://music.youtube.com/search?q=";
+ let query = format!("{title} {artist}");
+ let encoded: String = query
+ .chars()
+ .map(|c| match c {
+ 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
+ ' ' => "+".to_string(),
+ c => c
+ .to_string()
+ .as_bytes()
+ .iter()
+ .map(|b| format!("%{b:02X}"))
+ .collect(),
+ })
+ .collect();
+ let max_encoded = 512 - base.len();
+ let encoded = if encoded.len() > max_encoded {
+ let trimmed = &encoded[..max_encoded];
+ match trimmed.rfind('%') {
+ Some(i) if i + 3 > max_encoded => trimmed[..i].to_string(),
+ _ => trimmed.to_string(),
+ }
+ } else {
+ encoded
+ };
+ let ytm_query = format!("{base}{encoded}");
+
let large_img = match album_art_url {
Some(url) if !url.is_empty() && is_direct_image_url(url) => url,
_ => "aqloss",
@@ -123,11 +150,7 @@ pub fn update_playing(
.state(&state_str)
.details(&title_truncated)
.timestamps(|t| t.start(start_ts).end(end_ts))
- .append_buttons(|button| {
- button
- .label(format!("Find {find_artist}"))
- .url("https://google.com")
- })
+ .append_buttons(|button| button.label(format!("Find {find_artist}")).url(&ytm_query))
.append_buttons(|button| {
button
.label("Listen with Aqloss")
diff --git a/rust/src/lib.rs b/rust/src/lib.rs
index 915889d..1dd81cc 100755
--- a/rust/src/lib.rs
+++ b/rust/src/lib.rs
@@ -11,6 +11,24 @@ pub mod resampler;
use flutter_rust_bridge::frb;
+#[cfg(target_os = "android")]
+#[no_mangle]
+pub unsafe extern "C" fn Java_xyz_nokarin_aqloss_MainActivity_initAudioContext(
+ mut env: jni::JNIEnv,
+ _class: jni::objects::JClass,
+ ctx: jni::objects::JObject,
+) {
+ let vm = env.get_java_vm().expect("initAudioContext: get_java_vm");
+ let ctx_global = env
+ .new_global_ref(ctx)
+ .expect("initAudioContext: new_global_ref");
+ ndk_context::initialize_android_context(
+ vm.get_java_vm_pointer().cast(),
+ ctx_global.as_raw().cast(),
+ );
+ std::mem::forget(ctx_global);
+}
+
#[frb(dart_metadata = ("freezed"))]
pub struct TrackInfo {
pub path: String,
diff --git a/rust/src/logger.rs b/rust/src/logger.rs
index 15bd121..0c6f62b 100644
--- a/rust/src/logger.rs
+++ b/rust/src/logger.rs
@@ -29,6 +29,7 @@ impl Level {
// Internal state
struct Logger {
audio: Mutex,
+ output: Mutex,
discord: Mutex,
}
@@ -78,6 +79,7 @@ pub fn init() {
let logger = Logger {
audio: Mutex::new(open_log(&dir, "audio.log")),
+ output: Mutex::new(open_log(&dir, "output.log")),
discord: Mutex::new(open_log(&dir, "discord_rpc.log")),
};
@@ -130,6 +132,7 @@ fn timestamp() -> String {
// Core write
enum Target {
Audio,
+ Output,
Discord,
}
@@ -150,6 +153,7 @@ fn write(target: Target, level: Level, msg: &str) {
let file = match target {
Target::Audio => &logger.audio,
+ Target::Output => &logger.output,
Target::Discord => &logger.discord,
};
@@ -206,8 +210,31 @@ pub fn error_discord(msg: impl AsRef) {
#[macro_export]
macro_rules! debug_discord { ($($arg:tt)*) => { $crate::logger::debug_discord(format!($($arg)*)) }; }
#[macro_export]
-macro_rules! info_discord { ($($arg:tt)*) => { $crate::logger::info_discord (format!($($arg)*)) }; }
+macro_rules! info_discord { ($($arg:tt)*) => { $crate::logger::info_discord(format!($($arg)*)) }; }
#[macro_export]
-macro_rules! warn_discord { ($($arg:tt)*) => { $crate::logger::warn_discord (format!($($arg)*)) }; }
+macro_rules! warn_discord { ($($arg:tt)*) => { $crate::logger::warn_discord(format!($($arg)*)) }; }
#[macro_export]
macro_rules! error_discord { ($($arg:tt)*) => { $crate::logger::error_discord(format!($($arg)*)) }; }
+
+// output
+pub fn debug_output(msg: impl AsRef) {
+ write(Target::Output, Level::Debug, msg.as_ref());
+}
+pub fn info_output(msg: impl AsRef) {
+ write(Target::Output, Level::Info, msg.as_ref());
+}
+pub fn warn_output(msg: impl AsRef) {
+ write(Target::Output, Level::Warn, msg.as_ref());
+}
+pub fn error_output(msg: impl AsRef) {
+ write(Target::Output, Level::Error, msg.as_ref());
+}
+
+#[macro_export]
+macro_rules! debug_output { ($($arg:tt)*) => { $crate::logger::debug_output(format!($($arg)*)) }; }
+#[macro_export]
+macro_rules! info_output { ($($arg:tt)*) => { $crate::logger::info_output(format!($($arg)*)) }; }
+#[macro_export]
+macro_rules! warn_output { ($($arg:tt)*) => { $crate::logger::warn_output(format!($($arg)*)) }; }
+#[macro_export]
+macro_rules! error_output { ($($arg:tt)*) => { $crate::logger::error_output(format!($($arg)*)) }; }
diff --git a/rust/src/output.rs b/rust/src/output.rs
index adf2304..6b1f904 100755
--- a/rust/src/output.rs
+++ b/rust/src/output.rs
@@ -36,7 +36,9 @@ impl AudioOutput {
if let Ok(exc) = wasapi_exclusive::ExclusiveStream::open_default() {
return Ok(Self::from_exclusive(exc));
}
- eprintln!("[aqloss] WASAPI exclusive not available on default device, using shared");
+ crate::logger::warn_output(
+ "[aqloss] WASAPI exclusive not available on default device, using shared",
+ );
}
Self::new_cpal_shared(None)
}
@@ -78,11 +80,13 @@ impl AudioOutput {
});
match found {
Some(d) => {
- eprintln!("[aqloss] output device: {id}");
+ crate::logger::info_output(format!("[aqloss] output device: {id}"));
d
}
None => {
- eprintln!("[aqloss] device '{id}' not found, using system default");
+ crate::logger::warn_output(format!(
+ "[aqloss] device '{id}' not found, using system default"
+ ));
host.default_output_device()
.ok_or_else(|| anyhow!("No audio output device found"))?
}
@@ -93,13 +97,23 @@ impl AudioOutput {
.ok_or_else(|| anyhow!("No audio output device found"))?,
};
- let supported = device.default_output_config()?;
+ let supported = device.default_output_config().map_err(|e| {
+ crate::logger::error_output(format!("[aqloss] default_output_config failed: {e}"));
+ e
+ })?;
let sample_rate: u32 = supported.sample_rate();
let channels: u32 = supported.channels() as u32;
+ crate::logger::info_output(format!(
+ "[aqloss] opening stream: {sample_rate}Hz {channels}ch"
+ ));
+
let config = cpal::StreamConfig {
channels: supported.channels(),
sample_rate: supported.sample_rate(),
+ #[cfg(target_os = "android")]
+ buffer_size: cpal::BufferSize::Default,
+ #[cfg(not(target_os = "android"))]
buffer_size: cpal::BufferSize::Fixed(CPAL_BUFFER_FRAMES as u32),
};
@@ -111,33 +125,41 @@ impl AudioOutput {
let draining = Arc::new(AtomicBool::new(false));
let draining_cb = draining.clone();
- let stream = device.build_output_stream(
- &config,
- move |output: &mut [f32], _info| {
- if draining_cb.load(Ordering::Relaxed) {
- let avail = cons.occupied_len();
- let mut tmp = vec![0f32; avail];
- cons.pop_slice(&mut tmp);
- output.fill(0.0);
- } else {
- let n = cons.occupied_len().min(output.len());
- cons.pop_slice(&mut output[..n]);
- output[n..].fill(0.0);
- }
- },
- |err| eprintln!("[cpal] stream error: {err}"),
- None,
- )?;
-
- stream.play()?;
-
- eprintln!(
+ let stream = device
+ .build_output_stream(
+ &config,
+ move |output: &mut [f32], _info| {
+ if draining_cb.load(Ordering::Relaxed) {
+ let avail = cons.occupied_len();
+ let mut tmp = vec![0f32; avail];
+ cons.pop_slice(&mut tmp);
+ output.fill(0.0);
+ } else {
+ let n = cons.occupied_len().min(output.len());
+ cons.pop_slice(&mut output[..n]);
+ output[n..].fill(0.0);
+ }
+ },
+ |err| crate::logger::error_output(format!("[cpal] stream error: {err}")),
+ None,
+ )
+ .map_err(|e| {
+ crate::logger::error_output(format!("[aqloss] build_output_stream failed: {e}"));
+ anyhow::anyhow!(e)
+ })?;
+
+ stream.play().map_err(|e| {
+ crate::logger::error_output(format!("[aqloss] stream.play() failed: {e}"));
+ anyhow::anyhow!(e)
+ })?;
+
+ crate::logger::info_output(format!(
"[aqloss] shared-mode: {} @ {}Hz {}ch (buffer={} frames)",
device_id.unwrap_or("default"),
sample_rate,
channels,
CPAL_BUFFER_FRAMES
- );
+ ));
Ok(Self {
_stream: AudioStream::Cpal(stream),
@@ -319,10 +341,10 @@ pub mod wasapi_exclusive {
{
chosen_sr = sr;
chosen_ch = ch;
- eprintln!(
+ crate::logger::info_output(format!(
"[wasapi-exclusive] format: {}Hz {}ch f32 on device {}",
sr, ch, device_id
- );
+ ));
break;
}
}
@@ -379,7 +401,10 @@ pub mod wasapi_exclusive {
alive: Arc,
draining: Arc,
) {
- eprintln!("[wasapi-exclusive] audio thread started for {}", device_id);
+ crate::logger::info_output(format!(
+ "[wasapi-exclusive] audio thread started for {}",
+ device_id
+ ));
unsafe {
let _ = CoInitializeEx(None, COINIT_MULTITHREADED).ok();
@@ -387,7 +412,9 @@ pub mod wasapi_exclusive {
let (audio_client, render_client, buffer_frames, event) = match result {
Ok(r) => r,
Err(e) => {
- eprintln!("[wasapi-exclusive] thread setup failed: {e}");
+ crate::logger::error_output(format!(
+ "[wasapi-exclusive] thread setup failed: {e}"
+ ));
CoUninitialize();
return;
}
@@ -405,7 +432,7 @@ pub mod wasapi_exclusive {
let buf_ptr = match render_client.GetBuffer(buffer_frames as u32) {
Ok(p) => p,
Err(e) => {
- eprintln!("[wasapi-exclusive] GetBuffer: {e}");
+ crate::logger::warn_output(format!("[wasapi-exclusive] GetBuffer: {e}"));
break;
}
};
@@ -460,10 +487,10 @@ pub mod wasapi_exclusive {
Err(ref e) if e.code().0 as u32 == AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED => {
let aligned_frames = audio_client.GetBufferSize()? as u64;
let aligned_dur = (aligned_frames * 10_000_000) / sample_rate as u64;
- eprintln!(
+ crate::logger::info_output(format!(
"[wasapi-exclusive] alignment fix: {} frames",
aligned_frames
- );
+ ));
let wide2 = to_wide(device_id);
let device2 = enumerator.GetDevice(PCWSTR::from_raw(wide2.as_ptr()))?;
let ac2: IAudioClient = device2.Activate(CLSCTX_ALL, None)?;
@@ -485,7 +512,9 @@ pub mod wasapi_exclusive {
let render_client: IAudioRenderClient = audio_client.GetService()?;
let buffer_frames = audio_client.GetBufferSize()? as usize;
audio_client.Start()?;
- eprintln!("[wasapi-exclusive] started, buffer_frames={buffer_frames}");
+ crate::logger::info_output(format!(
+ "[wasapi-exclusive] started, buffer_frames={buffer_frames}"
+ ));
Ok((audio_client, render_client, buffer_frames, event))
}
diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc
index c6fe39a..d014922 100755
--- a/windows/flutter/generated_plugin_registrant.cc
+++ b/windows/flutter/generated_plugin_registrant.cc
@@ -6,10 +6,13 @@
#include "generated_plugin_registrant.h"
+#include
#include
#include
void RegisterPlugins(flutter::PluginRegistry* registry) {
+ PermissionHandlerWindowsPluginRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
WindowManagerPluginRegisterWithRegistrar(
diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake
index b88734a..3b5d82b 100755
--- a/windows/flutter/generated_plugins.cmake
+++ b/windows/flutter/generated_plugins.cmake
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
+ permission_handler_windows
screen_retriever_windows
window_manager
)