From af9a660231be6ff92f1d3939a2287aeb1f9f0908 Mon Sep 17 00:00:00 2001 From: imsyy Date: Mon, 25 May 2026 11:24:04 +0800 Subject: [PATCH 1/5] =?UTF-8?q?audio-engine=20=E5=88=87=E6=8D=A2=20ffmpeg-?= =?UTF-8?q?next=20=E5=88=B0=20ffmpeg=5Faudio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 18 +- Cargo.lock | 189 +++++++-- native/audio-engine/Cargo.toml | 8 +- native/audio-engine/src/decoder.rs | 511 ++++--------------------- native/audio-engine/src/http_source.rs | 221 +++++++++++ native/audio-engine/src/lib.rs | 7 +- native/audio-engine/src/metadata.rs | 138 +++---- native/audio-engine/src/player.rs | 2 +- native/audio-engine/src/scanner.rs | 62 ++- 9 files changed, 548 insertions(+), 608 deletions(-) create mode 100644 native/audio-engine/src/http_source.rs diff --git a/CLAUDE.md b/CLAUDE.md index 9e6081df..4310f7e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,21 +20,7 @@ pnpm build:native # Rust only; add `-- --dev` for debug `SKIP_NATIVE_BUILD=true` skips Rust during dev. -### FFmpeg Setup (first-time native build) - -`audio-engine` static-links FFmpeg. Before first `pnpm dev` / `pnpm build:native`, download FFmpeg static libs (with `include` and `lib`) and set: - -```bash -# macOS / Linux -export FFMPEG_DIR=/path/to/ffmpeg -export PKG_CONFIG_PATH="$FFMPEG_DIR/lib/pkgconfig" -``` - -```powershell -# Windows -$env:FFMPEG_DIR="D:\ffmpeg" -$env:PKG_CONFIG_PATH="$env:FFMPEG_DIR\lib\pkgconfig" -``` +`audio-engine` static-links FFmpeg via the `ffmpeg_audio` crate (vendor zip + cc-built at compile time). Zero environment dependency — no `FFMPEG_DIR` / `PKG_CONFIG_PATH`, no system FFmpeg required. ## Architecture @@ -49,7 +35,7 @@ $env:PKG_CONFIG_PATH="$env:FFMPEG_DIR\lib\pkgconfig" Three `.node` modules in `native/`, built via `scripts/build-native.ts`, lazy-loaded by `electron/main/utils/nativeLoader.ts`. NAPI-RS auto-generates `index.d.ts`, imported via path aliases `@splayer/audio-engine`, `@splayer/media-ctrl`, `@splayer/taskbar-lyric`. -- `audio-engine` — FFmpeg decode + rodio playback + FFT + cover extraction. Pushes events (state/position/ended/outputStalled) via ThreadsafeFunction. Has load_token race protection and `AVIOInterruptCB` for instant stop on blocking IO. +- `audio-engine` — `ffmpeg_audio` decode (static FFmpeg) + rodio playback + FFT + cover extraction. URLs wrapped as `Read + Seek` via `HttpRangeSource` (ureq + rustls) — TLS handled in Rust, cross-platform with no system deps. Pushes events (state/position/ended/outputStalled) via ThreadsafeFunction. Has load_token race protection and a cancel flag (injected into `HttpRangeSource`) for instant stop on blocking IO. - `media-ctrl` — Cross-platform system media controls (Windows SMTC / Linux MPRIS / macOS MPNowPlaying) + Discord RPC. - `taskbar-lyric` — Windows taskbar lyric text rendering with RegistryWatcher / UiaWatcher / TrayWatcher. diff --git a/Cargo.lock b/Cargo.lock index c6dfc2ce..afbf0c17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,7 +188,7 @@ version = "0.1.0" dependencies = [ "anyhow", "cpal", - "ffmpeg-next", + "ffmpeg_audio", "image", "napi", "napi-build", @@ -202,6 +202,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "ureq", "walkdir", ] @@ -211,6 +212,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bindgen" version = "0.70.1" @@ -241,6 +248,8 @@ dependencies = [ "cexpr", "clang-sys", "itertools", + "log", + "prettyplease", "proc-macro2", "quote", "regex", @@ -733,28 +742,26 @@ dependencies = [ ] [[package]] -name = "ffmpeg-next" -version = "8.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d" +name = "ffmpeg_audio" +version = "0.1.0" +source = "git+https://github.com/SPlayer-Dev/ffmpeg-audio?branch=feat%2Fduration-api#4743d479c43595382873306a63ccb2420d2afda2" dependencies = [ - "bitflags 2.11.0", - "ffmpeg-sys-next", + "ffmpeg_audio_sys", "libc", + "thiserror 2.0.18", ] [[package]] -name = "ffmpeg-sys-next" -version = "8.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a314bc0e022a33a99567ed4bd2576bd58ffd8fcff7891c29194cfecc26a62547" +name = "ffmpeg_audio_sys" +version = "0.1.0" +source = "git+https://github.com/SPlayer-Dev/ffmpeg-audio?branch=feat%2Fduration-api#4743d479c43595382873306a63ccb2420d2afda2" dependencies = [ "bindgen 0.72.1", "cc", "libc", - "num_cpus", "pkg-config", "vcpkg", + "zip", ] [[package]] @@ -771,6 +778,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1523,16 +1531,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "num_enum" version = "0.7.6" @@ -1989,6 +1987,20 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rodio" version = "0.20.1" @@ -2037,6 +2049,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2194,6 +2241,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -2487,6 +2540,12 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "uds_windows" version = "1.2.1" @@ -2516,6 +2575,27 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -2699,6 +2779,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -2847,6 +2945,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -3222,6 +3329,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" @@ -3255,12 +3368,44 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/native/audio-engine/Cargo.toml b/native/audio-engine/Cargo.toml index 8eb7634f..20df67e3 100644 --- a/native/audio-engine/Cargo.toml +++ b/native/audio-engine/Cargo.toml @@ -9,12 +9,8 @@ crate-type = ["cdylib"] [dependencies] napi = { version = "3.8", features = ["napi9", "serde-json", "tokio_rt"] } napi-derive = "3.5" -ffmpeg-next = { version = "8", default-features = false, features = [ - "codec", - "format", - "software-resampling", - "static", -] } +ffmpeg_audio = { git = "https://github.com/SPlayer-Dev/ffmpeg-audio", branch = "feat/duration-api", default-features = false } +ureq = { version = "2", default-features = false, features = ["tls"] } rodio = { version = "0.20", default-features = false } cpal = "0.15" parking_lot = "0.12" diff --git a/native/audio-engine/src/decoder.rs b/native/audio-engine/src/decoder.rs index 1ee27213..679607b3 100644 --- a/native/audio-engine/src/decoder.rs +++ b/native/audio-engine/src/decoder.rs @@ -1,202 +1,83 @@ -use std::ffi::c_void; -use std::os::raw::c_int; +use std::fs::File; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread::{self, JoinHandle}; +use std::time::Duration; use anyhow::{Context, Result}; -use ffmpeg_next as ffmpeg; -use ffmpeg_next::ChannelLayout; -use ffmpeg_next::Dictionary; -use tracing::{debug, warn}; +use ffmpeg_audio::{AudioError, AudioReader}; +use tracing::warn; +use crate::http_source; use crate::loudness::LoudnessAnalyzer; use crate::metadata; use crate::shared::{AudioChunk, AudioMetadata, Shared}; -/// 播放输出目标格式 +/// 播放输出目标格式(重采样后送入 rodio) pub const TARGET_SAMPLE_RATE: u32 = 48000; pub const TARGET_CHANNELS: u16 = 2; -/// FFT 分析目标格式:单声道 44100Hz -pub const FFT_SAMPLE_RATE: u32 = 44100; -/// 网络流读取失败最大重试次数(ffmpeg 自带 reconnect_delay_max 已在内部重试, -/// 这里只是兜底防止 reconnect 不生效时立即放弃;次数从 3 降到 1 避免与 ffmpeg 重叠) -const NETWORK_READ_RETRIES: u32 = 1; -/// 重试间隔(毫秒) -const NETWORK_RETRY_DELAY_MS: u64 = 500; -/// HTTP 读写超时(微秒),15s。无此项时 ffmpeg HTTP 默认无限阻塞 -const HTTP_RW_TIMEOUT_US: &str = "15000000"; - - -/// 中断回调上下文:ffmpeg 内部线程会按 opaque 指针解引用,必须比 input_ctx 活得久 -/// 通过 Box 持有;DecoderData 把它放在 input_ctx 之后保证 drop 顺序 -struct InterruptCtx { - flag: Arc, -} - -/// ffmpeg 中断回调函数:返回非零让 ffmpeg 立即 abort 当前 IO(返回 AVERROR_EXIT) -/// 在 ffmpeg 的 IO 线程被周期性调用,必须 reentrant + 极快 -extern "C" fn ffmpeg_interrupt_callback(opaque: *mut c_void) -> c_int { - if opaque.is_null() { - return 0; - } - // SAFETY: opaque 指向的 Box 在 DecoderData 生命周期内有效 - let ctx = unsafe { &*(opaque as *const InterruptCtx) }; - if ctx.flag.load(Ordering::Acquire) { - 1 - } else { - 0 - } -} - -/// 解码会话所需的资源(跨 seek 复用,避免重建 FFmpeg 上下文) +/// 解码会话所需的资源(跨 seek 复用,避免重建 ffmpeg_audio 上下文) pub struct DecoderData { - /// 注意 drop 顺序:input_ctx 必须在 _interrupt_ctx 之前被 drop - /// (字段按声明顺序 drop;input_ctx 关闭时 ffmpeg 不再回调,再释放 ctx 才安全) - input_ctx: ffmpeg::format::context::Input, - decoder: ffmpeg::decoder::Audio, - audio_stream_index: usize, - resampler: Option, - fft_resampler: Option, - /// 中断标志的 Arc(与 _interrupt_ctx 内的同一 Arc);resume_decode 需要 clone 给新 shared - interrupt_flag: Arc, - /// 中断回调上下文(保活用),drop 时机晚于 input_ctx - _interrupt_ctx: Box, + reader: AudioReader, + /// 中断标志:仅网络源持有;本地 File 不会长时间阻塞,没必要绑 + /// 通过 shared.bind_interrupt 注入,外部 stop() 触发后 HttpRangeSource::read 会返回 Interrupted + interrupt_flag: Option>, } impl DecoderData { - /// 在已有上下文上 seek + flush,不重新打开文件 - /// 返回 false 表示 seek 失败,调用方应回退到完整 load + /// 在已有 reader 上 seek,失败时调用方应回退到完整 load pub fn seek(&mut self, position_secs: f64) -> bool { - let ts = (position_secs * ffmpeg::ffi::AV_TIME_BASE as f64) as i64; - // 优先 ..ts(max_ts=ts,只接受 ≤ts 的关键帧),失败再退到无约束 - // 反过来在 mp3/aac 上可能定位到 >ts 的帧导致 seek 后多跳一帧 - let ok = self.input_ctx.seek(ts, ..ts).is_ok() - || self.input_ctx.seek(ts, ..).is_ok(); - if ok { - // 清空解码器内部缓冲区 - // SAFETY: decoder 内部持有有效的 AVCodecContext 指针 - unsafe { - ffmpeg::ffi::avcodec_flush_buffers(self.decoder.as_mut_ptr()); - } - } - ok + self.reader + .seek(Duration::from_secs_f64(position_secs)) + .is_ok() } /// 清除中断标志:seek 路径在 join 旧解码线程后调用一次,避免 stop 信号导致 seek 自爆 pub fn reset_interrupt(&self) { - self.interrupt_flag.store(false, Ordering::Release); + if let Some(ref flag) = self.interrupt_flag { + flag.store(false, Ordering::Release); + } } - /// 拿中断标志的 Arc clone:resume_decode 后需把它绑定到新 shared - pub fn interrupt_handle(&self) -> Arc { - Arc::clone(&self.interrupt_flag) - } -} - -/// 把中断回调装到刚打开的 input 上,返回中断标志 + 上下文 Box -/// 标志由调用方保管 Arc,写 true 即可让 ffmpeg 中断 -fn install_interrupt(input: &mut ffmpeg::format::context::Input) -> (Arc, Box) { - let flag = Arc::new(AtomicBool::new(false)); - let ctx = Box::new(InterruptCtx { - flag: Arc::clone(&flag), - }); - let opaque = ctx.as_ref() as *const InterruptCtx as *mut c_void; - // SAFETY: input.as_mut_ptr() 返回有效的 AVFormatContext 指针; - // opaque 指向的 Box 由 DecoderData 持有,drop 顺序保证早于 input_ctx 释放后才回收 - unsafe { - let raw = input.as_mut_ptr(); - (*raw).interrupt_callback.callback = Some(ffmpeg_interrupt_callback); - (*raw).interrupt_callback.opaque = opaque; + /// 拿中断标志的 Arc clone:resume_decode 后需把它绑到新 shared + pub fn interrupt_handle(&self) -> Option> { + self.interrupt_flag.clone() } - (flag, ctx) } -// SAFETY: DecoderData 的所有字段(FFmpeg C 指针的 Rust wrapper) -// 仅被单一线程独占使用,在 spawn 时 move 进线程,不存在并发访问。 +// SAFETY: AudioReader 内部持有 ffmpeg C 指针,仅被解码线程独占使用,spawn 时 move 进线程 unsafe impl Send for DecoderData {} -/// 启动解码线程,返回音频元数据和线程句柄。 +/// 启动解码线程,返回音频元数据和线程句柄 /// /// 线程结束时返回 `DecoderData`,调用方可通过 `handle.join()` 回收并复用于后续 seek, -/// 避免重建 FFmpeg 上下文。 -/// -/// - `cover_cache_dir`: 封面缓存目录,传 Some 时提取封面并写入缓存 +/// 避免重建 ffmpeg_audio 上下文。 pub fn start_decode( source: &str, shared: Arc, cover_cache_dir: Option<&str>, ) -> Result<(AudioMetadata, JoinHandle)> { - let mut input_ctx = open_input(source)?; - // 装中断回调:之后 stop 即可让阻塞中的 read_frame / seek_file 立即返回 - // 注:open_input 自身的 avformat_open_input 不在保护范围内(回调是 open 后才装的), - // 这一步靠 rw_timeout=15s 兜底 - let (interrupt_flag, interrupt_ctx) = install_interrupt(&mut input_ctx); - shared.bind_interrupt(Arc::clone(&interrupt_flag)); - - let stream = input_ctx - .streams() - .best(ffmpeg::media::Type::Audio) - .context("No audio stream found")?; - let audio_stream_index = stream.index(); - - // 时长:优先从流的 time_base 获取 - let time_base = stream.time_base(); - let stream_duration = stream.duration(); - let duration_secs = if stream_duration > 0 { - stream_duration as f64 * time_base.0 as f64 / time_base.1 as f64 - } else if input_ctx.duration() > 0 { - input_ctx.duration() as f64 / f64::from(ffmpeg::ffi::AV_TIME_BASE) - } else { - 0.0 - }; - - let tags = metadata::extract_tags(&input_ctx); + let (reader, interrupt_flag) = open_source(source)?; + if let Some(ref flag) = interrupt_flag { + shared.bind_interrupt(Arc::clone(flag)); + } - // SAFETY: input_ctx 在此作用域内有效,stream 引用自 input_ctx - let stream_info = unsafe { metadata::extract_stream_info(&stream, &input_ctx) }; - let codec = ffmpeg::codec::decoder::find(stream.parameters().id()) - .map(|c| c.name().to_string()) - .unwrap_or_default(); + let info = reader.source_info(); + let duration_secs = reader.duration().map(|d| d.as_secs_f64()).unwrap_or(0.0); + let stream_info = metadata::extract_stream_info(info); + let codec = info.codec_name.clone(); + let raw_metadata = reader.metadata(); + let tags = metadata::extract_tags(&raw_metadata); let cover = - cover_cache_dir.and_then(|dir| metadata::extract_cover_thumbnail(&input_ctx, source, dir)); - let cover_raw = metadata::read_attached_pic(&input_ctx); - let embedded_lyric = metadata::extract_embedded_lyric(&input_ctx); + cover_cache_dir.and_then(|dir| metadata::extract_cover_thumbnail(&reader, source, dir)); + let cover_raw = metadata::read_attached_pic(&reader); + let embedded_lyric = metadata::extract_embedded_lyric(&raw_metadata); let external_lyrics = metadata::find_all_external_lyrics(source); - let replay_gain_db = metadata::extract_replay_gain(&input_ctx); - - let decoder_ctx = ffmpeg::codec::context::Context::from_parameters(stream.parameters())?; - let decoder = decoder_ctx.decoder().audio()?; - - let src_format = decoder.format(); - let src_layout = decoder.channel_layout(); - let src_rate = decoder.rate(); - - // 使用平面 F32 格式输出(与参考实现一致) - let target_format = ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar); - let target_layout = ChannelLayout::default(TARGET_CHANNELS as i32); - - let resampler = create_resampler( - src_format, - src_layout, - src_rate, - target_format, - target_layout, - TARGET_SAMPLE_RATE, - )?; + let replay_gain_db = metadata::extract_replay_gain(&raw_metadata); - let fft_resampler = create_resampler( - src_format, - src_layout, - src_rate, - ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar), - ChannelLayout::MONO, - FFT_SAMPLE_RATE, - )?; - - // 设置归一化增益:有 ReplayGain tag 时直接使用,否则由实时分析器动态计算 + // 有 ReplayGain tag 时设固定增益,否则由实时分析器动态计算 if let Some(db) = replay_gain_db { shared.set_normalization_gain(metadata::db_to_linear(db)); } @@ -220,13 +101,8 @@ pub fn start_decode( }; let data = DecoderData { - input_ctx, - decoder, - audio_stream_index, - resampler, - fft_resampler, + reader, interrupt_flag, - _interrupt_ctx: interrupt_ctx, }; let handle = thread::spawn(move || { @@ -243,9 +119,10 @@ pub fn start_decode( } /// 用已有的 DecoderData 继续解码(seek 后复用) -/// 复用 input_ctx 上已经装好的中断回调,把它绑定到新的 shared 上 pub fn resume_decode(data: DecoderData, shared: Arc) -> JoinHandle { - shared.bind_interrupt(data.interrupt_handle()); + if let Some(flag) = data.interrupt_handle() { + shared.bind_interrupt(flag); + } thread::spawn(move || { let mut data = data; let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -256,66 +133,24 @@ pub fn resume_decode(data: DecoderData, shared: Arc) -> JoinHandle Result { - debug!(source, "打开音频输入"); - let is_network = source.starts_with("http://") || source.starts_with("https://"); - - if is_network { - let mut opts = Dictionary::new(); - opts.set("reconnect", "1"); - opts.set("reconnect_streamed", "1"); - opts.set("reconnect_delay_max", "5"); - // 新增:超时与连接复用,参见 https://ffmpeg.org/ffmpeg-protocols.html - opts.set("rw_timeout", HTTP_RW_TIMEOUT_US); - opts.set("timeout", HTTP_RW_TIMEOUT_US); // 老版本 ffmpeg 用这个名字 - opts.set("multiple_requests", "1"); - opts.set("icy", "0"); - opts.set("user_agent", "SPlayer-Next/1.0"); - ffmpeg::format::input_with_dictionary(source, opts) - .with_context(|| format!("Failed to open: {source}")) +/// 根据 source 协议打开音频:http(s) 走 HttpRangeSource + 拿 cancel flag,其他走本地 File +fn open_source(source: &str) -> Result<(AudioReader, Option>)> { + if http_source::is_network_source(source) { + let http = http_source::HttpRangeSource::new(source)?; + let cancel = http.cancel_handle(); + let reader = AudioReader::new(http, TARGET_SAMPLE_RATE as i32, TARGET_CHANNELS as i32) + .with_context(|| format!("打开网络音频失败: {source}"))?; + Ok((reader, Some(cancel))) } else { - ffmpeg::format::input(source).with_context(|| format!("Failed to open: {source}")) + let file = File::open(source).with_context(|| format!("打开本地文件失败: {source}"))?; + let reader = AudioReader::new(file, TARGET_SAMPLE_RATE as i32, TARGET_CHANNELS as i32) + .with_context(|| format!("打开本地音频失败: {source}"))?; + Ok((reader, None)) } } -/// 仅在格式/声道/采样率不同时创建重采样器 -fn create_resampler( - src_format: ffmpeg::format::Sample, - src_layout: ChannelLayout, - src_rate: u32, - target_format: ffmpeg::format::Sample, - target_layout: ChannelLayout, - target_rate: u32, -) -> Result> { - if src_format != target_format || src_layout != target_layout || src_rate != target_rate { - let resampler = ffmpeg::software::resampling::Context::get( - src_format, - src_layout, - src_rate, - target_format, - target_layout, - target_rate, - )?; - Ok(Some(resampler)) - } else { - Ok(None) - } -} - -/// 核心解码循环 +/// 核心解码循环:从 reader 拉交错 stereo f32,按 chunk 推入 shared fn run_decoding_loop(data: &mut DecoderData, shared: &Shared) { - let mut player_scratch = Vec::new(); - let mut fft_scratch = Vec::new(); - let mut eof_sent = false; - let mut consecutive_read_failures: u32 = 0; - // 响度归一化:有 ReplayGain 标签时用固定增益,否则用实时分析 let has_replay_gain = (shared.normalization_gain() - 1.0).abs() > f32::EPSILON; let mut loudness = LoudnessAnalyzer::new(); @@ -327,224 +162,42 @@ fn run_decoding_loop(data: &mut DecoderData, shared: &Shared) { return; } - let mut decoded = ffmpeg::frame::Audio::empty(); - match data.decoder.receive_frame(&mut decoded) { - Ok(()) => { - consecutive_read_failures = 0; - - // 部分文件不设置 channel_layout,需要填默认值 - if decoded.channel_layout().is_empty() { - let default_layout = ChannelLayout::default(decoded.channels() as i32); - decoded.set_channel_layout(default_layout); - } - - player_scratch.clear(); - fft_scratch.clear(); - - resample_frame(data, &mut decoded, &mut player_scratch, &mut fft_scratch); - - // 音量归一化 - if shared.is_normalization_enabled() { - let gain = if has_replay_gain { - // 有 ReplayGain 标签:用固定增益 - shared.normalization_gain() - } else { - // 无标签:实时分析动态增益 - loudness.process(&player_scratch) - }; - if (gain - 1.0).abs() > f32::EPSILON { - for sample in &mut player_scratch { - *sample *= gain; - } - } - } - - let chunk = AudioChunk { - player_samples: std::mem::take(&mut player_scratch), - fft_samples: std::mem::take(&mut fft_scratch), - }; - - shared.push(chunk); - } - Err(ffmpeg::Error::Eof) => { - // 解码器已 drain 完毕;resampler 内部还可能有 delay 样本,flush 一次再退 - flush_and_push_tail(data, shared); + let samples = match data.reader.receive_frame() { + Ok(Some(s)) => s.to_vec(), + Ok(None) => return, + Err(AudioError::Eof) => return, + Err(e) => { + warn!(error = %e, "解码失败"); + shared.mark_decode_failed(); return; } - Err(ffmpeg::Error::Other { errno }) if errno == ffmpeg::ffi::EAGAIN => { - if eof_sent { - // 已发送 EOF,解码器清空完毕,flush resampler 尾巴 - flush_and_push_tail(data, shared); - return; - } + }; - // 读取下一个数据包;Packet::read 能区分真正的 EOF 与读取失败 - let mut packet = ffmpeg::codec::packet::Packet::empty(); - match packet.read(&mut data.input_ctx) { - Ok(()) => { - if packet.stream() == data.audio_stream_index { - consecutive_read_failures = 0; - if data.decoder.send_packet(&packet).is_err() { - return; - } - } - // 非音频包:跳过 - } - Err(ffmpeg::Error::Eof) => { - // 真正的文件结束,刷新解码器 - let _ = data.decoder.send_eof(); - eof_sent = true; - } - Err(err) => { - // 读取失败:网络中断 / URL 失效 - consecutive_read_failures += 1; - warn!(error = %err, retries = consecutive_read_failures, "音频源读取失败"); - if consecutive_read_failures <= NETWORK_READ_RETRIES { - // 网络流可能恢复,等待后重试 - std::thread::sleep(std::time::Duration::from_millis( - NETWORK_RETRY_DELAY_MS, - )); - continue; - } - // 重试耗尽:标记音源失效,仍走 EOF 流程把已缓冲数据放完 - shared.mark_decode_failed(); - let _ = data.decoder.send_eof(); - eof_sent = true; - } - } - } - Err(err) => { - // 其他解码错误,重试后放弃 - consecutive_read_failures += 1; - warn!(error = %err, retries = consecutive_read_failures, "解码错误"); - if consecutive_read_failures <= NETWORK_READ_RETRIES { - std::thread::sleep(std::time::Duration::from_millis(NETWORK_RETRY_DELAY_MS)); - continue; - } - return; - } + if samples.is_empty() { + continue; } - } -} - -/// 对解码帧进行重采样,分别输出播放数据和 FFT 数据 -fn resample_frame( - data: &mut DecoderData, - decoded: &mut ffmpeg::frame::Audio, - player_buf: &mut Vec, - fft_buf: &mut Vec, -) { - // 播放重采样 - if let Some(ref mut resampler) = data.resampler { - if let Some(frame) = try_resample(resampler, decoded) { - interleave_planar_frame(player_buf, &frame, frame.samples()); - } - } else { - interleave_planar_frame(player_buf, decoded, decoded.samples()); - } - - // FFT 重采样(单声道) - if let Some(ref mut resampler) = data.fft_resampler { - if let Some(frame) = try_resample(resampler, decoded) { - let samples = frame.samples(); - if samples > 0 { - fft_buf.extend_from_slice(&frame.plane::(0)[..samples]); - } - } - } else { - let samples = decoded.samples(); - if samples > 0 { - fft_buf.extend_from_slice(&decoded.plane::(0)[..samples]); - } - } -} - -/// 执行重采样,为每次调用创建正确尺寸的输出帧 -fn try_resample( - resampler: &mut ffmpeg::software::resampling::Context, - decoded: &ffmpeg::frame::Audio, -) -> Option { - let output_samples = (decoded.samples() as f64 * resampler.output().rate as f64 - / decoded.rate() as f64) - .ceil() as usize; - let mut output_frame = ffmpeg::frame::Audio::new( - resampler.output().format, - output_samples, - resampler.output().channel_layout, - ); + let mut player_samples = samples; - if resampler.run(decoded, &mut output_frame).is_ok() { - Some(output_frame) - } else { - None - } -} + // FFT 取左声道(48k mono),fft.rs 直接用 TARGET_SAMPLE_RATE 不再单独重采样 + let fft_samples: Vec = player_samples.chunks_exact(2).map(|c| c[0]).collect(); -/// 排空 resampler 内部 delay buffer -/// EOF 时调用:swr_convert(NULL,0) 输出剩余样本,否则末尾 10–50ms 音频会丢失 -fn try_flush_resampler( - resampler: &mut ffmpeg::software::resampling::Context, -) -> Option { - // 4096 samples 是经验值:典型 resampler delay 几百到 2k 个样本,4k 留充足余量 - let mut output_frame = ffmpeg::frame::Audio::new( - resampler.output().format, - 4096, - resampler.output().channel_layout, - ); - if resampler.flush(&mut output_frame).is_ok() && output_frame.samples() > 0 { - Some(output_frame) - } else { - None - } -} - -/// 把 resampler 残留样本组装成最后一个 chunk 推进缓冲区 -/// 仅在解码器返回 EOF 时调用一次 -fn flush_and_push_tail(data: &mut DecoderData, shared: &Shared) { - let mut player_buf = Vec::new(); - let mut fft_buf = Vec::new(); - - if let Some(ref mut resampler) = data.resampler { - if let Some(frame) = try_flush_resampler(resampler) { - interleave_planar_frame(&mut player_buf, &frame, frame.samples()); - } - } - if let Some(ref mut resampler) = data.fft_resampler { - if let Some(frame) = try_flush_resampler(resampler) { - let samples = frame.samples(); - if samples > 0 { - fft_buf.extend_from_slice(&frame.plane::(0)[..samples]); + if shared.is_normalization_enabled() { + let gain = if has_replay_gain { + shared.normalization_gain() + } else { + loudness.process(&player_samples) + }; + if (gain - 1.0).abs() > f32::EPSILON { + for s in &mut player_samples { + *s *= gain; + } } } - } - if !player_buf.is_empty() || !fft_buf.is_empty() { + shared.push(AudioChunk { - player_samples: player_buf, - fft_samples: fft_buf, + player_samples, + fft_samples, }); } } - -/// 将平面格式(L L L... R R R...)转为交错格式(L R L R ...) -fn interleave_planar_frame( - sample_buffer: &mut Vec, - frame: &ffmpeg::frame::Audio, - samples_written: usize, -) { - if samples_written == 0 { - return; - } - let left_plane = &frame.plane::(0)[..samples_written]; - - if frame.channels() >= 2 { - let right_plane = &frame.plane::(1)[..samples_written]; - let interleaved = left_plane - .iter() - .zip(right_plane.iter()) - .flat_map(|(&l, &r)| [l, r]); - sample_buffer.extend(interleaved); - } else { - sample_buffer.extend(left_plane.iter().copied()); - } -} diff --git a/native/audio-engine/src/http_source.rs b/native/audio-engine/src/http_source.rs new file mode 100644 index 00000000..b6c20d3a --- /dev/null +++ b/native/audio-engine/src/http_source.rs @@ -0,0 +1,221 @@ +//! 把 HTTP/HTTPS 资源伪装成 Read + Seek 流喂给 ffmpeg_audio +//! +//! 通过 Range 请求按需拉数据,TLS 走 rustls(webpki-roots),跨平台无系统依赖 +//! 内置 cancellation flag:drop 即停的同时,外部可拿 `cancel_handle()` 注入到 shared, +//! 让 player.stop() 能让阻塞中的 read 立即返回 Interrupted,触发 ffmpeg 退出解码循环 +//! +//! 一次 GET 预拉 `PREFETCH_SIZE`(512KB)放入内部 buffer,ffmpeg 的 32KB AVIO 回调 + +use std::io::{self, Read, Seek, SeekFrom}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use tracing::warn; + +/// HTTP 读写超时 +const HTTP_TIMEOUT_SECS: u64 = 15; +/// User-Agent,部分 CDN 会拒绝默认的 ureq UA +const USER_AGENT: &str = "SPlayer-Next/1.0"; +/// 单次预拉块大小:太大首次 load 等首字节时间长,太小回到 GET 数过多 +/// 128KB 是甜点:5Mbps 下 ~200ms 下完,5MB 歌总 GET 数 ~40 次,rodio 缓冲完全吸收 +const PREFETCH_SIZE: usize = 128 * 1024; +/// 每次 fetch 失败重试次数(首次失败 + 2 次重试 = 3 次尝试) +const FETCH_RETRIES: u32 = 2; +/// 重试间隔(毫秒) +const RETRY_DELAY_MS: u64 = 500; +/// fetch_chunk_once 内部的读取缓冲块 +const READ_CHUNK_SIZE: usize = 16 * 1024; + +pub struct HttpRangeSource { + url: String, + agent: ureq::Agent, + total_size: u64, + pos: u64, + /// 预读 buffer:对应文件 [buf_start, buf_start + buf_data.len()) + buf_start: u64, + buf_data: Vec, + cancel: Arc, +} + +impl HttpRangeSource { + pub fn new(url: impl Into) -> Result { + let url = url.into(); + let agent: ureq::Agent = ureq::AgentBuilder::new() + .user_agent(USER_AGENT) + .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS)) + .build(); + + let resp = agent + .head(&url) + .call() + .with_context(|| format!("HEAD 请求失败: {url}"))?; + + let total_size: u64 = resp + .header("Content-Length") + .and_then(|v| v.parse().ok()) + .ok_or_else(|| anyhow!("响应缺少 Content-Length,无法做 seekable 流: {url}"))?; + + Ok(Self { + url, + agent, + total_size, + pos: 0, + buf_start: 0, + buf_data: Vec::new(), + cancel: Arc::new(AtomicBool::new(false)), + }) + } + + /// 拿 cancel flag 的 Arc clone,注入到 shared 后即可被 player.stop() 触发 + pub fn cancel_handle(&self) -> Arc { + Arc::clone(&self.cancel) + } + + fn check_cancel(&self) -> io::Result<()> { + if self.cancel.load(Ordering::Acquire) { + Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled")) + } else { + Ok(()) + } + } + + /// 当前 pos 是否落在已缓存的 buffer 内 + fn buf_contains_pos(&self) -> bool { + !self.buf_data.is_empty() + && self.pos >= self.buf_start + && self.pos < self.buf_start + self.buf_data.len() as u64 + } + + /// 从 pos 开始拉一个 chunk 到 buf_data,失败时按 RETRY_DELAY_MS 间隔重试 + fn fetch_chunk(&mut self) -> io::Result<()> { + let remaining = self.total_size.saturating_sub(self.pos); + if remaining == 0 { + self.buf_start = self.pos; + self.buf_data.clear(); + return Ok(()); + } + let chunk_size = (PREFETCH_SIZE as u64).min(remaining) as usize; + let last = self.pos + chunk_size as u64 - 1; + let range = format!("bytes={}-{}", self.pos, last); + + let mut last_err: Option = None; + for attempt in 0..=FETCH_RETRIES { + self.check_cancel()?; + if attempt > 0 { + // sleep 期间分多段 check cancel,避免 stop 后还要等 500ms + for _ in 0..(RETRY_DELAY_MS / 50) { + std::thread::sleep(Duration::from_millis(50)); + self.check_cancel()?; + } + } + match self.fetch_chunk_once(&range, chunk_size) { + Ok(data) => { + self.buf_start = self.pos; + self.buf_data = data; + return Ok(()); + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => return Err(e), + Err(e) if attempt < FETCH_RETRIES => { + warn!( + error = %e, + attempt = attempt + 1, + range = %range, + "HTTP fetch 失败,将重试" + ); + last_err = Some(e); + } + Err(e) => return Err(e), + } + } + Err(last_err.unwrap_or_else(|| io::Error::other("fetch failed"))) + } + + fn fetch_chunk_once(&self, range: &str, expected: usize) -> io::Result> { + let resp = self + .agent + .get(&self.url) + .set("Range", range) + .call() + .map_err(|e| io::Error::other(format!("GET Range 失败: {e}")))?; + + let mut reader = resp.into_reader(); + let mut data = Vec::with_capacity(expected); + let mut chunk = [0u8; READ_CHUNK_SIZE]; + + while data.len() < expected { + self.check_cancel()?; + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + let want = (expected - data.len()).min(n); + data.extend_from_slice(&chunk[..want]); + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + + if data.is_empty() { + return Err(io::Error::other("response body empty")); + } + Ok(data) + } +} + +impl Read for HttpRangeSource { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.check_cancel()?; + if self.pos >= self.total_size || buf.is_empty() { + return Ok(0); + } + + if !self.buf_contains_pos() { + self.fetch_chunk()?; + } + + // fetch_chunk 后理论上必定 contains_pos;保险加 defensive 检查 + if !self.buf_contains_pos() { + return Ok(0); + } + + let offset = (self.pos - self.buf_start) as usize; + let available = self.buf_data.len() - offset; + let n = buf.len().min(available); + buf[..n].copy_from_slice(&self.buf_data[offset..offset + n]); + self.pos += n as u64; + Ok(n) + } +} + +impl Seek for HttpRangeSource { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + let new_pos = match pos { + SeekFrom::Start(p) => p, + SeekFrom::End(o) => { + if o >= 0 { + self.total_size.saturating_add(o as u64) + } else { + self.total_size.saturating_sub((-o) as u64) + } + } + SeekFrom::Current(o) => { + if o >= 0 { + self.pos.saturating_add(o as u64) + } else { + self.pos.saturating_sub((-o) as u64) + } + } + }; + // 不主动清 buffer:seek 命中 buffer 是 bonus(小距离前后跳), + // 跳出 buffer 时下次 read 自然触发 fetch_chunk 覆盖 + self.pos = new_pos; + Ok(new_pos) + } +} + +/// 判断 source 字符串是否是 http/https URL +pub fn is_network_source(source: &str) -> bool { + source.starts_with("http://") || source.starts_with("https://") +} diff --git a/native/audio-engine/src/lib.rs b/native/audio-engine/src/lib.rs index 2272386a..13f9b0bc 100644 --- a/native/audio-engine/src/lib.rs +++ b/native/audio-engine/src/lib.rs @@ -5,6 +5,7 @@ mod audio_output; mod decoder; mod equalizer; mod fft; +mod http_source; mod logger; mod loudness; mod metadata; @@ -55,9 +56,9 @@ impl IntoNapiResult for anyhow::Result { #[napi] pub fn init_logger(log_dir: String, is_dev: bool) { logger::init_logger(&log_dir, is_dev); - // 静默 FFmpeg 内部日志,避免 seek 时的 invalid sync code 等无害警告输出到控制台 - // 只保留致命错误,过滤 seek 时的 invalid sync code 等无害警告 - ffmpeg_next::log::set_level(ffmpeg_next::log::Level::Fatal); + // 先 init 触发 Once 注册 callback,再 set_log_level 覆盖默认 verbose + ffmpeg_audio::log::init_ffmpeg_logging(); + ffmpeg_audio::log::set_log_level(ffmpeg_audio::log::AV_LOG_FATAL); info!(log_dir, is_dev, "audio-engine 日志系统已初始化"); } diff --git a/native/audio-engine/src/metadata.rs b/native/audio-engine/src/metadata.rs index c6eb966f..8354d1b0 100644 --- a/native/audio-engine/src/metadata.rs +++ b/native/audio-engine/src/metadata.rs @@ -1,27 +1,22 @@ use std::collections::hash_map::DefaultHasher; +use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::path::Path; -use ffmpeg_next as ffmpeg; +use ffmpeg_audio::{AudioReader, SourceAudioInfo}; /// 一条外部歌词(仅格式和路径,内容按需加载) #[derive(Clone)] pub struct ExternalLyric { - /// 格式(如 "lrc", "ttml", "yrc") pub format: String, - /// 文件路径 pub path: String, } /// 音频流基本参数(scanner 和 decoder 共用) pub struct StreamInfo { - /// 比特率(bps) pub bit_rate: i64, - /// 原始采样率(Hz) pub sample_rate: u32, - /// 位深(bits per sample) pub bits_per_sample: u32, - /// 声道数 pub channels: u32, } @@ -39,49 +34,32 @@ const THUMB_SIZE: u32 = 300; /// 支持的歌词文件扩展名 const LYRIC_EXTENSIONS: &[&str] = &["ttml", "lys", "qrc", "krc", "yrc", "lrc", "ass", "srt"]; -/// 从音频流参数中提取比特率、原始采样率和位深 -/// -/// # Safety -/// `stream` 和 `input_ctx` 的底层指针必须有效(由调用方保证生命周期) -pub unsafe fn extract_stream_info( - stream: &ffmpeg::Stream, - input_ctx: &ffmpeg::format::context::Input, -) -> StreamInfo { - let params = stream.parameters().as_ptr(); - let stream_bit_rate = (*params).bit_rate; - // FLAC 等无损格式的 stream bit_rate 通常为 0,fallback 到容器级别 - let bit_rate = if stream_bit_rate > 0 { - stream_bit_rate - } else { - (*input_ctx.as_ptr()).bit_rate - }; - let sample_rate = (*params).sample_rate as u32; - let bits_per_raw = (*params).bits_per_raw_sample as u32; - let bits_per_coded = (*params).bits_per_coded_sample as u32; - let bits_per_sample = if bits_per_raw > 0 { - bits_per_raw - } else { - bits_per_coded - }; - let channels = (*params).ch_layout.nb_channels.max(0) as u32; +/// 把 ffmpeg_audio 的 SourceAudioInfo 转成内部 StreamInfo +pub fn extract_stream_info(info: &SourceAudioInfo) -> StreamInfo { StreamInfo { - bit_rate, - sample_rate, - bits_per_sample, - channels, + bit_rate: info.bit_rate, + sample_rate: info.sample_rate.max(0) as u32, + bits_per_sample: info.bits_per_sample.max(0) as u32, + channels: info.channels.max(0) as u32, } } +/// 大小写不敏感查找:原 ffmpeg-next 的 Dictionary::get 默认 case-insensitive, +/// 而 ffmpeg_audio 把 dict 转成普通 HashMap 后丢了这个语义,这里补回来 +fn dict_get<'a>(dict: &'a HashMap, key: &str) -> Option<&'a str> { + dict.iter() + .find(|(k, _)| k.eq_ignore_ascii_case(key)) + .map(|(_, v)| v.as_str()) +} + /// 从容器 metadata 提取常见 tag -pub fn extract_tags(input_ctx: &ffmpeg::format::context::Input) -> Tags { - let dict = input_ctx.metadata(); - let title = dict.get("title").map(ToString::to_string); - let artist = dict - .get("artist") - .or_else(|| dict.get("album_artist")) +pub fn extract_tags(dict: &HashMap) -> Tags { + let title = dict_get(dict, "title").map(ToString::to_string); + let artist = dict_get(dict, "artist") + .or_else(|| dict_get(dict, "album_artist")) .map(ToString::to_string); - let album = dict.get("album").map(ToString::to_string); - let comment = dict.get("comment").map(ToString::to_string); + let album = dict_get(dict, "album").map(ToString::to_string); + let comment = dict_get(dict, "comment").map(ToString::to_string); Tags { title, artist, @@ -92,23 +70,20 @@ pub fn extract_tags(input_ctx: &ffmpeg::format::context::Input) -> Tags { /// 从容器 metadata 提取 ReplayGain / R128 增益值(dB) /// -/// 按优先级尝试:R128_TRACK_GAIN → replaygain_track_gain → replaygain_album_gain -pub fn extract_replay_gain(input_ctx: &ffmpeg::format::context::Input) -> Option { - let dict = input_ctx.metadata(); - +/// 按优先级尝试:R128_TRACK_GAIN → replaygain_track_gain → album 版本 +pub fn extract_replay_gain(dict: &HashMap) -> Option { // EBU R128:值为 1/256 dB 单位的整数 - if let Some(val) = dict.get("R128_TRACK_GAIN").or_else(|| dict.get("R128_ALBUM_GAIN")) { + if let Some(val) = + dict_get(dict, "R128_TRACK_GAIN").or_else(|| dict_get(dict, "R128_ALBUM_GAIN")) + { if let Ok(raw) = val.trim().parse::() { return Some(raw / 256.0); } } // ReplayGain:格式如 "-6.50 dB" - if let Some(val) = dict - .get("replaygain_track_gain") - .or_else(|| dict.get("REPLAYGAIN_TRACK_GAIN")) - .or_else(|| dict.get("replaygain_album_gain")) - .or_else(|| dict.get("REPLAYGAIN_ALBUM_GAIN")) + if let Some(val) = dict_get(dict, "replaygain_track_gain") + .or_else(|| dict_get(dict, "replaygain_album_gain")) { let cleaned = val.trim().trim_end_matches(" dB").trim_end_matches("dB"); if let Ok(db) = cleaned.parse::() { @@ -124,12 +99,9 @@ pub fn db_to_linear(db: f32) -> f32 { 10.0_f32.powf(db / 20.0) } -/// 从已打开的 input_ctx 中提取封面缩略图,写入缓存目录,返回缩略图路径。 -/// -/// 只缓存 300x300 JPEG 缩略图供前端日常显示,原图不落盘。 -/// 高清封面在 `start_decode` 中通过 `read_attached_pic` 一并提取并缓存于 InnerPlayer。 +/// 从 reader 中提取封面缩略图,写入缓存目录,返回缩略图路径 pub fn extract_cover_thumbnail( - input_ctx: &ffmpeg::format::context::Input, + reader: &AudioReader, source: &str, cache_dir: &str, ) -> Option { @@ -141,59 +113,41 @@ pub fn extract_cover_thumbnail( let thumb_file = cache_dir.join(format!("cover_{hash:016x}_thumb.jpg")); - // 缓存命中 if thumb_file.exists() { return Some(thumb_file.to_string_lossy().into_owned()); } - let data = read_attached_pic(input_ctx)?; - + let cover = reader.cover()?; std::fs::create_dir_all(cache_dir).ok()?; - if generate_cover_thumbnail(&data, &thumb_file).is_err() { + if generate_cover_thumbnail(&cover.data, &thumb_file).is_err() { // 缩略图生成失败,直接写入原始数据作为回退 - std::fs::write(&thumb_file, &data).ok()?; + std::fs::write(&thumb_file, &cover.data).ok()?; } Some(thumb_file.to_string_lossy().into_owned()) } -/// 从 input_ctx 的 attached_pic 流读取原始封面字节 -pub fn read_attached_pic(input_ctx: &ffmpeg::format::context::Input) -> Option> { - for stream in input_ctx.streams() { - if stream - .disposition() - .contains(ffmpeg::format::stream::Disposition::ATTACHED_PIC) - { - // SAFETY: stream.as_ptr() 返回有效的 AVStream 指针,attached_pic 字段 - // 在 ATTACHED_PIC disposition 下由 FFmpeg 保证初始化且生命周期与 input_ctx 一致 - unsafe { - let raw_stream = stream.as_ptr(); - let pkt = &(*raw_stream).attached_pic; - if pkt.size > 0 && !pkt.data.is_null() { - let data = std::slice::from_raw_parts(pkt.data.cast_const(), pkt.size as usize); - return Some(data.to_vec()); - } - } - } - } - None +/// 拿原始封面字节(供 SMTC / 全屏播放器使用,不缓存) +pub fn read_attached_pic(reader: &AudioReader) -> Option> { + reader.cover().map(|c| c.data) } -/// 将原始图片数据缩放为 JPEG 缩略图(供 scanner 和 extract_cover_thumbnail 共用) -pub fn generate_cover_thumbnail(data: &[u8], output_path: &Path) -> Result<(), Box> { +/// 将原始图片数据缩放为 JPEG 缩略图 +pub fn generate_cover_thumbnail( + data: &[u8], + output_path: &Path, +) -> Result<(), Box> { let img = image::load_from_memory(data)?; let thumb = img.thumbnail(THUMB_SIZE, THUMB_SIZE); thumb.save_with_format(output_path, image::ImageFormat::Jpeg)?; Ok(()) } -/// 从已打开的 input_ctx 中读取内嵌歌词 -pub fn extract_embedded_lyric(input_ctx: &ffmpeg::format::context::Input) -> Option { - let dict = input_ctx.metadata(); - dict.get("lyrics") - .or_else(|| dict.get("LYRICS")) - .or_else(|| dict.get("UNSYNCEDLYRICS")) +/// 从容器 metadata 提取内嵌歌词 +pub fn extract_embedded_lyric(dict: &HashMap) -> Option { + dict_get(dict, "lyrics") + .or_else(|| dict_get(dict, "UNSYNCEDLYRICS")) .map(ToString::to_string) .filter(|s| !s.is_empty()) } diff --git a/native/audio-engine/src/player.rs b/native/audio-engine/src/player.rs index 355f031b..93ed8e54 100644 --- a/native/audio-engine/src/player.rs +++ b/native/audio-engine/src/player.rs @@ -203,7 +203,7 @@ impl InnerPlayer { sink: None, shared: None, decoder_thread: None, - fft: Arc::new(FftAnalyzer::new(decoder::FFT_SAMPLE_RATE)), + fft: Arc::new(FftAnalyzer::new(decoder::TARGET_SAMPLE_RATE)), audio_sample_rate: 0, audio_channels: 0, audio_duration: 0.0, diff --git a/native/audio-engine/src/scanner.rs b/native/audio-engine/src/scanner.rs index f7ead07d..5b897660 100644 --- a/native/audio-engine/src/scanner.rs +++ b/native/audio-engine/src/scanner.rs @@ -10,11 +10,11 @@ use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Instant, SystemTime}; -use ffmpeg_next as ffmpeg; -use ffmpeg_next::media::Type; +use ffmpeg_audio::AudioReader; use tracing::{debug, info, warn}; use walkdir::WalkDir; +use crate::decoder; use crate::metadata; /// 支持的音频文件扩展名 @@ -81,38 +81,23 @@ fn is_audio_file(path: &Path) -> bool { .is_some_and(|ext| AUDIO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())) } -/// 使用 FFmpeg 快速读取音频文件元数据(仅读取容器头和 tag,不解码音频数据) +/// 使用 ffmpeg_audio 打开音频文件并读取元数据 +/// +/// 会顺带初始化解码器和重采样器(一次性开销),扫库速度仍能跑到几百文件/秒 fn probe_fast(path: &str, cover_cache_dir: Option<&str>) -> Option { - let ctx = ffmpeg::format::input(path).ok()?; + let file = fs::File::open(path).ok()?; + let reader = AudioReader::new( + file, + decoder::TARGET_SAMPLE_RATE as i32, + decoder::TARGET_CHANNELS as i32, + ) + .ok()?; - // 时长 - let duration = if ctx.duration() >= 0 { - ctx.duration() as f64 / f64::from(ffmpeg::ffi::AV_TIME_BASE) - } else { - 0.0 - }; + let duration = reader.duration().map(|d| d.as_secs_f64()).unwrap_or(0.0); - // 音频流参数(全部从 AVCodecParameters 读取,不初始化解码器) - let mut sample_rate = 0u32; - let mut channels = 0u32; - let mut bits_per_sample = 0u32; - let mut bit_rate = 0i64; - let mut codec = String::new(); - - if let Some(stream) = ctx.streams().best(Type::Audio) { - // SAFETY: ctx 和 stream 在此作用域内有效 - let info = unsafe { metadata::extract_stream_info(&stream, &ctx) }; - bit_rate = info.bit_rate; - bits_per_sample = info.bits_per_sample; - sample_rate = info.sample_rate; - channels = info.channels; - - codec = stream - .parameters() - .id() - .name() - .to_string(); - } + let info = reader.source_info(); + let stream_info = metadata::extract_stream_info(info); + let mut codec = info.codec_name.clone(); // codec 兜底:从扩展名推导 if codec.is_empty() { @@ -123,12 +108,11 @@ fn probe_fast(path: &str, cover_cache_dir: Option<&str>) -> Option .to_ascii_lowercase(); } - // tag 提取(复用 metadata 模块) - let tags = metadata::extract_tags(&ctx); + let raw_metadata = reader.metadata(); + let tags = metadata::extract_tags(&raw_metadata); - // 封面缩略图(复用 metadata 模块,与播放时的封面缓存逻辑一致) let cover = - cover_cache_dir.and_then(|dir| metadata::extract_cover_thumbnail(&ctx, path, dir)); + cover_cache_dir.and_then(|dir| metadata::extract_cover_thumbnail(&reader, path, dir)); Some(ScannedTrack { path: path.to_string(), @@ -137,10 +121,10 @@ fn probe_fast(path: &str, cover_cache_dir: Option<&str>) -> Option album: tags.album, duration, codec, - sample_rate, - bit_rate, - channels, - bits_per_sample, + sample_rate: stream_info.sample_rate, + bit_rate: stream_info.bit_rate, + channels: stream_info.channels, + bits_per_sample: stream_info.bits_per_sample, cover, file_size: 0, // 由调用方填充 mtime: 0, From 8581c358c99328b5c3fc917f5be09ebc8eaccfe8 Mon Sep 17 00:00:00 2001 From: imsyy Date: Mon, 25 May 2026 11:32:20 +0800 Subject: [PATCH 2/5] =?UTF-8?q?ci:=20=E7=A7=BB=E9=99=A4=20FFmpeg=20?= =?UTF-8?q?=E9=9D=99=E6=80=81=E5=BA=93=E4=B8=8B=E8=BD=BD=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev.yml | 48 +-------------------------------------- 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 71dc5337..289e7dad 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -21,8 +21,6 @@ on: env: NODE_VERSION: 22.x - BUILDER_REPO: apoint123/ffmpeg-builder - FFMPEG_VERSION_TAG: "8.0.1" jobs: build: @@ -76,57 +74,13 @@ jobs: with: workspaces: "native/audio-engine -> target\nnative/media-ctrl -> target" - # 下载预编译的 FFmpeg 静态库 (Windows) - - name: 下载预编译的 FFmpeg 库 (Windows) - if: runner.os == 'Windows' - run: | - $packageName = "ffmpeg-${{ env.FFMPEG_VERSION_TAG }}-${{ matrix.artifact_name_suffix }}.${{ matrix.package_extension }}" - $downloadUrl = "https://github.com/${{ env.BUILDER_REPO }}/releases/latest/download/$packageName" - Write-Host "Downloading $packageName from $downloadUrl ..." - Invoke-WebRequest -Uri $downloadUrl -OutFile $packageName - - mkdir -p vendor/ffmpeg - Expand-Archive -Path $packageName -DestinationPath vendor/ffmpeg_temp -Force - Move-Item -Path vendor/ffmpeg_temp/include -Destination vendor/ffmpeg/include - Move-Item -Path vendor/ffmpeg_temp/lib -Destination vendor/ffmpeg/lib - Remove-Item -Recurse -Force vendor/ffmpeg_temp - Remove-Item $packageName - - echo "FFMPEG_DIR=${{ github.workspace }}/vendor/ffmpeg" >> $env:GITHUB_ENV - shell: powershell - - # 下载预编译的 FFmpeg 静态库 (macOS/Linux) - - name: 下载预编译的 FFmpeg 库 (macOS/Linux) - if: runner.os != 'Windows' - run: | - PACKAGE_NAME="ffmpeg-${{ env.FFMPEG_VERSION_TAG }}-${{ matrix.artifact_name_suffix }}.${{ matrix.package_extension }}" - DOWNLOAD_URL="https://github.com/${{ env.BUILDER_REPO }}/releases/latest/download/${PACKAGE_NAME}" - echo "Downloading $PACKAGE_NAME from $DOWNLOAD_URL ..." - curl -f -sL $DOWNLOAD_URL -o $PACKAGE_NAME - - mkdir -p vendor/ffmpeg - tar -xzf $PACKAGE_NAME -C vendor/ffmpeg - rm $PACKAGE_NAME - - echo "FFMPEG_DIR=${{ github.workspace }}/vendor/ffmpeg" >> $GITHUB_ENV - shell: bash - - # 设置 FFmpeg 环境变量 - - name: 设置 FFmpeg 环境变量 - run: | - echo "PKG_CONFIG_PATH=${{ github.workspace }}/vendor/ffmpeg/lib/pkgconfig" >> $GITHUB_ENV - if [[ "$RUNNER_OS" != "Windows" ]]; then - echo "CFLAGS=-I${{ github.workspace }}/vendor/ffmpeg/include" >> $GITHUB_ENV - fi - shell: bash - # Linux: 安装系统构建/打包依赖 - name: 安装系统依赖 (Linux) if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install --no-install-recommends -y \ - libasound2-dev libdbus-1-dev pkg-config clang nasm \ + libasound2-dev libdbus-1-dev pkg-config clang \ rpm libarchive-tools # 清理旧构建产物 From 9069c31ed9b31fb70ed7c0c9ed50aad9172ae7b7 Mon Sep 17 00:00:00 2001 From: imsyy Date: Mon, 25 May 2026 11:34:49 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20copilot=20=E6=8C=87?= =?UTF-8?q?=E5=BC=95=EF=BC=9B=E7=A7=BB=E9=99=A4=E6=9C=AA=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E7=9A=84=20claude=20=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 2 +- .github/workflows/claude-code-review.yml | 45 ------------------------ .github/workflows/claude.yml | 44 ----------------------- 3 files changed, 1 insertion(+), 90 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml delete mode 100644 .github/workflows/claude.yml diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 11219f9a..23c0fed9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,7 +14,7 @@ SPlayer-Next 是基于 **Electron + Vue 3 + TypeScript** 的桌面音乐播放 ### 原生模块(`native/`) -- `audio-engine`:FFmpeg 解码 + rodio 播放 + FFT + 封面提取 +- `audio-engine`:`ffmpeg_audio` 解码(静态编 FFmpeg,零环境依赖)+ rodio 播放 + FFT + 封面提取;URL 通过 `HttpRangeSource`(ureq + rustls)包成 Read+Seek 流喂给 ffmpeg_audio - `media-ctrl`:跨平台系统媒体控件(Windows SMTC / Linux MPRIS / macOS MPNowPlaying)+ Discord RPC - `taskbar-lyric`:Windows 专属,把窗口嵌入任务栏 + RegistryWatcher / UiaWatcher / TrayWatcher 四路监听 diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 5478d0d8..00000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: "https://github.com/anthropics/claude-code.git" - plugins: "code-review@claude-code-plugins" - prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}" - # 始终使用中文进行代码审查与评论 - claude_args: '--append-system-prompt "请始终使用简体中文进行代码审查与评论,遵循项目 CLAUDE.md 中的约定。"' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 0fe79752..00000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # 始终使用中文回答,并遵循仓库的代码规范 - claude_args: '--append-system-prompt "请始终使用简体中文回答所有问题、评论与代码注释。遵循项目 CLAUDE.md 中的约定。"' From 2551210f4e4f9475d8c78a071abc005f0c55c33d Mon Sep 17 00:00:00 2001 From: imsyy Date: Mon, 25 May 2026 12:16:56 +0800 Subject: [PATCH 4/5] =?UTF-8?q?audio-engine=20=E8=B7=9F=E8=BF=9B=20ffmpeg?= =?UTF-8?q?=5Faudio=20=E6=96=B0=20set=5Flog=5Flevel=20=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 4 ++-- native/audio-engine/src/lib.rs | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index afbf0c17..3ccc14ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -744,7 +744,7 @@ dependencies = [ [[package]] name = "ffmpeg_audio" version = "0.1.0" -source = "git+https://github.com/SPlayer-Dev/ffmpeg-audio?branch=feat%2Fduration-api#4743d479c43595382873306a63ccb2420d2afda2" +source = "git+https://github.com/SPlayer-Dev/ffmpeg-audio?branch=feat%2Fduration-api#5b7d2c05d7fadd1a530b5c32c5b63e7385d8745c" dependencies = [ "ffmpeg_audio_sys", "libc", @@ -754,7 +754,7 @@ dependencies = [ [[package]] name = "ffmpeg_audio_sys" version = "0.1.0" -source = "git+https://github.com/SPlayer-Dev/ffmpeg-audio?branch=feat%2Fduration-api#4743d479c43595382873306a63ccb2420d2afda2" +source = "git+https://github.com/SPlayer-Dev/ffmpeg-audio?branch=feat%2Fduration-api#5b7d2c05d7fadd1a530b5c32c5b63e7385d8745c" dependencies = [ "bindgen 0.72.1", "cc", diff --git a/native/audio-engine/src/lib.rs b/native/audio-engine/src/lib.rs index 13f9b0bc..0c5f4950 100644 --- a/native/audio-engine/src/lib.rs +++ b/native/audio-engine/src/lib.rs @@ -56,9 +56,7 @@ impl IntoNapiResult for anyhow::Result { #[napi] pub fn init_logger(log_dir: String, is_dev: bool) { logger::init_logger(&log_dir, is_dev); - // 先 init 触发 Once 注册 callback,再 set_log_level 覆盖默认 verbose - ffmpeg_audio::log::init_ffmpeg_logging(); - ffmpeg_audio::log::set_log_level(ffmpeg_audio::log::AV_LOG_FATAL); + ffmpeg_audio::log::set_log_level(ffmpeg_audio::log::AV_LOG_FATAL as i32); info!(log_dir, is_dev, "audio-engine 日志系统已初始化"); } From d49ab51adc7c83a3da02939fc84972dedc59edd2 Mon Sep 17 00:00:00 2001 From: imsyy Date: Mon, 25 May 2026 15:32:00 +0800 Subject: [PATCH 5/5] feat(http_source): enhance HTTP range source with robust error handling and retry logic - Added configurable timeouts and retry mechanisms to improve resilience against network issues. - Refactored stream handling to ensure proper reconnection and error classification. - Introduced jitter-based backoff strategy for retries to reduce server load. - Updated tests to cover new behaviors and ensure reliability under various scenarios. - Removed unnecessary unsafe implementations for thread safety in FftAnalyzer. --- Cargo.lock | 609 ++++++++++++++++++++++-- native/audio-engine/Cargo.toml | 3 + native/audio-engine/src/fft.rs | 7 - native/audio-engine/src/http_source.rs | 610 ++++++++++++++++++++----- 4 files changed, 1063 insertions(+), 166 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ccc14ce..020e8ec5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,18 +45,58 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + [[package]] name = "async-broadcast" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener", + "event-listener 5.4.1", "event-listener-strategy", "futures-core", "pin-project-lite", ] +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -83,6 +123,21 @@ dependencies = [ "slab", ] +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + [[package]] name = "async-io" version = "2.6.0" @@ -107,25 +162,34 @@ version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener", + "event-listener 5.4.1", "event-listener-strategy", "pin-project-lite", ] +[[package]] +name = "async-object-pool" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333c456b97c3f2d50604e8b2624253b7f787208cb72eb75e64b0ad11b221652c" +dependencies = [ + "async-std", +] + [[package]] name = "async-process" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ - "async-channel", + "async-channel 2.5.0", "async-io", "async-lock", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener", + "event-listener 5.4.1", "futures-lite", "rustix", ] @@ -138,7 +202,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -159,6 +223,34 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + [[package]] name = "async-task" version = "4.7.1" @@ -173,7 +265,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -189,6 +281,7 @@ dependencies = [ "anyhow", "cpal", "ffmpeg_audio", + "httpmock", "image", "napi", "napi-build", @@ -212,12 +305,29 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-cookies" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67bd8fd42c16bdb08688243dc5f0cc117a3ca9efeeaba3a345a18a6159ad96f7" +dependencies = [ + "lalrpop", + "lalrpop-util", + "regex", +] + [[package]] name = "bindgen" version = "0.70.1" @@ -227,7 +337,7 @@ dependencies = [ "bitflags 2.11.0", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -235,7 +345,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn", + "syn 2.0.117", ] [[package]] @@ -247,7 +357,7 @@ dependencies = [ "bitflags 2.11.0", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -255,9 +365,24 @@ dependencies = [ "regex", "rustc-hash 2.1.2", "shlex", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "1.3.2" @@ -285,7 +410,7 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "async-channel", + "async-channel 2.5.0", "async-task", "futures-io", "futures-lite", @@ -461,6 +586,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "ctor" version = "0.8.0" @@ -605,6 +736,27 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "discord-rich-presence" version = "1.1.0" @@ -638,7 +790,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -662,6 +814,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "endi" version = "1.1.1" @@ -686,7 +847,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -705,6 +866,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "event-listener" version = "5.4.1" @@ -722,7 +889,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener", + "event-listener 5.4.1", "pin-project-lite", ] @@ -770,6 +937,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -781,6 +954,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -865,7 +1044,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -939,6 +1118,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -972,6 +1163,91 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "httpmock" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ec9586ee0910472dec1a1f0f8acf52f0fdde93aea74d70d4a3107b4be0fd5b" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-std", + "async-trait", + "base64 0.21.7", + "basic-cookies", + "crossbeam-utils", + "form_urlencoded", + "futures-util", + "hyper", + "lazy_static", + "levenshtein", + "log", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "tokio", + "url", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1118,6 +1394,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1174,7 +1459,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1199,6 +1484,46 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.11.0", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1211,6 +1536,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + [[package]] name = "libc" version = "0.2.183" @@ -1237,6 +1568,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1263,6 +1603,9 @@ name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] [[package]] name = "mach2" @@ -1366,7 +1709,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "392ce2be7224867869df37e473f28871ab0ff725c0014f1b196ba56a38aea9a8" dependencies = [ - "async-channel", + "async-channel 2.5.0", "futures-channel", "serde", "trait-variant", @@ -1408,7 +1751,7 @@ dependencies = [ "napi-derive-backend", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1421,7 +1764,7 @@ dependencies = [ "proc-macro2", "quote", "semver", - "syn", + "syn 2.0.117", ] [[package]] @@ -1462,6 +1805,12 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -1510,7 +1859,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1550,7 +1899,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1814,12 +2163,43 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "piper" version = "0.2.5" @@ -1879,6 +2259,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -1886,7 +2272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1958,6 +2344,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.12.3" @@ -2138,7 +2535,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2154,6 +2551,16 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_regex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -2162,7 +2569,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2207,6 +2614,18 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2219,6 +2638,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -2241,12 +2670,35 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -2266,7 +2718,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2295,6 +2747,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2321,7 +2784,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2332,7 +2795,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2377,6 +2840,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2399,7 +2871,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -2412,7 +2884,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2445,6 +2917,12 @@ dependencies = [ "winnow 1.0.1", ] +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -2476,7 +2954,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2527,7 +3005,7 @@ checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2540,6 +3018,12 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typed-path" version = "0.12.3" @@ -2587,7 +3071,7 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ - "base64", + "base64 0.22.1", "log", "once_cell", "rustls", @@ -2640,6 +3124,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + [[package]] name = "vcpkg" version = "0.2.15" @@ -2656,6 +3146,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2722,7 +3221,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2797,6 +3296,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2806,6 +3321,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.54.0" @@ -2879,7 +3400,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2890,7 +3411,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3160,7 +3681,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3176,7 +3697,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3243,7 +3764,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3263,7 +3784,7 @@ dependencies = [ "async-trait", "blocking", "enumflags2", - "event-listener", + "event-listener 5.4.1", "futures-core", "futures-lite", "hex", @@ -3291,7 +3812,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -3325,7 +3846,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3365,7 +3886,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3444,7 +3965,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zvariant_utils", ] @@ -3457,6 +3978,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.117", "winnow 0.7.15", ] diff --git a/native/audio-engine/Cargo.toml b/native/audio-engine/Cargo.toml index 20df67e3..b90f9c44 100644 --- a/native/audio-engine/Cargo.toml +++ b/native/audio-engine/Cargo.toml @@ -36,5 +36,8 @@ tokio = { version = "1", features = ["rt"] } [build-dependencies] napi-build = "2.3" +[dev-dependencies] +httpmock = "0.7" + [lints] workspace = true diff --git a/native/audio-engine/src/fft.rs b/native/audio-engine/src/fft.rs index cd9b9776..71c623fb 100644 --- a/native/audio-engine/src/fft.rs +++ b/native/audio-engine/src/fft.rs @@ -30,13 +30,6 @@ struct FftWorkBuffers { output: Vec, } -// SAFETY: FftAnalyzer 需要手动实现 Send+Sync,因为 fft_plan 字段类型为 -// Arc>,trait object 默认不含 Send+Sync bound。 -// 实际上 rustfft::FftPlanner 返回的所有实现都是线程安全的, -// 且 FftAnalyzer 的其余字段(Mutex, Mutex, u32)均为 Send+Sync。 -unsafe impl Send for FftAnalyzer {} -unsafe impl Sync for FftAnalyzer {} - impl FftAnalyzer { pub fn new(sample_rate: u32) -> Self { let mut planner = FftPlanner::::new(); diff --git a/native/audio-engine/src/http_source.rs b/native/audio-engine/src/http_source.rs index b6c20d3a..30f544a0 100644 --- a/native/audio-engine/src/http_source.rs +++ b/native/audio-engine/src/http_source.rs @@ -1,70 +1,114 @@ //! 把 HTTP/HTTPS 资源伪装成 Read + Seek 流喂给 ffmpeg_audio //! -//! 通过 Range 请求按需拉数据,TLS 走 rustls(webpki-roots),跨平台无系统依赖 -//! 内置 cancellation flag:drop 即停的同时,外部可拿 `cancel_handle()` 注入到 shared, -//! 让 player.stop() 能让阻塞中的 read 立即返回 Interrupted,触发 ffmpeg 退出解码循环 +//! 行为对齐 Chrome