diff --git a/crates/erika/src/presenter.rs b/crates/erika/src/presenter.rs index 56fd5a2..49d39d2 100644 --- a/crates/erika/src/presenter.rs +++ b/crates/erika/src/presenter.rs @@ -75,6 +75,7 @@ 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; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TransitionFramePolicy { @@ -248,6 +249,7 @@ pub struct PresenterRuntime { current_surface_metrics: Option, current_danmaku_viewport: Option, subtitle_font_scale: f64, + subtitle_delay: f64, subtitles: SubtitleFrameState, overlay: OverlayTimeline, render_test_pattern_when_idle: bool, @@ -550,6 +552,7 @@ impl PresenterRuntime { current_surface_metrics: None, current_danmaku_viewport: None, subtitle_font_scale: DEFAULT_SUBTITLE_FONT_SCALE, + subtitle_delay: 0.0, subtitles: SubtitleFrameState::default(), overlay: config.overlay, render_test_pattern_when_idle: config.render_test_pattern_when_idle, @@ -709,6 +712,28 @@ 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.subtitles.subtitle_delay = delay; + self.refresh_current_overlay(); + } + + pub fn subtitle_delay(&self) -> f64 { + self.subtitle_delay + } + pub fn set_danmaku_timeline(&mut self, timeline: DanmakuTimeline) { self.danmaku_session.replace_default_track( timeline, @@ -1027,7 +1052,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, ); @@ -1302,8 +1327,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={}", @@ -1463,8 +1491,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={}", @@ -1503,8 +1534,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); } @@ -2158,6 +2192,31 @@ 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 + } +} + +/// 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) + } +} + fn bump_generation(current_generation: &mut u64, danmaku_generation: &mut u64) { *danmaku_generation = danmaku_generation.saturating_add(1).max(1); *current_generation = current_generation @@ -2416,6 +2475,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")] @@ -2433,7 +2498,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")] { @@ -2996,6 +3064,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(); @@ -3323,6 +3423,53 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text assert_eq!(presenter.volume(), 1.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] + #[cfg(feature = "wgpu")] + fn presenter_subtitle_delay_setter_clamps_range() { + let mut presenter = PresenterRuntime::new(PresenterConfig::default()).unwrap(); + + assert_eq!(presenter.subtitle_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); + } + #[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 9b0fa5c..3716542 100644 --- a/crates/erika_capi/include/erika.h +++ b/crates/erika_capi/include/erika.h @@ -438,6 +438,9 @@ ErikaStatus erika_presenter_set_playback_rate(ErikaPresenterHandle *handle, doub ErikaStatus erika_presenter_set_volume(ErikaPresenterHandle *handle, double volume); ErikaStatus erika_presenter_set_upscaler(ErikaPresenterHandle *handle, int32_t mode); ErikaStatus erika_presenter_set_subtitle_scale(ErikaPresenterHandle *handle, double scale); +/* Positive sub-delay shows subtitles later. seconds is clamped to ±60. + * Negative subtitle delay is limited by decode lookahead. */ +ErikaStatus erika_presenter_set_subtitle_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 3031606..f092343 100644 --- a/crates/erika_capi/src/android_jni.rs +++ b/crates/erika_capi/src/android_jni.rs @@ -763,6 +763,10 @@ 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) }) + } "setOutputHeadroom" => { let headroom = required_f64(args, "headroom")? as f32; let known = diff --git a/crates/erika_capi/src/lib.rs b/crates/erika_capi/src/lib.rs index 81aee1c..1201702 100644 --- a/crates/erika_capi/src/lib.rs +++ b/crates/erika_capi/src/lib.rs @@ -1637,6 +1637,25 @@ pub unsafe extern "C" fn erika_presenter_set_volume( target_os = "android", target_env = "ohos" ))] +/// # 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" +))] #[unsafe(no_mangle)] pub unsafe extern "C" fn erika_presenter_set_upscaler( handle: *mut ErikaPresenterHandle, @@ -2434,6 +2453,20 @@ pub unsafe extern "C" fn erika_presenter_set_volume( target_env = "ohos" )))] #[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_upscaler( _handle: *mut std::ffi::c_void, _mode: i32, @@ -4092,6 +4125,33 @@ mod tests { target_env = "ohos" ))] #[test] + fn c_presenter_subtitle_delay_setter_accepts_valid_handle() { + assert_eq!( + unsafe { erika_presenter_set_subtitle_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 + ); + // 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 + ); + unsafe { erika_presenter_destroy(handle) }; + } + + #[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" + ))] + #[test] fn c_presenter_set_upscaler_accepts_valid_handle() { assert_eq!( unsafe { 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..68d24dc 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", @@ -1720,6 +1721,7 @@ class ErikaFlutterPlugin : "setVolume", "setUpscaler", "setSubtitleScale", + "setSubtitleDelay", "getUpscalerStatus", "getOutputStatus", "getPresenterStats", diff --git a/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift b/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift index 163529f..5099413 100644 --- a/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift +++ b/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift @@ -356,6 +356,7 @@ private final class ErikaNativeLibrary { typealias SetVolumeFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 typealias SetUpscalerFn = @convention(c) (UnsafeMutableRawPointer?, Int32) -> Int32 typealias SetSubtitleScaleFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 + typealias SetSubtitleDelayFn = @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 @@ -418,6 +419,7 @@ private final class ErikaNativeLibrary { let setVolume: SetVolumeFn? let setUpscaler: SetUpscalerFn? let setSubtitleScale: SetSubtitleScaleFn? + let setSubtitleDelay: SetSubtitleDelayFn? let getUpscalerStatus: GetUpscalerStatusFn? let getOutputStatus: GetOutputStatusFn? let selectAudioTrack: SelectTrackFn @@ -476,6 +478,7 @@ private final class ErikaNativeLibrary { setVolume = Self.loadOptional("erika_presenter_set_volume", from: libraryHandle, as: SetVolumeFn.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) 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) @@ -674,6 +677,14 @@ 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 upscalerStatus() throws -> [String: Any] { guard let getStatus = library.getUpscalerStatus else { throw ErikaPluginError.symbolMissing("erika_presenter_get_upscaler_status") @@ -1482,6 +1493,13 @@ 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 "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 d3504ec..4b7f741 100644 --- a/packages/erika_flutter/lib/src/erika_player.dart +++ b/packages/erika_flutter/lib/src/erika_player.dart @@ -777,6 +777,22 @@ 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, + }); + } + 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 cc599a7..a2926db 100644 --- a/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift +++ b/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift @@ -227,6 +227,7 @@ private final class ErikaNativeLibrary { typealias SetVolumeFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 typealias SetUpscalerFn = @convention(c) (UnsafeMutableRawPointer?, Int32) -> Int32 typealias SetSubtitleScaleFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 + typealias SetSubtitleDelayFn = @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 @@ -289,6 +290,7 @@ private final class ErikaNativeLibrary { let setVolume: SetVolumeFn? let setUpscaler: SetUpscalerFn? let setSubtitleScale: SetSubtitleScaleFn? + let setSubtitleDelay: SetSubtitleDelayFn? let getUpscalerStatus: GetUpscalerStatusFn? let getOutputStatus: GetOutputStatusFn? let selectAudioTrack: SelectTrackFn @@ -341,6 +343,7 @@ private final class ErikaNativeLibrary { setVolume = Self.loadOptional("erika_presenter_set_volume", from: libraryHandle, as: SetVolumeFn.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) 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) @@ -552,6 +555,14 @@ 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 upscalerStatus() throws -> [String: Any] { guard let getStatus = library.getUpscalerStatus else { throw ErikaPluginError.symbolMissing("erika_presenter_get_upscaler_status") @@ -1512,6 +1523,14 @@ 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 "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 1a23d78..aea7fa5 100644 --- a/packages/erika_flutter/test/erika_player_test.dart +++ b/packages/erika_flutter/test/erika_player_test.dart @@ -402,6 +402,28 @@ 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('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 56cbf6b..c317417 100644 --- a/packages/erika_flutter/windows/erika_flutter_plugin.cpp +++ b/packages/erika_flutter/windows/erika_flutter_plugin.cpp @@ -640,6 +640,7 @@ struct ErikaFlutterPlugin::ErikaNativeLibrary { using SetVolumeFn = ErikaStatus (*)(ErikaPresenterHandle*, double); using SetUpscalerFn = ErikaStatus (*)(ErikaPresenterHandle*, int32_t); using SetSubtitleScaleFn = ErikaStatus (*)(ErikaPresenterHandle*, double); + using SetSubtitleDelayFn = ErikaStatus (*)(ErikaPresenterHandle*, double); using GetUpscalerStatusFn = ErikaStatus (*)(ErikaPresenterHandle*, ErikaUpscalerStatus*); using GetOutputStatusFn = @@ -748,6 +749,7 @@ struct ErikaFlutterPlugin::ErikaNativeLibrary { SetVolumeFn set_volume = nullptr; SetUpscalerFn set_upscaler = nullptr; SetSubtitleScaleFn set_subtitle_scale = nullptr; + SetSubtitleDelayFn set_subtitle_delay = nullptr; GetUpscalerStatusFn get_upscaler_status = nullptr; GetOutputStatusFn get_output_status = nullptr; SelectTrackFn select_audio_track = nullptr; @@ -807,6 +809,8 @@ struct ErikaFlutterPlugin::ErikaNativeLibrary { LoadOptional("erika_presenter_set_upscaler"); set_subtitle_scale = LoadOptional( "erika_presenter_set_subtitle_scale"); + set_subtitle_delay = LoadOptional( + "erika_presenter_set_subtitle_delay"); get_upscaler_status = LoadOptional( "erika_presenter_get_upscaler_status"); get_output_status = LoadOptional( @@ -1163,6 +1167,16 @@ 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"); + } + EncodableValue GetUpscalerStatus() { if (library->get_upscaler_status == nullptr) { throw PluginError("Missing Erika C ABI symbol: erika_presenter_get_upscaler_status"); @@ -2143,6 +2157,10 @@ 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 == "getUpscalerStatus") { result->Success(PlayerFromArgs(args).GetUpscalerStatus()); } else if (method == "getOutputStatus") {