From a1727254bedb42c149e8f2201dc0520464480442 Mon Sep 17 00:00:00 2001 From: Sakiko Date: Sun, 26 Jul 2026 22:43:33 +0800 Subject: [PATCH 1/2] feat(presenter): add subtitle/audio delay, mute, and volume query across the full stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four playback-control APIs that danmaku players need, wired from the Rust presenter through the C ABI and every platform plugin to Dart: - erika_presenter_set_subtitle_delay (±60s, mpv sub-delay semantics): shifts the pts handed to subtitle overlay composition; the danmaku timeline keeps its own independent offsets - erika_presenter_set_audio_delay (±10s, mpv audio-delay semantics): shifts audio frame pts before the ring buffer push; the audio-master clock, subtitles, and danmaku follow automatically - erika_presenter_set_muted / erika_presenter_muted: independent flag that does not clobber the stored volume, so getters keep reporting the user's level while muted - erika_presenter_get_volume Platform wiring follows the existing set_volume/setSubtitleScale template: C header declarations, cfg-gated stubs for unsupported targets, JNI dispatch, macOS/iOS Swift (function additions only — no mirrored C struct changes), Windows C++, Kotlin method registry (setSubtitleDelay also triggers a render request), and Dart methods taking Duration/bool. Tests: pts-shift and mute state-machine unit tests, C ABI round-trip and null-pointer tests, and 4 Dart method-channel forwarding tests. Co-Authored-By: Claude Fable 5 --- crates/erika/src/presenter.rs | 258 +++++++++++++++- crates/erika_capi/include/erika.h | 13 + crates/erika_capi/src/android_jni.rs | 21 ++ crates/erika_capi/src/lib.rs | 286 ++++++++++++++++++ .../erika_flutter/ErikaFlutterPlugin.kt | 5 + .../ios/Classes/ErikaFlutterPlugin.swift | 68 +++++ .../erika_flutter/lib/src/erika_player.dart | 57 ++++ .../macos/Classes/ErikaFlutterPlugin.swift | 71 +++++ .../erika_flutter/test/erika_player_test.dart | 88 ++++++ .../windows/erika_flutter_plugin.cpp | 66 ++++ 10 files changed, 924 insertions(+), 9 deletions(-) diff --git a/crates/erika/src/presenter.rs b/crates/erika/src/presenter.rs index 6ee0ffe..0f321ab 100644 --- a/crates/erika/src/presenter.rs +++ b/crates/erika/src/presenter.rs @@ -72,6 +72,8 @@ const DANMAKU_PREPARE_REFRESH_MARGIN: Duration = Duration::from_secs(4); const DANMAKU_PLAN_LOOKAHEAD: Duration = Duration::from_secs(8); const DANMAKU_PLAN_LOOKBACK_PADDING: Duration = Duration::from_secs(2); const DEFAULT_SUBTITLE_FONT_SCALE: f64 = 1.0; +const SUBTITLE_DELAY_LIMIT_SECONDS: f64 = 60.0; +const AUDIO_DELAY_LIMIT_SECONDS: f64 = 10.0; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TransitionFramePolicy { @@ -237,6 +239,10 @@ pub struct PresenterRuntime { current_surface_metrics: Option, current_danmaku_viewport: Option, subtitle_font_scale: f64, + subtitle_delay: f64, + audio_delay: f64, + muted: bool, + saved_volume: f64, subtitles: SubtitleFrameState, overlay: OverlayTimeline, render_test_pattern_when_idle: bool, @@ -534,6 +540,10 @@ impl PresenterRuntime { current_surface_metrics: None, current_danmaku_viewport: None, subtitle_font_scale: DEFAULT_SUBTITLE_FONT_SCALE, + subtitle_delay: 0.0, + audio_delay: 0.0, + muted: false, + saved_volume: 1.0, subtitles: SubtitleFrameState::default(), overlay: config.overlay, render_test_pattern_when_idle: config.render_test_pattern_when_idle, @@ -677,11 +687,28 @@ impl PresenterRuntime { } pub fn set_volume(&mut self, volume: f64) { - self.audio_output.set_volume(volume as f32); + self.saved_volume = normalize_presenter_volume(volume); + let output = effective_output_volume(self.muted, self.saved_volume); + self.audio_output.set_volume(output as f32); } + /// Returns the user-selected volume. While muted the audio output runs at + /// zero gain, but this still reports the saved volume so UIs can restore + /// the slider position on unmute. pub fn volume(&self) -> f64 { - self.audio_output.volume() as f64 + self.saved_volume + } + + /// Mutes or unmutes audio without discarding the saved volume. Volume + /// changes made while muted are remembered and applied on unmute. + pub fn set_muted(&mut self, muted: bool) { + self.muted = muted; + let output = effective_output_volume(self.muted, self.saved_volume); + self.audio_output.set_volume(output as f32); + } + + pub fn muted(&self) -> bool { + self.muted } pub fn set_subtitle_scale(&mut self, scale: f64) { @@ -693,6 +720,39 @@ impl PresenterRuntime { self.refresh_current_overlay(); } + /// Sets the subtitle delay in seconds with mpv `sub-delay` semantics: a + /// positive value displays subtitles later relative to the video clock, a + /// negative value displays them earlier. The value is clamped to ±60 s. + /// + /// Negative delays are bounded by decode lookahead: subtitle cues are + /// decoded only slightly ahead of the playback clock, so cues shifted + /// earlier than the buffered window appear once decode catches up (or + /// after a seek). + pub fn set_subtitle_delay(&mut self, delay_seconds: f64) { + let delay = normalize_subtitle_delay(delay_seconds); + if (self.subtitle_delay - delay).abs() < 0.001 { + return; + } + self.subtitle_delay = delay; + self.refresh_current_overlay(); + } + + pub fn subtitle_delay(&self) -> f64 { + self.subtitle_delay + } + + /// Sets the audio delay in seconds with mpv `audio-delay` semantics: a + /// positive value delays audio relative to the video. The value is + /// clamped to ±10 s and takes effect from the next decoded audio frame; + /// already-queued output is not rewritten. + pub fn set_audio_delay(&mut self, delay_seconds: f64) { + self.audio_delay = normalize_audio_delay(delay_seconds); + } + + pub fn audio_delay(&self) -> f64 { + self.audio_delay + } + pub fn set_danmaku_timeline(&mut self, timeline: DanmakuTimeline) { self.danmaku_session.replace_default_track( timeline, @@ -1011,7 +1071,7 @@ impl PresenterRuntime { .render(self.current_media_time, capture_overlay_viewport); let subtitle_style = self.subtitle_ass_style(capture_overlay.viewport); self.subtitles.append_to_overlay( - self.current_media_time, + shifted_subtitle_pts(self.current_media_time, self.subtitle_delay), &mut capture_overlay, subtitle_style, ); @@ -1283,8 +1343,11 @@ impl PresenterRuntime { .overlay .render(pts, OverlayViewport::new(viewport.width, viewport.height)); let subtitle_style = self.subtitle_ass_style(overlay.viewport); - self.subtitles - .append_to_overlay(pts, &mut overlay, subtitle_style); + self.subtitles.append_to_overlay( + shifted_subtitle_pts(pts, self.subtitle_delay), + &mut overlay, + subtitle_style, + ); if subtitle_diag_enabled() { eprintln!( "[erika-subtitle-diag] stage=update_overlay pts={} gen={} video={}x{} overlay={}", @@ -1444,8 +1507,11 @@ impl PresenterRuntime { OverlayViewport::new(viewport.width, viewport.height), ); let subtitle_style = self.subtitle_ass_style(overlay.viewport); - self.subtitles - .append_to_overlay(player_time, &mut overlay, subtitle_style); + self.subtitles.append_to_overlay( + shifted_subtitle_pts(player_time, self.subtitle_delay), + &mut overlay, + subtitle_style, + ); if subtitle_diag_enabled() { eprintln!( "[erika-subtitle-diag] stage=clock_overlay player={} gen={} overlay_viewport={}x{} overlay={}", @@ -1484,8 +1550,11 @@ impl PresenterRuntime { OverlayViewport::new(viewport.width, viewport.height), ); let subtitle_style = self.subtitle_ass_style(overlay.viewport); - self.subtitles - .append_to_overlay(self.current_media_time, &mut overlay, subtitle_style); + self.subtitles.append_to_overlay( + shifted_subtitle_pts(self.current_media_time, self.subtitle_delay), + &mut overlay, + subtitle_style, + ); self.current_overlay = Some(overlay); } @@ -1946,6 +2015,14 @@ impl PresenterRuntime { self.audio_configured = true; self.last_audio_clock_report = None; } + let mut frame = frame; + // The audio clock is anchored on frame pts, so shifting the pts here + // delays (or advances) audio relative to the video while clock sync, + // subtitles, and danmaku follow automatically. + frame.frame.pts = frame + .frame + .pts + .map(|pts| shifted_audio_pts(pts, self.audio_delay)); match self.audio_output.push(frame.frame) { Ok(_) => self.stats.pushed_audio_frames += 1, Err(error) => { @@ -2139,6 +2216,68 @@ fn normalize_subtitle_font_scale(scale: f64) -> f64 { } } +fn normalize_subtitle_delay(delay_seconds: f64) -> f64 { + if delay_seconds.is_finite() { + delay_seconds.clamp(-SUBTITLE_DELAY_LIMIT_SECONDS, SUBTITLE_DELAY_LIMIT_SECONDS) + } else { + 0.0 + } +} + +fn normalize_audio_delay(delay_seconds: f64) -> f64 { + if delay_seconds.is_finite() { + delay_seconds.clamp(-AUDIO_DELAY_LIMIT_SECONDS, AUDIO_DELAY_LIMIT_SECONDS) + } else { + 0.0 + } +} + +fn normalize_presenter_volume(volume: f64) -> f64 { + if volume.is_finite() { + volume.clamp(0.0, 1.0) + } else { + 1.0 + } +} + +fn effective_output_volume(muted: bool, saved_volume: f64) -> f64 { + if muted { 0.0 } else { saved_volume } +} + +/// Maps a playback clock time to the subtitle lookup time for a given +/// `sub-delay`. A positive delay displays subtitles later, so the lookup time +/// moves backwards (`pts - delay`); a negative delay moves it forwards. +/// `Duration` cannot go negative, so lookups clamp at zero. +fn shifted_subtitle_pts(pts: Duration, delay_seconds: f64) -> Duration { + let delay = normalize_subtitle_delay(delay_seconds); + if delay == 0.0 { + return pts; + } + let magnitude = Duration::from_secs_f64(delay.abs()); + if delay > 0.0 { + pts.saturating_sub(magnitude) + } else { + pts.saturating_add(magnitude) + } +} + +/// Maps a decoded audio frame's pts to its output pts for a given +/// `audio-delay`. A positive delay pushes the audio clock forwards (`pts + +/// delay`), which delays audio content relative to the video the clock +/// drives; a negative delay pulls it backwards, clamping at zero. +fn shifted_audio_pts(pts: Duration, delay_seconds: f64) -> Duration { + let delay = normalize_audio_delay(delay_seconds); + if delay == 0.0 { + return pts; + } + let magnitude = Duration::from_secs_f64(delay.abs()); + if delay > 0.0 { + pts.saturating_add(magnitude) + } else { + pts.saturating_sub(magnitude) + } +} + fn bump_generation(current_generation: &mut u64, danmaku_generation: &mut u64) { *danmaku_generation = danmaku_generation.saturating_add(1).max(1); *current_generation = current_generation @@ -3297,6 +3436,107 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text assert_eq!(presenter.volume(), 1.0); } + #[test] + #[cfg(feature = "wgpu")] + fn presenter_mute_preserves_saved_volume() { + let mut presenter = PresenterRuntime::new(PresenterConfig::default()).unwrap(); + + presenter.set_volume(0.6); + presenter.set_muted(true); + assert!(presenter.muted()); + // Output gain is silenced while the reported volume stays at the + // user's saved value. + assert_eq!(presenter.audio_output.volume(), 0.0); + assert!((presenter.volume() - 0.6).abs() < 0.000_001); + + // Volume changes while muted are remembered but not applied yet. + presenter.set_volume(0.3); + assert_eq!(presenter.audio_output.volume(), 0.0); + assert!((presenter.volume() - 0.3).abs() < 0.000_001); + + presenter.set_muted(false); + assert!(!presenter.muted()); + assert!((f64::from(presenter.audio_output.volume()) - 0.3).abs() < 0.000_001); + } + + #[test] + fn effective_output_volume_silences_only_while_muted() { + assert_eq!(effective_output_volume(true, 0.7), 0.0); + assert_eq!(effective_output_volume(false, 0.7), 0.7); + assert_eq!(effective_output_volume(false, 0.0), 0.0); + } + + #[test] + fn subtitle_pts_shift_follows_mpv_sub_delay_semantics() { + let pts = Duration::from_secs(10); + + // Positive delay shows subtitles later: the lookup time lags the clock. + assert_eq!( + shifted_subtitle_pts(pts, 2.0), + Duration::from_secs(8), + "positive delay must move the lookup time backwards" + ); + // Negative delay shows subtitles earlier: the lookup time leads. + assert_eq!(shifted_subtitle_pts(pts, -2.0), Duration::from_secs(12)); + assert_eq!(shifted_subtitle_pts(pts, 0.0), pts); + } + + #[test] + fn subtitle_pts_shift_saturates_and_clamps() { + let pts = Duration::from_secs(1); + + // Underflow clamps at zero instead of panicking. + assert_eq!(shifted_subtitle_pts(pts, 5.0), Duration::ZERO); + // Delay magnitude clamps to ±60 s. + assert_eq!( + shifted_subtitle_pts(pts, 1_000.0), + Duration::ZERO, + "positive overflow clamps to the 60 s limit before shifting" + ); + assert_eq!(shifted_subtitle_pts(pts, -1_000.0), Duration::from_secs(61)); + // Non-finite delays are ignored. + assert_eq!(shifted_subtitle_pts(pts, f64::NAN), pts); + assert_eq!(shifted_subtitle_pts(pts, f64::INFINITY), pts); + } + + #[test] + fn audio_pts_shift_follows_mpv_audio_delay_semantics() { + let pts = Duration::from_secs(10); + + // Positive delay pushes audio content later than the video. + assert_eq!(shifted_audio_pts(pts, 2.0), Duration::from_secs(12)); + assert_eq!(shifted_audio_pts(pts, -2.0), Duration::from_secs(8)); + assert_eq!(shifted_audio_pts(pts, 0.0), pts); + // Underflow clamps at zero; delay magnitude clamps to ±10 s. + assert_eq!( + shifted_audio_pts(Duration::from_secs(1), -5.0), + Duration::ZERO + ); + assert_eq!(shifted_audio_pts(pts, 1_000.0), Duration::from_secs(20)); + assert_eq!(shifted_audio_pts(pts, f64::NAN), pts); + } + + #[test] + #[cfg(feature = "wgpu")] + fn presenter_delay_setters_clamp_their_ranges() { + let mut presenter = PresenterRuntime::new(PresenterConfig::default()).unwrap(); + + assert_eq!(presenter.subtitle_delay(), 0.0); + assert_eq!(presenter.audio_delay(), 0.0); + presenter.set_subtitle_delay(1.5); + assert!((presenter.subtitle_delay() - 1.5).abs() < 0.000_001); + presenter.set_subtitle_delay(-90.0); + assert_eq!(presenter.subtitle_delay(), -60.0); + presenter.set_subtitle_delay(f64::NAN); + assert_eq!(presenter.subtitle_delay(), 0.0); + presenter.set_audio_delay(25.0); + assert_eq!(presenter.audio_delay(), 10.0); + presenter.set_audio_delay(-25.0); + assert_eq!(presenter.audio_delay(), -10.0); + presenter.set_audio_delay(f64::NAN); + assert_eq!(presenter.audio_delay(), 0.0); + } + #[test] fn surface_dimensions_are_converted_to_full_output_danmaku_viewport() { let viewport = surface_metrics_to_viewport(SurfaceMetrics::new(1600, 900, 2.0)); diff --git a/crates/erika_capi/include/erika.h b/crates/erika_capi/include/erika.h index f34c30c..48703af 100644 --- a/crates/erika_capi/include/erika.h +++ b/crates/erika_capi/include/erika.h @@ -419,8 +419,21 @@ ErikaStatus erika_presenter_close(ErikaPresenterHandle *handle); ErikaStatus erika_presenter_seek(ErikaPresenterHandle *handle, uint64_t position_micros); ErikaStatus erika_presenter_set_playback_rate(ErikaPresenterHandle *handle, double rate); ErikaStatus erika_presenter_set_volume(ErikaPresenterHandle *handle, double volume); +/* get_volume reports the saved user volume even while muted so hosts can + * restore their slider position on unmute. */ +ErikaStatus erika_presenter_get_volume(ErikaPresenterHandle *handle, double *out_volume); +/* Mute silences output without discarding the saved volume; set_volume while + * muted updates the saved value and is applied on unmute. */ +ErikaStatus erika_presenter_set_muted(ErikaPresenterHandle *handle, bool muted); +ErikaStatus erika_presenter_muted(ErikaPresenterHandle *handle, bool *out_muted); ErikaStatus erika_presenter_set_upscaler(ErikaPresenterHandle *handle, int32_t mode); ErikaStatus erika_presenter_set_subtitle_scale(ErikaPresenterHandle *handle, double scale); +/* Delays follow mpv semantics: positive sub-delay shows subtitles later, + * positive audio-delay plays audio later than video. seconds is clamped to + * ±60 (subtitle) / ±10 (audio). Negative subtitle delay is limited by decode + * lookahead; audio delay applies from the next decoded frame. */ +ErikaStatus erika_presenter_set_subtitle_delay(ErikaPresenterHandle *handle, double seconds); +ErikaStatus erika_presenter_set_audio_delay(ErikaPresenterHandle *handle, double seconds); ErikaStatus erika_presenter_set_output_headroom( ErikaPresenterHandle *handle, float headroom, diff --git a/crates/erika_capi/src/android_jni.rs b/crates/erika_capi/src/android_jni.rs index 9b01f27..e334a2a 100644 --- a/crates/erika_capi/src/android_jni.rs +++ b/crates/erika_capi/src/android_jni.rs @@ -755,6 +755,23 @@ unsafe fn invoke_presenter( let scale = required_f64(args, "scale")?; status_value(unsafe { erika_presenter_set_subtitle_scale(handle, scale) }) } + "setSubtitleDelay" => { + let seconds = required_f64(args, "seconds")?; + status_value(unsafe { erika_presenter_set_subtitle_delay(handle, seconds) }) + } + "setAudioDelay" => { + let seconds = required_f64(args, "seconds")?; + status_value(unsafe { erika_presenter_set_audio_delay(handle, seconds) }) + } + "setMuted" => { + let muted = required_bool(args, "muted")?; + status_value(unsafe { erika_presenter_set_muted(handle, muted) }) + } + "getVolume" => { + let mut volume = 1.0f64; + call_status(unsafe { erika_presenter_get_volume(handle, &mut volume) })?; + Ok(json!(volume)) + } "setOutputHeadroom" => { let headroom = required_f64(args, "headroom")? as f32; let known = @@ -1374,6 +1391,10 @@ fn optional_bool(args: &Map, name: &str) -> Option { args.get(name).and_then(Value::as_bool) } +fn required_bool(args: &Map, name: &str) -> Result { + optional_bool(args, name).ok_or_else(|| format!("{name} is required")) +} + fn c_string(value: &str, name: &str) -> Result { CString::new(value).map_err(|_| format!("{name} contains an embedded NUL byte")) } diff --git a/crates/erika_capi/src/lib.rs b/crates/erika_capi/src/lib.rs index b7dd82c..49bfd94 100644 --- a/crates/erika_capi/src/lib.rs +++ b/crates/erika_capi/src/lib.rs @@ -1525,6 +1525,111 @@ pub unsafe extern "C" fn erika_presenter_set_volume( }) } +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" +))] +/// # Safety +/// `handle` must be a live pointer returned by `erika_presenter_create*` and +/// `out_volume` must point to writable memory for one `f64`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_get_volume( + handle: *mut ErikaPresenterHandle, + out_volume: *mut f64, +) -> ErikaStatus { + if out_volume.is_null() { + return ErikaStatus::NullPointer; + } + with_presenter_mut(handle, |handle| { + let volume = handle.presenter.volume(); + unsafe { *out_volume = volume }; + ErikaStatus::Ok + }) +} + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" +))] +/// # Safety +/// `handle` must be a live pointer returned by `erika_presenter_create*`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_set_muted( + handle: *mut ErikaPresenterHandle, + muted: bool, +) -> ErikaStatus { + with_presenter_mut(handle, |handle| { + handle.presenter.set_muted(muted); + ErikaStatus::Ok + }) +} + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" +))] +/// # Safety +/// `handle` must be a live pointer returned by `erika_presenter_create*` and +/// `out_muted` must point to writable memory for one `bool`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_muted( + handle: *mut ErikaPresenterHandle, + out_muted: *mut bool, +) -> ErikaStatus { + if out_muted.is_null() { + return ErikaStatus::NullPointer; + } + with_presenter_mut(handle, |handle| { + let muted = handle.presenter.muted(); + unsafe { *out_muted = muted }; + ErikaStatus::Ok + }) +} + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" +))] +/// # Safety +/// `handle` must be a live pointer returned by `erika_presenter_create*`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_set_subtitle_delay( + handle: *mut ErikaPresenterHandle, + seconds: f64, +) -> ErikaStatus { + with_presenter_mut(handle, |handle| { + handle.presenter.set_subtitle_delay(seconds); + ErikaStatus::Ok + }) +} + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" +))] +/// # Safety +/// `handle` must be a live pointer returned by `erika_presenter_create*`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_set_audio_delay( + handle: *mut ErikaPresenterHandle, + seconds: f64, +) -> ErikaStatus { + with_presenter_mut(handle, |handle| { + handle.presenter.set_audio_delay(seconds); + ErikaStatus::Ok + }) +} + #[cfg(any( target_os = "macos", target_os = "ios", @@ -2285,6 +2390,82 @@ pub unsafe extern "C" fn erika_presenter_set_volume( ErikaStatus::PlayerError } +#[cfg(not(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" +)))] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_get_volume( + _handle: *mut std::ffi::c_void, + out_volume: *mut f64, +) -> ErikaStatus { + if out_volume.is_null() { + return ErikaStatus::NullPointer; + } + ErikaStatus::PlayerError +} + +#[cfg(not(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" +)))] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_set_muted( + _handle: *mut std::ffi::c_void, + _muted: bool, +) -> ErikaStatus { + ErikaStatus::PlayerError +} + +#[cfg(not(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" +)))] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_muted( + _handle: *mut std::ffi::c_void, + out_muted: *mut bool, +) -> ErikaStatus { + if out_muted.is_null() { + return ErikaStatus::NullPointer; + } + ErikaStatus::PlayerError +} + +#[cfg(not(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" +)))] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_set_subtitle_delay( + _handle: *mut std::ffi::c_void, + _seconds: f64, +) -> ErikaStatus { + ErikaStatus::PlayerError +} + +#[cfg(not(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" +)))] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_set_audio_delay( + _handle: *mut std::ffi::c_void, + _seconds: f64, +) -> ErikaStatus { + ErikaStatus::PlayerError +} + #[cfg(not(any( target_os = "macos", target_os = "ios", @@ -3644,6 +3825,111 @@ mod tests { unsafe { erika_presenter_destroy(handle) }; } + #[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" + ))] + #[test] + fn c_presenter_volume_and_mute_round_trip() { + let mut volume = f64::NAN; + assert_eq!( + unsafe { erika_presenter_get_volume(std::ptr::null_mut(), &mut volume) }, + ErikaStatus::NullPointer + ); + let mut muted = false; + assert_eq!( + unsafe { erika_presenter_set_muted(std::ptr::null_mut(), true) }, + ErikaStatus::NullPointer + ); + assert_eq!( + unsafe { erika_presenter_muted(std::ptr::null_mut(), &mut muted) }, + ErikaStatus::NullPointer + ); + + let handle = erika_presenter_create(); + assert!(!handle.is_null()); + assert_eq!( + unsafe { erika_presenter_get_volume(handle, std::ptr::null_mut()) }, + ErikaStatus::NullPointer + ); + assert_eq!( + unsafe { erika_presenter_muted(handle, std::ptr::null_mut()) }, + ErikaStatus::NullPointer + ); + + assert_eq!( + unsafe { erika_presenter_set_volume(handle, 0.4) }, + ErikaStatus::Ok + ); + assert_eq!( + unsafe { erika_presenter_set_muted(handle, true) }, + ErikaStatus::Ok + ); + assert_eq!( + unsafe { erika_presenter_muted(handle, &mut muted) }, + ErikaStatus::Ok + ); + assert!(muted); + // Muting must not clobber the saved volume the getter reports. + assert_eq!( + unsafe { erika_presenter_get_volume(handle, &mut volume) }, + ErikaStatus::Ok + ); + assert!((volume - 0.4).abs() < 0.000_001); + + assert_eq!( + unsafe { erika_presenter_set_muted(handle, false) }, + ErikaStatus::Ok + ); + assert_eq!( + unsafe { erika_presenter_muted(handle, &mut muted) }, + ErikaStatus::Ok + ); + assert!(!muted); + unsafe { erika_presenter_destroy(handle) }; + } + + #[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" + ))] + #[test] + fn c_presenter_delay_setters_accept_valid_handle() { + assert_eq!( + unsafe { erika_presenter_set_subtitle_delay(std::ptr::null_mut(), 1.0) }, + ErikaStatus::NullPointer + ); + assert_eq!( + unsafe { erika_presenter_set_audio_delay(std::ptr::null_mut(), 1.0) }, + ErikaStatus::NullPointer + ); + + let handle = erika_presenter_create(); + assert!(!handle.is_null()); + assert_eq!( + unsafe { erika_presenter_set_subtitle_delay(handle, 2.5) }, + ErikaStatus::Ok + ); + assert_eq!( + unsafe { erika_presenter_set_audio_delay(handle, -0.5) }, + ErikaStatus::Ok + ); + // Out-of-range and non-finite values are clamped/ignored, never errors. + assert_eq!( + unsafe { erika_presenter_set_subtitle_delay(handle, f64::NAN) }, + ErikaStatus::Ok + ); + assert_eq!( + unsafe { erika_presenter_set_audio_delay(handle, 1_000.0) }, + ErikaStatus::Ok + ); + unsafe { erika_presenter_destroy(handle) }; + } + #[cfg(any( target_os = "macos", target_os = "ios", diff --git a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt index eda00f5..481a331 100644 --- a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt +++ b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt @@ -1692,6 +1692,7 @@ class ErikaFlutterPlugin : "seek", "setUpscaler", "setSubtitleScale", + "setSubtitleDelay", "addExternalSubtitle", "removeSubtitleTrack", "loadDanmakuFile", @@ -1718,8 +1719,12 @@ class ErikaFlutterPlugin : "seek", "setPlaybackRate", "setVolume", + "getVolume", + "setMuted", "setUpscaler", "setSubtitleScale", + "setSubtitleDelay", + "setAudioDelay", "getUpscalerStatus", "getOutputStatus", "getPresenterStats", diff --git a/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift b/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift index 98b71a4..dab01e0 100644 --- a/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift +++ b/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift @@ -345,8 +345,12 @@ private final class ErikaNativeLibrary { typealias SeekFn = @convention(c) (UnsafeMutableRawPointer?, UInt64) -> Int32 typealias SetPlaybackRateFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 typealias SetVolumeFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 + typealias GetVolumeFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer?) -> Int32 + typealias SetMutedFn = @convention(c) (UnsafeMutableRawPointer?, Bool) -> Int32 typealias SetUpscalerFn = @convention(c) (UnsafeMutableRawPointer?, Int32) -> Int32 typealias SetSubtitleScaleFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 + typealias SetSubtitleDelayFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 + typealias SetAudioDelayFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 typealias GetUpscalerStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 typealias GetOutputStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 typealias SelectTrackFn = @convention(c) (UnsafeMutableRawPointer?, Int64) -> Int32 @@ -406,8 +410,12 @@ private final class ErikaNativeLibrary { let seek: SeekFn let setPlaybackRate: SetPlaybackRateFn? let setVolume: SetVolumeFn? + let getVolume: GetVolumeFn? + let setMuted: SetMutedFn? let setUpscaler: SetUpscalerFn? let setSubtitleScale: SetSubtitleScaleFn? + let setSubtitleDelay: SetSubtitleDelayFn? + let setAudioDelay: SetAudioDelayFn? let getUpscalerStatus: GetUpscalerStatusFn? let getOutputStatus: GetOutputStatusFn? let selectAudioTrack: SelectTrackFn @@ -463,8 +471,12 @@ private final class ErikaNativeLibrary { seek = try Self.load("erika_presenter_seek", from: libraryHandle, as: SeekFn.self) setPlaybackRate = Self.loadOptional("erika_presenter_set_playback_rate", from: libraryHandle, as: SetPlaybackRateFn.self) setVolume = Self.loadOptional("erika_presenter_set_volume", from: libraryHandle, as: SetVolumeFn.self) + getVolume = Self.loadOptional("erika_presenter_get_volume", from: libraryHandle, as: GetVolumeFn.self) + setMuted = Self.loadOptional("erika_presenter_set_muted", from: libraryHandle, as: SetMutedFn.self) setUpscaler = Self.loadOptional("erika_presenter_set_upscaler", from: libraryHandle, as: SetUpscalerFn.self) setSubtitleScale = Self.loadOptional("erika_presenter_set_subtitle_scale", from: libraryHandle, as: SetSubtitleScaleFn.self) + setSubtitleDelay = Self.loadOptional("erika_presenter_set_subtitle_delay", from: libraryHandle, as: SetSubtitleDelayFn.self) + setAudioDelay = Self.loadOptional("erika_presenter_set_audio_delay", from: libraryHandle, as: SetAudioDelayFn.self) getUpscalerStatus = Self.loadOptional("erika_presenter_get_upscaler_status", from: libraryHandle, as: GetUpscalerStatusFn.self) getOutputStatus = Self.loadOptional("erika_presenter_get_output_status", from: libraryHandle, as: GetOutputStatusFn.self) selectAudioTrack = try Self.load("erika_presenter_select_audio_track", from: libraryHandle, as: SelectTrackFn.self) @@ -630,6 +642,22 @@ private final class ErikaPlayerHost { try check(setVolume(handle, clampedVolume), operation: "set_volume") } + func volume() throws -> Double { + guard let getVolume = library.getVolume else { + throw ErikaPluginError.symbolMissing("erika_presenter_get_volume") + } + var value: Double = 1.0 + try check(getVolume(handle, &value), operation: "get_volume") + return value + } + + func setMuted(_ muted: Bool) throws { + guard let setMuted = library.setMuted else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_muted") + } + try check(setMuted(handle, muted), operation: "set_muted") + } + func setUpscaler(mode: Int32) throws { guard let setUpscaler = library.setUpscaler else { throw ErikaPluginError.symbolMissing("erika_presenter_set_upscaler") @@ -645,6 +673,22 @@ private final class ErikaPlayerHost { try check(setSubtitleScale(handle, clampedScale), operation: "set_subtitle_scale") } + func setSubtitleDelay(_ seconds: Double) throws { + guard let setSubtitleDelay = library.setSubtitleDelay else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_subtitle_delay") + } + let clampedSeconds = seconds.isFinite ? min(max(seconds, -60.0), 60.0) : 0.0 + try check(setSubtitleDelay(handle, clampedSeconds), operation: "set_subtitle_delay") + } + + func setAudioDelay(_ seconds: Double) throws { + guard let setAudioDelay = library.setAudioDelay else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_audio_delay") + } + let clampedSeconds = seconds.isFinite ? min(max(seconds, -10.0), 10.0) : 0.0 + try check(setAudioDelay(handle, clampedSeconds), operation: "set_audio_delay") + } + func upscalerStatus() throws -> [String: Any] { guard let getStatus = library.getUpscalerStatus else { throw ErikaPluginError.symbolMissing("erika_presenter_get_upscaler_status") @@ -1438,6 +1482,16 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan } try playerHost(from: args).setVolume(volume) result(nil) + case "getVolume": + let args = try dictionaryArgs(call.arguments) + result(try playerHost(from: args).volume()) + case "setMuted": + let args = try dictionaryArgs(call.arguments) + guard let muted = boolValue(args["muted"]) else { + throw ErikaPluginError.invalidArguments("muted is required.") + } + try playerHost(from: args).setMuted(muted) + result(nil) case "setUpscaler": let args = try dictionaryArgs(call.arguments) guard let mode = int32Value(args["mode"]) else { @@ -1452,6 +1506,20 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan } try playerHost(from: args).setSubtitleScale(scale) result(nil) + case "setSubtitleDelay": + let args = try dictionaryArgs(call.arguments) + guard let seconds = doubleValue(args["seconds"]) else { + throw ErikaPluginError.invalidArguments("seconds is required.") + } + try playerHost(from: args).setSubtitleDelay(seconds) + result(nil) + case "setAudioDelay": + let args = try dictionaryArgs(call.arguments) + guard let seconds = doubleValue(args["seconds"]) else { + throw ErikaPluginError.invalidArguments("seconds is required.") + } + try playerHost(from: args).setAudioDelay(seconds) + result(nil) case "getUpscalerStatus": let args = try dictionaryArgs(call.arguments) result(try playerHost(from: args).upscalerStatus()) diff --git a/packages/erika_flutter/lib/src/erika_player.dart b/packages/erika_flutter/lib/src/erika_player.dart index c6fd621..7184f9a 100644 --- a/packages/erika_flutter/lib/src/erika_player.dart +++ b/packages/erika_flutter/lib/src/erika_player.dart @@ -750,6 +750,31 @@ class ErikaPlayer { }); } + /// Returns the saved user volume in `[0.0, 1.0]`. While muted this still + /// reports the volume set via [setVolume] so UIs can keep their slider + /// position; the audio output itself runs at zero gain. + Future getVolume() async { + final playerId = await ensureCreated(); + final volume = await _channel.invokeMethod( + 'getVolume', + {'playerId': playerId}, + ); + if (volume == null) { + throw StateError('Erika volume returned null.'); + } + return volume.toDouble(); + } + + /// Mutes or unmutes audio output without discarding the saved volume. + /// Volume changes made while muted are remembered and applied on unmute. + Future setMuted(bool muted) async { + final playerId = await ensureCreated(); + await _invoke('setMuted', { + 'playerId': playerId, + 'muted': muted, + }); + } + Future setUpscaler(ErikaUpscalerMode mode) async { final playerId = await ensureCreated(); await _invoke('setUpscaler', { @@ -767,6 +792,38 @@ class ErikaPlayer { }); } + /// Sets the subtitle delay with mpv `sub-delay` semantics: a positive + /// [delay] displays subtitles later relative to the video, a negative one + /// earlier. Clamped to plus or minus 60 seconds. + Future setSubtitleDelay(Duration delay) async { + final playerId = await ensureCreated(); + final seconds = + (delay.inMicroseconds / Duration.microsecondsPerSecond).clamp( + -60.0, + 60.0, + ); + await _invoke('setSubtitleDelay', { + 'playerId': playerId, + 'seconds': seconds, + }); + } + + /// Sets the audio delay with mpv `audio-delay` semantics: a positive + /// [delay] plays audio later than the video. Clamped to plus or minus + /// 10 seconds. + Future setAudioDelay(Duration delay) async { + final playerId = await ensureCreated(); + final seconds = + (delay.inMicroseconds / Duration.microsecondsPerSecond).clamp( + -10.0, + 10.0, + ); + await _invoke('setAudioDelay', { + 'playerId': playerId, + 'seconds': seconds, + }); + } + Future getUpscalerStatus() async { final playerId = await ensureCreated(); final status = await _channel.invokeMethod>( diff --git a/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift b/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift index 14684fc..812ae3e 100644 --- a/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift +++ b/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift @@ -216,8 +216,12 @@ private final class ErikaNativeLibrary { typealias SeekFn = @convention(c) (UnsafeMutableRawPointer?, UInt64) -> Int32 typealias SetPlaybackRateFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 typealias SetVolumeFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 + typealias GetVolumeFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer?) -> Int32 + typealias SetMutedFn = @convention(c) (UnsafeMutableRawPointer?, Bool) -> Int32 typealias SetUpscalerFn = @convention(c) (UnsafeMutableRawPointer?, Int32) -> Int32 typealias SetSubtitleScaleFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 + typealias SetSubtitleDelayFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 + typealias SetAudioDelayFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 typealias GetUpscalerStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 typealias GetOutputStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 typealias SelectTrackFn = @convention(c) (UnsafeMutableRawPointer?, Int64) -> Int32 @@ -277,8 +281,12 @@ private final class ErikaNativeLibrary { let seek: SeekFn let setPlaybackRate: SetPlaybackRateFn? let setVolume: SetVolumeFn? + let getVolume: GetVolumeFn? + let setMuted: SetMutedFn? let setUpscaler: SetUpscalerFn? let setSubtitleScale: SetSubtitleScaleFn? + let setSubtitleDelay: SetSubtitleDelayFn? + let setAudioDelay: SetAudioDelayFn? let getUpscalerStatus: GetUpscalerStatusFn? let getOutputStatus: GetOutputStatusFn? let selectAudioTrack: SelectTrackFn @@ -328,8 +336,12 @@ private final class ErikaNativeLibrary { seek = try Self.load("erika_presenter_seek", from: libraryHandle, as: SeekFn.self) setPlaybackRate = Self.loadOptional("erika_presenter_set_playback_rate", from: libraryHandle, as: SetPlaybackRateFn.self) setVolume = Self.loadOptional("erika_presenter_set_volume", from: libraryHandle, as: SetVolumeFn.self) + getVolume = Self.loadOptional("erika_presenter_get_volume", from: libraryHandle, as: GetVolumeFn.self) + setMuted = Self.loadOptional("erika_presenter_set_muted", from: libraryHandle, as: SetMutedFn.self) setUpscaler = Self.loadOptional("erika_presenter_set_upscaler", from: libraryHandle, as: SetUpscalerFn.self) setSubtitleScale = Self.loadOptional("erika_presenter_set_subtitle_scale", from: libraryHandle, as: SetSubtitleScaleFn.self) + setSubtitleDelay = Self.loadOptional("erika_presenter_set_subtitle_delay", from: libraryHandle, as: SetSubtitleDelayFn.self) + setAudioDelay = Self.loadOptional("erika_presenter_set_audio_delay", from: libraryHandle, as: SetAudioDelayFn.self) getUpscalerStatus = Self.loadOptional("erika_presenter_get_upscaler_status", from: libraryHandle, as: GetUpscalerStatusFn.self) getOutputStatus = Self.loadOptional("erika_presenter_get_output_status", from: libraryHandle, as: GetOutputStatusFn.self) selectAudioTrack = try Self.load("erika_presenter_select_audio_track", from: libraryHandle, as: SelectTrackFn.self) @@ -508,6 +520,22 @@ private final class ErikaPlayerHost { try check(setVolume(handle, clampedVolume), operation: "set_volume") } + func volume() throws -> Double { + guard let getVolume = library.getVolume else { + throw ErikaPluginError.symbolMissing("erika_presenter_get_volume") + } + var value: Double = 1.0 + try check(getVolume(handle, &value), operation: "get_volume") + return value + } + + func setMuted(_ muted: Bool) throws { + guard let setMuted = library.setMuted else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_muted") + } + try check(setMuted(handle, muted), operation: "set_muted") + } + func setUpscaler(mode: Int32) throws { guard let setUpscaler = library.setUpscaler else { throw ErikaPluginError.symbolMissing("erika_presenter_set_upscaler") @@ -523,6 +551,22 @@ private final class ErikaPlayerHost { try check(setSubtitleScale(handle, clampedScale), operation: "set_subtitle_scale") } + func setSubtitleDelay(_ seconds: Double) throws { + guard let setSubtitleDelay = library.setSubtitleDelay else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_subtitle_delay") + } + let clampedSeconds = seconds.isFinite ? min(max(seconds, -60.0), 60.0) : 0.0 + try check(setSubtitleDelay(handle, clampedSeconds), operation: "set_subtitle_delay") + } + + func setAudioDelay(_ seconds: Double) throws { + guard let setAudioDelay = library.setAudioDelay else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_audio_delay") + } + let clampedSeconds = seconds.isFinite ? min(max(seconds, -10.0), 10.0) : 0.0 + try check(setAudioDelay(handle, clampedSeconds), operation: "set_audio_delay") + } + func upscalerStatus() throws -> [String: Any] { guard let getStatus = library.getUpscalerStatus else { throw ErikaPluginError.symbolMissing("erika_presenter_get_upscaler_status") @@ -1466,6 +1510,17 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan } try host.setVolume(volume) result(nil) + case "getVolume": + let args = try dictionaryArgs(call.arguments) + result(try playerHost(from: args).volume()) + case "setMuted": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let muted = boolValue(args["muted"]) else { + throw ErikaPluginError.invalidArguments("muted is required.") + } + try host.setMuted(muted) + result(nil) case "setUpscaler": let args = try dictionaryArgs(call.arguments) let host = try playerHost(from: args) @@ -1482,6 +1537,22 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan } try host.setSubtitleScale(scale) result(nil) + case "setSubtitleDelay": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let seconds = doubleValue(args["seconds"]) else { + throw ErikaPluginError.invalidArguments("seconds is required.") + } + try host.setSubtitleDelay(seconds) + result(nil) + case "setAudioDelay": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let seconds = doubleValue(args["seconds"]) else { + throw ErikaPluginError.invalidArguments("seconds is required.") + } + try host.setAudioDelay(seconds) + result(nil) case "getUpscalerStatus": let args = try dictionaryArgs(call.arguments) result(try playerHost(from: args).upscalerStatus()) diff --git a/packages/erika_flutter/test/erika_player_test.dart b/packages/erika_flutter/test/erika_player_test.dart index 7592fc4..e508629 100644 --- a/packages/erika_flutter/test/erika_player_test.dart +++ b/packages/erika_flutter/test/erika_player_test.dart @@ -342,6 +342,94 @@ void main() { await player.dispose(); }); + test('subtitle delay is forwarded in clamped seconds', () async { + final player = ErikaPlayer(); + + await player.setSubtitleDelay(const Duration(milliseconds: 2500)); + await player.setSubtitleDelay(const Duration(seconds: -90)); + + final calls = playerCalls + .where((MethodCall call) => call.method == 'setSubtitleDelay') + .toList(); + expect(calls, hasLength(2)); + expect(calls[0].arguments, { + 'playerId': 7, + 'seconds': 2.5, + }); + expect(calls[1].arguments, { + 'playerId': 7, + 'seconds': -60.0, + }); + + await player.dispose(); + }); + + test('audio delay is forwarded in clamped seconds', () async { + final player = ErikaPlayer(); + + await player.setAudioDelay(const Duration(milliseconds: -250)); + await player.setAudioDelay(const Duration(seconds: 25)); + + final calls = playerCalls + .where((MethodCall call) => call.method == 'setAudioDelay') + .toList(); + expect(calls, hasLength(2)); + expect(calls[0].arguments, { + 'playerId': 7, + 'seconds': -0.25, + }); + expect(calls[1].arguments, { + 'playerId': 7, + 'seconds': 10.0, + }); + + await player.dispose(); + }); + + test('mute flag is forwarded to native player', () async { + final player = ErikaPlayer(); + + await player.setMuted(true); + await player.setMuted(false); + + final calls = playerCalls + .where((MethodCall call) => call.method == 'setMuted') + .toList(); + expect(calls, hasLength(2)); + expect(calls[0].arguments, { + 'playerId': 7, + 'muted': true, + }); + expect(calls[1].arguments, { + 'playerId': 7, + 'muted': false, + }); + + await player.dispose(); + }); + + test('volume getter reads the saved native volume', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(playerChannel, (MethodCall call) async { + playerCalls.add(call); + return switch (call.method) { + 'create' => 7, + 'getVolume' => 0.35, + _ => null, + }; + }); + final player = ErikaPlayer(); + + expect(await player.getVolume(), closeTo(0.35, 0.000001)); + + final call = playerCalls.singleWhere( + (MethodCall call) => call.method == 'getVolume', + ); + expect(call.arguments, {'playerId': 7}); + + await player.dispose(); + }); + test('window overlay methods forward surface geometry', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(playerChannel, (MethodCall call) async { diff --git a/packages/erika_flutter/windows/erika_flutter_plugin.cpp b/packages/erika_flutter/windows/erika_flutter_plugin.cpp index 5e9e16c..eacc021 100644 --- a/packages/erika_flutter/windows/erika_flutter_plugin.cpp +++ b/packages/erika_flutter/windows/erika_flutter_plugin.cpp @@ -637,8 +637,12 @@ struct ErikaFlutterPlugin::ErikaNativeLibrary { using SeekFn = ErikaStatus (*)(ErikaPresenterHandle*, uint64_t); using SetPlaybackRateFn = ErikaStatus (*)(ErikaPresenterHandle*, double); using SetVolumeFn = ErikaStatus (*)(ErikaPresenterHandle*, double); + using GetVolumeFn = ErikaStatus (*)(ErikaPresenterHandle*, double*); + using SetMutedFn = ErikaStatus (*)(ErikaPresenterHandle*, bool); using SetUpscalerFn = ErikaStatus (*)(ErikaPresenterHandle*, int32_t); using SetSubtitleScaleFn = ErikaStatus (*)(ErikaPresenterHandle*, double); + using SetSubtitleDelayFn = ErikaStatus (*)(ErikaPresenterHandle*, double); + using SetAudioDelayFn = ErikaStatus (*)(ErikaPresenterHandle*, double); using GetUpscalerStatusFn = ErikaStatus (*)(ErikaPresenterHandle*, ErikaUpscalerStatus*); using GetOutputStatusFn = @@ -744,8 +748,12 @@ struct ErikaFlutterPlugin::ErikaNativeLibrary { SeekFn seek = nullptr; SetPlaybackRateFn set_playback_rate = nullptr; SetVolumeFn set_volume = nullptr; + GetVolumeFn get_volume = nullptr; + SetMutedFn set_muted = nullptr; SetUpscalerFn set_upscaler = nullptr; SetSubtitleScaleFn set_subtitle_scale = nullptr; + SetSubtitleDelayFn set_subtitle_delay = nullptr; + SetAudioDelayFn set_audio_delay = nullptr; GetUpscalerStatusFn get_upscaler_status = nullptr; GetOutputStatusFn get_output_status = nullptr; SelectTrackFn select_audio_track = nullptr; @@ -800,10 +808,16 @@ struct ErikaFlutterPlugin::ErikaNativeLibrary { set_playback_rate = LoadOptional("erika_presenter_set_playback_rate"); set_volume = LoadOptional("erika_presenter_set_volume"); + get_volume = LoadOptional("erika_presenter_get_volume"); + set_muted = LoadOptional("erika_presenter_set_muted"); set_upscaler = LoadOptional("erika_presenter_set_upscaler"); set_subtitle_scale = LoadOptional( "erika_presenter_set_subtitle_scale"); + set_subtitle_delay = LoadOptional( + "erika_presenter_set_subtitle_delay"); + set_audio_delay = LoadOptional( + "erika_presenter_set_audio_delay"); get_upscaler_status = LoadOptional( "erika_presenter_get_upscaler_status"); get_output_status = LoadOptional( @@ -1102,6 +1116,24 @@ struct ErikaFlutterPlugin::PlayerHost { library->TakeLastError()); } + double GetVolume() { + if (library->get_volume == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_get_volume"); + } + double volume = 1.0; + Check(library->get_volume(handle, &volume), "get_volume", + library->TakeLastError()); + return volume; + } + + void SetMuted(bool muted) { + if (library->set_muted == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_muted"); + } + Check(library->set_muted(handle, muted), "set_muted", + library->TakeLastError()); + } + void SetUpscaler(int32_t mode) { if (library->set_upscaler == nullptr) { throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_upscaler"); @@ -1118,6 +1150,26 @@ struct ErikaFlutterPlugin::PlayerHost { Check(library->set_subtitle_scale(handle, clamped), "set_subtitle_scale"); } + void SetSubtitleDelay(double seconds) { + if (library->set_subtitle_delay == nullptr) { + throw PluginError( + "Missing Erika C ABI symbol: erika_presenter_set_subtitle_delay"); + } + const double clamped = + std::isfinite(seconds) ? std::clamp(seconds, -60.0, 60.0) : 0.0; + Check(library->set_subtitle_delay(handle, clamped), "set_subtitle_delay"); + } + + void SetAudioDelay(double seconds) { + if (library->set_audio_delay == nullptr) { + throw PluginError( + "Missing Erika C ABI symbol: erika_presenter_set_audio_delay"); + } + const double clamped = + std::isfinite(seconds) ? std::clamp(seconds, -10.0, 10.0) : 0.0; + Check(library->set_audio_delay(handle, clamped), "set_audio_delay"); + } + EncodableValue GetUpscalerStatus() { if (library->get_upscaler_status == nullptr) { throw PluginError("Missing Erika C ABI symbol: erika_presenter_get_upscaler_status"); @@ -2090,6 +2142,12 @@ void ErikaFlutterPlugin::HandleMethodCall( PlayerFromArgs(args).SetVolume( DoubleValue(FindArg(args, "volume")).value_or(1.0)); result->Success(); + } else if (method == "getVolume") { + result->Success(EncodableValue(PlayerFromArgs(args).GetVolume())); + } else if (method == "setMuted") { + PlayerFromArgs(args).SetMuted( + BoolValue(FindArg(args, "muted")).value_or(false)); + result->Success(); } else if (method == "setUpscaler") { PlayerFromArgs(args).SetUpscaler( static_cast(RequiredInt64(args, "mode"))); @@ -2098,6 +2156,14 @@ void ErikaFlutterPlugin::HandleMethodCall( PlayerFromArgs(args).SetSubtitleScale( DoubleValue(FindArg(args, "scale")).value_or(1.0)); result->Success(); + } else if (method == "setSubtitleDelay") { + PlayerFromArgs(args).SetSubtitleDelay( + DoubleValue(FindArg(args, "seconds")).value_or(0.0)); + result->Success(); + } else if (method == "setAudioDelay") { + PlayerFromArgs(args).SetAudioDelay( + DoubleValue(FindArg(args, "seconds")).value_or(0.0)); + result->Success(); } else if (method == "getUpscalerStatus") { result->Success(PlayerFromArgs(args).GetUpscalerStatus()); } else if (method == "getOutputStatus") { From 26bb3413248af73b8195793f4a8dace0f253e119 Mon Sep 17 00:00:00 2001 From: Sakiko Date: Tue, 28 Jul 2026 15:49:15 +0800 Subject: [PATCH 2/2] fix(presenter): retire subtitle cues in the delayed display domain Cues arrive on the unshifted timeline but render on the shifted one, and retention ran in the arrival domain: an incoming cue retired everything that ended at or before its own raw start time. For adjacent cues that is the cue immediately before it, which a positive delay still owes `delay` seconds of screen time, so the text vanished early and left a gap. Mirror the delay onto SubtitleFrameState so retention and display agree. It defaults to zero, so nothing moves until a delay is actually set. Co-Authored-By: Claude Opus 5 --- crates/erika/src/presenter.rs | 44 ++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/erika/src/presenter.rs b/crates/erika/src/presenter.rs index 0f321ab..34b7e9b 100644 --- a/crates/erika/src/presenter.rs +++ b/crates/erika/src/presenter.rs @@ -734,6 +734,7 @@ impl PresenterRuntime { return; } self.subtitle_delay = delay; + self.subtitles.subtitle_delay = delay; self.refresh_current_overlay(); } @@ -2529,6 +2530,12 @@ fn subtitle_start(frame: &PlayerSubtitleFrame) -> Option { #[derive(Debug, Default)] struct SubtitleFrameState { frames: Vec, + /// Mirrors [`PresenterRuntime::subtitle_delay`] so retention runs in the + /// same shifted domain the overlay renders in. Cues arrive on the unshifted + /// timeline, so retiring them against an incoming cue's raw start time + /// would drop the cue before it while a positive delay still owes that cue + /// `delay` seconds of screen time. + subtitle_delay: f64, #[cfg(feature = "libass")] ass_renderer: CachedAssTrackRenderer, #[cfg(feature = "libass")] @@ -2546,7 +2553,10 @@ impl SubtitleFrameState { } fn push(&mut self, mut frame: PlayerSubtitleFrame) { - self.retain_at(subtitle_start(&frame).unwrap_or(frame.media_time)); + self.retain_at(shifted_subtitle_pts( + subtitle_start(&frame).unwrap_or(frame.media_time), + self.subtitle_delay, + )); if frame.frame.is_empty() { #[cfg(feature = "libass")] { @@ -3109,6 +3119,38 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text assert!(!subtitle_is_active(&empty, Duration::ZERO)); } + #[test] + fn positive_subtitle_delay_keeps_a_cue_through_its_delayed_window() { + // Two adjacent cues: 1..3 then 3..5. With a 2 s delay the first is + // still on screen until the clock reaches 5, but it is the arrival of + // the second cue at its unshifted start (3) that used to retire it. + let mut state = SubtitleFrameState { + subtitle_delay: 2.0, + ..SubtitleFrameState::default() + }; + state.push(subtitle_frame( + Duration::from_secs(1), + Some(Duration::from_secs(3)), + )); + state.push(subtitle_frame( + Duration::from_secs(3), + Some(Duration::from_secs(5)), + )); + + let mut overlay = empty_overlay(); + state.append_to_overlay( + shifted_subtitle_pts(Duration::from_secs(4), 2.0), + &mut overlay, + SubtitleAssStyle::default(), + ); + + assert_eq!( + overlay.subtitle_planes.len(), + 1, + "the first cue must survive until its delayed window closes" + ); + } + #[test] fn subtitle_state_keeps_overlapping_bitmap_frames() { let mut state = SubtitleFrameState::default();