From 29f47da8e64ae9caacfd7317c2a6ad9d2d609e9b Mon Sep 17 00:00:00 2001 From: MCDFSteve <2911898435@qq.com> Date: Sun, 2 Aug 2026 19:27:26 +0800 Subject: [PATCH 1/2] appletv --- .github/workflows/ci.yml | 32 + .gitignore | 1 + crates/erika/Cargo.toml | 4 +- crates/erika/build.rs | 10 +- crates/erika/src/apple.rs | 2 +- crates/erika/src/playback.rs | 12 +- crates/erika/src/presenter.rs | 23 +- crates/erika/src/renderer/metal/apple.rs | 4 +- crates/erika/src/renderer/metal/mod.rs | 82 +- crates/erika/src/renderer/presentation.rs | 20 +- crates/erika/src/renderer/wgpu.rs | 10 +- crates/erika/src/source.rs | 41 + crates/erika/src/subtitle.rs | 6 +- crates/erika/tests/artcnn_upscaler.rs | 2 +- crates/erika_capi/src/lib.rs | 240 +- crates/erika_ffmpeg_sys/build.rs | 16 +- docs/building.ja.md | 24 +- docs/building.md | 23 +- docs/building.zh.md | 23 +- packages/erika_flutter/README.ja.md | 17 +- packages/erika_flutter/README.md | 18 +- packages/erika_flutter/README.zh.md | 17 +- packages/erika_flutter/pubspec.yaml | 2 + .../tvos/Classes/ErikaFlutterPlugin.swift | 2442 +++++++++++++++++ .../erika_flutter/tvos/erika_flutter.podspec | 214 ++ xtask/src/main.rs | 122 +- 26 files changed, 3189 insertions(+), 218 deletions(-) create mode 100644 packages/erika_flutter/tvos/Classes/ErikaFlutterPlugin.swift create mode 100644 packages/erika_flutter/tvos/erika_flutter.podspec diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7ac6d6..c271072 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ concurrency: env: PROFILE: lgpl IPHONEOS_DEPLOYMENT_TARGET: "13.0" + TVOS_DEPLOYMENT_TARGET: "13.0" CARGO_TERM_COLOR: always CARGO_NET_GIT_FETCH_WITH_CLI: "true" @@ -138,6 +139,37 @@ jobs: --target aarch64-apple-ios \ --no-default-features --features libass --crate-type staticlib + tvos: + name: tvOS simulator staticlib + runs-on: macos-14 + timeout-minutes: 150 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + with: + components: rust-src + - name: Install native build tools + run: brew install nasm meson ninja pkg-config + - name: Cache native dependencies + uses: actions/cache@v4 + with: + path: third_party + key: native-v2-tvos-simulator-aarch64-min${{ env.TVOS_DEPLOYMENT_TARGET }}-${{ hashFiles('xtask/src/main.rs', 'third_party/patches/**') }} + - name: Build tvOS native dependencies + run: >- + cargo run --locked -p xtask -- deps build --all + --profile "$PROFILE" --target aarch64-apple-tvos-sim + - name: Compile tvOS static library + run: | + TVOS_SDKROOT="$(xcrun --sdk appletvsimulator --show-sdk-path)" + export SDKROOT="$TVOS_SDKROOT" + export BINDGEN_EXTRA_CLANG_ARGS_aarch64_apple_tvos_sim="--target=arm64-apple-tvos${TVOS_DEPLOYMENT_TARGET}-simulator -isysroot $TVOS_SDKROOT" + ERIKA_NATIVE_PROFILE="$PROFILE" ERIKA_NATIVE_TARGET="aarch64-apple-tvos-sim" \ + cargo +nightly rustc -Z build-std=std,panic_abort --locked \ + -p erika_capi --lib --release \ + --target aarch64-apple-tvos-sim \ + --no-default-features --features libass --crate-type staticlib + windows: name: Windows ${{ matrix.arch }} build runs-on: ${{ matrix.runner }} diff --git a/.gitignore b/.gitignore index 3516703..95b5923 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ /packages/erika_flutter/android/build/ /packages/erika_flutter/android/local.properties /packages/erika_flutter/ios/native/ +/packages/erika_flutter/tvos/native/ .DS_Store diff --git a/crates/erika/Cargo.toml b/crates/erika/Cargo.toml index a963be5..2b9ab62 100644 --- a/crates/erika/Cargo.toml +++ b/crates/erika/Cargo.toml @@ -60,7 +60,7 @@ ash = "0.38.0" ash = "0.38.0" glow = "0.17.0" -[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] +[target.'cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))'.dependencies] objc2.workspace = true objc2-core-foundation.workspace = true objc2-core-graphics = { workspace = true, features = ["CGColorSpace"] } @@ -69,6 +69,6 @@ objc2-foundation.workspace = true objc2-metal.workspace = true objc2-quartz-core.workspace = true -[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dev-dependencies] +[target.'cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))'.dev-dependencies] objc2.workspace = true objc2-metal.workspace = true diff --git a/crates/erika/build.rs b/crates/erika/build.rs index 7b883c1..9ea9a1e 100644 --- a/crates/erika/build.rs +++ b/crates/erika/build.rs @@ -16,6 +16,7 @@ fn main() { println!("cargo:rerun-if-env-changed=ANDROID_API_LEVEL"); println!("cargo:rerun-if-env-changed=ANDROID_NDK_HOME"); println!("cargo:rerun-if-env-changed=ANDROID_NDK_ROOT"); + println!("cargo:rerun-if-env-changed=TVOS_DEPLOYMENT_TARGET"); println!("cargo:rerun-if-changed=src/renderer/ohos_native_buffer.vert"); println!("cargo:rerun-if-changed=src/renderer/ohos_native_buffer.frag"); println!("cargo:rerun-if-changed=src/renderer/ohos_native_buffer.vert.spv"); @@ -25,7 +26,7 @@ fn main() { enforce_bundled_ffmpeg_version(ffmpeg_version_major); let target_os = env::var("CARGO_CFG_TARGET_OS").ok(); - if target_os.as_deref() == Some("ios") { + if matches!(target_os.as_deref(), Some("ios" | "tvos")) { println!("cargo:rustc-link-lib=framework=AudioToolbox"); } else if target_os.as_deref() == Some("android") { for lib in [ @@ -113,7 +114,7 @@ fn main() { if target_os.as_deref() == Some("windows") { println!("cargo:rustc-link-lib=dwrite"); - } else if matches!(target_os.as_deref(), Some("ios" | "macos")) { + } else if matches!(target_os.as_deref(), Some("ios" | "tvos" | "macos")) { if target_os.as_deref() == Some("macos") { println!("cargo:rustc-link-lib=framework=ApplicationServices"); } @@ -286,6 +287,11 @@ fn inferred_native_target() -> Option { ("android", "x86_64") => Some("x86_64-linux-android".to_string()), ("android", "x86") => Some("i686-linux-android".to_string()), ("ios", _) => Some("ios".to_string()), + ("tvos", "aarch64") if env::var("CARGO_CFG_TARGET_ABI").as_deref() == Ok("sim") => { + Some("aarch64-apple-tvos-sim".to_string()) + } + ("tvos", "aarch64") => Some("aarch64-apple-tvos".to_string()), + ("tvos", "x86_64") => Some("x86_64-apple-tvos".to_string()), _ => None, } } diff --git a/crates/erika/src/apple.rs b/crates/erika/src/apple.rs index 7f7b934..ca9650d 100644 --- a/crates/erika/src/apple.rs +++ b/crates/erika/src/apple.rs @@ -19,7 +19,7 @@ pub enum AppleDecodeBackend { Software, } -#[cfg(target_os = "ios")] +#[cfg(any(target_os = "ios", target_os = "tvos"))] pub mod iosaudio { use std::ffi::c_void; use std::ptr::{self, NonNull}; diff --git a/crates/erika/src/playback.rs b/crates/erika/src/playback.rs index f3cd431..15a742f 100644 --- a/crates/erika/src/playback.rs +++ b/crates/erika/src/playback.rs @@ -537,7 +537,7 @@ impl VideoDecodePreference { } } -#[cfg(any(target_os = "macos", target_os = "ios"))] +#[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] impl Default for VideoDecodePreference { fn default() -> Self { Self::VideoToolbox @@ -567,7 +567,7 @@ impl Default for VideoDecodePreference { #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1603,8 +1603,12 @@ impl PlaybackSession { let decoder_alive = self.video_decoder.is_some(); let discarded = self.discard_queued_frames_and_packets(); trace_discarded_playback_queues("seek_before_decoder_transition", discarded, decoder_alive); - let bypass_seek_keyframe_gate = cfg!(any(target_os = "macos", target_os = "ios")) - && self.active_video_decoder_backend() == Some(DecoderBackend::VideoToolbox) + let bypass_seek_keyframe_gate = cfg!(any( + target_os = "macos", + target_os = "ios", + target_os = "tvos" + )) && self.active_video_decoder_backend() + == Some(DecoderBackend::VideoToolbox) && self.active_video_codec_is_av1(); self.video_fallback_waiting_for_keyframe = self.video_decoder.is_some() && !bypass_seek_keyframe_gate; diff --git a/crates/erika/src/presenter.rs b/crates/erika/src/presenter.rs index 1b03eb8..d4f33d1 100644 --- a/crates/erika/src/presenter.rs +++ b/crates/erika/src/presenter.rs @@ -15,12 +15,12 @@ use crossbeam_channel::{Receiver, Sender}; use crate::android::aaudio::{AAudioOutput, AAudioOutputConfig}; #[cfg(target_os = "macos")] use crate::apple::coreaudio::{CoreAudioOutput, CoreAudioOutputConfig}; -#[cfg(target_os = "ios")] +#[cfg(any(target_os = "ios", target_os = "tvos"))] use crate::apple::iosaudio::{IosAudioQueueOutput, IosAudioQueueOutputConfig}; #[cfg(not(any( target_os = "android", target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_env = "ohos" )))] @@ -47,7 +47,7 @@ use crate::ohos::ohaudio::{OHAudioOutput, OHAudioOutputConfig}; use crate::overlay::{OverlayFrame, OverlayTimeline, OverlayViewport}; #[cfg(any(target_os = "windows", target_os = "android"))] use crate::playback::VideoDecodePreference; -#[cfg(any(target_os = "macos", target_os = "ios"))] +#[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] use crate::renderer::metal::MetalRenderer; use crate::renderer::metal::MetalRendererConfig; #[cfg(feature = "libass")] @@ -113,7 +113,7 @@ impl Default for PresenterAudioConfig { ring_buffer: config.ring_buffer, } } - #[cfg(target_os = "ios")] + #[cfg(any(target_os = "ios", target_os = "tvos"))] { let config = IosAudioQueueOutputConfig::default(); Self { @@ -144,7 +144,7 @@ impl Default for PresenterAudioConfig { #[cfg(not(any( target_os = "android", target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_env = "ohos" )))] @@ -2893,7 +2893,7 @@ fn build_renderer( _metal_config: MetalRendererConfig, ) -> Result> { match preference { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] RendererBackendPreference::PlatformNative | RendererBackendPreference::Auto => { Ok(Box::new(MetalRenderer::with_config(_metal_config)?)) } @@ -2903,7 +2903,12 @@ fn build_renderer( crate::renderer::d3d11::D3d11Renderer::with_config(_metal_config)?, )) } - #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))] + #[cfg(not(any( + target_os = "macos", + target_os = "ios", + target_os = "tvos", + target_os = "windows" + )))] RendererBackendPreference::PlatformNative | RendererBackendPreference::Auto => { build_wgpu_renderer(_metal_config) } @@ -2968,7 +2973,7 @@ fn build_audio_output(config: PresenterAudioConfig) -> Box Box, current_frame_visible: bool, @@ -105,7 +105,7 @@ pub(crate) fn metal_target_color( crate::renderer::pipeline::TargetColorState::sdr(ColorPrimaries::Bt709) } MetalOutputMode::AppleEdr { headroom } | MetalOutputMode::ExtendedLinear { headroom } => { - #[cfg(target_os = "ios")] + #[cfg(any(target_os = "ios", target_os = "tvos"))] { let _ = source; let headroom = headroom.max(1.0); @@ -118,7 +118,7 @@ pub(crate) fn metal_target_color( }; } - #[cfg(not(target_os = "ios"))] + #[cfg(not(any(target_os = "ios", target_os = "tvos")))] { let primaries = match (source.transfer, source.primaries) { (TransferFunction::Pq, ColorPrimaries::Unknown) => ColorPrimaries::Bt2020, @@ -210,9 +210,9 @@ pub struct ImportedVideoFrameInfo { pub struct ImportedVideoFrame { info: ImportedVideoFrameInfo, source_color: SourceColorState, - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] inner: Option, - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] _unsupported: (), } @@ -222,11 +222,11 @@ impl ImportedVideoFrame { } pub fn plane_count(&self) -> usize { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { self.inner.as_ref().map_or(0, |inner| inner.plane_count()) } - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] { 0 } @@ -357,7 +357,7 @@ impl MetalRenderer { } pub fn with_config(_config: MetalRendererConfig) -> Result { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { Ok(Self { inner: apple::MetalRendererImpl::new(_config)?, @@ -370,7 +370,7 @@ impl MetalRenderer { output_mode: _config.output_mode, }) } - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] { Err(PlayerError::Renderer( "Metal renderer is only available on Apple platforms for v0".to_string(), @@ -383,11 +383,11 @@ impl MetalRenderer { layer: *mut c_void, metrics: SurfaceMetrics, ) -> Result<()> { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { unsafe { self.inner.attach_raw_layer(layer, metrics) } } - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] { let _ = (layer, metrics); Err(PlayerError::Renderer( @@ -400,7 +400,7 @@ impl MetalRenderer { &mut self, source: VideoFrameTextureSource, ) -> Result { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { let imported = unsafe { self.inner.import_video_frame_textures(source) }?; let source_color = @@ -412,7 +412,7 @@ impl MetalRenderer { inner: Some(imported.textures), }) } - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] { let _ = source; Err(PlayerError::Renderer( @@ -422,11 +422,11 @@ impl MetalRenderer { } pub fn render_video_frame(&mut self, frame: VideoRenderFrame<'_>) -> Result<()> { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { self.inner.render_video_frame(frame) } - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] { let _ = frame; Err(PlayerError::Renderer( @@ -440,11 +440,11 @@ impl MetalRenderer { frame: VideoRenderFrame<'_>, overlay: OverlayRenderFrame<'_>, ) -> Result<()> { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { self.inner.render_video_frame_with_overlay(frame, overlay) } - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] { let _ = (frame, overlay); Err(PlayerError::Renderer( @@ -459,12 +459,12 @@ impl MetalRenderer { overlay: Option>, danmaku: Option>, ) -> Result<()> { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { self.inner .render_video_frame_with_context(frame, overlay, danmaku) } - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] { let _ = (frame, overlay, danmaku); Err(PlayerError::Renderer( @@ -481,12 +481,12 @@ impl MetalRenderer { width: u32, height: u32, ) -> Result> { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { self.inner .capture_video_frame_rgba(frame, overlay, danmaku, width, height) } - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] { let _ = (frame, overlay, danmaku, width, height); Err(PlayerError::Renderer( @@ -496,11 +496,11 @@ impl MetalRenderer { } pub fn render_overlay_frame(&mut self, overlay: OverlayRenderFrame<'_>) -> Result<()> { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { self.inner.render_overlay_frame(overlay) } - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] { let _ = overlay; Err(PlayerError::Renderer( @@ -514,7 +514,7 @@ impl MetalRenderer { frame: OverlayRenderFrame<'_>, ) -> Result { let info = inspect_overlay_frame(frame.frame)?; - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { self.inner.record_prepared_overlay_frame(info); } @@ -556,7 +556,7 @@ impl MetalRenderer { frame: &PlanarFrame, color_range: ColorRange, ) -> Result { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { let result = self .inner @@ -567,7 +567,7 @@ impl MetalRenderer { inner: Some(result.textures), }) } - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] { let _ = (frame, color_range); Err(PlayerError::Renderer( @@ -577,11 +577,11 @@ impl MetalRenderer { } pub fn stats(&self) -> MetalRendererStats { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { self.inner.stats() } - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] { MetalRendererStats::default() } @@ -658,7 +658,7 @@ impl RendererBackend for MetalRenderer { } fn detach_surface(&mut self) -> Result<()> { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { self.inner.detach_surface(); } @@ -666,11 +666,11 @@ impl RendererBackend for MetalRenderer { } fn resize_surface(&mut self, metrics: SurfaceMetrics) -> Result<()> { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { self.inner.resize_surface(metrics); } - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] { let _ = metrics; } @@ -682,7 +682,7 @@ impl RendererBackend for MetalRenderer { self.current_frame_visible = false; self.current_media_time = Duration::ZERO; self.current_generation = 1; - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { if self.inner.has_surface() { self.inner.render_clear(ClearColor::black())?; @@ -696,7 +696,7 @@ impl RendererBackend for MetalRenderer { } fn render_test_frame(&mut self, time_seconds: f64) -> Result<()> { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { let started = std::time::Instant::now(); self.inner.render_clear(ClearColor::animated(time_seconds)) @@ -711,7 +711,7 @@ impl RendererBackend for MetalRenderer { result }) } - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] { let _ = time_seconds; Err(PlayerError::Renderer( @@ -919,11 +919,11 @@ impl RendererBackend for MetalRenderer { } fn set_luma_upscaler(&mut self, mode: crate::renderer::pipeline::LumaUpscalerMode) { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] { self.inner.set_luma_upscaler(mode); } - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] { let _ = mode; } @@ -955,9 +955,9 @@ mod tests { planes: Vec::new(), }, source_color, - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] inner: None, - #[cfg(not(any(target_os = "macos", target_os = "ios")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "tvos")))] _unsupported: (), } } diff --git a/crates/erika/src/renderer/presentation.rs b/crates/erika/src/renderer/presentation.rs index 51dcbc5..6b42c58 100644 --- a/crates/erika/src/renderer/presentation.rs +++ b/crates/erika/src/renderer/presentation.rs @@ -60,22 +60,34 @@ impl PresentationLayout { } } - #[cfg_attr(not(any(target_os = "macos", target_os = "ios")), allow(dead_code))] + #[cfg_attr( + not(any(target_os = "macos", target_os = "ios", target_os = "tvos")), + allow(dead_code) + )] pub(crate) fn is_source_upscaled(self) -> bool { self.target_rect[2] > self.source_width } - #[cfg_attr(not(any(target_os = "macos", target_os = "ios")), allow(dead_code))] + #[cfg_attr( + not(any(target_os = "macos", target_os = "ios", target_os = "tvos")), + allow(dead_code) + )] pub(crate) fn video_viewport(self) -> [f32; 4] { [self.drawable_width, self.drawable_height, 0.0, 0.0] } - #[cfg_attr(not(any(target_os = "macos", target_os = "ios")), allow(dead_code))] + #[cfg_attr( + not(any(target_os = "macos", target_os = "ios", target_os = "tvos")), + allow(dead_code) + )] pub(crate) fn overlay_viewport(self) -> [f32; 2] { [self.drawable_width, self.drawable_height] } - #[cfg_attr(not(any(target_os = "macos", target_os = "ios")), allow(dead_code))] + #[cfg_attr( + not(any(target_os = "macos", target_os = "ios", target_os = "tvos")), + allow(dead_code) + )] pub(crate) fn map_source_rect(self, x: f32, y: f32, width: f32, height: f32) -> [f32; 4] { let scale_x = self.target_rect[2] / self.source_width; let scale_y = self.target_rect[3] / self.source_height; diff --git a/crates/erika/src/renderer/wgpu.rs b/crates/erika/src/renderer/wgpu.rs index f3f12d1..9ec7847 100644 --- a/crates/erika/src/renderer/wgpu.rs +++ b/crates/erika/src/renderer/wgpu.rs @@ -1,6 +1,6 @@ #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "android", target_env = "ohos" ))] @@ -22,7 +22,7 @@ use crate::android::{AndroidDataSpaceErrorKind, AndroidNativeWindow}; #[cfg(any( target_os = "android", target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_env = "ohos" ))] @@ -3293,7 +3293,7 @@ impl WgpuRenderer { #[cfg(not(any( target_os = "android", target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_env = "ohos" )))] @@ -3307,7 +3307,7 @@ impl WgpuRenderer { #[cfg(any( target_os = "android", target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_env = "ohos" ))] @@ -3319,7 +3319,7 @@ impl WgpuRenderer { // Android we additionally acquire an ANativeWindow reference retained by // `AttachedSurface`, so the raw handle outlives the wgpu surface. let target = match handle.kind { - #[cfg(any(target_os = "macos", target_os = "ios"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] WgpuSurfaceKind::MacOsCaMetalLayer => { wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(handle.raw_window as *mut c_void) } diff --git a/crates/erika/src/source.rs b/crates/erika/src/source.rs index 06cc89f..b01fac2 100644 --- a/crates/erika/src/source.rs +++ b/crates/erika/src/source.rs @@ -937,6 +937,31 @@ impl MediaSource for HttpRangeSource { .get("content-length") .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse::().ok()); + // Some streaming servers synthesize an empty HEAD body and + // incorrectly report that body length as the media length. + // A zero-byte media resource is not useful to the demuxer, + // so verify it with a one-byte range request before caching + // the value. Content-Range carries the actual object size. + if length == Some(0) { + http_trace_log(format!( + "[erika-http-trace] stage=head_zero_length_fallback status={} elapsed_ms={:.3}", + status, + started.elapsed().as_secs_f64() * 1000.0, + )); + return match probe_http_total_length( + &self.agent, + &self.uri, + &self.http_headers, + ) { + Ok(total_length) => { + self.content_length = total_length; + Ok(self.content_length) + } + Err(error) => Err(SourceError::Http(format!( + "HEAD reported Content-Length: 0 and range probe failed: {error}" + ))), + }; + } self.content_length = length; http_trace_log(format!( "[erika-http-trace] stage=head_response status={} length={} elapsed_ms={:.3}", @@ -1780,6 +1805,22 @@ mod tests { assert!(probe.contains("range: bytes=0-0")); } + #[test] + fn len_falls_back_to_range_probe_when_head_reports_zero() { + let (uri, requests) = spawn_mock_http_server(vec![ + MockResponse::immediate( + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec(), + ), + MockResponse::immediate(http_206_response(0, 911_198_509, b"z")), + ]); + let mut source = HttpRangeSource::new(uri); + assert_eq!(source.len().unwrap(), Some(911_198_509)); + assert!(recv_request_head(&requests).starts_with("head")); + let probe = recv_request_head(&requests); + assert!(probe.starts_with("get")); + assert!(probe.contains("range: bytes=0-0")); + } + #[test] fn redacted_uri_hides_access_tokens() { assert_eq!( diff --git a/crates/erika/src/subtitle.rs b/crates/erika/src/subtitle.rs index 936baa5..c71a4e2 100644 --- a/crates/erika/src/subtitle.rs +++ b/crates/erika/src/subtitle.rs @@ -2175,7 +2175,11 @@ const DEFAULT_ASS_FONT_FAMILY: &str = BUNDLED_ASS_FALLBACK_FONT_FAMILY; #[cfg(feature = "libass")] fn default_ass_font_provider() -> libc::c_int { - if cfg!(any(target_os = "ios", target_os = "android")) { + if cfg!(any( + target_os = "ios", + target_os = "tvos", + target_os = "android" + )) { ASS_FONTPROVIDER_NONE } else if cfg!(target_os = "macos") { ASS_FONTPROVIDER_CORETEXT diff --git a/crates/erika/tests/artcnn_upscaler.rs b/crates/erika/tests/artcnn_upscaler.rs index b7b6d72..2733ea9 100644 --- a/crates/erika/tests/artcnn_upscaler.rs +++ b/crates/erika/tests/artcnn_upscaler.rs @@ -2,7 +2,7 @@ //! onnxruntime reference outputs generated by //! `assets/artcnn/export_artcnn.py --test-vector`. -#![cfg(any(target_os = "macos", target_os = "ios"))] +#![cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] use std::ffi::c_void; use std::ptr::NonNull; diff --git a/crates/erika_capi/src/lib.rs b/crates/erika_capi/src/lib.rs index 275e287..6541182 100644 --- a/crates/erika_capi/src/lib.rs +++ b/crates/erika_capi/src/lib.rs @@ -12,7 +12,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; mod android_jni; #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -22,7 +22,7 @@ mod presenter_json; use crossbeam_channel::Receiver; #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -30,7 +30,7 @@ use crossbeam_channel::Receiver; use erika::LumaUpscalerBackendStatus; #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -41,7 +41,7 @@ use erika::danmaku::{ }; #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -49,7 +49,7 @@ use erika::danmaku::{ use erika::presenter::{PresenterConfig, PresenterRuntime, PresenterRuntimeSnapshot}; #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -61,7 +61,7 @@ use erika::renderer::output::{ }; #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -69,7 +69,7 @@ use erika::renderer::output::{ use erika::renderer::pipeline::LumaUpscalerMode; #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -658,7 +658,7 @@ pub struct ErikaHandle { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1081,7 +1081,7 @@ pub unsafe extern "C" fn erika_poll_event( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1098,7 +1098,7 @@ pub extern "C" fn erika_presenter_create() -> *mut ErikaPresenterHandle { #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1110,7 +1110,7 @@ pub extern "C" fn erika_presenter_create() -> *mut std::ffi::c_void { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1124,7 +1124,7 @@ pub extern "C" fn erika_presenter_create_with_config( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1143,7 +1143,7 @@ pub extern "C" fn erika_presenter_create_with_output_mode( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1157,7 +1157,7 @@ pub extern "C" fn erika_presenter_create_with_config( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1172,7 +1172,7 @@ pub extern "C" fn erika_presenter_create_with_output_mode( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1189,7 +1189,7 @@ pub unsafe extern "C" fn erika_presenter_open_with_headers( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1272,7 +1272,7 @@ fn report_capi_panic( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1310,7 +1310,7 @@ fn presenter_config_from_c(config: ErikaPresenterConfig) -> PresenterConfig { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1344,7 +1344,7 @@ fn subtitle_style_from_c(style: ErikaSubtitleStyle) -> Result LumaUpscalerMode { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1374,7 +1374,7 @@ fn luma_upscaler_mode_to_c(mode: LumaUpscalerMode) -> i32 { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1393,7 +1393,7 @@ fn upscaler_backend_status_to_c(status: LumaUpscalerBackendStatus) -> i32 { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1515,7 +1515,7 @@ fn danmaku_block_words_from_json(json: &str) -> Result, ErikaStatus> #[cfg(all( any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1528,7 +1528,7 @@ fn metal_output_mode_from_c(config: ErikaPresenterConfig) -> MetalOutputMode { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1542,7 +1542,7 @@ pub unsafe extern "C" fn erika_presenter_destroy(handle: *mut ErikaPresenterHand #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1552,7 +1552,7 @@ pub unsafe extern "C" fn erika_presenter_destroy(_handle: *mut std::ffi::c_void) #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1567,7 +1567,7 @@ pub unsafe extern "C" fn erika_presenter_open( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1606,7 +1606,7 @@ pub unsafe extern "C" fn erika_presenter_open_with_headers( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1625,7 +1625,7 @@ pub unsafe extern "C" fn erika_presenter_play(handle: *mut ErikaPresenterHandle) #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1644,7 +1644,7 @@ pub unsafe extern "C" fn erika_presenter_pause(handle: *mut ErikaPresenterHandle #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1658,7 +1658,7 @@ pub unsafe extern "C" fn erika_presenter_stop(handle: *mut ErikaPresenterHandle) #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1672,7 +1672,7 @@ pub unsafe extern "C" fn erika_presenter_close(handle: *mut ErikaPresenterHandle #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1700,7 +1700,7 @@ pub unsafe extern "C" fn erika_presenter_seek( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1717,7 +1717,7 @@ pub unsafe extern "C" fn erika_presenter_set_playback_rate( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1735,7 +1735,7 @@ pub unsafe extern "C" fn erika_presenter_set_volume( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1755,7 +1755,7 @@ pub unsafe extern "C" fn erika_presenter_set_upscaler( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1775,7 +1775,7 @@ pub unsafe extern "C" fn erika_presenter_set_subtitle_scale( /// clears that half of the selection and restores the platform default. #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1796,7 +1796,7 @@ pub unsafe extern "C" fn erika_presenter_set_subtitle_font( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1818,7 +1818,7 @@ pub unsafe extern "C" fn erika_presenter_set_subtitle_style( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1842,7 +1842,7 @@ pub unsafe extern "C" fn erika_presenter_set_output_headroom( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1864,7 +1864,7 @@ pub unsafe extern "C" fn erika_presenter_get_upscaler_status( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1886,7 +1886,7 @@ pub unsafe extern "C" fn erika_presenter_get_output_status( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1917,7 +1917,7 @@ pub unsafe extern "C" fn erika_presenter_add_external_subtitle( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1933,7 +1933,7 @@ pub unsafe extern "C" fn erika_presenter_add_external_subtitle( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1949,7 +1949,7 @@ pub unsafe extern "C" fn erika_presenter_set_output_headroom( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1966,7 +1966,7 @@ pub unsafe extern "C" fn erika_presenter_remove_subtitle_track( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -1987,7 +1987,7 @@ pub unsafe extern "C" fn erika_presenter_select_audio_track( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2008,7 +2008,7 @@ pub unsafe extern "C" fn erika_presenter_select_subtitle_track( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2038,7 +2038,7 @@ pub unsafe extern "C" fn erika_presenter_load_danmaku_file( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2068,7 +2068,7 @@ pub unsafe extern "C" fn erika_presenter_load_danmaku_json( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2111,7 +2111,7 @@ pub unsafe extern "C" fn erika_presenter_add_danmaku_track_file( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2151,7 +2151,7 @@ pub unsafe extern "C" fn erika_presenter_add_danmaku_track_json( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2172,7 +2172,7 @@ pub unsafe extern "C" fn erika_presenter_remove_danmaku_track( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2197,7 +2197,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_track_enabled( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2222,7 +2222,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_track_offset( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2240,7 +2240,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_global_offset( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2267,7 +2267,7 @@ pub unsafe extern "C" fn erika_presenter_danmaku_tracks( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2284,7 +2284,7 @@ pub unsafe extern "C" fn erika_presenter_clear_danmaku( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2302,7 +2302,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_enabled( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2320,7 +2320,7 @@ pub unsafe extern "C" fn erika_presenter_set_debug_hud_enabled( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2345,7 +2345,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_config( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2363,7 +2363,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_config_ptr( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2391,7 +2391,7 @@ pub unsafe extern "C" fn erika_presenter_get_danmaku_config( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2412,7 +2412,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_font( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2444,7 +2444,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_block_words_json( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2465,7 +2465,7 @@ pub unsafe extern "C" fn erika_presenter_track_selection( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2487,7 +2487,7 @@ pub unsafe extern "C" fn erika_presenter_tracks( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2502,7 +2502,7 @@ pub unsafe extern "C" fn erika_presenter_remove_subtitle_track( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2517,7 +2517,7 @@ pub unsafe extern "C" fn erika_presenter_select_audio_track( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2532,7 +2532,7 @@ pub unsafe extern "C" fn erika_presenter_select_subtitle_track( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2547,7 +2547,7 @@ pub unsafe extern "C" fn erika_presenter_track_selection( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2564,7 +2564,7 @@ pub unsafe extern "C" fn erika_presenter_tracks( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2579,7 +2579,7 @@ pub unsafe extern "C" fn erika_presenter_set_playback_rate( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2594,7 +2594,7 @@ pub unsafe extern "C" fn erika_presenter_set_volume( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2609,7 +2609,7 @@ pub unsafe extern "C" fn erika_presenter_set_upscaler( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2624,7 +2624,7 @@ pub unsafe extern "C" fn erika_presenter_set_subtitle_scale( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2640,7 +2640,7 @@ pub unsafe extern "C" fn erika_presenter_set_subtitle_font( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2655,7 +2655,7 @@ pub unsafe extern "C" fn erika_presenter_set_subtitle_style( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2673,7 +2673,7 @@ pub unsafe extern "C" fn erika_presenter_get_upscaler_status( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2691,7 +2691,7 @@ pub unsafe extern "C" fn erika_presenter_get_output_status( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2706,7 +2706,7 @@ pub unsafe extern "C" fn erika_presenter_load_danmaku_file( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2721,7 +2721,7 @@ pub unsafe extern "C" fn erika_presenter_load_danmaku_json( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2742,7 +2742,7 @@ pub unsafe extern "C" fn erika_presenter_add_danmaku_track_file( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2763,7 +2763,7 @@ pub unsafe extern "C" fn erika_presenter_add_danmaku_track_json( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2778,7 +2778,7 @@ pub unsafe extern "C" fn erika_presenter_remove_danmaku_track( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2794,7 +2794,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_track_enabled( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2810,7 +2810,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_track_offset( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2825,7 +2825,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_global_offset( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2845,7 +2845,7 @@ pub unsafe extern "C" fn erika_presenter_danmaku_tracks( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2859,7 +2859,7 @@ pub unsafe extern "C" fn erika_presenter_clear_danmaku( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2874,7 +2874,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_enabled( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2889,7 +2889,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_config( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2907,7 +2907,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_config_ptr( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2925,7 +2925,7 @@ pub unsafe extern "C" fn erika_presenter_get_danmaku_config( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2941,7 +2941,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_font( #[cfg(not(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2959,7 +2959,7 @@ pub unsafe extern "C" fn erika_presenter_set_danmaku_block_words_json( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -2984,7 +2984,7 @@ pub unsafe extern "C" fn erika_presenter_attach_metal_layer( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -3015,7 +3015,7 @@ pub unsafe extern "C" fn erika_presenter_attach_wgpu_surface( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -3046,7 +3046,7 @@ pub unsafe extern "C" fn erika_presenter_attach_wgpu_surface_with_output_capabil #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -3075,7 +3075,7 @@ pub unsafe extern "C" fn erika_presenter_attach_windows_hwnd( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -3094,7 +3094,7 @@ pub unsafe extern "C" fn erika_presenter_resize_surface( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -3110,7 +3110,7 @@ pub unsafe extern "C" fn erika_presenter_detach_surface( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -3138,7 +3138,7 @@ pub unsafe extern "C" fn erika_presenter_render_tick( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -3161,7 +3161,7 @@ pub unsafe extern "C" fn erika_presenter_get_stats( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -3201,7 +3201,7 @@ pub unsafe extern "C" fn erika_presenter_capture_frame_rgba( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -3230,7 +3230,7 @@ fn capture_presenter_frame_rgba( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -3315,7 +3315,7 @@ fn with_handle_mut( #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -3780,7 +3780,7 @@ fn duration_micros_u64(duration: Duration) -> u64 { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -3831,7 +3831,7 @@ fn presenter_stats_to_c(snapshot: PresenterRuntimeSnapshot) -> ErikaPresenterSta #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -4246,7 +4246,7 @@ mod tests { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -4264,7 +4264,7 @@ mod tests { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -4285,7 +4285,7 @@ mod tests { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -4312,7 +4312,7 @@ mod tests { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -4341,7 +4341,7 @@ mod tests { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -4379,7 +4379,7 @@ mod tests { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -4433,7 +4433,7 @@ mod tests { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -4451,7 +4451,7 @@ mod tests { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -4487,7 +4487,7 @@ mod tests { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -4534,7 +4534,7 @@ mod tests { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" @@ -4609,7 +4609,7 @@ mod tests { #[cfg(any( target_os = "macos", - target_os = "ios", + any(target_os = "ios", target_os = "tvos"), target_os = "windows", target_os = "android", target_env = "ohos" diff --git a/crates/erika_ffmpeg_sys/build.rs b/crates/erika_ffmpeg_sys/build.rs index b51d9a3..8aff2b5 100644 --- a/crates/erika_ffmpeg_sys/build.rs +++ b/crates/erika_ffmpeg_sys/build.rs @@ -19,6 +19,7 @@ fn main() { println!("cargo:rerun-if-env-changed=ANDROID_SDK_ROOT"); println!("cargo:rerun-if-env-changed=OHOS_NDK_HOME"); println!("cargo:rerun-if-env-changed=OHOS_SDK_NATIVE"); + println!("cargo:rerun-if-env-changed=TVOS_DEPLOYMENT_TARGET"); let dist_dir = ffmpeg_dist_dir(); let zlib_dir = native_dep_dir("ERIKA_ZLIB_DIR", "zlib"); @@ -36,7 +37,7 @@ fn main() { if matches!( env::var("CARGO_CFG_TARGET_OS").as_deref(), - Ok("android" | "macos" | "ios") + Ok("android" | "macos" | "ios" | "tvos") ) { for archive in [ "libavdevice.a", @@ -72,7 +73,7 @@ fn main() { } if matches!( env::var("CARGO_CFG_TARGET_OS").as_deref(), - Ok("android" | "macos" | "ios") + Ok("android" | "macos" | "ios" | "tvos") ) { let dav1d_header = dav1d_dir.join("include/dav1d/dav1d.h"); let dav1d_archive = dav1d_dir.join("lib/libdav1d.a"); @@ -98,7 +99,7 @@ fn main() { ); if matches!( env::var("CARGO_CFG_TARGET_OS").as_deref(), - Ok("android" | "macos" | "ios") + Ok("android" | "macos" | "ios" | "tvos") ) { println!( "cargo:rustc-link-search=native={}", @@ -114,7 +115,7 @@ fn main() { println!("cargo:rustc-link-lib=static=avutil"); if matches!( env::var("CARGO_CFG_TARGET_OS").as_deref(), - Ok("android" | "macos" | "ios") + Ok("android" | "macos" | "ios" | "tvos") ) { println!("cargo:rustc-link-lib=static=dav1d"); } @@ -126,7 +127,7 @@ fn main() { if matches!( env::var("CARGO_CFG_TARGET_OS").as_deref(), - Ok("macos" | "ios") + Ok("macos" | "ios" | "tvos") ) { println!("cargo:rustc-link-lib=framework=CoreFoundation"); println!("cargo:rustc-link-lib=framework=CoreMedia"); @@ -512,6 +513,11 @@ fn inferred_native_target() -> Option { ("android", "x86_64") => Some("x86_64-linux-android".to_string()), ("android", "x86") => Some("i686-linux-android".to_string()), ("ios", _) => Some("ios".to_string()), + ("tvos", "aarch64") if env::var("CARGO_CFG_TARGET_ABI").as_deref() == Ok("sim") => { + Some("aarch64-apple-tvos-sim".to_string()) + } + ("tvos", "aarch64") => Some("aarch64-apple-tvos".to_string()), + ("tvos", "x86_64") => Some("x86_64-apple-tvos".to_string()), _ => None, } } diff --git a/docs/building.ja.md b/docs/building.ja.md index e92a6c6..f99e9dd 100644 --- a/docs/building.ja.md +++ b/docs/building.ja.md @@ -25,6 +25,9 @@ xtask deps build ──▶ third_party/dist///{ffmpeg,dav1d,z - クロスターゲットでは対応する Rust std target を追加: `rustup target add aarch64-apple-ios` や `rustup target add x86_64-pc-windows-msvc`。 +- Rust では tvOS target は現在 tier 3 です。`rust-src` component 付きの + nightly を導入し、`rustup target add` ではなく Cargo の `-Z build-std` + を使用します。 ### ビルドツール —— macOS / Unix ホスト @@ -94,6 +97,9 @@ cargo run -p xtask -- deps build --all --profile lgpl | `aarch64-apple-ios` | iOS 実機 | | | `aarch64-apple-ios-sim` | iOS sim(Apple Silicon) | | | `x86_64-apple-ios` | iOS sim(Intel) | | +| `aarch64-apple-tvos` | tvOS 実機 | nightly + `rust-src` が必要。 | +| `aarch64-apple-tvos-sim` | tvOS sim(Apple Silicon) | nightly + `rust-src` が必要。 | +| `x86_64-apple-tvos` | tvOS sim(Intel) | nightly + `rust-src` が必要。 | | `x86_64-pc-windows-msvc`(または `windows-x64`) | Windows | FFmpeg で VideoToolbox を D3D11VA/DXVA2 に置換。 | | `aarch64-pc-windows-msvc`(または `windows-arm64`) | Windows ARM64 | ARM64 native host と x64 から ARM64 への cross build をサポート。 | | `aarch64-linux-android`(`arm64-v8a`) | Android arm64 | | @@ -102,8 +108,9 @@ cargo run -p xtask -- deps build --all --profile lgpl | `i686-linux-android`(`x86`) | Android x86 | Android 共有ライブラリで非 PIC 再配置を避けるため、x86 アセンブリ高速化を無効化。 | | `aarch64-unknown-linux-ohos`(`ohos-arm64`) | HarmonyOS arm64 | DevEco Studio の OpenHarmony Native SDK を使用。`OHOS_NDK_HOME` または `OHOS_SDK_NATIVE` と `aarch64-unknown-linux-ohos` Rust target が必要。 | -デプロイ最小バージョンは既定で macOS `11.0` / iOS `13.0`。 -`MACOSX_DEPLOYMENT_TARGET` / `IPHONEOS_DEPLOYMENT_TARGET` で上書き可能。 +デプロイ最小バージョンは既定で macOS `11.0` / iOS `13.0` / tvOS `13.0`。 +`MACOSX_DEPLOYMENT_TARGET` / `IPHONEOS_DEPLOYMENT_TARGET` / +`TVOS_DEPLOYMENT_TARGET` で上書き可能。 HarmonyOS build は `OHOS_NDK_HOME` または `OHOS_SDK_NATIVE` が指す DevEco Studio の `openharmony/native` ディレクトリを使い、その中の `aarch64-unknown-linux-ohos-clang` @@ -123,6 +130,19 @@ ERIKA_NATIVE_PROFILE=lgpl ERIKA_NATIVE_TARGET=aarch64-apple-darwin \ cargo build -p erika_capi --release --target aarch64-apple-darwin ``` +Rust の tvOS target は tier 3 のため、直接 build する場合は nightly と +`rust-src` から standard library を build します: + +```sh +rustup toolchain install nightly --component rust-src +cargo run -p xtask -- deps build --all --profile lgpl \ + --target aarch64-apple-tvos-sim +ERIKA_NATIVE_PROFILE=lgpl ERIKA_NATIVE_TARGET=aarch64-apple-tvos-sim \ + cargo +nightly rustc -Z build-std=std,panic_abort -p erika_capi --release \ + --target aarch64-apple-tvos-sim --no-default-features --features libass \ + --lib --crate-type staticlib +``` + Windows ARM64 では対応する PowerShell command を使います: ```powershell diff --git a/docs/building.md b/docs/building.md index 50d30ee..d08bb7e 100644 --- a/docs/building.md +++ b/docs/building.md @@ -24,6 +24,8 @@ xtask deps build ──▶ third_party/dist///{ffmpeg,dav1d,z - For cross-targets, add the Rust std target, e.g. `rustup target add aarch64-apple-ios` or `rustup target add x86_64-pc-windows-msvc`. +- Rust currently treats tvOS targets as tier 3. Install nightly with `rust-src`; + tvOS builds use Cargo's `-Z build-std` instead of `rustup target add`. ### Build tools — macOS / Unix host @@ -109,6 +111,9 @@ Subcommands: `plan` (print the plan), `fetch` (download sources only), | `aarch64-apple-ios` | iOS device | | | `aarch64-apple-ios-sim` | iOS sim (Apple Silicon) | | | `x86_64-apple-ios` | iOS sim (Intel) | | +| `aarch64-apple-tvos` | tvOS device | Requires nightly + `rust-src`. | +| `aarch64-apple-tvos-sim` | tvOS sim (Apple Silicon) | Requires nightly + `rust-src`. | +| `x86_64-apple-tvos` | tvOS sim (Intel) | Requires nightly + `rust-src`. | | `x86_64-pc-windows-msvc` (or `windows-x64`) | Windows | Swaps VideoToolbox for D3D11VA/DXVA2 in FFmpeg. | | `aarch64-pc-windows-msvc` (or `windows-arm64`) | Windows ARM64 | Supports native ARM64 hosts and x64-to-ARM64 cross builds. | | `aarch64-linux-android` (or `arm64-v8a`) | Android arm64 | Flutter/Play primary device ABI. | @@ -117,8 +122,9 @@ Subcommands: `plan` (print the plan), `fetch` (download sources only), | `i686-linux-android` (or `x86`) | Android x86 | 32-bit emulator compatibility ABI; x86 assembly acceleration is disabled because Android shared libraries cannot contain its non-PIC relocations. | | `aarch64-unknown-linux-ohos` (or `ohos-arm64`) | HarmonyOS arm64 | Uses the DevEco Studio OpenHarmony Native SDK. | -Deployment minimums default to macOS `11.0` / iOS `13.0` and can be overridden -with `MACOSX_DEPLOYMENT_TARGET` / `IPHONEOS_DEPLOYMENT_TARGET`. +Deployment minimums default to macOS `11.0` / iOS `13.0` / tvOS `13.0` and can +be overridden with `MACOSX_DEPLOYMENT_TARGET` / +`IPHONEOS_DEPLOYMENT_TARGET` / `TVOS_DEPLOYMENT_TARGET`. Android builds use `android-26`, `c++_shared`, PIC static dependencies, and the NDK LLVM toolchain selected for the requested ABI. @@ -144,6 +150,19 @@ ERIKA_NATIVE_PROFILE=lgpl ERIKA_NATIVE_TARGET=aarch64-apple-darwin \ cargo build -p erika_capi --release --target aarch64-apple-darwin ``` +Because Rust's tvOS targets are tier 3, direct tvOS builds use nightly and build +the standard library from `rust-src`: + +```sh +rustup toolchain install nightly --component rust-src +cargo run -p xtask -- deps build --all --profile lgpl \ + --target aarch64-apple-tvos-sim +ERIKA_NATIVE_PROFILE=lgpl ERIKA_NATIVE_TARGET=aarch64-apple-tvos-sim \ + cargo +nightly rustc -Z build-std=std,panic_abort -p erika_capi --release \ + --target aarch64-apple-tvos-sim --no-default-features --features libass \ + --lib --crate-type staticlib +``` + On Windows ARM64, use the equivalent PowerShell commands: ```powershell diff --git a/docs/building.zh.md b/docs/building.zh.md index e14c88b..d830b21 100644 --- a/docs/building.zh.md +++ b/docs/building.zh.md @@ -22,6 +22,8 @@ xtask deps build ──▶ third_party/dist///{ffmpeg,dav1d,z - 交叉目标需安装对应 Rust std target,如 `rustup target add aarch64-apple-ios` 或 `rustup target add x86_64-pc-windows-msvc`。 +- Rust 当前将 tvOS 目标列为 tier 3。请安装带 `rust-src` 的 nightly;tvOS + 通过 Cargo 的 `-Z build-std` 构建,不使用 `rustup target add`。 ### 构建工具 —— macOS / Unix 宿主 @@ -98,6 +100,9 @@ cargo run -p xtask -- deps build --all --profile lgpl | `aarch64-apple-ios` | iOS 设备 | | | `aarch64-apple-ios-sim` | iOS 模拟器(Apple Silicon) | | | `x86_64-apple-ios` | iOS 模拟器(Intel) | | +| `aarch64-apple-tvos` | tvOS 设备 | 需要 nightly + `rust-src`。 | +| `aarch64-apple-tvos-sim` | tvOS 模拟器(Apple Silicon) | 需要 nightly + `rust-src`。 | +| `x86_64-apple-tvos` | tvOS 模拟器(Intel) | 需要 nightly + `rust-src`。 | | `x86_64-pc-windows-msvc`(或 `windows-x64`) | Windows | FFmpeg 里把 VideoToolbox 换成 D3D11VA/DXVA2。 | | `aarch64-pc-windows-msvc`(或 `windows-arm64`) | Windows ARM64 | 支持 ARM64 原生宿主和 x64 到 ARM64 交叉构建。 | | `aarch64-linux-android`(或 `arm64-v8a`) | Android arm64 | 主流真机 ABI。 | @@ -106,8 +111,9 @@ cargo run -p xtask -- deps build --all --profile lgpl | `i686-linux-android`(或 `x86`) | Android x86 | 32 位模拟器兼容;Android 共享库不允许对应的非 PIC 重定位,因此禁用 x86 汇编加速。 | | `aarch64-unknown-linux-ohos`(或 `ohos-arm64`) | HarmonyOS arm64 | 使用 DevEco Studio OpenHarmony Native SDK。 | -部署最低版本默认 macOS `11.0` / iOS `13.0`,可用 -`MACOSX_DEPLOYMENT_TARGET` / `IPHONEOS_DEPLOYMENT_TARGET` 覆盖。 +部署最低版本默认 macOS `11.0` / iOS `13.0` / tvOS `13.0`,可用 +`MACOSX_DEPLOYMENT_TARGET` / `IPHONEOS_DEPLOYMENT_TARGET` / +`TVOS_DEPLOYMENT_TARGET` 覆盖。 Android 使用 `android-26`、`c++_shared`、PIC 静态依赖和所选 ABI 对应的 NDK LLVM 工具链。 @@ -130,6 +136,19 @@ ERIKA_NATIVE_PROFILE=lgpl ERIKA_NATIVE_TARGET=aarch64-apple-darwin \ cargo build -p erika_capi --release --target aarch64-apple-darwin ``` +Rust 的 tvOS 目标属于 tier 3,直接构建时需要 nightly 并从 `rust-src` +构建标准库: + +```sh +rustup toolchain install nightly --component rust-src +cargo run -p xtask -- deps build --all --profile lgpl \ + --target aarch64-apple-tvos-sim +ERIKA_NATIVE_PROFILE=lgpl ERIKA_NATIVE_TARGET=aarch64-apple-tvos-sim \ + cargo +nightly rustc -Z build-std=std,panic_abort -p erika_capi --release \ + --target aarch64-apple-tvos-sim --no-default-features --features libass \ + --lib --crate-type staticlib +``` + Windows ARM64 使用对应的 PowerShell 命令: ```powershell diff --git a/packages/erika_flutter/README.ja.md b/packages/erika_flutter/README.ja.md index 87bb276..db9a5e7 100644 --- a/packages/erika_flutter/README.ja.md +++ b/packages/erika_flutter/README.ja.md @@ -7,9 +7,10 @@ Erika メディア再生エンジン向けの Flutter plugin です。 この plugin は Dart を hot path から外します。 - Dart は低頻度の player command と event stream だけを公開します。 -- native plugin は 2 種類の surface を提供します。推奨は `ErikaWindowOverlayVideoView`(macOS/iOS は Metal、Windows は D3D11 swapchain)、platform view 用は `ErikaVideoView` です。Android では両方が同じ native-view selector を使い、SDR は実体のある `TextureView`、extended-linear request は Hybrid Composition `SurfaceView` になります。 +- native plugin は 2 種類の surface を提供します。推奨は `ErikaWindowOverlayVideoView`(macOS/iOS/tvOS は Metal、Windows は D3D11 swapchain)、platform view 用は `ErikaVideoView` です。Android では両方が同じ native-view selector を使い、SDR は実体のある `TextureView`、extended-linear request は Hybrid Composition `SurfaceView` になります。 - macOS plugin は Erika の dynamic library を読み込みます。 - iOS plugin は Erika の static library を link します。 +- tvOS plugin は Erika の static library を link し、Apple TV platform view で Metal layer を host します。 - Windows plugin は Erika C ABI DLL を build して link します。 - Android plugin は ABI ごとに `liberika_capi.so` を build し、`Choreographer` から native surface を駆動します。 - HarmonyOS plugin は Flutter external texture を登録し、その `OHNativeWindow` を Erika に attach して、OHAudio で低レイテンシの PCM 出力を行います。 @@ -17,7 +18,7 @@ Erika メディア再生エンジン向けの Flutter plugin です。 ## Video Surfaces -フルプレイヤーの macOS/iOS UI では `ErikaWindowOverlayVideoView` を使うのが推奨です。Flutter の layout では矩形領域を予約しつつ、plugin が横に native `CAMetalLayer` を持ち、video を Flutter platform-view compositor の外に置きます。 +フルプレイヤーの macOS/iOS/tvOS UI では `ErikaWindowOverlayVideoView` を使うのが推奨です。Flutter の layout では矩形領域を予約しつつ、plugin が横に native `CAMetalLayer` を持ち、video を Flutter platform-view compositor の外に置きます。 Windows では `ErikaWindowOverlayVideoView` が window-level の Direct3D 11 swapchain を sibling surface として host し、同じ overlay モデルに従います。 @@ -46,6 +47,18 @@ iOS の CocoaPod script phase が、Xcode build 中に Erika の native dependen - `rustup target add aarch64-apple-ios` +## tvOS Setup + +tvOS の CocoaPod script phase が、Apple TV 実機または simulator 向けの native +dependency と C ABI static library を Xcode build 中に自動 build します。Rust の +tvOS target は tier 3 のため、source component 付き nightly が必要です: + +- `rustup toolchain install nightly --component rust-src` + +script は現在の Xcode SDK と architecture から `aarch64-apple-tvos`、 +`aarch64-apple-tvos-sim`、または `x86_64-apple-tvos` を選び、 +`-Z build-std=std,panic_abort` で compile します。 + ## Windows Setup Windows plugin(`ErikaFlutterPluginCApi`)は CMake build 中に `build_erika_runtime.cmake` で Erika C ABI runtime(`erika_capi.dll`)を build し、CMake generator の x64 または ARM64 architecture に自動追従して DLL を app の隣に配置します。依存 project は CMake cache の `ERIKA_WINDOWS_ARCH=x64|arm64` または環境変数 `ERIKA_WINDOWS_ARCH` で明示的に選択できます。高度な用途では `ERIKA_NATIVE_TARGET=x86_64-pc-windows-msvc|aarch64-pc-windows-msvc` も指定できます。必要なもの: diff --git a/packages/erika_flutter/README.md b/packages/erika_flutter/README.md index 48bc59d..814d3dd 100644 --- a/packages/erika_flutter/README.md +++ b/packages/erika_flutter/README.md @@ -6,13 +6,15 @@ The plugin keeps Dart out of the hot path: - Dart exposes low-frequency player commands and event streams. - The native plugins expose two surface strategies: `ErikaWindowOverlayVideoView` - for the recommended window-hosted overlay path (Metal on macOS/iOS, a D3D11 + for the recommended window-hosted overlay path (Metal on macOS/iOS/tvOS, a D3D11 swapchain on Windows), and `ErikaVideoView` for platform-view embedding. On Android both widgets route through the same native-view selector: SDR uses a real `TextureView`, while requested extended-linear output uses a `SurfaceView` with Hybrid Composition. - The macOS plugin loads the Erika dynamic library. - The iOS plugin links the Erika static library. +- The tvOS plugin links the Erika static library and hosts its Metal layer in an + Apple TV platform view. - The Windows plugin builds and links the Erika C ABI DLL. - The Android plugin builds `liberika_capi.so` per ABI and drives its native surface from `Choreographer`. @@ -23,7 +25,7 @@ The plugin keeps Dart out of the hot path: ## Video Surfaces -Use `ErikaWindowOverlayVideoView` for full-player macOS/iOS UIs. It reserves a +Use `ErikaWindowOverlayVideoView` for full-player macOS/iOS/tvOS UIs. It reserves a Flutter layout rect while the plugin hosts a sibling native `CAMetalLayer`, so video stays outside Flutter's platform-view compositor. @@ -77,6 +79,18 @@ static library automatically during Xcode builds. Requirements: - Rust toolchain with the appropriate iOS target (`rustup target add aarch64-apple-ios`) +## tvOS Setup + +The tvOS CocoaPod script phase builds the native dependencies and C ABI static +library automatically for Apple TV devices and simulators. Rust's tvOS targets +are tier 3, so install nightly with its source component: + +- `rustup toolchain install nightly --component rust-src` + +The script selects `aarch64-apple-tvos`, `aarch64-apple-tvos-sim`, or +`x86_64-apple-tvos` from the active Xcode SDK and architecture, then compiles it +with `-Z build-std=std,panic_abort`. + ## Windows Setup The Windows plugin (`ErikaFlutterPluginCApi`) builds the Erika C ABI runtime diff --git a/packages/erika_flutter/README.zh.md b/packages/erika_flutter/README.zh.md index 4d9ae05..8a25e6b 100644 --- a/packages/erika_flutter/README.zh.md +++ b/packages/erika_flutter/README.zh.md @@ -7,9 +7,10 @@ Erika 媒体播放引擎的 Flutter plugin。 插件让 Dart 不进入热路径: - Dart 只暴露低频播放器命令和事件流。 -- 原生插件提供两种 surface:推荐的 `ErikaWindowOverlayVideoView`(macOS/iOS 为 Metal,Windows 为 D3D11 swapchain),以及 platform view 用的 `ErikaVideoView`。Android 上两者都通过同一套原生 view 选择器:SDR 使用真实 `TextureView`,请求 extended-linear 时使用 Hybrid Composition `SurfaceView`。 +- 原生插件提供两种 surface:推荐的 `ErikaWindowOverlayVideoView`(macOS/iOS/tvOS 为 Metal,Windows 为 D3D11 swapchain),以及 platform view 用的 `ErikaVideoView`。Android 上两者都通过同一套原生 view 选择器:SDR 使用真实 `TextureView`,请求 extended-linear 时使用 Hybrid Composition `SurfaceView`。 - macOS 插件加载 Erika 动态库。 - iOS 插件链接 Erika 静态库。 +- tvOS 插件链接 Erika 静态库,并在 Apple TV platform view 中承载 Metal layer。 - Windows 插件构建并链接 Erika C ABI DLL。 - Android 插件按 ABI 构建 `liberika_capi.so`,并由 `Choreographer` 驱动原生 surface。 - HarmonyOS 插件注册 Flutter 外部纹理,把它的 `OHNativeWindow` attach 给 Erika,并用 OHAudio 做低延迟 PCM 输出。 @@ -17,7 +18,7 @@ Erika 媒体播放引擎的 Flutter plugin。 ## Video Surfaces -全播放器 macOS/iOS UI 推荐使用 `ErikaWindowOverlayVideoView`。它会在 Flutter 布局中预留矩形区域,同时插件在旁边托管一个原生 `CAMetalLayer`,让视频保持在 Flutter platform-view compositor 之外。 +全播放器 macOS/iOS/tvOS UI 推荐使用 `ErikaWindowOverlayVideoView`。它会在 Flutter 布局中预留矩形区域,同时插件在旁边托管一个原生 `CAMetalLayer`,让视频保持在 Flutter platform-view compositor 之外。 Windows 上 `ErikaWindowOverlayVideoView` 以 sibling surface 的形式托管一个 window-level Direct3D 11 swapchain,遵循同样的 overlay 模型。 @@ -46,6 +47,18 @@ iOS CocoaPod script phase 会在 Xcode 构建期间自动构建 Erika 原生依 - `rustup target add aarch64-apple-ios` +## tvOS Setup + +tvOS CocoaPod script phase 会在 Xcode 构建期间自动为 Apple TV 真机或模拟器构建 +原生依赖和 C ABI 静态库。Rust 的 tvOS 目标属于 tier 3,因此需要安装带源码组件的 +nightly: + +- `rustup toolchain install nightly --component rust-src` + +脚本会根据当前 Xcode SDK 与架构选择 `aarch64-apple-tvos`、 +`aarch64-apple-tvos-sim` 或 `x86_64-apple-tvos`,并通过 +`-Z build-std=std,panic_abort` 完成编译。 + ## Windows Setup Windows 插件(`ErikaFlutterPluginCApi`)在 CMake 构建期间通过 `build_erika_runtime.cmake` 构建 Erika C ABI runtime(`erika_capi.dll`),自动跟随 CMake 的 x64 或 ARM64 生成器架构,并把 DLL 部署到 app 旁边。依赖项目也可通过 CMake cache `ERIKA_WINDOWS_ARCH=x64|arm64` 或环境变量 `ERIKA_WINDOWS_ARCH` 显式选择;高级场景可直接设置 `ERIKA_NATIVE_TARGET=x86_64-pc-windows-msvc|aarch64-pc-windows-msvc`。需要: diff --git a/packages/erika_flutter/pubspec.yaml b/packages/erika_flutter/pubspec.yaml index 0ee154a..74c63a1 100644 --- a/packages/erika_flutter/pubspec.yaml +++ b/packages/erika_flutter/pubspec.yaml @@ -24,6 +24,8 @@ flutter: pluginClass: ErikaFlutterPlugin ios: pluginClass: ErikaFlutterPlugin + tvos: + pluginClass: ErikaFlutterPlugin macos: pluginClass: ErikaFlutterPlugin windows: diff --git a/packages/erika_flutter/tvos/Classes/ErikaFlutterPlugin.swift b/packages/erika_flutter/tvos/Classes/ErikaFlutterPlugin.swift new file mode 100644 index 0000000..e6f2de3 --- /dev/null +++ b/packages/erika_flutter/tvos/Classes/ErikaFlutterPlugin.swift @@ -0,0 +1,2442 @@ +import Darwin +import AVFoundation +import Flutter +import Metal +import ObjectiveC.runtime +import QuartzCore +import UIKit + +private let erikaWindowHostedVideoSurfaceId: Int64 = -1 +private let erikaDebugLabelsEnabled = + ProcessInfo.processInfo.environment["ERIKA_DEBUG_LABELS"] == "1" + +private func erikaHdrWrite(_ message: String) { + fputs("ErikaHDR[tvOS]: \(message)\n", stderr) + fflush(stderr) +} + +private func erikaHdrLog(_ enabled: Bool, _ message: String) { + if enabled { + erikaHdrWrite(message) + } +} + +private func erikaHdrEnvironmentEnabled() -> Bool { + guard let value = ProcessInfo.processInfo.environment["ERIKA_HDR_DEBUG"] else { + return false + } + switch value.lowercased() { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +private func erikaOutputModeLabel(_ config: ErikaPresenterConfigC) -> String { + config.outputMode == 1 + ? String(format: "AppleEdr(headroom=%.2f)", config.edrHeadroom) + : "Sdr" +} + +private func erikaScreenSummary(_ screen: UIScreen?) -> String { + guard let screen else { + return "screen=nil" + } + var parts = [ + "scale=\(screen.scale)", + "nativeScale=\(screen.nativeScale)", + "gamut=\(screen.traitCollection.displayGamut.rawValue)", + ] + if #available(tvOS 16.0, *) { + parts.append("currentEDR=\(String(format: "%.3f", screen.currentEDRHeadroom))") + parts.append("potentialEDR=\(String(format: "%.3f", screen.potentialEDRHeadroom))") + } + return parts.joined(separator: " ") +} + +private func erikaLayerValue(_ layer: CAMetalLayer, selector name: String) -> String { + let selector = Selector(name) + guard layer.responds(to: selector) else { + return "unavailable" + } + return String(describing: layer.value(forKey: name) ?? "nil") +} + +private func erikaConfigureLayerDynamicRange(_ layer: CAMetalLayer, config: ErikaPresenterConfigC) { + if config.outputMode == 1 { + layer.contentsFormat = .RGBA16Float + if #available(tvOS 18.0, *) { + layer.toneMapMode = .ifSupported + } + if #available(tvOS 26.0, *) { + layer.preferredDynamicRange = .high + layer.contentsHeadroom = CGFloat(max(config.edrHeadroom, 1.0)) + } + } else { + layer.contentsFormat = .RGBA8Uint + if #available(tvOS 18.0, *) { + layer.toneMapMode = .automatic + } + if #available(tvOS 26.0, *) { + layer.preferredDynamicRange = .standard + layer.contentsHeadroom = 0.0 + } + } +} + +private func erikaLayerSummary(_ layer: CAMetalLayer) -> String { + let wantsEDR = "unavailable" + let toneMapMode: String + if #available(tvOS 18.0, *) { + toneMapMode = String(describing: layer.toneMapMode) + } else { + toneMapMode = "unavailable" + } + let preferredDynamicRange: String + if #available(tvOS 26.0, *) { + preferredDynamicRange = String(describing: layer.preferredDynamicRange) + } else { + preferredDynamicRange = "unavailable" + } + let contentsHeadroom: String + if #available(tvOS 26.0, *) { + contentsHeadroom = String(format: "%.3f", layer.contentsHeadroom) + } else { + contentsHeadroom = "unavailable" + } + let colorSpace = layer.colorspace?.name.map { String(describing: $0) } ?? "nil" + return [ + "pixelFormat=\(layer.pixelFormat.rawValue)", + "drawable=\(Int(layer.drawableSize.width))x\(Int(layer.drawableSize.height))", + "framebufferOnly=\(layer.framebufferOnly)", + "opaque=\(layer.isOpaque)", + "contentsFormat=\(layer.contentsFormat)", + "wantsEDR=\(wantsEDR)", + "toneMapMode=\(toneMapMode)", + "preferredDynamicRange=\(preferredDynamicRange)", + "contentsHeadroom=\(contentsHeadroom)", + "edrMetadata=\(erikaLayerValue(layer, selector: "EDRMetadata"))", + "colorspace=\(colorSpace)", + ].joined(separator: " ") +} + +private struct ErikaTrackSelectionC { + var video: Int64 = -1 + var audio: Int64 = -1 + var subtitle: Int64 = -1 +} + +private struct ErikaVideoParamsC { + var width: UInt32 = 0 + var height: UInt32 = 0 + var primaries: UInt32 = 0 + var transfer: UInt32 = 0 +} + +private struct ErikaTrackCountsC { + var video: UInt32 = 0 + var audio: UInt32 = 0 + var subtitle: UInt32 = 0 +} + +private struct ErikaTrackInfoC { + var id: Int64 = -1 + var kind: Int32 = 0 + var source: Int32 = 0 + var selected: UInt8 = 0 + var canRemove: UInt8 = 0 + var title: UnsafeMutablePointer? + var language: UnsafeMutablePointer? + var codec: UnsafeMutablePointer? + var width: UInt32 = 0 + var height: UInt32 = 0 + var sampleRate: UInt32 = 0 + var channels: UInt32 = 0 + var pixelFormat: UnsafeMutablePointer? + var sampleFormat: UnsafeMutablePointer? + var profile: UnsafeMutablePointer? + var level: Int32 = 0 + var bitRate: UInt64 = 0 + var frameRateNumerator: UInt32 = 0 + var frameRateDenominator: UInt32 = 0 +} + +private struct ErikaPresenterConfigC { + var outputMode: Int32 = 0 + var edrHeadroom: Float = 1.0 + + static let sdr = ErikaPresenterConfigC() + + static func appleEdr(headroom: Float) -> ErikaPresenterConfigC { + ErikaPresenterConfigC(outputMode: 1, edrHeadroom: max(1.0, headroom)) + } +} + +private struct ErikaSubtitleStyleC { + var fontFamily: UnsafePointer? + var fontFilePath: UnsafePointer? + var primaryColorRgba: UInt32 + var outlineColorRgba: UInt32 + var fontSize: Double + var outlineWidth: Double + var bold: Bool + var italic: Bool + var underline: Bool + var strikeOut: Bool + var spacing: Double + var scaleXPercent: Double + var scaleYPercent: Double + var borderStyle: Int32 + var shadowDepth: Double + var blur: Double + var alignment: Int32 + var marginLeft: Int32 + var marginRight: Int32 + var marginVertical: Int32 + var overrideMask: UInt32 +} + +private struct ErikaHttpHeader { + var name: UnsafeMutablePointer? + var value: UnsafeMutablePointer? +} + +private struct ErikaEventC { + var kind: Int32 = 0 + var status: Int32 = 0 + var state: Int32 = 0 + var durationMicros: Int64 = -1 + var positionMicros: UInt64 = 0 + var buffering: UInt8 = 0 + var video: ErikaVideoParamsC = ErikaVideoParamsC() + var tracks: ErikaTrackCountsC = ErikaTrackCountsC() +} + +private struct ErikaPresenterStatsC { + var decodedVideoFrames: UInt64 = 0 + var renderedVideoFrames: UInt64 = 0 + var renderedTestFrames: UInt64 = 0 + var pushedAudioFrames: UInt64 = 0 + var overlayFrames: UInt64 = 0 + var danmakuFrames: UInt64 = 0 + var danmakuItems: UInt64 = 0 + var importFailures: UInt64 = 0 + var renderFailures: UInt64 = 0 + var audioFailures: UInt64 = 0 + // The fields below must mirror `ErikaPresenterStats` in + // crates/erika_capi/include/erika.h exactly (order and types). erika_presenter_render_tick + // writes the full struct through this pointer, so any missing field overflows the buffer. + var softwareVideoFrames: UInt64 = 0 + var hardwareVideoFrames: UInt64 = 0 + var zeroCopyVideoFrames: UInt64 = 0 + var cpuVideoFrameFallbacks: UInt64 = 0 + var lastRenderMicros: UInt64 = 0 + var lastRenderCurrentMicros: UInt64 = 0 + var audioClockReadFrames: UInt64 = 0 + var audioClockQueuedFrames: UInt64 = 0 + var audioClockUnderflowFrames: UInt64 = 0 + var audioRecoveryState: Int32 = 0 + var audioLastErrorCode: Int32 = 0 + var audioRecoveryAttempts: UInt64 = 0 + var audioRecoveryCount: UInt64 = 0 + var audioRecoveryFailures: UInt64 = 0 + var directZeroCopyVideoFrames: UInt64 = 0 + var sharedHandleVideoFrames: UInt64 = 0 + var hdrSourceFrames: UInt64 = 0 + var hdr10OutputFrames: UInt64 = 0 + var sdrTonemapFrames: UInt64 = 0 + var hdr10MetadataUpdates: UInt64 = 0 + var hdr10MetadataFailures: UInt64 = 0 + var hdr10OutputFailures: UInt64 = 0 + var hdr10OutputActive: Bool = false + var videoFrameBackpressureDrops: UInt64 = 0 +} + +private struct ErikaUpscalerStatusC { + var requestedMode: Int32 = 0 + var activeBackend: Int32 = 0 + var fallbackCount: UInt64 = 0 + var upscaledFrames: UInt64 = 0 + var lastEncodeMicros: UInt64 = 0 + var lastGpuMicros: UInt64 = 0 +} + +// Keep field order and types aligned with `ErikaOutputStatus` in erika.h. +private struct ErikaOutputStatusC { + var requestedMode: Int32 = 0 + var activeEncoding: Int32 = 0 + var surfaceFormat: Int32 = 0 + var nativeDataSpace: Int32 = 0 + var requestedHeadroom: Float = 1.0 + var activeHeadroom: Float = 1.0 + var activeHeadroomKnown: Bool = false + var extendedLinearActive: Bool = false + var fallbackReason: Int32 = 0 + var fallbackCount: UInt64 = 0 + var dataSpaceFailures: UInt64 = 0 + var headroomUpdates: UInt64 = 0 + var extendedLinearFrames: UInt64 = 0 +} + +private let erikaDefaultDisplayFps = 60 + +private struct ErikaDanmakuConfigC { + var enabled: UInt8 = 1 + var fontSize: Float = 30.0 + var opacity: Float = 1.0 + var displayArea: Float = 1.0 + var scrollDurationSeconds: Float = 10.0 + var scrollSpeedFactor: Float = 1.0 + var trackGapRatio: Float = 0.15 + var outlineWidth: Float = 1.0 + var shadowOffsetX: Float = 1.0 + var shadowOffsetY: Float = 1.0 + var mergeDuplicates: UInt8 = 0 + var allowStacking: UInt8 = 0 + var allowScrollOverwrite: UInt8 = 1 + var maxQuantity: UInt32 = 0 + var maxLinesPerMode: UInt32 = 0 + var blockTop: UInt8 = 0 + var blockBottom: UInt8 = 0 + var blockScroll: UInt8 = 0 + var shadowStyle: Int32 = 3 +} + +private struct ErikaDanmakuTrackInfoC { + var id: UInt64 = 0 + var enabled: UInt8 = 0 + var offsetMicros: Int64 = 0 + var itemCount: Int = 0 + var name: UnsafeMutablePointer? + var source: UnsafeMutablePointer? +} + +private enum ErikaPluginError: Error, CustomStringConvertible { + case libraryNotFound([String]) + case symbolMissing(String) + case httpHeadersUnsupported + case invalidArguments(String) + case playerNotFound(Int64) + case viewNotFound(Int64) + case overlayNotAvailable + case presenterCreateFailed + case erikaStatus(String, Int32, String?) + case libraryLoadFailed(String, String?) + + var description: String { + switch self { + case .libraryNotFound(let paths): + return "Unable to load Erika C ABI. Tried: \(paths.joined(separator: ", "))" + case .symbolMissing(let symbol): + return "Missing Erika C ABI symbol: \(symbol)" + case .httpHeadersUnsupported: + return "The loaded Erika native library does not export erika_presenter_open_with_headers, so httpHeaders cannot be applied. Update the bundled native library (a prebuilt from 0.1.3 or earlier predates HTTP header support)." + case .invalidArguments(let message): + return message + case .playerNotFound(let playerId): + return "Erika player \(playerId) was not found." + case .viewNotFound(let viewId): + return "Erika video view \(viewId) was not found." + case .overlayNotAvailable: + return "No window-hosted Erika overlay is available." + case .presenterCreateFailed: + return "erika_presenter_create returned null." + case .erikaStatus(let operation, let status, let detail): + if let detail, !detail.isEmpty { + return "\(operation) failed with ErikaStatus \(status): \(detail)" + } + return "\(operation) failed with ErikaStatus \(status)." + case .libraryLoadFailed(let path, let detail): + if let detail, !detail.isEmpty { + return "\(path) (\(detail))" + } + return path + } + } +} + +private final class ErikaNativeLibrary { + typealias CreateFn = @convention(c) () -> UnsafeMutableRawPointer? + typealias CreateWithOutputModeFn = @convention(c) (Int32, Float) -> UnsafeMutableRawPointer? + typealias DestroyFn = @convention(c) (UnsafeMutableRawPointer?) -> Void + typealias OpenFn = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?) -> Int32 + typealias OpenWithHeadersFn = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UnsafeRawPointer?, UInt) -> Int32 + typealias CommandFn = @convention(c) (UnsafeMutableRawPointer?) -> Int32 + typealias SeekFn = @convention(c) (UnsafeMutableRawPointer?, UInt64) -> Int32 + typealias SetPlaybackRateFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 + typealias SetVolumeFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 + typealias SetUpscalerFn = @convention(c) (UnsafeMutableRawPointer?, Int32) -> Int32 + typealias SetSubtitleScaleFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 + typealias SetSubtitleFontFn = @convention(c) ( + UnsafeMutableRawPointer?, + UnsafePointer?, + UnsafePointer? + ) -> Int32 + typealias SetSubtitleStyleFn = @convention(c) ( + UnsafeMutableRawPointer?, + UnsafeRawPointer? + ) -> Int32 + typealias GetUpscalerStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias GetOutputStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias SelectTrackFn = @convention(c) (UnsafeMutableRawPointer?, Int64) -> Int32 + typealias AddExternalSubtitleFn = @convention(c) ( + UnsafeMutableRawPointer?, + UnsafePointer?, + UnsafeMutablePointer? + ) -> Int32 + typealias RemoveSubtitleTrackFn = @convention(c) (UnsafeMutableRawPointer?, Int64) -> Int32 + typealias LoadDanmakuFn = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?) -> Int32 + typealias AddDanmakuTrackFn = @convention(c) ( + UnsafeMutableRawPointer?, + UnsafePointer?, + UnsafePointer?, + Int64, + UnsafeMutablePointer? + ) -> Int32 + typealias ClearDanmakuFn = @convention(c) (UnsafeMutableRawPointer?) -> Int32 + typealias SetDanmakuEnabledFn = @convention(c) (UnsafeMutableRawPointer?, Bool) -> Int32 + typealias SetDebugHudEnabledFn = @convention(c) (UnsafeMutableRawPointer?, Bool) -> Int32 + typealias SetDanmakuConfigFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeRawPointer?) -> Int32 + typealias GetDanmakuConfigFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias SetDanmakuFontFn = @convention(c) ( + UnsafeMutableRawPointer?, + UnsafePointer?, + UnsafePointer? + ) -> Int32 + typealias SetDanmakuBlockWordsFn = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?) -> Int32 + typealias RemoveDanmakuTrackFn = @convention(c) (UnsafeMutableRawPointer?, UInt64) -> Int32 + typealias SetDanmakuTrackEnabledFn = @convention(c) (UnsafeMutableRawPointer?, UInt64, Bool) -> Int32 + typealias SetDanmakuTrackOffsetFn = @convention(c) (UnsafeMutableRawPointer?, UInt64, Int64) -> Int32 + typealias SetDanmakuGlobalOffsetFn = @convention(c) (UnsafeMutableRawPointer?, Int64) -> Int32 + typealias TrackSelectionFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias TracksFn = @convention(c) ( + UnsafeMutableRawPointer?, + UnsafeMutableRawPointer?, + Int, + UnsafeMutablePointer? + ) -> Int32 + typealias TrackInfoFreeFn = @convention(c) (UnsafeMutableRawPointer?) -> Void + typealias DanmakuTrackInfoFreeFn = @convention(c) (UnsafeMutableRawPointer?) -> Void + 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 CaptureFrameRgbaFn = @convention(c) (UnsafeMutableRawPointer?, UInt32, UInt32, UnsafeMutableRawPointer?, Int) -> Int32 + typealias PollEventFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias LastErrorMessageFn = @convention(c) () -> UnsafeMutablePointer? + typealias StringFreeFn = @convention(c) (UnsafeMutablePointer?) -> Void + + static let shared = try? ErikaNativeLibrary() + + let create: CreateFn + let createWithOutputMode: CreateWithOutputModeFn? + let destroy: DestroyFn + let open: OpenFn + let openWithHeaders: OpenWithHeadersFn? + let play: CommandFn + let pause: CommandFn + let stop: CommandFn + let close: CommandFn + let seek: SeekFn + let setPlaybackRate: SetPlaybackRateFn? + let setVolume: SetVolumeFn? + let setUpscaler: SetUpscalerFn? + let setSubtitleScale: SetSubtitleScaleFn? + let setSubtitleFont: SetSubtitleFontFn? + let setSubtitleStyle: SetSubtitleStyleFn? + let getUpscalerStatus: GetUpscalerStatusFn? + let getOutputStatus: GetOutputStatusFn? + let selectAudioTrack: SelectTrackFn + let selectSubtitleTrack: SelectTrackFn + let addExternalSubtitle: AddExternalSubtitleFn + let removeSubtitleTrack: RemoveSubtitleTrackFn + let loadDanmakuFile: LoadDanmakuFn? + let loadDanmakuJson: LoadDanmakuFn? + let addDanmakuTrackFile: AddDanmakuTrackFn? + let addDanmakuTrackJson: AddDanmakuTrackFn? + let removeDanmakuTrack: RemoveDanmakuTrackFn? + let setDanmakuTrackEnabled: SetDanmakuTrackEnabledFn? + let setDanmakuTrackOffset: SetDanmakuTrackOffsetFn? + let setDanmakuGlobalOffset: SetDanmakuGlobalOffsetFn? + let danmakuTracks: TracksFn? + let clearDanmaku: ClearDanmakuFn? + let setDanmakuEnabled: SetDanmakuEnabledFn? + let setDebugHudEnabled: SetDebugHudEnabledFn? + let setDanmakuConfig: SetDanmakuConfigFn? + let getDanmakuConfig: GetDanmakuConfigFn? + let setDanmakuFont: SetDanmakuFontFn? + let setDanmakuBlockWords: SetDanmakuBlockWordsFn? + let trackSelection: TrackSelectionFn + let tracks: TracksFn + let freeTrackInfo: TrackInfoFreeFn + let freeDanmakuTrackInfo: DanmakuTrackInfoFreeFn? + let attachMetalLayer: AttachMetalLayerFn + let resizeSurface: ResizeSurfaceFn + let detachSurface: CommandFn + let renderTick: RenderTickFn + let captureFrameRgba: CaptureFrameRgbaFn? + let pollEvent: PollEventFn + let lastErrorMessage: LastErrorMessageFn + let stringFree: StringFreeFn + + private let libraryHandle: UnsafeMutableRawPointer + let path: String + + private init() throws { + let loaded = try Self.openLibrary() + libraryHandle = loaded.handle + path = loaded.path + erikaHdrLog( + erikaHdrEnvironmentEnabled(), + "loaded native library from \(path)" + ) + + create = try Self.load("erika_presenter_create", from: libraryHandle, as: CreateFn.self) + createWithOutputMode = Self.loadOptional("erika_presenter_create_with_output_mode", from: libraryHandle, as: CreateWithOutputModeFn.self) + destroy = try Self.load("erika_presenter_destroy", from: libraryHandle, as: DestroyFn.self) + open = try Self.load("erika_presenter_open", from: libraryHandle, as: OpenFn.self) + openWithHeaders = Self.loadOptional("erika_presenter_open_with_headers", from: libraryHandle, as: OpenWithHeadersFn.self) + play = try Self.load("erika_presenter_play", from: libraryHandle, as: CommandFn.self) + pause = try Self.load("erika_presenter_pause", from: libraryHandle, as: CommandFn.self) + stop = try Self.load("erika_presenter_stop", from: libraryHandle, as: CommandFn.self) + close = try Self.load("erika_presenter_close", from: libraryHandle, as: CommandFn.self) + seek = try Self.load("erika_presenter_seek", from: libraryHandle, as: SeekFn.self) + setPlaybackRate = Self.loadOptional("erika_presenter_set_playback_rate", from: libraryHandle, as: SetPlaybackRateFn.self) + setVolume = Self.loadOptional("erika_presenter_set_volume", from: libraryHandle, as: SetVolumeFn.self) + setUpscaler = Self.loadOptional("erika_presenter_set_upscaler", from: libraryHandle, as: SetUpscalerFn.self) + setSubtitleScale = Self.loadOptional("erika_presenter_set_subtitle_scale", from: libraryHandle, as: SetSubtitleScaleFn.self) + setSubtitleFont = Self.loadOptional("erika_presenter_set_subtitle_font", from: libraryHandle, as: SetSubtitleFontFn.self) + setSubtitleStyle = Self.loadOptional("erika_presenter_set_subtitle_style", from: libraryHandle, as: SetSubtitleStyleFn.self) + getUpscalerStatus = Self.loadOptional("erika_presenter_get_upscaler_status", from: libraryHandle, as: GetUpscalerStatusFn.self) + getOutputStatus = Self.loadOptional("erika_presenter_get_output_status", from: libraryHandle, as: GetOutputStatusFn.self) + selectAudioTrack = try Self.load("erika_presenter_select_audio_track", from: libraryHandle, as: SelectTrackFn.self) + selectSubtitleTrack = try Self.load("erika_presenter_select_subtitle_track", from: libraryHandle, as: SelectTrackFn.self) + addExternalSubtitle = try Self.load("erika_presenter_add_external_subtitle", from: libraryHandle, as: AddExternalSubtitleFn.self) + removeSubtitleTrack = try Self.load("erika_presenter_remove_subtitle_track", from: libraryHandle, as: RemoveSubtitleTrackFn.self) + loadDanmakuFile = Self.loadOptional("erika_presenter_load_danmaku_file", from: libraryHandle, as: LoadDanmakuFn.self) + loadDanmakuJson = Self.loadOptional("erika_presenter_load_danmaku_json", from: libraryHandle, as: LoadDanmakuFn.self) + addDanmakuTrackFile = Self.loadOptional("erika_presenter_add_danmaku_track_file", from: libraryHandle, as: AddDanmakuTrackFn.self) + addDanmakuTrackJson = Self.loadOptional("erika_presenter_add_danmaku_track_json", from: libraryHandle, as: AddDanmakuTrackFn.self) + removeDanmakuTrack = Self.loadOptional("erika_presenter_remove_danmaku_track", from: libraryHandle, as: RemoveDanmakuTrackFn.self) + setDanmakuTrackEnabled = Self.loadOptional("erika_presenter_set_danmaku_track_enabled", from: libraryHandle, as: SetDanmakuTrackEnabledFn.self) + setDanmakuTrackOffset = Self.loadOptional("erika_presenter_set_danmaku_track_offset", from: libraryHandle, as: SetDanmakuTrackOffsetFn.self) + setDanmakuGlobalOffset = Self.loadOptional("erika_presenter_set_danmaku_global_offset", from: libraryHandle, as: SetDanmakuGlobalOffsetFn.self) + danmakuTracks = Self.loadOptional("erika_presenter_danmaku_tracks", from: libraryHandle, as: TracksFn.self) + clearDanmaku = Self.loadOptional("erika_presenter_clear_danmaku", from: libraryHandle, as: ClearDanmakuFn.self) + setDanmakuEnabled = Self.loadOptional("erika_presenter_set_danmaku_enabled", from: libraryHandle, as: SetDanmakuEnabledFn.self) + setDebugHudEnabled = Self.loadOptional("erika_presenter_set_debug_hud_enabled", from: libraryHandle, as: SetDebugHudEnabledFn.self) + setDanmakuConfig = Self.loadOptional("erika_presenter_set_danmaku_config_ptr", from: libraryHandle, as: SetDanmakuConfigFn.self) + getDanmakuConfig = Self.loadOptional("erika_presenter_get_danmaku_config", from: libraryHandle, as: GetDanmakuConfigFn.self) + setDanmakuFont = Self.loadOptional("erika_presenter_set_danmaku_font", from: libraryHandle, as: SetDanmakuFontFn.self) + setDanmakuBlockWords = Self.loadOptional("erika_presenter_set_danmaku_block_words_json", from: libraryHandle, as: SetDanmakuBlockWordsFn.self) + trackSelection = try Self.load("erika_presenter_track_selection", from: libraryHandle, as: TrackSelectionFn.self) + tracks = try Self.load("erika_presenter_tracks", from: libraryHandle, as: TracksFn.self) + freeTrackInfo = try Self.load("erika_track_info_free", from: libraryHandle, as: TrackInfoFreeFn.self) + freeDanmakuTrackInfo = Self.loadOptional("erika_danmaku_track_info_free", from: libraryHandle, as: DanmakuTrackInfoFreeFn.self) + attachMetalLayer = try Self.load("erika_presenter_attach_metal_layer", from: libraryHandle, as: AttachMetalLayerFn.self) + 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) + 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) + stringFree = try Self.load("erika_string_free", from: libraryHandle, as: StringFreeFn.self) + } + + private static func openLibrary() throws -> (handle: UnsafeMutableRawPointer, path: String) { + var failures: [ErikaPluginError] = [] + if let handle = dlopen(nil, RTLD_NOW), dlsym(handle, "erika_presenter_create") != nil { + return (handle, "main executable") + } + + var candidates: [String] = [] + let environment = ProcessInfo.processInfo.environment + if let override = environment["ERIKA_CAPI_DYLIB"], !override.isEmpty { + candidates.append(override) + } + let bundle = Bundle(for: ErikaFlutterPlugin.self) + if let pluginExecutable = bundle.executablePath { + candidates.append(pluginExecutable) + } + if let resourcePath = bundle.path(forResource: "liberika_capi", ofType: "dylib") { + candidates.append(resourcePath) + } + if let frameworksPath = Bundle.main.privateFrameworksPath { + candidates.append(URL(fileURLWithPath: frameworksPath).appendingPathComponent("liberika_capi.dylib").path) + } + if let executablePath = Bundle.main.executablePath { + let executableDirectory = URL(fileURLWithPath: executablePath).deletingLastPathComponent().path + candidates.append(URL(fileURLWithPath: executableDirectory).appendingPathComponent("liberika_capi.dylib").path) + } + + for path in candidates { + if let handle = dlopen(path, RTLD_NOW | RTLD_LOCAL) { + if dlsym(handle, "erika_presenter_create") != nil { + return (handle, path) + } + dlclose(handle) + failures.append(.libraryLoadFailed(path, "erika_presenter_create not found")) + continue + } + let detail = dlerror().map { String(cString: $0) } + failures.append(.libraryLoadFailed(path, detail)) + } + throw ErikaPluginError.libraryNotFound(failures.map(String.init(describing:))) + } + + private static func load(_ symbol: String, from handle: UnsafeMutableRawPointer, as type: T.Type) throws -> T { + guard let raw = dlsym(handle, symbol) else { + throw ErikaPluginError.symbolMissing(symbol) + } + return unsafeBitCast(raw, to: type) + } + + private static func loadOptional(_ symbol: String, from handle: UnsafeMutableRawPointer, as type: T.Type) -> T? { + guard let raw = dlsym(handle, symbol) else { + return nil + } + return unsafeBitCast(raw, to: type) + } + + func createPresenter(config: ErikaPresenterConfigC) -> UnsafeMutableRawPointer? { + if let createWithOutputMode { + return createWithOutputMode(config.outputMode, config.edrHeadroom) + } + return create() + } + + func currentEventMessage() -> String? { + guard let pointer = lastErrorMessage() else { + return nil + } + defer { stringFree(pointer) } + return String(validatingUTF8: pointer) + } +} + +private final class ErikaPlayerHost { + let id: Int64 + + private let library: ErikaNativeLibrary + private let handle: UnsafeMutableRawPointer + private weak var attachedView: ErikaMetalSurfaceView? + private var displayLink: CADisplayLink? + private var displayLinkProxy: DisplayLinkProxy? + private var startTimeSeconds: CFTimeInterval = CACurrentMediaTime() + private var currentDanmakuConfig = ErikaDanmakuConfigC() + private let hdrDebug: Bool + private let presenterConfig: ErikaPresenterConfigC + private var loggedFirstRenderedVideoFrame = false + private var latestPresenterStats = ErikaPresenterStatsC() + + init(id: Int64, library: ErikaNativeLibrary, config: ErikaPresenterConfigC, hdrDebug: Bool) throws { + self.id = id + self.library = library + self.hdrDebug = hdrDebug + presenterConfig = config + guard let handle = library.createPresenter(config: config) else { + throw ErikaPluginError.presenterCreateFailed + } + self.handle = handle + erikaHdrLog( + hdrDebug, + "created presenter player=\(id) mode=\(erikaOutputModeLabel(config)) library=\(library.path) createWithOutputMode=\(library.createWithOutputMode != nil)" + ) + } + + deinit { + displayLink?.invalidate() + _ = library.detachSurface(handle) + library.destroy(handle) + } + + func open(uri: String, httpHeaders: [String: String]) throws { + try uri.withCString { cString in + guard !httpHeaders.isEmpty else { + try check(library.open(handle, cString), operation: "open") + return + } + // Never fall back to the headerless entry point here: silently dropping + // the headers turns an authenticated stream into an opaque 403. + guard let openWithHeaders = library.openWithHeaders else { + throw ErikaPluginError.httpHeadersUnsupported + } + let names = httpHeaders.keys.map { strdup($0) } + let values = httpHeaders.values.map { strdup($0) } + defer { + names.forEach { free($0) } + values.forEach { free($0) } + } + let headers = zip(names, values).map { ErikaHttpHeader(name: $0.0, value: $0.1) } + try headers.withUnsafeBufferPointer { buffer in + try check(openWithHeaders(handle, cString, buffer.baseAddress.map(UnsafeRawPointer.init), UInt(headers.count)), operation: "open") + } + } + } + + func play() throws { + try configureAudioSessionForPlayback() + try check(library.play(handle), operation: "play") + } + 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") + } + + func setPlaybackRate(_ rate: Double) throws { + guard let setRate = library.setPlaybackRate else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_playback_rate") + } + try check(setRate(handle, rate), operation: "set_playback_rate") + } + + func setVolume(_ volume: Double) throws { + guard let setVolume = library.setVolume else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_volume") + } + let clampedVolume = volume.isFinite ? min(max(volume, 0.0), 1.0) : 1.0 + try check(setVolume(handle, clampedVolume), operation: "set_volume") + } + + func setUpscaler(mode: Int32) throws { + guard let setUpscaler = library.setUpscaler else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_upscaler") + } + try check(setUpscaler(handle, mode), operation: "set_upscaler") + } + + func setSubtitleScale(_ scale: Double) throws { + guard let setSubtitleScale = library.setSubtitleScale else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_subtitle_scale") + } + let clampedScale = scale.isFinite ? min(max(scale, 0.25), 4.0) : 1.0 + try check(setSubtitleScale(handle, clampedScale), operation: "set_subtitle_scale") + } + + func setSubtitleFont(family: String?, filePath: String?) throws { + guard let setSubtitleFont = library.setSubtitleFont else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_subtitle_font") + } + let status = withOptionalCString(family ?? "") { familyCString in + withOptionalCString(filePath ?? "") { filePathCString in + setSubtitleFont(handle, familyCString, filePathCString) + } + } + try check(status, operation: "set_subtitle_font") + } + + func setSubtitleStyle( + fontFamily: String?, + fontFilePath: String?, + primaryRgba: UInt32, + outlineRgba: UInt32, + fontSize: Double, + outlineWidth: Double, + bold: Bool, + italic: Bool, + underline: Bool, + strikeOut: Bool, + spacing: Double, + scaleXPercent: Double, + scaleYPercent: Double, + borderStyle: Int32, + shadowDepth: Double, + blur: Double, + alignment: Int32, + marginLeft: Int32, + marginRight: Int32, + marginVertical: Int32, + overrideMask: UInt32 + ) throws { + guard let setSubtitleStyle = library.setSubtitleStyle else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_subtitle_style") + } + let status = withOptionalCString(fontFamily ?? "") { fontFamilyCString in + withOptionalCString(fontFilePath ?? "") { fontFilePathCString in + var style = ErikaSubtitleStyleC( + fontFamily: fontFamilyCString, + fontFilePath: fontFilePathCString, + primaryColorRgba: primaryRgba, + outlineColorRgba: outlineRgba, + fontSize: fontSize, + outlineWidth: outlineWidth, + bold: bold, + italic: italic, + underline: underline, + strikeOut: strikeOut, + spacing: spacing, + scaleXPercent: scaleXPercent, + scaleYPercent: scaleYPercent, + borderStyle: borderStyle, + shadowDepth: shadowDepth, + blur: blur, + alignment: alignment, + marginLeft: marginLeft, + marginRight: marginRight, + marginVertical: marginVertical, + overrideMask: overrideMask + ) + return withUnsafePointer(to: &style) { pointer in + setSubtitleStyle(handle, UnsafeRawPointer(pointer)) + } + } + } + try check(status, operation: "set_subtitle_style") + } + + func upscalerStatus() throws -> [String: Any] { + guard let getStatus = library.getUpscalerStatus else { + throw ErikaPluginError.symbolMissing("erika_presenter_get_upscaler_status") + } + var status = ErikaUpscalerStatusC() + let result = withUnsafeMutablePointer(to: &status) { pointer in + getStatus(handle, UnsafeMutableRawPointer(pointer)) + } + try check(result, operation: "get_upscaler_status") + return status.toFlutterMap() + } + + func outputStatus() throws -> [String: Any] { + guard let getStatus = library.getOutputStatus else { + throw ErikaPluginError.symbolMissing("erika_presenter_get_output_status") + } + var status = ErikaOutputStatusC() + let result = withUnsafeMutablePointer(to: &status) { pointer in + getStatus(handle, UnsafeMutableRawPointer(pointer)) + } + try check(result, operation: "get_output_status") + return status.toFlutterMap() + } + + func presenterStats() -> [String: Any] { + latestPresenterStats.toFlutterMap() + } + + func addExternalSubtitle(uri: String) throws -> Int64 { + var trackId: Int64 = 0 + try uri.withCString { cString in + try check(library.addExternalSubtitle(handle, cString, &trackId), operation: "add_external_subtitle") + } + return trackId + } + + func removeSubtitleTrack(trackId: Int64) throws { + try check(library.removeSubtitleTrack(handle, trackId), operation: "remove_subtitle_track") + } + + func loadDanmakuFile(uri: String) throws { + guard let load = library.loadDanmakuFile else { + throw ErikaPluginError.symbolMissing("erika_presenter_load_danmaku_file") + } + try uri.withCString { cString in + try check(load(handle, cString), operation: "load_danmaku_file") + } + } + + func loadDanmakuJson(_ json: String) throws { + guard let load = library.loadDanmakuJson else { + throw ErikaPluginError.symbolMissing("erika_presenter_load_danmaku_json") + } + try json.withCString { cString in + try check(load(handle, cString), operation: "load_danmaku_json") + } + } + + func addDanmakuTrackFile(uri: String, name: String?, offsetMicros: Int64) throws -> UInt64 { + guard let add = library.addDanmakuTrackFile else { + throw ErikaPluginError.symbolMissing("erika_presenter_add_danmaku_track_file") + } + var trackId: UInt64 = 0 + let status = uri.withCString { uriCString in + withOptionalCString(name) { nameCString in + add(handle, uriCString, nameCString, offsetMicros, &trackId) + } + } + try check(status, operation: "add_danmaku_track_file") + return trackId + } + + func addDanmakuTrackJson(_ json: String, name: String?, offsetMicros: Int64) throws -> UInt64 { + guard let add = library.addDanmakuTrackJson else { + throw ErikaPluginError.symbolMissing("erika_presenter_add_danmaku_track_json") + } + var trackId: UInt64 = 0 + let status = json.withCString { jsonCString in + withOptionalCString(name) { nameCString in + add(handle, jsonCString, nameCString, offsetMicros, &trackId) + } + } + try check(status, operation: "add_danmaku_track_json") + return trackId + } + + func removeDanmakuTrack(trackId: UInt64) throws { + guard let remove = library.removeDanmakuTrack else { + throw ErikaPluginError.symbolMissing("erika_presenter_remove_danmaku_track") + } + try check(remove(handle, trackId), operation: "remove_danmaku_track") + } + + func setDanmakuTrackEnabled(trackId: UInt64, enabled: Bool) throws { + guard let setEnabled = library.setDanmakuTrackEnabled else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_danmaku_track_enabled") + } + try check(setEnabled(handle, trackId, enabled), operation: "set_danmaku_track_enabled") + } + + func setDanmakuTrackOffset(trackId: UInt64, offsetMicros: Int64) throws { + guard let setOffset = library.setDanmakuTrackOffset else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_danmaku_track_offset") + } + try check(setOffset(handle, trackId, offsetMicros), operation: "set_danmaku_track_offset") + } + + func setDanmakuGlobalOffset(offsetMicros: Int64) throws { + guard let setOffset = library.setDanmakuGlobalOffset else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_danmaku_global_offset") + } + try check(setOffset(handle, offsetMicros), operation: "set_danmaku_global_offset") + } + + func danmakuTracks() throws -> [[String: Any]] { + guard let danmakuTracks = library.danmakuTracks else { + throw ErikaPluginError.symbolMissing("erika_presenter_danmaku_tracks") + } + var count: Int = 0 + try check(danmakuTracks(handle, nil, 0, &count), operation: "danmaku_tracks_len") + if count <= 0 { return [] } + var tracks = Array(repeating: ErikaDanmakuTrackInfoC(), count: count) + var written: Int = 0 + let status = tracks.withUnsafeMutableBufferPointer { buffer in + danmakuTracks(handle, UnsafeMutableRawPointer(buffer.baseAddress), buffer.count, &written) + } + try check(status, operation: "danmaku_tracks") + let result = tracks.prefix(min(written, tracks.count)).map { $0.toFlutterMap() } + if let free = library.freeDanmakuTrackInfo { + for index in tracks.indices { + withUnsafeMutablePointer(to: &tracks[index]) { pointer in + free(UnsafeMutableRawPointer(pointer)) + } + } + } + return result + } + + func clearDanmaku() throws { + guard let clear = library.clearDanmaku else { + throw ErikaPluginError.symbolMissing("erika_presenter_clear_danmaku") + } + try check(clear(handle), operation: "clear_danmaku") + } + + func setDanmakuEnabled(_ enabled: Bool) throws { + guard let setEnabled = library.setDanmakuEnabled else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_danmaku_enabled") + } + try check(setEnabled(handle, enabled), operation: "set_danmaku_enabled") + currentDanmakuConfig.enabled = enabled ? 1 : 0 + } + + func setDebugHudEnabled(_ enabled: Bool) throws { + guard let setEnabled = library.setDebugHudEnabled else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_debug_hud_enabled") + } + try check(setEnabled(handle, enabled), operation: "set_debug_hud_enabled") + } + + func danmakuConfigSnapshot() -> ErikaDanmakuConfigC { + currentDanmakuConfig + } + + private func refreshDanmakuConfigSnapshot() { + guard let getConfig = library.getDanmakuConfig else { + return + } + var config = ErikaDanmakuConfigC() + let status = withUnsafeMutablePointer(to: &config) { pointer in + getConfig(handle, UnsafeMutableRawPointer(pointer)) + } + if status == 0 { + currentDanmakuConfig = config + } + } + + func setDanmakuConfig(_ config: ErikaDanmakuConfigC) throws { + guard let setConfig = library.setDanmakuConfig else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_danmaku_config_ptr") + } + var config = config + let status = withUnsafePointer(to: &config) { pointer in + setConfig(handle, UnsafeRawPointer(pointer)) + } + try check(status, operation: "set_danmaku_config") + currentDanmakuConfig = config + } + + func setDanmakuFont(family: String?, filePath: String?) throws { + guard let setFont = library.setDanmakuFont else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_danmaku_font") + } + let status = withOptionalCString(family ?? "") { familyCString in + withOptionalCString(filePath ?? "") { filePathCString in + setFont(handle, familyCString, filePathCString) + } + } + try check(status, operation: "set_danmaku_font") + refreshDanmakuConfigSnapshot() + } + + func setDanmakuBlockWordsJson(_ json: String) throws { + guard let setBlockWords = library.setDanmakuBlockWords else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_danmaku_block_words_json") + } + try json.withCString { cString in + try check(setBlockWords(handle, cString), operation: "set_danmaku_block_words") + } + refreshDanmakuConfigSnapshot() + } + + func selectAudioTrack(trackId: Int64?) throws { + try check(library.selectAudioTrack(handle, trackId ?? -1), operation: "select_audio_track") + } + + func selectSubtitleTrack(trackId: Int64?) throws { + try check(library.selectSubtitleTrack(handle, trackId ?? -1), operation: "select_subtitle_track") + } + + func tracks() throws -> [[String: Any]] { + var count: Int = 0 + try check(library.tracks(handle, nil, 0, &count), operation: "tracks_len") + if count <= 0 { return [] } + var tracks = Array(repeating: ErikaTrackInfoC(), count: count) + var written: Int = 0 + let status = tracks.withUnsafeMutableBufferPointer { buffer in + library.tracks(handle, UnsafeMutableRawPointer(buffer.baseAddress), buffer.count, &written) + } + try check(status, operation: "tracks") + let result = tracks.prefix(min(written, tracks.count)).map { $0.toFlutterMap() } + for index in tracks.indices { + withUnsafeMutablePointer(to: &tracks[index]) { pointer in + library.freeTrackInfo(UnsafeMutableRawPointer(pointer)) + } + } + return result + } + + func trackSelection() throws -> [String: Any] { + var selection = ErikaTrackSelectionC() + let status = withUnsafeMutablePointer(to: &selection) { pointer in + library.trackSelection(handle, UnsafeMutableRawPointer(pointer)) + } + try check(status, operation: "track_selection") + return selection.toFlutterMap() + } + + func captureFrameRgba(width: Int, height: Int) -> Data? { + guard width > 0, height > 0, let captureFrameRgba = library.captureFrameRgba else { + return nil + } + let byteCount = width * height * 4 + var data = Data(count: byteCount) + let status = data.withUnsafeMutableBytes { buffer in + captureFrameRgba( + handle, + UInt32(width), + UInt32(height), + buffer.baseAddress, + byteCount + ) + } + guard status == 0 else { + NSLog("Erika: captureFrameRgba failed with status \(status)") + return nil + } + return data + } + + func screenshot(view: ErikaMetalSurfaceView? = nil, width: Int? = nil, height: Int? = nil) -> Data? { + if let width, let height, let data = captureFrameRgba(width: width, height: height) { + return data + } + return (view ?? attachedView)?.pngSnapshotData() + } + + func attach(view: ErikaMetalSurfaceView) throws { + attachedView = view + view.attachedPlayerId = id + try attachOrResize(view: view, attach: true) + startDisplayLinkIfNeeded() + } + + func detach(viewId: Int64?) { + guard viewId == nil || attachedView?.platformViewId == viewId else { return } + attachedView?.attachedPlayerId = nil + attachedView = nil + displayLink?.invalidate() + displayLink = nil + displayLinkProxy = nil + _ = library.detachSurface(handle) + } + + func resizeFromAttachedView() { + guard let view = attachedView else { return } + do { + try attachOrResize(view: view, attach: false) + } catch { + NSLog("ErikaFlutterPlugin: resize failed: \(error)") + } + } + + func renderTick(sendEvent: (([String: Any]) -> Void)?) { + let timeSeconds = CACurrentMediaTime() - startTimeSeconds + var stats = ErikaPresenterStatsC() + let status = withUnsafeMutablePointer(to: &stats) { pointer in + library.renderTick(handle, timeSeconds, UnsafeMutableRawPointer(pointer)) + } + if status != 0 { + NSLog("ErikaFlutterPlugin: render_tick failed with status \(status)") + } else { + latestPresenterStats = stats + } + if hdrDebug && !loggedFirstRenderedVideoFrame && stats.renderedVideoFrames > 0 { + loggedFirstRenderedVideoFrame = true + let layer = attachedView.map { erikaLayerSummary($0.metalLayer) } ?? "layer=nil" + let screen = erikaScreenSummary(attachedView?.window?.screen ?? UIScreen.main) + erikaHdrLog( + true, + "first rendered frame player=\(id) mode=\(erikaOutputModeLabel(presenterConfig)) decoded=\(stats.decodedVideoFrames) rendered=\(stats.renderedVideoFrames) test=\(stats.renderedTestFrames) \(screen) \(layer)" + ) + } + 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.kind == 6 { + erikaHdrLog( + hdrDebug, + "video params player=\(id) width=\(event.video.width) height=\(event.video.height) primaries=\(event.video.primaries) transfer=\(event.video.transfer)" + ) + } + let message = event.kind == 9 || event.kind == 11 || event.kind == 12 + ? library.currentEventMessage() + : nil + sendEvent(event.toFlutterMap(playerId: id, host: self, structuredMessage: message)) + continue + } + if status != 5 { + NSLog("ErikaFlutterPlugin: poll_event failed with status \(status)") + } + break + } + } + + private func attachOrResize(view: ErikaMetalSurfaceView, attach: Bool) throws { + erikaConfigureLayerDynamicRange(view.metalLayer, config: presenterConfig) + view.updateDrawableSize() + let width = UInt32(max(1.0, view.metalLayer.drawableSize.width).rounded()) + let height = UInt32(max(1.0, view.metalLayer.drawableSize.height).rounded()) + let scale = view.currentScale + if attach { + let rawLayer = UInt64(UInt(bitPattern: Unmanaged.passUnretained(view.metalLayer).toOpaque())) + try check(library.attachMetalLayer(handle, rawLayer, width, height, scale), operation: "attach_metal_layer") + erikaHdrLog( + hdrDebug, + "attached layer player=\(id) view=\(view.platformViewId) physical=\(width)x\(height) scale=\(String(format: "%.3f", scale)) \(erikaScreenSummary(view.window?.screen ?? UIScreen.main)) \(erikaLayerSummary(view.metalLayer))" + ) + } else { + try check(library.resizeSurface(handle, width, height, scale), operation: "resize_surface") + erikaHdrLog( + hdrDebug, + "resized layer player=\(id) view=\(view.platformViewId) physical=\(width)x\(height) scale=\(String(format: "%.3f", scale)) \(erikaLayerSummary(view.metalLayer))" + ) + } + } + + private func startDisplayLinkIfNeeded() { + guard displayLink == nil else { return } + startTimeSeconds = CACurrentMediaTime() + let proxy = DisplayLinkProxy { [weak self] in + self?.renderTick(sendEvent: ErikaFlutterPlugin.sharedEventSink) + } + let link = CADisplayLink(target: proxy, selector: #selector(DisplayLinkProxy.tick)) + link.preferredFramesPerSecond = resolvedDisplayLinkFps() + link.add(to: .main, forMode: .common) + displayLinkProxy = proxy + displayLink = link + } + + private func resolvedDisplayLinkFps() -> Int { + if let override = ProcessInfo.processInfo.environment["ERIKA_FLUTTER_TARGET_FPS"], + let fps = Int(override), fps > 0 { + return min(max(fps, 1), 1000) + } + let fps = attachedView?.window?.screen.maximumFramesPerSecond ?? UIScreen.main.maximumFramesPerSecond + return fps > 0 ? fps : erikaDefaultDisplayFps + } + + private func check(_ status: Int32, operation: String) throws { + if status != 0 { + throw ErikaPluginError.erikaStatus( + operation, + status, + library.currentEventMessage() + ) + } + } + + private func configureAudioSessionForPlayback() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .moviePlayback, options: []) + try session.setActive(true) + } +} + +private final class DisplayLinkProxy: NSObject { + private let body: () -> Void + + init(_ body: @escaping () -> Void) { + self.body = body + } + + @objc func tick() { + body() + } +} + +private protocol ErikaMetalSurfaceView: AnyObject { + var platformViewId: Int64 { get } + var metalLayer: CAMetalLayer { get } + var attachedPlayerId: Int64? { get set } + var bounds: CGRect { get } + var window: UIWindow? { get } + var currentScale: Double { get } + + func updateDrawableSize() + func pngSnapshotData() -> Data? +} + +private final class WeakErikaVideoPlatformViewBox { + weak var view: ErikaMetalSurfaceView? + + init(view: ErikaMetalSurfaceView) { + self.view = view + } +} + +private final class ErikaMetalUIView: UIView, ErikaMetalSurfaceView { + let platformViewId: Int64 + weak var plugin: ErikaFlutterPlugin? + var attachedPlayerId: Int64? + + override class var layerClass: AnyClass { CAMetalLayer.self } + + var metalLayer: CAMetalLayer { layer as! CAMetalLayer } + + var currentScale: Double { + Double(max(1.0, window?.screen.scale ?? UIScreen.main.scale)) + } + + init(frame: CGRect, viewId: Int64, arguments: Any?, plugin: ErikaFlutterPlugin?) { + platformViewId = viewId + self.plugin = plugin + super.init(frame: frame) + isOpaque = true + isUserInteractionEnabled = false + backgroundColor = .black + contentScaleFactor = CGFloat(currentScale) + metalLayer.pixelFormat = .bgra8Unorm + metalLayer.framebufferOnly = true + metalLayer.isOpaque = true + metalLayer.backgroundColor = UIColor.black.cgColor + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + plugin?.unregisterView(viewId: platformViewId) + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + false + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + nil + } + + override func layoutSubviews() { + super.layoutSubviews() + updateDrawableSize() + plugin?.resizePlayerAttachedToView(viewId: platformViewId) + } + + override func didMoveToWindow() { + super.didMoveToWindow() + updateDrawableSize() + plugin?.resizePlayerAttachedToView(viewId: platformViewId) + } + + func updateDrawableSize() { + let scale = CGFloat(currentScale) + contentScaleFactor = scale + metalLayer.contentsScale = scale + // metalLayer is this view's *backing* layer (layerClass == CAMetalLayer); + // UIKit already syncs its frame to the view. Setting it to `bounds` would + // move the backing layer to the superlayer origin, rendering the video at + // (0,0) instead of the view's frame (visible once the view isn't full-screen). + metalLayer.drawableSize = CGSize( + width: max(1.0, bounds.width * scale), + height: max(1.0, bounds.height * scale) + ) + } + + func pngSnapshotData() -> Data? { + snapshotPngData(of: self) + } +} + +private final class ErikaWindowOverlayView: UIView, ErikaMetalSurfaceView { + let platformViewId: Int64 = erikaWindowHostedVideoSurfaceId + weak var plugin: ErikaFlutterPlugin? + var attachedPlayerId: Int64? + + private var overlayFrameGeneration: Int64? + private var debugLabelView: UILabel? + + /// Generation of the widget that currently owns this shared overlay surface. + /// Used to reject stale detach calls from disposed widgets. + var activeGeneration: Int64? { overlayFrameGeneration } + + override class var layerClass: AnyClass { CAMetalLayer.self } + + var metalLayer: CAMetalLayer { layer as! CAMetalLayer } + + var currentScale: Double { + Double(max(1.0, window?.screen.scale ?? UIScreen.main.scale)) + } + + init(plugin: ErikaFlutterPlugin?) { + self.plugin = plugin + super.init(frame: .zero) + isOpaque = true + isHidden = true + isUserInteractionEnabled = false + backgroundColor = .black + contentScaleFactor = CGFloat(currentScale) + autoresizingMask = [] + metalLayer.pixelFormat = .bgra8Unorm + metalLayer.framebufferOnly = true + metalLayer.isOpaque = true + metalLayer.backgroundColor = UIColor.black.cgColor + layer.actions = [ + "bounds": NSNull(), + "frame": NSNull(), + "position": NSNull(), + ] + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + plugin?.detachOverlayView(self) + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + false + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + nil + } + + override func layoutSubviews() { + super.layoutSubviews() + updateDrawableSize() + plugin?.resizePlayerAttachedToView(viewId: platformViewId) + } + + override func didMoveToWindow() { + super.didMoveToWindow() + updateDrawableSize() + plugin?.resizePlayerAttachedToView(viewId: platformViewId) + } + + func updateOverlayFrame( + _ frame: CGRect?, + visible: Bool, + debugLabel: String?, + generation: Int64? + ) { + if visible { + overlayFrameGeneration = generation + } else if let generation, + let overlayFrameGeneration, + generation != overlayFrameGeneration { + return + } + + updateDebugLabel(debugLabel) + let shouldShow = visible && + (frame?.width ?? 0) > 0 && + (frame?.height ?? 0) > 0 + + CATransaction.begin() + CATransaction.setDisableActions(true) + defer { CATransaction.commit() } + + guard shouldShow, let frame else { + isHidden = true + return + } + + let resolvedFrame = frame.integral + if self.frame != resolvedFrame { + self.frame = resolvedFrame + } + isHidden = false + updateDrawableSize() + plugin?.resizePlayerAttachedToView(viewId: platformViewId) + } + + func updateDrawableSize() { + let scale = CGFloat(currentScale) + contentScaleFactor = scale + metalLayer.contentsScale = scale + // metalLayer is this view's *backing* layer (layerClass == CAMetalLayer); + // UIKit already syncs its frame to the view. Setting it to `bounds` would + // move the backing layer to the superlayer origin, rendering the video at + // (0,0) instead of the view's frame (visible once the view isn't full-screen). + metalLayer.drawableSize = CGSize( + width: max(1.0, bounds.width * scale), + height: max(1.0, bounds.height * scale) + ) + } + + func pngSnapshotData() -> Data? { + snapshotPngData(of: self) + } + + private func updateDebugLabel(_ text: String?) { + guard erikaDebugLabelsEnabled, let text, !text.isEmpty else { + debugLabelView?.removeFromSuperview() + debugLabelView = nil + return + } + let label = debugLabelView ?? UILabel() + if debugLabelView == nil { + label.textColor = UIColor(white: 1.0, alpha: 0.45) + label.font = UIFont.systemFont(ofSize: 12, weight: .medium) + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10), + ]) + debugLabelView = label + } + label.text = text + } +} + +private func snapshotPngData(of view: UIView) -> Data? { + guard view.bounds.width > 0, view.bounds.height > 0 else { + return nil + } + let format = UIGraphicsImageRendererFormat() + format.scale = view.window?.screen.scale ?? UIScreen.main.scale + format.opaque = view.isOpaque + let renderer = UIGraphicsImageRenderer(bounds: view.bounds, format: format) + let image = renderer.image { _ in + view.drawHierarchy(in: view.bounds, afterScreenUpdates: false) + } + return image.pngData() +} + +private final class ErikaVideoPlatformView: NSObject, FlutterPlatformView { + let metalView: ErikaMetalUIView + + init(frame: CGRect, viewId: Int64, arguments: Any?, plugin: ErikaFlutterPlugin?) { + metalView = ErikaMetalUIView(frame: frame, viewId: viewId, arguments: arguments, plugin: plugin) + super.init() + } + + func view() -> UIView { + metalView + } +} + +private final class ErikaVideoViewFactory: NSObject, FlutterPlatformViewFactory { + private weak var plugin: ErikaFlutterPlugin? + + init(plugin: ErikaFlutterPlugin) { + self.plugin = plugin + super.init() + } + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + let platformView = ErikaVideoPlatformView(frame: frame, viewId: viewId, arguments: args, plugin: plugin) + plugin?.registerView(platformView.metalView, viewId: viewId) + return platformView + } +} + +private enum ErikaAssociatedObjectKeys { + static var windowOverlayView: UInt8 = 0 +} + +private extension UIWindow { + var erikaWindowOverlayView: ErikaWindowOverlayView? { + get { + objc_getAssociatedObject( + self, + &ErikaAssociatedObjectKeys.windowOverlayView + ) as? ErikaWindowOverlayView + } + set { + objc_setAssociatedObject( + self, + &ErikaAssociatedObjectKeys.windowOverlayView, + newValue, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } +} + +public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + static var sharedEventSink: FlutterEventSink? + + private static let playerChannelName = "erika_flutter/player" + private static let eventsChannelName = "erika_flutter/events" + private static let videoViewType = "erika_flutter/video_view" + + private var players: [Int64: ErikaPlayerHost] = [:] + private var views: [Int64: WeakErikaVideoPlatformViewBox] = [:] + private var nextPlayerId: Int64 = 1 + private var pollTimer: Timer? + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = ErikaFlutterPlugin() + let playerChannel = FlutterMethodChannel(name: playerChannelName, binaryMessenger: registrar.messenger()) + let eventsChannel = FlutterEventChannel(name: eventsChannelName, binaryMessenger: registrar.messenger()) + registrar.addMethodCallDelegate(instance, channel: playerChannel) + eventsChannel.setStreamHandler(instance) + registrar.register(ErikaVideoViewFactory(plugin: instance), withId: videoViewType) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + do { + switch call.method { + case "create": + result(try createPlayer(arguments: call.arguments)) + case "dispose": + let args = try dictionaryArgs(call.arguments) + let playerId = try requiredInt64(args["playerId"], name: "playerId") + players.removeValue(forKey: playerId) + result(nil) + case "open": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let uri = args["uri"] as? String, !uri.isEmpty else { + throw ErikaPluginError.invalidArguments("uri is required.") + } + let headers = (args["httpHeaders"] as? [String: String]) ?? [:] + try host.open(uri: uri, httpHeaders: headers) + result(nil) + case "play": + try playerHost(from: try dictionaryArgs(call.arguments)).play() + result(nil) + case "pause": + try playerHost(from: try dictionaryArgs(call.arguments)).pause() + result(nil) + case "stop": + try playerHost(from: try dictionaryArgs(call.arguments)).stop() + result(nil) + case "close": + try playerHost(from: try dictionaryArgs(call.arguments)).close() + result(nil) + case "seek": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).seek(positionMicros: try requiredUInt64(args["positionMicros"], name: "positionMicros")) + result(nil) + case "setPlaybackRate": + let args = try dictionaryArgs(call.arguments) + guard let rate = doubleValue(args["rate"]) else { + throw ErikaPluginError.invalidArguments("rate is required.") + } + try playerHost(from: args).setPlaybackRate(rate) + result(nil) + case "setVolume": + let args = try dictionaryArgs(call.arguments) + guard let volume = doubleValue(args["volume"]) else { + throw ErikaPluginError.invalidArguments("volume is required.") + } + try playerHost(from: args).setVolume(volume) + result(nil) + case "setUpscaler": + let args = try dictionaryArgs(call.arguments) + guard let mode = int32Value(args["mode"]) else { + throw ErikaPluginError.invalidArguments("mode is required.") + } + try playerHost(from: args).setUpscaler(mode: mode) + result(nil) + case "setSubtitleScale": + let args = try dictionaryArgs(call.arguments) + guard let scale = doubleValue(args["scale"]) else { + throw ErikaPluginError.invalidArguments("scale is required.") + } + try playerHost(from: args).setSubtitleScale(scale) + result(nil) + case "setSubtitleStyle": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + if args.keys.contains("fontFamily") || args.keys.contains("fontFilePath") { + try host.setSubtitleFont( + family: args["fontFamily"] as? String, + filePath: args["fontFilePath"] as? String + ) + } + if args.keys.contains("primaryColorRgba") || args.keys.contains("outlineColorRgba") + || args.keys.contains("fontSize") || args.keys.contains("outlineWidth") + || args.keys.contains("bold") || args.keys.contains("italic") + || args.keys.contains("underline") || args.keys.contains("strikeOut") + || args.keys.contains("spacing") || args.keys.contains("scaleXPercent") + || args.keys.contains("scaleYPercent") || args.keys.contains("borderStyle") + || args.keys.contains("shadowDepth") || args.keys.contains("blur") + || args.keys.contains("alignment") || args.keys.contains("marginLeft") + || args.keys.contains("marginRight") || args.keys.contains("marginVertical") + || args.keys.contains("overrideMask") + { + let primary = int64Value(args["primaryColorRgba"]) ?? 0xFFFF_FFFF + let outline = int64Value(args["outlineColorRgba"]) ?? 0x0000_007F + try host.setSubtitleStyle( + fontFamily: args["fontFamily"] as? String, + fontFilePath: args["fontFilePath"] as? String, + primaryRgba: UInt32(truncatingIfNeeded: primary), + outlineRgba: UInt32(truncatingIfNeeded: outline), + fontSize: doubleValue(args["fontSize"]) ?? 48.0, + outlineWidth: doubleValue(args["outlineWidth"]) ?? 2.0, + bold: boolValue(args["bold"]) ?? false, + italic: boolValue(args["italic"]) ?? false, + underline: boolValue(args["underline"]) ?? false, + strikeOut: boolValue(args["strikeOut"]) ?? false, + spacing: doubleValue(args["spacing"]) ?? 0.0, + scaleXPercent: doubleValue(args["scaleXPercent"]) ?? 100.0, + scaleYPercent: doubleValue(args["scaleYPercent"]) ?? 100.0, + borderStyle: int32Value(args["borderStyle"]) ?? 1, + shadowDepth: doubleValue(args["shadowDepth"]) ?? 0.0, + blur: doubleValue(args["blur"]) ?? 0.0, + alignment: int32Value(args["alignment"]) ?? 2, + marginLeft: int32Value(args["marginLeft"]) ?? 48, + marginRight: int32Value(args["marginRight"]) ?? 48, + marginVertical: int32Value(args["marginVertical"]) ?? 54, + overrideMask: UInt32(truncatingIfNeeded: int64Value(args["overrideMask"]) ?? 0) + ) + } + result(nil) + case "getUpscalerStatus": + let args = try dictionaryArgs(call.arguments) + result(try playerHost(from: args).upscalerStatus()) + case "getOutputStatus": + let args = try dictionaryArgs(call.arguments) + result(try playerHost(from: args).outputStatus()) + case "getPresenterStats": + let args = try dictionaryArgs(call.arguments) + result(try playerHost(from: args).presenterStats()) + case "setDebugHudEnabled": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).setDebugHudEnabled(boolValue(args["enabled"]) ?? false) + result(nil) + case "addExternalSubtitle": + let args = try dictionaryArgs(call.arguments) + guard let uri = args["uri"] as? String, !uri.isEmpty else { + throw ErikaPluginError.invalidArguments("uri is required.") + } + result(try playerHost(from: args).addExternalSubtitle(uri: uri)) + case "removeSubtitleTrack": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).removeSubtitleTrack(trackId: try requiredInt64(args["trackId"], name: "trackId")) + result(nil) + case "loadDanmakuFile": + let args = try dictionaryArgs(call.arguments) + guard let uri = args["uri"] as? String, !uri.isEmpty else { + throw ErikaPluginError.invalidArguments("uri is required.") + } + try playerHost(from: args).loadDanmakuFile(uri: uri) + result(nil) + case "loadDanmakuJson": + let args = try dictionaryArgs(call.arguments) + guard let json = args["json"] as? String, !json.isEmpty else { + throw ErikaPluginError.invalidArguments("json is required.") + } + try playerHost(from: args).loadDanmakuJson(json) + result(nil) + case "addDanmakuTrackFile": + let args = try dictionaryArgs(call.arguments) + guard let uri = args["uri"] as? String, !uri.isEmpty else { + throw ErikaPluginError.invalidArguments("uri is required.") + } + result(Int64(clamping: try playerHost(from: args).addDanmakuTrackFile( + uri: uri, + name: args["name"] as? String, + offsetMicros: int64Value(args["offsetMicros"]) ?? 0 + ))) + case "addDanmakuTrackJson": + let args = try dictionaryArgs(call.arguments) + guard let json = args["json"] as? String, !json.isEmpty else { + throw ErikaPluginError.invalidArguments("json is required.") + } + result(Int64(clamping: try playerHost(from: args).addDanmakuTrackJson( + json, + name: args["name"] as? String, + offsetMicros: int64Value(args["offsetMicros"]) ?? 0 + ))) + case "removeDanmakuTrack": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).removeDanmakuTrack(trackId: try requiredUInt64(args["trackId"], name: "trackId")) + result(nil) + case "setDanmakuTrackEnabled": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).setDanmakuTrackEnabled( + trackId: try requiredUInt64(args["trackId"], name: "trackId"), + enabled: boolValue(args["enabled"]) ?? true + ) + result(nil) + case "setDanmakuTrackOffset": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).setDanmakuTrackOffset( + trackId: try requiredUInt64(args["trackId"], name: "trackId"), + offsetMicros: int64Value(args["offsetMicros"]) ?? 0 + ) + result(nil) + case "setDanmakuGlobalOffset": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).setDanmakuGlobalOffset(offsetMicros: int64Value(args["offsetMicros"]) ?? 0) + result(nil) + case "danmakuTracks": + result(try playerHost(from: try dictionaryArgs(call.arguments)).danmakuTracks()) + case "clearDanmaku": + try playerHost(from: try dictionaryArgs(call.arguments)).clearDanmaku() + result(nil) + case "setDanmakuEnabled": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).setDanmakuEnabled(boolValue(args["enabled"]) ?? true) + result(nil) + case "setDanmakuConfig": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + try host.setDanmakuConfig( + danmakuConfig(from: args, base: host.danmakuConfigSnapshot()) + ) + if args.keys.contains("customFontFamily") || args.keys.contains("customFontFilePath") { + try host.setDanmakuFont( + family: args["customFontFamily"] as? String, + filePath: args["customFontFilePath"] as? String + ) + } + if let blockWordsJson = args["blockWordsJson"] as? String { + try host.setDanmakuBlockWordsJson(blockWordsJson) + } + result(nil) + case "selectAudioTrack": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).selectAudioTrack(trackId: optionalTrackId(args["trackId"])) + result(nil) + case "selectSubtitleTrack": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).selectSubtitleTrack(trackId: optionalTrackId(args["trackId"])) + result(nil) + case "tracks": + result(try playerHost(from: try dictionaryArgs(call.arguments)).tracks()) + case "screenshot": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + let view = try optionalVideoView(from: args, host: host) + let width = int64Value(args["width"]).map(Int.init) + let height = int64Value(args["height"]).map(Int.init) + if let data = host.screenshot(view: view, width: width, height: height) { + result(FlutterStandardTypedData(bytes: data)) + } else { + result(nil) + } + case "attachView": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + let viewId = try requiredInt64(args["viewId"], name: "viewId") + guard let view = views[viewId]?.view else { + throw ErikaPluginError.viewNotFound(viewId) + } + try host.attach(view: view) + result(nil) + case "detachView": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + let viewId = try requiredInt64(args["viewId"], name: "viewId") + host.detach(viewId: viewId) + result(nil) + case "attachOverlay": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + let overlay = try ensureWindowOverlayInstalled() + try host.attach(view: overlay) + result(erikaWindowHostedVideoSurfaceId) + case "detachOverlay": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + let generation = int64Value(args["generation"]) + let overlay = resolveWindowOverlay() + // A disposing widget can fire detachOverlay after a newer widget has + // already re-attached the shared overlay surface. Skip the teardown so + // the stale detach cannot stop the live surface's display link and + // leave a frozen, non-rendering overlay on screen. + if let generation, + let activeGeneration = overlay?.activeGeneration, + generation != activeGeneration { + result(nil) + return + } + host.detach(viewId: erikaWindowHostedVideoSurfaceId) + overlay?.updateOverlayFrame( + nil, + visible: false, + debugLabel: nil, + generation: generation + ) + result(nil) + case "setOverlayFrame": + let args = try dictionaryArgs(call.arguments) + let overlay = try ensureWindowOverlayInstalled() + let visible = boolValue(args["visible"]) ?? true + let frame = convertedOverlayRect(from: args, targetView: overlay) + overlay.updateOverlayFrame( + frame, + visible: visible, + debugLabel: args["debugLabel"] as? String, + generation: int64Value(args["generation"]) + ) + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } catch { + result(flutterError(error)) + } + } + + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + Self.sharedEventSink = events + startPollTimerIfNeeded() + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + Self.sharedEventSink = nil + pollTimer?.invalidate() + pollTimer = nil + return nil + } + + fileprivate func registerView(_ view: ErikaMetalSurfaceView, viewId: Int64) { + views[viewId] = WeakErikaVideoPlatformViewBox(view: view) + } + + fileprivate func unregisterView(viewId: Int64) { + views.removeValue(forKey: viewId) + for host in players.values { + host.detach(viewId: viewId) + } + } + + fileprivate func resizePlayerAttachedToView(viewId: Int64) { + for host in players.values { + if let attachedPlayerId = views[viewId]?.view?.attachedPlayerId, + attachedPlayerId == host.id { + host.resizeFromAttachedView() + } + } + } + + fileprivate func detachOverlayView(_ view: ErikaWindowOverlayView) { + for host in players.values { + host.detach(viewId: view.platformViewId) + } + if views[view.platformViewId]?.view === view { + views.removeValue(forKey: view.platformViewId) + } + if view.window?.erikaWindowOverlayView === view { + view.window?.erikaWindowOverlayView = nil + } + } + + private func ensureWindowOverlayInstalled() throws -> ErikaWindowOverlayView { + if let existing = resolveWindowOverlay(), + existing.superview != nil { + return existing + } + + guard let flutterHostView = currentFlutterHostView() else { + throw ErikaPluginError.overlayNotAvailable + } + guard let hostWindow = flutterHostView.window else { + throw ErikaPluginError.overlayNotAvailable + } + let hostSuperview = flutterHostView.superview ?? hostWindow + + prepareFlutterHostViewForWindowOverlay(flutterHostView) + + let overlay = hostWindow.erikaWindowOverlayView ?? + ErikaWindowOverlayView(plugin: self) + overlay.plugin = self + + if overlay.superview !== hostSuperview { + overlay.removeFromSuperview() + overlay.frame = .zero + } + if flutterHostView.superview === hostSuperview, + shouldPlaceWindowOverlayAboveFlutter() { + hostSuperview.insertSubview(overlay, aboveSubview: flutterHostView) + } else if flutterHostView.superview === hostSuperview { + hostSuperview.insertSubview(overlay, belowSubview: flutterHostView) + } else if shouldPlaceWindowOverlayAboveFlutter() { + hostSuperview.addSubview(overlay) + } else { + hostSuperview.insertSubview(overlay, at: 0) + } + + hostWindow.erikaWindowOverlayView = overlay + registerView(overlay, viewId: overlay.platformViewId) + return overlay + } + + private func resolveWindowOverlay() -> ErikaWindowOverlayView? { + for window in activeWindows() { + if let overlay = window.erikaWindowOverlayView { + return overlay + } + } + return nil + } + + private func currentFlutterHostView() -> UIView? { + for window in activeWindows() { + if let controller = findFlutterViewController(from: window.rootViewController) { + return controller.view + } + } + return activeWindows().first?.rootViewController?.view + } + + private func activeWindows() -> [UIWindow] { + let scenes = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { + $0.activationState == .foregroundActive || + $0.activationState == .foregroundInactive + } + let windows = scenes.flatMap(\.windows).filter { !$0.isHidden } + return windows.sorted { lhs, rhs in + if lhs.isKeyWindow != rhs.isKeyWindow { + return lhs.isKeyWindow + } + return lhs.windowLevel.rawValue > rhs.windowLevel.rawValue + } + } + + private func findFlutterViewController(from controller: UIViewController?) -> FlutterViewController? { + guard let controller else { + return nil + } + if let flutter = controller as? FlutterViewController { + return flutter + } + if let presented = findFlutterViewController(from: controller.presentedViewController) { + return presented + } + if let navigation = controller as? UINavigationController, + let visible = findFlutterViewController(from: navigation.visibleViewController) { + return visible + } + if let tab = controller as? UITabBarController, + let selected = findFlutterViewController(from: tab.selectedViewController) { + return selected + } + for child in controller.children { + if let flutter = findFlutterViewController(from: child) { + return flutter + } + } + return nil + } + + private func prepareFlutterHostViewForWindowOverlay(_ view: UIView) { + if shouldPlaceWindowOverlayAboveFlutter() { + return + } + view.isOpaque = false + view.backgroundColor = .clear + view.layer.isOpaque = false + view.layer.backgroundColor = UIColor.clear.cgColor + view.window?.backgroundColor = .black + } + + private func shouldPlaceWindowOverlayAboveFlutter() -> Bool { + let environment = ProcessInfo.processInfo.environment + if environment["ERIKA_WINDOW_OVERLAY_BELOW"] == "1" { + return false + } + return environment["ERIKA_WINDOW_OVERLAY_ABOVE"] == "1" + } + + private func convertedOverlayRect( + from args: [String: Any], + targetView: UIView + ) -> CGRect? { + guard let x = doubleValue(args["x"]), + let y = doubleValue(args["y"]), + let width = doubleValue(args["width"]), + let height = doubleValue(args["height"]) else { + return nil + } + guard width > 0, height > 0 else { + return nil + } + guard let flutterHostView = currentFlutterHostView(), + let targetSuperview = targetView.superview else { + return CGRect(x: x, y: y, width: width, height: height) + } + let rect = CGRect(x: x, y: y, width: width, height: height) + return flutterHostView.convert(rect, to: targetSuperview) + } + + private func createPlayer(arguments: Any?) throws -> Int64 { + guard let library = ErikaNativeLibrary.shared else { + throw ErikaPluginError.libraryNotFound(["main executable", "ERIKA_CAPI_DYLIB", "app bundle"]) + } + let args = arguments as? [String: Any] + let hdrDebug = boolValue(args?["hdrDebug"]) ?? + boolEnvironmentFlag("ERIKA_HDR_DEBUG", environment: ProcessInfo.processInfo.environment) + let config = presenterConfigForNewPlayer(arguments: arguments, hdrDebug: hdrDebug) + let id = nextPlayerId + nextPlayerId += 1 + players[id] = try ErikaPlayerHost(id: id, library: library, config: config, hdrDebug: hdrDebug) + startPollTimerIfNeeded() + return id + } + + 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 + let config = explicitMode == 1 ? ErikaPresenterConfigC.appleEdr(headroom: headroom) : .sdr + erikaHdrLog( + hdrDebug, + "create explicit outputMode=\(explicitMode) requestedHeadroom=\(String(format: "%.3f", headroom)) selected=\(erikaOutputModeLabel(config))" + ) + return config + } + let headroom = resolvedEdrHeadroom(hdrDebug: hdrDebug) + let config = headroom > 1.0 ? ErikaPresenterConfigC.appleEdr(headroom: headroom) : .sdr + erikaHdrLog( + hdrDebug, + "create auto selected=\(erikaOutputModeLabel(config)) resolvedHeadroom=\(String(format: "%.3f", headroom))" + ) + return config + } + + private func resolvedEdrHeadroom(hdrDebug: Bool) -> Float { + let environment = ProcessInfo.processInfo.environment + if boolEnvironmentFlag("ERIKA_DISABLE_EDR", environment: environment) { + erikaHdrLog(hdrDebug, "EDR disabled by ERIKA_DISABLE_EDR") + return 1.0 + } + if let override = floatEnvironmentValue("ERIKA_EDR_HEADROOM", environment: environment), override > 1.0 { + erikaHdrLog(hdrDebug, "EDR headroom override ERIKA_EDR_HEADROOM=\(String(format: "%.3f", override))") + return override + } + let screenHeadroom = currentScreenEdrHeadroom(hdrDebug: hdrDebug) + if screenHeadroom > 1.0 { return screenHeadroom } + if boolEnvironmentFlag("ERIKA_ENABLE_EDR", environment: environment) { + erikaHdrLog(hdrDebug, "EDR forced by ERIKA_ENABLE_EDR") + return 4.0 + } + return 1.0 + } + + private func currentScreenEdrHeadroom(hdrDebug: Bool) -> Float { + let screen = UIScreen.main + var samples: [String] = [] + for key in ["potentialEDRHeadroom", "currentEDRHeadroom", "maximumPotentialExtendedDynamicRangeColorComponentValue"] { + let selector = Selector(key) + if screen.responds(to: selector), let number = screen.value(forKey: key) as? NSNumber { + let value = number.floatValue + samples.append("\(key)=\(String(format: "%.3f", value))") + if value.isFinite && value > 1.0 { + erikaHdrLog( + hdrDebug, + "screen headroom selected \(key)=\(String(format: "%.3f", value)) \(erikaScreenSummary(screen)) samples=[\(samples.joined(separator: ", "))]" + ) + return value + } + } else { + samples.append("\(key)=unavailable") + } + } + erikaHdrLog( + hdrDebug, + "screen headroom fallback=1.000 \(erikaScreenSummary(screen)) samples=[\(samples.joined(separator: ", "))]" + ) + return 1.0 + } + + private func startPollTimerIfNeeded() { + guard pollTimer == nil else { return } + let timer = Timer(timeInterval: 0.25, repeats: true) { [weak self] _ in + guard let self else { return } + let sink = Self.sharedEventSink + for host in self.players.values { + host.pollEvents(sendEvent: sink) + } + } + pollTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + + private func playerHost(from args: [String: Any]) throws -> ErikaPlayerHost { + let playerId = try requiredInt64(args["playerId"], name: "playerId") + guard let host = players[playerId] else { + throw ErikaPluginError.playerNotFound(playerId) + } + return host + } + + private func optionalVideoView( + from args: [String: Any], + host: ErikaPlayerHost + ) throws -> ErikaMetalSurfaceView? { + guard let viewId = int64Value(args["viewId"]) else { + return nil + } + guard let view = views[viewId]?.view, + view.attachedPlayerId == host.id else { + throw ErikaPluginError.viewNotFound(viewId) + } + return view + } + + private func optionalTrackId(_ value: Any?) throws -> Int64? { + if value == nil || value is NSNull { return nil } + guard let trackId = int64Value(value) else { + throw ErikaPluginError.invalidArguments("trackId must be an integer or null.") + } + return trackId >= 0 ? trackId : nil + } + + private func danmakuConfig( + from args: [String: Any], + base: ErikaDanmakuConfigC + ) -> ErikaDanmakuConfigC { + var config = base + if let value = boolValue(args["enabled"]) { config.enabled = value ? 1 : 0 } + if let value = doubleValue(args["fontSize"]) { config.fontSize = Float(value) } + if let value = doubleValue(args["opacity"]) { config.opacity = Float(value) } + if let value = doubleValue(args["displayArea"]) { config.displayArea = Float(value) } + if let value = doubleValue(args["scrollDurationSeconds"]) { config.scrollDurationSeconds = Float(value) } + if let value = doubleValue(args["scrollSpeedFactor"]) { config.scrollSpeedFactor = Float(value) } + if let value = doubleValue(args["trackGapRatio"]) { config.trackGapRatio = Float(value) } + if let value = doubleValue(args["outlineWidth"]) { config.outlineWidth = Float(value) } + if let value = doubleValue(args["shadowOffsetX"]) { config.shadowOffsetX = Float(value) } + if let value = doubleValue(args["shadowOffsetY"]) { config.shadowOffsetY = Float(value) } + if let value = boolValue(args["mergeDuplicates"]) { config.mergeDuplicates = value ? 1 : 0 } + if let value = boolValue(args["allowStacking"]) { config.allowStacking = value ? 1 : 0 } + if let value = boolValue(args["allowScrollOverwrite"]) { config.allowScrollOverwrite = value ? 1 : 0 } + if let value = int64Value(args["maxQuantity"]), value > 0 { config.maxQuantity = UInt32(clamping: value) } + if let value = int64Value(args["maxLinesPerMode"]), value > 0 { config.maxLinesPerMode = UInt32(clamping: value) } + if let value = boolValue(args["blockTop"]) { config.blockTop = value ? 1 : 0 } + if let value = boolValue(args["blockBottom"]) { config.blockBottom = value ? 1 : 0 } + if let value = boolValue(args["blockScroll"]) { config.blockScroll = value ? 1 : 0 } + if let value = int64Value(args["shadowStyle"]) { config.shadowStyle = Int32(clamping: value) } + return config + } + + private func dictionaryArgs(_ arguments: Any?) throws -> [String: Any] { + guard let args = arguments as? [String: Any] else { + throw ErikaPluginError.invalidArguments("Arguments must be a dictionary.") + } + return args + } + + private func int32Value(_ value: Any?) -> Int32? { + if let value = value as? Int32 { return value } + if let value = value as? NSNumber { return value.int32Value } + if let value = value as? String { return Int32(value) } + return nil + } + + private func int64Value(_ value: Any?) -> Int64? { + if let value = value as? Int64 { return value } + if let value = value as? NSNumber { return value.int64Value } + if let value = value as? String { return Int64(value) } + return nil + } + + private func doubleValue(_ value: Any?) -> Double? { + if let value = value as? Double { return value } + if let value = value as? NSNumber { return value.doubleValue } + if let value = value as? String { return Double(value) } + return nil + } + + private func floatValue(_ value: Any?) -> Float? { + if let value = value as? Float, value.isFinite { return value } + if let value = value as? Double, value.isFinite { return Float(value) } + if let value = value as? NSNumber { + let result = value.floatValue + return result.isFinite ? result : nil + } + if let value = value as? String, let result = Float(value), result.isFinite { return result } + return nil + } + + private func boolValue(_ value: Any?) -> Bool? { + if let value = value as? Bool { return value } + if let value = value as? NSNumber { return value.boolValue } + if let value = value as? String { + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "on": return true + case "0", "false", "no", "off": return false + default: return nil + } + } + return nil + } + + private func requiredInt64(_ value: Any?, name: String) throws -> Int64 { + if let value = int64Value(value) { return value } + throw ErikaPluginError.invalidArguments("\(name) is required.") + } + + private func requiredUInt64(_ value: Any?, name: String) throws -> UInt64 { + if let value = value as? UInt64 { return value } + if let value = value as? Int64, value >= 0 { return UInt64(value) } + if let value = value as? NSNumber { return value.uint64Value } + if let value = value as? String, let parsed = UInt64(value) { return parsed } + throw ErikaPluginError.invalidArguments("\(name) is required.") + } + + private func boolEnvironmentFlag(_ name: String, environment: [String: String]) -> Bool { + switch environment[name]?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "on": return true + default: return false + } + } + + private func floatEnvironmentValue(_ name: String, environment: [String: String]) -> Float? { + guard let raw = environment[name]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty, + let value = Float(raw), + value.isFinite else { + return nil + } + return value + } + + private func flutterError(_ error: Error) -> FlutterError { + FlutterError(code: "ERIKA_ERROR", message: String(describing: error), details: nil) + } +} + +private extension ErikaEventC { + func toFlutterMap( + playerId: Int64, + host: ErikaPlayerHost? = nil, + structuredMessage: String? = nil + ) -> [String: Any] { + var map: [String: Any] = [ + "playerId": playerId, + "kind": Int(kind), + "status": Int(status), + "state": Int(state), + "durationMicros": Int(durationMicros), + "positionMicros": Int64(positionMicros), + "buffering": buffering != 0, + "video": [ + "width": Int(video.width), + "height": Int(video.height), + "primaries": Int(video.primaries), + "transfer": Int(video.transfer), + ], + "tracks": [ + "video": Int(tracks.video), + "audio": Int(tracks.audio), + "subtitle": Int(tracks.subtitle), + ], + ] + if kind == 4 || kind == 10 { + map["trackList"] = (try? host?.tracks()) ?? [] + map["trackSelection"] = (try? host?.trackSelection()) ?? [ + "video": -1, + "audio": -1, + "subtitle": -1, + ] + } + if let structuredMessage, + let data = structuredMessage.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if kind == 11 { + map["decoder"] = payload + } else if kind == 12 { + map["audio"] = payload + } else if kind == 9 { + map["error"] = structuredMessage + } + } + return map + } +} + +private extension ErikaTrackSelectionC { + func toFlutterMap() -> [String: Any] { + ["video": Int(video), "audio": Int(audio), "subtitle": Int(subtitle)] + } +} + +private extension ErikaUpscalerStatusC { + func toFlutterMap() -> [String: Any] { + [ + "requestedMode": Int(requestedMode), + "activeBackend": Int(activeBackend), + "fallbackCount": Int64(clamping: fallbackCount), + "upscaledFrames": Int64(clamping: upscaledFrames), + "lastEncodeMicros": Int64(clamping: lastEncodeMicros), + "lastGpuMicros": Int64(clamping: lastGpuMicros), + ] + } +} + +private extension ErikaOutputStatusC { + func toFlutterMap() -> [String: Any] { + [ + "requestedMode": Int(requestedMode), + "activeEncoding": Int(activeEncoding), + "surfaceFormat": Int(surfaceFormat), + "nativeDataSpace": Int(nativeDataSpace), + "requestedHeadroom": Double(requestedHeadroom), + "activeHeadroom": Double(activeHeadroom), + "activeHeadroomKnown": activeHeadroomKnown, + "extendedLinearActive": extendedLinearActive, + "fallbackReason": Int(fallbackReason), + "fallbackCount": Int64(clamping: fallbackCount), + "dataSpaceFailures": Int64(clamping: dataSpaceFailures), + "headroomUpdates": Int64(clamping: headroomUpdates), + "extendedLinearFrames": Int64(clamping: extendedLinearFrames), + ] + } +} + +private extension ErikaPresenterStatsC { + func toFlutterMap() -> [String: Any] { + [ + "decodedVideoFrames": Int64(clamping: decodedVideoFrames), + "renderedVideoFrames": Int64(clamping: renderedVideoFrames), + "renderedTestFrames": Int64(clamping: renderedTestFrames), + "pushedAudioFrames": Int64(clamping: pushedAudioFrames), + "overlayFrames": Int64(clamping: overlayFrames), + "danmakuFrames": Int64(clamping: danmakuFrames), + "danmakuItems": Int64(clamping: danmakuItems), + "importFailures": Int64(clamping: importFailures), + "renderFailures": Int64(clamping: renderFailures), + "audioFailures": Int64(clamping: audioFailures), + "softwareVideoFrames": Int64(clamping: softwareVideoFrames), + "hardwareVideoFrames": Int64(clamping: hardwareVideoFrames), + "zeroCopyVideoFrames": Int64(clamping: zeroCopyVideoFrames), + "cpuVideoFrameFallbacks": Int64(clamping: cpuVideoFrameFallbacks), + "lastRenderMicros": Int64(clamping: lastRenderMicros), + "lastRenderCurrentMicros": Int64(clamping: lastRenderCurrentMicros), + "audioClockReadFrames": Int64(clamping: audioClockReadFrames), + "audioClockQueuedFrames": Int64(clamping: audioClockQueuedFrames), + "audioClockUnderflowFrames": Int64(clamping: audioClockUnderflowFrames), + "audioRecoveryState": Int(audioRecoveryState), + "audioLastErrorCode": Int(audioLastErrorCode), + "audioRecoveryAttempts": Int64(clamping: audioRecoveryAttempts), + "audioRecoveryCount": Int64(clamping: audioRecoveryCount), + "audioRecoveryFailures": Int64(clamping: audioRecoveryFailures), + "directZeroCopyVideoFrames": Int64(clamping: directZeroCopyVideoFrames), + "sharedHandleVideoFrames": Int64(clamping: sharedHandleVideoFrames), + "hdrSourceFrames": Int64(clamping: hdrSourceFrames), + "hdr10OutputFrames": Int64(clamping: hdr10OutputFrames), + "sdrTonemapFrames": Int64(clamping: sdrTonemapFrames), + "hdr10MetadataUpdates": Int64(clamping: hdr10MetadataUpdates), + "hdr10MetadataFailures": Int64(clamping: hdr10MetadataFailures), + "hdr10OutputFailures": Int64(clamping: hdr10OutputFailures), + "hdr10OutputActive": hdr10OutputActive, + "videoFrameBackpressureDrops": Int64(clamping: videoFrameBackpressureDrops), + ] + } +} + +private extension ErikaTrackInfoC { + func toFlutterMap() -> [String: Any] { + [ + "id": Int(id), + "kind": Int(kind), + "source": Int(source), + "selected": selected != 0, + "canRemove": canRemove != 0, + "title": title.map { String(cString: $0) } as Any, + "language": language.map { String(cString: $0) } as Any, + "codec": codec.map { String(cString: $0) } as Any, + "width": Int(width), + "height": Int(height), + "sampleRate": Int(sampleRate), + "channels": Int(channels), + "pixelFormat": pixelFormat.map { String(cString: $0) } as Any, + "sampleFormat": sampleFormat.map { String(cString: $0) } as Any, + "profile": profile.map { String(cString: $0) } as Any, + "level": Int(level), + "bitRate": Int64(clamping: bitRate), + "frameRateNumerator": Int(frameRateNumerator), + "frameRateDenominator": Int(frameRateDenominator), + ] + } +} + +private extension ErikaDanmakuTrackInfoC { + func toFlutterMap() -> [String: Any] { + [ + "id": Int64(clamping: id), + "enabled": enabled != 0, + "offsetMicros": offsetMicros, + "itemCount": itemCount, + "name": name.map { String(cString: $0) } as Any, + "source": source.map { String(cString: $0) } as Any, + ] + } +} + +private func withOptionalCString(_ value: String?, _ body: (UnsafePointer?) -> R) -> R { + guard let value, !value.isEmpty else { + return body(nil) + } + return value.withCString { pointer in body(pointer) } +} diff --git a/packages/erika_flutter/tvos/erika_flutter.podspec b/packages/erika_flutter/tvos/erika_flutter.podspec new file mode 100644 index 0000000..06c8e62 --- /dev/null +++ b/packages/erika_flutter/tvos/erika_flutter.podspec @@ -0,0 +1,214 @@ +Pod::Spec.new do |s| + erika_cabi_symbols = %w[ + erika_danmaku_track_info_free + erika_presenter_add_danmaku_track_file + erika_presenter_add_danmaku_track_json + erika_presenter_add_external_subtitle + erika_presenter_attach_metal_layer + erika_presenter_clear_danmaku + erika_presenter_close + erika_presenter_create + erika_presenter_create_with_output_mode + erika_presenter_danmaku_tracks + erika_presenter_destroy + erika_presenter_detach_surface + erika_presenter_get_danmaku_config + erika_presenter_get_upscaler_status + erika_presenter_load_danmaku_file + erika_presenter_load_danmaku_json + erika_presenter_open + erika_presenter_open_with_headers + erika_presenter_pause + erika_presenter_play + erika_presenter_poll_event + erika_presenter_remove_danmaku_track + erika_presenter_remove_subtitle_track + erika_presenter_render_tick + erika_presenter_resize_surface + erika_presenter_seek + erika_presenter_select_audio_track + erika_presenter_select_subtitle_track + erika_presenter_set_danmaku_block_words_json + erika_presenter_set_danmaku_config_ptr + erika_presenter_set_danmaku_enabled + erika_presenter_set_danmaku_font + erika_presenter_set_danmaku_global_offset + erika_presenter_set_danmaku_track_enabled + erika_presenter_set_danmaku_track_offset + erika_presenter_set_playback_rate + erika_presenter_set_subtitle_scale + erika_presenter_set_upscaler + erika_presenter_set_volume + erika_presenter_stop + erika_presenter_track_selection + erika_presenter_tracks + erika_track_info_free + ] + erika_cabi_undefined_flags = erika_cabi_symbols + .map { |symbol| "-Wl,-u,_#{symbol}" } + .join(' ') + + s.name = 'erika_flutter' + s.version = '0.1.4' + s.summary = 'Flutter embedder glue for the Erika Rust media engine.' + s.description = <<-DESC +Flutter tvOS plugin that hosts a CAMetalLayer and drives Erika through its C ABI. + DESC + s.homepage = 'https://github.com/AimesSoft/Erika' + s.license = { :type => 'MPL-2.0' } + s.author = { 'AimesSoft' => 'dev@aimesoft.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + # Flutter.framework is supplied by the flutter-tvos host app. + s.platform = :tvos, '13.0' + s.swift_version = '5.0' + s.xcconfig = { + 'FRAMEWORK_SEARCH_PATHS' => '"${PODS_ROOT}/../Flutter"', + 'OTHER_SWIFT_FLAGS' => '$(inherited) -DTARGET_OS_TV', + } + s.script_phase = { + :name => 'Build Erika C ABI', + :execution_position => :before_compile, + :input_files => ['${BUILT_PRODUCTS_DIR}/erika_capi_phony'], + :output_files => ['${PODS_TARGET_SRCROOT}/native/liberika_capi.a'], + :script => <<-SCRIPT +set -eu + +export PATH="$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" + +PLUGIN_TVOS_DIR="$(cd "$PODS_TARGET_SRCROOT" && pwd -P)" +ERIKA_ROOT="$(cd "$PLUGIN_TVOS_DIR/../../.." && pwd -P)" +ERIKA_NATIVE_PROFILE="${ERIKA_NATIVE_PROFILE:-lgpl}" +HOST_JOBS="$(sysctl -n hw.ncpu 2>/dev/null || echo 4)" +ARCH="${CURRENT_ARCH:-}" +if [ -z "$ARCH" ] || [ "$ARCH" = "undefined_arch" ]; then + ARCH="${ARCHS%% *}" +fi + +case "${PLATFORM_NAME:-appletvos}" in + appletvos) + RUST_TARGET="aarch64-apple-tvos" + BINDGEN_CLANG_TARGET="arm64-apple-tvos" + BINDGEN_SDK="appletvos" + ;; + appletvsimulator) + if [ "$ARCH" = "x86_64" ]; then + RUST_TARGET="x86_64-apple-tvos" + BINDGEN_CLANG_TARGET="x86_64-apple-tvos-simulator" + else + RUST_TARGET="aarch64-apple-tvos-sim" + BINDGEN_CLANG_TARGET="arm64-apple-tvos-simulator" + fi + BINDGEN_SDK="appletvsimulator" + ;; + *) + echo "error: unsupported Erika tvOS platform: ${PLATFORM_NAME:-unknown}" >&2 + exit 1 + ;; +esac + +if [ -n "${ERIKA_TVOS_CAPI_PROFILE:-}" ]; then + CARGO_PROFILE="$ERIKA_TVOS_CAPI_PROFILE" +elif [ "${CONFIGURATION:-Debug}" = "Release" ]; then + CARGO_PROFILE="release" +else + CARGO_PROFILE="debug" +fi + +if [ "$CARGO_PROFILE" = "release" ]; then + CARGO_ARGS="--release" +elif [ "$CARGO_PROFILE" = "debug" ]; then + CARGO_ARGS="" +else + echo "error: unsupported ERIKA_TVOS_CAPI_PROFILE=$CARGO_PROFILE" >&2 + exit 1 +fi + +if ! command -v rustup >/dev/null 2>&1; then + echo "error: rustup is required to build Erika for tvOS" >&2 + exit 1 +fi +if ! rustup run nightly rustc --version >/dev/null 2>&1; then + rustup toolchain install nightly --profile minimal --component rust-src +elif ! rustup component list --toolchain nightly --installed | grep -q '^rust-src'; then + rustup component add rust-src --toolchain nightly +fi +export TVOS_DEPLOYMENT_TARGET="${TVOS_DEPLOYMENT_TARGET:-13.0}" + +BINDGEN_SDKROOT="$(xcrun --sdk "$BINDGEN_SDK" --show-sdk-path)" +BINDGEN_TARGET_ENV="$(echo "$RUST_TARGET" | tr '-' '_')" +export "BINDGEN_EXTRA_CLANG_ARGS_$BINDGEN_TARGET_ENV=--target=$BINDGEN_CLANG_TARGET -isysroot $BINDGEN_SDKROOT" + +if [ -z "${ERIKA_FFMPEG_DIR:-}" ]; then + ERIKA_FFMPEG_DIR="$ERIKA_ROOT/third_party/dist/$RUST_TARGET/$ERIKA_NATIVE_PROFILE/ffmpeg" +fi +ERIKA_TARGET_DIST="$ERIKA_ROOT/third_party/dist/$RUST_TARGET/$ERIKA_NATIVE_PROFILE" +ERIKA_DAV1D_DIR="${ERIKA_DAV1D_DIR:-$ERIKA_TARGET_DIST/dav1d}" +ERIKA_LIBASS_DIR="${ERIKA_LIBASS_DIR:-$ERIKA_TARGET_DIST/libass}" +ERIKA_FREETYPE_DIR="${ERIKA_FREETYPE_DIR:-$ERIKA_TARGET_DIST/freetype}" +ERIKA_HARFBUZZ_DIR="${ERIKA_HARFBUZZ_DIR:-$ERIKA_TARGET_DIST/harfbuzz}" +ERIKA_FRIBIDI_DIR="${ERIKA_FRIBIDI_DIR:-$ERIKA_TARGET_DIST/fribidi}" +ERIKA_DAV1D_MARKER="$ERIKA_ROOT/third_party/build/$RUST_TARGET/$ERIKA_NATIVE_PROFILE/dav1d/dav1d-built.txt" + +# Optional: use a prebuilt static lib from a GitHub Release (opt-in). +# Enable with ERIKA_PREBUILT=1; ERIKA_PREBUILT_TAG selects the tag (default +# v0.1.4). Any failure falls through to the source build below, so enabling it +# never breaks a build. ERIKA_TVOS_CAPI_STATICLIB still takes precedence. +PREBUILT_LIB="" +if [ "${ERIKA_FORCE_SOURCE_BUILD:-0}" != "1" ] && [ "${ERIKA_PREBUILT:-0}" = "1" ] && [ -z "${ERIKA_TVOS_CAPI_STATICLIB:-}" ]; then + PREBUILT_TAG="${ERIKA_PREBUILT_TAG:-v0.1.4}" + PREBUILT_WORK="$ERIKA_ROOT/target/erika-prebuilt-tvos" + PREBUILT_ZIP="$PREBUILT_WORK/erika-capi-tvos.zip" + PREBUILT_URL="https://github.com/AimesSoft/Erika/releases/download/$PREBUILT_TAG/erika-capi-tvos.zip" + rm -rf "$PREBUILT_WORK" + mkdir -p "$PREBUILT_WORK" + echo "Erika: downloading prebuilt $PREBUILT_URL" + if curl -fSL --retry 3 -o "$PREBUILT_ZIP" "$PREBUILT_URL" && unzip -oq "$PREBUILT_ZIP" -d "$PREBUILT_WORK"; then + XCF="$(find "$PREBUILT_WORK" -type d -name 'erika_capi.xcframework' | head -1)" + if [ -n "$XCF" ]; then + case "${PLATFORM_NAME:-appletvos}" in + appletvsimulator) SLICE="$(find "$XCF" -maxdepth 1 -type d -name '*simulator*' | head -1)" ;; + *) SLICE="$(find "$XCF" -maxdepth 1 -type d -name 'tvos-*' ! -name '*simulator*' | head -1)" ;; + esac + if [ -n "${SLICE:-}" ] && [ -f "$SLICE/liberika_capi.a" ]; then + PREBUILT_LIB="$SLICE/liberika_capi.a" + echo "Erika: using prebuilt $PREBUILT_TAG -> $PREBUILT_LIB" + fi + fi + fi + [ -n "$PREBUILT_LIB" ] || echo "Erika: prebuilt unavailable; building from source" +fi + +if [ -n "${ERIKA_TVOS_CAPI_STATICLIB:-}" ]; then + LIB_SOURCE="$ERIKA_TVOS_CAPI_STATICLIB" +elif [ -n "$PREBUILT_LIB" ]; then + LIB_SOURCE="$PREBUILT_LIB" +else + if [ ! -f "$ERIKA_FFMPEG_DIR/include/libavformat/avformat.h" ] || [ ! -f "$ERIKA_DAV1D_DIR/include/dav1d/dav1d.h" ] || [ ! -f "$ERIKA_DAV1D_DIR/lib/libdav1d.a" ] || [ ! -f "$ERIKA_DAV1D_MARKER" ] || ! grep -qx 'dav1d=1.5.1' "$ERIKA_DAV1D_MARKER" || [ ! -f "$ERIKA_LIBASS_DIR/lib/libass.a" ]; then + echo "Building Erika native dependencies for $RUST_TARGET ($ERIKA_NATIVE_PROFILE, with libass)" + (cd "$ERIKA_ROOT" && cargo run -p xtask -- deps build --all --profile "$ERIKA_NATIVE_PROFILE" --target "$RUST_TARGET" --jobs "$HOST_JOBS") + fi + LIB_SOURCE="$ERIKA_ROOT/target/$RUST_TARGET/$CARGO_PROFILE/liberika_capi.a" + echo "Building Erika C ABI staticlib for $RUST_TARGET ($CARGO_PROFILE)" + (cd "$ERIKA_ROOT" && ERIKA_NATIVE_PROFILE="$ERIKA_NATIVE_PROFILE" ERIKA_NATIVE_TARGET="$RUST_TARGET" ERIKA_FFMPEG_DIR="$ERIKA_FFMPEG_DIR" ERIKA_DAV1D_DIR="$ERIKA_DAV1D_DIR" ERIKA_LIBASS_DIR="$ERIKA_LIBASS_DIR" ERIKA_FREETYPE_DIR="$ERIKA_FREETYPE_DIR" ERIKA_HARFBUZZ_DIR="$ERIKA_HARFBUZZ_DIR" ERIKA_FRIBIDI_DIR="$ERIKA_FRIBIDI_DIR" cargo +nightly rustc -Z build-std=std,panic_abort -p erika_capi --target "$RUST_TARGET" --no-default-features --features libass $CARGO_ARGS --lib --crate-type staticlib) +fi + +if [ ! -f "$LIB_SOURCE" ]; then + echo "error: Erika C ABI static library not found: $LIB_SOURCE" >&2 + echo " Build it with: cargo +nightly rustc -Z build-std=std,panic_abort -p erika_capi --target $RUST_TARGET $CARGO_ARGS --lib --crate-type staticlib" >&2 + exit 1 +fi + +mkdir -p "$PODS_TARGET_SRCROOT/native" +cp "$LIB_SOURCE" "$PODS_TARGET_SRCROOT/native/liberika_capi.a" +if [ -f "$OBJROOT/XCBuildData/build.db" ]; then + ln -fs "$OBJROOT/XCBuildData/build.db" "$BUILT_PRODUCTS_DIR/erika_capi_phony" +fi + SCRIPT + } + 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", + } +end diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 967308a..4ab9373 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -311,6 +311,9 @@ enum NativeTarget { Aarch64Ios, Aarch64IosSimulator, X86_64IosSimulator, + Aarch64Tvos, + Aarch64TvosSimulator, + X86_64TvosSimulator, X86_64WindowsMsvc, Aarch64WindowsMsvc, Aarch64Android, @@ -329,6 +332,9 @@ impl NativeTarget { "aarch64-apple-ios" => Ok(Self::Aarch64Ios), "aarch64-apple-ios-sim" => Ok(Self::Aarch64IosSimulator), "x86_64-apple-ios" => Ok(Self::X86_64IosSimulator), + "aarch64-apple-tvos" => Ok(Self::Aarch64Tvos), + "aarch64-apple-tvos-sim" => Ok(Self::Aarch64TvosSimulator), + "x86_64-apple-tvos" => Ok(Self::X86_64TvosSimulator), "x86_64-pc-windows-msvc" | "windows-x64" => Ok(Self::X86_64WindowsMsvc), "aarch64-pc-windows-msvc" | "windows-arm64" => Ok(Self::Aarch64WindowsMsvc), "aarch64-linux-android" | "arm64-v8a" => Ok(Self::Aarch64Android), @@ -348,6 +354,9 @@ impl NativeTarget { Self::Aarch64Ios => Some("aarch64-apple-ios"), Self::Aarch64IosSimulator => Some("aarch64-apple-ios-sim"), Self::X86_64IosSimulator => Some("x86_64-apple-ios"), + Self::Aarch64Tvos => Some("aarch64-apple-tvos"), + Self::Aarch64TvosSimulator => Some("aarch64-apple-tvos-sim"), + Self::X86_64TvosSimulator => Some("x86_64-apple-tvos"), Self::X86_64WindowsMsvc => Some("x86_64-pc-windows-msvc"), Self::Aarch64WindowsMsvc => Some("aarch64-pc-windows-msvc"), Self::Aarch64Android => Some("aarch64-linux-android"), @@ -364,6 +373,8 @@ impl NativeTarget { Self::Aarch64Macos | Self::X86_64Macos => Some("macosx"), Self::Aarch64Ios => Some("iphoneos"), Self::Aarch64IosSimulator | Self::X86_64IosSimulator => Some("iphonesimulator"), + Self::Aarch64Tvos => Some("appletvos"), + Self::Aarch64TvosSimulator | Self::X86_64TvosSimulator => Some("appletvsimulator"), Self::X86_64WindowsMsvc | Self::Aarch64WindowsMsvc | Self::Aarch64Android @@ -377,8 +388,14 @@ impl NativeTarget { fn ffmpeg_arch(self) -> Option<&'static str> { match self { Self::Host => None, - Self::Aarch64Macos | Self::Aarch64Ios | Self::Aarch64IosSimulator => Some("arm64"), - Self::X86_64Macos | Self::X86_64IosSimulator => Some("x86_64"), + Self::Aarch64Macos + | Self::Aarch64Ios + | Self::Aarch64IosSimulator + | Self::Aarch64Tvos + | Self::Aarch64TvosSimulator => Some("arm64"), + Self::X86_64Macos | Self::X86_64IosSimulator | Self::X86_64TvosSimulator => { + Some("x86_64") + } Self::X86_64WindowsMsvc => Some("x86_64"), Self::Aarch64WindowsMsvc => Some("aarch64"), Self::Aarch64Android => Some("aarch64"), @@ -392,8 +409,14 @@ impl NativeTarget { fn meson_cpu_family(self) -> Option<&'static str> { match self { Self::Host => None, - Self::Aarch64Macos | Self::Aarch64Ios | Self::Aarch64IosSimulator => Some("aarch64"), - Self::X86_64Macos | Self::X86_64IosSimulator => Some("x86_64"), + Self::Aarch64Macos + | Self::Aarch64Ios + | Self::Aarch64IosSimulator + | Self::Aarch64Tvos + | Self::Aarch64TvosSimulator => Some("aarch64"), + Self::X86_64Macos | Self::X86_64IosSimulator | Self::X86_64TvosSimulator => { + Some("x86_64") + } Self::X86_64WindowsMsvc => Some("x86_64"), Self::Aarch64WindowsMsvc => Some("aarch64"), Self::Aarch64Android => Some("aarch64"), @@ -407,8 +430,14 @@ impl NativeTarget { fn meson_cpu(self) -> Option<&'static str> { match self { Self::Host => None, - Self::Aarch64Macos | Self::Aarch64Ios | Self::Aarch64IosSimulator => Some("arm64"), - Self::X86_64Macos | Self::X86_64IosSimulator => Some("x86_64"), + Self::Aarch64Macos + | Self::Aarch64Ios + | Self::Aarch64IosSimulator + | Self::Aarch64Tvos + | Self::Aarch64TvosSimulator => Some("arm64"), + Self::X86_64Macos | Self::X86_64IosSimulator | Self::X86_64TvosSimulator => { + Some("x86_64") + } Self::X86_64WindowsMsvc => Some("x86_64"), Self::Aarch64WindowsMsvc => Some("arm64"), Self::Aarch64Android => Some("aarch64"), @@ -446,6 +475,13 @@ impl NativeTarget { ) } + fn is_tvos(self) -> bool { + matches!( + self, + Self::Aarch64Tvos | Self::Aarch64TvosSimulator | Self::X86_64TvosSimulator + ) + } + fn is_windows(self) -> bool { matches!(self, Self::X86_64WindowsMsvc | Self::Aarch64WindowsMsvc) || (matches!(self, Self::Host) && cfg!(windows)) @@ -459,6 +495,9 @@ impl NativeTarget { | Self::Aarch64Ios | Self::Aarch64IosSimulator | Self::X86_64IosSimulator + | Self::Aarch64Tvos + | Self::Aarch64TvosSimulator + | Self::X86_64TvosSimulator ) || (matches!(self, Self::Host) && cfg!(target_vendor = "apple")) } @@ -489,6 +528,14 @@ impl NativeTarget { env::var("IPHONEOS_DEPLOYMENT_TARGET").unwrap_or_else(|_| "13.0".to_string()), "-mios-simulator-version-min", )), + Self::Aarch64Tvos => Some(( + env::var("TVOS_DEPLOYMENT_TARGET").unwrap_or_else(|_| "13.0".to_string()), + "-mtvos-version-min", + )), + Self::Aarch64TvosSimulator | Self::X86_64TvosSimulator => Some(( + env::var("TVOS_DEPLOYMENT_TARGET").unwrap_or_else(|_| "13.0".to_string()), + "-mtvos-simulator-version-min", + )), Self::X86_64WindowsMsvc | Self::Aarch64WindowsMsvc | Self::Aarch64Android @@ -1139,7 +1186,10 @@ fn dav1d_asm_enabled(target: NativeTarget) -> bool { fn dav1d_requires_nasm(target: NativeTarget) -> bool { matches!( target, - NativeTarget::X86_64Macos | NativeTarget::X86_64IosSimulator | NativeTarget::X86_64Android + NativeTarget::X86_64Macos + | NativeTarget::X86_64IosSimulator + | NativeTarget::X86_64TvosSimulator + | NativeTarget::X86_64Android ) } @@ -1148,6 +1198,7 @@ fn ffmpeg_requires_nasm(target: NativeTarget) -> bool { target, NativeTarget::X86_64Macos | NativeTarget::X86_64IosSimulator + | NativeTarget::X86_64TvosSimulator | NativeTarget::X86_64WindowsMsvc | NativeTarget::X86_64Android ) || (matches!(target, NativeTarget::Host) && cfg!(target_arch = "x86_64")) @@ -1596,6 +1647,8 @@ fn apply_cmake_apple_target(command: &mut Command, target: NativeTarget) -> Resu )); if target.is_ios() { command.arg("-DCMAKE_SYSTEM_NAME=iOS"); + } else if target.is_tvos() { + command.arg("-DCMAKE_SYSTEM_NAME=tvOS"); } apply_apple_target_env(command, target) } @@ -1747,6 +1800,8 @@ fn apply_apple_target_env(command: &mut Command, target: NativeTarget) -> Result command.env("SDKROOT", &config.sdk_root); if target.is_ios() { command.env("IPHONEOS_DEPLOYMENT_TARGET", &config.deployment_target); + } else if target.is_tvos() { + command.env("TVOS_DEPLOYMENT_TARGET", &config.deployment_target); } else { command.env("MACOSX_DEPLOYMENT_TARGET", &config.deployment_target); } @@ -2537,6 +2592,11 @@ fn build_ffmpeg(layout: &WorkspaceLayout, options: DepsOptions) -> Result<()> { | NativeTarget::X86_64IosSimulator => { configure.env("IPHONEOS_DEPLOYMENT_TARGET", &config.deployment_target); } + NativeTarget::Aarch64Tvos + | NativeTarget::Aarch64TvosSimulator + | NativeTarget::X86_64TvosSimulator => { + configure.env("TVOS_DEPLOYMENT_TARGET", &config.deployment_target); + } NativeTarget::Host | NativeTarget::X86_64WindowsMsvc | NativeTarget::Aarch64WindowsMsvc @@ -4632,11 +4692,50 @@ mod tests { ); } + #[test] + fn tvos_targets_map_to_rust_sdks_and_native_architectures() { + let cases = [ + ( + "aarch64-apple-tvos", + NativeTarget::Aarch64Tvos, + "appletvos", + "arm64", + "arm64", + ), + ( + "aarch64-apple-tvos-sim", + NativeTarget::Aarch64TvosSimulator, + "appletvsimulator", + "arm64", + "arm64", + ), + ( + "x86_64-apple-tvos", + NativeTarget::X86_64TvosSimulator, + "appletvsimulator", + "x86_64", + "x86_64", + ), + ]; + + for (triple, expected, sdk, ffmpeg_arch, meson_cpu) in cases { + let target = NativeTarget::parse(triple).unwrap(); + assert_eq!(target, expected); + assert!(target.is_tvos()); + assert!(target.is_apple()); + assert_eq!(target.triple(), Some(triple)); + assert_eq!(target.sdk(), Some(sdk)); + assert_eq!(target.ffmpeg_arch(), Some(ffmpeg_arch)); + assert_eq!(target.meson_cpu(), Some(meson_cpu)); + } + } + #[test] fn x86_64_targets_keep_ffmpeg_assembly_enabled() { for target in [ NativeTarget::X86_64Macos, NativeTarget::X86_64IosSimulator, + NativeTarget::X86_64TvosSimulator, NativeTarget::X86_64WindowsMsvc, NativeTarget::X86_64Android, ] { @@ -4699,7 +4798,11 @@ mod tests { #[test] fn apple_ffmpeg_plan_enables_videotoolbox_with_dav1d_fallback() { - for target in [NativeTarget::Aarch64Macos, NativeTarget::Aarch64Ios] { + for target in [ + NativeTarget::Aarch64Macos, + NativeTarget::Aarch64Ios, + NativeTarget::Aarch64Tvos, + ] { let flags = NativeDependencyProfile::Lgpl.ffmpeg_configure_flags_for_target(target); assert!(flags.contains(&"--enable-videotoolbox")); assert!(flags.contains(&"--enable-libdav1d")); @@ -4727,6 +4830,7 @@ mod tests { fn x86_64_dav1d_targets_require_nasm() { assert!(dav1d_requires_nasm(NativeTarget::X86_64Macos)); assert!(dav1d_requires_nasm(NativeTarget::X86_64IosSimulator)); + assert!(dav1d_requires_nasm(NativeTarget::X86_64TvosSimulator)); assert!(dav1d_requires_nasm(NativeTarget::X86_64Android)); assert!(!dav1d_requires_nasm(NativeTarget::Aarch64Macos)); assert!(!dav1d_requires_nasm(NativeTarget::Aarch64Ios)); @@ -4969,7 +5073,7 @@ fn print_help() { println!(" cargo run -p xtask -- deps fetch --profile lgpl [--all]"); println!(" cargo run -p xtask -- deps status --profile lgpl"); println!( - " cargo run -p xtask -- deps build --profile lgpl [--target host|aarch64-apple-darwin|x86_64-apple-darwin|aarch64-apple-ios|aarch64-apple-ios-sim|x86_64-apple-ios|x86_64-pc-windows-msvc|aarch64-pc-windows-msvc|aarch64-linux-android|armv7-linux-androideabi|x86_64-linux-android|i686-linux-android] [--force] [--jobs N]" + " cargo run -p xtask -- deps build --profile lgpl [--target host|aarch64-apple-darwin|x86_64-apple-darwin|aarch64-apple-ios|aarch64-apple-ios-sim|x86_64-apple-ios|aarch64-apple-tvos|aarch64-apple-tvos-sim|x86_64-apple-tvos|x86_64-pc-windows-msvc|aarch64-pc-windows-msvc|aarch64-linux-android|armv7-linux-androideabi|x86_64-linux-android|i686-linux-android] [--force] [--jobs N]" ); println!(" cargo run -p xtask -- check cargo-patches"); println!(" cargo run -p xtask -- check license"); From ea98e5622ff841f79332c4dda3a25c3732d12de7 Mon Sep 17 00:00:00 2001 From: Sakiko Date: Mon, 3 Aug 2026 14:20:22 +0800 Subject: [PATCH 2/2] fix tvOS platform environment and HTTP zero length --- .github/workflows/ci.yml | 6 ++-- crates/erika/src/source.rs | 56 ++++++++++++++++++++++++++++++++------ xtask/src/main.rs | 35 ++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c271072..8795964 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,6 @@ concurrency: env: PROFILE: lgpl - IPHONEOS_DEPLOYMENT_TARGET: "13.0" - TVOS_DEPLOYMENT_TARGET: "13.0" CARGO_TERM_COLOR: always CARGO_NET_GIT_FETCH_WITH_CLI: "true" @@ -116,6 +114,8 @@ jobs: name: iOS device staticlib runs-on: macos-14 timeout-minutes: 150 + env: + IPHONEOS_DEPLOYMENT_TARGET: "13.0" steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -143,6 +143,8 @@ jobs: name: tvOS simulator staticlib runs-on: macos-14 timeout-minutes: 150 + env: + TVOS_DEPLOYMENT_TARGET: "13.0" steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@nightly diff --git a/crates/erika/src/source.rs b/crates/erika/src/source.rs index b01fac2..47a5394 100644 --- a/crates/erika/src/source.rs +++ b/crates/erika/src/source.rs @@ -647,13 +647,18 @@ fn probe_http_total_length( for (name, value) in http_headers { request = request.header(name, value); } - let response = request.call().map_err(|error| { - http_trace_log(format!( - "{{\"event\":\"http_length_probe_error\",\"phase\":\"request\",\"error\":\"{}\"}}", - json_escape(&error.to_string()), - )); - SourceError::Http(error.to_string()) - })?; + let response = request + .config() + .http_status_as_error(false) + .build() + .call() + .map_err(|error| { + http_trace_log(format!( + "{{\"event\":\"http_length_probe_error\",\"phase\":\"request\",\"error\":\"{}\"}}", + json_escape(&error.to_string()), + )); + SourceError::Http(error.to_string()) + })?; let status = response.status().as_u16(); let header = |name: &str| { response @@ -663,12 +668,19 @@ fn probe_http_total_length( .map(str::to_string) }; let total_length = match status { - 206 => header("content-range") + 206 | 416 => header("content-range") .as_deref() .and_then(parse_content_range_total), // Range was ignored; Content-Length is the whole object, which is // exactly the total being probed for. 200 => header("content-length").and_then(|value| value.trim().parse::().ok()), + status if status >= 400 => { + let error = SourceError::Http(format!("http status: {status}")); + http_trace_log(format!( + "{{\"event\":\"http_length_probe_error\",\"phase\":\"status\",\"status\":{status}}}" + )); + return Err(error); + } _ => None, }; http_trace_log(format!( @@ -1551,6 +1563,15 @@ mod tests { assert_eq!(parse_content_range_total(""), None); } + #[test] + fn length_probe_keeps_non_range_http_statuses_as_errors() { + let (uri, _requests) = spawn_mock_http_server(vec![MockResponse::immediate( + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec(), + )]); + let error = probe_http_total_length(&http_agent(), &uri, &[]).unwrap_err(); + assert!(error.to_string().contains("404")); + } + #[test] fn http_range_rejects_status_200_for_nonzero_offset() { let body = vec![b'a'; 100]; @@ -1821,6 +1842,25 @@ mod tests { assert!(probe.contains("range: bytes=0-0")); } + #[test] + fn len_preserves_zero_when_range_probe_confirms_empty_resource() { + let (uri, requests) = spawn_mock_http_server(vec![ + MockResponse::immediate( + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec(), + ), + MockResponse::immediate( + b"HTTP/1.1 416 Range Not Satisfiable\r\nContent-Range: bytes */0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .to_vec(), + ), + ]); + let mut source = HttpRangeSource::new(uri); + assert_eq!(source.len().unwrap(), Some(0)); + assert!(recv_request_head(&requests).starts_with("head")); + let probe = recv_request_head(&requests); + assert!(probe.starts_with("get")); + assert!(probe.contains("range: bytes=0-0")); + } + #[test] fn redacted_uri_hides_access_tokens() { assert_eq!( diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 4ab9373..c7643f8 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1797,6 +1797,7 @@ fn apply_apple_target_env(command: &mut Command, target: NativeTarget) -> Result let Some(config) = apple_toolchain(target)? else { return Ok(()); }; + clear_apple_deployment_target_env(command); command.env("SDKROOT", &config.sdk_root); if target.is_ios() { command.env("IPHONEOS_DEPLOYMENT_TARGET", &config.deployment_target); @@ -1808,6 +1809,16 @@ fn apply_apple_target_env(command: &mut Command, target: NativeTarget) -> Result Ok(()) } +fn clear_apple_deployment_target_env(command: &mut Command) { + for name in [ + "MACOSX_DEPLOYMENT_TARGET", + "IPHONEOS_DEPLOYMENT_TARGET", + "TVOS_DEPLOYMENT_TARGET", + ] { + command.env_remove(name); + } +} + fn apple_arch_flags(config: &AppleToolchain) -> Vec { vec![ "-arch".to_string(), @@ -2582,6 +2593,7 @@ fn build_ffmpeg(layout: &WorkspaceLayout, options: DepsOptions) -> Result<()> { ffmpeg_flag_path_arg(&layout.dav1d_prefix.join("lib")) )); } + clear_apple_deployment_target_env(&mut configure); configure.env("SDKROOT", &config.sdk_root); match options.target { NativeTarget::Aarch64Macos | NativeTarget::X86_64Macos => { @@ -4662,6 +4674,29 @@ fn command_display(command: &Command) -> String { mod tests { use super::*; + #[test] + fn apple_child_commands_remove_inherited_deployment_targets() { + let mut command = Command::new("tool"); + command + .env("MACOSX_DEPLOYMENT_TARGET", "11.0") + .env("IPHONEOS_DEPLOYMENT_TARGET", "13.0") + .env("TVOS_DEPLOYMENT_TARGET", "13.0"); + + clear_apple_deployment_target_env(&mut command); + + for name in [ + "MACOSX_DEPLOYMENT_TARGET", + "IPHONEOS_DEPLOYMENT_TARGET", + "TVOS_DEPLOYMENT_TARGET", + ] { + assert!( + command + .get_envs() + .any(|(key, value)| { key == OsStr::new(name) && value.is_none() }) + ); + } + } + #[test] fn windows_targets_map_to_rust_and_native_architectures() { let cases = [