diff --git a/CHANGELOG.md b/CHANGELOG.md index d254a2d..68798fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ ## 0.1.5 - 2026-08-03 +### System media controls and background audio + +- Added Now Playing metadata, playback state, timeline, play/pause/seek, and + previous/next navigation controls across iOS, tvOS, macOS, Android, Windows, + and OpenHarmony. +- Added opt-in background audio playback for iOS and Android while suspending + video decoding, with bounded playback-worker barriers so lifecycle callbacks + cannot block an application thread indefinitely. +- Cleared stale metadata when opening media without `ErikaMediaMetadata`, made + pause/play sequencing deterministic, and isolated Android media callbacks + across multiple Flutter engines. +- Cached Android artwork and avoided rebuilding media notifications when native + playback state has not changed. + ### Platform support and release artifacts - Added the tvOS Flutter plugin and native target support for Apple TV devices diff --git a/crates/erika/src/core.rs b/crates/erika/src/core.rs index 9209308..e84dd6b 100644 --- a/crates/erika/src/core.rs +++ b/crates/erika/src/core.rs @@ -909,6 +909,10 @@ enum PlaybackCommand { quiesced: bool, reply: Sender<()>, }, + SetVideoDecodeSuspended { + suspended: bool, + reply: Sender>, + }, Shutdown, } @@ -1253,6 +1257,9 @@ impl Player { commands .send(PlaybackCommand::Pause { sequence }) .map_err(|_| PlayerError::Playback("playback worker is not running".to_string()))?; + // Publish the paused intent immediately so a sequential pause/play pair + // cannot race the worker and observe a stale Playing state. The worker + // still replaces the parked clock with its authoritative media time. let _ = commit_playback_command_intent( &self.inner, sequence, @@ -1439,6 +1446,58 @@ impl Player { self.set_frame_output_quiesced_with_timeout(quiesced, FRAME_OUTPUT_BARRIER_TIMEOUT) } + pub(crate) fn set_video_decode_suspended(&self, suspended: bool) -> Result { + self.set_video_decode_suspended_with_timeout(suspended, FRAME_OUTPUT_BARRIER_TIMEOUT) + } + + fn set_video_decode_suspended_with_timeout( + &self, + suspended: bool, + timeout: Duration, + ) -> Result { + let Some(commands) = self.optional_playback_commands() else { + return Ok(false); + }; + let (reply, response) = bounded(1); + let requested_at = Instant::now(); + match commands.send_timeout( + PlaybackCommand::SetVideoDecodeSuspended { suspended, reply }, + timeout, + ) { + Ok(()) => {} + Err(crossbeam_channel::SendTimeoutError::Timeout(_)) => { + return Err(PlayerError::Playback(format!( + "timed out after {} ms while sending video decode mode to the playback worker", + timeout.as_millis(), + ))); + } + Err(crossbeam_channel::SendTimeoutError::Disconnected(_)) => { + return Err(PlayerError::Playback( + "playback worker is not running".to_string(), + )); + } + } + let remaining = timeout.saturating_sub(requested_at.elapsed()); + match response.recv_timeout(remaining) { + Ok(result) => result.map_err(PlayerError::Playback)?, + Err(RecvTimeoutError::Timeout) => { + if suspended { + enqueue_video_decode_resume_after_timeout(&commands); + } + return Err(PlayerError::Playback(format!( + "playback worker did not acknowledge video decode mode within {} ms", + timeout.as_millis(), + ))); + } + Err(RecvTimeoutError::Disconnected) => { + return Err(PlayerError::Playback( + "playback worker stopped before acknowledging video decode mode".to_string(), + )); + } + } + Ok(true) + } + fn set_frame_output_quiesced_with_timeout( &self, quiesced: bool, @@ -2278,11 +2337,12 @@ fn handle_playback_command( return true; } engine.pause(); + let position = engine.media_time(); let _ = commit_playback_command_intent( inner, sequence, None, - None, + Some(position), Some(PlayerState::Paused), ); } @@ -2540,6 +2600,20 @@ fn handle_playback_command( ); let _ = reply.send(()); } + PlaybackCommand::SetVideoDecodeSuspended { suspended, reply } => { + let result = engine + .set_video_decode_suspended(suspended) + .map_err(|error| error.to_string()); + trace::diagnostic( + serde_json::json!({ + "event": "player_video_decode", + "stage": if suspended { "suspended" } else { "resumed_at_keyframe" }, + "generation": *playback_generation, + }) + .to_string(), + ); + let _ = reply.send(result); + } PlaybackCommand::Shutdown => return false, } true @@ -2873,6 +2947,15 @@ fn enqueue_frame_output_resume_after_timeout(commands: &Sender) } } +fn enqueue_video_decode_resume_after_timeout(commands: &Sender) { + let (reply, response) = bounded(1); + drop(response); + let _ = commands.try_send(PlaybackCommand::SetVideoDecodeSuspended { + suspended: false, + reply, + }); +} + fn set_state_from_worker(inner: &Arc>, next: PlayerState) { let previous = { let mut inner = inner.lock().expect("player mutex poisoned"); @@ -3217,6 +3300,61 @@ mod tests { assert!(!player.set_frame_output_quiesced(true).unwrap()); } + #[test] + fn video_decode_mode_is_a_noop_without_open_media() { + let player = Player::new(PlayerConfig::default()); + assert!(!player.set_video_decode_suspended(true).unwrap()); + } + + #[test] + fn video_decode_mode_uses_an_acknowledged_worker_command() { + let player = Player::new(PlayerConfig::default()); + let receiver = install_test_runtime(&player, 2); + let worker = thread::spawn(move || match receiver.recv().unwrap() { + PlaybackCommand::SetVideoDecodeSuspended { + suspended: true, + reply, + } => reply.send(Ok(())).unwrap(), + _ => panic!("unexpected playback command"), + }); + + assert!(player.set_video_decode_suspended(true).unwrap()); + worker.join().unwrap(); + } + + #[test] + fn video_decode_mode_reports_an_unresponsive_worker() { + let player = Player::new(PlayerConfig::default()); + let receiver = install_test_runtime(&player, 2); + let started = Instant::now(); + + let error = player + .set_video_decode_suspended_with_timeout(true, Duration::from_millis(25)) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("did not acknowledge video decode mode") + ); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(matches!( + receiver.recv_timeout(Duration::from_secs(1)).unwrap(), + PlaybackCommand::SetVideoDecodeSuspended { + suspended: true, + .. + } + )); + assert!(matches!( + receiver.recv_timeout(Duration::from_secs(1)).unwrap(), + PlaybackCommand::SetVideoDecodeSuspended { + suspended: false, + .. + } + )); + drop(receiver); + } + #[test] fn frame_output_barrier_reports_an_unresponsive_worker() { let player = Player::new(PlayerConfig::default()); @@ -3494,8 +3632,9 @@ mod tests { } #[test] - fn pause_publishes_a_parked_clock_with_the_paused_state() { + fn pause_publishes_intent_before_worker_position() { let player = Player::new(PlayerConfig::default()); + let events = player.subscribe(); let commands = install_test_runtime(&player, 1); let anchor = Instant::now(); { @@ -3506,10 +3645,31 @@ mod tests { player.pause().unwrap(); - assert!(matches!( - commands.recv_timeout(Duration::from_secs(1)).unwrap(), - PlaybackCommand::Pause { .. } + let sequence = match commands.recv_timeout(Duration::from_secs(1)).unwrap() { + PlaybackCommand::Pause { sequence } => sequence, + _ => panic!("expected pause command"), + }; + assert_eq!(player.state(), PlayerState::Paused); + assert_eq!( + events.recv().unwrap(), + PlayerEvent::StateChanged(PlayerState::Paused) + ); + + let position = Duration::from_millis(9_250); + assert!(commit_playback_command_intent( + &player.inner, + sequence, + None, + Some(position), + Some(PlayerState::Paused), )); + assert_eq!( + events.recv().unwrap(), + PlayerEvent::PositionChanged(position) + ); + assert!(events.try_recv().is_err()); + assert_eq!(player.current_media_time(), position); + assert_eq!(player.state(), PlayerState::Paused); let snapshot = player.playback_snapshot(); assert_eq!(snapshot.state, PlayerState::Paused); assert!(!snapshot.clock.is_running()); diff --git a/crates/erika/src/playback.rs b/crates/erika/src/playback.rs index 15a742f..d5f8c95 100644 --- a/crates/erika/src/playback.rs +++ b/crates/erika/src/playback.rs @@ -689,6 +689,7 @@ pub struct PlaybackSession { audio_resampler: Option, audio_output: PcmFormat, audio_output_active: bool, + video_decode_suspended: bool, info: OpenedMediaInfo, video_frames: VecDeque, audio_frames: VecDeque, @@ -1126,6 +1127,7 @@ impl PlaybackSession { audio_resampler: None, audio_output: config.audio_output, audio_output_active: true, + video_decode_suspended: false, info, video_frames: VecDeque::new(), audio_frames: VecDeque::new(), @@ -1208,6 +1210,38 @@ impl PlaybackSession { discarded } + fn set_video_decode_suspended(&mut self, suspended: bool) { + if self.video_decode_suspended == suspended { + return; + } + self.video_decode_suspended = suspended; + let discarded = self.discard_video_frames_and_packets(); + trace_discarded_playback_queues( + if suspended { + "video_decode_suspend" + } else { + "video_decode_resume" + }, + discarded, + self.video_decoder.is_some(), + ); + self.reset_video_packet_stall_state(); + if !suspended { + if let Some(decoder) = &mut self.video_decoder { + decoder.flush(); + } + self.video_fallback_waiting_for_keyframe = self.video_decoder.is_some(); + trace::diagnostic( + serde_json::json!({ + "event": "video_decoder_recovery_waiting_for_keyframe", + "stage": "foreground_resume", + "backend": self.active_video_decoder_backend().map(DecoderBackend::as_str), + }) + .to_string(), + ); + } + } + fn selected_video_codec(&self) -> Option { let selected = self.info.selected_video_track?; self.info @@ -1980,10 +2014,10 @@ impl PlaybackSession { } fn pump_once(&mut self, demand: PlaybackPumpDemand) -> Result { - if self.route_pending_video_packets()? { + if !self.video_decode_suspended && self.route_pending_video_packets()? { return Ok(true); } - if !self.pending_video_packets.is_empty() { + if !self.video_decode_suspended && !self.pending_video_packets.is_empty() { return Ok(false); } if audio_queue_blocks_demux( @@ -2017,6 +2051,9 @@ impl PlaybackSession { .as_ref() .is_some_and(|decoder| packet.stream_index() == decoder.stream_index()) { + if self.video_decode_suspended { + return Ok(()); + } if self.should_defer_video_packet() { self.pending_video_packets.push_back(packet); return Ok(()); @@ -2765,11 +2802,13 @@ impl PlaybackSession { } let mut made_progress = false; - while self.route_pending_video_packets()? { - made_progress = true; - } - if !self.pending_video_packets.is_empty() { - return Ok(made_progress); + if !self.video_decode_suspended { + while self.route_pending_video_packets()? { + made_progress = true; + } + if !self.pending_video_packets.is_empty() { + return Ok(made_progress); + } } self.eof_drain_polls = self.eof_drain_polls.saturating_add(1); @@ -2779,7 +2818,7 @@ impl PlaybackSession { if let Some(decoder) = self .video_decoder .as_mut() - .filter(|decoder| !decoder.is_end_of_stream()) + .filter(|decoder| !self.video_decode_suspended && !decoder.is_end_of_stream()) { if !decoder.eof_sent() { match decoder.send_eof() { @@ -2812,10 +2851,11 @@ impl PlaybackSession { self.eof_drain_last_progress_at = Some(now); } - let video_complete = self - .video_decoder - .as_ref() - .is_none_or(Decoder::is_end_of_stream); + let video_complete = self.video_decode_suspended + || self + .video_decoder + .as_ref() + .is_none_or(Decoder::is_end_of_stream); let audio_complete = self .audio_decoder .as_ref() @@ -3698,6 +3738,32 @@ pub struct VideoPlaybackEngine { last_video_seek_preroll_log: Option, } +impl VideoPlaybackEngine { + pub fn set_video_decode_suspended(&mut self, suspended: bool) -> Result<()> { + let resume_position = (!suspended).then(|| self.media_time_at(Instant::now())); + if suspended { + self.pending_frame = None; + } + if let Some(position) = resume_position { + self.session.video_decode_suspended = false; + self.session.seek_with_decoder_flush(position, true, true)?; + } else { + self.session.set_video_decode_suspended(true); + } + if !suspended { + self.pending_frame = None; + self.pending_audio = None; + self.pending_subtitle = None; + self.last_presented_pts = None; + self.waiting_for_first_frame = true; + self.video_seek_floor = resume_position; + self.audio_seek_floor = resume_position; + self.reset_video_seek_preroll_budget(Instant::now()); + } + Ok(()) + } +} + unsafe impl Send for VideoPlaybackEngine {} impl Drop for VideoPlaybackEngine { @@ -5383,6 +5449,38 @@ mod tests { engine } + #[test] + fn audio_only_mode_discards_video_packets_and_resumes_at_keyframe() { + let mut engine = playback_fixture_engine(); + let started_at = Instant::now(); + engine.play_at(started_at); + engine.set_video_decode_suspended(true).unwrap(); + + let deadline = Instant::now() + FIXTURE_WAIT_TIMEOUT; + let mut decoded_audio_frames = 0; + while decoded_audio_frames < 80 { + if engine.next_audio_frame().unwrap().is_some() { + decoded_audio_frames += 1; + } + assert!( + Instant::now() < deadline, + "timed out pumping audio-only playback" + ); + thread::yield_now(); + } + + assert!(engine.session.video_frames.is_empty()); + assert!(engine.session.pending_video_packets.is_empty()); + assert!(engine.session.video_decode_suspended); + + engine.set_video_decode_suspended(false).unwrap(); + + assert!(!engine.session.video_decode_suspended); + assert!(engine.session.video_fallback_waiting_for_keyframe); + let _ = next_fixture_video_at(&mut engine, started_at); + assert!(!engine.session.video_fallback_waiting_for_keyframe); + } + fn next_fixture_video_at(engine: &mut VideoPlaybackEngine, now: Instant) -> TimedVideoFrame { let deadline = Instant::now() + FIXTURE_WAIT_TIMEOUT; loop { diff --git a/crates/erika/src/presenter.rs b/crates/erika/src/presenter.rs index 48dc94c..a066142 100644 --- a/crates/erika/src/presenter.rs +++ b/crates/erika/src/presenter.rs @@ -287,6 +287,7 @@ pub struct PresenterRuntime { last_audio_clock_report: Option, last_audio_runtime_stats: AudioOutputRuntimeStats, playback_rate: f64, + audio_only_tick_active: bool, latest_video_decoder: Option, current_overlay: Option, debug_hud: DebugHud, @@ -675,6 +676,7 @@ impl PresenterRuntime { last_audio_clock_report: None, last_audio_runtime_stats: AudioOutputRuntimeStats::default(), playback_rate: 1.0, + audio_only_tick_active: false, latest_video_decoder: None, current_overlay: None, debug_hud: DebugHud::new(), @@ -1260,6 +1262,11 @@ impl PresenterRuntime { } pub fn render_tick(&mut self, time_seconds: f64) -> Result { + if self.audio_only_tick_active { + self.discard_pending_video_frames(); + self.player.set_video_decode_suspended(false)?; + self.audio_only_tick_active = false; + } let tick_started = Instant::now(); let pump_started = Instant::now(); self.refresh_video_decoder_status(); @@ -1431,6 +1438,37 @@ impl PresenterRuntime { Ok(self.stats) } + pub fn audio_only_tick(&mut self) -> Result { + let tick_started = Instant::now(); + if !self.audio_only_tick_active { + self.player.set_video_decode_suspended(true)?; + self.discard_pending_video_frames(); + self.audio_only_tick_active = true; + } + let pump_started = Instant::now(); + self.last_subtitle_pump_duration = Duration::ZERO; + self.last_video_pump_duration = Duration::ZERO; + self.report_audio_output_runtime_stats(); + let audio_started = Instant::now(); + self.pump_audio(); + self.report_audio_output_runtime_stats(); + self.last_audio_pump_duration = audio_started.elapsed(); + let sync_started = Instant::now(); + self.sync_media_time_from_player(); + self.last_clock_sync_duration = sync_started.elapsed(); + self.last_danmaku_plan_duration = Duration::ZERO; + self.last_render_duration = Duration::ZERO; + self.last_render_current_duration = Duration::ZERO; + self.last_render_test_duration = Duration::ZERO; + self.last_pump_duration = pump_started.elapsed(); + self.last_tick_duration = tick_started.elapsed(); + Ok(self.stats) + } + + fn discard_pending_video_frames(&self) { + while self.video_frames.try_recv().is_ok() {} + } + fn debug_hud_snapshot(&self) -> DebugHudSnapshot { let selection = self.player.track_selection(); let tracks = self.player.tracks(); diff --git a/crates/erika_capi/include/erika.h b/crates/erika_capi/include/erika.h index a7424ad..df42eb9 100644 --- a/crates/erika_capi/include/erika.h +++ b/crates/erika_capi/include/erika.h @@ -685,6 +685,9 @@ ErikaStatus erika_presenter_render_tick( ErikaPresenterHandle *handle, double time_seconds, ErikaPresenterStats *out_stats); +ErikaStatus erika_presenter_audio_only_tick( + ErikaPresenterHandle *handle, + ErikaPresenterStats *out_stats); ErikaStatus erika_presenter_get_stats( ErikaPresenterHandle *handle, ErikaPresenterStats *out_stats); diff --git a/crates/erika_capi/src/android_jni.rs b/crates/erika_capi/src/android_jni.rs index f82cf88..d26d646 100644 --- a/crates/erika_capi/src/android_jni.rs +++ b/crates/erika_capi/src/android_jni.rs @@ -636,6 +636,23 @@ pub extern "system" fn Java_dev_aimesoft_erika_1flutter_ErikaNative_nativeRender response_to_jstring(&mut env, response) } +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_aimesoft_erika_1flutter_ErikaNative_nativeAudioOnlyTick( + mut env: JNIEnv<'_>, + _class: JClass<'_>, + handle: jlong, +) -> jstring { + let response = catch_unwind(AssertUnwindSafe(|| { + with_registered_presenter(handle, "audioOnlyTick", |presenter| { + let mut stats = ErikaPresenterStats::default(); + call_status(unsafe { erika_presenter_audio_only_tick(presenter.handle, &mut stats) })?; + presenter.latest_stats = stats; + Ok(stats_to_json(stats)) + }) + })); + response_to_jstring(&mut env, response) +} + #[unsafe(no_mangle)] pub extern "system" fn Java_dev_aimesoft_erika_1flutter_ErikaNative_nativePollEvent( mut env: JNIEnv<'_>, diff --git a/crates/erika_capi/src/lib.rs b/crates/erika_capi/src/lib.rs index 23a2036..b0272aa 100644 --- a/crates/erika_capi/src/lib.rs +++ b/crates/erika_capi/src/lib.rs @@ -3331,6 +3331,31 @@ pub unsafe extern "C" fn erika_presenter_render_tick( }) } +#[cfg(any( + target_os = "macos", + any(target_os = "ios", target_os = "tvos"), + target_os = "windows", + target_os = "android", + target_env = "ohos" +))] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_audio_only_tick( + handle: *mut ErikaPresenterHandle, + out_stats: *mut ErikaPresenterStats, +) -> ErikaStatus { + with_presenter_mut(handle, |handle| match handle.presenter.audio_only_tick() { + Ok(_stats) => { + if !out_stats.is_null() { + let snapshot = handle.presenter.runtime_snapshot(); + unsafe { *out_stats = presenter_stats_to_c(snapshot) }; + } + clear_last_error(); + ErikaStatus::Ok + } + Err(error) => player_error(format!("audio_only_tick failed: {error}")), + }) +} + #[cfg(any( target_os = "macos", any(target_os = "ios", target_os = "tvos"), diff --git a/packages/erika_flutter/README.ja.md b/packages/erika_flutter/README.ja.md index c473d62..5d0db99 100644 --- a/packages/erika_flutter/README.ja.md +++ b/packages/erika_flutter/README.ja.md @@ -41,12 +41,117 @@ cargo build -p erika_capi source build の architecture は macOS では `ERIKA_MACOS_ARCHS=arm64|x86_64|universal`、Windows では `ERIKA_WINDOWS_ARCH=x64|arm64`、Android では `ERIKA_ANDROID_ABIS=arm64-v8a,armeabi-v7a,x86_64,x86` で選択します。native library を直接 build する場合、`xtask --target`、`ERIKA_NATIVE_TARGET`、`cargo build --target` は同じ target にしてください。詳細は [building.ja.md](../../docs/building.ja.md) を参照してください。 +macOS plugin は Now Playing を通じてタイトル、アーティスト、アルバム、artwork、再生状態、timeline を公開し、Remote Command Center からの再生、一時停止、停止、seek を処理します。 + ## iOS Setup iOS の CocoaPod script phase が、Xcode build 中に Erika の native dependency と C ABI static library を自動 build します。対応する iOS target の Rust toolchain が必要です。 - `rustup target add aarch64-apple-ios` +host app では、Xcode の Signing & Capabilities で **Background Modes > Audio, AirPlay, and Picture in Picture** を有効にするか、`Info.plist` の `UIBackgroundModes` に `audio` を追加してください。player は Now Playing 情報と再生 control を Control Center に登録します。タイトル、アーティスト、アルバム、エンコード済み artwork bytes を表示するには `ErikaMediaMetadata` を指定してください。 + +バックグラウンド再生はデフォルトで無効です。バックグラウンドで音声を継続する場合は、`ErikaPlayer(allowBackgroundPlayback: true)` を指定してください。host app で上記の Background Mode が有効でない場合、この option を指定しても iOS はバックグラウンド再生の継続を保証しません。 + +```dart +final player = ErikaPlayer( + allowBackgroundPlayback: true, +); + +final artwork = await rootBundle.load('assets/cover.jpg'); +await player.open( + mediaUrl, + metadata: ErikaMediaMetadata( + title: 'タイトル', + artist: 'アーティスト', + album: 'アルバム', + artwork: artwork.buffer.asUint8List(), + ), +); +await player.play(); +``` + +`allowBackgroundPlayback` は player 作成時の option であり、native player の作成後には変更できません。`false` の場合、App がバックグラウンドに入ると再生を一時停止し、foreground に戻っても一時停止状態を維持します。`true` の場合、バックグラウンドでは動画 decode を停止して音声のみを継続し、App が active になると動画を再開します。Control Center から再生、一時停止、再生位置の変更ができます。artwork には raw pixel ではなく、JPEG や PNG など `UIImage` が対応する形式の完全な encoded image bytes を指定してください。 + +## System Media の前後移動 + +playlist app は active item に応じて system media panel の前へ・次へ button を有効に +できます。Erika 自身は次の media item を選択せず、 +`systemMediaNavigationRequested` event を発行するため、Dart を playlist の唯一の +source of truth にできます。active item が変わるたびに capability を更新してください。 + +```dart +import 'dart:async'; + +import 'package:erika_flutter/erika_flutter.dart'; + +class PlaylistController { + final ErikaPlayer player = ErikaPlayer(allowBackgroundPlayback: true); + final List<({String title, String url})> items = <({String title, String url})>[ + (title: 'エピソード 1', url: 'https://example.com/episode-1.mp4'), + (title: 'エピソード 2', url: 'https://example.com/episode-2.mp4'), + ]; + + StreamSubscription? subscription; + int index = 0; + bool switching = false; + + Future initialize() async { + subscription = player.events.listen((ErikaPlayerEvent event) async { + if (event.kind != ErikaEventKind.systemMediaNavigationRequested) { + return; + } + switch (event.systemMediaCommand) { + case ErikaSystemMediaCommand.previous: + await openAt(index - 1); + case ErikaSystemMediaCommand.next: + await openAt(index + 1); + case null: + break; + } + }); + await openAt(0); + } + + Future openAt(int newIndex) async { + if (switching || newIndex < 0 || newIndex >= items.length) { + return; + } + switching = true; + await player.setSystemMediaNavigation( + previousEnabled: false, + nextEnabled: false, + ); + try { + final item = items[newIndex]; + await player.open( + item.url, + metadata: ErikaMediaMetadata(title: item.title), + ); + await player.play(); + index = newIndex; + } finally { + switching = false; + await player.setSystemMediaNavigation( + previousEnabled: index > 0, + nextEnabled: index + 1 < items.length, + ); + } + } + + Future dispose() async { + await subscription?.cancel(); + await player.dispose(); + } +} +``` + +capability は既定で無効で、iOS、tvOS、macOS、Android、Windows、HarmonyOS に対応します。 +item の切り替え中は両方の button を一時的に無効化して重複 request を拒否し、切り替え +成功後に index、metadata、capability を更新してください。この API が通知するのは +`previous` と `next` のみです。再生、一時停止、停止、seek は引き続き各 platform の +native system-media integration が直接処理します。 + ## tvOS Setup tvOS の CocoaPod script phase が、Apple TV 実機または simulator 向けの native @@ -69,12 +174,16 @@ Windows plugin(`ErikaFlutterPluginCApi`)は CMake build 中に `build_erika_ plugin が Erika checkout を自動検出できない場合は `ERIKA_REPO_ROOT` を設定してください。 +Windows plugin は System Media Transport Controls(SMTC)を通じてタイトル、アーティスト、アルバム、artwork、再生状態、timeline を公開し、system の再生、一時停止、seek を処理します。C++/WinRT を含む Windows SDK が必要で、必要な WinRT system library は plugin が自動的に link します。 + ## Android Setup Android Gradle build は Erika の `xtask` で native dependency を構築し、選択した ABI 向けに Cargo で `erika_capi` を build します。Android API 26 以降、Android NDK、対応する Rust target が必要です。生成される `jniLibs` には `liberika_capi.so` と ABI に対応する NDK の `libc++_shared.so` が含まれます。既定は arm64 と x86_64 で、`-PerikaAndroidAbis=arm64-v8a,x86_64` または `ERIKA_ANDROID_ABIS` で変更できます。 Android の `content://` media/subtitle URI は `ContentResolver` で開いて detach し、provider の offset/length を含む所有権付き `fd://` source として Erika に渡します。 +Android は MediaSession と media notification を使って lock screen、Bluetooth、system media control に接続します。`allowBackgroundPlayback: true` の場合は `mediaPlayback` foreground Service を起動し、video decode を停止したままバックグラウンドで音声を継続します。plugin Manifest には foreground service と Android 13+ の notification permission が宣言されていますが、host app は product flow に応じて `POST_NOTIFICATIONS` runtime permission を要求する必要があります。permission が拒否された場合も media session は動作しますが、notification の表示は Android version と system policy に依存します。 + Android minimum は API 26 のままです。Extended-linear は native-window dataspace API (API 28+)も必要で、API 26/27 は SDR playback を継続して該当 fallback を報告します。 API 34+ では plugin が `Display.registerHdrSdrRatioChangedListener` を監視し、実際の ratio @@ -90,6 +199,8 @@ download して `liberika_flutter.so` と一緒に package します。それ以 の `aarch64-unknown-linux-ohos` target が必要で、LGPL native dependency と runtime を source build します。download 失敗時は source build に fallback します。 +HarmonyOS は AVSession を通じて metadata、artwork、再生状態、位置、再生速度を公開し、system の再生、一時停止、停止、seek command を処理します。 + HarmonyOS では `ErikaVideoView` を使ってください。Flutter external texture を登録し、 その texture surface を `OHNativeWindow` として取得して、wgpu Vulkan で描画します。 音声は OHAudio の interleaved f32 PCM です。 diff --git a/packages/erika_flutter/README.md b/packages/erika_flutter/README.md index dde6b85..345514d 100644 --- a/packages/erika_flutter/README.md +++ b/packages/erika_flutter/README.md @@ -51,6 +51,10 @@ consuming project can set `ERIKA_MACOS_ARCHS=arm64`, `x86_64`, or matching `macos-arm64`, `macos-x64`, or `macos-universal` archive. At runtime the plugin loads the library via `dlopen`. +The macOS plugin publishes title, artist, album, artwork, playback state, and +timeline through Now Playing, and handles system play, pause, stop, and seek +commands through Remote Command Center. + Overrides: `ERIKA_CAPI_DYLIB` forces the runtime dylib path; `ERIKA_MACOS_CAPI_DYLIB` points the build phase at an explicit dylib to bundle instead of building. @@ -79,6 +83,110 @@ static library automatically during Xcode builds. Requirements: - Rust toolchain with the appropriate iOS target (`rustup target add aarch64-apple-ios`) +The host app must enable **Background Modes > Audio, AirPlay, and Picture in Picture** under Xcode's Signing & Capabilities, or add `audio` to `UIBackgroundModes` in `Info.plist`. The player registers Now Playing metadata and playback controls with Control Center. Pass an `ErikaMediaMetadata` value to provide the title, artist, album, and encoded artwork bytes. + +Background playback is disabled by default. Create the player with `ErikaPlayer(allowBackgroundPlayback: true)` to keep audio playing in the background. iOS does not guarantee continued background playback unless the host app also enables the Background Mode described above. + +```dart +final player = ErikaPlayer( + allowBackgroundPlayback: true, +); + +final artwork = await rootBundle.load('assets/cover.jpg'); +await player.open( + mediaUrl, + metadata: ErikaMediaMetadata( + title: 'Title', + artist: 'Artist', + album: 'Album', + artwork: artwork.buffer.asUint8List(), + ), +); +await player.play(); +``` + +`allowBackgroundPlayback` is a player creation option and cannot be changed after the native player has been created. When it is `false`, playback pauses as the app enters the background and remains paused on return. When it is `true`, video decoding is suspended while audio continues in the background, and video resumes when the app becomes active. Control Center supports play, pause, and position changes. Artwork must contain complete encoded image bytes in a format supported by `UIImage`, such as JPEG or PNG, rather than raw pixels. + +## System Media Navigation + +Playlist apps can enable the system previous and next buttons for the active +item. Erika emits a `systemMediaNavigationRequested` event instead of choosing +the next media item itself, so Dart remains the source of truth for the +playlist. Update the capabilities whenever the active item changes. + +```dart +import 'dart:async'; + +import 'package:erika_flutter/erika_flutter.dart'; + +class PlaylistController { + final ErikaPlayer player = ErikaPlayer(allowBackgroundPlayback: true); + final List<({String title, String url})> items = <({String title, String url})>[ + (title: 'Episode 1', url: 'https://example.com/episode-1.mp4'), + (title: 'Episode 2', url: 'https://example.com/episode-2.mp4'), + ]; + + StreamSubscription? subscription; + int index = 0; + bool switching = false; + + Future initialize() async { + subscription = player.events.listen((ErikaPlayerEvent event) async { + if (event.kind != ErikaEventKind.systemMediaNavigationRequested) { + return; + } + switch (event.systemMediaCommand) { + case ErikaSystemMediaCommand.previous: + await openAt(index - 1); + case ErikaSystemMediaCommand.next: + await openAt(index + 1); + case null: + break; + } + }); + await openAt(0); + } + + Future openAt(int newIndex) async { + if (switching || newIndex < 0 || newIndex >= items.length) { + return; + } + switching = true; + await player.setSystemMediaNavigation( + previousEnabled: false, + nextEnabled: false, + ); + try { + final item = items[newIndex]; + await player.open( + item.url, + metadata: ErikaMediaMetadata(title: item.title), + ); + await player.play(); + index = newIndex; + } finally { + switching = false; + await player.setSystemMediaNavigation( + previousEnabled: index > 0, + nextEnabled: index + 1 < items.length, + ); + } + } + + Future dispose() async { + await subscription?.cancel(); + await player.dispose(); + } +} +``` + +The capabilities default to disabled and work on iOS, tvOS, macOS, Android, +Windows, and HarmonyOS. Disable both buttons and reject duplicate requests while an item +is switching, then update the index, metadata, and capabilities after a +successful switch. Only `previous` and `next` are emitted by this API. Play, +pause, stop, and seek continue to be handled directly by the native +system-media integration. + ## tvOS Setup The tvOS CocoaPod script phase builds the native dependencies and C ABI static @@ -110,6 +218,11 @@ Requirements: Set `ERIKA_REPO_ROOT` if the plugin cannot locate the Erika checkout automatically. +The Windows plugin publishes title, artist, album, artwork, playback state, and +timeline through System Media Transport Controls (SMTC), and handles system +play, pause, and seek commands. A Windows SDK with C++/WinRT is required; the +plugin links the required WinRT system libraries automatically. + ## Android Setup The Android Gradle plugin invokes Erika's `xtask` dependency build and then @@ -123,6 +236,15 @@ Android `content://` media and subtitle URIs are opened through `ContentResolver`, detached, and passed to Erika as owned `fd://` sources with their provider offset and length. +Android uses MediaSession and a media notification for lock-screen, Bluetooth, +and system media controls. With `allowBackgroundPlayback: true`, a +`mediaPlayback` foreground service keeps audio running while video decoding is +suspended. The plugin manifest declares the foreground-service and Android 13+ +notification permissions, but the host app must request `POST_NOTIFICATIONS` +at runtime as appropriate for its product flow. If permission is denied, the +media session remains available while notification visibility depends on the +Android version and system policy. + Android's minimum remains API 26. Extended-linear output additionally needs the native-window dataspace API (API 28+); API 26/27 continue in SDR and report the specific fallback. On API 34+, the plugin observes @@ -139,6 +261,9 @@ selected release and packages it beside `liberika_flutter.so`; otherwise it requires the Rust `aarch64-unknown-linux-ohos` target and builds the LGPL native dependencies and runtime from source. Download failures fall back to source. +HarmonyOS uses AVSession to publish metadata, artwork, playback state, position, +and playback rate, and handles system play, pause, stop, and seek commands. + Use `ErikaVideoView` on HarmonyOS. It registers a Flutter external texture, obtains the texture surface as an `OHNativeWindow`, and renders through wgpu Vulkan. Audio uses OHAudio with interleaved f32 PCM. diff --git a/packages/erika_flutter/README.zh.md b/packages/erika_flutter/README.zh.md index 53bd18f..c46db4c 100644 --- a/packages/erika_flutter/README.zh.md +++ b/packages/erika_flutter/README.zh.md @@ -47,6 +47,106 @@ iOS CocoaPod script phase 会在 Xcode 构建期间自动构建 Erika 原生依 - `rustup target add aarch64-apple-ios` +宿主应用必须在 Xcode 的 Signing & Capabilities 中启用 Background Modes > Audio, AirPlay, and Picture in Picture,或在 `Info.plist` 的 `UIBackgroundModes` 中加入 `audio`。iOS、tvOS 和 macOS 会注册 Now Playing 信息及系统播放控制;建议通过 `ErikaMediaMetadata` 提供标题、作者、专辑和封面图片字节。 + +后台播放默认关闭。需要后台继续播放音频时,创建播放器时设置 `ErikaPlayer(allowBackgroundPlayback: true)`。宿主未启用上述 Background Mode 时,即使设置该选项,iOS 也不会保证后台持续播放。 + +```dart +final player = ErikaPlayer( + allowBackgroundPlayback: true, +); + +final artwork = await rootBundle.load('assets/cover.jpg'); +await player.open( + mediaUrl, + metadata: ErikaMediaMetadata( + title: '标题', + artist: '作者', + album: '专辑', + artwork: artwork.buffer.asUint8List(), + ), +); +await player.play(); +``` + +`allowBackgroundPlayback` 是播放器创建选项,播放器创建后不能动态修改。设为 `false` 时,App 进入后台会暂停播放,返回前台后保持暂停;设为 `true` 时,后台暂停视频解码但继续播放音频,返回前台后恢复视频。系统媒体面板支持播放、暂停和进度调整。封面应传入 JPEG、PNG 等完整编码图片字节,而不是原始像素数据。 + +## 系统媒体上一项与下一项 + +播放列表应用可以按当前条目启用系统媒体面板的上一项和下一项按钮。Erika 不会自行选择 +媒体,而是发出 `systemMediaNavigationRequested` 事件,让 Dart 始终作为播放列表的唯一 +数据源。当前条目变化后应同步更新按钮能力。 + +```dart +import 'dart:async'; + +import 'package:erika_flutter/erika_flutter.dart'; + +class PlaylistController { + final ErikaPlayer player = ErikaPlayer(allowBackgroundPlayback: true); + final List<({String title, String url})> items = <({String title, String url})>[ + (title: '第 1 集', url: 'https://example.com/episode-1.mp4'), + (title: '第 2 集', url: 'https://example.com/episode-2.mp4'), + ]; + + StreamSubscription? subscription; + int index = 0; + bool switching = false; + + Future initialize() async { + subscription = player.events.listen((ErikaPlayerEvent event) async { + if (event.kind != ErikaEventKind.systemMediaNavigationRequested) { + return; + } + switch (event.systemMediaCommand) { + case ErikaSystemMediaCommand.previous: + await openAt(index - 1); + case ErikaSystemMediaCommand.next: + await openAt(index + 1); + case null: + break; + } + }); + await openAt(0); + } + + Future openAt(int newIndex) async { + if (switching || newIndex < 0 || newIndex >= items.length) { + return; + } + switching = true; + await player.setSystemMediaNavigation( + previousEnabled: false, + nextEnabled: false, + ); + try { + final item = items[newIndex]; + await player.open( + item.url, + metadata: ErikaMediaMetadata(title: item.title), + ); + await player.play(); + index = newIndex; + } finally { + switching = false; + await player.setSystemMediaNavigation( + previousEnabled: index > 0, + nextEnabled: index + 1 < items.length, + ); + } + } + + Future dispose() async { + await subscription?.cancel(); + await player.dispose(); + } +} +``` + +该能力默认关闭,并适用于 iOS、tvOS、macOS、Android、Windows 和 HarmonyOS。切集期间应暂时 +关闭两个按钮并阻止重复请求;切换成功后再更新索引、metadata 和按钮能力。该 API 只上报 +`previous` 和 `next`,播放、暂停、停止与进度调整仍由各平台的原生系统媒体集成直接处理。 + ## tvOS Setup tvOS CocoaPod script phase 会在 Xcode 构建期间自动为 Apple TV 真机或模拟器构建 @@ -69,12 +169,16 @@ Windows 插件(`ErikaFlutterPluginCApi`)在 CMake 构建期间通过 `build_ 若插件无法自动定位 Erika checkout,可设置 `ERIKA_REPO_ROOT`。 +Windows 通过 System Media Transport Controls 发布标题、作者、专辑、封面、播放状态和时间线,并支持系统播放、暂停和进度调整。需要包含 C++/WinRT 的 Windows SDK;插件会自动链接所需的 WinRT 系统库。 + ## Android Setup Android Gradle 构建会先调用 Erika 的 `xtask` 构建原生依赖,再用 Cargo 为选定 ABI 构建 `erika_capi`。需要 Android API 26 或更高版本,并安装 Android NDK 和对应 Rust target。生成的 `jniLibs` 会同时包含 `liberika_capi.so` 与匹配 ABI 的 NDK `libc++_shared.so`。默认构建 arm64 与 x86_64;可通过 `-PerikaAndroidAbis=arm64-v8a,x86_64` 或 `ERIKA_ANDROID_ABIS` 指定。 Android `content://` 媒体和字幕 URI 会通过 `ContentResolver` 打开并 detach,连同 provider 的 offset/length 作为由 Rust 接管所有权的 `fd://` source 传入 Erika。 +Android 使用 MediaSession 和媒体通知接入锁屏、蓝牙耳机及系统媒体面板。`allowBackgroundPlayback: true` 时会启动 `mediaPlayback` 前台 Service,并在后台仅驱动音频;插件 Manifest 已声明前台服务和 Android 13+ 通知权限,但宿主应用仍需按产品流程向用户请求 `POST_NOTIFICATIONS` 运行时权限。该权限被拒绝时,系统媒体会话仍可工作,但通知展示取决于 Android 版本和系统策略。 + Android 最低版本仍为 API 26。Extended-linear 还要求 native-window dataspace API(API 28+);API 26/27 会继续 SDR 播放并报告对应 fallback。API 34+ 上,插件会监听 `Display.registerHdrSdrRatioChangedListener`,把真实 ratio 变化发布给 Erika,让 wgpu 无需 @@ -88,6 +192,8 @@ HarmonyOS 模块需要 DevEco Studio 的 OpenHarmony Native SDK。设置 `liberika_flutter.so` 一起打包;否则需要 Rust 的 `aarch64-unknown-linux-ohos` target,并从源码构建 LGPL 原生依赖和 runtime。下载失败会自动回退源码构建。 +HarmonyOS 使用 AVSession 发布媒体元数据、封面、播放状态、进度和倍速,并接收系统播放、暂停、停止及进度调整命令。 + HarmonyOS 上请使用 `ErikaVideoView`。它注册 Flutter 外部纹理,把纹理 surface 取为 `OHNativeWindow`,并通过 wgpu Vulkan 渲染。音频走 OHAudio,交错 f32 PCM。 diff --git a/packages/erika_flutter/android/src/main/AndroidManifest.xml b/packages/erika_flutter/android/src/main/AndroidManifest.xml index fbb2ef8..c48c289 100644 --- a/packages/erika_flutter/android/src/main/AndroidManifest.xml +++ b/packages/erika_flutter/android/src/main/AndroidManifest.xml @@ -1,3 +1,16 @@ + + + + + + + + diff --git a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidMediaState.kt b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidMediaState.kt new file mode 100644 index 0000000..76a2777 --- /dev/null +++ b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidMediaState.kt @@ -0,0 +1,91 @@ +package dev.aimesoft.erika_flutter + +internal data class AndroidMediaMetadata( + val title: String, + val artist: String?, + val album: String?, + val artwork: ByteArray?, +) + +internal data class AndroidMediaState( + val playerId: Long, + val metadata: AndroidMediaMetadata? = null, + val playbackState: Int = 0, + val positionMicros: Long = 0L, + val durationMicros: Long = 0L, + val playbackRate: Float = 1f, + val allowBackgroundPlayback: Boolean = false, + val previousEnabled: Boolean = false, + val nextEnabled: Boolean = false, +) + +internal fun AndroidMediaState.canPlay(activityActive: Boolean): Boolean = + activityActive || allowBackgroundPlayback + +internal fun androidMediaMetadata(arguments: Map): AndroidMediaMetadata { + val raw = arguments["metadata"] as? Map<*, *> + ?: throw IllegalArgumentException("metadata is required") + val title = (raw["title"] as? String)?.trim().orEmpty() + require(title.isNotEmpty()) { "metadata.title is required" } + return AndroidMediaMetadata( + title = title, + artist = (raw["artist"] as? String)?.takeIf(String::isNotBlank), + album = (raw["album"] as? String)?.takeIf(String::isNotBlank), + artwork = raw["artwork"] as? ByteArray, + ) +} + +internal fun updatedSystemMediaNavigation( + state: AndroidMediaState, + arguments: Map, +): AndroidMediaState = state.copy( + previousEnabled = arguments["previousEnabled"] as? Boolean ?: false, + nextEnabled = arguments["nextEnabled"] as? Boolean ?: false, +) + +internal fun systemMediaNavigationEvent( + state: AndroidMediaState, + navigation: String, +): Map? { + val enabled = when (navigation) { + SYSTEM_MEDIA_NAVIGATION_PREVIOUS -> state.previousEnabled + SYSTEM_MEDIA_NAVIGATION_NEXT -> state.nextEnabled + else -> false + } + if (!enabled) { + return null + } + return linkedMapOf( + "playerId" to state.playerId, + "kind" to SYSTEM_MEDIA_NAVIGATION_EVENT_KIND, + "navigation" to navigation, + ) +} + +internal const val SYSTEM_MEDIA_NAVIGATION_EVENT_KIND = 13 +internal const val SYSTEM_MEDIA_NAVIGATION_PREVIOUS = "previous" +internal const val SYSTEM_MEDIA_NAVIGATION_NEXT = "next" + +internal fun updatedAndroidMediaState( + state: AndroidMediaState, + event: Map<*, *>, +): AndroidMediaState { + val kind = (event["kind"] as? Number)?.toInt() + return state.copy( + playbackState = if (kind == STATE_CHANGED_EVENT_KIND) { + (event["state"] as? Number)?.toInt() ?: state.playbackState + } else { + state.playbackState + }, + positionMicros = if (kind == 3) { + ((event["positionMicros"] as? Number)?.toLong() ?: state.positionMicros).coerceAtLeast(0L) + } else { + state.positionMicros + }, + durationMicros = if (kind == 2 || kind == STATE_CHANGED_EVENT_KIND) { + ((event["durationMicros"] as? Number)?.toLong() ?: state.durationMicros).coerceAtLeast(0L) + } else { + state.durationMicros + }, + ) +} diff --git a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPlayerHost.kt b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPlayerHost.kt index 7d0e998..24e6703 100644 --- a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPlayerHost.kt +++ b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPlayerHost.kt @@ -5,6 +5,7 @@ import android.view.Surface internal class AndroidPlayerHost( val handle: Long, val requestedOutputMode: Int, + allowBackgroundPlayback: Boolean, ) { val requiresExtendedLinearSurface: Boolean get() = requestedOutputMode == 2 @@ -12,6 +13,11 @@ internal class AndroidPlayerHost( private val playbackTracker = AndroidPlaybackTracker() private val contentPreparations = AndroidContentPreparationRegistry() private val pendingEvents = AndroidPendingEventQueue(MAX_PENDING_EVENTS) + var mediaState = AndroidMediaState( + playerId = handle, + allowBackgroundPlayback = allowBackgroundPlayback, + ) + private set val playbackPhase: AndroidPlaybackPhase get() = playbackTracker.phase val surfaceAttached: Boolean @@ -43,6 +49,31 @@ internal class AndroidPlayerHost( fun cancelPlaybackIntent(): Boolean = playbackTracker.cancelPlaybackIntent() + fun setMediaMetadata(metadata: AndroidMediaMetadata?) { + mediaState = mediaState.copy(metadata = metadata) + } + + fun prepareForOpen(metadata: AndroidMediaMetadata?) { + mediaState = mediaState.copy( + metadata = metadata, + playbackState = 0, + positionMicros = 0L, + durationMicros = 0L, + ) + } + + fun setSystemMediaNavigation(arguments: Map) { + mediaState = updatedSystemMediaNavigation(mediaState, arguments) + } + + fun setPlaybackRate(rate: Float) { + mediaState = mediaState.copy(playbackRate = rate) + } + + fun updateMediaState(event: Map<*, *>) { + mediaState = updatedAndroidMediaState(mediaState, event) + } + fun requestRender() = playbackTracker.requestRender() fun markRenderAttempted() = playbackTracker.markRenderAttempted() @@ -139,6 +170,11 @@ internal class AndroidPlayerHost( return NativeJson.decodeResponse(ErikaNative.nativeRenderTick(handle, timeSeconds)) } + fun audioOnlyTick(): NativeResponse { + check(!destroyed) { "Erika player $handle has been destroyed" } + return NativeJson.decodeResponse(ErikaNative.nativeAudioOnlyTick(handle)) + } + fun pollEvent(): NativeResponse? { if (destroyed) { return null 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 a49389f..088e9a3 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 @@ -48,6 +48,7 @@ class ErikaFlutterPlugin : private lateinit var eventChannel: EventChannel private lateinit var choreographer: Choreographer private lateinit var audioFocus: ErikaAudioFocus + private lateinit var mediaSession: ErikaMediaSession private lateinit var mainHandler: Handler private lateinit var contentPreparationExecutor: ExecutorService @Volatile @@ -59,6 +60,7 @@ class ErikaFlutterPlugin : private var attachedToEngine = false private var activityLifecycle: Lifecycle? = null private var activityActive = false + private var activeMediaPlayerId: Long? = null internal val isActivityActive: Boolean get() = attachedToEngine && activityActive @@ -97,6 +99,22 @@ class ErikaFlutterPlugin : onFocusLoss = ::handleAudioFocusLoss, onFocusGain = ::handleAudioFocusGain, ) + mediaSession = ErikaMediaSession( + applicationContext, + object : ErikaMediaCommandHandler { + override fun play(playerId: Long) = performSystemMediaCommand(playerId, "play") + override fun pause(playerId: Long) = performSystemMediaCommand(playerId, "pause") + override fun stop(playerId: Long) = performSystemMediaCommand(playerId, "stop") + override fun seek(playerId: Long, positionMicros: Long) = + performSystemMediaCommand(playerId, "seek", mapOf("positionMicros" to positionMicros)) + override fun previous(playerId: Long) = + emitSystemMediaNavigation(playerId, SYSTEM_MEDIA_NAVIGATION_PREVIOUS) + override fun next(playerId: Long) = + emitSystemMediaNavigation(playerId, SYSTEM_MEDIA_NAVIGATION_NEXT) + }, + ) + ErikaMediaCommandReceiver.register(this, mediaSession::dispatch) + ErikaMediaPlaybackService.registerTickHandler(this, ::performBackgroundPlaybackTick) methodChannel = MethodChannel(binding.binaryMessenger, PLAYER_CHANNEL) eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) methodChannel.setMethodCallHandler(this) @@ -127,6 +145,9 @@ class ErikaFlutterPlugin : contentPreparationExecutor.shutdownNow() } audioFocus.abandon() + ErikaMediaCommandReceiver.unregister(this) + ErikaMediaPlaybackService.unregisterTickHandler(this) + mediaSession.release() } override fun onAttachedToActivity(binding: ActivityPluginBinding) { @@ -179,6 +200,8 @@ class ErikaFlutterPlugin : "detachOverlay" -> detachOverlay(arguments(call), result) "setOverlayFrame" -> setOverlayFrame(arguments(call), result) "screenshot" -> captureFrame(arguments(call), result) + "setMediaMetadata" -> setMediaMetadata(arguments(call), result) + "setSystemMediaNavigation" -> setSystemMediaNavigation(arguments(call), result) "registerSubtitleMemoryFont" -> registerSubtitleMemoryFont(arguments(call), result) in NATIVE_METHODS -> invokePlayer(call.method, arguments(call), result) else -> result.notImplemented() @@ -285,7 +308,11 @@ class ErikaFlutterPlugin : ) return } - players[handle] = AndroidPlayerHost(handle, outputMode) + players[handle] = AndroidPlayerHost( + handle, + outputMode, + arguments["allowBackgroundPlayback"] == true, + ) result.success(handle) } @@ -303,6 +330,11 @@ class ErikaFlutterPlugin : runCatching(host::destroy).onFailure { error -> Log.e(TAG, "Unable to destroy Erika player ${host.handle}", error) } + if (activeMediaPlayerId == host.handle) { + activeMediaPlayerId = null + ErikaMediaCommandReceiver.deactivate(this) + mediaSession.clear(host.handle) + } refreshFrameScheduling() } @@ -463,6 +495,13 @@ class ErikaFlutterPlugin : result: MethodChannel.Result, ) { val host = player(arguments) + if (method == "open") { + val metadata = arguments["metadata"] + if (metadata != null && metadata !is Map<*, *>) { + throw IllegalArgumentException("metadata must be a map") + } + host.prepareForOpen(metadata?.let { androidMediaMetadata(arguments) }) + } if (method == "play") { playWithAudioFocus(host, result) return @@ -522,6 +561,12 @@ class ErikaFlutterPlugin : if (response.ok && method in RENDER_REQUEST_METHODS) { host.requestRender() } + if (response.ok && method == "setPlaybackRate") { + host.setPlaybackRate((prepared.arguments["rate"] as? Number)?.toFloat() ?: 1f) + if (activeMediaPlayerId == host.handle) { + mediaSession.update(host.mediaState) + } + } drainEvents(host) refreshFrameScheduling() complete(result, response) @@ -691,7 +736,8 @@ class ErikaFlutterPlugin : private fun playWithAudioFocus(host: AndroidPlayerHost, result: MethodChannel.Result) { host.requestPlayback() refreshFrameScheduling() - if (!isActivityActive) { + if (!host.mediaState.canPlay(isActivityActive)) { + host.cancelPlaybackIntent() result.success(null) return } @@ -715,6 +761,9 @@ class ErikaFlutterPlugin : } if (response.ok) { host.playbackStarted() + activeMediaPlayerId = host.handle + ErikaMediaCommandReceiver.activate(this) + mediaSession.update(host.mediaState.copy(playbackState = PLAYING_STATE)) } else { host.cancelPlaybackIntent() abandonAudioFocusIfIdle() @@ -782,8 +831,16 @@ class ErikaFlutterPlugin : private fun suspendForActivityStop() { cancelFrameCallback() - val hostsToPause = players.values.toList().filter(AndroidPlayerHost::suspendPlayback) - audioFocus.abandon() + val hostsToPause = players.values.toList().filter { host -> + !host.mediaState.allowBackgroundPlayback && host.cancelPlaybackIntent() + } + if (players.values.none { host -> + host.mediaState.allowBackgroundPlayback && + host.playbackPhase != AndroidPlaybackPhase.PAUSED + } + ) { + audioFocus.abandon() + } hostsToPause.forEach { host -> runCatching { host.invoke("pause", emptyMap()) } .onSuccess { response -> @@ -841,7 +898,7 @@ class ErikaFlutterPlugin : } private fun startPendingPlayback(host: AndroidPlayerHost, source: String) { - if (!isActivityActive || + if (!host.mediaState.canPlay(isActivityActive) || !audioFocus.focusGranted || host.playbackPhase != AndroidPlaybackPhase.PENDING ) { @@ -871,6 +928,9 @@ class ErikaFlutterPlugin : ): PreparedNativeArguments { val nativeArguments = arguments.toMutableMap() nativeArguments.remove("playerId") + if (method == "open") { + nativeArguments.remove("metadata") + } if (method !in URI_METHODS) { return PreparedNativeArguments(nativeArguments, null) } @@ -1270,11 +1330,14 @@ class ErikaFlutterPlugin : } private fun handleAudioFocusGain() { - if (!isActivityActive || !audioFocus.focusGranted) { + if (!audioFocus.focusGranted) { return } players.values.toList() - .filter { it.playbackPhase == AndroidPlaybackPhase.PENDING } + .filter { + it.playbackPhase == AndroidPlaybackPhase.PENDING && + (isActivityActive || it.mediaState.allowBackgroundPlayback) + } .forEach { host -> startPendingPlayback(host, "audio focus") } refreshFrameScheduling() } @@ -1318,6 +1381,23 @@ class ErikaFlutterPlugin : } } + private fun performBackgroundPlaybackTick(@Suppress("UNUSED_PARAMETER") timeSeconds: Double) { + if (isActivityActive) { + return + } + players.values.toList() + .filter { + it.mediaState.allowBackgroundPlayback && + it.playbackPhase == AndroidPlaybackPhase.PLAYING + } + .forEach { host -> + runCatching { host.audioOnlyTick() } + .onSuccess { response -> reportRenderResponse(host, response) } + .onFailure { error -> reportRenderException(host, error) } + } + players.values.toList().forEach(::drainEvents) + } + private fun cancelFrameCallback() { if (!frameScheduled) { return @@ -1464,14 +1544,80 @@ class ErikaFlutterPlugin : ) } latestPlaybackState = updatedPlaybackState(latestPlaybackState, event) + host.updateMediaState(event) enqueuePendingEvent(host, AndroidPendingEvent.Success(event)) } latestPlaybackState?.let { state -> observeNativePlaybackState(host, state) } + if (activeMediaPlayerId == host.handle) { + mediaSession.update(host.mediaState) + } flushPendingEvents(host) } + private fun setMediaMetadata(arguments: Map, result: MethodChannel.Result) { + val host = player(arguments) + host.setMediaMetadata(androidMediaMetadata(arguments)) + if (activeMediaPlayerId == host.handle) { + mediaSession.update(host.mediaState) + } + result.success(null) + } + + private fun setSystemMediaNavigation( + arguments: Map, + result: MethodChannel.Result, + ) { + val host = player(arguments) + host.setSystemMediaNavigation(arguments) + if (activeMediaPlayerId == host.handle) { + mediaSession.update(host.mediaState) + } + result.success(null) + } + + private fun emitSystemMediaNavigation(playerId: Long, navigation: String) { + val host = players[playerId] ?: return + val event = systemMediaNavigationEvent(host.mediaState, navigation) ?: return + enqueuePendingEvent(host, AndroidPendingEvent.Success(event)) + flushPendingEvents(host) + } + + private fun performSystemMediaCommand( + playerId: Long, + method: String, + arguments: Map = emptyMap(), + ) { + val host = players[playerId] ?: return + if (method == "play") { + if (!host.mediaState.canPlay(isActivityActive)) { + return + } + host.requestPlayback() + val granted = runCatching { audioFocus.request() }.getOrNull() + if (granted != AudioFocusGrant.GRANTED) { + return + } + } else if (method in PLAYBACK_INTENT_CANCEL_METHODS) { + host.cancelPlaybackIntent() + } + runCatching { host.invoke(method, arguments) } + .onSuccess { response -> + if (response.ok && method == "play") { + host.playbackStarted() + activeMediaPlayerId = host.handle + ErikaMediaCommandReceiver.activate(this) + } + drainEvents(host) + if (activeMediaPlayerId == host.handle) { + mediaSession.update(host.mediaState) + } + refreshFrameScheduling() + } + .onFailure { error -> Log.e(TAG, "System media $method threw", error) } + } + private fun observeNativePlaybackState(host: AndroidPlayerHost, state: Int) { when (state) { PLAYING_STATE -> { diff --git a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaCommandReceiver.kt b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaCommandReceiver.kt new file mode 100644 index 0000000..d4c9a6d --- /dev/null +++ b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaCommandReceiver.kt @@ -0,0 +1,52 @@ +package dev.aimesoft.erika_flutter + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class ErikaMediaCommandReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + ErikaMediaSession.ACTION_PLAY -> dispatch(ErikaMediaSession.ACTION_PLAY) + ErikaMediaSession.ACTION_PAUSE -> dispatch(ErikaMediaSession.ACTION_PAUSE) + ErikaMediaSession.ACTION_STOP -> dispatch(ErikaMediaSession.ACTION_STOP) + } + } + + internal companion object { + private val handlers = LinkedHashMap Unit>() + private val activationOrder = LinkedHashSet() + + @Synchronized + fun register(owner: Any, handler: (String) -> Unit) { + handlers[owner] = handler + } + + @Synchronized + fun activate(owner: Any) { + if (!handlers.containsKey(owner)) { + return + } + activationOrder.remove(owner) + activationOrder.add(owner) + } + + @Synchronized + fun deactivate(owner: Any) { + activationOrder.remove(owner) + } + + @Synchronized + fun unregister(owner: Any) { + activationOrder.remove(owner) + handlers.remove(owner) + } + + private fun dispatch(action: String) { + val handler = synchronized(this) { + activationOrder.lastOrNull()?.let(handlers::get) + } + handler?.invoke(action) + } + } +} diff --git a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaPlaybackService.kt b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaPlaybackService.kt new file mode 100644 index 0000000..22c8e0e --- /dev/null +++ b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaPlaybackService.kt @@ -0,0 +1,93 @@ +package dev.aimesoft.erika_flutter + +import android.app.Notification +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.SystemClock + +class ErikaMediaPlaybackService : Service() { + private val handler = Handler(Looper.getMainLooper()) + private val tick = object : Runnable { + override fun run() { + dispatchTick(SystemClock.elapsedRealtimeNanos().toDouble() / 1_000_000_000.0) + handler.postDelayed(this, TICK_INTERVAL_MILLIS) + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> { + val notification = intent.notification() ?: return START_NOT_STICKY + startForeground(NOTIFICATION_ID, notification) + handler.removeCallbacks(tick) + handler.post(tick) + } + ACTION_STOP -> stopPlaybackService() + } + return START_NOT_STICKY + } + + override fun onDestroy() { + handler.removeCallbacks(tick) + super.onDestroy() + } + + private fun stopPlaybackService() { + handler.removeCallbacks(tick) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun Intent.notification(): Notification? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(EXTRA_NOTIFICATION, Notification::class.java) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(EXTRA_NOTIFICATION) + } + + companion object { + private const val ACTION_START = "dev.aimesoft.erika_flutter.action.START_MEDIA_PLAYBACK" + private const val ACTION_STOP = "dev.aimesoft.erika_flutter.action.STOP_MEDIA_PLAYBACK" + private const val EXTRA_NOTIFICATION = "notification" + private const val NOTIFICATION_ID = 0x4552494B + private const val TICK_INTERVAL_MILLIS = 16L + private val tickHandlers = LinkedHashMap Unit>() + + @Synchronized + fun registerTickHandler(owner: Any, handler: (Double) -> Unit) { + tickHandlers[owner] = handler + } + + @Synchronized + fun unregisterTickHandler(owner: Any) { + tickHandlers.remove(owner) + } + + private fun dispatchTick(timeSeconds: Double) { + val handlers = synchronized(this) { tickHandlers.values.toList() } + handlers.forEach { it(timeSeconds) } + } + + fun start(context: Context, notification: Notification) { + val intent = Intent(context, ErikaMediaPlaybackService::class.java) + .setAction(ACTION_START) + .putExtra(EXTRA_NOTIFICATION, notification) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + context.stopService(Intent(context, ErikaMediaPlaybackService::class.java)) + } + } +} diff --git a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaSession.kt b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaSession.kt new file mode 100644 index 0000000..4b22093 --- /dev/null +++ b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaSession.kt @@ -0,0 +1,297 @@ +package dev.aimesoft.erika_flutter + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadata +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.os.Handler +import android.os.Looper + +internal interface ErikaMediaCommandHandler { + fun play(playerId: Long) + fun pause(playerId: Long) + fun stop(playerId: Long) + fun seek(playerId: Long, positionMicros: Long) + fun previous(playerId: Long) + fun next(playerId: Long) +} + +internal class ErikaMediaSession( + context: Context, + private val commands: ErikaMediaCommandHandler, +) { + private val applicationContext = context.applicationContext + private val notificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val session = MediaSession(applicationContext, SESSION_TAG) + private var activeState: AndroidMediaState? = null + private var publishedState: AndroidMediaState? = null + private var cachedArtworkBytes: ByteArray? = null + private var cachedArtwork: Bitmap? = null + private var playbackServiceActive = false + + init { + notificationManager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + "Media playback", + NotificationManager.IMPORTANCE_LOW, + ), + ) + session.setCallback(object : MediaSession.Callback() { + override fun onPlay() = activeState?.let { commands.play(it.playerId) } ?: Unit + override fun onPause() = activeState?.let { commands.pause(it.playerId) } ?: Unit + override fun onStop() = activeState?.let { commands.stop(it.playerId) } ?: Unit + override fun onSeekTo(pos: Long) = + activeState?.let { commands.seek(it.playerId, pos.coerceAtLeast(0L) * 1_000L) } ?: Unit + override fun onSkipToPrevious() = + activeState?.takeIf(AndroidMediaState::previousEnabled) + ?.let { commands.previous(it.playerId) } ?: Unit + override fun onSkipToNext() = + activeState?.takeIf(AndroidMediaState::nextEnabled) + ?.let { commands.next(it.playerId) } ?: Unit + }, Handler(Looper.getMainLooper())) + } + + fun update(state: AndroidMediaState) { + activeState = state + val previous = publishedState + val metadataChanged = previous == null || + !sameMetadata(previous.metadata, state.metadata) || + previous.durationMicros != state.durationMicros + if (metadataChanged) { + val metadata = state.metadata + val metadataBuilder = MediaMetadata.Builder() + .putString( + MediaMetadata.METADATA_KEY_TITLE, + metadata?.title + ?: applicationContext.applicationInfo.loadLabel(applicationContext.packageManager).toString(), + ) + .putLong(MediaMetadata.METADATA_KEY_DURATION, state.durationMicros / 1_000L) + metadata?.artist?.let { metadataBuilder.putString(MediaMetadata.METADATA_KEY_ARTIST, it) } + metadata?.album?.let { metadataBuilder.putString(MediaMetadata.METADATA_KEY_ALBUM, it) } + artworkBitmap(metadata?.artwork)?.let { + metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it) + } + session.setMetadata(metadataBuilder.build()) + } + + val playbackChanged = previous == null || + previous.playbackState != state.playbackState || + previous.positionMicros != state.positionMicros || + previous.playbackRate != state.playbackRate || + previous.previousEnabled != state.previousEnabled || + previous.nextEnabled != state.nextEnabled + if (playbackChanged) { + session.setPlaybackState( + PlaybackState.Builder() + .setActions(state.androidPlaybackActions()) + .setState( + state.playbackState.toAndroidPlaybackState(), + state.positionMicros / 1_000L, + if (state.playbackState == PLAYING_STATE) state.playbackRate else 0f, + ) + .build(), + ) + } + + val wasActive = previous?.playbackState !in setOf(null, CLOSED_STATE, ERROR_STATE) + val isActive = state.playbackState !in setOf(CLOSED_STATE, ERROR_STATE) + if (wasActive != isActive) { + session.isActive = isActive + } + val notificationChanged = previous == null || metadataChanged || + previous.playbackState != state.playbackState || + previous.allowBackgroundPlayback != state.allowBackgroundPlayback + if (isActive && notificationChanged) { + val notification = notification(state) + if (state.shouldUsePlaybackService()) { + if (playbackServiceActive) { + notificationManager.notify(NOTIFICATION_ID, notification) + } else { + ErikaMediaPlaybackService.start(applicationContext, notification) + playbackServiceActive = true + } + } else { + stopPlaybackService() + notificationManager.notify(NOTIFICATION_ID, notification) + } + } else if (!isActive && wasActive) { + stopPlaybackService() + notificationManager.cancel(NOTIFICATION_ID) + } + publishedState = state + } + + fun dispatch(action: String) { + val state = activeState ?: return + when (action) { + ACTION_PLAY -> commands.play(state.playerId) + ACTION_PAUSE -> commands.pause(state.playerId) + ACTION_STOP -> commands.stop(state.playerId) + } + } + + fun clear(playerId: Long) { + if (activeState?.playerId != playerId) { + return + } + activeState = null + publishedState = null + cachedArtworkBytes = null + cachedArtwork = null + stopPlaybackService() + notificationManager.cancel(NOTIFICATION_ID) + session.setMetadata(null) + session.setPlaybackState( + PlaybackState.Builder().setState(PlaybackState.STATE_NONE, 0L, 0f).build(), + ) + session.isActive = false + } + + fun release() { + activeState = null + publishedState = null + cachedArtworkBytes = null + cachedArtwork = null + stopPlaybackService() + notificationManager.cancel(NOTIFICATION_ID) + session.isActive = false + session.release() + } + + private fun stopPlaybackService() { + if (!playbackServiceActive) { + return + } + ErikaMediaPlaybackService.stop(applicationContext) + playbackServiceActive = false + } + + private fun notification(state: AndroidMediaState): Notification { + val playing = state.playbackState == PLAYING_STATE + val builder = Notification.Builder(applicationContext, CHANNEL_ID) + .setSmallIcon( + applicationContext.applicationInfo.icon.takeIf { it != 0 } + ?: android.R.drawable.ic_media_play, + ) + .setContentTitle(state.metadata?.title ?: applicationContext.applicationInfo.loadLabel(applicationContext.packageManager)) + .setContentText(state.metadata?.artist ?: state.metadata?.album) + .setCategory(Notification.CATEGORY_TRANSPORT) + .setOnlyAlertOnce(true) + .setOngoing(playing) + .setShowWhen(false) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setStyle(Notification.MediaStyle().setMediaSession(session.sessionToken).setShowActionsInCompactView(0, 1)) + .addAction( + Notification.Action.Builder( + android.R.drawable.ic_delete, + "Stop", + commandIntent(ACTION_STOP), + ).build(), + ) + .addAction( + Notification.Action.Builder( + if (playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play, + if (playing) "Pause" else "Play", + commandIntent(if (playing) ACTION_PAUSE else ACTION_PLAY), + ).build(), + ) + artworkBitmap(state.metadata?.artwork)?.let(builder::setLargeIcon) + applicationContext.packageManager.getLaunchIntentForPackage(applicationContext.packageName)?.let { + builder.setContentIntent( + PendingIntent.getActivity( + applicationContext, + 0, + it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ), + ) + } + return builder.build() + } + + private fun artworkBitmap(bytes: ByteArray?): Bitmap? { + if (bytes == null) { + cachedArtworkBytes = null + cachedArtwork = null + return null + } + if (cachedArtworkBytes?.contentEquals(bytes) == true) { + return cachedArtwork + } + cachedArtworkBytes = bytes.copyOf() + cachedArtwork = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + return cachedArtwork + } + + private fun sameMetadata(left: AndroidMediaMetadata?, right: AndroidMediaMetadata?): Boolean { + if (left === right) { + return true + } + if (left == null || right == null) { + return false + } + return left.title == right.title && + left.artist == right.artist && + left.album == right.album && + when { + left.artwork === right.artwork -> true + left.artwork == null || right.artwork == null -> false + else -> left.artwork.contentEquals(right.artwork) + } + } + + private fun commandIntent(action: String): PendingIntent = PendingIntent.getBroadcast( + applicationContext, + action.hashCode(), + Intent(applicationContext, ErikaMediaCommandReceiver::class.java).setAction(action), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + companion object { + const val ACTION_PLAY = "dev.aimesoft.erika_flutter.action.PLAY" + const val ACTION_PAUSE = "dev.aimesoft.erika_flutter.action.PAUSE" + const val ACTION_STOP = "dev.aimesoft.erika_flutter.action.STOP" + const val PLAYING_STATE = 3 + const val CLOSED_STATE = 6 + const val ERROR_STATE = 7 + private const val SESSION_TAG = "ErikaMediaSession" + private const val CHANNEL_ID = "erika_media_playback" + private const val NOTIFICATION_ID = 0x4552494B + } +} + +internal fun AndroidMediaState.shouldUsePlaybackService(): Boolean = + allowBackgroundPlayback && playbackState == ErikaMediaSession.PLAYING_STATE + +internal fun AndroidMediaState.androidPlaybackActions(): Long { + var actions = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or + PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_STOP or + PlaybackState.ACTION_SEEK_TO + if (previousEnabled) { + actions = actions or PlaybackState.ACTION_SKIP_TO_PREVIOUS + } + if (nextEnabled) { + actions = actions or PlaybackState.ACTION_SKIP_TO_NEXT + } + return actions +} + +internal fun Int.toAndroidPlaybackState(): Int = when (this) { + 1 -> PlaybackState.STATE_CONNECTING + 2 -> PlaybackState.STATE_PAUSED + 3 -> PlaybackState.STATE_PLAYING + 4 -> PlaybackState.STATE_PAUSED + 5 -> PlaybackState.STATE_STOPPED + 6 -> PlaybackState.STATE_NONE + 7 -> PlaybackState.STATE_ERROR + else -> PlaybackState.STATE_NONE +} diff --git a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaNative.kt b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaNative.kt index 41daba6..a8802e5 100644 --- a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaNative.kt +++ b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaNative.kt @@ -54,6 +54,9 @@ internal object ErikaNative { @JvmStatic external fun nativeRenderTick(handle: Long, timeSeconds: Double): String + @JvmStatic + external fun nativeAudioOnlyTick(handle: Long): String + @JvmStatic external fun nativePollEvent(handle: Long): String? diff --git a/packages/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidMediaStateTest.kt b/packages/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidMediaStateTest.kt new file mode 100644 index 0000000..d6749bd --- /dev/null +++ b/packages/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidMediaStateTest.kt @@ -0,0 +1,143 @@ +package dev.aimesoft.erika_flutter + +import android.media.session.PlaybackState +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class AndroidMediaStateTest { + @Test + fun `metadata accepts supported system media fields`() { + val artwork = byteArrayOf(1, 2, 3) + + val metadata = androidMediaMetadata( + mapOf( + "metadata" to mapOf( + "title" to "Episode 1", + "artist" to "Erika", + "album" to "Season 1", + "artwork" to artwork, + ), + ), + ) + + assertEquals("Episode 1", metadata.title) + assertEquals("Erika", metadata.artist) + assertEquals("Season 1", metadata.album) + assertArrayEquals(artwork, metadata.artwork) + } + + @Test + fun `metadata requires a non blank title`() { + assertThrows(IllegalArgumentException::class.java) { + androidMediaMetadata(mapOf("metadata" to mapOf("title" to " "))) + } + } + + @Test + fun `native media events update only their authoritative fields`() { + var state = AndroidMediaState(playerId = 7L) + state = updatedAndroidMediaState( + state, + mapOf("kind" to 1, "state" to 3, "durationMicros" to 9_000_000L), + ) + state = updatedAndroidMediaState( + state, + mapOf("kind" to 3, "positionMicros" to 2_500_000L, "state" to 7), + ) + + assertEquals(3, state.playbackState) + assertEquals(9_000_000L, state.durationMicros) + assertEquals(2_500_000L, state.positionMicros) + } + + @Test + fun `erika playback states map to Android media session states`() { + assertEquals(PlaybackState.STATE_PLAYING, 3.toAndroidPlaybackState()) + assertEquals(PlaybackState.STATE_PAUSED, 4.toAndroidPlaybackState()) + assertEquals(PlaybackState.STATE_STOPPED, 5.toAndroidPlaybackState()) + assertEquals(PlaybackState.STATE_NONE, 6.toAndroidPlaybackState()) + assertEquals(PlaybackState.STATE_ERROR, 7.toAndroidPlaybackState()) + } + + @Test + fun `foreground playback service requires playing background enabled media`() { + val state = AndroidMediaState( + playerId = 7L, + playbackState = ErikaMediaSession.PLAYING_STATE, + allowBackgroundPlayback = true, + ) + + assertEquals(true, state.shouldUsePlaybackService()) + assertEquals(false, state.copy(playbackState = 4).shouldUsePlaybackService()) + assertEquals(false, state.copy(allowBackgroundPlayback = false).shouldUsePlaybackService()) + } + + @Test + fun `playback outside an active activity requires background opt in`() { + val foregroundOnly = AndroidMediaState(playerId = 7L) + val backgroundAllowed = foregroundOnly.copy(allowBackgroundPlayback = true) + + assertEquals(true, foregroundOnly.canPlay(activityActive = true)) + assertEquals(false, foregroundOnly.canPlay(activityActive = false)) + assertEquals(true, backgroundAllowed.canPlay(activityActive = false)) + } + + @Test + fun `system media navigation updates capabilities independently`() { + val state = updatedSystemMediaNavigation( + AndroidMediaState(playerId = 7L, nextEnabled = true), + mapOf("previousEnabled" to true, "nextEnabled" to false), + ) + + assertEquals(true, state.previousEnabled) + assertEquals(false, state.nextEnabled) + } + + @Test + fun `system media navigation defaults missing capabilities to disabled`() { + val state = updatedSystemMediaNavigation( + AndroidMediaState(playerId = 7L, previousEnabled = true, nextEnabled = true), + emptyMap(), + ) + + assertEquals(false, state.previousEnabled) + assertEquals(false, state.nextEnabled) + } + + @Test + fun `Android playback actions reflect navigation capabilities`() { + val base = AndroidMediaState(playerId = 7L) + + assertEquals(0L, base.androidPlaybackActions() and PlaybackState.ACTION_SKIP_TO_PREVIOUS) + assertEquals(0L, base.androidPlaybackActions() and PlaybackState.ACTION_SKIP_TO_NEXT) + assertEquals( + PlaybackState.ACTION_SKIP_TO_PREVIOUS, + base.copy(previousEnabled = true).androidPlaybackActions() and + PlaybackState.ACTION_SKIP_TO_PREVIOUS, + ) + assertEquals( + PlaybackState.ACTION_SKIP_TO_NEXT, + base.copy(nextEnabled = true).androidPlaybackActions() and + PlaybackState.ACTION_SKIP_TO_NEXT, + ) + } + + @Test + fun `enabled system media navigation creates kind 13 event`() { + val state = AndroidMediaState(playerId = 7L, previousEnabled = true) + + assertEquals( + mapOf( + "playerId" to 7L, + "kind" to SYSTEM_MEDIA_NAVIGATION_EVENT_KIND, + "navigation" to SYSTEM_MEDIA_NAVIGATION_PREVIOUS, + ), + systemMediaNavigationEvent(state, SYSTEM_MEDIA_NAVIGATION_PREVIOUS), + ) + assertNull(systemMediaNavigationEvent(state, SYSTEM_MEDIA_NAVIGATION_NEXT)) + assertNull(systemMediaNavigationEvent(state, "unknown")) + } +} diff --git a/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift b/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift index 8e635f4..91eed1a 100644 --- a/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift +++ b/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift @@ -1,6 +1,7 @@ import Darwin import AVFoundation import Flutter +import MediaPlayer import Metal import ObjectiveC.runtime import QuartzCore @@ -448,6 +449,7 @@ private final class ErikaNativeLibrary { typealias AttachMetalLayerFn = @convention(c) (UnsafeMutableRawPointer?, UInt64, UInt32, UInt32, Double) -> Int32 typealias ResizeSurfaceFn = @convention(c) (UnsafeMutableRawPointer?, UInt32, UInt32, Double) -> Int32 typealias RenderTickFn = @convention(c) (UnsafeMutableRawPointer?, Double, UnsafeMutableRawPointer?) -> Int32 + typealias AudioOnlyTickFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 typealias CaptureFrameRgbaFn = @convention(c) (UnsafeMutableRawPointer?, UInt32, UInt32, UnsafeMutableRawPointer?, Int) -> Int32 typealias PollEventFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 typealias LastErrorMessageFn = @convention(c) () -> UnsafeMutablePointer? @@ -506,6 +508,7 @@ private final class ErikaNativeLibrary { let resizeSurface: ResizeSurfaceFn let detachSurface: CommandFn let renderTick: RenderTickFn + let audioOnlyTick: AudioOnlyTickFn let captureFrameRgba: CaptureFrameRgbaFn? let pollEvent: PollEventFn let lastErrorMessage: LastErrorMessageFn @@ -574,6 +577,7 @@ private final class ErikaNativeLibrary { resizeSurface = try Self.load("erika_presenter_resize_surface", from: libraryHandle, as: ResizeSurfaceFn.self) detachSurface = try Self.load("erika_presenter_detach_surface", from: libraryHandle, as: CommandFn.self) renderTick = try Self.load("erika_presenter_render_tick", from: libraryHandle, as: RenderTickFn.self) + audioOnlyTick = try Self.load("erika_presenter_audio_only_tick", from: libraryHandle, as: AudioOnlyTickFn.self) captureFrameRgba = Self.loadOptional("erika_presenter_capture_frame_rgba", from: libraryHandle, as: CaptureFrameRgbaFn.self) pollEvent = try Self.load("erika_presenter_poll_event", from: libraryHandle, as: PollEventFn.self) lastErrorMessage = try Self.load("erika_last_error_message", from: libraryHandle, as: LastErrorMessageFn.self) @@ -663,13 +667,32 @@ private final class ErikaPlayerHost { private var currentDanmakuConfig = ErikaDanmakuConfigC() private let hdrDebug: Bool private let presenterConfig: ErikaPresenterConfigC + private let allowBackgroundPlayback: Bool private var loggedFirstRenderedVideoFrame = false private var latestPresenterStats = ErikaPresenterStatsC() - - init(id: Int64, library: ErikaNativeLibrary, config: ErikaPresenterConfigC, hdrDebug: Bool) throws { + private var fallbackTimer: DispatchSourceTimer? + private var isAppInBackground = false + private(set) var nowPlayingTitle = "" + private(set) var nowPlayingArtist: String? + private(set) var nowPlayingAlbum: String? + private(set) var nowPlayingArtwork: MPMediaItemArtwork? + private(set) var durationSeconds: Double? + private(set) var positionSeconds = 0.0 + private(set) var playbackRate = 1.0 + private(set) var isPlaying = false + var onNowPlayingChanged: ((ErikaPlayerHost) -> Void)? + + init( + id: Int64, + library: ErikaNativeLibrary, + config: ErikaPresenterConfigC, + hdrDebug: Bool, + allowBackgroundPlayback: Bool + ) throws { self.id = id self.library = library self.hdrDebug = hdrDebug + self.allowBackgroundPlayback = allowBackgroundPlayback presenterConfig = config guard let handle = library.createPresenter(config: config) else { throw ErikaPluginError.presenterCreateFailed @@ -683,11 +706,20 @@ private final class ErikaPlayerHost { deinit { displayLink?.invalidate() + fallbackTimer?.cancel() _ = library.detachSurface(handle) library.destroy(handle) } func open(uri: String, httpHeaders: [String: String]) throws { + isPlaying = false + positionSeconds = 0 + durationSeconds = nil + if nowPlayingTitle.isEmpty { + let fallbackTitle = URL(string: uri)?.lastPathComponent.removingPercentEncoding + ?? URL(fileURLWithPath: uri).lastPathComponent + nowPlayingTitle = fallbackTitle.isEmpty ? "Erika" : fallbackTitle + } try uri.withCString { cString in guard !httpHeaders.isEmpty else { try check(library.open(handle, cString), operation: "open") @@ -709,18 +741,42 @@ private final class ErikaPlayerHost { try check(openWithHeaders(handle, cString, buffer.baseAddress.map(UnsafeRawPointer.init), UInt(headers.count)), operation: "open") } } + notifyNowPlayingChanged() } func play() throws { try configureAudioSessionForPlayback() try check(library.play(handle), operation: "play") + isPlaying = true + updateTickDriver() + notifyNowPlayingChanged() + } + func pause() throws { + try check(library.pause(handle), operation: "pause") + isPlaying = false + updateTickDriver() + notifyNowPlayingChanged() + } + func stop() throws { + try check(library.stop(handle), operation: "stop") + isPlaying = false + positionSeconds = 0 + updateTickDriver() + notifyNowPlayingChanged() + } + func close() throws { + try check(library.close(handle), operation: "close") + isPlaying = false + positionSeconds = 0 + durationSeconds = nil + updateTickDriver() + notifyNowPlayingChanged() } - func pause() throws { try check(library.pause(handle), operation: "pause") } - func stop() throws { try check(library.stop(handle), operation: "stop") } - func close() throws { try check(library.close(handle), operation: "close") } func seek(positionMicros: UInt64) throws { try check(library.seek(handle, positionMicros), operation: "seek") + positionSeconds = Double(positionMicros) / 1_000_000 + notifyNowPlayingChanged() } func setPlaybackRate(_ rate: Double) throws { @@ -728,6 +784,50 @@ private final class ErikaPlayerHost { throw ErikaPluginError.symbolMissing("erika_presenter_set_playback_rate") } try check(setRate(handle, rate), operation: "set_playback_rate") + playbackRate = rate + notifyNowPlayingChanged() + } + + func setMediaMetadata(title: String, artist: String?, album: String?, artworkData: Data?) throws { + let artwork: MPMediaItemArtwork? + if let artworkData { + guard let image = UIImage(data: artworkData) else { + throw ErikaPluginError.invalidArguments("metadata.artwork must contain a supported image.") + } + artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + } else { + artwork = nil + } + nowPlayingTitle = title + nowPlayingArtist = artist + nowPlayingAlbum = album + nowPlayingArtwork = artwork + notifyNowPlayingChanged() + } + + func clearMediaMetadata() { + nowPlayingTitle = "" + nowPlayingArtist = nil + nowPlayingAlbum = nil + nowPlayingArtwork = nil + notifyNowPlayingChanged() + } + + func setAppInBackground(_ value: Bool) { + isAppInBackground = value + updateTickDriver() + } + + func prepareForInactiveApp() { + setAppInBackground(true) + audioOnlyTick(sendEvent: ErikaFlutterPlugin.sharedEventSink) + } + + func didEnterBackground() { + setAppInBackground(true) + if !allowBackgroundPlayback && isPlaying { + try? pause() + } } func setVolume(_ volume: Double) throws { @@ -1158,6 +1258,7 @@ private final class ErikaPlayerHost { view.attachedPlayerId = id try attachOrResize(view: view, attach: true) startDisplayLinkIfNeeded() + updateTickDriver() } func detach(viewId: Int64?) { @@ -1168,6 +1269,7 @@ private final class ErikaPlayerHost { displayLink = nil displayLinkProxy = nil _ = library.detachSurface(handle) + updateTickDriver() } func resizeFromAttachedView() { @@ -1202,14 +1304,39 @@ private final class ErikaPlayerHost { pollEvents(sendEvent: sendEvent) } + func audioOnlyTick(sendEvent: (([String: Any]) -> Void)?) { + var stats = ErikaPresenterStatsC() + let status = withUnsafeMutablePointer(to: &stats) { pointer in + library.audioOnlyTick(handle, UnsafeMutableRawPointer(pointer)) + } + if status != 0 { + NSLog("ErikaFlutterPlugin: audio_only_tick failed with status \(status)") + } else { + latestPresenterStats = stats + } + pollEvents(sendEvent: sendEvent) + } + func pollEvents(sendEvent: (([String: Any]) -> Void)?) { - guard let sendEvent else { return } while true { var event = ErikaEventC() let status = withUnsafeMutablePointer(to: &event) { pointer in library.pollEvent(handle, UnsafeMutableRawPointer(pointer)) } if status == 0 { + if event.durationMicros >= 0 { + durationSeconds = Double(event.durationMicros) / 1_000_000 + } + if event.kind == 3 { + positionSeconds = Double(event.positionMicros) / 1_000_000 + } + if event.kind == 1 { + isPlaying = event.state == 3 + updateTickDriver() + } + if event.kind == 1 || event.kind == 2 || event.kind == 3 { + notifyNowPlayingChanged() + } if event.kind == 6 { erikaHdrLog( hdrDebug, @@ -1219,7 +1346,7 @@ private final class ErikaPlayerHost { let message = event.kind == 9 || event.kind == 11 || event.kind == 12 ? library.currentEventMessage() : nil - sendEvent(event.toFlutterMap(playerId: id, host: self, structuredMessage: message)) + sendEvent?(event.toFlutterMap(playerId: id, host: self, structuredMessage: message)) continue } if status != 5 { @@ -1252,7 +1379,7 @@ private final class ErikaPlayerHost { } private func startDisplayLinkIfNeeded() { - guard displayLink == nil else { return } + guard displayLink == nil, attachedView != nil, !isAppInBackground else { return } startTimeSeconds = CACurrentMediaTime() let proxy = DisplayLinkProxy { [weak self] in self?.renderTick(sendEvent: ErikaFlutterPlugin.sharedEventSink) @@ -1264,6 +1391,39 @@ private final class ErikaPlayerHost { displayLink = link } + private func updateTickDriver() { + if isAppInBackground || (attachedView == nil && isPlaying) { + displayLink?.invalidate() + displayLink = nil + displayLinkProxy = nil + startFallbackTimerIfNeeded() + } else { + fallbackTimer?.cancel() + fallbackTimer = nil + startDisplayLinkIfNeeded() + } + } + + private func startFallbackTimerIfNeeded() { + guard fallbackTimer == nil, isPlaying else { return } + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now(), repeating: .milliseconds(16), leeway: .milliseconds(2)) + timer.setEventHandler { [weak self] in + guard let self else { return } + if self.isAppInBackground { + self.audioOnlyTick(sendEvent: ErikaFlutterPlugin.sharedEventSink) + } else { + self.renderTick(sendEvent: ErikaFlutterPlugin.sharedEventSink) + } + } + fallbackTimer = timer + timer.resume() + } + + private func notifyNowPlayingChanged() { + onNowPlayingChanged?(self) + } + private func resolvedDisplayLinkFps() -> Int { if let override = ProcessInfo.processInfo.environment["ERIKA_FLUTTER_TARGET_FPS"], let fps = Int(override), fps > 0 { @@ -1621,9 +1781,22 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan private var views: [Int64: WeakErikaVideoPlatformViewBox] = [:] private var nextPlayerId: Int64 = 1 private var pollTimer: Timer? + private var activePlayerId: Int64? + private var interruptedPlayerId: Int64? + private var notificationObservers: [NSObjectProtocol] = [] + private var remoteCommandTargets: [(MPRemoteCommand, Any)] = [] + private var systemMediaNavigation: [Int64: (previousEnabled: Bool, nextEnabled: Bool)] = [:] + + deinit { + notificationObservers.forEach(NotificationCenter.default.removeObserver) + remoteCommandTargets.forEach { command, target in + command.removeTarget(target) + } + } public static func register(with registrar: FlutterPluginRegistrar) { let instance = ErikaFlutterPlugin() + instance.configureSystemPlayback() let playerChannel = FlutterMethodChannel(name: playerChannelName, binaryMessenger: registrar.messenger()) let eventsChannel = FlutterEventChannel(name: eventsChannelName, binaryMessenger: registrar.messenger()) registrar.addMethodCallDelegate(instance, channel: playerChannel) @@ -1640,6 +1813,12 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan let args = try dictionaryArgs(call.arguments) let playerId = try requiredInt64(args["playerId"], name: "playerId") players.removeValue(forKey: playerId) + systemMediaNavigation.removeValue(forKey: playerId) + if activePlayerId == playerId { + activePlayerId = nil + clearNowPlayingInfo() + refreshRemoteCommands() + } result(nil) case "open": let args = try dictionaryArgs(call.arguments) @@ -1648,10 +1827,19 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan throw ErikaPluginError.invalidArguments("uri is required.") } let headers = (args["httpHeaders"] as? [String: String]) ?? [:] + if let metadata = args["metadata"] as? [String: Any] { + try applyMediaMetadata(metadata, to: host) + } else { + host.clearMediaMetadata() + } try host.open(uri: uri, httpHeaders: headers) result(nil) case "play": - try playerHost(from: try dictionaryArgs(call.arguments)).play() + let host = try playerHost(from: try dictionaryArgs(call.arguments)) + try host.play() + activePlayerId = host.id + refreshRemoteCommands() + updateNowPlayingInfo(for: host) result(nil) case "pause": try playerHost(from: try dictionaryArgs(call.arguments)).pause() @@ -1673,6 +1861,25 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan } try playerHost(from: args).setPlaybackRate(rate) result(nil) + case "setMediaMetadata": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let metadata = args["metadata"] as? [String: Any] else { + throw ErikaPluginError.invalidArguments("metadata is required.") + } + try applyMediaMetadata(metadata, to: host) + result(nil) + case "setSystemMediaNavigation": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + systemMediaNavigation[host.id] = ( + previousEnabled: boolValue(args["previousEnabled"]) ?? false, + nextEnabled: boolValue(args["nextEnabled"]) ?? false + ) + if activePlayerId == host.id { + refreshRemoteCommands() + } + result(nil) case "setVolume": let args = try dictionaryArgs(call.arguments) guard let volume = doubleValue(args["volume"]) else { @@ -2143,11 +2350,210 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan let config = presenterConfigForNewPlayer(arguments: arguments, hdrDebug: hdrDebug) let id = nextPlayerId nextPlayerId += 1 - players[id] = try ErikaPlayerHost(id: id, library: library, config: config, hdrDebug: hdrDebug) + let host = try ErikaPlayerHost( + id: id, + library: library, + config: config, + hdrDebug: hdrDebug, + allowBackgroundPlayback: boolValue(args?["allowBackgroundPlayback"]) ?? false + ) + host.onNowPlayingChanged = { [weak self] changedHost in + guard self?.activePlayerId == changedHost.id else { return } + self?.updateNowPlayingInfo(for: changedHost) + } + players[id] = host + systemMediaNavigation[id] = (previousEnabled: false, nextEnabled: false) startPollTimerIfNeeded() return id } + private func configureSystemPlayback() { + let center = NotificationCenter.default + notificationObservers.append(center.addObserver( + forName: UIApplication.willResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.players.values.forEach { $0.prepareForInactiveApp() } + }) + notificationObservers.append(center.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.players.values.forEach { $0.didEnterBackground() } + }) + notificationObservers.append(center.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.players.values.forEach { $0.setAppInBackground(false) } + }) + notificationObservers.append(center.addObserver( + forName: AVAudioSession.interruptionNotification, + object: AVAudioSession.sharedInstance(), + queue: .main + ) { [weak self] notification in + self?.handleAudioInterruption(notification) + }) + + let commands = MPRemoteCommandCenter.shared() + addRemoteTarget(commands.playCommand) { [weak self] _ in self?.performRemotePlay() ?? .commandFailed } + addRemoteTarget(commands.pauseCommand) { [weak self] _ in self?.performRemotePause() ?? .commandFailed } + addRemoteTarget(commands.togglePlayPauseCommand) { [weak self] _ in self?.performRemoteToggle() ?? .commandFailed } + addRemoteTarget(commands.changePlaybackPositionCommand) { [weak self] event in + guard let positionEvent = event as? MPChangePlaybackPositionCommandEvent else { + return .commandFailed + } + return self?.performRemoteSeek(positionEvent.positionTime) ?? .commandFailed + } + addRemoteTarget(commands.previousTrackCommand) { [weak self] _ in + self?.emitSystemMediaNavigation("previous") ?? .commandFailed + } + addRemoteTarget(commands.nextTrackCommand) { [weak self] _ in + self?.emitSystemMediaNavigation("next") ?? .commandFailed + } + refreshRemoteCommands() + } + + private func addRemoteTarget( + _ command: MPRemoteCommand, + handler: @escaping (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus + ) { + let target = command.addTarget(handler: handler) + remoteCommandTargets.append((command, target)) + } + + private func applyMediaMetadata(_ metadata: [String: Any], to host: ErikaPlayerHost) throws { + guard let title = metadata["title"] as? String, !title.isEmpty else { + throw ErikaPluginError.invalidArguments("metadata.title is required.") + } + try host.setMediaMetadata( + title: title, + artist: metadata["artist"] as? String, + album: metadata["album"] as? String, + artworkData: (metadata["artwork"] as? FlutterStandardTypedData)?.data + ) + } + + private func updateNowPlayingInfo(for host: ErikaPlayerHost) { + var info: [String: Any] = [ + MPMediaItemPropertyTitle: host.nowPlayingTitle, + MPNowPlayingInfoPropertyElapsedPlaybackTime: host.positionSeconds, + MPNowPlayingInfoPropertyPlaybackRate: host.isPlaying ? host.playbackRate : 0, + MPNowPlayingInfoPropertyDefaultPlaybackRate: host.playbackRate, + MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.video.rawValue, + ] + if let artist = host.nowPlayingArtist { info[MPMediaItemPropertyArtist] = artist } + if let album = host.nowPlayingAlbum { info[MPMediaItemPropertyAlbumTitle] = album } + if let artwork = host.nowPlayingArtwork { info[MPMediaItemPropertyArtwork] = artwork } + if let duration = host.durationSeconds { info[MPMediaItemPropertyPlaybackDuration] = duration } + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = info + center.playbackState = host.isPlaying ? .playing : .paused + } + + private func clearNowPlayingInfo() { + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = nil + center.playbackState = .stopped + } + + private func performRemotePlay() -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + do { + try host.play() + return .success + } catch { + return .commandFailed + } + } + + private func performRemotePause() -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + do { + try host.pause() + return .success + } catch { + return .commandFailed + } + } + + private func performRemoteToggle() -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + return host.isPlaying ? performRemotePause() : performRemotePlay() + } + + private func performRemoteSeek(_ position: TimeInterval) -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + do { + try host.seek(positionMicros: UInt64(max(0, position) * 1_000_000)) + return .success + } catch { + return .commandFailed + } + } + + private func emitSystemMediaNavigation(_ navigation: String) -> MPRemoteCommandHandlerStatus { + performOnMain { + guard let playerId = self.activePlayerId, + self.players[playerId] != nil, + let capabilities = self.systemMediaNavigation[playerId] else { return .noSuchContent } + let enabled = navigation == "previous" + ? capabilities.previousEnabled + : capabilities.nextEnabled + guard enabled else { return .noSuchContent } + Self.sharedEventSink?([ + "playerId": playerId, + "kind": 13, + "navigation": navigation, + ]) + return .success + } + } + + private func performOnMain( + _ work: @escaping () -> MPRemoteCommandHandlerStatus + ) -> MPRemoteCommandHandlerStatus { + if Thread.isMainThread { + return work() + } + return DispatchQueue.main.sync(execute: work) + } + + private func refreshRemoteCommands() { + let commands = MPRemoteCommandCenter.shared() + let enabled = activePlayerId.flatMap { players[$0] } != nil + remoteCommandTargets.forEach { command, _ in + command.isEnabled = enabled + } + let capabilities = activePlayerId.flatMap { systemMediaNavigation[$0] } + commands.previousTrackCommand.isEnabled = enabled && capabilities?.previousEnabled == true + commands.nextTrackCommand.isEnabled = enabled && capabilities?.nextEnabled == true + } + + private func handleAudioInterruption(_ notification: Notification) { + guard let rawType = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: rawType), + let host = activePlayerId.flatMap({ players[$0] }) else { return } + if type == .began { + interruptedPlayerId = host.isPlaying ? host.id : nil + if host.isPlaying { + try? host.pause() + } + return + } + guard let rawOptions = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt, + AVAudioSession.InterruptionOptions(rawValue: rawOptions).contains(.shouldResume), + interruptedPlayerId == host.id else { + interruptedPlayerId = nil + return + } + interruptedPlayerId = nil + try? host.play() + } + private func presenterConfigForNewPlayer(arguments: Any?, hdrDebug: Bool) -> ErikaPresenterConfigC { if let args = arguments as? [String: Any], let explicitMode = int32Value(args["outputMode"]) { let headroom = floatValue(args["edrHeadroom"]) ?? 4.0 diff --git a/packages/erika_flutter/lib/src/erika_event.dart b/packages/erika_flutter/lib/src/erika_event.dart index 60bda2e..b92f5e2 100644 --- a/packages/erika_flutter/lib/src/erika_event.dart +++ b/packages/erika_flutter/lib/src/erika_event.dart @@ -23,6 +23,12 @@ enum ErikaEventKind { trackSelectionChanged, videoDecoderChanged, audioOutputChanged, + systemMediaNavigationRequested, +} + +enum ErikaSystemMediaCommand { + previous, + next, } enum ErikaTrackKind { @@ -374,6 +380,7 @@ class ErikaPlayerEvent { this.message, this.decoder, this.audio, + this.systemMediaCommand, }); factory ErikaPlayerEvent.fromMap(Map map) { @@ -405,6 +412,11 @@ class ErikaPlayerEvent { ErikaAudioOutputInfo.fromMap(value), _ => null, }, + systemMediaCommand: switch (map['navigation']) { + 'previous' => ErikaSystemMediaCommand.previous, + 'next' => ErikaSystemMediaCommand.next, + _ => null, + }, ); } @@ -423,6 +435,7 @@ class ErikaPlayerEvent { final String? message; final ErikaVideoDecoderInfo? decoder; final ErikaAudioOutputInfo? audio; + final ErikaSystemMediaCommand? systemMediaCommand; static int _asInt(Object? value) { if (value is int) { diff --git a/packages/erika_flutter/lib/src/erika_player.dart b/packages/erika_flutter/lib/src/erika_player.dart index 8ce9d66..b770578 100644 --- a/packages/erika_flutter/lib/src/erika_player.dart +++ b/packages/erika_flutter/lib/src/erika_player.dart @@ -6,6 +6,28 @@ import 'package:flutter/services.dart'; import 'erika_event.dart'; +@immutable +class ErikaMediaMetadata { + const ErikaMediaMetadata({ + required this.title, + this.artist, + this.album, + this.artwork, + }); + + final String title; + final String? artist; + final String? album; + final Uint8List? artwork; + + Map toMap() => { + 'title': title, + if (artist != null) 'artist': artist!, + if (album != null) 'album': album!, + if (artwork != null) 'artwork': artwork!, + }; +} + /// Subtitle text colour Erika falls back to, as `0xRRGGBBAA`: opaque white. const int kErikaDefaultSubtitlePrimaryColorRgba = 0xFFFFFFFF; @@ -26,8 +48,7 @@ const int kErikaSubtitleOverrideBorder = 1 << 6; const int kErikaSubtitleOverrideAlignment = 1 << 7; const int kErikaSubtitleOverrideMargins = 1 << 8; const int kErikaSubtitleOverrideBlur = 1 << 11; -const int kErikaSubtitleOverrideAll = - kErikaSubtitleOverrideFontSizeFields | +const int kErikaSubtitleOverrideAll = kErikaSubtitleOverrideFontSizeFields | kErikaSubtitleOverrideFontName | kErikaSubtitleOverrideColors | kErikaSubtitleOverrideAttributes | @@ -519,9 +540,8 @@ class _ErikaDanmakuConfigPatch { this.blockBottom, this.blockScroll, List? blockWords, - }) : blockWords = blockWords == null - ? null - : List.unmodifiable(blockWords); + }) : blockWords = + blockWords == null ? null : List.unmodifiable(blockWords); final bool? enabled; final double? fontSize; @@ -603,39 +623,36 @@ class _ErikaDanmakuConfigPatch { enabled: _changed(enabled, previous?.enabled) ? enabled : null, fontSize: _changed(fontSize, previous?.fontSize) ? fontSize : null, opacity: _changed(opacity, previous?.opacity) ? opacity : null, - displayArea: _changed(displayArea, previous?.displayArea) - ? displayArea - : null, + displayArea: + _changed(displayArea, previous?.displayArea) ? displayArea : null, scrollDurationSeconds: _changed(scrollDurationSeconds, previous?.scrollDurationSeconds) - ? scrollDurationSeconds - : null, + ? scrollDurationSeconds + : null, scrollSpeedFactor: _changed(scrollSpeedFactor, previous?.scrollSpeedFactor) - ? scrollSpeedFactor - : null, + ? scrollSpeedFactor + : null, trackGapRatio: _changed(trackGapRatio, previous?.trackGapRatio) ? trackGapRatio : null, - outlineWidth: _changed(outlineWidth, previous?.outlineWidth) - ? outlineWidth - : null, + outlineWidth: + _changed(outlineWidth, previous?.outlineWidth) ? outlineWidth : null, shadowOffsetX: _changed(shadowOffsetX, previous?.shadowOffsetX) ? shadowOffsetX : null, shadowOffsetY: _changed(shadowOffsetY, previous?.shadowOffsetY) ? shadowOffsetY : null, - shadowStyle: _changed(shadowStyle, previous?.shadowStyle) - ? shadowStyle - : null, + shadowStyle: + _changed(shadowStyle, previous?.shadowStyle) ? shadowStyle : null, customFontFamily: _changed(customFontFamily, previous?.customFontFamily) ? customFontFamily : null, customFontFilePath: _changed(customFontFilePath, previous?.customFontFilePath) - ? customFontFilePath - : null, + ? customFontFilePath + : null, mergeDuplicates: _changed(mergeDuplicates, previous?.mergeDuplicates) ? mergeDuplicates : null, @@ -644,24 +661,20 @@ class _ErikaDanmakuConfigPatch { : null, allowScrollOverwrite: _changed(allowScrollOverwrite, previous?.allowScrollOverwrite) - ? allowScrollOverwrite - : null, - maxQuantity: _changed(maxQuantity, previous?.maxQuantity) - ? maxQuantity - : null, + ? allowScrollOverwrite + : null, + maxQuantity: + _changed(maxQuantity, previous?.maxQuantity) ? maxQuantity : null, maxLinesPerMode: _changed(maxLinesPerMode, previous?.maxLinesPerMode) ? maxLinesPerMode : null, blockTop: _changed(blockTop, previous?.blockTop) ? blockTop : null, - blockBottom: _changed(blockBottom, previous?.blockBottom) - ? blockBottom - : null, - blockScroll: _changed(blockScroll, previous?.blockScroll) - ? blockScroll - : null, - blockWords: _changedList(blockWords, previous?.blockWords) - ? blockWords - : null, + blockBottom: + _changed(blockBottom, previous?.blockBottom) ? blockBottom : null, + blockScroll: + _changed(blockScroll, previous?.blockScroll) ? blockScroll : null, + blockWords: + _changedList(blockWords, previous?.blockWords) ? blockWords : null, ); } @@ -708,6 +721,7 @@ class ErikaPlayer { this.edrHeadroom, this.upscaler, this.hdrDebug = false, + this.allowBackgroundPlayback = false, }) { final headroom = edrHeadroom; if (headroom != null && @@ -772,6 +786,7 @@ class ErikaPlayer { final double? edrHeadroom; final ErikaUpscalerMode? upscaler; final bool hdrDebug; + final bool allowBackgroundPlayback; int? get id => _id; @@ -791,13 +806,38 @@ class ErikaPlayer { return _requireActiveAfter(player); } - Future open(String uri, {Map? httpHeaders}) async { + Future open( + String uri, { + Map? httpHeaders, + ErikaMediaMetadata? metadata, + }) async { final playerId = await ensureCreated(); await _invoke('open', { 'playerId': playerId, 'uri': uri, if (httpHeaders != null && httpHeaders.isNotEmpty) 'httpHeaders': httpHeaders, + 'metadata': metadata?.toMap(), + }); + } + + Future setMediaMetadata(ErikaMediaMetadata metadata) async { + final playerId = await ensureCreated(); + await _invoke('setMediaMetadata', { + 'playerId': playerId, + 'metadata': metadata.toMap(), + }); + } + + Future setSystemMediaNavigation({ + required bool previousEnabled, + required bool nextEnabled, + }) async { + final playerId = await ensureCreated(); + await _invoke('setSystemMediaNavigation', { + 'playerId': playerId, + 'previousEnabled': previousEnabled, + 'nextEnabled': nextEnabled, }); } @@ -1120,11 +1160,11 @@ class ErikaPlayer { final playerId = await ensureCreated(); final trackId = await _channel .invokeMethod('addDanmakuTrackFile', { - 'playerId': playerId, - 'uri': uri, - if (name != null) 'name': name, - 'offsetMicros': offset.inMicroseconds, - }); + 'playerId': playerId, + 'uri': uri, + if (name != null) 'name': name, + 'offsetMicros': offset.inMicroseconds, + }); if (trackId == null || trackId <= 0) { throw StateError('Erika danmaku track add returned no track id.'); } @@ -1139,11 +1179,11 @@ class ErikaPlayer { final playerId = await ensureCreated(); final trackId = await _channel .invokeMethod('addDanmakuTrackJson', { - 'playerId': playerId, - 'json': json, - if (name != null) 'name': name, - 'offsetMicros': offset.inMicroseconds, - }); + 'playerId': playerId, + 'json': json, + if (name != null) 'name': name, + 'offsetMicros': offset.inMicroseconds, + }); if (trackId == null || trackId <= 0) { throw StateError('Erika danmaku track add returned no track id.'); } @@ -1541,14 +1581,14 @@ class ErikaPlayer { } Future _create() async { - final requestedHeadroom = - edrHeadroom ?? + final requestedHeadroom = edrHeadroom ?? (outputMode == ErikaOutputMode.extendedLinear ? 4.0 : null); final arguments = { if (outputMode case final mode?) 'outputMode': mode.nativeValue, if (requestedHeadroom case final headroom?) 'edrHeadroom': headroom, if (upscaler case final mode?) 'upscaler': mode.nativeValue, if (hdrDebug) 'hdrDebug': true, + if (allowBackgroundPlayback) 'allowBackgroundPlayback': true, }; if (hdrDebug) { debugPrint('ErikaHDR[Dart]: create arguments=$arguments'); diff --git a/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift b/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift index 30bda52..7525c85 100644 --- a/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift +++ b/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift @@ -3,6 +3,7 @@ import CoreVideo import Darwin import FlutterMacOS import Metal +import MediaPlayer import ObjectiveC.runtime import QuartzCore @@ -690,6 +691,26 @@ private final class ErikaPlayerHost { private var startTimeSeconds: CFTimeInterval = CACurrentMediaTime() private var currentDanmakuConfig = ErikaDanmakuConfigC() private var latestPresenterStats = ErikaPresenterStatsC() + private(set) var nowPlayingTitle = "" + private(set) var nowPlayingArtist: String? + private(set) var nowPlayingAlbum: String? + private(set) var nowPlayingArtwork: MPMediaItemArtwork? + private(set) var durationSeconds: Double? + private(set) var positionSeconds = 0.0 + private(set) var playbackRate = 1.0 + private(set) var playbackState = 0 + private var positionUpdateTime = ProcessInfo.processInfo.systemUptime + var onNowPlayingChanged: ((ErikaPlayerHost) -> Void)? + + var isPlaying: Bool { playbackState == 3 } + + var isStopped: Bool { playbackState == 5 || playbackState == 6 || playbackState == 7 } + + var nowPlayingPositionSeconds: Double { + guard isPlaying else { return positionSeconds } + return positionSeconds + + max(0, ProcessInfo.processInfo.systemUptime - positionUpdateTime) * playbackRate + } init(id: Int64, library: ErikaNativeLibrary, config: ErikaPresenterConfigC) throws { self.id = id @@ -708,6 +729,15 @@ private final class ErikaPlayerHost { } func open(uri: String, httpHeaders: [String: String]) throws { + playbackState = 0 + positionSeconds = 0 + durationSeconds = nil + positionUpdateTime = ProcessInfo.processInfo.systemUptime + if nowPlayingTitle.isEmpty { + let fallbackTitle = URL(string: uri)?.lastPathComponent.removingPercentEncoding + ?? URL(fileURLWithPath: uri).lastPathComponent + nowPlayingTitle = fallbackTitle.isEmpty ? "Erika" : fallbackTitle + } try uri.withCString { cString in guard !httpHeaders.isEmpty else { try check(library.open(handle, cString), operation: "open") @@ -729,6 +759,7 @@ private final class ErikaPlayerHost { try check(openWithHeaders(handle, cString, buffer.baseAddress.map(UnsafeRawPointer.init), UInt(headers.count)), operation: "open") } } + onNowPlayingChanged?(self) } func play() throws { @@ -749,6 +780,9 @@ private final class ErikaPlayerHost { func seek(positionMicros: UInt64) throws { try check(library.seek(handle, positionMicros), operation: "seek") + positionSeconds = Double(positionMicros) / 1_000_000 + positionUpdateTime = ProcessInfo.processInfo.systemUptime + onNowPlayingChanged?(self) } func setPlaybackRate(_ rate: Double) throws { @@ -756,6 +790,35 @@ private final class ErikaPlayerHost { throw ErikaPluginError.symbolMissing("erika_presenter_set_playback_rate") } try check(setRate(handle, rate), operation: "set_playback_rate") + positionSeconds = nowPlayingPositionSeconds + positionUpdateTime = ProcessInfo.processInfo.systemUptime + playbackRate = rate + onNowPlayingChanged?(self) + } + + func setMediaMetadata(title: String, artist: String?, album: String?, artworkData: Data?) throws { + let artwork: MPMediaItemArtwork? + if let artworkData { + guard let image = NSImage(data: artworkData) else { + throw ErikaPluginError.invalidArguments("metadata.artwork must contain a supported image.") + } + artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + } else { + artwork = nil + } + nowPlayingTitle = title + nowPlayingArtist = artist + nowPlayingAlbum = album + nowPlayingArtwork = artwork + onNowPlayingChanged?(self) + } + + func clearMediaMetadata() { + nowPlayingTitle = "" + nowPlayingArtist = nil + nowPlayingAlbum = nil + nowPlayingArtwork = nil + onNowPlayingChanged?(self) } func setVolume(_ volume: Double) throws { @@ -1234,19 +1297,37 @@ private final class ErikaPlayerHost { } func pollEvents(sendEvent: (([String: Any]) -> Void)?) { - guard let sendEvent else { - return - } while true { var event = ErikaEventC() let status = withUnsafeMutablePointer(to: &event) { pointer in library.pollEvent(handle, UnsafeMutableRawPointer(pointer)) } if status == 0 { + if event.kind == 1 { + positionSeconds = nowPlayingPositionSeconds + positionUpdateTime = ProcessInfo.processInfo.systemUptime + playbackState = Int(event.state) + if isStopped { + positionSeconds = 0 + } + if playbackState == 6 { + durationSeconds = nil + } + } else if event.kind == 2 { + durationSeconds = event.durationMicros >= 0 + ? Double(event.durationMicros) / 1_000_000 + : nil + } else if event.kind == 3 { + positionSeconds = Double(event.positionMicros) / 1_000_000 + positionUpdateTime = ProcessInfo.processInfo.systemUptime + } + if event.kind == 1 || event.kind == 2 || event.kind == 3 { + onNowPlayingChanged?(self) + } let message = event.kind == 9 || event.kind == 11 || event.kind == 12 ? library.currentEventMessage() : nil - sendEvent(event.toFlutterMap(playerId: id, host: self, structuredMessage: message)) + sendEvent?(event.toFlutterMap(playerId: id, host: self, structuredMessage: message)) continue } if status != 5 { @@ -1891,6 +1972,9 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan private var windowOverlayPlayerIds: Set = [] private var nextPlayerId: Int64 = 1 private var pollTimer: Timer? + private var activePlayerId: Int64? + private var remoteCommandTargets: [(MPRemoteCommand, Any)] = [] + private var systemMediaNavigation: [Int64: (previousEnabled: Bool, nextEnabled: Bool)] = [:] init(flutterHostView: NSView?, flutterHostViewController: NSViewController?) { self.flutterHostView = flutterHostView @@ -1898,11 +1982,21 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan super.init() } + deinit { + pollTimer?.invalidate() + clearNowPlayingInfo() + remoteCommandTargets.forEach { command, target in + command.isEnabled = false + command.removeTarget(target) + } + } + public static func register(with registrar: FlutterPluginRegistrar) { let instance = ErikaFlutterPlugin( flutterHostView: registrar.view, flutterHostViewController: registrar.viewController ) + instance.configureSystemPlayback() let playerChannel = FlutterMethodChannel( name: playerChannelName, binaryMessenger: registrar.messenger @@ -1926,6 +2020,12 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan let playerId = try requiredInt64(args["playerId"], name: "playerId") windowOverlayPlayerIds.remove(playerId) players.removeValue(forKey: playerId) + systemMediaNavigation.removeValue(forKey: playerId) + if activePlayerId == playerId { + activePlayerId = nil + clearNowPlayingInfo() + refreshRemoteCommands() + } result(nil) case "open": let args = try dictionaryArgs(call.arguments) @@ -1934,10 +2034,19 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan throw ErikaPluginError.invalidArguments("uri is required.") } let headers = (args["httpHeaders"] as? [String: String]) ?? [:] + if let metadata = args["metadata"] as? [String: Any] { + try applyMediaMetadata(metadata, to: host) + } else { + host.clearMediaMetadata() + } try host.open(uri: uri, httpHeaders: headers) result(nil) case "play": - try playerHost(from: try dictionaryArgs(call.arguments)).play() + let host = try playerHost(from: try dictionaryArgs(call.arguments)) + try host.play() + activePlayerId = host.id + refreshRemoteCommands() + publishNowPlayingInfo(for: host) result(nil) case "pause": try playerHost(from: try dictionaryArgs(call.arguments)).pause() @@ -1962,6 +2071,26 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan } try host.setPlaybackRate(rate) result(nil) + case "setMediaMetadata": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let metadata = args["metadata"] as? [String: Any] else { + throw ErikaPluginError.invalidArguments("metadata is required.") + } + try applyMediaMetadata(metadata, to: host) + result(nil) + case "setSystemMediaNavigation": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + systemMediaNavigation[host.id] = ( + previousEnabled: boolValue(args["previousEnabled"]) ?? false, + nextEnabled: boolValue(args["nextEnabled"]) ?? false + ) + if activePlayerId == host.id { + refreshRemoteCommands() + publishNowPlayingInfo(for: host) + } + result(nil) case "setVolume": let args = try dictionaryArgs(call.arguments) let host = try playerHost(from: args) @@ -2295,8 +2424,6 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan public func onCancel(withArguments arguments: Any?) -> FlutterError? { Self.sharedEventSink = nil - pollTimer?.invalidate() - pollTimer = nil return nil } @@ -2526,15 +2653,180 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan } let id = nextPlayerId nextPlayerId += 1 - players[id] = try ErikaPlayerHost( + let host = try ErikaPlayerHost( id: id, library: library, config: presenterConfigForNewPlayer(arguments: arguments) ) + host.onNowPlayingChanged = { [weak self] changedHost in + guard self?.activePlayerId == changedHost.id else { return } + self?.handleNowPlayingChanged(for: changedHost) + } + players[id] = host + systemMediaNavigation[id] = (previousEnabled: false, nextEnabled: false) startPollTimerIfNeeded() return id } + private func configureSystemPlayback() { + let commands = MPRemoteCommandCenter.shared() + addRemoteTarget(commands.playCommand) { [weak self] _ in + self?.performRemoteCommand { try $0.play() } ?? .commandFailed + } + addRemoteTarget(commands.pauseCommand) { [weak self] _ in + self?.performRemoteCommand { try $0.pause() } ?? .commandFailed + } + addRemoteTarget(commands.togglePlayPauseCommand) { [weak self] _ in + self?.performRemoteCommand { host in + if host.isPlaying { + try host.pause() + } else { + try host.play() + } + } ?? .commandFailed + } + addRemoteTarget(commands.stopCommand) { [weak self] _ in + self?.performRemoteCommand { try $0.stop() } ?? .commandFailed + } + addRemoteTarget(commands.changePlaybackPositionCommand) { [weak self] event in + guard let positionEvent = event as? MPChangePlaybackPositionCommandEvent else { + return .commandFailed + } + return self?.performRemoteSeek(positionEvent.positionTime) ?? .commandFailed + } + addRemoteTarget(commands.previousTrackCommand) { [weak self] _ in + self?.emitSystemMediaNavigation("previous") ?? .commandFailed + } + addRemoteTarget(commands.nextTrackCommand) { [weak self] _ in + self?.emitSystemMediaNavigation("next") ?? .commandFailed + } + refreshRemoteCommands() + } + + private func addRemoteTarget( + _ command: MPRemoteCommand, + handler: @escaping (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus + ) { + command.isEnabled = false + let target = command.addTarget(handler: handler) + remoteCommandTargets.append((command, target)) + } + + private func applyMediaMetadata(_ metadata: [String: Any], to host: ErikaPlayerHost) throws { + guard let title = metadata["title"] as? String, !title.isEmpty else { + throw ErikaPluginError.invalidArguments("metadata.title is required.") + } + try host.setMediaMetadata( + title: title, + artist: metadata["artist"] as? String, + album: metadata["album"] as? String, + artworkData: (metadata["artwork"] as? FlutterStandardTypedData)?.data + ) + } + + private func handleNowPlayingChanged(for host: ErikaPlayerHost) { + if host.playbackState == 6 { + activePlayerId = nil + clearNowPlayingInfo() + refreshRemoteCommands() + return + } + publishNowPlayingInfo(for: host) + } + + private func publishNowPlayingInfo( + for host: ErikaPlayerHost, + playbackState: MPNowPlayingPlaybackState? = nil + ) { + let resolvedPlaybackState = playbackState ?? + (host.isPlaying ? .playing : host.isStopped ? .stopped : .paused) + var info: [String: Any] = [ + MPMediaItemPropertyTitle: host.nowPlayingTitle, + MPNowPlayingInfoPropertyElapsedPlaybackTime: host.nowPlayingPositionSeconds, + MPNowPlayingInfoPropertyPlaybackRate: resolvedPlaybackState == .playing ? host.playbackRate : 0, + MPNowPlayingInfoPropertyDefaultPlaybackRate: host.playbackRate, + MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.video.rawValue, + ] + if let artist = host.nowPlayingArtist { info[MPMediaItemPropertyArtist] = artist } + if let album = host.nowPlayingAlbum { info[MPMediaItemPropertyAlbumTitle] = album } + if let artwork = host.nowPlayingArtwork { info[MPMediaItemPropertyArtwork] = artwork } + if let duration = host.durationSeconds { info[MPMediaItemPropertyPlaybackDuration] = duration } + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = info + center.playbackState = resolvedPlaybackState + } + + private func clearNowPlayingInfo() { + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = nil + center.playbackState = .stopped + } + + private func activePlayer() -> ErikaPlayerHost? { + activePlayerId.flatMap { players[$0] } + } + + private func performRemoteCommand( + _ command: @escaping (ErikaPlayerHost) throws -> Void + ) -> MPRemoteCommandHandlerStatus { + performOnMain { + guard let host = self.activePlayer() else { return .noSuchContent } + do { + try command(host) + return .success + } catch { + return .commandFailed + } + } + } + + private func performRemoteSeek(_ position: TimeInterval) -> MPRemoteCommandHandlerStatus { + guard position.isFinite else { return .commandFailed } + return performRemoteCommand { host in + let duration = host.durationSeconds ?? position + let boundedPosition = min(max(0, position), max(0, duration)) + try host.seek(positionMicros: UInt64(boundedPosition * 1_000_000)) + } + } + + private func emitSystemMediaNavigation(_ navigation: String) -> MPRemoteCommandHandlerStatus { + performOnMain { + guard let playerId = self.activePlayerId, + self.players[playerId] != nil, + let capabilities = self.systemMediaNavigation[playerId] else { return .noSuchContent } + let enabled = navigation == "previous" + ? capabilities.previousEnabled + : capabilities.nextEnabled + guard enabled else { return .noSuchContent } + Self.sharedEventSink?([ + "playerId": playerId, + "kind": 13, + "navigation": navigation, + ]) + return .success + } + } + + private func performOnMain( + _ work: @escaping () -> MPRemoteCommandHandlerStatus + ) -> MPRemoteCommandHandlerStatus { + if Thread.isMainThread { + return work() + } + return DispatchQueue.main.sync(execute: work) + } + + private func refreshRemoteCommands() { + let commands = MPRemoteCommandCenter.shared() + let enabled = activePlayer() != nil + remoteCommandTargets.forEach { command, _ in + command.isEnabled = enabled + } + let capabilities = activePlayerId.flatMap { systemMediaNavigation[$0] } + commands.previousTrackCommand.isEnabled = enabled && capabilities?.previousEnabled == true + commands.nextTrackCommand.isEnabled = enabled && capabilities?.nextEnabled == true + } + private func presenterConfigForNewPlayer(arguments: Any?) throws -> ErikaPresenterConfigC { if let args = arguments as? [String: Any], let explicitMode = int32Value(args["outputMode"]) { diff --git a/packages/erika_flutter/macos/erika_flutter.podspec b/packages/erika_flutter/macos/erika_flutter.podspec index 7517179..539fb84 100644 --- a/packages/erika_flutter/macos/erika_flutter.podspec +++ b/packages/erika_flutter/macos/erika_flutter.podspec @@ -12,7 +12,7 @@ Flutter macOS plugin that hosts a CAMetalLayer and drives Erika through its C AB s.source_files = 'Classes/**/*' s.dependency 'FlutterMacOS' s.platform = :osx, '10.14' - s.swift_version = '5.0' + s.swift_version = '5.7' s.script_phase = { :name => 'Build Erika C ABI', :execution_position => :before_compile, @@ -157,6 +157,6 @@ codesign --force --sign "${EXPANDED_CODE_SIGN_IDENTITY:--}" "$DEST_DYLIB" SCRIPT } s.pod_target_xcconfig = { - 'OTHER_LDFLAGS' => '$(inherited) -framework QuartzCore -framework Metal' + 'OTHER_LDFLAGS' => '$(inherited) -framework QuartzCore -framework Metal -framework MediaPlayer' } end diff --git a/packages/erika_flutter/ohos/src/main/cpp/CMakeLists.txt b/packages/erika_flutter/ohos/src/main/cpp/CMakeLists.txt index 42c0647..314244f 100644 --- a/packages/erika_flutter/ohos/src/main/cpp/CMakeLists.txt +++ b/packages/erika_flutter/ohos/src/main/cpp/CMakeLists.txt @@ -114,7 +114,7 @@ if(NOT ERIKA_USE_PREBUILT) COMMAND "${CMAKE_COMMAND}" -E env "OHOS_NDK_HOME=${OHOS_SDK_NATIVE}" - "${CARGO_EXECUTABLE}" +1.93.0 run -q -p xtask -- + "${CARGO_EXECUTABLE}" run -q -p xtask -- deps build --profile lgpl --target "${ERIKA_TARGET}" COMMAND "${CMAKE_COMMAND}" -E env @@ -127,7 +127,7 @@ if(NOT ERIKA_USE_PREBUILT) "CC_aarch64_unknown_linux_ohos=${OHOS_LLVM_BIN}/aarch64-unknown-linux-ohos-clang" "CXX_aarch64_unknown_linux_ohos=${OHOS_LLVM_BIN}/aarch64-unknown-linux-ohos-clang++" "AR_aarch64_unknown_linux_ohos=${OHOS_LLVM_BIN}/llvm-ar" - "${CARGO_EXECUTABLE}" +1.93.0 build -p erika_capi + "${CARGO_EXECUTABLE}" build -p erika_capi --target "${ERIKA_TARGET}" --release --no-default-features --features wgpu WORKING_DIRECTORY "${ERIKA_ROOT}" diff --git a/packages/erika_flutter/ohos/src/main/cpp/types/liberika_flutter/Index.d.ts b/packages/erika_flutter/ohos/src/main/cpp/types/liberika_flutter/Index.d.ts new file mode 100644 index 0000000..ddd85ff --- /dev/null +++ b/packages/erika_flutter/ohos/src/main/cpp/types/liberika_flutter/Index.d.ts @@ -0,0 +1,9 @@ +export const nativeCreate: (outputMode: number, headroom: number, upscaler: number) => number; +export const nativeLastError: () => string | null; +export const nativeDestroy: (playerId: number) => void; +export const nativeInvoke: (playerId: number, method: string, argumentsJson: string) => string; +export const nativeAttachSurface: (playerId: number, surfaceId: number, width: number, height: number, scale: number) => number; +export const nativeResizeSurface: (playerId: number, width: number, height: number, scale: number) => number; +export const nativeDetachSurface: (playerId: number) => number; +export const nativeRenderTick: (playerId: number, timeSeconds: number) => string; +export const nativePollEvent: (playerId: number) => string | null; diff --git a/packages/erika_flutter/ohos/src/main/cpp/types/liberika_flutter/oh-package.json5 b/packages/erika_flutter/ohos/src/main/cpp/types/liberika_flutter/oh-package.json5 new file mode 100644 index 0000000..61d8dcb --- /dev/null +++ b/packages/erika_flutter/ohos/src/main/cpp/types/liberika_flutter/oh-package.json5 @@ -0,0 +1,5 @@ +{ + "name": "liberika_flutter.so", + "version": "0.1.3", + "types": "./Index.d.ts" +} diff --git a/packages/erika_flutter/ohos/src/main/ets/components/plugin/ErikaFlutterPlugin.ets b/packages/erika_flutter/ohos/src/main/ets/components/plugin/ErikaFlutterPlugin.ets index 0e05da4..0784b6e 100644 --- a/packages/erika_flutter/ohos/src/main/ets/components/plugin/ErikaFlutterPlugin.ets +++ b/packages/erika_flutter/ohos/src/main/ets/components/plugin/ErikaFlutterPlugin.ets @@ -1,4 +1,6 @@ import { + AbilityAware, + AbilityPluginBinding, EventChannel, EventSink, FlutterPlugin, @@ -10,6 +12,9 @@ import { StandardMethodCodec, SurfaceTextureEntry, } from '@ohos/flutter_ohos'; +import { common, UIAbility } from '@kit.AbilityKit'; +import { avSession } from '@kit.AVSessionKit'; +import { image } from '@kit.ImageKit'; import erikaNative from 'liberika_flutter.so'; interface NativeResponse { @@ -28,17 +33,53 @@ interface ErikaTexture { playerId: number; } +interface ErikaMediaMetadata { + title: string; + artist?: string; + album?: string; + artwork?: Uint8Array; +} + +interface ErikaMediaState { + playbackState: number; + positionMicros: number; + durationMicros: number; + playbackRate: number; +} + +interface ErikaMediaNavigation { + previousEnabled: boolean; + nextEnabled: boolean; +} + const PLAYER_CHANNEL: string = 'erika_flutter/player'; const EVENT_CHANNEL: string = 'erika_flutter/events'; const FRAME_INTERVAL_MS: number = 16; +const STATE_CHANGED_EVENT_KIND: number = 1; +const DURATION_CHANGED_EVENT_KIND: number = 2; +const POSITION_CHANGED_EVENT_KIND: number = 3; +const SYSTEM_MEDIA_NAVIGATION_EVENT_KIND: number = 13; +const PLAYING_STATE: number = 3; +const PAUSED_STATE: number = 4; +const STOPPED_STATE: number = 5; +const CLOSED_STATE: number = 6; +const ERROR_STATE: number = 7; -export default class ErikaFlutterPlugin implements FlutterPlugin, MethodCallHandler { +export default class ErikaFlutterPlugin implements FlutterPlugin, MethodCallHandler, AbilityAware { private binding: FlutterPluginBinding | null = null; + private ability: UIAbility | null = null; private methodChannel: MethodChannel | null = null; private eventChannel: EventChannel | null = null; private eventSink: EventSink | null = null; private players: Set = new Set(); private textures: Map = new Map(); + private mediaMetadata: Map = new Map(); + private mediaStates: Map = new Map(); + private mediaNavigation: Map = new Map(); + private currentPlayerId: number = 0; + private currentSession: avSession.AVSession | null = null; + private sessionPromise: Promise | null = null; + private sessionGeneration: number = 0; private frameTimer: number = -1; getUniqueClassName(): string { @@ -84,6 +125,11 @@ export default class ErikaFlutterPlugin implements FlutterPlugin, MethodCallHand erikaNative.nativeDestroy(playerId); }); this.players.clear(); + this.mediaMetadata.clear(); + this.mediaStates.clear(); + this.mediaNavigation.clear(); + this.currentPlayerId = 0; + this.destroyAVSession(); this.methodChannel?.setMethodCallHandler(null); this.eventChannel?.setStreamHandler(null); this.methodChannel = null; @@ -92,6 +138,15 @@ export default class ErikaFlutterPlugin implements FlutterPlugin, MethodCallHand this.binding = null; } + onAttachedToAbility(binding: AbilityPluginBinding): void { + this.ability = binding.getAbility(); + } + + onDetachedFromAbility(): void { + this.ability = null; + this.destroyAVSession(); + } + onMethodCall(call: MethodCall, result: MethodResult): void { const args: Map = call.args as Map; if (call.method === 'create') { @@ -126,6 +181,18 @@ export default class ErikaFlutterPlugin implements FlutterPlugin, MethodCallHand this.captureFrame(args, result); return; } + if (call.method === 'setMediaMetadata') { + this.setMediaMetadata(args, result); + return; + } + if (call.method === 'setSystemMediaNavigation') { + this.setSystemMediaNavigation(args, result); + return; + } + if (call.method === 'open') { + this.openPlayer(args, result); + return; + } if (call.method === 'registerSubtitleMemoryFont') { this.registerSubtitleMemoryFont(args, result); return; @@ -156,6 +223,16 @@ export default class ErikaFlutterPlugin implements FlutterPlugin, MethodCallHand return; } this.players.add(playerId); + this.mediaStates.set(playerId, { + playbackState: 0, + positionMicros: 0, + durationMicros: 0, + playbackRate: 1.0, + }); + this.mediaNavigation.set(playerId, { + previousEnabled: false, + nextEnabled: false, + }); result.success(playerId); } @@ -170,7 +247,90 @@ export default class ErikaFlutterPlugin implements FlutterPlugin, MethodCallHand if (this.players.delete(playerId)) { erikaNative.nativeDestroy(playerId); } + this.mediaMetadata.delete(playerId); + this.mediaStates.delete(playerId); + this.mediaNavigation.delete(playerId); + if (this.currentPlayerId === playerId) { + this.currentPlayerId = 0; + this.deactivateAVSession(); + } + result.success(null); + } + + private openPlayer(args: Map, result: MethodResult): void { + const metadataValue = args.get('metadata'); + let metadata: ErikaMediaMetadata | null = null; + if (metadataValue !== undefined && metadataValue !== null) { + metadata = this.parseMediaMetadata(metadataValue); + if (metadata === null) { + result.error('INVALID_ARGUMENT', 'metadata.title is required', null); + return; + } + } + const playerId = this.numberArg(args, 'playerId', 0); + const nativeArgs = new Map(args); + nativeArgs.delete('metadata'); + if (!this.invokePlayerNative('open', nativeArgs, result)) { + return; + } + const state = this.mediaStates.get(playerId); + if (state !== undefined) { + state.playbackState = 0; + state.positionMicros = 0; + state.durationMicros = 0; + } + if (metadata !== null) { + this.mediaMetadata.set(playerId, metadata); + if (this.currentPlayerId === playerId) { + this.publishMediaMetadata(playerId, metadata); + } + } else { + this.mediaMetadata.delete(playerId); + if (this.currentPlayerId === playerId) { + this.destroyAVSession(); + } + } + if (this.currentPlayerId === playerId) { + this.publishPlaybackState(playerId); + } + } + + private setMediaMetadata(args: Map, result: MethodResult): void { + const playerId = this.numberArg(args, 'playerId', 0); + if (!this.players.has(playerId)) { + result.error('ERIKA_ERROR', 'Unknown Erika player ' + playerId, null); + return; + } + const metadata = this.parseMediaMetadata(args.get('metadata')); + if (metadata === null) { + result.error('INVALID_ARGUMENT', 'metadata.title is required', null); + return; + } + this.mediaMetadata.set(playerId, metadata); + result.success(null); + if (this.currentPlayerId === playerId) { + this.publishMediaMetadata(playerId, metadata); + } + } + + private setSystemMediaNavigation( + args: Map, + result: MethodResult, + ): void { + const playerId = this.numberArg(args, 'playerId', 0); + if (!this.players.has(playerId)) { + result.error('ERIKA_ERROR', 'Unknown Erika player ' + playerId, null); + return; + } + this.mediaNavigation.set(playerId, { + previousEnabled: args.get('previousEnabled') === true, + nextEnabled: args.get('nextEnabled') === true, + }); result.success(null); + const metadata = this.mediaMetadata.get(playerId); + if (metadata !== undefined && this.currentPlayerId === playerId) { + this.publishMediaMetadata(playerId, metadata); + } } private createTexture(args: Map, result: MethodResult): void { @@ -320,10 +480,35 @@ export default class ErikaFlutterPlugin implements FlutterPlugin, MethodCallHand args: Map, result: MethodResult, ): void { + if (!this.invokePlayerNative(method, args, result)) { + return; + } + const playerId = this.numberArg(args, 'playerId', 0); + if (method === 'play') { + this.currentPlayerId = playerId; + const metadata = this.mediaMetadata.get(playerId); + if (metadata !== undefined) { + this.publishMediaMetadata(playerId, metadata); + } + this.publishPlaybackState(playerId); + } else if (method === 'setPlaybackRate') { + const state = this.mediaStates.get(playerId); + if (state !== undefined) { + state.playbackRate = this.numberArg(args, 'rate', 1.0); + } + this.publishPlaybackState(playerId); + } + } + + private invokePlayerNative( + method: string, + args: Map, + result: MethodResult, + ): boolean { const playerId = this.numberArg(args, 'playerId', 0); if (!this.players.has(playerId)) { result.error('ERIKA_ERROR', 'Unknown Erika player ' + playerId, null); - return; + return false; } const argumentsJson = JSON.stringify(this.mapToObject(args)); const raw = erikaNative.nativeInvoke(playerId, method, argumentsJson) as string; @@ -334,10 +519,239 @@ export default class ErikaFlutterPlugin implements FlutterPlugin, MethodCallHand response.error ?? ('Erika native method failed with status ' + response.status), response, ); - return; + return false; } result.success(response.value ?? null); this.drainEvents(playerId); + return true; + } + + private parseMediaMetadata(value: ESObject): ErikaMediaMetadata | null { + if (!(value instanceof Map)) { + return null; + } + const metadata = value as Map; + const titleValue = metadata.get('title'); + if (typeof titleValue !== 'string' || titleValue.length === 0) { + return null; + } + const parsed: ErikaMediaMetadata = { title: titleValue }; + const artist = metadata.get('artist'); + const album = metadata.get('album'); + const artwork = metadata.get('artwork'); + if (typeof artist === 'string') { + parsed.artist = artist; + } + if (typeof album === 'string') { + parsed.album = album; + } + if (artwork instanceof Uint8Array) { + parsed.artwork = artwork; + } + return parsed; + } + + private async ensureAVSession(): Promise { + if (this.currentSession !== null) { + return this.currentSession; + } + if (this.sessionPromise !== null) { + return this.sessionPromise; + } + const context = this.ability?.context as common.UIAbilityContext | undefined; + if (context === undefined) { + return null; + } + const generation = this.sessionGeneration; + const promise = this.createAVSession(context, generation); + this.sessionPromise = promise; + const session = await promise; + if (this.sessionPromise === promise) { + this.sessionPromise = null; + } + return session; + } + + private async createAVSession( + context: common.UIAbilityContext, + generation: number, + ): Promise { + let session: avSession.AVSession | null = null; + try { + session = await avSession.createAVSession(context, 'ErikaVideoPlayer', 'video'); + if (generation !== this.sessionGeneration || this.ability?.context !== context) { + await session.destroy().catch((): void => {}); + return null; + } + this.registerAVSessionCommands(session); + await session.activate(); + if (generation !== this.sessionGeneration || this.ability?.context !== context) { + await session.destroy().catch((): void => {}); + return null; + } + this.currentSession = session; + return session; + } catch (_) { + await session?.destroy().catch((): void => {}); + return null; + } + } + + private registerAVSessionCommands(session: avSession.AVSession): void { + session.on('play', (): void => this.invokeFromAVSession('play')); + session.on('pause', (): void => this.invokeFromAVSession('pause')); + session.on('stop', (): void => this.invokeFromAVSession('stop')); + session.on('seek', (time: number): void => { + this.invokeFromAVSession('seek', new Map([ + ['positionMicros', time * 1000], + ])); + }); + session.on('playPrevious', (): void => this.emitSystemMediaNavigation('previous')); + session.on('playNext', (): void => this.emitSystemMediaNavigation('next')); + } + + private emitSystemMediaNavigation(navigation: string): void { + const playerId = this.currentPlayerId; + const capabilities = this.mediaNavigation.get(playerId); + const enabled = navigation === 'previous' + ? capabilities?.previousEnabled === true + : capabilities?.nextEnabled === true; + if (!this.players.has(playerId) || !enabled) { + return; + } + this.eventSink?.success({ + playerId: playerId, + kind: SYSTEM_MEDIA_NAVIGATION_EVENT_KIND, + navigation: navigation, + }); + } + + private invokeFromAVSession(method: string, extra?: Map): void { + const playerId = this.currentPlayerId; + if (!this.players.has(playerId)) { + return; + } + const args = extra ?? new Map(); + args.set('playerId', playerId); + const raw = erikaNative.nativeInvoke( + playerId, + method, + JSON.stringify(this.mapToObject(args)), + ) as string; + const response = JSON.parse(raw) as NativeResponse; + if (response.ok) { + this.drainEvents(playerId); + } + } + + private async publishMediaMetadata( + playerId: number, + metadata: ErikaMediaMetadata, + ): Promise { + const session = await this.ensureAVSession(); + if (session === null || this.currentPlayerId !== playerId || + this.mediaMetadata.get(playerId) !== metadata) { + return; + } + const avMetadata: avSession.AVMetadata = { + assetId: 'erika-' + playerId, + title: metadata.title, + }; + const capabilities = this.mediaNavigation.get(playerId); + if (capabilities?.previousEnabled === true) { + avMetadata.previousAssetId = 'erika-previous-' + playerId; + } + if (capabilities?.nextEnabled === true) { + avMetadata.nextAssetId = 'erika-next-' + playerId; + } + if (metadata.artist !== undefined) { + avMetadata.artist = metadata.artist; + } + if (metadata.album !== undefined) { + avMetadata.album = metadata.album; + } + let mediaImage: image.PixelMap | null = null; + if (metadata.artwork !== undefined) { + let source: image.ImageSource | null = null; + try { + const bytes = metadata.artwork; + const artwork = new Uint8Array(bytes.byteLength); + artwork.set(bytes); + source = image.createImageSource(artwork.buffer as ArrayBuffer); + mediaImage = await source.createPixelMap(); + avMetadata.mediaImage = mediaImage; + } catch (_) { + } finally { + await source?.release().catch((): void => {}); + } + } + const state = this.mediaStates.get(playerId); + if (state !== undefined && state.durationMicros > 0) { + avMetadata.duration = state.durationMicros / 1000.0; + } + if (this.currentPlayerId !== playerId || this.mediaMetadata.get(playerId) !== metadata) { + await mediaImage?.release().catch((): void => {}); + return; + } + await session.setAVMetadata(avMetadata).catch((): void => {}); + await mediaImage?.release().catch((): void => {}); + } + + private async publishPlaybackState(playerId: number): Promise { + const mediaState = this.mediaStates.get(playerId); + if (mediaState === undefined || this.currentPlayerId !== playerId) { + return; + } + const session = await this.ensureAVSession(); + if (session === null || this.currentPlayerId !== playerId) { + return; + } + await session.activate().catch((): void => {}); + await session.setAVPlaybackState({ + state: this.avPlaybackState(mediaState.playbackState), + speed: mediaState.playbackState === PLAYING_STATE ? mediaState.playbackRate : 0, + position: { + elapsedTime: mediaState.positionMicros / 1000.0, + updateTime: Date.now(), + }, + duration: mediaState.durationMicros / 1000.0, + }).catch((): void => {}); + if (mediaState.playbackState === CLOSED_STATE || mediaState.playbackState === ERROR_STATE) { + this.deactivateAVSession(); + } + } + + private avPlaybackState(state: number): avSession.PlaybackState { + if (state === PLAYING_STATE) { + return avSession.PlaybackState.PLAYBACK_STATE_PLAY; + } + if (state === PAUSED_STATE) { + return avSession.PlaybackState.PLAYBACK_STATE_PAUSE; + } + if (state === STOPPED_STATE) { + return avSession.PlaybackState.PLAYBACK_STATE_STOP; + } + if (state === CLOSED_STATE) { + return avSession.PlaybackState.PLAYBACK_STATE_RELEASED; + } + if (state === ERROR_STATE) { + return avSession.PlaybackState.PLAYBACK_STATE_ERROR; + } + return avSession.PlaybackState.PLAYBACK_STATE_PREPARE; + } + + private deactivateAVSession(): void { + this.sessionGeneration += 1; + this.sessionPromise = null; + this.currentSession?.deactivate().catch((): void => {}); + } + + private destroyAVSession(): void { + const session = this.currentSession; + this.sessionGeneration += 1; + this.currentSession = null; + this.sessionPromise = null; + session?.destroy().catch((): void => {}); } private registerSubtitleMemoryFont( @@ -401,10 +815,44 @@ export default class ErikaFlutterPlugin implements FlutterPlugin, MethodCallHand } const event: ESObject = response.value; event.playerId = playerId; + this.observeMediaEvent(playerId, event); sink.success(event); } } + private observeMediaEvent(playerId: number, event: ESObject): void { + const mediaState = this.mediaStates.get(playerId); + if (mediaState === undefined) { + return; + } + const kind = typeof event.kind === 'number' ? event.kind as number : 0; + if (kind === STATE_CHANGED_EVENT_KIND) { + mediaState.playbackState = typeof event.state === 'number' + ? event.state as number + : mediaState.playbackState; + } + if (kind === POSITION_CHANGED_EVENT_KIND && typeof event.positionMicros === 'number') { + mediaState.positionMicros = Math.max(0, event.positionMicros as number); + } + if ((kind === DURATION_CHANGED_EVENT_KIND || kind === STATE_CHANGED_EVENT_KIND) && + typeof event.durationMicros === 'number') { + mediaState.durationMicros = Math.max(0, event.durationMicros as number); + } + if (this.currentPlayerId !== playerId) { + return; + } + if (kind === DURATION_CHANGED_EVENT_KIND) { + const metadata = this.mediaMetadata.get(playerId); + if (metadata !== undefined) { + this.publishMediaMetadata(playerId, metadata); + } + } + if (kind === STATE_CHANGED_EVENT_KIND || kind === DURATION_CHANGED_EVENT_KIND || + kind === POSITION_CHANGED_EVENT_KIND) { + this.publishPlaybackState(playerId); + } + } + private completeStatus(status: number, result: MethodResult, operation: string): boolean { if (status === 0) { return true; diff --git a/packages/erika_flutter/test/apple_memory_font_contract_test.dart b/packages/erika_flutter/test/apple_memory_font_contract_test.dart index f8e1032..92286ec 100644 --- a/packages/erika_flutter/test/apple_memory_font_contract_test.dart +++ b/packages/erika_flutter/test/apple_memory_font_contract_test.dart @@ -34,6 +34,22 @@ void main() { }); } + test('tvOS exposes Now Playing metadata and remote commands', () { + final plugin = File( + 'tvos/Classes/ErikaFlutterPlugin.swift', + ).readAsStringSync(); + final podspec = File( + 'tvos/erika_flutter.podspec', + ).readAsStringSync(); + + expect(plugin, contains('import MediaPlayer')); + expect(plugin, contains('MPNowPlayingInfoCenter.default()')); + expect(plugin, contains('MPRemoteCommandCenter.shared()')); + expect(plugin, contains('case "setMediaMetadata"')); + expect(plugin, contains('case "setSystemMediaNavigation"')); + expect(podspec, contains('-framework MediaPlayer')); + }); + test('release builds complete Apple XCFrameworks on macOS 26', () { final workflow = File( '../../.github/workflows/release.yml', diff --git a/packages/erika_flutter/test/erika_player_test.dart b/packages/erika_flutter/test/erika_player_test.dart index 02df049..d77f3b0 100644 --- a/packages/erika_flutter/test/erika_player_test.dart +++ b/packages/erika_flutter/test/erika_player_test.dart @@ -60,6 +60,21 @@ void main() { await player.dispose(); }); + test('background playback is opt-in at player creation', () async { + final player = ErikaPlayer(allowBackgroundPlayback: true); + + expect(await player.ensureCreated(), 7); + + final createCall = playerCalls.singleWhere( + (MethodCall call) => call.method == 'create', + ); + expect(createCall.arguments, { + 'allowBackgroundPlayback': true, + }); + + await player.dispose(); + }); + test('open forwards HTTP headers without exposing them elsewhere', () async { final player = ErikaPlayer(); @@ -73,6 +88,7 @@ void main() { 'playerId': 7, 'uri': 'https://example.test/video.mkv', 'httpHeaders': {'Authorization': 'Bearer secret'}, + 'metadata': null, }); await player.dispose(); }); @@ -149,10 +165,12 @@ void main() { expect(openCalls[0].arguments, { 'playerId': 7, 'uri': 'https://example.test/null.mkv', + 'metadata': null, }); expect(openCalls[1].arguments, { 'playerId': 7, 'uri': 'https://example.test/empty.mkv', + 'metadata': null, }); await player.dispose(); @@ -181,6 +199,69 @@ void main() { 'X-Empty': '', 'X-Request-ID': 'request-123', }, + 'metadata': null, + }); + + await player.dispose(); + }); + + test('media metadata is forwarded for system now playing info', () async { + final player = ErikaPlayer(); + final artwork = Uint8List.fromList([1, 2, 3, 4]); + final metadata = ErikaMediaMetadata( + title: 'Episode 1', + artist: 'Erika', + album: 'Season 1', + artwork: artwork, + ); + + await player.open('https://example.test/video.mkv', metadata: metadata); + await player.setMediaMetadata(metadata); + + final openCall = playerCalls.singleWhere( + (MethodCall call) => call.method == 'open', + ); + expect(openCall.arguments, { + 'playerId': 7, + 'uri': 'https://example.test/video.mkv', + 'metadata': { + 'title': 'Episode 1', + 'artist': 'Erika', + 'album': 'Season 1', + 'artwork': artwork, + }, + }); + final metadataCall = playerCalls.singleWhere( + (MethodCall call) => call.method == 'setMediaMetadata', + ); + expect(metadataCall.arguments, { + 'playerId': 7, + 'metadata': { + 'title': 'Episode 1', + 'artist': 'Erika', + 'album': 'Season 1', + 'artwork': artwork, + }, + }); + + await player.dispose(); + }); + + test('system media navigation capabilities are forwarded', () async { + final player = ErikaPlayer(); + + await player.setSystemMediaNavigation( + previousEnabled: true, + nextEnabled: false, + ); + + final call = playerCalls.singleWhere( + (MethodCall call) => call.method == 'setSystemMediaNavigation', + ); + expect(call.arguments, { + 'playerId': 7, + 'previousEnabled': true, + 'nextEnabled': false, }); await player.dispose(); @@ -1540,4 +1621,30 @@ void main() { expect(event.message, contains('audio_output_changed')); }, ); + + test('player event parses kind 13 system media navigation request', () { + expect(ErikaEventKind.systemMediaNavigationRequested.index, 13); + final nextEvent = ErikaPlayerEvent.fromMap({ + 'playerId': 7, + 'kind': 13, + 'navigation': 'next', + }); + final previousEvent = ErikaPlayerEvent.fromMap({ + 'playerId': 7, + 'kind': 13.0, + 'navigation': 'previous', + }); + final unknownEvent = ErikaPlayerEvent.fromMap({ + 'playerId': 7, + 'kind': 13, + 'navigation': 'later', + }); + + expect(nextEvent.kind, ErikaEventKind.systemMediaNavigationRequested); + expect(nextEvent.systemMediaCommand, ErikaSystemMediaCommand.next); + expect(previousEvent.kind, ErikaEventKind.systemMediaNavigationRequested); + expect(previousEvent.systemMediaCommand, ErikaSystemMediaCommand.previous); + expect(unknownEvent.kind, ErikaEventKind.systemMediaNavigationRequested); + expect(unknownEvent.systemMediaCommand, isNull); + }); } diff --git a/packages/erika_flutter/tvos/Classes/ErikaFlutterPlugin.swift b/packages/erika_flutter/tvos/Classes/ErikaFlutterPlugin.swift index 926e0d7..d33c4a5 100644 --- a/packages/erika_flutter/tvos/Classes/ErikaFlutterPlugin.swift +++ b/packages/erika_flutter/tvos/Classes/ErikaFlutterPlugin.swift @@ -1,6 +1,7 @@ import Darwin import AVFoundation import Flutter +import MediaPlayer import Metal import ObjectiveC.runtime import QuartzCore @@ -650,6 +651,15 @@ private final class ErikaPlayerHost { private let presenterConfig: ErikaPresenterConfigC private var loggedFirstRenderedVideoFrame = false private var latestPresenterStats = ErikaPresenterStatsC() + private(set) var nowPlayingTitle = "" + private(set) var nowPlayingArtist: String? + private(set) var nowPlayingAlbum: String? + private(set) var nowPlayingArtwork: MPMediaItemArtwork? + private(set) var durationSeconds: Double? + private(set) var positionSeconds = 0.0 + private(set) var playbackRate = 1.0 + private(set) var isPlaying = false + var onNowPlayingChanged: ((ErikaPlayerHost) -> Void)? init(id: Int64, library: ErikaNativeLibrary, config: ErikaPresenterConfigC, hdrDebug: Bool) throws { self.id = id @@ -673,6 +683,14 @@ private final class ErikaPlayerHost { } func open(uri: String, httpHeaders: [String: String]) throws { + isPlaying = false + positionSeconds = 0 + durationSeconds = nil + if nowPlayingTitle.isEmpty { + let fallbackTitle = URL(string: uri)?.lastPathComponent.removingPercentEncoding + ?? URL(fileURLWithPath: uri).lastPathComponent + nowPlayingTitle = fallbackTitle.isEmpty ? "Erika" : fallbackTitle + } try uri.withCString { cString in guard !httpHeaders.isEmpty else { try check(library.open(handle, cString), operation: "open") @@ -694,18 +712,38 @@ private final class ErikaPlayerHost { try check(openWithHeaders(handle, cString, buffer.baseAddress.map(UnsafeRawPointer.init), UInt(headers.count)), operation: "open") } } + notifyNowPlayingChanged() } func play() throws { try configureAudioSessionForPlayback() try check(library.play(handle), operation: "play") + isPlaying = true + notifyNowPlayingChanged() + } + func pause() throws { + try check(library.pause(handle), operation: "pause") + isPlaying = false + notifyNowPlayingChanged() + } + func stop() throws { + try check(library.stop(handle), operation: "stop") + isPlaying = false + positionSeconds = 0 + notifyNowPlayingChanged() + } + func close() throws { + try check(library.close(handle), operation: "close") + isPlaying = false + positionSeconds = 0 + durationSeconds = nil + notifyNowPlayingChanged() } - func pause() throws { try check(library.pause(handle), operation: "pause") } - func stop() throws { try check(library.stop(handle), operation: "stop") } - func close() throws { try check(library.close(handle), operation: "close") } func seek(positionMicros: UInt64) throws { try check(library.seek(handle, positionMicros), operation: "seek") + positionSeconds = Double(positionMicros) / 1_000_000 + notifyNowPlayingChanged() } func setPlaybackRate(_ rate: Double) throws { @@ -713,6 +751,33 @@ private final class ErikaPlayerHost { throw ErikaPluginError.symbolMissing("erika_presenter_set_playback_rate") } try check(setRate(handle, rate), operation: "set_playback_rate") + playbackRate = rate + notifyNowPlayingChanged() + } + + func setMediaMetadata(title: String, artist: String?, album: String?, artworkData: Data?) throws { + let artwork: MPMediaItemArtwork? + if let artworkData { + guard let image = UIImage(data: artworkData) else { + throw ErikaPluginError.invalidArguments("metadata.artwork must contain a supported image.") + } + artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + } else { + artwork = nil + } + nowPlayingTitle = title + nowPlayingArtist = artist + nowPlayingAlbum = album + nowPlayingArtwork = artwork + notifyNowPlayingChanged() + } + + func clearMediaMetadata() { + nowPlayingTitle = "" + nowPlayingArtist = nil + nowPlayingAlbum = nil + nowPlayingArtwork = nil + notifyNowPlayingChanged() } func setVolume(_ volume: Double) throws { @@ -1188,13 +1253,24 @@ private final class ErikaPlayerHost { } func pollEvents(sendEvent: (([String: Any]) -> Void)?) { - guard let sendEvent else { return } while true { var event = ErikaEventC() let status = withUnsafeMutablePointer(to: &event) { pointer in library.pollEvent(handle, UnsafeMutableRawPointer(pointer)) } if status == 0 { + if event.durationMicros >= 0 { + durationSeconds = Double(event.durationMicros) / 1_000_000 + } + if event.kind == 3 { + positionSeconds = Double(event.positionMicros) / 1_000_000 + } + if event.kind == 1 { + isPlaying = event.state == 3 + } + if event.kind == 1 || event.kind == 2 || event.kind == 3 { + notifyNowPlayingChanged() + } if event.kind == 6 { erikaHdrLog( hdrDebug, @@ -1204,7 +1280,7 @@ private final class ErikaPlayerHost { let message = event.kind == 9 || event.kind == 11 || event.kind == 12 ? library.currentEventMessage() : nil - sendEvent(event.toFlutterMap(playerId: id, host: self, structuredMessage: message)) + sendEvent?(event.toFlutterMap(playerId: id, host: self, structuredMessage: message)) continue } if status != 5 { @@ -1249,6 +1325,10 @@ private final class ErikaPlayerHost { displayLink = link } + private func notifyNowPlayingChanged() { + onNowPlayingChanged?(self) + } + private func resolvedDisplayLinkFps() -> Int { if let override = ProcessInfo.processInfo.environment["ERIKA_FLUTTER_TARGET_FPS"], let fps = Int(override), fps > 0 { @@ -1610,9 +1690,19 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan private var views: [Int64: WeakErikaVideoPlatformViewBox] = [:] private var nextPlayerId: Int64 = 1 private var pollTimer: Timer? + private var activePlayerId: Int64? + private var remoteCommandTargets: [(MPRemoteCommand, Any)] = [] + private var systemMediaNavigation: [Int64: (previousEnabled: Bool, nextEnabled: Bool)] = [:] + + deinit { + remoteCommandTargets.forEach { command, target in + command.removeTarget(target) + } + } public static func register(with registrar: FlutterPluginRegistrar) { let instance = ErikaFlutterPlugin() + instance.configureSystemPlayback() let playerChannel = FlutterMethodChannel(name: playerChannelName, binaryMessenger: registrar.messenger()) let eventsChannel = FlutterEventChannel(name: eventsChannelName, binaryMessenger: registrar.messenger()) registrar.addMethodCallDelegate(instance, channel: playerChannel) @@ -1629,6 +1719,12 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan let args = try dictionaryArgs(call.arguments) let playerId = try requiredInt64(args["playerId"], name: "playerId") players.removeValue(forKey: playerId) + systemMediaNavigation.removeValue(forKey: playerId) + if activePlayerId == playerId { + activePlayerId = nil + clearNowPlayingInfo() + refreshRemoteCommands() + } result(nil) case "open": let args = try dictionaryArgs(call.arguments) @@ -1637,10 +1733,19 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan throw ErikaPluginError.invalidArguments("uri is required.") } let headers = (args["httpHeaders"] as? [String: String]) ?? [:] + if let metadata = args["metadata"] as? [String: Any] { + try applyMediaMetadata(metadata, to: host) + } else { + host.clearMediaMetadata() + } try host.open(uri: uri, httpHeaders: headers) result(nil) case "play": - try playerHost(from: try dictionaryArgs(call.arguments)).play() + let host = try playerHost(from: try dictionaryArgs(call.arguments)) + try host.play() + activePlayerId = host.id + refreshRemoteCommands() + updateNowPlayingInfo(for: host) result(nil) case "pause": try playerHost(from: try dictionaryArgs(call.arguments)).pause() @@ -1662,6 +1767,25 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan } try playerHost(from: args).setPlaybackRate(rate) result(nil) + case "setMediaMetadata": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let metadata = args["metadata"] as? [String: Any] else { + throw ErikaPluginError.invalidArguments("metadata is required.") + } + try applyMediaMetadata(metadata, to: host) + result(nil) + case "setSystemMediaNavigation": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + systemMediaNavigation[host.id] = ( + previousEnabled: boolValue(args["previousEnabled"]) ?? false, + nextEnabled: boolValue(args["nextEnabled"]) ?? false + ) + if activePlayerId == host.id { + refreshRemoteCommands() + } + result(nil) case "setVolume": let args = try dictionaryArgs(call.arguments) guard let volume = doubleValue(args["volume"]) else { @@ -2132,11 +2256,161 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan let config = presenterConfigForNewPlayer(arguments: arguments, hdrDebug: hdrDebug) let id = nextPlayerId nextPlayerId += 1 - players[id] = try ErikaPlayerHost(id: id, library: library, config: config, hdrDebug: hdrDebug) + let host = try ErikaPlayerHost(id: id, library: library, config: config, hdrDebug: hdrDebug) + host.onNowPlayingChanged = { [weak self] changedHost in + guard self?.activePlayerId == changedHost.id else { return } + self?.updateNowPlayingInfo(for: changedHost) + } + players[id] = host + systemMediaNavigation[id] = (previousEnabled: false, nextEnabled: false) startPollTimerIfNeeded() return id } + private func configureSystemPlayback() { + let commands = MPRemoteCommandCenter.shared() + addRemoteTarget(commands.playCommand) { [weak self] _ in + self?.performRemotePlay() ?? .commandFailed + } + addRemoteTarget(commands.pauseCommand) { [weak self] _ in + self?.performRemotePause() ?? .commandFailed + } + addRemoteTarget(commands.togglePlayPauseCommand) { [weak self] _ in + self?.performRemoteToggle() ?? .commandFailed + } + addRemoteTarget(commands.changePlaybackPositionCommand) { [weak self] event in + guard let positionEvent = event as? MPChangePlaybackPositionCommandEvent else { + return .commandFailed + } + return self?.performRemoteSeek(positionEvent.positionTime) ?? .commandFailed + } + addRemoteTarget(commands.previousTrackCommand) { [weak self] _ in + self?.emitSystemMediaNavigation("previous") ?? .commandFailed + } + addRemoteTarget(commands.nextTrackCommand) { [weak self] _ in + self?.emitSystemMediaNavigation("next") ?? .commandFailed + } + refreshRemoteCommands() + } + + private func addRemoteTarget( + _ command: MPRemoteCommand, + handler: @escaping (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus + ) { + let target = command.addTarget(handler: handler) + remoteCommandTargets.append((command, target)) + } + + private func applyMediaMetadata(_ metadata: [String: Any], to host: ErikaPlayerHost) throws { + guard let title = metadata["title"] as? String, !title.isEmpty else { + throw ErikaPluginError.invalidArguments("metadata.title is required.") + } + try host.setMediaMetadata( + title: title, + artist: metadata["artist"] as? String, + album: metadata["album"] as? String, + artworkData: (metadata["artwork"] as? FlutterStandardTypedData)?.data + ) + } + + private func updateNowPlayingInfo(for host: ErikaPlayerHost) { + var info: [String: Any] = [ + MPMediaItemPropertyTitle: host.nowPlayingTitle, + MPNowPlayingInfoPropertyElapsedPlaybackTime: host.positionSeconds, + MPNowPlayingInfoPropertyPlaybackRate: host.isPlaying ? host.playbackRate : 0, + MPNowPlayingInfoPropertyDefaultPlaybackRate: host.playbackRate, + MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.video.rawValue, + ] + if let artist = host.nowPlayingArtist { info[MPMediaItemPropertyArtist] = artist } + if let album = host.nowPlayingAlbum { info[MPMediaItemPropertyAlbumTitle] = album } + if let artwork = host.nowPlayingArtwork { info[MPMediaItemPropertyArtwork] = artwork } + if let duration = host.durationSeconds { info[MPMediaItemPropertyPlaybackDuration] = duration } + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = info + center.playbackState = host.isPlaying ? .playing : .paused + } + + private func clearNowPlayingInfo() { + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = nil + center.playbackState = .stopped + } + + private func performRemotePlay() -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + do { + try host.play() + return .success + } catch { + return .commandFailed + } + } + + private func performRemotePause() -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + do { + try host.pause() + return .success + } catch { + return .commandFailed + } + } + + private func performRemoteToggle() -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + return host.isPlaying ? performRemotePause() : performRemotePlay() + } + + private func performRemoteSeek(_ position: TimeInterval) -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + do { + try host.seek(positionMicros: UInt64(max(0, position) * 1_000_000)) + return .success + } catch { + return .commandFailed + } + } + + private func emitSystemMediaNavigation(_ navigation: String) -> MPRemoteCommandHandlerStatus { + performOnMain { + guard let playerId = self.activePlayerId, + self.players[playerId] != nil, + let capabilities = self.systemMediaNavigation[playerId] else { + return .noSuchContent + } + let enabled = navigation == "previous" + ? capabilities.previousEnabled + : capabilities.nextEnabled + guard enabled else { return .noSuchContent } + Self.sharedEventSink?([ + "playerId": playerId, + "kind": 13, + "navigation": navigation, + ]) + return .success + } + } + + private func performOnMain( + _ work: @escaping () -> MPRemoteCommandHandlerStatus + ) -> MPRemoteCommandHandlerStatus { + if Thread.isMainThread { + return work() + } + return DispatchQueue.main.sync(execute: work) + } + + private func refreshRemoteCommands() { + let commands = MPRemoteCommandCenter.shared() + let enabled = activePlayerId.flatMap { players[$0] } != nil + remoteCommandTargets.forEach { command, _ in + command.isEnabled = enabled + } + let capabilities = activePlayerId.flatMap { systemMediaNavigation[$0] } + commands.previousTrackCommand.isEnabled = enabled && capabilities?.previousEnabled == true + commands.nextTrackCommand.isEnabled = enabled && capabilities?.nextEnabled == true + } + private func presenterConfigForNewPlayer(arguments: Any?, hdrDebug: Bool) -> ErikaPresenterConfigC { if let args = arguments as? [String: Any], let explicitMode = int32Value(args["outputMode"]) { let headroom = floatValue(args["edrHeadroom"]) ?? 4.0 diff --git a/packages/erika_flutter/tvos/erika_flutter.podspec b/packages/erika_flutter/tvos/erika_flutter.podspec index f25b0f0..2efeca4 100644 --- a/packages/erika_flutter/tvos/erika_flutter.podspec +++ b/packages/erika_flutter/tvos/erika_flutter.podspec @@ -214,6 +214,6 @@ fi s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=appletvsimulator*]' => 'i386', - 'OTHER_LDFLAGS' => "$(inherited) \"$(PODS_TARGET_SRCROOT)/native/liberika_capi.a\" #{erika_cabi_undefined_flags} -framework AVFoundation -framework AudioToolbox -framework QuartzCore -framework Metal -framework CoreVideo -framework CoreMedia -framework VideoToolbox -framework CoreText -framework CoreFoundation -framework CoreGraphics -framework Foundation -liconv -lbz2 -lz", + 'OTHER_LDFLAGS' => "$(inherited) \"$(PODS_TARGET_SRCROOT)/native/liberika_capi.a\" #{erika_cabi_undefined_flags} -framework AVFoundation -framework AudioToolbox -framework MediaPlayer -framework QuartzCore -framework Metal -framework CoreVideo -framework CoreMedia -framework VideoToolbox -framework CoreText -framework CoreFoundation -framework CoreGraphics -framework Foundation -liconv -lbz2 -lz", } end diff --git a/packages/erika_flutter/windows/CMakeLists.txt b/packages/erika_flutter/windows/CMakeLists.txt index 8303e87..b440810 100644 --- a/packages/erika_flutter/windows/CMakeLists.txt +++ b/packages/erika_flutter/windows/CMakeLists.txt @@ -53,6 +53,8 @@ endif() list(APPEND PLUGIN_SOURCES "erika_flutter_plugin.cpp" "erika_flutter_plugin.h" + "erika_windows_smtc.cpp" + "erika_windows_smtc.h" ) add_library(${PLUGIN_NAME} SHARED @@ -76,7 +78,7 @@ target_include_directories(${PLUGIN_NAME} PRIVATE "${ERIKA_REPO_ROOT}/crates/erika_capi/include") target_compile_definitions(${PLUGIN_NAME} PRIVATE ERIKA_REPO_ROOT_PATH="${ERIKA_REPO_ROOT}") -target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin runtimeobject windowsapp shcore shlwapi) find_program(CARGO_EXECUTABLE cargo REQUIRED) set(ERIKA_WINDOWS_ARCH "" CACHE STRING diff --git a/packages/erika_flutter/windows/erika_flutter_plugin.cpp b/packages/erika_flutter/windows/erika_flutter_plugin.cpp index adff043..faffe86 100644 --- a/packages/erika_flutter/windows/erika_flutter_plugin.cpp +++ b/packages/erika_flutter/windows/erika_flutter_plugin.cpp @@ -30,6 +30,7 @@ constexpr wchar_t kFrameMessageWindowClassName[] = L"ErikaFlutterFrameScheduler" constexpr wchar_t kFlutterRegularHostWindowClassName[] = L"FLUTTER_HOST_WINDOW"; constexpr UINT kFrameTimerMessage = WM_APP + 1; +constexpr UINT kSmtcMessage = WM_APP + 2; constexpr double kFrameTimerMinFps = 1.0; constexpr double kFrameTimerMaxFps = 1000.0; constexpr double kFrameTimerDefaultFps = 60.0; @@ -1145,6 +1146,7 @@ struct ErikaFlutterPlugin::PlayerHost { std::shared_ptr native_library, ErikaPresenterConfig config) : id(player_id), library(std::move(native_library)) { + smtc_state.player_id = player_id; handle = library->CreatePresenter(config); if (handle == nullptr) { std::string message = "erika_presenter_create returned null"; @@ -1212,14 +1214,73 @@ struct ErikaFlutterPlugin::PlayerHost { Check(library->open(handle, uri.c_str()), "open", library->TakeLastError()); } - void Play() { Check(library->play(handle), "play", library->TakeLastError()); } - void Pause() { Check(library->pause(handle), "pause", library->TakeLastError()); } - void Stop() { Check(library->stop(handle), "stop", library->TakeLastError()); } - void Close() { Check(library->close(handle), "close", library->TakeLastError()); } + void SetMediaMetadata(const EncodableMap& metadata) { + const auto title = StringValue(FindArg(metadata, "title")); + if (!title || title->empty()) { + throw PluginError("metadata.title is required."); + } + smtc_state.title = *title; + smtc_state.artist = StringValue(FindArg(metadata, "artist")).value_or(""); + smtc_state.album = StringValue(FindArg(metadata, "album")).value_or(""); + smtc_state.artwork.clear(); + if (const auto* value = FindArg(metadata, "artwork"); value != nullptr) { + if (const auto* bytes = std::get_if>(value)) { + smtc_state.artwork = *bytes; + } else if (!std::holds_alternative(*value)) { + throw PluginError("metadata.artwork must contain image bytes."); + } + } + ++smtc_state.metadata_revision; + } + + void ClearMediaMetadata() { + smtc_state.title.clear(); + smtc_state.artist.clear(); + smtc_state.album.clear(); + smtc_state.artwork.clear(); + ++smtc_state.metadata_revision; + } + + void PrepareForOpen() { + smtc_state.playing = false; + smtc_state.stopped = false; + smtc_state.duration_micros = 0; + smtc_state.position_micros = 0; + } + + void SetSystemMediaNavigation(bool previous_enabled, bool next_enabled) { + smtc_state.previous_enabled = previous_enabled; + smtc_state.next_enabled = next_enabled; + } + + void Play() { + Check(library->play(handle), "play", library->TakeLastError()); + smtc_state.playing = true; + smtc_state.stopped = false; + } + void Pause() { + Check(library->pause(handle), "pause", library->TakeLastError()); + smtc_state.playing = false; + smtc_state.stopped = false; + } + void Stop() { + Check(library->stop(handle), "stop", library->TakeLastError()); + smtc_state.playing = false; + smtc_state.stopped = true; + smtc_state.position_micros = 0; + } + void Close() { + Check(library->close(handle), "close", library->TakeLastError()); + smtc_state.playing = false; + smtc_state.stopped = true; + smtc_state.duration_micros = 0; + smtc_state.position_micros = 0; + } void Seek(uint64_t position_micros) { Check(library->seek(handle, position_micros), "seek", library->TakeLastError()); + smtc_state.position_micros = position_micros; } void SetPlaybackRate(double rate) { @@ -1228,6 +1289,7 @@ struct ErikaFlutterPlugin::PlayerHost { } Check(library->set_playback_rate(handle, rate), "set_playback_rate", library->TakeLastError()); + smtc_state.playback_rate = rate; } void SetVolume(double volume) { @@ -1677,13 +1739,20 @@ struct ErikaFlutterPlugin::PlayerHost { } void PollEvents(flutter::EventSink* event_sink) { - if (event_sink == nullptr) { - return; - } while (true) { ErikaEvent event{}; const auto status = library->poll_event(handle, &event); if (status == ErikaStatus_Ok) { + if (event.kind == ErikaEventKind_StateChanged) { + smtc_state.playing = event.state == ErikaState_Playing; + smtc_state.stopped = event.state == ErikaState_Stopped || + event.state == ErikaState_Closed || + event.state == ErikaState_Idle; + } else if (event.kind == ErikaEventKind_DurationChanged) { + smtc_state.duration_micros = event.duration_micros; + } else if (event.kind == ErikaEventKind_PositionChanged) { + smtc_state.position_micros = event.position_micros; + } if (event.kind == ErikaEventKind_Error) { DebugLog("player " + std::to_string(id) + " event error status=ErikaStatus_" + @@ -1691,7 +1760,9 @@ struct ErikaFlutterPlugin::PlayerHost { std::to_string(static_cast(event.status)) + "): " + library->TakeLastError()); } - event_sink->Success(EventToMap(event)); + if (event_sink != nullptr) { + event_sink->Success(EventToMap(event)); + } continue; } if (status != ErikaStatus_NoEvent) { @@ -1828,6 +1899,7 @@ struct ErikaFlutterPlugin::PlayerHost { } int64_t id = 0; + ErikaSmtcState smtc_state{}; std::shared_ptr library; ErikaPresenterHandle* handle = nullptr; HWND attached_hwnd = nullptr; @@ -1893,6 +1965,7 @@ ErikaFlutterPlugin::ErikaFlutterPlugin( ErikaFlutterPlugin::~ErikaFlutterPlugin() { StopFrameTimer(); + smtc_.reset(); DestroyFrameMessageWindow(); if (window_proc_delegate_id_ != 0) { registrar_->UnregisterTopLevelWindowProcDelegate(window_proc_delegate_id_); @@ -2196,6 +2269,11 @@ LRESULT CALLBACK ErikaFlutterPlugin::FrameMessageWindowProc(HWND hwnd, } return 0; } + if (message == kSmtcMessage && plugin != nullptr) { + plugin->HandleSmtcCommand(static_cast(wparam), + static_cast(lparam)); + return 0; + } if (message == WM_NCDESTROY && plugin != nullptr) { if (plugin->frame_message_window_ == hwnd) { @@ -2218,6 +2296,7 @@ void ErikaFlutterPlugin::OnFrameTimer() { for (auto& entry : players_) { entry.second->RenderTick(event_sink_.get()); } + RefreshSmtc(); if (trace_enabled) { tick_count += 1; const auto elapsed = std::chrono::duration( @@ -2252,6 +2331,7 @@ std::optional ErikaFlutterPlugin::OnTopLevelWindowProc( } if (message == WM_DESTROY) { StopFrameTimer(); + smtc_.reset(); for (auto& entry : players_) { entry.second->Detach(std::nullopt); } @@ -2329,6 +2409,89 @@ int64_t ErikaFlutterPlugin::CreatePlayer(const EncodableValue* arguments) { return id; } +void ErikaFlutterPlugin::EnsureSmtc() { + if (smtc_) { + return; + } + HWND window = RootHostWindow(FlutterWindow()); + if (window == nullptr) { + return; + } + HWND message_window = EnsureFrameMessageWindow(); + if (message_window == nullptr) { + return; + } + smtc_ = std::make_unique( + window, [message_window](ErikaSmtcCommand command, + uint64_t position_micros) { + PostMessageW(message_window, kSmtcMessage, + static_cast(command), + static_cast(position_micros)); + }); + if (!smtc_->available()) { + smtc_.reset(); + } +} + +void ErikaFlutterPlugin::SetActivePlayer(int64_t player_id) { + active_player_id_ = player_id; + EnsureSmtc(); + RefreshSmtc(); +} + +void ErikaFlutterPlugin::RefreshSmtc() { + if (!smtc_ || active_player_id_ == 0) { + return; + } + const auto it = players_.find(active_player_id_); + if (it == players_.end()) { + smtc_->Clear(); + active_player_id_ = 0; + return; + } + smtc_->Update(it->second->smtc_state); +} + +void ErikaFlutterPlugin::HandleSmtcCommand(ErikaSmtcCommand command, + uint64_t position_micros) { + const auto it = players_.find(active_player_id_); + if (it == players_.end()) { + return; + } + try { + if (command == ErikaSmtcCommand::play) { + it->second->Play(); + } else if (command == ErikaSmtcCommand::pause) { + it->second->Pause(); + } else if (command == ErikaSmtcCommand::toggle) { + if (it->second->smtc_state.playing) { + it->second->Pause(); + } else { + it->second->Play(); + } + } else if (command == ErikaSmtcCommand::seek) { + it->second->Seek(position_micros); + } else if (command == ErikaSmtcCommand::previous || + command == ErikaSmtcCommand::next) { + const bool enabled = command == ErikaSmtcCommand::previous + ? it->second->smtc_state.previous_enabled + : it->second->smtc_state.next_enabled; + if (enabled) { + SendEvent(EncodableValue(EncodableMap{ + {EncodableValue("playerId"), EncodableValue(active_player_id_)}, + {EncodableValue("kind"), EncodableValue(13)}, + {EncodableValue("navigation"), + EncodableValue(command == ErikaSmtcCommand::previous ? "previous" + : "next")}, + })); + } + } + OnFrameTimer(); + } catch (const std::exception& error) { + DebugLog(std::string("SMTC command failed: ") + error.what()); + } +} + void ErikaFlutterPlugin::RemovePlayer(int64_t player_id) { const auto it = players_.find(player_id); if (it == players_.end()) { @@ -2342,6 +2505,12 @@ void ErikaFlutterPlugin::RemovePlayer(int64_t player_id) { std::nullopt); overlay_window_->owner_player_id = 0; } + if (active_player_id_ == player_id) { + active_player_id_ = 0; + if (smtc_) { + smtc_->Clear(); + } + } players_.erase(it); } @@ -2370,11 +2539,26 @@ void ErikaFlutterPlugin::HandleMethodCall( OnFrameTimer(); result->Success(); } else if (method == "open") { - PlayerFromArgs(args).Open(RequiredString(args, "uri"), args); + auto& player = PlayerFromArgs(args); + if (const auto* value = FindArg(args, "metadata"); value != nullptr) { + if (const auto* metadata = std::get_if(value)) { + player.SetMediaMetadata(*metadata); + } else if (std::holds_alternative(*value)) { + player.ClearMediaMetadata(); + } else { + throw PluginError("metadata must be a map."); + } + } else { + player.ClearMediaMetadata(); + } + player.PrepareForOpen(); + player.Open(RequiredString(args, "uri"), args); OnFrameTimer(); result->Success(); } else if (method == "play") { - PlayerFromArgs(args).Play(); + auto& player = PlayerFromArgs(args); + player.Play(); + SetActivePlayer(player.id); OnFrameTimer(); result->Success(); } else if (method == "pause") { @@ -2399,6 +2583,21 @@ void ErikaFlutterPlugin::HandleMethodCall( PlayerFromArgs(args).SetPlaybackRate( DoubleValue(FindArg(args, "rate")).value_or(1.0)); result->Success(); + } else if (method == "setMediaMetadata") { + const auto* value = FindArg(args, "metadata"); + const auto* metadata = value == nullptr ? nullptr : std::get_if(value); + if (metadata == nullptr) { + throw PluginError("metadata is required."); + } + PlayerFromArgs(args).SetMediaMetadata(*metadata); + RefreshSmtc(); + result->Success(); + } else if (method == "setSystemMediaNavigation") { + PlayerFromArgs(args).SetSystemMediaNavigation( + BoolValue(FindArg(args, "previousEnabled")).value_or(false), + BoolValue(FindArg(args, "nextEnabled")).value_or(false)); + RefreshSmtc(); + result->Success(); } else if (method == "setVolume") { PlayerFromArgs(args).SetVolume( DoubleValue(FindArg(args, "volume")).value_or(1.0)); diff --git a/packages/erika_flutter/windows/erika_flutter_plugin.h b/packages/erika_flutter/windows/erika_flutter_plugin.h index 0f6c5c3..9448061 100644 --- a/packages/erika_flutter/windows/erika_flutter_plugin.h +++ b/packages/erika_flutter/windows/erika_flutter_plugin.h @@ -21,6 +21,7 @@ #include #include "erika.h" +#include "erika_windows_smtc.h" namespace erika_flutter { @@ -100,6 +101,10 @@ class ErikaFlutterPlugin : public flutter::Plugin { int64_t CreatePlayer(const flutter::EncodableValue* arguments); void RemovePlayer(int64_t player_id); void SendEvent(flutter::EncodableValue event); + void EnsureSmtc(); + void SetActivePlayer(int64_t player_id); + void RefreshSmtc(); + void HandleSmtcCommand(ErikaSmtcCommand command, uint64_t position_micros); flutter::PluginRegistrarWindows* registrar_ = nullptr; std::unique_ptr> @@ -107,6 +112,8 @@ class ErikaFlutterPlugin : public flutter::Plugin { std::unique_ptr> event_sink_; std::unordered_map> players_; std::unique_ptr overlay_window_; + std::unique_ptr smtc_; + int64_t active_player_id_ = 0; int64_t requested_flutter_view_id_ = 0; bool overlay_uses_secondary_window_ = false; int64_t next_player_id_ = 1; diff --git a/packages/erika_flutter/windows/erika_windows_smtc.cpp b/packages/erika_flutter/windows/erika_windows_smtc.cpp new file mode 100644 index 0000000..e1f32e0 --- /dev/null +++ b/packages/erika_flutter/windows/erika_windows_smtc.cpp @@ -0,0 +1,205 @@ +#include "erika_windows_smtc.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace erika_flutter { +namespace { + +winrt::Windows::Foundation::TimeSpan MicrosToTimeSpan(uint64_t micros) { + using namespace std::chrono; + return winrt::Windows::Foundation::TimeSpan( + duration_cast( + microseconds(micros))); +} + +winrt::Windows::Storage::Streams::IRandomAccessStream ArtworkStream( + const std::vector& artwork) { + if (artwork.size() > std::numeric_limits::max()) { + throw winrt::hresult_invalid_argument(); + } + winrt::com_ptr stream; + stream.attach(SHCreateMemStream(artwork.data(), + static_cast(artwork.size()))); + if (!stream) { + winrt::throw_hresult(E_OUTOFMEMORY); + } + return winrt::capture< + winrt::Windows::Storage::Streams::IRandomAccessStream>( + CreateRandomAccessStreamOverStream, stream.get(), BSOS_DEFAULT); +} + +} + +struct ErikaWindowsSmtc::Impl { + struct CallbackState { + CommandHandler handler; + std::atomic enabled{true}; + }; + + Impl(HWND window, CommandHandler command_handler) + : callback_state(std::make_shared()) { + callback_state->handler = std::move(command_handler); + try { + auto interop = winrt::get_activation_factory< + winrt::Windows::Media::SystemMediaTransportControls, + ISystemMediaTransportControlsInterop>(); + winrt::check_hresult(interop->GetForWindow( + window, winrt::guid_of(), + winrt::put_abi(controls))); + controls.IsEnabled(true); + controls.IsPlayEnabled(true); + controls.IsPauseEnabled(true); + controls.IsStopEnabled(false); + controls.IsNextEnabled(false); + controls.IsPreviousEnabled(false); + button_token = controls.ButtonPressed( + [state = callback_state](const auto&, const auto& args) { + if (!state->enabled.load(std::memory_order_acquire)) { + return; + } + using Button = + winrt::Windows::Media::SystemMediaTransportControlsButton; + if (args.Button() == Button::Play) { + state->handler(ErikaSmtcCommand::play, 0); + } else if (args.Button() == Button::Pause) { + state->handler(ErikaSmtcCommand::pause, 0); + } else if (args.Button() == Button::Previous) { + state->handler(ErikaSmtcCommand::previous, 0); + } else if (args.Button() == Button::Next) { + state->handler(ErikaSmtcCommand::next, 0); + } + }); + seek_token = controls.PlaybackPositionChangeRequested( + [state = callback_state](const auto&, const auto& args) { + if (!state->enabled.load(std::memory_order_acquire)) { + return; + } + const auto ticks = args.RequestedPlaybackPosition().count(); + state->handler( + ErikaSmtcCommand::seek, + ticks <= 0 ? 0 : static_cast(ticks / 10)); + }); + } catch (...) { + controls = nullptr; + } + } + + ~Impl() { + callback_state->enabled.store(false, std::memory_order_release); + if (controls) { + controls.ButtonPressed(button_token); + controls.PlaybackPositionChangeRequested(seek_token); + controls.IsEnabled(false); + } + } + + void Update(const ErikaSmtcState& state) { + if (!controls) { + return; + } + try { + if (!has_state || + state.metadata_revision != last_state.metadata_revision) { + auto display = controls.DisplayUpdater(); + display.Type(winrt::Windows::Media::MediaPlaybackType::Music); + auto properties = display.MusicProperties(); + properties.Title(winrt::to_hstring(state.title)); + properties.Artist(winrt::to_hstring(state.artist)); + properties.AlbumTitle(winrt::to_hstring(state.album)); + if (!state.artwork.empty()) { + display.Thumbnail( + winrt::Windows::Storage::Streams::RandomAccessStreamReference::CreateFromStream( + ArtworkStream(state.artwork))); + } else { + display.Thumbnail(nullptr); + } + display.Update(); + } + + if (!has_state || state.duration_micros != last_state.duration_micros || + state.position_micros != last_state.position_micros) { + winrt::Windows::Media::SystemMediaTransportControlsTimelineProperties timeline; + timeline.StartTime(MicrosToTimeSpan(0)); + timeline.MinSeekTime(MicrosToTimeSpan(0)); + timeline.Position(MicrosToTimeSpan( + std::min(state.position_micros, state.duration_micros))); + timeline.MaxSeekTime(MicrosToTimeSpan(state.duration_micros)); + timeline.EndTime(MicrosToTimeSpan(state.duration_micros)); + controls.UpdateTimelineProperties(timeline); + } + if (!has_state || state.playback_rate != last_state.playback_rate) { + controls.PlaybackRate(state.playback_rate); + } + if (!has_state || + state.previous_enabled != last_state.previous_enabled) { + controls.IsPreviousEnabled(state.previous_enabled); + } + if (!has_state || state.next_enabled != last_state.next_enabled) { + controls.IsNextEnabled(state.next_enabled); + } + if (!has_state || state.playing != last_state.playing || + state.stopped != last_state.stopped) { + controls.PlaybackStatus( + state.playing + ? winrt::Windows::Media::MediaPlaybackStatus::Playing + : state.stopped + ? winrt::Windows::Media::MediaPlaybackStatus::Stopped + : winrt::Windows::Media::MediaPlaybackStatus::Paused); + } + last_state = state; + has_state = true; + } catch (...) { + } + } + + void Clear() { + if (!controls) { + return; + } + try { + controls.DisplayUpdater().ClearAll(); + controls.PlaybackStatus( + winrt::Windows::Media::MediaPlaybackStatus::Closed); + has_state = false; + } catch (...) { + } + } + + std::shared_ptr callback_state; + winrt::Windows::Media::SystemMediaTransportControls controls{nullptr}; + winrt::event_token button_token{}; + winrt::event_token seek_token{}; + ErikaSmtcState last_state{}; + bool has_state = false; +}; + +ErikaWindowsSmtc::ErikaWindowsSmtc(HWND window, CommandHandler handler) + : impl_(std::make_unique(window, std::move(handler))) {} + +ErikaWindowsSmtc::~ErikaWindowsSmtc() = default; + +bool ErikaWindowsSmtc::available() const { + return impl_->controls != nullptr; +} + +void ErikaWindowsSmtc::Update(const ErikaSmtcState& state) { + impl_->Update(state); +} + +void ErikaWindowsSmtc::Clear() { + impl_->Clear(); +} + +} diff --git a/packages/erika_flutter/windows/erika_windows_smtc.h b/packages/erika_flutter/windows/erika_windows_smtc.h new file mode 100644 index 0000000..50b2430 --- /dev/null +++ b/packages/erika_flutter/windows/erika_windows_smtc.h @@ -0,0 +1,61 @@ +#ifndef FLUTTER_PLUGIN_ERIKA_WINDOWS_SMTC_H_ +#define FLUTTER_PLUGIN_ERIKA_WINDOWS_SMTC_H_ + +#include + +#include +#include +#include +#include +#include + +namespace erika_flutter { + +enum class ErikaSmtcCommand { + play, + pause, + toggle, + seek, + previous, + next, +}; + +struct ErikaSmtcState { + int64_t player_id = 0; + std::string title; + std::string artist; + std::string album; + std::vector artwork; + uint64_t metadata_revision = 0; + uint64_t duration_micros = 0; + uint64_t position_micros = 0; + double playback_rate = 1.0; + bool playing = false; + bool stopped = true; + bool previous_enabled = false; + bool next_enabled = false; +}; + +class ErikaWindowsSmtc { + public: + using CommandHandler = + std::function; + + ErikaWindowsSmtc(HWND window, CommandHandler handler); + ~ErikaWindowsSmtc(); + + ErikaWindowsSmtc(const ErikaWindowsSmtc&) = delete; + ErikaWindowsSmtc& operator=(const ErikaWindowsSmtc&) = delete; + + bool available() const; + void Update(const ErikaSmtcState& state); + void Clear(); + + private: + struct Impl; + std::unique_ptr impl_; +}; + +} + +#endif