diff --git a/.gitattributes b/.gitattributes index dfe07704..b659bd4a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ # Auto detect text files and perform LF normalization * text=auto +toolchains/cross/packs/** -diff +third_party/erika_flutter/native/macos/** -diff +third_party/erika_flutter/native/windows/** -diff diff --git a/.github/workflows/build-cross-target-packs.yml b/.github/workflows/build-cross-target-packs.yml new file mode 100644 index 00000000..27c50c2f --- /dev/null +++ b/.github/workflows/build-cross-target-packs.yml @@ -0,0 +1,220 @@ +name: Build Offline Desktop Cross Target Packs + +on: + workflow_dispatch: + inputs: + flutter_version: + description: Exact Flutter version embedded packs must match + required: true + default: '3.44.0' + erika_ref: + description: Erika source revision used by the Flutter plugin + required: true + default: 'main' + erika_source_build: + description: Rebuild Erika C API from source instead of its verified release bundle + required: true + type: boolean + default: false + push: + branches: + - codex/cross-compilation + paths: + - .github/workflows/build-cross-target-packs.yml + +permissions: + contents: read + +env: + FLUTTER_VERSION: ${{ inputs.flutter_version || '3.44.0' }} + ERIKA_REF: ${{ inputs.erika_ref || 'main' }} + ERIKA_FORCE_SOURCE_BUILD: ${{ inputs.erika_source_build && '1' || '0' }} + +jobs: + snapshotter: + name: macOS x64 AOT snapshotter + runs-on: macos-15-intel + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + architecture: x64 + cache: true + - name: Export snapshotter + shell: bash + run: | + set -euo pipefail + erika_macos=third_party/erika_flutter/native/macos/liberika_capi.dylib + test -f "$erika_macos" + lipo "$erika_macos" -verify_arch arm64 x86_64 + flutter precache --macos + flutter_root="$(cd "$(dirname "$(command -v flutter)")/.." && pwd)" + engine_revision="$(tr -d '\r\n' < "$flutter_root/bin/cache/engine.stamp")" + source="$flutter_root/bin/cache/artifacts/engine/darwin-x64-release/gen_snapshot" + test -x "$source" + mkdir -p cross-pack/snapshotter + cp "$source" cross-pack/snapshotter/gen_snapshot + chmod +x cross-pack/snapshotter/gen_snapshot + jq -n \ + --arg flutterVersion "$FLUTTER_VERSION" \ + --arg engineRevision "$engine_revision" \ + '{flutterVersion:$flutterVersion,engineRevision:$engineRevision,flutterSnapshotDirectory:"darwin-x64-release"}' \ + > cross-pack/snapshotter/metadata.json + file cross-pack/snapshotter/gen_snapshot + - uses: actions/upload-artifact@v4 + with: + name: saki-cross-snapshotter + path: cross-pack/snapshotter + compression-level: 0 + retention-days: 14 + + linux-runner: + name: Linux x64 SakiEngine runner + runs-on: ubuntu-24.04 + timeout-minutes: 180 + steps: + - uses: actions/checkout@v4 + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + clang cmake ninja-build pkg-config libgtk-3-dev libkeybinder-3.0-dev \ + libmpv-dev libasound2-dev libass-dev \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav + - uses: dtolnay/rust-toolchain@stable + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + - name: Build native runner and plugins + working-directory: Game/SakiEngine + shell: bash + run: | + set -euo pipefail + # Git does not retain the empty asset directories declared by the + # engine template, while Flutter validates every pubspec entry. + mkdir -p Assets/gui Assets/images/cg/chapter1 Assets/images/items \ + Assets/music Assets/sound Assets/voice + flutter config --enable-linux-desktop + flutter pub get + flutter build linux --release + - name: Export runner template + working-directory: Game/SakiEngine + shell: bash + run: | + set -euo pipefail + source_dir=build/linux/x64/release/bundle + runner_path="$(find "$source_dir" -maxdepth 1 -type f -perm -111 -print -quit)" + test -n "$runner_path" + runner_executable="$(basename "$runner_path")" + mkdir -p ../../cross-pack/linux-x64/template + cp -a "$source_dir/." ../../cross-pack/linux-x64/template/ + rm -rf ../../cross-pack/linux-x64/template/data/flutter_assets + rm -f ../../cross-pack/linux-x64/template/lib/libapp.so + jq -n \ + --arg runnerExecutable "$runner_executable" \ + --argjson plugins "$(jq '[.plugins.linux[] | select(.native_build == true and .dev_dependency != true) | .name] | sort' .flutter-plugins-dependencies)" \ + '{targetPlatform:"linux-x64",runnerExecutable:$runnerExecutable,nativePlugins:$plugins}' \ + > ../../cross-pack/linux-x64/metadata.json + find ../../cross-pack/linux-x64/template -type f -maxdepth 4 -print + - uses: actions/upload-artifact@v4 + with: + name: saki-cross-linux-x64 + path: cross-pack/linux-x64 + compression-level: 0 + retention-days: 14 + + windows-runner: + name: Windows x64 SakiEngine runner + runs-on: windows-2022 + timeout-minutes: 240 + steps: + - uses: actions/checkout@v4 + - name: Checkout Erika package source + if: env.ERIKA_FORCE_SOURCE_BUILD == '1' + shell: bash + run: | + set -euo pipefail + erika_root="$(cd "$GITHUB_WORKSPACE/../.." && pwd)/RustProject/Erika" + mkdir -p "$(dirname "$erika_root")" + git clone --depth 1 --branch "$ERIKA_REF" https://github.com/AimesSoft/Erika.git "$erika_root" + echo "ERIKA_REPO_ROOT=$erika_root" >> "$GITHUB_ENV" + - uses: dtolnay/rust-toolchain@stable + if: env.ERIKA_FORCE_SOURCE_BUILD == '1' + with: + targets: x86_64-pc-windows-msvc + - uses: msys2/setup-msys2@v2 + if: env.ERIKA_FORCE_SOURCE_BUILD == '1' + with: + msystem: UCRT64 + release: false + update: false + path-type: inherit + install: >- + make + tar + diffutils + coreutils + pkgconf + perl + zip + - name: Install Erika native build tools + if: env.ERIKA_FORCE_SOURCE_BUILD == '1' + shell: pwsh + run: | + choco install -y --no-progress nasm llvm + pip install --quiet meson ninja + "LIBCLANG_PATH=C:\Program Files\LLVM\bin" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + Add-Content -Path $env:GITHUB_PATH -Value "C:\Program Files\LLVM\bin" + Add-Content -Path $env:GITHUB_PATH -Value "C:\Program Files\NASM" + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + - name: Build native runner and plugins + working-directory: Game/SakiEngine + shell: bash + env: + ERIKA_FORCE_SOURCE_BUILD: ${{ env.ERIKA_FORCE_SOURCE_BUILD }} + ERIKA_NATIVE_PROFILE: lgpl + ERIKA_NATIVE_TARGET: x86_64-pc-windows-msvc + run: | + set -euo pipefail + # Git does not retain the empty asset directories declared by the + # engine template, while Flutter validates every pubspec entry. + mkdir -p Assets/gui Assets/images/cg/chapter1 Assets/images/items \ + Assets/music Assets/sound Assets/voice + flutter config --enable-windows-desktop + flutter pub get + flutter build windows --release + - name: Export runner template + working-directory: Game/SakiEngine + shell: bash + run: | + set -euo pipefail + source_dir=build/windows/x64/runner/Release + runner_path="$(find "$source_dir" -maxdepth 1 -type f -iname '*.exe' -print -quit)" + test -n "$runner_path" + runner_executable="$(basename "$runner_path")" + mkdir -p ../../cross-pack/windows-x64/template + cp -a "$source_dir/." ../../cross-pack/windows-x64/template/ + rm -rf ../../cross-pack/windows-x64/template/data/flutter_assets + rm -f ../../cross-pack/windows-x64/template/data/app.so + jq -n \ + --arg runnerExecutable "$runner_executable" \ + --argjson plugins "$(jq '[.plugins.windows[] | select(.native_build == true and .dev_dependency != true) | .name] | sort' .flutter-plugins-dependencies)" \ + '{targetPlatform:"windows-x64",runnerExecutable:$runnerExecutable,nativePlugins:$plugins}' \ + > ../../cross-pack/windows-x64/metadata.json + find ../../cross-pack/windows-x64/template -type f -maxdepth 4 -print + - uses: actions/upload-artifact@v4 + with: + name: saki-cross-windows-x64 + path: cross-pack/windows-x64 + compression-level: 0 + retention-days: 14 diff --git a/.gitignore b/.gitignore index 22b13c66..d150a911 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,7 @@ Engine/.flutter-plugins-dependencies /Launcher/linux/flutter/ephemeral /third_party/media_kit_libs_macos_video_hotfix/macos/Frameworks /third_party/media_kit_libs_macos_video_hotfix/macos/.cache + +# SakiEngine 的离线 Erika Windows 运行库必须随仓库分发。 +!/third_party/erika_flutter/native/windows/x64/ +!/third_party/erika_flutter/native/windows/x64/erika_capi.dll diff --git a/Engine/assets/shaders/dissolve.frag b/Engine/assets/shaders/dissolve.frag index cf6e4852..fdf984bf 100644 --- a/Engine/assets/shaders/dissolve.frag +++ b/Engine/assets/shaders/dissolve.frag @@ -2,10 +2,12 @@ #include uniform float progress; -uniform vec2 uSize; +uniform vec2 u_imageFrom_size; uniform vec2 u_imageFrom_dimensions; +uniform vec2 u_imageTo_size; uniform vec2 u_imageTo_dimensions; -uniform vec2 uOffset; +uniform vec2 u_imageFrom_offset; +uniform vec2 u_imageTo_offset; uniform sampler2D imageFrom; uniform sampler2D imageTo; uniform float overallAlpha; @@ -16,15 +18,28 @@ void main() { // 采样品质由引擎侧 setImageSampler(filterQuality) 控制: // - FilterQuality.none → 最近邻(像素风立绘保持锐利) // - FilterQuality.high → 双线性(平滑过渡) - vec2 uv = (FlutterFragCoord().xy - uOffset) / uSize; + vec2 frag_coord = FlutterFragCoord().xy; + vec2 from_uv = (frag_coord - u_imageFrom_offset) / u_imageFrom_size; + vec2 to_uv = (frag_coord - u_imageTo_offset) / u_imageTo_size; + bool inside_from = all(greaterThanEqual(from_uv, vec2(0.0))) && + all(lessThanEqual(from_uv, vec2(1.0))); + bool inside_to = all(greaterThanEqual(to_uv, vec2(0.0))) && + all(lessThanEqual(to_uv, vec2(1.0))); + // 按各图尺寸做半像素内缩,避免采样到边缘外(双线性模式下的边缘渗色)。 vec2 from_halfTexel = 0.5 / max(u_imageFrom_dimensions, vec2(1.0)); vec2 to_halfTexel = 0.5 / max(u_imageTo_dimensions, vec2(1.0)); - vec2 from_uv = clamp(uv, from_halfTexel, vec2(1.0) - from_halfTexel); - vec2 to_uv = clamp(uv, to_halfTexel, vec2(1.0) - to_halfTexel); + from_uv = clamp(from_uv, from_halfTexel, vec2(1.0) - from_halfTexel); + to_uv = clamp(to_uv, to_halfTexel, vec2(1.0) - to_halfTexel); - vec4 from_color = texture(imageFrom, from_uv); - vec4 to_color = texture(imageTo, to_uv); + vec4 from_color = vec4(0.0); + vec4 to_color = vec4(0.0); + if (inside_from) { + from_color = texture(imageFrom, from_uv); + } + if (inside_to) { + to_color = texture(imageTo, to_uv); + } fragColor = mix(from_color, to_color, progress) * overallAlpha; } diff --git a/Engine/lib/sakiengine.dart b/Engine/lib/sakiengine.dart index 898b5f00..0f00ecc5 100644 --- a/Engine/lib/sakiengine.dart +++ b/Engine/lib/sakiengine.dart @@ -11,3 +11,4 @@ export 'src/config/saki_engine_config.dart'; export 'src/core/game_module.dart'; export 'src/core/script_canvas.dart'; export 'src/core/module_registry.dart' show registerProjectModule; +export 'src/utils/asset_path_utils.dart' show isFileSystemAssetPath; diff --git a/Engine/lib/src/app/saki_engine_entry.dart b/Engine/lib/src/app/saki_engine_entry.dart index 619918e5..29919528 100644 --- a/Engine/lib/src/app/saki_engine_entry.dart +++ b/Engine/lib/src/app/saki_engine_entry.dart @@ -9,7 +9,6 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; -import 'package:just_audio_media_kit/just_audio_media_kit.dart'; import 'package:sakiengine/src/config/runtime_project_config.dart'; import 'package:sakiengine/src/config/saki_engine_config.dart'; import 'package:sakiengine/src/core/game_module.dart'; @@ -145,12 +144,9 @@ Future _openShowcaseResourceDirectoryIfNeeded() async { if (Platform.isMacOS) { opened = await _startDetachedProcess('open', [path]); } else if (Platform.isWindows) { - opened = await _startDetachedProcess( - 'explorer', - [ - path, - ], - runInShell: true); + opened = await _startDetachedProcess('explorer', [ + path, + ], runInShell: true); } else if (Platform.isLinux) { opened = await _startDetachedProcess('xdg-open', [path]); if (!opened) { @@ -425,7 +421,7 @@ class _GameContainerState extends State with WindowListener { final sessionKey = saveSlot == null ? 'new_game:${initialScript.isEmpty ? 'start' : initialScript}' : 'save:${saveSlot.id}:${saveSlot.currentScript}:' - '${saveSlot.saveTime.microsecondsSinceEpoch}'; + '${saveSlot.saveTime.microsecondsSinceEpoch}'; currentScreen = gameModule.createGamePlayScreen( key: ValueKey(sessionKey), saveSlotToLoad: saveSlot, @@ -449,6 +445,7 @@ Future runSakiEngine({ String? gamePath, int steamAppId = 3536120, bool enableSteamworks = true, + bool restoreStartupWindowBounds = true, bool useNearestNeighborSampling = false, }) async { setupDebugLogger(); @@ -509,14 +506,6 @@ Future runSakiEngine({ ]); } - JustAudioMediaKit.ensureInitialized( - android: false, - iOS: false, - macOS: false, - windows: true, - linux: true, - ); - if (!kIsWeb) { await PlatformWindowManager.ensureInitialized(); await PlatformWindowManager.setPreventClose(true); @@ -529,11 +518,9 @@ Future runSakiEngine({ await SakiEngineConfig().loadConfig(); await SettingsManager().init(); - if (!kIsWeb) { - await PlatformWindowManager.applyStartupWindowSizeForAspectRatio( - SettingsManager().currentGameWindowAspectRatio, - ); - } + final startupWindowAspectRatio = !kIsWeb && restoreStartupWindowBounds + ? SettingsManager().currentGameWindowAspectRatio + : null; frameRateBinding.attachSettingsSync(); await LocalizationManager().init(); await UISoundManager().initialize(); @@ -558,6 +545,19 @@ Future runSakiEngine({ ); runApp(const SakiEngineApp()); + if (startupWindowAspectRatio != null) { + // Resizing the native macOS window before Flutter has submitted its + // first surface frame makes ResizeSynchronizer wait and time out. + // Restore the persisted bounds only after a frame can satisfy the + // resize handshake. + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited( + PlatformWindowManager.applyStartupWindowSizeForAspectRatio( + startupWindowAspectRatio, + ), + ); + }); + } }, zoneSpecification: ZoneSpecification( print: (Zone self, ZoneDelegate parent, Zone zone, String line) { @@ -656,7 +656,8 @@ class _SakiEngineAppState extends State { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - theme: customTheme ?? + theme: + customTheme ?? ThemeData( primarySwatch: Colors.blue, fontFamily: 'SourceHanSansCN', diff --git a/Engine/lib/src/config/asset_manager_io.dart b/Engine/lib/src/config/asset_manager_io.dart index 4e4682fe..51d8b2b7 100644 --- a/Engine/lib/src/config/asset_manager_io.dart +++ b/Engine/lib/src/config/asset_manager_io.dart @@ -21,10 +21,9 @@ class AssetManager { static final AssetManager _instance = AssetManager._internal(); factory AssetManager() => _instance; AssetManager._internal() { - // Print the CWD at initialization if (_shouldLoadFromExternal()) { - print("AssetManager CWD: ${Directory.current.path}"); - print( + sakiDiagnosticLog("AssetManager CWD: ${Directory.current.path}"); + sakiDiagnosticLog( "Game path hint: ${GamePathResolver.configuredGamePathHint() ?? ''}", ); } @@ -99,13 +98,11 @@ class AssetManager { .toLowerCase()] = entry; } - if (kEngineDebugMode) { - print( - '[SAKI_NATIVE][ASSETS] indexed=${catalog.entries.length} ' - 'root=${catalog.rootPath} ' - 'time=${catalog.elapsedMicros.toDouble() / 1000.0}ms', - ); - } + sakiDiagnosticLog( + '[SAKI_NATIVE][ASSETS] indexed=${catalog.entries.length} ' + 'root=${catalog.rootPath} ' + 'time=${catalog.elapsedMicros.toDouble() / 1000.0}ms', + ); return true; } catch (error, stackTrace) { if (kEngineDebugMode) { diff --git a/Engine/lib/src/effects/mouse_parallax.dart b/Engine/lib/src/effects/mouse_parallax.dart index 2d402cbe..4aa77ec1 100644 --- a/Engine/lib/src/effects/mouse_parallax.dart +++ b/Engine/lib/src/effects/mouse_parallax.dart @@ -1,6 +1,39 @@ +import 'dart:math' as math; + import 'package:sakiengine/src/utils/foundation_compat.dart'; import 'package:flutter/material.dart'; +/// 计算覆盖最大视差位移所需的最小等比缩放。 +/// +/// [padding] 会在每条边额外保留少量安全区域,避免像素取整或采样在极限 +/// 位移处产生细缝。 +@visibleForTesting +double resolveParallaxBleedScale({ + required Size viewportSize, + required Offset maxOffset, + required double depth, + double padding = 1.0, +}) { + if (!viewportSize.width.isFinite || + !viewportSize.height.isFinite || + viewportSize.width <= 0 || + viewportSize.height <= 0) { + return 1.0; + } + + final safePadding = math.max(0.0, padding); + final effectiveDepth = depth.abs(); + final horizontalScale = + 1.0 + + (2.0 * (maxOffset.dx.abs() * effectiveDepth + safePadding)) / + viewportSize.width; + final verticalScale = + 1.0 + + (2.0 * (maxOffset.dy.abs() * effectiveDepth + safePadding)) / + viewportSize.height; + return math.max(horizontalScale, verticalScale); +} + /// 在鼠标移动时为子组件提供视差偏移能力的封装组件。 class MouseParallax extends StatefulWidget { const MouseParallax({ @@ -177,7 +210,9 @@ class ParallaxAware extends StatelessWidget { required this.child, this.customMaxOffset, this.invert = true, - }); + this.reserveBleed = false, + this.bleedPadding = 1.0, + }) : assert(bleedPadding >= 0); /// 深度系数,越大移动越明显。 final double depth; @@ -191,6 +226,12 @@ class ParallaxAware extends StatelessWidget { /// 是否反向移动(默认背景->鼠标反向)。 final bool invert; + /// 是否自动等比放大内容,为最大视差位移预留出血区域。 + final bool reserveBleed; + + /// 出血区域在最大位移之外额外保留的安全像素。 + final double bleedPadding; + @override Widget build(BuildContext context) { if (depth == 0) { @@ -204,19 +245,43 @@ class ParallaxAware extends StatelessWidget { final maxOffset = customMaxOffset ?? scope.maxOffset; - return ValueListenableBuilder( - valueListenable: scope.offsetListenable, - builder: (context, normalized, widgetChild) { - final effectiveOffset = scope.enabled ? normalized : Offset.zero; - final direction = invert ? -1.0 : 1.0; - final dx = effectiveOffset.dx * maxOffset.dx * depth * direction; - final dy = effectiveOffset.dy * maxOffset.dy * depth * direction; - return Transform.translate( - offset: Offset(dx, dy), - child: widgetChild, + Widget buildTranslatedChild(Widget transformedChild) { + return ValueListenableBuilder( + valueListenable: scope.offsetListenable, + builder: (context, normalized, widgetChild) { + final effectiveOffset = scope.enabled ? normalized : Offset.zero; + final direction = invert ? -1.0 : 1.0; + final dx = effectiveOffset.dx * maxOffset.dx * depth * direction; + final dy = effectiveOffset.dy * maxOffset.dy * depth * direction; + return Transform.translate( + offset: Offset(dx, dy), + child: widgetChild, + ); + }, + child: transformedChild, + ); + } + + if (!reserveBleed) { + return buildTranslatedChild(child); + } + + return LayoutBuilder( + builder: (context, constraints) { + final bleedScale = resolveParallaxBleedScale( + viewportSize: Size(constraints.maxWidth, constraints.maxHeight), + maxOffset: maxOffset, + depth: depth, + padding: bleedPadding, + ); + return buildTranslatedChild( + Transform.scale( + scale: bleedScale, + alignment: Alignment.center, + child: child, + ), ); }, - child: child, ); } } diff --git a/Engine/lib/src/game/game_manager.dart b/Engine/lib/src/game/game_manager.dart index c01a5e58..0c6b7ff7 100644 --- a/Engine/lib/src/game/game_manager.dart +++ b/Engine/lib/src/game/game_manager.dart @@ -43,37 +43,10 @@ import 'package:sakiengine/src/native/native_memory_pressure.dart'; import 'package:sakiengine/src/utils/asset_path_utils.dart'; part 'game_manager_lifecycle.dart'; +part 'game_manager_choice_seek.dart'; enum _NvlContextMode { none, standard, movie, noMask } -class _MenuPromptDialogue { - final String? speaker; - final String dialogue; - final String? dialogueTag; - final int scriptIndex; - final String? sourceScriptFile; - final int? sourceLine; - - const _MenuPromptDialogue({ - required this.speaker, - required this.dialogue, - required this.dialogueTag, - required this.scriptIndex, - required this.sourceScriptFile, - required this.sourceLine, - }); -} - -class _MenuJumpTarget { - final int menuIndex; - final List<_MenuPromptDialogue> promptDialogues; - - const _MenuJumpTarget({ - required this.menuIndex, - required this.promptDialogues, - }); -} - /// 音乐区间类 /// 定义音乐播放的有效范围,从play music到下一个play music/stop music之间 class MusicRegion { @@ -229,6 +202,11 @@ class GameManager { // 快进状态 bool _isFastForwardMode = false; + bool _isSeekingNextChoice = false; + int _choiceSeekNodesRemaining = 0; + int _choiceSeekRevision = 0; + final _choiceSeekLoopingAnimations = {}; + bool get _skipPresentation => _isFastForwardMode || _isSeekingNextChoice; String? _activeCgTransitionType; Completer? _cgTransitionCompleter; static const Duration _defaultCgTransitionDuration = Duration( @@ -254,6 +232,22 @@ class GameManager { // 角色位置动画管理器 CharacterPositionAnimator? _characterPositionAnimator; + int _characterPresentationRevision = 0; + bool _pendingCharacterReflow = false; + + void _cancelCharacterPresentation() { + _characterPresentationRevision++; + _isProcessing = false; + _pendingCharacterReflow = false; + _characterPositionAnimator?.dispose(); + _characterPositionAnimator = null; + final animations = _activeCharacterAnimations.values.toList(); + _activeCharacterAnimations.clear(); + for (final animation in animations) { + animation.stopInfiniteLoop(); + animation.dispose(); + } + } // CG脚本预分析器 final CgScriptPreAnalyzer _cgPreAnalyzer = CgScriptPreAnalyzer(); @@ -269,7 +263,7 @@ class GameManager { int scriptIndex, { String? reason, }) async { - if (_disableRuntimeSideEffectsForTesting) { + if (_disableRuntimeSideEffectsForTesting || _isSeekingNextChoice) { return; } try { @@ -330,7 +324,7 @@ class GameManager { } Future _createRuntimeAutoSave({required String reason}) async { - if (_disableRuntimeSideEffectsForTesting) { + if (_disableRuntimeSideEffectsForTesting || _isSeekingNextChoice) { return; } try { @@ -386,6 +380,7 @@ class GameManager { /// 检查当前scene是否是章节末尾前最后一个有对话的scene /// 如果是则创建自动存档,用于解锁流程图跳转功能 Future _checkChapterEndAutoSave(int sceneIndex) async { + if (_isSeekingNextChoice || _disableRuntimeSideEffectsForTesting) return; final currentNode = _script.children[sceneIndex]; String sceneName = ''; if (currentNode is BackgroundNode) { @@ -598,6 +593,7 @@ class GameManager { required String? newPositionId, }) async { if (_tickerProvider == null || oldPositionId == newPositionId) return; + final revision = _characterPresentationRevision; // 获取旧的和新的pose配置 final oldPoseConfig = oldPositionId != null @@ -648,6 +644,7 @@ class GameManager { duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, onUpdate: (attributesMap) { + if (_disposed || revision != _characterPresentationRevision) return; // 更新角色的动画属性 final updatedCharacters = Map.from( _currentState.characters, @@ -665,11 +662,12 @@ class GameManager { _currentState = _currentState.copyWith( characters: updatedCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); } } }, onComplete: () { + if (_disposed || revision != _characterPresentationRevision) return; //print('[PoseAttributeAnimation] 属性动画完成: $characterId'); // 动画完成后,清除动画属性,让角色使用新pose的正常属性 final updatedCharacters = Map.from( @@ -678,19 +676,20 @@ class GameManager { final character = updatedCharacters[characterId]; if (character != null) { updatedCharacters[characterId] = character.copyWith( - animationProperties: null, // 清除动画属性,回到新pose的基础位置 + clearAnimationProperties: true, ); _currentState = _currentState.copyWith(characters: updatedCharacters); - _gameStateController.add(_currentState); + _emitCurrentState(); } }, ); } Future _checkAndAnimateCharacterPositions( - Map newCharacters, - ) async { - if (_tickerProvider == null) return; + Map newCharacters, { + bool reconcileCurrentPositions = false, + }) async { + final revision = _characterPresentationRevision; //print('[CharacterPositionAnimation] 检测位置变化...'); //print('[CharacterPositionAnimation] 旧角色: ${_currentState.characters.keys.toList()}'); @@ -698,7 +697,7 @@ class GameManager { // 检测位置变化 final characterOrder = newCharacters.keys.toList(); - final positionChanges = CharacterAutoDistribution.calculatePositionChanges( + var positionChanges = CharacterAutoDistribution.calculatePositionChanges( _currentState.characters, newCharacters, _poseConfigs, @@ -706,16 +705,80 @@ class GameManager { characterOrder, ); + if (reconcileCurrentPositions) { + final distributed = CharacterAutoDistribution.calculateAutoDistribution( + newCharacters, + _poseConfigs, + characterOrder, + ); + positionChanges = [ + for (final entry in newCharacters.entries) + if (distributed['${entry.key}_auto_distributed'] case final target?) + CharacterPositionChange( + characterId: entry.key, + fromX: + entry.value.animationProperties?['xcenter'] ?? target.xcenter, + toX: target.xcenter, + ), + ]; + } else { + // Layout changes start from the displayed position, including an + // interrupted reflow, rather than jumping to the old nominal slot. + positionChanges = [ + for (final change in positionChanges) + CharacterPositionChange( + characterId: change.characterId, + fromX: + _currentState + .characters[change.characterId] + ?.animationProperties?['xcenter'] ?? + change.fromX, + toX: change.toX, + ), + ]; + } + final layoutChanged = positionChanges.isNotEmpty; + positionChanges = positionChanges + .where((change) => (change.fromX - change.toX).abs() > 0.001) + .toList(); + + void applyPositions(Map positions) { + if (_disposed || revision != _characterPresentationRevision) return; + final updatedCharacters = Map.of(_currentState.characters); + for (final entry in positions.entries) { + final character = updatedCharacters[entry.key]; + if (character != null) { + _activeCharacterAnimations[entry.key]?.rebaseXCenter(entry.value); + updatedCharacters[entry.key] = character.copyWith( + animationProperties: { + ...?character.animationProperties, + 'xcenter': entry.value, + }, + ); + } + } + _currentState = _currentState.copyWith(characters: updatedCharacters); + _emitCurrentState(); + } + //print('[CharacterPositionAnimation] 检测到 ${positionChanges.length} 个位置变化'); for (final change in positionChanges) { //print('[CharacterPositionAnimation] ${change.characterId}: ${change.fromX} -> ${change.toX}'); } - if (positionChanges.isNotEmpty) { - // 如果有位置变化,播放动画 + if (layoutChanged) { _characterPositionAnimator?.stop(); + } + if (positionChanges.isNotEmpty) { _characterPositionAnimator = CharacterPositionAnimator(); + if (_skipPresentation || _tickerProvider == null) { + applyPositions({ + for (final change in positionChanges) change.characterId: change.toX, + }); + return; + } + //print('[CharacterPositionAnimation] 开始播放位置动画...'); await _characterPositionAnimator!.animatePositionChanges( @@ -723,28 +786,7 @@ class GameManager { vsync: _tickerProvider!, duration: const Duration(milliseconds: 500), curve: Curves.easeInOut, - onUpdate: (positions) { - // 更新角色的动画属性 - final updatedCharacters = Map.from( - _currentState.characters, - ); - for (final entry in positions.entries) { - final characterId = entry.key; - final xPosition = entry.value; - final character = updatedCharacters[characterId]; - if (character != null) { - updatedCharacters[characterId] = character.copyWith( - animationProperties: { - ...character.animationProperties ?? {}, - 'xcenter': xPosition, - }, - ); - } - } - - _currentState = _currentState.copyWith(characters: updatedCharacters); - _gameStateController.add(_currentState); - }, + onUpdate: applyPositions, onComplete: () { // 动画完成,清理动画属性 //print('[CharacterPositionAnimation] 角色位置动画完成'); @@ -1212,7 +1254,7 @@ class GameManager { forceNullCurrentNode: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); } /// Debug: previews or clears one persistent canvas without advancing SKS. @@ -1231,7 +1273,7 @@ class GameManager { clearScriptCanvas: shouldClear, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); } /// 分析脚本中CG差分的表达式变化 @@ -1310,6 +1352,23 @@ class GameManager { return characterAlias; } + ({String pose, String expression}) _resolveCharacterVisualForResource({ + required CharacterState currentState, + required String targetResourceId, + String? requestedPose, + String? requestedExpression, + }) { + final resourceChanged = currentState.resourceId != targetResourceId; + return ( + pose: + requestedPose ?? + (resourceChanged ? 'pose1' : (currentState.pose ?? 'pose1')), + expression: + requestedExpression ?? + (resourceChanged ? 'happy' : (currentState.expression ?? 'happy')), + ); + } + ({String alias, CharacterConfig config})? _resolveCharacterIdentityByAliasOrResourceId(String aliasOrResourceId) { final normalized = aliasOrResourceId.trim(); @@ -1387,10 +1446,14 @@ class GameManager { characters: newCharacters, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); if (animation != null && animation.isNotEmpty) { - if (_isFastForwardMode) { - _applyCharacterAnimationFinalState(targetKey, animation); + if (_skipPresentation) { + _applyCharacterAnimationFinalState( + targetKey, + animation, + repeatCount: repeatCount, + ); } else { _playCharacterAnimation(targetKey, animation, repeatCount: repeatCount); } @@ -1581,9 +1644,13 @@ class GameManager { if (result.nextState != null) { _currentState = result.nextState!; - _gameStateController.add(_currentState); + _emitCurrentState(); } + if (_isSeekingNextChoice && result.stateAfterWait != null) { + _applyScriptApiStateAfterWait(result.stateAfterWait!); + return; + } final waitDuration = result.waitDuration; if (waitDuration != null && waitDuration > Duration.zero) { if (kEngineDebugMode) { @@ -1625,7 +1692,7 @@ class GameManager { isFastForwarding: enabled, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); if (enabled) { _completeCurrentTimerWait(); } @@ -1642,11 +1709,14 @@ class GameManager { isAutoPlaying: enabled, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); //print('[AutoPlay] 自动播放模式: ${enabled ? "开启" : "关闭"}'); } void _applyScriptApiStateAfterWait(GameState stateAfterWait) { + if (_disposed) { + return; + } _currentState = stateAfterWait.copyWith( // `stateAfterWait` is created before the wait starts. During that wait a // renderer may finish an asynchronous character/CG fade and remove the @@ -1658,11 +1728,11 @@ class GameManager { isAutoPlaying: _isAutoPlayMode, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); } bool _completeCurrentTimerWait() { - if (!_isWaitingForTimer) { + if (_disposed || !_isWaitingForTimer) { return false; } @@ -1681,7 +1751,7 @@ class GameManager { isPaused: false, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); } if (completion != null) { @@ -1760,7 +1830,10 @@ class GameManager { int scriptIndex = 0, GameState? initialState, bool disableRuntimeSideEffects = true, + Map? characterConfigs, + Map? poseConfigs, }) async { + _cancelCharacterPresentation(); _currentTimer?.cancel(); _currentTimer = null; _currentTimerCompletion = null; @@ -1770,6 +1843,9 @@ class GameManager { _disableRuntimeSideEffectsForTesting = disableRuntimeSideEffects; _activeNvlContext = _NvlContextMode.none; _showNvlOverlayOnNextDialogue = false; + // Keep legacy fixtures that configure the manager before starting a script. + _characterConfigs = characterConfigs ?? _characterConfigs; + _poseConfigs = poseConfigs ?? _poseConfigs; _script = script; _scriptIndex = scriptIndex.clamp(0, _script.children.length).toInt(); _currentState = initialState ?? GameState.initial(); @@ -1781,6 +1857,7 @@ class GameManager { } Future startGame(String scriptName) { + _cancelCharacterPresentation(); _disableRuntimeSideEffectsForTesting = false; return _startGameLifecycle(scriptName); } @@ -1792,6 +1869,7 @@ class GameManager { bool reloadCharacterConfigs = true, bool restoreDialogueVoice = true, }) { + _cancelCharacterPresentation(); _disableRuntimeSideEffectsForTesting = false; return _restoreFromSnapshotLifecycle( scriptName, @@ -1830,6 +1908,7 @@ class GameManager { } Future hotReload(String scriptName) { + _cancelCharacterPresentation(); _disableRuntimeSideEffectsForTesting = false; return _hotReloadLifecycle(scriptName); } @@ -2138,6 +2217,7 @@ class GameManager { Future _checkMusicRegionAtCurrentIndex({ bool forceCheck = false, }) async { + if (_disableRuntimeSideEffectsForTesting || _isSeekingNextChoice) return; if (!forceCheck && _scriptIndex >= 0 && _scriptIndex < _script.children.length && @@ -2168,7 +2248,7 @@ class GameManager { fadeOut: true, fadeDuration: const Duration(milliseconds: 800), ); - _currentState = _currentState.copyWith(currentMusicRegion: null); + _currentState = _currentState.copyWith(clearCurrentMusicRegion: true); } else { // 当前位置在音乐区间内 final fullMusicPath = _buildMusicAssetPath(currentRegion.musicFile); @@ -2178,7 +2258,7 @@ class GameManager { '[MusicRegion] 当前位置($_scriptIndex)音乐名为空,跳过播放: region=$currentRegion', ); } - _currentState = _currentState.copyWith(currentMusicRegion: null); + _currentState = _currentState.copyWith(clearCurrentMusicRegion: true); return; } @@ -2198,11 +2278,13 @@ class GameManager { ); } - await MusicManager().playBackgroundMusic( - fullMusicPath, - fadeTransition: true, - fadeDuration: const Duration(milliseconds: 1200), - ); + if (!_isSeekingNextChoice && !_disableRuntimeSideEffectsForTesting) { + await MusicManager().playBackgroundMusic( + fullMusicPath, + fadeTransition: true, + fadeDuration: const Duration(milliseconds: 1200), + ); + } _currentState = _currentState.copyWith( currentMusicRegion: currentRegion, ); @@ -2248,237 +2330,11 @@ class GameManager { return null; } - _MenuJumpTarget? _findReachableMenuJumpTarget(int startIndex) { - if (!_isScriptInitialized()) { - return null; - } - - final nativeRuntime = _scriptMerger.nativeRuntimeIndex; - if (nativeRuntime != null) { - final boolVariables = { - for (final variable in nativeRuntime.conditionVariables) - variable: GlobalVariableManager().getBoolVariableSync( - variable, - defaultValue: false, - ), - }; - final nativeResult = nativeRuntime.seekMenu(startIndex, boolVariables); - if (nativeResult.found && nativeResult.menuIndex != null) { - return _MenuJumpTarget( - menuIndex: nativeResult.menuIndex!, - promptDialogues: [ - for (final prompt in nativeResult.prompts) - _MenuPromptDialogue( - speaker: prompt.character == null - ? null - : _characterConfigs[prompt.character]?.name, - dialogue: _resolveScriptText(prompt.dialogue), - dialogueTag: prompt.dialogueTag, - scriptIndex: prompt.scriptIndex, - sourceScriptFile: prompt.sourceFile, - sourceLine: prompt.sourceLine, - ), - ], - ); - } - if (!nativeResult.found) { - return null; - } - } - - var nextIndex = startIndex.clamp(0, _script.children.length).toInt(); - final visitedIndexes = {}; - final promptDialogues = <_MenuPromptDialogue>[]; - - while (nextIndex >= 0 && nextIndex < _script.children.length) { - if (!visitedIndexes.add(nextIndex)) { - return null; - } - - final nextNode = _script.children[nextIndex]; - if (nextNode is MenuNode) { - return _MenuJumpTarget( - menuIndex: nextIndex, - promptDialogues: promptDialogues, - ); - } - if (nextNode is ReturnNode) { - return null; - } - if (nextNode is JumpNode) { - if (nextNode.isConditional) { - final currentValue = GlobalVariableManager().getBoolVariableSync( - nextNode.conditionVariable!, - defaultValue: false, - ); - if (currentValue != nextNode.conditionValue) { - nextIndex++; - continue; - } - } - final targetIndex = _labelIndexMap[nextNode.targetLabel]; - if (targetIndex == null) { - return null; - } - nextIndex = targetIndex; - continue; - } - if (nextNode is SayNode) { - promptDialogues.add( - _buildMenuPromptDialogueFromSayNode(nextNode, nextIndex), - ); - } else if (nextNode is ConditionalSayNode) { - final prompt = _buildMenuPromptDialogueFromConditionalSayNode( - nextNode, - nextIndex, - ); - if (prompt != null) { - promptDialogues.add(prompt); - } - } - - nextIndex++; - } - - return null; - } - - _MenuPromptDialogue _buildMenuPromptDialogueFromSayNode( - SayNode node, - int scriptIndex, - ) { - final characterConfig = _characterConfigs[node.character]; - return _MenuPromptDialogue( - speaker: characterConfig?.name, - dialogue: _resolveScriptText(node.dialogue), - dialogueTag: node.dialogueTag, - scriptIndex: scriptIndex, - sourceScriptFile: node.sourceFile ?? currentScriptFile, - sourceLine: node.sourceLine, - ); - } - - _MenuPromptDialogue? _buildMenuPromptDialogueFromConditionalSayNode( - ConditionalSayNode node, - int scriptIndex, - ) { - final currentValue = GlobalVariableManager().getBoolVariableSync( - node.conditionVariable, - defaultValue: false, - ); - if (currentValue != node.conditionValue) { - return null; - } - - final characterConfig = _characterConfigs[node.character]; - return _MenuPromptDialogue( - speaker: characterConfig?.name, - dialogue: _resolveScriptText(node.dialogue), - dialogueTag: node.dialogueTag, - scriptIndex: scriptIndex, - sourceScriptFile: node.sourceFile ?? currentScriptFile, - sourceLine: node.sourceLine, - ); - } - - void _addMenuPromptDialogueToHistoryIfNeeded( - _MenuPromptDialogue promptDialogue, - ) { - if (_dialogueHistory.isNotEmpty) { - final lastDialogue = _dialogueHistory.last; - if (lastDialogue.scriptIndex == promptDialogue.scriptIndex) { - return; - } - } - - _addToDialogueHistory( - speaker: promptDialogue.speaker, - dialogue: promptDialogue.dialogue, - dialogueTag: promptDialogue.dialogueTag, - timestamp: DateTime.now(), - currentNodeIndex: promptDialogue.scriptIndex, - sourceScriptFile: promptDialogue.sourceScriptFile, - sourceLine: promptDialogue.sourceLine, - ); - } - - void _addMenuJumpDialoguesToHistory( - List<_MenuPromptDialogue> promptDialogues, - ) { - for (final promptDialogue in promptDialogues) { - _currentState = _currentState.copyWith( - dialogue: promptDialogue.dialogue, - dialogueTag: promptDialogue.dialogueTag, - speaker: promptDialogue.speaker, - forceNullSpeaker: promptDialogue.speaker == null, - currentNode: null, - clearDialogueAndSpeaker: false, - everShownCharacters: _everShownCharacters, - ); - _addMenuPromptDialogueToHistoryIfNeeded(promptDialogue); - } - } - - Future jumpToNextChoice() async { - if (!_isScriptInitialized() || _isProcessing) { - return false; - } - if (_currentState.currentNode is MenuNode) { - return true; - } - - final jumpTarget = _findReachableMenuJumpTarget(_scriptIndex); - if (jumpTarget == null) { - return false; - } - final targetMenuIndex = jumpTarget.menuIndex; - - _currentTimer?.cancel(); - _currentTimer = null; - _currentTimerCompletion = null; - _isWaitingForTimer = false; - _isProcessing = false; - - final menuNode = _script.children[targetMenuIndex] as MenuNode; - final localizedMenuNode = _localizeMenuNode(menuNode); - _addMenuJumpDialoguesToHistory(jumpTarget.promptDialogues); - - await _createRuntimeAutoSave(reason: '分支选择'); - await _checkAndCreateAutoSave(targetMenuIndex, reason: '分支选择'); - - final previousDialogueEntry = _dialogueHistory.length >= 2 - ? _dialogueHistory[_dialogueHistory.length - 2] - : null; - _scriptIndex = targetMenuIndex; - if (previousDialogueEntry != null) { - _currentState = _currentState.copyWith( - dialogue: previousDialogueEntry.dialogue, - dialogueTag: previousDialogueEntry.dialogueTag, - speaker: previousDialogueEntry.speaker, - forceNullSpeaker: previousDialogueEntry.speaker == null, - currentNode: localizedMenuNode, - clearDialogueAndSpeaker: false, - clearScriptOverlay: true, - isFastForwarding: _isFastForwardMode, - isPaused: false, - everShownCharacters: _everShownCharacters, - ); - } else { - _currentState = _currentState.copyWith( - currentNode: localizedMenuNode, - clearDialogueAndSpeaker: true, - clearScriptOverlay: true, - isFastForwarding: _isFastForwardMode, - isPaused: false, - everShownCharacters: _everShownCharacters, - ); - } - _gameStateController.add(_currentState); - - return true; - } + /// Advance through directing and story state without presenting intermediate frames. + Future jumpToNextChoice() => _seekNextChoice(); Future jumpToLabel(String label) async { + if (_disposed || _isSeekingNextChoice) return; if (!_disableRuntimeSideEffectsForTesting) { await MusicManager().stopVoice(); } @@ -2489,7 +2345,7 @@ class GameManager { forceNullCurrentNode: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); if (kEngineDebugMode) { //////print('[GameManager] 跳转到标签: $label, 索引: $_scriptIndex'); } @@ -2505,14 +2361,22 @@ class GameManager { } void next() async { - if (_isProcessing || _isWaitingForTimer) { + if (_disposed || + _isSeekingNextChoice || + _isProcessing || + _isWaitingForTimer) { return; } + final presentationRevision = _characterPresentationRevision; if (!_disableRuntimeSideEffectsForTesting) { await MusicManager().stopVoice(); } + if (_disposed || presentationRevision != _characterPresentationRevision) { + return; + } + // 检查是否需要清除anime覆盖层(在用户交互时) if (_currentState.animeOverlay != null && !_currentState.animeKeep) { ////print('[GameManager] 用户点击继续,清除anime覆盖层: ${_currentState.animeOverlay}'); @@ -2520,11 +2384,14 @@ class GameManager { clearAnimeOverlay: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); } // 在用户点击继续时检查音乐区间 await _checkMusicRegionAtCurrentIndex(); + if (_disposed || presentationRevision != _characterPresentationRevision) { + return; + } _executeScript(); } @@ -2540,26 +2407,31 @@ class GameManager { clearDialogueAndSpeaker: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); _executeScript(); } /// 视频播放完成后继续执行脚本 void executeScriptAfterMovie() { + if (_disposed || _isSeekingNextChoice || _currentState.movieFile == null) { + return; + } //print('[GameManager] 视频播放完成,开始黑屏转场'); + final navigationRevision = _choiceSeekRevision; // 如果有context,使用转场效果;否则直接切换 if (_context != null) { TransitionOverlayManager.instance.transition( context: _context!, duration: const Duration(milliseconds: 600), // 转场时长 onMidTransition: () { + if (_disposed || navigationRevision != _choiceSeekRevision) return; // 在黑屏最深时清理movie状态并继续执行脚本 //print('[GameManager] 转场中点:清理movie状态并继续执行脚本'); _currentState = _currentState.copyWith( clearMovieFile: true, // 清理视频文件 ); - _gameStateController.add(_currentState); + _emitCurrentState(); _executeScript(); }, ); @@ -2569,54 +2441,93 @@ class GameManager { _currentState = _currentState.copyWith( clearMovieFile: true, // 清理视频文件 ); - _gameStateController.add(_currentState); + _emitCurrentState(); _executeScript(); } } Future _executeScript() async { - if (_isProcessing || _isWaitingForTimer) { + if (_disposed || + _isSeekingNextChoice || + _isProcessing || + _isWaitingForTimer) { return; } _isProcessing = true; + final presentationRevision = _characterPresentationRevision; + try { + await _processScriptNodes(); + } finally { + // Rendering/cache failures must never leave input permanently locked. + if (presentationRevision == _characterPresentationRevision) { + _isProcessing = false; + if (!_disposed && _pendingCharacterReflow) { + _pendingCharacterReflow = false; + unawaited( + _checkAndAnimateCharacterPositions( + _currentState.characters, + reconcileCurrentPositions: true, + ), + ); + } + } + } + } + + Future _processScriptNodes() async { + final presentationRevision = _characterPresentationRevision; //print('🎮 开始处理脚本,当前索引: $_scriptIndex'); - while (_scriptIndex < _script.children.length) { + // Async media, save, transition, and project API work can finish after the + // owning GamePlayScreen has been removed. Re-check the lifecycle at every + // node boundary so a stale execution cannot resume into a closed state + // stream after dispose. + while (!_disposed && + presentationRevision == _characterPresentationRevision && + _scriptIndex < _script.children.length) { final node = _script.children[_scriptIndex]; final currentNodeIndex = _scriptIndex; // 保存当前节点索引 - // 触发CG预分析(后台异步执行,不阻塞主流程) - _cgPreAnalyzer.preAnalyzeScript( - scriptNodes: _script.children, - currentIndex: _scriptIndex, - lookAheadLines: _isFastForwardMode ? 50 : 10, - isSkipping: _isFastForwardMode, - ); - - // 分段预取附近的短动画,避免进入游戏时扫描和解码整部作品。 - if (_scriptIndex % 120 == 0) { - unawaited(_analyzeAndPreloadAnimeResources()); - } - - // 智能预测器:每10个节点更新一次预热范围 - if (_scriptIndex % 10 == 0) { - // 获取当前标签 - String? currentLabel; - for (int i = _scriptIndex; i >= 0; i--) { - final checkNode = _script.children[i]; - if (checkNode is LabelNode) { - currentLabel = checkNode.name; - break; - } + if (_isSeekingNextChoice) { + if (--_choiceSeekNodesRemaining < 0) { + throw StateError('Choice seek exceeded the script execution limit'); } + } - // 更新智能预热 - _smartPredictor.smartPreWarm( + if (!_isSeekingNextChoice && !_disableRuntimeSideEffectsForTesting) { + // 触发CG预分析(后台异步执行,不阻塞主流程) + _cgPreAnalyzer.preAnalyzeScript( scriptNodes: _script.children, currentIndex: _scriptIndex, - currentLabel: currentLabel, + lookAheadLines: _isFastForwardMode ? 50 : 10, + isSkipping: _isFastForwardMode, ); + + // 分段预取附近的短动画,避免进入游戏时扫描和解码整部作品。 + if (_scriptIndex % 120 == 0) { + unawaited(_analyzeAndPreloadAnimeResources()); + } + + // 智能预测器:每10个节点更新一次预热范围 + if (_scriptIndex % 10 == 0) { + // 获取当前标签 + String? currentLabel; + for (int i = _scriptIndex; i >= 0; i--) { + final checkNode = _script.children[i]; + if (checkNode is LabelNode) { + currentLabel = checkNode.name; + break; + } + } + + // 更新智能预热 + _smartPredictor.smartPreWarm( + scriptNodes: _script.children, + currentIndex: _scriptIndex, + currentLabel: currentLabel, + ); + } } // 跳过注释节点(文件边界标记) @@ -2682,12 +2593,16 @@ class GameManager { // 快进模式下跳过转场效果,或其他需要跳过转场的情况 // 但从CG切换到场景时必须使用转场效果 - if ((_isFastForwardMode || - _context == null || - isInitialBackground || - isSameBackground) && - !isFromCGToScene) { + if (_isSeekingNextChoice || + ((_skipPresentation || + _context == null || + isInitialBackground || + isSameBackground) && + !isFromCGToScene)) { ////print('[GameManager] 跳过转场:${_isFastForwardMode ? "快进模式" : (isInitialBackground ? "初始背景" : (isSameBackground ? "相同背景" : "无context"))}'); + if (_isSeekingNextChoice && !isSameBackground) { + _choiceSeekLoopingAnimations.clear(); + } // 直接切换背景 _currentState = _currentState.copyWith( background: node.background, @@ -2710,17 +2625,23 @@ class GameManager { everShownCharacters: _everShownCharacters, ); //print('[GameManager] Scene命令清理movie状态: 新状态 movieFile=${_currentState.movieFile}, background=${_currentState.background}'); - _gameStateController.add(_currentState); + _emitCurrentState(); + + if (_isSeekingNextChoice) _settleSceneAnimationForChoiceSeek(); // 快进模式下跳过场景动画 - if (!_isFastForwardMode && + if (!_skipPresentation && node.animation != null && _tickerProvider != null) { _startSceneAnimation(node.animation!, node.repeatCount); } + // 等待前消费当前scene及已合并的fx,避免计时结束后重复执行。 + _scriptIndex += sceneFilter != null ? 2 : 1; + // 快进模式下跳过计时器 - if (!_isFastForwardMode && node.timer != null && node.timer! > 0) { + if (!_skipPresentation && node.timer != null && node.timer! > 0) { + _isWaitingForTimer = true; _startSceneTimer(node.timer!); return; } @@ -2759,6 +2680,7 @@ class GameManager { ////print('[GameManager] 从CG切换到场景,使用默认fade效果'); } + final navigationRevision = _choiceSeekRevision; _transitionToNewBackground( node.background, sceneFilter, @@ -2768,14 +2690,13 @@ class GameManager { node.repeatCount, shouldClearCG, ).then((_) { + if (_disposed || navigationRevision != _choiceSeekRevision) return; // 转场完成后启动计时器 _startSceneTimer(timerDuration); }); return; // 转场过程中暂停脚本执行,将在转场完成后自动恢复 } - // 如果有fx节点也跳过 - _scriptIndex += sceneFilter != null ? 2 : 1; continue; } @@ -2785,7 +2706,9 @@ class GameManager { // Movie处理逻辑,类似BackgroundNode但用于视频播放 // 检测是否包含chapter,如果是则停止快进 - if (_isFastForwardMode && _containsChapter(node.movieFile)) { + if (!_isSeekingNextChoice && + _isFastForwardMode && + _containsChapter(node.movieFile)) { //print('[GameManager] 检测到chapter视频,停止快进: ${node.movieFile}'); setFastForwardMode(false); } @@ -2810,13 +2733,15 @@ class GameManager { final isSameMovie = _currentState.movieFile == node.movieFile; // 快进模式下跳过转场效果,或其他需要跳过转场的情况 - if (_isFastForwardMode || + if (_skipPresentation || _context == null || isInitialMovie || isSameMovie) { + if (_isSeekingNextChoice) _choiceSeekLoopingAnimations.clear(); // 直接切换到视频 _currentState = _currentState.copyWith( movieFile: node.movieFile, + clearMovieFile: _isSeekingNextChoice, movieRepeatCount: node.repeatCount, // 新增:传递视频重复播放次数 background: null, // 清空背景,视频优先显示 sceneFilter: sceneFilter, @@ -2834,10 +2759,10 @@ class GameManager { clearSceneAnimation: node.animation == null, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); // 快进模式下跳过计时器,但正常模式下需要等待视频播放完成 - if (!_isFastForwardMode) { + if (!_skipPresentation) { // 设置处理锁,等待视频播放完成后再继续 _isProcessing = false; // 脚本索引推进,避免重复执行当前节点 @@ -2845,7 +2770,9 @@ class GameManager { return; // 等待视频播放完成的回调 } else { // 快进模式下跳过视频等待 - if (node.timer != null && node.timer! > 0) { + if (!_isSeekingNextChoice && + node.timer != null && + node.timer! > 0) { _startSceneTimer(node.timer!); return; } @@ -2859,6 +2786,7 @@ class GameManager { _isWaitingForTimer = true; _isProcessing = false; + final navigationRevision = _choiceSeekRevision; _transitionToNewMovie( node.movieFile, sceneFilter, @@ -2867,6 +2795,7 @@ class GameManager { node.animation, node.repeatCount, ).then((_) { + if (_disposed || navigationRevision != _choiceSeekRevision) return; _startSceneTimer(timerDuration); }); return; @@ -2881,7 +2810,7 @@ class GameManager { ////print('[GameManager] 处理AnimeNode: ${node.animeName}, loop: ${node.loop}, keep: ${node.keep}'); // 快进模式下跳过anime显示 - if (!_isFastForwardMode) { + if (!_skipPresentation || (_isSeekingNextChoice && node.keep)) { // 正常模式下显示anime _currentState = _currentState.copyWith( animeOverlay: node.animeName, @@ -2890,17 +2819,19 @@ class GameManager { clearDialogueAndSpeaker: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); } + // 等待前推进索引,计时结束后从下一个节点继续。 + _scriptIndex++; + // 快进模式下跳过计时器 - if (!_isFastForwardMode && node.timer != null && node.timer! > 0) { + if (!_skipPresentation && node.timer != null && node.timer! > 0) { _isWaitingForTimer = true; _startSceneTimer(node.timer!); return; // 等待计时器结束 } - _scriptIndex++; continue; } @@ -2911,7 +2842,7 @@ class GameManager { animeKeep: false, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); _scriptIndex++; continue; } @@ -2923,7 +2854,7 @@ class GameManager { scriptCanvasRevision: _currentState.scriptCanvasRevision + 1, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); _scriptIndex++; continue; } @@ -2934,7 +2865,7 @@ class GameManager { scriptCanvasRevision: _currentState.scriptCanvasRevision + 1, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); _scriptIndex++; continue; } @@ -2972,27 +2903,22 @@ class GameManager { // 跟踪角色是否曾经显示过 _everShownCharacters.add(finalCharacterKey); - final newCharacters = Map.of(_currentState.characters); - final currentCharacterState = _currentState.characters[finalCharacterKey] ?? CharacterState(resourceId: resourceId, positionId: positionId); // 检测角色位置变化并触发动画(如果需要) // 先将新角色添加到临时角色列表,然后检测位置变化 - final tempCharacters = Map.of(newCharacters); + final tempCharacters = Map.of(_currentState.characters); - // 清理现有角色的动画属性,确保位置计算基于基础位置 - for (final entry in tempCharacters.entries) { - tempCharacters[entry.key] = entry.value.copyWith( - clearAnimationProperties: true, // 清理动画属性 - ); - } - - // 先计算目标pose和expression,确保使用默认值 - final targetPose = node.pose ?? currentCharacterState.pose ?? 'pose1'; - final targetExpression = - node.expression ?? currentCharacterState.expression ?? 'happy'; + final targetVisual = _resolveCharacterVisualForResource( + currentState: currentCharacterState, + targetResourceId: resourceId, + requestedPose: node.pose, + requestedExpression: node.expression, + ); + final targetPose = targetVisual.pose; + final targetExpression = targetVisual.expression; tempCharacters[finalCharacterKey] = currentCharacterState.copyWith( resourceId: resourceId, @@ -3002,21 +2928,35 @@ class GameManager { clearAnimationProperties: true, ); - // 快进模式下跳过位置动画 - if (!_isFastForwardMode) { - await _checkAndAnimateCharacterPositions(tempCharacters); + await _checkAndAnimateCharacterPositions(tempCharacters); + if (_disposed || + presentationRevision != _characterPresentationRevision) { + return; } - await CharacterCompositeCache.instance.preload( - resourceId, - targetPose, - targetExpression, - ); + if (!_isSeekingNextChoice && !_disableRuntimeSideEffectsForTesting) { + await CharacterCompositeCache.instance.preload( + resourceId, + targetPose, + targetExpression, + ); + } + + if (_disposed || + presentationRevision != _characterPresentationRevision) { + return; + } - newCharacters[finalCharacterKey] = currentCharacterState.copyWith( + // Position ticks and completed hide callbacks own the live map. Merge + // this show into it instead of restoring the pre-animation snapshot. + final newCharacters = Map.of(_currentState.characters); + final latestCharacter = + newCharacters[finalCharacterKey] ?? currentCharacterState; + newCharacters[finalCharacterKey] = latestCharacter.copyWith( resourceId: resourceId, pose: targetPose, expression: targetExpression, + isFadingOut: false, positionId: node.position ?? currentCharacterState.positionId, clearAnimationProperties: node.animation == null && node.position != null, @@ -3027,14 +2967,15 @@ class GameManager { clearDialogueAndSpeaker: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); // 如果有动画,启动动画播放(非阻塞) if (node.animation != null) { - if (_isFastForwardMode) { + if (_skipPresentation) { _applyCharacterAnimationFinalState( finalCharacterKey, node.animation!, + repeatCount: node.repeatCount, ); } else { _playCharacterAnimation( @@ -3082,7 +3023,9 @@ class GameManager { if (kEngineDebugMode) { //print('[GameManager] CG参数: resourceId=$resourceId, pose=$newPose, expression=$newExpression, finalKey=$finalCharacterKey'); } - if (!kIsWeb) { + if (!kIsWeb && + !_isSeekingNextChoice && + !_disableRuntimeSideEffectsForTesting) { final gpuEntry = await GpuImageCompositor().getCompositeEntry( resourceId: resourceId, pose: newPose, @@ -3116,15 +3059,22 @@ class GameManager { // CgSlotWidget 使用 CharacterCompositeCache 取首帧。先在状态切换前 // 等待同一份合成结果,保证 diss 的 800ms 从真实 CG 已可绘制时开始, // 而不是把异步合成时间算进转场并提前清掉旧场景。 - await CharacterCompositeCache.instance.preload( - resourceId, - newPose, - newExpression, - ); + if (!_isSeekingNextChoice && !_disableRuntimeSideEffectsForTesting) { + await CharacterCompositeCache.instance.preload( + resourceId, + newPose, + newExpression, + ); + } + if (_isSeekingNextChoice) { + _choiceSeekLoopingAnimations.removeWhere( + (key, value) => key != finalCharacterKey || value.$1 != resourceId, + ); + } final requestedTransition = node.transitionType?.toLowerCase(); final useDirectDissolve = - !_isFastForwardMode && + !_skipPresentation && _context != null && (requestedTransition == 'diss' || requestedTransition == 'dissolve'); @@ -3200,7 +3150,7 @@ class GameManager { clearDialogueAndSpeaker: !isCgVariantUpdate, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); if (kEngineDebugMode) { //print('[GameManager] CG状态已更新,当前CG角色数量: ${_currentState.cgCharacters.length}'); @@ -3209,10 +3159,11 @@ class GameManager { // 如果有动画,启动动画播放(非阻塞) if (node.animation != null) { - if (_isFastForwardMode) { + if (_skipPresentation) { _applyCharacterAnimationFinalState( finalCharacterKey, node.animation!, + repeatCount: node.repeatCount, ); } else { _playCharacterAnimation( @@ -3242,7 +3193,7 @@ class GameManager { ); _activeCgTransitionType = null; _cgTransitionCompleter = null; - _gameStateController.add(_currentState); + _emitCurrentState(); } _scriptIndex++; @@ -3259,7 +3210,8 @@ class GameManager { final character = newCharacters[hideKey]; if (character != null) { - if (node.immediate) { + if (node.immediate || _isSeekingNextChoice) { + _choiceSeekLoopingAnimations.remove(hideKey); // 全屏遮罩等紧随其后的演出需要角色在同一脚本节点内消失, // 不能等待渲染器完成异步淡出。 final activeAnimation = _activeCharacterAnimations.remove(hideKey); @@ -3276,7 +3228,7 @@ class GameManager { clearDialogueAndSpeaker: false, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); } _scriptIndex++; @@ -3333,10 +3285,16 @@ class GameManager { //print('[GameManager] ConditionalSay: 角色 $finalCharacterKey 差分切换时继承动画属性: $inheritedAnimationProperties'); } + final targetVisual = _resolveCharacterVisualForResource( + currentState: currentCharacterState, + targetResourceId: targetResourceId, + requestedPose: node.pose, + requestedExpression: node.expression, + ); final updatedCharacter = currentCharacterState.copyWith( resourceId: targetResourceId, - pose: node.pose, - expression: node.expression, + pose: targetVisual.pose, + expression: targetVisual.expression, // Dialogue aliases auto-render their sprite. If a preceding // hide is still fading, speaking again cancels that fade. isFadingOut: false, @@ -3353,7 +3311,7 @@ class GameManager { if (node.position != null && node.position != currentCharacterState.positionId) { // 快进模式下跳过位置变化动画 - if (!_isFastForwardMode) { + if (!_skipPresentation) { await _checkAndAnimatePoseAttributeChanges( characterId: finalCharacterKey, oldPositionId: currentCharacterState.positionId, @@ -3362,18 +3320,27 @@ class GameManager { } } + if (_disposed || + presentationRevision != _characterPresentationRevision) { + return; + } + _currentState = _currentState.copyWith( - characters: newCharacters, + characters: { + ..._currentState.characters, + finalCharacterKey: newCharacters[finalCharacterKey]!, + }, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); // 如果有动画,启动动画播放(非阻塞) if (node.animation != null) { - if (_isFastForwardMode) { + if (_skipPresentation) { _applyCharacterAnimationFinalState( finalCharacterKey, node.animation!, + repeatCount: node.repeatCount, ); } else { _playCharacterAnimation( @@ -3394,13 +3361,6 @@ class GameManager { final newCharacters = Map.of(_currentState.characters); - // 清理现有角色的动画属性,确保位置计算基于基础位置 - for (final entry in newCharacters.entries) { - newCharacters[entry.key] = entry.value.copyWith( - clearAnimationProperties: true, // 清理动画属性 - ); - } - newCharacters[finalCharacterKey] = currentCharacterState.copyWith( pose: node.pose, expression: node.expression, @@ -3408,22 +3368,28 @@ class GameManager { ); // 检测角色位置变化并触发动画(如果需要) - if (!_isFastForwardMode) { - await _checkAndAnimateCharacterPositions(newCharacters); + await _checkAndAnimateCharacterPositions(newCharacters); + if (_disposed || + presentationRevision != _characterPresentationRevision) { + return; } _currentState = _currentState.copyWith( - characters: newCharacters, + characters: { + ..._currentState.characters, + finalCharacterKey: newCharacters[finalCharacterKey]!, + }, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); // 如果有动画,启动动画播放(非阻塞) if (node.animation != null) { - if (_isFastForwardMode) { + if (_skipPresentation) { _applyCharacterAnimationFinalState( finalCharacterKey, node.animation!, + repeatCount: node.repeatCount, ); } else { _playCharacterAnimation( @@ -3489,16 +3455,19 @@ class GameManager { sourceLine: node.sourceLine, ); - _gameStateController.add(_currentState); + _emitCurrentState(); // 检查是否需要创建章节开头的自动存档(NVL模式) try { - await _chapterAutoSaveManager.onDialogueDisplayed( - scriptIndex: currentNodeIndex, // 使用当前节点索引 - currentScriptFile: currentScriptFile, - currentLabel: _findNearestLabel(currentNodeIndex), - saveStateSnapshot: saveStateSnapshot, - flowchartManager: _flowchartManager, - ); + if (!_isSeekingNextChoice && + !_disableRuntimeSideEffectsForTesting) { + await _chapterAutoSaveManager.onDialogueDisplayed( + scriptIndex: currentNodeIndex, // 使用当前节点索引 + currentScriptFile: currentScriptFile, + currentLabel: _findNearestLabel(currentNodeIndex), + saveStateSnapshot: saveStateSnapshot, + flowchartManager: _flowchartManager, + ); + } } catch (e, stackTrace) { if (kEngineDebugMode) { print('[GameManager] ❌ 章节自动存档检查失败: $e'); @@ -3563,10 +3532,10 @@ class GameManager { clearDialogueAndSpeaker: false, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); _scriptIndex = followingMenuNodeIndex; } else { - _gameStateController.add(_currentState); + _emitCurrentState(); _scriptIndex++; } _isProcessing = false; @@ -3625,7 +3594,7 @@ class GameManager { // 处理时序差分切换 String? finalExpression = node.expression; if (node.hasTimedExpression) { - if (_isFastForwardMode) { + if (_skipPresentation) { // 快进模式下直接使用目标差分 finalExpression = node.endExpression; //print('[GameManager] 快进模式: 直接使用目标差分 ${node.endExpression}'); @@ -3657,10 +3626,16 @@ class GameManager { //print('[GameManager] 角色 $finalCharacterKey 差分切换时继承动画属性: $inheritedAnimationProperties'); } + final targetVisual = _resolveCharacterVisualForResource( + currentState: currentCharacterState, + targetResourceId: targetResourceId, + requestedPose: node.pose, + requestedExpression: finalExpression, + ); final updatedCharacter = currentCharacterState.copyWith( resourceId: targetResourceId, - pose: node.pose, - expression: finalExpression, + pose: targetVisual.pose, + expression: targetVisual.expression, // Dialogue aliases auto-render their sprite. If a preceding // hide is still fading, speaking again cancels that fade. isFadingOut: false, @@ -3674,7 +3649,8 @@ class GameManager { newCharacters[finalCharacterKey] = updatedCharacter; // 如果位置发生变化,播放pose属性变化动画 - if (node.position != null && + if (!_skipPresentation && + node.position != null && node.position != currentCharacterState.positionId) { await _checkAndAnimatePoseAttributeChanges( characterId: finalCharacterKey, @@ -3683,20 +3659,29 @@ class GameManager { ); } + if (_disposed || + presentationRevision != _characterPresentationRevision) { + return; + } + ////print('[GameManager] 角色更新后状态: pose=${updatedCharacter.pose}, expression=${updatedCharacter.expression}, position=${updatedCharacter.positionId}'); _currentState = _currentState.copyWith( - characters: newCharacters, + characters: { + ..._currentState.characters, + finalCharacterKey: newCharacters[finalCharacterKey]!, + }, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); ////print('[GameManager] 发送状态更新,当前角色列表: ${newCharacters.keys}'); // 如果有动画,启动动画播放(非阻塞) if (node.animation != null) { - if (_isFastForwardMode) { + if (_skipPresentation) { _applyCharacterAnimationFinalState( finalCharacterKey, node.animation!, + repeatCount: node.repeatCount, ); } else { _playCharacterAnimation( @@ -3718,17 +3703,10 @@ class GameManager { final newCharacters = Map.of(_currentState.characters); - // 清理现有角色的动画属性,确保位置计算基于基础位置 - for (final entry in newCharacters.entries) { - newCharacters[entry.key] = entry.value.copyWith( - clearAnimationProperties: true, // 清理动画属性 - ); - } - // 处理时序差分切换 String? finalExpression = node.expression; if (node.hasTimedExpression) { - if (_isFastForwardMode) { + if (_skipPresentation) { // 快进模式下直接使用目标差分 finalExpression = node.endExpression; //print('[GameManager] 快进模式: 直接使用目标差分 ${node.endExpression}'); @@ -3753,23 +3731,29 @@ class GameManager { ); // 检测角色位置变化并触发动画(如果需要) - if (!_isFastForwardMode) { - await _checkAndAnimateCharacterPositions(newCharacters); + await _checkAndAnimateCharacterPositions(newCharacters); + if (_disposed || + presentationRevision != _characterPresentationRevision) { + return; } _currentState = _currentState.copyWith( - characters: newCharacters, + characters: { + ..._currentState.characters, + finalCharacterKey: newCharacters[finalCharacterKey]!, + }, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); ////print('[GameManager] 发送状态更新,当前角色列表: ${newCharacters.keys}'); // 如果有动画,启动动画播放(非阻塞) if (node.animation != null) { - if (_isFastForwardMode) { + if (_skipPresentation) { _applyCharacterAnimationFinalState( finalCharacterKey, node.animation!, + repeatCount: node.repeatCount, ); } else { _playCharacterAnimation( @@ -3836,19 +3820,22 @@ class GameManager { sourceLine: node.sourceLine, ); - _gameStateController.add(_currentState); + _emitCurrentState(); // 检查是否需要创建章节开头的自动存档(NVL模式-第二处) try { - await _chapterAutoSaveManager.onDialogueDisplayed( - scriptIndex: currentNodeIndex, // 使用当前节点索引,而不是_scriptIndex - currentScriptFile: currentScriptFile, - currentLabel: _findNearestLabel( - currentNodeIndex, - ), // 也使用currentNodeIndex查找label - saveStateSnapshot: saveStateSnapshot, - flowchartManager: _flowchartManager, - ); + if (!_isSeekingNextChoice && + !_disableRuntimeSideEffectsForTesting) { + await _chapterAutoSaveManager.onDialogueDisplayed( + scriptIndex: currentNodeIndex, // 使用当前节点索引,而不是_scriptIndex + currentScriptFile: currentScriptFile, + currentLabel: _findNearestLabel( + currentNodeIndex, + ), // 也使用currentNodeIndex查找label + saveStateSnapshot: saveStateSnapshot, + flowchartManager: _flowchartManager, + ); + } } catch (e, stackTrace) { if (kEngineDebugMode) { print('[GameManager] ❌ 章节自动存档检查失败: $e'); @@ -3889,13 +3876,16 @@ class GameManager { // 检查是否需要创建章节开头的自动存档(普通对话模式) try { - await _chapterAutoSaveManager.onDialogueDisplayed( - scriptIndex: currentNodeIndex, // 使用当前节点索引 - currentScriptFile: currentScriptFile, - currentLabel: _findNearestLabel(currentNodeIndex), - saveStateSnapshot: saveStateSnapshot, - flowchartManager: _flowchartManager, - ); + if (!_isSeekingNextChoice && + !_disableRuntimeSideEffectsForTesting) { + await _chapterAutoSaveManager.onDialogueDisplayed( + scriptIndex: currentNodeIndex, // 使用当前节点索引 + currentScriptFile: currentScriptFile, + currentLabel: _findNearestLabel(currentNodeIndex), + saveStateSnapshot: saveStateSnapshot, + flowchartManager: _flowchartManager, + ); + } } catch (e, stackTrace) { if (kEngineDebugMode) { print('[GameManager] ❌ 章节自动存档检查失败: $e'); @@ -3928,10 +3918,10 @@ class GameManager { clearDialogueAndSpeaker: false, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); _scriptIndex = followingMenuNodeIndex; } else { - _gameStateController.add(_currentState); + _emitCurrentState(); _scriptIndex++; } _isProcessing = false; @@ -3953,7 +3943,7 @@ class GameManager { clearDialogueAndSpeaker: false, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); // 注意:不立即推进脚本索引,让存档能够保存到MenuNode的位置 // _scriptIndex 将在选择完成后由 jumpToLabel 推进 _isProcessing = false; @@ -3984,6 +3974,12 @@ class GameManager { continue; } } + if (_isSeekingNextChoice) { + final targetIndex = _labelIndexMap[node.targetLabel]; + if (targetIndex == null) return; + _scriptIndex = targetIndex; + continue; + } _scriptIndex++; _isProcessing = false; jumpToLabel(node.targetLabel); @@ -4003,7 +3999,7 @@ class GameManager { clearDialogueAndSpeaker: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); _scriptIndex++; continue; } @@ -4020,7 +4016,7 @@ class GameManager { clearDialogueAndSpeaker: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); _scriptIndex++; continue; } @@ -4038,7 +4034,7 @@ class GameManager { clearDialogueAndSpeaker: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); _scriptIndex++; continue; } @@ -4056,7 +4052,7 @@ class GameManager { clearDialogueAndSpeaker: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); _scriptIndex++; continue; // 继续执行后续节点 } @@ -4073,7 +4069,7 @@ class GameManager { clearDialogueAndSpeaker: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); _scriptIndex++; continue; // 继续执行后续节点 } @@ -4091,7 +4087,7 @@ class GameManager { clearDialogueAndSpeaker: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); _scriptIndex++; continue; // 继续执行后续节点 } @@ -4111,7 +4107,7 @@ class GameManager { sceneFilter: filter, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); if (_fxDiagLogs) { debugPrint( '[FX_DIAG] FxNode applied index=$_scriptIndex ' @@ -4151,11 +4147,13 @@ class GameManager { '[MusicRegion] PlayMusicNode触发播放: index=$_scriptIndex, raw="${node.musicFile}", regionMusic="${musicRegion.musicFile}", resolvedPath="$fullMusicPath"', ); } - await MusicManager().playBackgroundMusic( - fullMusicPath, - fadeTransition: true, - fadeDuration: const Duration(milliseconds: 1000), - ); + if (!_isSeekingNextChoice && !_disableRuntimeSideEffectsForTesting) { + await MusicManager().playBackgroundMusic( + fullMusicPath, + fadeTransition: true, + fadeDuration: const Duration(milliseconds: 1000), + ); + } _currentState = _currentState.copyWith( currentMusicRegion: musicRegion, ); @@ -4170,11 +4168,13 @@ class GameManager { if (node is StopMusicNode) { // 使用音乐区间系统处理音乐停止 - await MusicManager().stopBackgroundMusic( - fadeOut: true, - fadeDuration: const Duration(milliseconds: 800), - ); - _currentState = _currentState.copyWith(currentMusicRegion: null); + if (!_isSeekingNextChoice && !_disableRuntimeSideEffectsForTesting) { + await MusicManager().stopBackgroundMusic( + fadeOut: true, + fadeDuration: const Duration(milliseconds: 800), + ); + } + _currentState = _currentState.copyWith(clearCurrentMusicRegion: true); if (kEngineDebugMode) { //print('[MusicRegion] 停止音乐 at index $_scriptIndex'); @@ -4191,7 +4191,9 @@ class GameManager { ..add(soundPath); } - if (!_disableRuntimeSideEffectsForTesting && soundPath.isNotEmpty) { + if (!_disableRuntimeSideEffectsForTesting && + !_isSeekingNextChoice && + soundPath.isNotEmpty) { await MusicManager().playAudio( soundPath, AudioTrackConfig.sound, @@ -4207,7 +4209,7 @@ class GameManager { if (node is VoiceNode) { final voicePath = MusicManager.buildVoiceAssetPath(node.voiceFile); if (!_disableRuntimeSideEffectsForTesting && - !_isFastForwardMode && + !_skipPresentation && voicePath.isNotEmpty) { await MusicManager().playVoice(voicePath); } @@ -4216,7 +4218,7 @@ class GameManager { } if (node is StopVoiceNode) { - if (!_disableRuntimeSideEffectsForTesting) { + if (!_disableRuntimeSideEffectsForTesting && !_isSeekingNextChoice) { await MusicManager().stopVoice(); } _scriptIndex++; @@ -4225,7 +4227,7 @@ class GameManager { if (node is StopSoundNode) { _activeLoopingSounds.clear(); - if (!_disableRuntimeSideEffectsForTesting) { + if (!_disableRuntimeSideEffectsForTesting && !_isSeekingNextChoice) { await MusicManager().stopAudio( AudioTrackConfig.sound, fadeOut: true, @@ -4259,14 +4261,14 @@ class GameManager { if (result.handled) { if (result.nextState != null) { _currentState = result.nextState!; - _gameStateController.add(_currentState); + _emitCurrentState(); } final waitDuration = result.waitDuration; _scriptIndex++; if (waitDuration != null && waitDuration > Duration.zero) { - if (_isFastForwardMode) { + if (_skipPresentation) { if (result.stateAfterWait != null) { _applyScriptApiStateAfterWait(result.stateAfterWait!); } @@ -4301,6 +4303,12 @@ class GameManager { continue; } + if (node is PauseNode && _skipPresentation || + node is ShakeNode && _isSeekingNextChoice) { + _scriptIndex++; + continue; + } + if (node is PauseNode) { // 处理暂停命令:pause(0.5) // 设置暂停等待标志 @@ -4312,7 +4320,7 @@ class GameManager { isPaused: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); // 启动计时器 _currentTimer?.cancel(); @@ -4341,10 +4349,14 @@ class GameManager { shakeIntensity: intensity, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); // 启动计时器,震动结束后清除震动状态 + final navigationRevision = _choiceSeekRevision; Timer(Duration(milliseconds: (duration * 1000).round()), () { + if (_disposed || navigationRevision != _choiceSeekRevision) { + return; + } _currentState = _currentState.copyWith( isShaking: false, shakeTarget: null, @@ -4352,14 +4364,13 @@ class GameManager { shakeIntensity: null, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); }); _scriptIndex++; continue; } } - _isProcessing = false; } GameStateSnapshot saveStateSnapshot() { @@ -4391,6 +4402,7 @@ class GameManager { int? repeatCount, ]) async { if (_context == null) return; + final navigationRevision = _choiceSeekRevision; //////print('[GameManager] 开始movie转场到视频: $movieFile, 转场类型: ${transitionType ?? "fade"}'); @@ -4405,6 +4417,7 @@ class GameManager { oldBackground: oldBackground, newBackground: null, // 视频会占据整个背景 onMidTransition: () async { + if (_disposed || navigationRevision != _choiceSeekRevision) return; //////print('[GameManager] movie转场中点,更新状态'); _currentState = _currentState.copyWith( movieFile: movieFile, @@ -4425,7 +4438,7 @@ class GameManager { clearSceneAnimation: animation == null, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); // 启动场景动画(如果有) if (animation != null && _tickerProvider != null) { @@ -4436,6 +4449,7 @@ class GameManager { //////print('[GameManager] movie转场完成'); } catch (e) { + if (_disposed || navigationRevision != _choiceSeekRevision) return; //print('[GameManager] movie转场失败: $e'); // 转场失败时直接更新状态 _currentState = _currentState.copyWith( @@ -4455,7 +4469,7 @@ class GameManager { clearSceneAnimation: animation == null, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); if (animation != null && _tickerProvider != null) { _startSceneAnimation(animation, repeatCount); @@ -4775,7 +4789,7 @@ class GameManager { // 兼容旧历史快照语义:确保跳转后立即显示选中句,并从下一句继续推进。 if (!snapshot.isNvlMode) { _refreshCurrentStateDialogue(dialogueScriptIndex: entry.scriptIndex); - _gameStateController.add(_currentState); + _emitCurrentState(); } _scriptIndex = nextScriptIndex; await _checkMusicRegionAtCurrentIndex(forceCheck: true); @@ -4821,6 +4835,9 @@ class GameManager { /// 启动场景计时器 void _startSceneTimer(double seconds) { + if (_disposed) { + return; + } // 取消之前的计时器(如果存在) _currentTimer?.cancel(); _currentTimerCompletion = null; @@ -4828,6 +4845,9 @@ class GameManager { final durationMs = (seconds * 1000).round(); _currentTimer = Timer(Duration(milliseconds: durationMs), () async { + if (_disposed) { + return; + } // 检查计时器是否仍然有效(防止已被取消的计时器执行) if (_isWaitingForTimer && _currentTimer != null && @@ -4860,8 +4880,12 @@ class GameManager { required double delay, }) { final delayMs = (delay * 1000).round(); + final navigationRevision = _choiceSeekRevision; Timer(Duration(milliseconds: delayMs), () { + if (_disposed || navigationRevision != _choiceSeekRevision) { + return; + } // 检查角色是否仍然存在 final currentCharacter = _currentState.characters[characterKey]; if (currentCharacter != null) { @@ -4877,7 +4901,7 @@ class GameManager { characters: newCharacters, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); } }); } @@ -4943,6 +4967,7 @@ class GameManager { bool? clearCG, ]) async { if (_context == null) return; + final navigationRevision = _choiceSeekRevision; //////print('[GameManager] 开始scene转场到背景: $newBackground, 转场类型: ${transitionType ?? "fade"}'); @@ -4973,6 +4998,7 @@ class GameManager { ////print('[GameManager] 警告: 无法找到背景图片进行预加载: $newBackground'); } } catch (e) { + if (_disposed || navigationRevision != _choiceSeekRevision) return; ////print('[GameManager] 预加载背景图片失败: $e'); } } @@ -5015,12 +5041,14 @@ class GameManager { ////print('[GameManager] diss转场参数: 旧背景="$oldBackgroundName", 新背景="$newBackgroundName"'); } + if (_disposed || navigationRevision != _choiceSeekRevision) return; + // 在转场开始前先清除对话框,避免"残留"效果 _currentState = _currentState.copyWith( clearDialogueAndSpeaker: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); // 根据转场类型选择转场管理器 if (effectType == TransitionType.fade) { @@ -5028,6 +5056,7 @@ class GameManager { await SceneTransitionManager.instance.transition( context: _context!, onMidTransition: () { + if (_disposed || navigationRevision != _choiceSeekRevision) return; //////print('[GameManager] scene转场中点 - 切换背景到: $newBackground'); // 在黑屏最深时切换背景和清除所有角色(类似Renpy) // 先停止并清理旧的场景动画控制器 @@ -5053,7 +5082,7 @@ class GameManager { everShownCharacters: _everShownCharacters, ); //////print('[GameManager] 状态更新 - 旧背景: ${oldState.background}, 新背景: ${_currentState.background}'); - _gameStateController.add(_currentState); + _emitCurrentState(); //////print('[GameManager] 状态已发送到Stream'); }, duration: const Duration(milliseconds: 800), @@ -5067,6 +5096,7 @@ class GameManager { newBackground: newBackgroundName, captureFrame: _transitionFrameCapturer, onMidTransition: () { + if (_disposed || navigationRevision != _choiceSeekRevision) return; //////print('[GameManager] scene转场中点 - 切换背景到: $newBackground'); // dissolve 会先用完整旧场景帧盖住底层,再在这里提交新状态并 // 捕获完整目标场景;其他效果仍在各自的视觉中点切换状态。 @@ -5095,7 +5125,7 @@ class GameManager { everShownCharacters: _everShownCharacters, ); //////print('[GameManager] 状态更新 - 旧背景: ${oldState.background}, 新背景: ${_currentState.background}'); - _gameStateController.add(_currentState); + _emitCurrentState(); //////print('[GameManager] 状态已发送到Stream'); } else { // 旧场景冻结帧仍完全可见,此时更新不会提前泄露目标背景。 @@ -5120,7 +5150,7 @@ class GameManager { clearSceneAnimation: animation == null, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); } }, duration: const Duration(milliseconds: 800), @@ -5196,7 +5226,7 @@ class GameManager { // 发送状态更新 if (notifyListeners) { - _gameStateController.add(_currentState); + _emitCurrentState(); } } else { ////print('[GameManager] 当前场景没有检测到动画'); @@ -5256,8 +5286,9 @@ class GameManager { void _applyCharacterAnimationFinalState( String characterId, - String animationName, - ) { + String animationName, { + int? repeatCount, + }) { var isCgCharacter = false; var characterState = _currentState.characters[characterId]; if (characterState == null) { @@ -5265,6 +5296,16 @@ class GameManager { isCgCharacter = characterState != null; } if (characterState == null) return; + if (_isSeekingNextChoice) { + if (repeatCount == 0) { + _choiceSeekLoopingAnimations[characterId] = ( + characterState.resourceId, + animationName, + ); + } else { + _choiceSeekLoopingAnimations.remove(characterId); + } + } final baseProperties = _buildCharacterAnimationBaseProperties( characterId, @@ -5297,7 +5338,7 @@ class GameManager { everShownCharacters: _everShownCharacters, ); } - _gameStateController.add(_currentState); + _emitCurrentState(); } /// 播放角色动画 @@ -5340,11 +5381,13 @@ class GameManager { ); if (baseProperties == null) return; + final navigationRevision = _choiceSeekRevision; // 创建动画控制器 late final CharacterAnimationController animController; animController = CharacterAnimationController( characterId: characterId, onAnimationUpdate: (properties) { + if (_disposed || navigationRevision != _choiceSeekRevision) return; if (isCgCharacter) { final newCgCharacters = Map.of(_currentState.cgCharacters); final currentCgState = newCgCharacters[characterId]; @@ -5356,7 +5399,7 @@ class GameManager { cgCharacters: newCgCharacters, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); } } else { final newCharacters = Map.of(_currentState.characters); @@ -5369,11 +5412,12 @@ class GameManager { characters: newCharacters, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); } } }, onComplete: () { + if (_disposed || navigationRevision != _choiceSeekRevision) return; //print('[GameManager] 角色 $characterId 动画 $animationName 播放完成'); // 与 Ren'Py ATL 一致:有限动画结束后保留最后一帧。 // 后续显式位置或动画命令负责接管/复位这些属性。 @@ -5445,18 +5489,21 @@ class GameManager { 'rotation': 0.0, }; + final navigationRevision = _choiceSeekRevision; // 创建场景动画控制器 _sceneAnimationController = SceneAnimationController( sceneId: 'scene_background', onAnimationUpdate: (properties) { + if (_disposed || navigationRevision != _choiceSeekRevision) return; // 实时更新场景动画属性 _currentState = _currentState.copyWith( sceneAnimationProperties: properties, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); }, onComplete: () { + if (_disposed || navigationRevision != _choiceSeekRevision) return; ////print('[GameManager] 场景动画 $animationName 播放完成'); // 保持动画的最终状态,不清除动画属性 final finalProperties = _sceneAnimationController?.currentProperties; @@ -5465,7 +5512,7 @@ class GameManager { sceneAnimationProperties: finalProperties, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); } _sceneAnimationController?.dispose(); _sceneAnimationController = null; @@ -5485,145 +5532,48 @@ class GameManager { /// 淡出动画完成后移除角色 void removeCharacterAfterFadeOut(String characterId) { + if (_disposed) return; final fadingCharacter = _currentState.characters[characterId]; if (fadingCharacter == null || !fadingCharacter.isFadingOut) { - // A character may have spoken again while the old fade completion - // callback was queued. Never let that stale callback remove the revived - // sprite. - return; - } - - final oldCharacters = Map.of(_currentState.characters); - final newCharacters = Map.of(_currentState.characters); - newCharacters.remove(characterId); - - if (_tickerProvider == null || newCharacters.isEmpty) { - _currentState = _currentState.copyWith( - characters: newCharacters, - clearDialogueAndSpeaker: false, - everShownCharacters: _everShownCharacters, - ); - _gameStateController.add(_currentState); + // Ignore an old hide callback if dialogue/show has revived this slot. return; } - // 手动计算位置变化,考虑角色的实际当前位置(包括动画属性) - final characterOrder = newCharacters.keys.toList(); - final newDistributed = CharacterAutoDistribution.calculateAutoDistribution( - newCharacters, + final oldCharacters = _currentState.characters; + final oldDistribution = CharacterAutoDistribution.calculateAutoDistribution( + oldCharacters, _poseConfigs, - characterOrder, + oldCharacters.keys.toList(), ); - - final positionChanges = []; - for (final characterId in newCharacters.keys) { - final character = newCharacters[characterId]!; - final originalPose = _poseConfigs[character.positionId]; - - if (originalPose != null && originalPose.isAutoAnchor) { - // 获取角色当前的实际显示位置 - double currentX = originalPose.xcenter; - if (character.animationProperties != null && - character.animationProperties!.containsKey('xcenter')) { - currentX = character.animationProperties!['xcenter']!; - } else { - // 如果没有动画属性,使用当前自动分布后的位置 - final currentDistributed = - CharacterAutoDistribution.calculateAutoDistribution( - oldCharacters, - _poseConfigs, - oldCharacters.keys.toList(), - ); - final currentAutoDistributedPoseId = - '${characterId}_auto_distributed'; - final currentDistributedPose = - currentDistributed[currentAutoDistributedPoseId] ?? originalPose; - currentX = currentDistributedPose.xcenter; - } - - // 获取新的目标位置 - final newAutoDistributedPoseId = '${characterId}_auto_distributed'; - final newDistributedPose = - newDistributed[newAutoDistributedPoseId] ?? originalPose; - final targetX = newDistributedPose.xcenter; - - // 如果位置有变化,添加到动画列表 - if ((currentX - targetX).abs() > 0.001) { - positionChanges.add( - CharacterPositionChange( - characterId: characterId, - fromX: currentX, - toX: targetX, - ), - ); - } + final newCharacters = Map.of(oldCharacters)..remove(characterId); + for (final entry in newCharacters.entries) { + final oldPose = oldDistribution['${entry.key}_auto_distributed']; + if (oldPose != null) { + // Keep the rendered position while the cast changes. Reflow must only + // change x; zoom, jump height, opacity, and other script transforms stay. + newCharacters[entry.key] = entry.value.copyWith( + animationProperties: { + ...?entry.value.animationProperties, + 'xcenter': + entry.value.animationProperties?['xcenter'] ?? oldPose.xcenter, + }, + ); } } + _currentState = _currentState.copyWith(characters: newCharacters); + _emitCurrentState(); - // 如果有位置变化,播放动画 - if (positionChanges.isNotEmpty) { - // 先移除角色,同时设置剩余角色的动画属性为当前位置,避免闪烁 - final updatedCharacters = Map.from(newCharacters); - for (final change in positionChanges) { - final character = updatedCharacters[change.characterId]; - if (character != null) { - updatedCharacters[change.characterId] = character.copyWith( - animationProperties: {'xcenter': change.fromX}, // 设置为当前位置,避免闪烁 - ); - } - } - - _currentState = _currentState.copyWith( - characters: updatedCharacters, - clearDialogueAndSpeaker: false, - everShownCharacters: _everShownCharacters, - ); - _gameStateController.add(_currentState); - - // 然后播放动画 - _characterPositionAnimator?.stop(); - _characterPositionAnimator = CharacterPositionAnimator(); - - _characterPositionAnimator!.animatePositionChanges( - positionChanges: positionChanges, - vsync: _tickerProvider!, - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut, - onUpdate: (positions) { - final animatingCharacters = Map.from( - _currentState.characters, - ); - for (final entry in positions.entries) { - final targetCharacterId = entry.key; - final xPosition = entry.value; - final character = animatingCharacters[targetCharacterId]; - if (character != null) { - animatingCharacters[targetCharacterId] = character.copyWith( - animationProperties: {'xcenter': xPosition}, - ); - } - } - - _currentState = _currentState.copyWith( - characters: animatingCharacters, - clearDialogueAndSpeaker: false, - everShownCharacters: _everShownCharacters, - ); - _gameStateController.add(_currentState); - }, - onComplete: () { - // 动画完成,简单更新状态,不清理动画属性 - // 让后续的正常操作(如添加新角色)自然地处理最终状态 - }, - ); + if (_isProcessing) { + // A show/pose command may be awaiting this same animator. Remove the + // faded sprite now, but reflow only after that command commits its cast. + _pendingCharacterReflow = true; } else { - // 没有位置变化,直接移除角色 - _currentState = _currentState.copyWith( - characters: newCharacters, - clearDialogueAndSpeaker: false, - everShownCharacters: _everShownCharacters, + unawaited( + _checkAndAnimateCharacterPositions( + newCharacters, + reconcileCurrentPositions: true, + ), ); - _gameStateController.add(_currentState); } } @@ -5633,7 +5583,7 @@ class GameManager { clearAnimeOverlay: true, everShownCharacters: _everShownCharacters, ); - _gameStateController.add(_currentState); + _emitCurrentState(); } /// 预热当前游戏状态的CG @@ -5723,6 +5673,9 @@ class GameManager { return; } _disposed = true; + _cancelCharacterPresentation(); + _isProcessing = false; + _isWaitingForTimer = false; final ownsGlobalResources = _resourceSessionId == _latestResourceSessionId; LocalizationManager().removeListener(_languageListener); _currentTimer?.cancel(); // 取消活跃的计时器 @@ -5974,6 +5927,7 @@ class GameState { String? sceneTopRightStatusText, bool clearSceneTopRightStatusText = false, MusicRegion? currentMusicRegion, + bool clearCurrentMusicRegion = false, Map? sceneAnimationProperties, bool clearSceneAnimation = false, String? sceneAnimation, @@ -6049,7 +6003,9 @@ class GameState { sceneTopRightStatusText: clearSceneTopRightStatusText ? null : (sceneTopRightStatusText ?? this.sceneTopRightStatusText), - currentMusicRegion: currentMusicRegion ?? this.currentMusicRegion, + currentMusicRegion: clearCurrentMusicRegion + ? null + : (currentMusicRegion ?? this.currentMusicRegion), sceneAnimationProperties: clearSceneAnimation ? null : (sceneAnimationProperties ?? this.sceneAnimationProperties), diff --git a/Engine/lib/src/game/game_manager_choice_seek.dart b/Engine/lib/src/game/game_manager_choice_seek.dart new file mode 100644 index 00000000..3ab027a3 --- /dev/null +++ b/Engine/lib/src/game/game_manager_choice_seek.dart @@ -0,0 +1,244 @@ +part of 'game_manager.dart'; + +extension _GameManagerChoiceSeek on GameManager { + void _emitCurrentState() { + if (!_disposed && !_isSeekingNextChoice) { + _gameStateController.add(_currentState); + } + } + + /// A read-only check keeps a click with no following choice a no-op. Bool + /// assignments are simulated locally. APIs may change conditions, so their + /// subsequent branches are conservatively explored in both directions. + bool _hasReachableChoice(int startIndex) { + final variables = { + for (final node in _script.children) + if (node is JumpNode && node.isConditional) node.conditionVariable!, + }.toList()..sort(); + final initialValues = { + for (final name in variables) + name: GlobalVariableManager().getBoolVariableSync(name), + }; + final pending = [(startIndex, initialValues)]; + final visited = <(int, String)>{}; + while (pending.isNotEmpty) { + var (index, values) = pending.removeLast(); + while (index >= 0 && index < _script.children.length) { + final signature = variables.map((name) => values[name]).join(','); + if (!visited.add((index, signature))) break; + final node = _script.children[index]; + if (node is MenuNode) return true; + if (node is ReturnNode) break; + if (node is BoolNode && values.containsKey(node.variableName)) { + values[node.variableName] = node.value; + } else if (node is ApiCallNode || + node is SayNode && node.inlineApiToken != null || + node is ConditionalSayNode && node.inlineApiToken != null) { + values.updateAll((_, _) => null); + } else if (node is JumpNode) { + final target = _labelIndexMap[node.targetLabel]; + if (!node.isConditional) { + if (target == null) break; + index = target; + continue; + } + final value = values[node.conditionVariable]; + if (value == null) { + if (target != null) pending.add((target, Map.of(values))); + } else if (value == node.conditionValue) { + if (target == null) break; + index = target; + continue; + } + } + index++; + } + } + return false; + } + + Future _seekNextChoice() async { + if (_disposed || + !_isScriptInitialized() || + _isProcessing || + _isSeekingNextChoice) { + return false; + } + if (_currentState.currentNode is MenuNode) return true; + // A transition has advanced its index but has not committed the new scene + // yet. Let that atomic change finish before allowing navigation. + if (_isWaitingForTimer && + _currentTimer == null && + _currentTimerCompletion == null) { + return false; + } + if (!_hasReachableChoice(_scriptIndex)) return false; + + _isSeekingNextChoice = true; + _choiceSeekRevision++; + _choiceSeekNodesRemaining = 100000; + try { + // Complete a pending API's final state before traversing further. Any + // continuation it schedules is held by the seek's execution lock. + _completeCurrentTimerWait(); + _currentTimer?.cancel(); + _currentTimer = null; + _currentTimerCompletion = null; + _isWaitingForTimer = false; + _settlePresentationBeforeChoiceSeek(); + + if (!_disableRuntimeSideEffectsForTesting) { + await MusicManager().stopVoice(); + await MusicManager().stopAudio(AudioTrackConfig.sound, fadeOut: false); + } + + // Every dialogue still runs its ordinary character/CG/inline-API and + // history logic. Only the click between dialogues is omitted. + while (!_disposed && _scriptIndex < _script.children.length) { + final previousIndex = _scriptIndex; + if (!_currentState.animeKeep) { + _currentState = _currentState.copyWith(clearAnimeOverlay: true); + } + _isProcessing = true; + await _processScriptNodes(); + if (_currentState.currentNode is MenuNode) break; + if (_scriptIndex == previousIndex) break; + } + if (_disposed) return false; + + _isProcessing = true; + await _prepareChoiceSeekPresentation(); + if (_disposed) return false; + + final reachedChoice = _currentState.currentNode is MenuNode; + // Save the fully reconstructed scene at the menu index, never an + // intermediate scene paired with the destination's script index. + _isSeekingNextChoice = false; + if (reachedChoice) { + await _createRuntimeAutoSave(reason: '分支选择'); + if (!_disposed) { + await _checkAndCreateAutoSave(_scriptIndex, reason: '分支选择'); + } + } + return reachedChoice && !_disposed; + } finally { + _isSeekingNextChoice = false; + _isProcessing = false; + if (!_disposed) _resumeChoiceSeekLoops(); + _emitCurrentState(); + } + } + + void _settlePresentationBeforeChoiceSeek() { + _currentState = _buildDialogueHistorySnapshotState(); + _choiceSeekLoopingAnimations.clear(); + for (final entry in _activeCharacterAnimations.entries) { + final character = + _currentState.characters[entry.key] ?? + _currentState.cgCharacters[entry.key]; + final name = entry.value.animationName; + if (character != null && + name != null && + entry.value.finiteFinalProperties == null) { + _choiceSeekLoopingAnimations[entry.key] = (character.resourceId, name); + } + } + final animations = _activeCharacterAnimations.values.toList(); + _activeCharacterAnimations.clear(); + for (final animation in animations) { + animation.stopInfiniteLoop(); + animation.dispose(); + } + _sceneAnimationController?.dispose(); + _sceneAnimationController = null; + _characterPositionAnimator?.stop(); + _activeCgTransitionType = null; + + final characters = Map.of(_currentState.characters) + ..removeWhere((_, character) => character.isFadingOut); + // The currently displayed line may still have a delayed expression. + if (_dialogueHistory.isNotEmpty) { + final index = _dialogueHistory.last.scriptIndex; + final node = _script.children[index]; + if (node is SayNode && node.hasTimedExpression) { + final key = _resolveCharacterRenderKey( + node.character, + characterConfig: _characterConfigs[node.character], + ); + final character = characters[key]; + if (character != null) { + characters[key] = character.copyWith(expression: node.endExpression); + } + } + } + _currentState = _currentState.copyWith( + characters: characters, + isPaused: false, + isShaking: false, + forceNullCurrentNode: true, + ); + _settleSceneAnimationForChoiceSeek(); + } + + void _settleSceneAnimationForChoiceSeek() { + final name = _currentState.sceneAnimation; + if (name == null) return; + final properties = AnimationManager.resolveFinalProperties(name, { + 'xcenter': 0.0, + 'ycenter': 0.0, + 'scale': 1.0, + 'alpha': 1.0, + 'rotation': 0.0, + }); + if (properties != null) { + _currentState = _currentState.copyWith( + sceneAnimationProperties: properties, + ); + } + } + + void _resumeChoiceSeekLoops() { + if (_tickerProvider != null) { + for (final entry in _choiceSeekLoopingAnimations.entries) { + final character = + _currentState.characters[entry.key] ?? + _currentState.cgCharacters[entry.key]; + if (character?.resourceId == entry.value.$1) { + _playCharacterAnimation(entry.key, entry.value.$2, repeatCount: 0); + } + } + final sceneAnimation = _currentState.sceneAnimation; + if (sceneAnimation != null && _currentState.sceneAnimationRepeat == 0) { + unawaited(_startSceneAnimation(sceneAnimation, 0)); + } + } + _choiceSeekLoopingAnimations.clear(); + } + + Future _prepareChoiceSeekPresentation() async { + if (_disableRuntimeSideEffectsForTesting) return; + for (final character in [ + ..._currentState.characters.values, + ..._currentState.cgCharacters.values, + ]) { + if (_disposed) return; + await CharacterCompositeCache.instance.preload( + character.resourceId, + character.pose ?? 'pose1', + character.expression ?? 'happy', + ); + } + if (_disposed) return; + final music = _currentState.currentMusicRegion; + if (music == null) { + await MusicManager().stopBackgroundMusic(fadeOut: false); + } else { + final path = _buildMusicAssetPath(music.musicFile); + if (path.isNotEmpty && !MusicManager().isPlayingMusic(path)) { + await MusicManager().playBackgroundMusic(path, fadeTransition: false); + } + } + if (_disposed) return; + await _restoreLoopingSoundsAfterHistoryJump(_activeLoopingSounds.toList()); + } +} diff --git a/Engine/lib/src/game/script_merger.dart b/Engine/lib/src/game/script_merger.dart index 183d6274..795a12af 100644 --- a/Engine/lib/src/game/script_merger.dart +++ b/Engine/lib/src/game/script_merger.dart @@ -115,21 +115,21 @@ class ScriptMerger { } _mergedScript = script; _publishSharedScript(); - if (kEngineDebugMode) { - print( - '[SAKI_NATIVE][SKS] files=${compiled.orderedFiles.length} ' - 'nodes=${script.children.length} ' - 'native=${compiled.elapsedMicros / 1000.0}ms', + sakiDiagnosticLog( + '[SAKI_NATIVE][SKS] files=${compiled.orderedFiles.length} ' + 'nodes=${script.children.length} ' + 'native=${compiled.elapsedMicros / 1000.0}ms', + ); + final runtime = _nativeRuntimeIndex; + if (runtime != null) { + sakiDiagnosticLog( + '[SAKI_NATIVE][RUNTIME] labels=${runtime.labelIndices.length} ' + 'flow=${runtime.flowNodes.length} ' + 'compact=${runtime.compactBytes}B ' + 'native=${runtime.elapsedMicros / 1000.0}ms', ); - final runtime = _nativeRuntimeIndex; - if (runtime != null) { - print( - '[SAKI_NATIVE][RUNTIME] labels=${runtime.labelIndices.length} ' - 'flow=${runtime.flowNodes.length} ' - 'compact=${runtime.compactBytes}B ' - 'native=${runtime.elapsedMicros / 1000.0}ms', - ); - } + } + if (kEngineDebugMode) { for (final diagnostic in compiled.diagnostics) { print('[SAKI_NATIVE][SKS] $diagnostic'); } diff --git a/Engine/lib/src/native/saki_native_runtime_io.dart b/Engine/lib/src/native/saki_native_runtime_io.dart index 453fbea1..488f0b29 100644 --- a/Engine/lib/src/native/saki_native_runtime_io.dart +++ b/Engine/lib/src/native/saki_native_runtime_io.dart @@ -32,7 +32,7 @@ class SakiNativeRuntime { 'elapsedMs=${stopwatch.elapsedMicroseconds / 1000.0}; ' 'Dart fallback enabled: $error', ); - print(stackTrace); + sakiDiagnosticLog(stackTrace); return false; } } diff --git a/Engine/lib/src/rendering/composite_cg_renderer.dart b/Engine/lib/src/rendering/composite_cg_renderer.dart index cc2a6848..9891fbd7 100644 --- a/Engine/lib/src/rendering/composite_cg_renderer.dart +++ b/Engine/lib/src/rendering/composite_cg_renderer.dart @@ -20,6 +20,27 @@ ui.FilterQuality _resolveFilterQuality(bool preferSpeed) { ); } +@visibleForTesting +ui.Rect calculateDissolveImageRect( + ui.Size canvasSize, + int imageWidth, + int imageHeight, + BoxFit fit, +) { + assert(fit == BoxFit.cover || fit == BoxFit.fitHeight); + final scaleX = canvasSize.width / imageWidth; + final scaleY = canvasSize.height / imageHeight; + final scale = fit == BoxFit.fitHeight ? scaleY : math.max(scaleX, scaleY); + final targetWidth = imageWidth * scale; + final targetHeight = imageHeight * scale; + return ui.Rect.fromLTWH( + (canvasSize.width - targetWidth) / 2, + (canvasSize.height - targetHeight) / 2, + targetWidth, + targetHeight, + ); +} + /// 基于预合成图像的CG角色渲染器 /// /// 替代原有的多层实时渲染方式,直接使用预合成的单张图像 @@ -1291,6 +1312,7 @@ class DirectCgDisplay extends StatefulWidget { final bool isFadingOut; final bool enableFadeIn; final bool skipAnimation; + final BoxFit fit; const DirectCgDisplay({ super.key, @@ -1299,6 +1321,7 @@ class DirectCgDisplay extends StatefulWidget { this.isFadingOut = false, this.enableFadeIn = false, this.skipAnimation = false, + this.fit = BoxFit.cover, }); @override @@ -1428,6 +1451,7 @@ class _DirectCgDisplayState extends State fromImage: fromImage, toImage: image, opacity: overallAlpha, + fit: widget.fit, ), ); }, @@ -1444,6 +1468,7 @@ class _DirectCgDisplayState extends State isFadingOut: widget.isFadingOut, enableFadeIn: widget.enableFadeIn && !_hasShownOnce, preferSpeed: widget.skipAnimation, + fit: widget.fit, ), ); }, @@ -1461,6 +1486,7 @@ class DirectCgPainter extends CustomPainter { final bool isFadingOut; final bool enableFadeIn; final bool preferSpeed; + final BoxFit fit; DirectCgPainter({ required this.currentImage, @@ -1469,6 +1495,7 @@ class DirectCgPainter extends CustomPainter { required this.isFadingOut, required this.enableFadeIn, this.preferSpeed = false, + this.fit = BoxFit.cover, }); @override @@ -1500,21 +1527,11 @@ class DirectCgPainter extends CustomPainter { ) { if (opacity <= 0) return; - final imageSize = Size(image.width.toDouble(), image.height.toDouble()); - final scaleX = size.width / imageSize.width; - final scaleY = size.height / imageSize.height; - final scale = math.max(scaleX, scaleY); - - final targetWidth = imageSize.width * scale; - final targetHeight = imageSize.height * scale; - final offsetX = (size.width - targetWidth) / 2; - final offsetY = (size.height - targetHeight) / 2; - - final targetRect = ui.Rect.fromLTWH( - offsetX, - offsetY, - targetWidth, - targetHeight, + final targetRect = calculateDissolveImageRect( + size, + image.width, + image.height, + fit, ); final paint = ui.Paint() @@ -1537,7 +1554,8 @@ class DirectCgPainter extends CustomPainter { progress != oldDelegate.progress || isFadingOut != oldDelegate.isFadingOut || enableFadeIn != oldDelegate.enableFadeIn || - preferSpeed != oldDelegate.preferSpeed; + preferSpeed != oldDelegate.preferSpeed || + fit != oldDelegate.fit; } } @@ -1967,6 +1985,7 @@ class _DissolveShaderPainter extends CustomPainter { final ui.Image fromImage; final ui.Image toImage; final double opacity; + final BoxFit fit; _DissolveShaderPainter({ required this.program, @@ -1974,26 +1993,46 @@ class _DissolveShaderPainter extends CustomPainter { required this.fromImage, required this.toImage, required this.opacity, + this.fit = BoxFit.cover, }); @override void paint(ui.Canvas canvas, ui.Size size) { if (size.isEmpty) return; - final targetRect = _calculateCoverRect(size, toImage.width, toImage.height); + // Each image gets its own aspect-preserving rectangle. The shader still + // performs the exact same per-pixel mix used by ordinary expression diffs, + // but resource sets with different canvas ratios no longer stretch the + // outgoing image into the incoming image's bounds. + final fromRect = calculateDissolveImageRect( + size, + fromImage.width, + fromImage.height, + fit, + ); + final toRect = calculateDissolveImageRect( + size, + toImage.width, + toImage.height, + fit, + ); final shader = program.fragmentShader(); shader ..setFloat(0, progress.clamp(0.0, 1.0)) - ..setFloat(1, targetRect.width) - ..setFloat(2, targetRect.height) + ..setFloat(1, fromRect.width) + ..setFloat(2, fromRect.height) ..setFloat(3, fromImage.width.toDouble()) ..setFloat(4, fromImage.height.toDouble()) - ..setFloat(5, toImage.width.toDouble()) - ..setFloat(6, toImage.height.toDouble()) - ..setFloat(7, targetRect.left) - ..setFloat(8, targetRect.top) - ..setFloat(9, opacity.clamp(0.0, 1.0)); + ..setFloat(5, toRect.width) + ..setFloat(6, toRect.height) + ..setFloat(7, toImage.width.toDouble()) + ..setFloat(8, toImage.height.toDouble()) + ..setFloat(9, fromRect.left) + ..setFloat(10, fromRect.top) + ..setFloat(11, toRect.left) + ..setFloat(12, toRect.top) + ..setFloat(13, opacity.clamp(0.0, 1.0)); // 让 shader 采样跟随引擎的最近邻配置(像素风立绘)。 // dissolve.frag 使用单点 texture() 采样,插值方式由 sampler 决定。 @@ -2006,7 +2045,10 @@ class _DissolveShaderPainter extends CustomPainter { final paint = ui.Paint()..shader = shader; - canvas.drawRect(targetRect, paint); + // Paint the union rather than clipping to the incoming image's widget + // bounds. The shader makes pixels outside each image rect transparent, so + // a wider outgoing sprite remains fully visible during the dissolve. + canvas.drawRect(fromRect.expandToInclude(toRect), paint); } @override @@ -2015,7 +2057,8 @@ class _DissolveShaderPainter extends CustomPainter { fromImage != oldDelegate.fromImage || toImage != oldDelegate.toImage || program != oldDelegate.program || - opacity != oldDelegate.opacity; + opacity != oldDelegate.opacity || + fit != oldDelegate.fit; } } diff --git a/Engine/lib/src/screens/game_play_screen.dart b/Engine/lib/src/screens/game_play_screen.dart index 600335f1..61c5c7f6 100644 --- a/Engine/lib/src/screens/game_play_screen.dart +++ b/Engine/lib/src/screens/game_play_screen.dart @@ -82,6 +82,19 @@ typedef InitialLoadingOverlayBuilder = typedef SaveLoadTransitionOverlayBuilder = Widget Function(BuildContext context, VoidCallback onCompleted); +/// Stable identity for a character's stage slot. +/// +/// A slot can switch between multiple resource IDs (for example `aru`, `aru2`, +/// and `aru3`). Keeping the widget keyed to the slot lets the existing +/// [DirectCgDisplay] retain its previous image and dissolve to the new one. +@visibleForTesting +Key characterCompositeRenderKey(String characterKey) => + ValueKey('composite-$characterKey'); + +@visibleForTesting +Key characterPositionedRenderKey(String characterKey) => + ValueKey('positioned-$characterKey'); + enum _CommandDebugMenuMode { expression, character, background, canvas, music } class GamePlayScreen extends StatefulWidget { @@ -1551,7 +1564,7 @@ class _GamePlayScreenState extends State milliseconds: ((gameState.shakeDuration ?? 1.0) * 1000).round(), ), - child: _wrapWithParallax(widget, 0.55), + child: _wrapWithParallax(widget, 0.55, reserveBleed: true), ), ), ], @@ -1886,7 +1899,7 @@ class _GamePlayScreenState extends State } final characterWidget = _CompositeCharacterWidget( - key: ValueKey('composite-${characterState.resourceId}'), + key: characterCompositeRenderKey(characterId), characterKey: characterId, resourceId: characterState.resourceId, pose: characterState.pose ?? 'pose1', @@ -1939,7 +1952,7 @@ class _GamePlayScreenState extends State finalWidget = _wrapWithParallax(finalWidget, 0.65); return Positioned( - key: ValueKey('positioned-${characterState.resourceId}'), + key: characterPositionedRenderKey(characterId), left: finalXCenter * MediaQuery.of(context).size.width, top: finalYCenter * MediaQuery.of(context).size.height, child: FractionalTranslation( @@ -1966,7 +1979,12 @@ class _GamePlayScreenState extends State } } - Widget _wrapWithParallax(Widget widget, double depth, {bool invert = true}) { + Widget _wrapWithParallax( + Widget widget, + double depth, { + bool invert = true, + bool reserveBleed = false, + }) { if (depth == 0) { return widget; } @@ -1983,9 +2001,10 @@ class _GamePlayScreenState extends State width: widget.width, height: widget.height, child: _wrapWithParallax( - widget.child ?? const SizedBox.shrink(), + widget.child, depth, invert: invert, + reserveBleed: reserveBleed, ), ); } @@ -1999,13 +2018,19 @@ class _GamePlayScreenState extends State width: widget.width, height: widget.height, child: _wrapWithParallax( - widget.child ?? const SizedBox.shrink(), + widget.child, depth, invert: invert, + reserveBleed: reserveBleed, ), ); } - return ParallaxAware(depth: depth, invert: invert, child: widget); + return ParallaxAware( + depth: depth, + invert: invert, + reserveBleed: reserveBleed, + child: widget, + ); } } @@ -2115,6 +2140,7 @@ class _CompositeCharacterWidgetState extends State<_CompositeCharacterWidget> { isFadingOut: widget.isFadingOut, enableFadeIn: !widget.isFadingOut, skipAnimation: widget.skipAnimation, + fit: BoxFit.fitHeight, ), ); } diff --git a/Engine/lib/src/sks_parser/sks_parser.dart b/Engine/lib/src/sks_parser/sks_parser.dart index 41a3253c..4a45767c 100644 --- a/Engine/lib/src/sks_parser/sks_parser.dart +++ b/Engine/lib/src/sks_parser/sks_parser.dart @@ -21,22 +21,6 @@ class SksParser { ).hasMatch(hex); } - /// 为角色对话添加引号(旁白除外) - String _formatDialogueWithQuotes(String dialogue, String? character) { - // 如果没有角色(旁白),直接返回原文本 - if (character == null || character.isEmpty) { - return dialogue; - } - - // 如果已经有引号,直接返回 - if (dialogue.startsWith('「') && dialogue.endsWith('」')) { - return dialogue; - } - - // 为角色对话添加引号 - return '「$dialogue」'; - } - String? _activeSourceFile; int _activeSourceLine = -1; @@ -1330,7 +1314,7 @@ class SksParser { return SayNode( character: character, - dialogue: _formatDialogueWithQuotes(dialogue, character), + dialogue: dialogue, pose: pose, inlineApiToken: inlineApiToken, dialogueTag: tailMeta.dialogueTag, @@ -1453,7 +1437,7 @@ class SksParser { } return ConditionalSayNode( - dialogue: _formatDialogueWithQuotes(dialogue, character), + dialogue: dialogue, character: character, inlineApiToken: inlineApiToken, dialogueTag: tailMeta.dialogueTag, @@ -1491,7 +1475,7 @@ class SksParser { if (simpleMatch != null) { final tailMeta = _parseDialogueTail(simpleMatch.group(2)); return SayNode( - dialogue: _formatDialogueWithQuotes(simpleMatch.group(1)!, null), + dialogue: simpleMatch.group(1)!, dialogueTag: tailMeta.dialogueTag, tailCharacter: tailMeta.tailCharacter, tailPose: tailMeta.tailPose, @@ -1512,7 +1496,7 @@ class SksParser { if (beforeQuote.isEmpty) { // Narration: "dialogue" return SayNode( - dialogue: _formatDialogueWithQuotes(dialogue, null), + dialogue: dialogue, dialogueTag: tailMeta.dialogueTag, tailCharacter: tailMeta.tailCharacter, tailPose: tailMeta.tailPose, @@ -1610,7 +1594,7 @@ class SksParser { return SayNode( character: character, - dialogue: _formatDialogueWithQuotes(dialogue, character), + dialogue: dialogue, inlineApiToken: inlineApiToken, dialogueTag: tailMeta.dialogueTag, tailCharacter: tailMeta.tailCharacter, @@ -1630,7 +1614,7 @@ class SksParser { return SayNode( character: character, - dialogue: _formatDialogueWithQuotes(dialogue, character), + dialogue: dialogue, inlineApiToken: inlineApiToken, dialogueTag: tailMeta.dialogueTag, tailCharacter: tailMeta.tailCharacter, diff --git a/Engine/lib/src/utils/animation_manager.dart b/Engine/lib/src/utils/animation_manager.dart index 6ff1adfa..a8ec4dde 100644 --- a/Engine/lib/src/utils/animation_manager.dart +++ b/Engine/lib/src/utils/animation_manager.dart @@ -212,6 +212,7 @@ class CharacterAnimationController { Map _baseProperties = {}; Map _currentProperties = {}; Map _originalBaseProperties = {}; // 保存真正的初始基础位置,永不改变 + double _layoutXOffset = 0; bool _shouldStop = false; // 用于控制无限循环的停止 bool _isInfiniteLoop = false; @@ -251,11 +252,12 @@ class CharacterAnimationController { _currentProperties = Map.from(_baseProperties); _originalBaseProperties = Map.from(baseProperties); // 保存真正的初始位置(不包含预设属性) + _layoutXOffset = 0; _shouldStop = false; // 重置停止标志 _isInfiniteLoop = repeatCount == 0; // Ren'Py ATL 会先应用 transform 的初始属性,再开始关键帧。 // 立即发布预设状态,避免上一段动画的最终状态残留一帧。 - onAnimationUpdate?.call(Map.from(_currentProperties)); + onAnimationUpdate?.call(currentProperties); return _playConfiguredAnimation(animDef.keyframes, vsync, repeatCount); } @@ -369,7 +371,7 @@ class CharacterAnimationController { startValue + (endValue - startValue) * progress; } - onAnimationUpdate?.call(Map.from(_currentProperties)); + onAnimationUpdate?.call(currentProperties); }); final completer = Completer(); @@ -464,7 +466,7 @@ class CharacterAnimationController { } // 调用实时更新回调 - onAnimationUpdate?.call(Map.from(_currentProperties)); + onAnimationUpdate?.call(currentProperties); }); final completer = Completer(); @@ -478,7 +480,20 @@ class CharacterAnimationController { await completer.future; } - Map get currentProperties => Map.from(_currentProperties); + /// Move the active animation with its layout slot without restarting its + /// keyframes. Subsequent ticks and history snapshots share the new origin. + void rebaseXCenter(double xcenter) { + _layoutXOffset = xcenter - (_currentProperties['xcenter'] ?? 0.0); + } + + Map _withLayoutOffset(Map properties) => { + ...properties, + if (properties.containsKey('xcenter')) + 'xcenter': properties['xcenter']! + _layoutXOffset, + }; + + Map get currentProperties => + _withLayoutOffset(_currentProperties); /// 返回有限动画应写入历史快照的确定末帧。 /// @@ -489,10 +504,11 @@ class CharacterAnimationController { if (_isInfiniteLoop || name == null || _originalBaseProperties.isEmpty) { return null; } - return AnimationManager.resolveFinalProperties( + final properties = AnimationManager.resolveFinalProperties( name, _originalBaseProperties, ); + return properties == null ? null : _withLayoutOffset(properties); } /// 停止无限循环动画 diff --git a/Engine/lib/src/utils/auto_play_manager.dart b/Engine/lib/src/utils/auto_play_manager.dart index 2291313e..14d53272 100644 --- a/Engine/lib/src/utils/auto_play_manager.dart +++ b/Engine/lib/src/utils/auto_play_manager.dart @@ -12,10 +12,10 @@ class AutoPlayManager { bool _isAutoPlaying = false; Timer? _readingTimer; bool _isWaitingForTypewriter = false; - + // 阅读等待时间(打字机完成后的停留时间) static const Duration _readingDelay = Duration(milliseconds: 1500); - + AutoPlayManager({ required this.dialogueProgressionManager, this.onAutoPlayStateChanged, @@ -28,22 +28,18 @@ class AutoPlayManager { /// 开始自动播放 void startAutoPlay() { if (_isAutoPlaying) return; - + // 检查是否可以自动播放 if (canAutoPlay != null && !canAutoPlay!()) { - if (kEngineDebugMode) { - print('AutoPlayManager: 当前状态不允许自动播放'); - } + sakiDiagnosticLog('AutoPlayManager: 当前状态不允许自动播放'); return; } - + _isAutoPlaying = true; onAutoPlayStateChanged?.call(); - - if (kEngineDebugMode) { - print('AutoPlayManager: 开始自动播放'); - } - + + sakiDiagnosticLog('AutoPlayManager: 开始自动播放'); + // 监听当前打字机状态 _checkTypewriterAndScheduleNext(); } @@ -51,15 +47,13 @@ class AutoPlayManager { /// 停止自动播放 void stopAutoPlay() { if (!_isAutoPlaying) return; - + _isAutoPlaying = false; _isWaitingForTypewriter = false; _cancelReadingTimer(); onAutoPlayStateChanged?.call(); - - if (kEngineDebugMode) { - print('AutoPlayManager: 停止自动播放'); - } + + sakiDiagnosticLog('AutoPlayManager: 停止自动播放'); } /// 切换自动播放状态 @@ -74,18 +68,18 @@ class AutoPlayManager { /// 检查打字机状态并安排下次推进 void _checkTypewriterAndScheduleNext() { if (!_isAutoPlaying) return; - + // 检查打字机是否在播放 if (dialogueProgressionManager.isTypewriterActive) { // 打字机正在播放,等待完成 _isWaitingForTypewriter = true; - if (kEngineDebugMode) { - print('AutoPlayManager: 等待打字机完成...'); - } - + sakiDiagnosticLog('AutoPlayManager: 等待打字机完成...'); + // 监听打字机完成事件 if (dialogueProgressionManager.currentTypewriter != null) { - dialogueProgressionManager.currentTypewriter!.addListener(_onTypewriterStateChanged); + dialogueProgressionManager.currentTypewriter!.addListener( + _onTypewriterStateChanged, + ); } } else { // 打字机已完成或不存在,直接开始阅读等待 @@ -96,18 +90,18 @@ class AutoPlayManager { /// 打字机状态变化回调 void _onTypewriterStateChanged() { if (!_isAutoPlaying || !_isWaitingForTypewriter) return; - + if (!dialogueProgressionManager.isTypewriterActive) { // 打字机完成,移除监听器并开始阅读等待 if (dialogueProgressionManager.currentTypewriter != null) { - dialogueProgressionManager.currentTypewriter!.removeListener(_onTypewriterStateChanged); + dialogueProgressionManager.currentTypewriter!.removeListener( + _onTypewriterStateChanged, + ); } _isWaitingForTypewriter = false; - - if (kEngineDebugMode) { - print('AutoPlayManager: 打字机完成,开始阅读等待'); - } - + + sakiDiagnosticLog('AutoPlayManager: 打字机完成,开始阅读等待'); + _startReadingDelay(); } } @@ -115,9 +109,9 @@ class AutoPlayManager { /// 开始阅读等待计时器 void _startReadingDelay() { if (!_isAutoPlaying) return; - + _cancelReadingTimer(); - + _readingTimer = Timer(_readingDelay, () { _onReadingDelayComplete(); }); @@ -126,12 +120,10 @@ class AutoPlayManager { /// 阅读等待完成,自动推进对话 void _onReadingDelayComplete() { if (!_isAutoPlaying) return; - + // 再次检查是否可以自动播放 if (canAutoPlay != null && !canAutoPlay!()) { - if (kEngineDebugMode) { - print('AutoPlayManager: 检测到不允许自动播放的状态,停止自动播放'); - } + sakiDiagnosticLog('AutoPlayManager: 检测到不允许自动播放的状态,停止自动播放'); stopAutoPlay(); return; } @@ -139,7 +131,7 @@ class AutoPlayManager { // 推进对话 try { dialogueProgressionManager.progressDialogue(isAutomated: true); - + // 推进后,等待一帧再检查下一个状态 Future.delayed(Duration(milliseconds: 50), () { if (_isAutoPlaying) { @@ -163,9 +155,7 @@ class AutoPlayManager { /// 手动推进对话时的处理 - 停止自动播放 void onManualProgress() { if (_isAutoPlaying) { - if (kEngineDebugMode) { - print('AutoPlayManager: 检测到手动推进,停止自动播放'); - } + sakiDiagnosticLog('AutoPlayManager: 检测到手动推进,停止自动播放'); stopAutoPlay(); } } @@ -173,9 +163,7 @@ class AutoPlayManager { /// 当遇到选择菜单或其他阻塞情况时强制停止自动播放 void forceStopOnBlocking() { if (_isAutoPlaying) { - if (kEngineDebugMode) { - print('AutoPlayManager: 遇到阻塞情况,强制停止自动播放'); - } + sakiDiagnosticLog('AutoPlayManager: 遇到阻塞情况,强制停止自动播放'); stopAutoPlay(); } } @@ -187,11 +175,9 @@ class AutoPlayManager { final typewriter = dialogueProgressionManager.currentTypewriter; typewriter?.removeListener(_onTypewriterStateChanged); } - + _cancelReadingTimer(); - - if (kEngineDebugMode) { - print('AutoPlayManager: 已释放资源'); - } + + sakiDiagnosticLog('AutoPlayManager: 已释放资源'); } } diff --git a/Engine/lib/src/utils/character_layer_parser.dart b/Engine/lib/src/utils/character_layer_parser.dart index ec27ee62..60839406 100644 --- a/Engine/lib/src/utils/character_layer_parser.dart +++ b/Engine/lib/src/utils/character_layer_parser.dart @@ -38,12 +38,14 @@ class CharacterLayerParser { if (itemExists) { // 这是一个物件,使用简化的图层结构 - print('[CharacterLayerParser] 检测到物件: $resourceId,使用items文件夹'); - layers.add(CharacterLayerInfo( - assetName: itemAssetName, - layerLevel: 0, - layerType: 'item', - )); + sakiDiagnosticLog('[CharacterLayerParser] 检测到物件: $resourceId,使用items文件夹'); + layers.add( + CharacterLayerInfo( + assetName: itemAssetName, + layerLevel: 0, + layerType: 'item', + ), + ); // 缓存结果 _layerCache[cacheKey] = layers; @@ -58,24 +60,30 @@ class CharacterLayerParser { if (!poseExists) { // 寻找可用的pose图层(level 0相当于pose层) - final availablePoses = - await AssetManager.getAvailableCharacterLayers(resourceId); - final poseLayersOnly = - availablePoses.where((layer) => !layer.contains('-')).toList(); + final availablePoses = await AssetManager.getAvailableCharacterLayers( + resourceId, + ); + final poseLayersOnly = availablePoses + .where((layer) => !layer.contains('-')) + .toList(); if (poseLayersOnly.isNotEmpty) { actualPose = poseLayersOnly.first; } } - layers.add(CharacterLayerInfo( - assetName: 'characters/$resourceId-$actualPose', - layerLevel: 0, - layerType: 'pose', - )); + layers.add( + CharacterLayerInfo( + assetName: 'characters/$resourceId-$actualPose', + layerLevel: 0, + layerType: 'pose', + ), + ); // 2. 解析expression,支持多级图层并处理默认值 - final expressionLayers = - await _parseExpressionLayers(resourceId, expression); + final expressionLayers = await _parseExpressionLayers( + resourceId, + expression, + ); layers.addAll(expressionLayers); // 3. 可选的姿势前景层。用于手臂、道具等必须覆盖在表情之上的部件, @@ -88,11 +96,13 @@ class CharacterLayerParser { final foregroundExists = await AssetManager().findAsset(foregroundAssetName) != null; if (foregroundExists) { - layers.add(CharacterLayerInfo( - assetName: foregroundAssetName, - layerLevel: 98, - layerType: 'pose_foreground', - )); + layers.add( + CharacterLayerInfo( + assetName: foregroundAssetName, + layerLevel: 98, + layerType: 'pose_foreground', + ), + ); } // 4. 检查是否需要添加帽子图层(仅针对特定角色和姿势) @@ -191,8 +201,10 @@ class CharacterLayerParser { if (!exists) { // 查找该级别下字母顺序第一个可用的图层 - final defaultLayer = - await AssetManager.getDefaultLayerForLevel(resourceId, layerLevel); + final defaultLayer = await AssetManager.getDefaultLayerForLevel( + resourceId, + layerLevel, + ); if (defaultLayer != null) { finalExpression = defaultLayer; } @@ -229,7 +241,9 @@ class CharacterLayerParser { /// 解析帽子图层 static Future _parseHatLayer( - String resourceId, String pose) async { + String resourceId, + String pose, + ) async { // 帽子图层的资源命名:characters/xiayo1-hat // 帽子图层应该在所有差分图层之上,使用层级99 final hatAssetName = 'characters/$resourceId-hat'; diff --git a/Engine/lib/src/utils/character_position_animator.dart b/Engine/lib/src/utils/character_position_animator.dart index c10db075..9fc1300f 100644 --- a/Engine/lib/src/utils/character_position_animator.dart +++ b/Engine/lib/src/utils/character_position_animator.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart' show TickerCanceled; import 'package:sakiengine/src/utils/foundation_compat.dart'; /// 角色位置变化的描述 @@ -7,7 +8,7 @@ class CharacterPositionChange { final String characterId; final double fromX; final double toX; - + CharacterPositionChange({ required this.characterId, required this.fromX, @@ -20,13 +21,13 @@ class CharacterAttributeChange { final String characterId; final Map fromAttributes; final Map toAttributes; - + CharacterAttributeChange({ required this.characterId, required this.fromAttributes, required this.toAttributes, }); - + /// 检查是否有属性变化 bool get hasChanges { for (final key in toAttributes.keys) { @@ -46,6 +47,7 @@ class CharacterAttributeChange { class CharacterPositionAnimator { AnimationController? _controller; Animation? _animation; + CurvedAnimation? _curvedAnimation; List _positionChanges = []; List _attributeChanges = []; Map _currentPositions = {}; @@ -53,14 +55,14 @@ class CharacterPositionAnimator { void Function(Map)? _onUpdate; void Function(Map>)? _onAttributeUpdate; VoidCallback? _onComplete; - + /// 开始角色位置变化的补间动画 /// [positionChanges] 所有需要动画的角色位置变化 /// [vsync] TickerProvider用于创建AnimationController /// [duration] 动画持续时间,默认0.5秒 /// [curve] 动画曲线,默认easeInOut /// [onUpdate] 动画更新回调,参数是角色ID到当前位置的映射 - /// [onComplete] 动画完成回调 + /// [onComplete] 动画正常完成回调;被停止或替换时仅结束等待,不调用此回调 Future animatePositionChanges({ required List positionChanges, required TickerProvider vsync, @@ -69,33 +71,34 @@ class CharacterPositionAnimator { void Function(Map)? onUpdate, VoidCallback? onComplete, }) async { + stop(); if (positionChanges.isEmpty) { onComplete?.call(); return; } - + _positionChanges = positionChanges; _attributeChanges = []; _onUpdate = onUpdate; _onAttributeUpdate = null; _onComplete = onComplete; - + // 初始化当前位置 _currentPositions.clear(); for (final change in positionChanges) { _currentPositions[change.characterId] = change.fromX; } - + await _startAnimation(vsync, duration, curve); } - + /// 开始角色属性变化的补间动画 /// [attributeChanges] 所有需要动画的角色属性变化 /// [vsync] TickerProvider用于创建AnimationController /// [duration] 动画持续时间,默认0.3秒(比位置动画稍快) /// [curve] 动画曲线,默认easeInOut /// [onUpdate] 动画更新回调,参数是角色ID到属性映射的映射 - /// [onComplete] 动画完成回调 + /// [onComplete] 动画正常完成回调;被停止或替换时仅结束等待,不调用此回调 Future animateAttributeChanges({ required List attributeChanges, required TickerProvider vsync, @@ -104,65 +107,75 @@ class CharacterPositionAnimator { void Function(Map>)? onUpdate, VoidCallback? onComplete, }) async { + stop(); // 过滤掉没有变化的属性 - final filteredChanges = attributeChanges.where((change) => change.hasChanges).toList(); - + final filteredChanges = attributeChanges + .where((change) => change.hasChanges) + .toList(); + if (filteredChanges.isEmpty) { onUpdate?.call({}); onComplete?.call(); return; } - + _positionChanges = []; _attributeChanges = filteredChanges; _onUpdate = null; _onAttributeUpdate = onUpdate; _onComplete = onComplete; - + // 初始化当前属性 _currentAttributes.clear(); for (final change in filteredChanges) { _currentAttributes[change.characterId] = Map.from(change.fromAttributes); } - + await _startAnimation(vsync, duration, curve); } - + /// 开始动画 - Future _startAnimation(TickerProvider vsync, Duration duration, Curve curve) async { + Future _startAnimation( + TickerProvider vsync, + Duration duration, + Curve curve, + ) async { // 创建动画控制器 - _controller?.dispose(); - _controller = AnimationController( - duration: duration, - vsync: vsync, - ); - + final controller = AnimationController(duration: duration, vsync: vsync); + _controller = controller; + // 创建动画 - final curvedAnimation = CurvedAnimation( - parent: _controller!, - curve: curve, - ); - + final curvedAnimation = CurvedAnimation(parent: controller, curve: curve); + _curvedAnimation = curvedAnimation; + _animation = Tween(begin: 0.0, end: 1.0).animate(curvedAnimation); - + _animation!.addListener(_updateAnimation); - - // 启动动画 - await _controller!.forward(); - - // 清理 - _animation!.removeListener(_updateAnimation); - _controller?.dispose(); - _controller = null; - _animation = null; - - _onComplete?.call(); + + final onComplete = _onComplete; + var completed = false; + try { + // 普通TickerFuture被取消后不会结束;orCancel让剧情可以正常解除等待。 + await controller.forward().orCancel; + completed = identical(_controller, controller); + } on TickerCanceled { + // 停止、替换和释放都是正常取消,不能执行旧动画的完成回调。 + } finally { + // 旧动画的异步收尾不能释放后来启动的新动画。 + if (identical(_controller, controller)) { + stop(); + } + } + + if (completed) { + onComplete?.call(); + } } - + /// 更新动画 void _updateAnimation() { final progress = _animation!.value; - + // 更新位置变化 if (_positionChanges.isNotEmpty) { for (final change in _positionChanges) { @@ -171,12 +184,11 @@ class CharacterPositionAnimator { } _onUpdate?.call(Map.from(_currentPositions)); } - // 更新属性变化 - if (_attributeChanges.isNotEmpty) { + else if (_attributeChanges.isNotEmpty) { for (final change in _attributeChanges) { final currentAttrs = _currentAttributes[change.characterId]!; - + // 对每个属性进行插值 for (final key in change.toAttributes.keys) { final fromValue = change.fromAttributes[key] ?? 0.0; @@ -188,28 +200,30 @@ class CharacterPositionAnimator { _onAttributeUpdate?.call(Map.from(_currentAttributes)); } } - + /// 更新角色位置(已废弃,保留以兼容旧代码) void _updatePositions() { _updateAnimation(); } - + /// 立即停止动画 void stop() { - if (_controller != null && _controller!.isAnimating) { - _animation?.removeListener(_updateAnimation); - _controller?.stop(); - _controller?.dispose(); - _controller = null; - _animation = null; - } + final controller = _controller; + if (controller == null) return; + + _controller = null; + _animation?.removeListener(_updateAnimation); + _animation = null; + _curvedAnimation?.dispose(); + _curvedAnimation = null; + controller.dispose(); } - + /// 释放资源 void dispose() { stop(); } - + /// 获取当前是否正在动画中 bool get isAnimating => _controller?.isAnimating ?? false; -} \ No newline at end of file +} diff --git a/Engine/lib/src/utils/gpu_image_compositor.dart b/Engine/lib/src/utils/gpu_image_compositor.dart index bb2e1a85..879e09c6 100644 --- a/Engine/lib/src/utils/gpu_image_compositor.dart +++ b/Engine/lib/src/utils/gpu_image_compositor.dart @@ -43,11 +43,13 @@ class GpuCompositeResult { /// 释放所有图层资源(减少引用计数,必要时真正释放纹理) void dispose() { if (_disposed) return; + _disposed = true; for (final handle in _handles) { _releaseLayer(handle.cacheKey); } - _handles.clear(); - _disposed = true; + // `_handles` is intentionally created with `growable: false`. Clearing it + // throws during LRU eviction; retaining this short-lived list is harmless + // once the result itself is removed from every cache. } } diff --git a/Engine/lib/src/utils/history_snapshot_codec_io.dart b/Engine/lib/src/utils/history_snapshot_codec_io.dart index 8360ca08..99eedb33 100644 --- a/Engine/lib/src/utils/history_snapshot_codec_io.dart +++ b/Engine/lib/src/utils/history_snapshot_codec_io.dart @@ -14,9 +14,9 @@ class HistorySnapshotCodec { if (compressed != null && compressed.length + 1 < bytes.length) { final result = Uint8List(compressed.length + 1)..[0] = _lz4; result.setRange(1, result.length, compressed); - if (kEngineDebugMode && !_loggedNativeCodec) { + if (!_loggedNativeCodec) { _loggedNativeCodec = true; - print( + sakiDiagnosticLog( '[SAKI_NATIVE][HISTORY] LZ4 active ' 'raw=${bytes.length}B packed=${result.length}B', ); diff --git a/Engine/lib/src/utils/key_sequence_detector.dart b/Engine/lib/src/utils/key_sequence_detector.dart index 7793a9d0..d67d7ca2 100644 --- a/Engine/lib/src/utils/key_sequence_detector.dart +++ b/Engine/lib/src/utils/key_sequence_detector.dart @@ -36,12 +36,8 @@ class KeySequenceDetector { _isListening = true; HardwareKeyboard.instance.addHandler(_handleKeyEvent); - if (kEngineDebugMode) { - final sequenceNames = _targetSequence - .map((key) => key.debugName) - .join('-'); - print('按键序列检测器: 开始监听序列 $sequenceNames'); - } + final sequenceNames = _targetSequence.map((key) => key.debugName).join('-'); + sakiDiagnosticLog('按键序列检测器: 开始监听序列 $sequenceNames'); } /// 停止监听键盘事件 @@ -53,9 +49,7 @@ class KeySequenceDetector { HardwareKeyboard.instance.removeHandler(_handleKeyEvent); _currentSequence.clear(); - if (kEngineDebugMode) { - print('按键序列检测器: 停止监听'); - } + sakiDiagnosticLog('按键序列检测器: 停止监听'); } bool _handleKeyEvent(KeyEvent event) { @@ -89,17 +83,14 @@ class KeySequenceDetector { _currentSequence.add(key); _resetTimeout(); - if (kEngineDebugMode) { - print( - '按键序列检测器: 按键 ${key.debugName} 匹配,当前序列长度: ${_currentSequence.length}/${_targetSequence.length}', - ); - } + sakiDiagnosticLog( + '按键序列检测器: 按键 ${key.debugName} 匹配,当前序列长度: ' + '${_currentSequence.length}/${_targetSequence.length}', + ); // 检查序列是否完成 if (_currentSequence.length == _targetSequence.length) { - if (kEngineDebugMode) { - print('按键序列检测器: 序列完成!'); - } + sakiDiagnosticLog('按键序列检测器: 序列完成!'); _onSequenceComplete(); _resetSequence(); return false; @@ -110,9 +101,7 @@ class KeySequenceDetector { } else { // 按键不匹配,重置序列 if (_currentSequence.isNotEmpty) { - if (kEngineDebugMode) { - print('按键序列检测器: 按键 ${key.debugName} 不匹配,重置序列'); - } + sakiDiagnosticLog('按键序列检测器: 按键 ${key.debugName} 不匹配,重置序列'); _resetSequence(); } } @@ -124,9 +113,7 @@ class KeySequenceDetector { void _resetTimeout() { _cancelTimeoutTimer(); _timeoutTimer = Timer(_sequenceTimeout, () { - if (kEngineDebugMode) { - print('按键序列检测器: 序列超时,重置'); - } + sakiDiagnosticLog('按键序列检测器: 序列超时,重置'); _resetSequence(); }); } diff --git a/Engine/lib/src/utils/music_manager.dart b/Engine/lib/src/utils/music_manager.dart index 59aa1cfd..2c31ed89 100644 --- a/Engine/lib/src/utils/music_manager.dart +++ b/Engine/lib/src/utils/music_manager.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'package:just_audio/just_audio.dart'; import 'package:path/path.dart' as p; import 'package:sakiengine/src/utils/bundle_asset_path_probe.dart'; import 'package:sakiengine/src/config/game_path_resolver.dart'; @@ -7,6 +6,7 @@ import 'package:sakiengine/src/config/saki_pack_store.dart'; import 'package:sakiengine/src/utils/foundation_compat.dart'; import 'package:sakiengine/src/game/unified_game_data_manager.dart'; import 'package:sakiengine/src/config/project_info_manager.dart'; +import 'package:sakiengine/src/utils/saki_audio_player.dart'; /// 音频轨道类型枚举 enum AudioTrackType { @@ -888,7 +888,22 @@ class MusicManager extends ChangeNotifier { if (sessionGeneration != _audioSessionGeneration) { return; } - await player.play(); + // `AudioPlayer.play()` completes when playback finishes or is interrupted + // on the native just_audio backends. Awaiting it would therefore block the + // SKS executor for the full duration of a one-shot sound, and forever for a + // looping ambience cue. Only source/setup work belongs to this Future; keep + // actual playback detached, matching the music and voice channels. + final playFuture = player.play(); + unawaited( + playFuture.catchError((Object error, StackTrace stackTrace) { + if (kEngineDebugMode) { + print( + '[MusicManager] sound.play async failed: $assetPath, error=$error', + ); + print(stackTrace); + } + }), + ); if (fadeTransition) { // 音效淡入(通常时间较短) @@ -985,7 +1000,7 @@ class MusicManager extends ChangeNotifier { } return; } - if (trimmed.startsWith('/')) { + if (p.isAbsolute(trimmed)) { if (traceMusic) { _musicSourceLog('try setFilePath(absolute): "$trimmed"'); } @@ -1403,10 +1418,10 @@ class MusicManager extends ChangeNotifier { ); } - /// 返回主菜单时释放本局剧情使用过的 mpv 音效核心和解码源。 + /// 返回主菜单时释放本局剧情使用过的音效核心和解码源。 /// /// 主音乐播放器继续保留给菜单音乐复用;音效主轨与重叠播放器按需重建, - /// 从而让已经启动的 mpv worker/demux 线程和其缓冲真正退出。 + /// 从而让已经启动的媒体 worker/demux 线程和其缓冲真正退出。 Future releaseGameAudioResources() async { final activeRelease = _gameAudioReleaseFuture; if (activeRelease != null) { @@ -1474,7 +1489,7 @@ class MusicManager extends ChangeNotifier { } } - /// 在 Flutter/Dart 运行时销毁前等待所有 mpv 后台线程退出。 + /// 在 Flutter/Dart 运行时销毁前等待所有媒体后台线程退出。 Future shutdown() async { final activeShutdown = _shutdownFuture; if (activeShutdown != null) { @@ -1514,8 +1529,6 @@ class MusicManager extends ChangeNotifier { }), eagerError: false, ); - // 给 media_kit 的事件端口一个事件循环周期来完成注销。 - await Future.delayed(const Duration(milliseconds: 100)); } @override diff --git a/Engine/lib/src/utils/platform_window_manager_io.dart b/Engine/lib/src/utils/platform_window_manager_io.dart index a2633fad..9223ef9b 100644 --- a/Engine/lib/src/utils/platform_window_manager_io.dart +++ b/Engine/lib/src/utils/platform_window_manager_io.dart @@ -75,6 +75,12 @@ class PlatformWindowManager { } } + static Future hide() async { + if (_isDesktop) { + await windowManager.hide(); + } + } + static Future close() async { if (_isDesktop) { await windowManager.close(); diff --git a/Engine/lib/src/utils/platform_window_manager_web.dart b/Engine/lib/src/utils/platform_window_manager_web.dart index 6f783372..2ab42db8 100644 --- a/Engine/lib/src/utils/platform_window_manager_web.dart +++ b/Engine/lib/src/utils/platform_window_manager_web.dart @@ -70,6 +70,8 @@ class PlatformWindowManager { } catch (_) {} } + static Future hide() async {} + static Future close() async { await destroy(); } diff --git a/Engine/lib/src/utils/read_text_tracker_io.dart b/Engine/lib/src/utils/read_text_tracker_io.dart index fc444de6..01a7aeb4 100644 --- a/Engine/lib/src/utils/read_text_tracker_io.dart +++ b/Engine/lib/src/utils/read_text_tracker_io.dart @@ -289,13 +289,11 @@ class ReadTextTracker extends ChangeNotifier { _readDialogues.addAll( snapshot.legacyHashes.map((hash) => hash.toString()), ); - if (kEngineDebugMode) { - print( - '[SAKI_NATIVE][READ] stable=${_stableReadHashes.length} ' - 'legacy=${_readDialogues.length} ' - 'migrated=${snapshot.migratedLegacyFile}', - ); - } + sakiDiagnosticLog( + '[SAKI_NATIVE][READ] stable=${_stableReadHashes.length} ' + 'legacy=${_readDialogues.length} ' + 'migrated=${snapshot.migratedLegacyFile}', + ); return; } catch (error, stackTrace) { if (kEngineDebugMode) { diff --git a/Engine/lib/src/utils/saki_audio_player.dart b/Engine/lib/src/utils/saki_audio_player.dart new file mode 100644 index 00000000..46e07cf0 --- /dev/null +++ b/Engine/lib/src/utils/saki_audio_player.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; +import 'package:just_audio/just_audio.dart' as native; + +import 'saki_linux_audio_player.dart'; + +export 'package:just_audio/just_audio.dart' show LoopMode, ProcessingState; + +/// Audio is independent of the Erika video presenter and its render surface. +/// +/// just_audio uses the platform audio implementation (including WinRT through +/// just_audio_windows). Linux uses the system GStreamer plugin instead of mpv. +class AudioPlayer { + AudioPlayer() + : this._(!kIsWeb && defaultTargetPlatform == TargetPlatform.linux); + + AudioPlayer._(bool linux) + : _native = linux ? null : native.AudioPlayer(), + _linux = linux ? SakiLinuxAudioPlayer() : null; + + final native.AudioPlayer? _native; + final SakiLinuxAudioPlayer? _linux; + + bool get playing => _native?.playing ?? _linux!.playing; + native.ProcessingState get processingState => + _native?.processingState ?? _linux!.processingState; + Stream get playbackEventStream => + _native?.playbackEventStream ?? _linux!.playbackEventStream; + + Future setLoopMode(native.LoopMode mode) => + _native?.setLoopMode(mode) ?? _linux!.setLoopMode(mode); + + Future setVolume(double volume) { + final normalized = volume.clamp(0.0, 1.0).toDouble(); + return _native?.setVolume(normalized) ?? _linux!.setVolume(normalized); + } + + Future setUrl(String url) async { + if (_native != null) { + await _native.setUrl(url); + } else { + await _linux!.setUrl(url); + } + } + + Future setFilePath(String path) async { + if (_native != null) { + await _native.setFilePath(path); + } else { + await _linux!.setFilePath(path); + } + } + + Future setAsset(String assetPath) async { + if (_native != null) { + await _native.setAsset(assetPath); + } else { + await _linux!.setAsset(assetPath); + } + } + + Future play() => _native?.play() ?? _linux!.play(); + Future pause() => _native?.pause() ?? _linux!.pause(); + Future stop() => _native?.stop() ?? _linux!.stop(); + Future dispose() => _native?.dispose() ?? _linux!.dispose(); +} diff --git a/Engine/lib/src/utils/saki_linux_audio_player.dart b/Engine/lib/src/utils/saki_linux_audio_player.dart new file mode 100644 index 00000000..22601937 --- /dev/null +++ b/Engine/lib/src/utils/saki_linux_audio_player.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; +import 'package:just_audio/just_audio.dart' show LoopMode, ProcessingState; +import 'package:sakiengine/src/config/asset_manager.dart'; + +/// Minimal engine audio contract over audioplayers_linux's system GStreamer. +/// Only the Linux plugin is a dependency; other platforms keep just_audio. +class SakiLinuxAudioPlayer { + SakiLinuxAudioPlayer() : _platform = AudioplayersPlatformInterface.instance; + + static int _nextId = 0; + final String _id = 'saki-audio-${_nextId++}'; + final AudioplayersPlatformInterface _platform; + final _events = StreamController.broadcast(); + StreamSubscription? _subscription; + Future? _creation; + Completer? _preparing; + LoopMode _loopMode = LoopMode.off; + bool _disposed = false; + bool _playing = false; + ProcessingState _state = ProcessingState.idle; + + bool get playing => _playing; + ProcessingState get processingState => _state; + Stream get playbackEventStream => _events.stream; + + Future _ensureCreated() { + if (_disposed) throw StateError('AudioPlayer has been disposed.'); + return _creation ??= _create(); + } + + Future _create() async { + await _platform.create(_id); + _subscription = _platform + .getEventStream(_id) + .listen( + (event) { + if (event.eventType == AudioEventType.prepared && + event.isPrepared == true) { + _state = ProcessingState.ready; + _preparing?.complete(); + _preparing = null; + } else if (event.eventType == AudioEventType.complete && + _loopMode == LoopMode.off) { + _playing = false; + _state = ProcessingState.completed; + } + _events.add(event); + }, + onError: (Object error, StackTrace stack) { + _playing = false; + _state = ProcessingState.idle; + final preparing = _preparing; + _preparing = null; + if (preparing != null) { + preparing.completeError(error, stack); + } else { + _events.addError(error, stack); + } + }, + ); + await _platform.setReleaseMode(_id, ReleaseMode.stop); + } + + Future setLoopMode(LoopMode mode) async { + await _ensureCreated(); + _loopMode = mode; + await _platform.setReleaseMode( + _id, + mode == LoopMode.off ? ReleaseMode.stop : ReleaseMode.loop, + ); + } + + Future setVolume(double volume) async { + await _ensureCreated(); + await _platform.setVolume(_id, volume); + } + + Future setUrl(String url) async { + await _ensureCreated(); + _interruptLoad(); + _playing = false; + _state = ProcessingState.loading; + final preparing = Completer(); + _preparing = preparing; + try { + // Wait for preroll, not for playback/EOF, matching just_audio.load. + await Future.wait([ + preparing.future.timeout(const Duration(seconds: 30)), + _platform.setSourceUrl(_id, url, isLocal: false), + ], eagerError: true); + } catch (_) { + if (identical(_preparing, preparing)) _state = ProcessingState.idle; + rethrow; + } finally { + if (identical(_preparing, preparing)) _preparing = null; + // Cancel the timeout if setting the source itself failed first. + if (!preparing.isCompleted) preparing.complete(); + } + } + + Future setFilePath(String path) => + setUrl(Uri.file(path, windows: false).toString()); + + Future setAsset(String assetPath) async { + final path = await AssetManager().findNativeMediaAsset(assetPath); + if (path == null) throw StateError('Audio asset not found: $assetPath'); + await setFilePath(path); + } + + Future play() async { + await _ensureCreated(); + await _platform.resume(_id); + _playing = true; + } + + Future pause() async { + if (_creation == null || _disposed) return; + await _creation; + await _platform.pause(_id); + _playing = false; + } + + Future stop() async { + _interruptLoad(); + _playing = false; + _state = ProcessingState.idle; + if (_creation == null || _disposed) return; + await _creation; + await _platform.stop(_id); + } + + void _interruptLoad() { + _preparing?.completeError(StateError('Audio loading interrupted')); + _preparing = null; + } + + Future dispose() async { + if (_disposed) return; + _disposed = true; + _playing = false; + _interruptLoad(); + try { + if (_creation != null) { + await _creation; + await _subscription?.cancel(); + await _platform.dispose(_id); + } + } finally { + await _events.close(); + } + } +} diff --git a/Engine/lib/src/utils/ui_sound_manager.dart b/Engine/lib/src/utils/ui_sound_manager.dart index 5c16205b..ddb524c6 100644 --- a/Engine/lib/src/utils/ui_sound_manager.dart +++ b/Engine/lib/src/utils/ui_sound_manager.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:math'; -import 'package:just_audio/just_audio.dart'; import 'package:path/path.dart' as p; import 'package:sakiengine/src/config/asset_manager.dart'; import 'package:sakiengine/src/config/game_path_resolver.dart'; @@ -10,6 +9,7 @@ import 'package:sakiengine/src/config/saki_pack_store.dart'; import 'package:sakiengine/src/game/unified_game_data_manager.dart'; import 'package:sakiengine/src/utils/bundle_asset_path_probe.dart'; import 'package:sakiengine/src/utils/foundation_compat.dart'; +import 'package:sakiengine/src/utils/saki_audio_player.dart'; /// UI interaction sound manager (hover/click). class UISoundManager { diff --git a/Engine/lib/src/widgets/common/exit_confirmation_dialog.dart b/Engine/lib/src/widgets/common/exit_confirmation_dialog.dart index 5e187a3c..636b0adf 100644 --- a/Engine/lib/src/widgets/common/exit_confirmation_dialog.dart +++ b/Engine/lib/src/widgets/common/exit_confirmation_dialog.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:sakiengine/src/localization/localization_manager.dart'; import 'package:sakiengine/src/utils/music_manager.dart'; @@ -12,14 +12,46 @@ import '../../utils/platform_window_manager_io.dart' class ExitConfirmationDialog { static Future closeApplication() async { try { - await Future.wait([ - MusicManager().shutdown(), - UISoundManager().shutdown(), - ]); await PlatformWindowManager.setPreventClose(false); + } catch (_) {} + + if (PlatformWindowManager.isWindows) { + // Remove the visible window before native media teardown. Erika owns the + // Windows players and releases them with the Flutter plugin, so waiting + // for every Dart-side player here only leaves a frozen final frame on + // screen while the process is already shutting down. + try { + await PlatformWindowManager.hide(); + } catch (_) {} + try { + await PlatformWindowManager.destroy(); + return; + } catch (_) {} + } else { + try { + await Future.wait([ + MusicManager().shutdown(), + UISoundManager().shutdown(), + ]); + } catch (_) { + // Audio cleanup must not prevent the application from terminating. + } + } + + try { + // This method exits the application, rather than merely asking the + // current window to perform its native close action. In particular, + // performClose is rejected with a system beep by borderless macOS + // windows that draw their own chrome. + await PlatformWindowManager.destroy(); + return; + } catch (_) {} + + try { await PlatformWindowManager.close(); + return; } catch (_) { - SystemNavigator.pop(); + await SystemNavigator.pop(); } } @@ -46,7 +78,9 @@ class ExitConfirmationDialog { return shouldExit ?? false; } - static Future showExitConfirmationAndDestroy(BuildContext context) async { + static Future showExitConfirmationAndDestroy( + BuildContext context, + ) async { final localization = LocalizationManager(); final title = localization.t('dialog.exit.title'); final content = localization.t('dialog.exit.contentSimple'); diff --git a/Engine/lib/src/widgets/movie_player.dart b/Engine/lib/src/widgets/movie_player.dart index 039462bd..32d975ed 100644 --- a/Engine/lib/src/widgets/movie_player.dart +++ b/Engine/lib/src/widgets/movie_player.dart @@ -5,6 +5,16 @@ import 'package:flutter/material.dart'; import 'package:sakiengine/src/config/asset_manager.dart'; import 'package:sakiengine/src/utils/smart_asset_image.dart'; +enum MovieVideoAlphaMode { opaque, packedAlphaRight } + +extension on MovieVideoAlphaMode { + ErikaVideoAlphaMode get erikaValue => switch (this) { + MovieVideoAlphaMode.opaque => ErikaVideoAlphaMode.opaque, + MovieVideoAlphaMode.packedAlphaRight => + ErikaVideoAlphaMode.packedAlphaRight, + }; +} + class MoviePlayer extends StatefulWidget { final String movieFile; final VoidCallback? onVideoEnd; @@ -14,6 +24,7 @@ class MoviePlayer extends StatefulWidget { final bool looping; final int? repeatCount; final Duration? loopStart; + final Duration initialPosition; final bool backgroundMode; final bool pingPongLoop; final String? pingPongReverseMovieFile; @@ -24,6 +35,9 @@ class MoviePlayer extends StatefulWidget { final String? placeholderImageAssetName; final double playbackRate; final Color backgroundColor; + final MovieVideoAlphaMode videoAlphaMode; + final BlendMode videoBlendMode; + final double videoOpacity; const MoviePlayer({ super.key, @@ -35,6 +49,7 @@ class MoviePlayer extends StatefulWidget { this.looping = false, this.repeatCount, this.loopStart, + this.initialPosition = Duration.zero, this.backgroundMode = false, this.pingPongLoop = false, this.pingPongReverseMovieFile, @@ -45,7 +60,12 @@ class MoviePlayer extends StatefulWidget { this.placeholderImageAssetName, this.playbackRate = 1.0, this.backgroundColor = Colors.black, - }) : assert(playbackRate > 0); + this.videoAlphaMode = MovieVideoAlphaMode.opaque, + this.videoBlendMode = BlendMode.srcOver, + this.videoOpacity = 1.0, + }) : assert(initialPosition >= Duration.zero), + assert(playbackRate > 0), + assert(videoOpacity >= 0.0 && videoOpacity <= 1.0); @override State createState() => _MoviePlayerState(); @@ -76,6 +96,7 @@ class _MoviePlayerState extends State { ErikaPlaybackState _playbackState = ErikaPlaybackState.idle; Completer? _surfaceAttachedCompleter; Completer? _secondarySurfaceAttachedCompleter; + Completer? _secondaryPlaybackProgressCompleter; bool get _hasSequentialFollowUp => widget.sequentialMovieFile?.trim().isNotEmpty == true; @@ -105,11 +126,13 @@ class _MoviePlayerState extends State { oldWidget.movieFile != widget.movieFile || oldWidget.looping != widget.looping || oldWidget.loopStart != widget.loopStart || + oldWidget.initialPosition != widget.initialPosition || oldWidget.backgroundMode != widget.backgroundMode || oldWidget.pingPongLoop != widget.pingPongLoop || oldWidget.pingPongReverseMovieFile != widget.pingPongReverseMovieFile || oldWidget.sequentialMovieFile != widget.sequentialMovieFile || - oldWidget.sequentialLooping != widget.sequentialLooping; + oldWidget.sequentialLooping != widget.sequentialLooping || + oldWidget.videoAlphaMode != widget.videoAlphaMode; if (shouldReinitialize) { unawaited(_initializeVideo()); return; @@ -152,6 +175,7 @@ class _MoviePlayerState extends State { _videoHeight = 0; _playbackState = ErikaPlaybackState.idle; }); + _secondaryPlaybackProgressCompleter = null; final surfaceAttachedCompleter = Completer(); _surfaceAttachedCompleter = surfaceAttachedCompleter; @@ -183,6 +207,7 @@ class _MoviePlayerState extends State { final player = ErikaPlayer( allowBackgroundPlayback: widget.backgroundMode, + videoAlphaMode: widget.videoAlphaMode.erikaValue, ); _player = player; _eventSubscription = player.events.listen( @@ -212,6 +237,9 @@ class _MoviePlayerState extends State { if (widget.backgroundMode) { await player.setVolume(0.0); } + if (widget.initialPosition > Duration.zero) { + await player.seek(widget.initialPosition); + } if (widget.autoPlay) { await player.play(); } @@ -229,7 +257,10 @@ class _MoviePlayerState extends State { } Future _initializeSecondaryPlayer(String path, int generation) async { - final player = ErikaPlayer(allowBackgroundPlayback: widget.backgroundMode); + final player = ErikaPlayer( + allowBackgroundPlayback: widget.backgroundMode, + videoAlphaMode: widget.videoAlphaMode.erikaValue, + ); _secondaryPlayer = player; _secondarySurfaceAttachedCompleter = Completer(); _secondaryEventSubscription = player.events.listen( @@ -274,10 +305,14 @@ class _MoviePlayerState extends State { case ErikaEventKind.videoParamsChanged: if (event.video.width > 0 && event.video.height > 0) { _hasVideoParams = true; - if (_videoWidth != event.video.width || + final logicalWidth = + widget.videoAlphaMode == MovieVideoAlphaMode.packedAlphaRight + ? event.video.width ~/ 2 + : event.video.width; + if (_videoWidth != logicalWidth || _videoHeight != event.video.height) { setState(() { - _videoWidth = event.video.width; + _videoWidth = logicalWidth; _videoHeight = event.video.height; }); } @@ -315,6 +350,13 @@ class _MoviePlayerState extends State { if (completer != null && !completer.isCompleted) { completer.complete(); } + } else if (event.kind == ErikaEventKind.positionChanged) { + final completer = _secondaryPlaybackProgressCompleter; + if (_secondaryHasStartedPlayback && + completer != null && + !completer.isCompleted) { + completer.complete(); + } } } @@ -346,12 +388,6 @@ class _MoviePlayerState extends State { final secondaryPlayer = _secondaryPlayer; if (secondaryPlayer != null && (widget.pingPongLoop || _hasSequentialFollowUp)) { - if (mounted) { - setState(() { - _showSecondaryPlayer = true; - }); - } - await WidgetsBinding.instance.endOfFrame; await _secondarySurfaceAttachedCompleter?.future.timeout( const Duration(seconds: 1), onTimeout: () {}, @@ -361,7 +397,25 @@ class _MoviePlayerState extends State { } await secondaryPlayer.seek(Duration.zero); if (widget.autoPlay) { + final playbackProgressCompleter = Completer(); + _secondaryPlaybackProgressCompleter = playbackProgressCompleter; + _secondaryHasStartedPlayback = true; await secondaryPlayer.play(); + await playbackProgressCompleter.future.timeout( + const Duration(milliseconds: 500), + onTimeout: () {}, + ); + if (identical( + _secondaryPlaybackProgressCompleter, + playbackProgressCompleter, + )) { + _secondaryPlaybackProgressCompleter = null; + } + } + if (mounted && _player == player) { + setState(() { + _showSecondaryPlayer = true; + }); } return; } @@ -495,6 +549,7 @@ class _MoviePlayerState extends State { _secondaryEventSubscription = null; _surfaceAttachedCompleter = null; _secondarySurfaceAttachedCompleter = null; + _secondaryPlaybackProgressCompleter = null; final player = _player; final secondaryPlayer = _secondaryPlayer; _player = null; @@ -513,7 +568,12 @@ class _MoviePlayerState extends State { } Widget _buildVideoLayer(BuildContext context, ErikaPlayer player) { - final texture = ErikaTextureVideoView(player: player); + final texture = ErikaTextureVideoView( + key: ObjectKey(player), + player: player, + blendMode: widget.videoBlendMode, + opacity: widget.videoOpacity, + ); if (_videoWidth <= 0 || _videoHeight <= 0) { return SizedBox.expand(child: texture); } @@ -581,6 +641,8 @@ class _MoviePlayerState extends State { fit: StackFit.expand, children: [ if (_hasPlaceholderImage) _buildPlaceholderLayer(), + if (secondaryPlayer != null && !_showSecondaryPlayer) + _buildVideoLayer(context, secondaryPlayer), _buildVideoLayer(context, player), if (secondaryPlayer != null && _showSecondaryPlayer) _buildVideoLayer(context, secondaryPlayer), diff --git a/Engine/linux/flutter/generated_plugin_registrant.cc b/Engine/linux/flutter/generated_plugin_registrant.cc index df30d6d7..cb342740 100644 --- a/Engine/linux/flutter/generated_plugin_registrant.cc +++ b/Engine/linux/flutter/generated_plugin_registrant.cc @@ -6,14 +6,17 @@ #include "generated_plugin_registrant.h" +#include #include #include #include -#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin"); flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar); @@ -23,9 +26,6 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) hotkey_manager_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "HotkeyManagerLinuxPlugin"); hotkey_manager_linux_plugin_register_with_registrar(hotkey_manager_linux_registrar); - g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); - media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); diff --git a/Engine/linux/flutter/generated_plugins.cmake b/Engine/linux/flutter/generated_plugins.cmake index 0a868ce0..1e8d619d 100644 --- a/Engine/linux/flutter/generated_plugins.cmake +++ b/Engine/linux/flutter/generated_plugins.cmake @@ -3,10 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux flutter_avif_linux flutter_steamworks hotkey_manager_linux - media_kit_libs_linux screen_retriever_linux window_manager ) diff --git a/Engine/pubspec.lock b/Engine/pubspec.lock index a4f63d11..5abfc2db 100644 --- a/Engine/pubspec.lock +++ b/Engine/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - archive: - dependency: transitive - description: - name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" - url: "https://pub.dev" - source: hosted - version: "4.0.7" args: dependency: transitive description: @@ -33,6 +25,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.25" + audioplayers_linux: + dependency: "direct main" + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: "direct main" + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" boolean_selector: dependency: transitive description: @@ -100,7 +108,7 @@ packages: erika_flutter: dependency: "direct main" description: - path: "../../../RustProject/Erika/packages/erika_flutter" + path: "../third_party/erika_flutter" relative: true source: path version: "0.1.7" @@ -356,14 +364,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - image: - dependency: transitive - description: - name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" - url: "https://pub.dev" - source: hosted - version: "4.5.4" intl: dependency: "direct main" description: @@ -388,14 +388,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.46" - just_audio_media_kit: - dependency: "direct main" - description: - name: just_audio_media_kit - sha256: f3cf04c3a50339709e87e90b4e841eef4364ab4be2bdbac0c54cc48679f84d23 - url: "https://pub.dev" - source: hosted - version: "2.1.0" just_audio_platform_interface: dependency: transitive description: @@ -412,6 +404,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.16" + just_audio_windows: + dependency: "direct main" + description: + name: just_audio_windows + sha256: "7d80dfa02a2189f1c26673a56f4d8584a306d3f22735302be6111e9578a40318" + url: "https://pub.dev" + source: hosted + version: "0.2.3" leak_tracker: dependency: transitive description: @@ -444,14 +444,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" matcher: dependency: transitive description: @@ -468,27 +460,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" - media_kit: - dependency: "direct overridden" - description: - path: "../third_party/media_kit_hotfix" - relative: true - source: path - version: "1.2.6" - media_kit_libs_linux: - dependency: "direct main" - description: - path: "../third_party/media_kit_libs_linux_hotfix" - relative: true - source: path - version: "1.2.1" - media_kit_libs_windows_video: - dependency: "direct main" - description: - path: "../third_party/media_kit_libs_windows_video_hotfix" - relative: true - source: path - version: "1.0.11" meta: dependency: transitive description: @@ -593,14 +564,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - posix: - dependency: transitive - description: - name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" - url: "https://pub.dev" - source: hosted - version: "6.0.3" protobuf: dependency: transitive description: @@ -625,14 +588,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" - safe_local_storage: - dependency: transitive - description: - name: safe_local_storage - sha256: e9a21b6fec7a8aa62cc2585ff4c1b127df42f3185adbd2aca66b47abe2e80236 - url: "https://pub.dev" - source: hosted - version: "2.0.1" saki_native: dependency: "direct main" description: @@ -805,22 +760,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" - universal_platform: - dependency: transitive - description: - name: universal_platform - sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - uri_parser: - dependency: transitive - description: - name: uri_parser - sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" - url: "https://pub.dev" - source: hosted - version: "3.0.2" uuid: dependency: transitive description: diff --git a/Engine/pubspec.yaml b/Engine/pubspec.yaml index 2b0479a2..3e153fde 100644 --- a/Engine/pubspec.yaml +++ b/Engine/pubspec.yaml @@ -50,11 +50,12 @@ dependencies: flutter_avif: ^3.0.0 flutter_svg: ^2.0.10 erika_flutter: - path: ../../../RustProject/Erika/packages/erika_flutter - media_kit_libs_windows_video: ^1.0.10 - media_kit_libs_linux: ^1.1.4 + path: ../third_party/erika_flutter just_audio: ^0.9.46 - just_audio_media_kit: ^2.1.0 + just_audio_windows: ^0.2.3 + # Later minor versions require Flutter 3.44; retain the existing SDK floor. + audioplayers_linux: '>=4.2.1 <4.3.0' + audioplayers_platform_interface: '>=7.1.1 <7.2.0' flutter_steamworks: ^0.0.4 saki_native: path: packages/saki_native @@ -71,14 +72,6 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 -dependency_overrides: - media_kit: - path: ../third_party/media_kit_hotfix - media_kit_libs_windows_video: - path: ../third_party/media_kit_libs_windows_video_hotfix - media_kit_libs_linux: - path: ../third_party/media_kit_libs_linux_hotfix - # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/Engine/test/character_position_animator_test.dart b/Engine/test/character_position_animator_test.dart new file mode 100644 index 00000000..f47f5028 --- /dev/null +++ b/Engine/test/character_position_animator_test.dart @@ -0,0 +1,283 @@ +import 'package:flutter/animation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sakiengine/src/utils/character_position_animator.dart'; + +void main() { + List positionChanges() => [ + CharacterPositionChange(characterId: 'saya', fromX: 0.2, toX: 0.8), + ]; + + List attributeChanges() => [ + CharacterAttributeChange( + characterId: 'saya', + fromAttributes: {'xcenter': 0.2, 'scale': 1.0}, + toAttributes: {'xcenter': 0.8, 'scale': 1.5}, + ), + ]; + + testWidgets('position animation reaches its target and completes once', ( + tester, + ) async { + final animator = CharacterPositionAnimator(); + addTearDown(animator.dispose); + var completions = 0; + double? position; + var finished = false; + final future = animator + .animatePositionChanges( + positionChanges: positionChanges(), + vsync: tester, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + onUpdate: (positions) => position = positions['saya'], + onComplete: () => completions++, + ) + .then((_) => finished = true); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(position, closeTo(0.5, 0.0001)); + expect(finished, isFalse); + expect(animator.isAnimating, isTrue); + + await tester.pump(const Duration(milliseconds: 51)); + expect(position, closeTo(0.8, 0.0001)); + expect(finished, isTrue); + expect(completions, 1); + expect(animator.isAnimating, isFalse); + await future; + animator.stop(); + expect(completions, 1); + }); + + testWidgets('attribute animation reaches every target and completes once', ( + tester, + ) async { + final animator = CharacterPositionAnimator(); + addTearDown(animator.dispose); + var completions = 0; + Map? attributes; + var finished = false; + final future = animator + .animateAttributeChanges( + attributeChanges: attributeChanges(), + vsync: tester, + duration: const Duration(milliseconds: 100), + onUpdate: (values) => attributes = values['saya'], + onComplete: () => completions++, + ) + .then((_) => finished = true); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 101)); + expect(attributes, {'xcenter': 0.8, 'scale': 1.5}); + expect(finished, isTrue); + expect(completions, 1); + expect(animator.isAnimating, isFalse); + await future; + }); + + testWidgets('stop releases the waiter without callbacks or further updates', ( + tester, + ) async { + final animator = CharacterPositionAnimator(); + addTearDown(animator.dispose); + var completions = 0; + var updates = 0; + var finished = false; + final future = animator + .animatePositionChanges( + positionChanges: positionChanges(), + vsync: tester, + onUpdate: (_) => updates++, + onComplete: () => completions++, + ) + .then((_) => finished = true); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(animator.isAnimating, isTrue); + animator.stop(); + animator.stop(); + final updatesAtStop = updates; + await tester.pump(); + expect(finished, isTrue); + expect(animator.isAnimating, isFalse); + expect(completions, 0); + await future; + await tester.pump(const Duration(seconds: 1)); + expect(updates, updatesAtStop); + }); + + testWidgets('dispose releases an attribute waiter without completing it', ( + tester, + ) async { + final animator = CharacterPositionAnimator(); + var completions = 0; + var finished = false; + final future = animator + .animateAttributeChanges( + attributeChanges: attributeChanges(), + vsync: tester, + onComplete: () => completions++, + ) + .then((_) => finished = true); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + animator.dispose(); + animator.dispose(); + await tester.pump(); + expect(finished, isTrue); + expect(completions, 0); + expect(animator.isAnimating, isFalse); + await future; + }); + + testWidgets('replacement releases the old waiter and keeps the new ticker', ( + tester, + ) async { + final animator = CharacterPositionAnimator(); + addTearDown(animator.dispose); + var oldCompletions = 0; + var oldUpdates = 0; + var oldFinished = false; + final oldFuture = animator + .animatePositionChanges( + positionChanges: positionChanges(), + vsync: tester, + onUpdate: (_) => oldUpdates++, + onComplete: () => oldCompletions++, + ) + .then((_) => oldFinished = true); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + var newCompletions = 0; + var newFinished = false; + Map? attributes; + final oldUpdatesAtReplacement = oldUpdates; + final newFuture = animator + .animateAttributeChanges( + attributeChanges: attributeChanges(), + vsync: tester, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + onUpdate: (values) => attributes = values['saya'], + onComplete: () => newCompletions++, + ) + .then((_) => newFinished = true); + + await tester.pump(); + expect(oldFinished, isTrue); + expect(oldCompletions, 0); + expect(animator.isAnimating, isTrue); + await oldFuture; + await tester.pump(const Duration(milliseconds: 100)); + expect(attributes!['xcenter'], closeTo(0.5, 0.0001)); + expect(attributes!['scale'], closeTo(1.25, 0.0001)); + expect(newFinished, isFalse); + await tester.pump(const Duration(milliseconds: 101)); + expect(newFinished, isTrue); + expect(newCompletions, 1); + expect(oldCompletions, 0); + expect(oldUpdates, oldUpdatesAtReplacement); + expect(attributes, {'xcenter': 0.8, 'scale': 1.5}); + await newFuture; + }); + + testWidgets('stop before the first frame releases the waiter', ( + tester, + ) async { + final animator = CharacterPositionAnimator(); + addTearDown(animator.dispose); + var completions = 0; + var finished = false; + final future = animator + .animatePositionChanges( + positionChanges: positionChanges(), + vsync: tester, + onComplete: () => completions++, + ) + .then((_) => finished = true); + animator.stop(); + await tester.pump(); + expect(finished, isTrue); + expect(completions, 0); + expect(animator.isAnimating, isFalse); + await future; + }); + + testWidgets('zero duration still completes normally and releases resources', ( + tester, + ) async { + final animator = CharacterPositionAnimator(); + addTearDown(animator.dispose); + var completions = 0; + double? position; + var finished = false; + final future = animator + .animatePositionChanges( + positionChanges: positionChanges(), + vsync: tester, + duration: Duration.zero, + onUpdate: (positions) => position = positions['saya'], + onComplete: () => completions++, + ) + .then((_) => finished = true); + await tester.pump(); + expect(finished, isTrue); + expect(position, closeTo(0.8, 0.0001)); + expect(completions, 1); + expect(animator.isAnimating, isFalse); + await future; + }); + + for (final useAttributes in [false, true]) { + testWidgets( + 'empty ${useAttributes ? 'attribute' : 'position'} request cancels its predecessor', + (tester) async { + final animator = CharacterPositionAnimator(); + addTearDown(animator.dispose); + var oldCompletions = 0; + var oldFinished = false; + final oldFuture = animator + .animatePositionChanges( + positionChanges: positionChanges(), + vsync: tester, + onComplete: () => oldCompletions++, + ) + .then((_) => oldFinished = true); + await tester.pump(); + + var newCompletions = 0; + if (useAttributes) { + await animator.animateAttributeChanges( + attributeChanges: [ + CharacterAttributeChange( + characterId: 'saya', + fromAttributes: {'xcenter': 0.5}, + toAttributes: {'xcenter': 0.5}, + ), + ], + vsync: tester, + onComplete: () => newCompletions++, + ); + } else { + await animator.animatePositionChanges( + positionChanges: [], + vsync: tester, + onComplete: () => newCompletions++, + ); + } + + await tester.pump(); + expect(oldFinished, isTrue); + expect(oldCompletions, 0); + expect(newCompletions, 1); + expect(animator.isAnimating, isFalse); + await oldFuture; + }, + ); + } +} diff --git a/Engine/test/character_resource_switch_test.dart b/Engine/test/character_resource_switch_test.dart new file mode 100644 index 00000000..511c9ffd --- /dev/null +++ b/Engine/test/character_resource_switch_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sakiengine/src/config/config_models.dart'; +import 'package:sakiengine/src/game/game_manager.dart'; +import 'package:sakiengine/src/sks_parser/sks_ast.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + GameManager createManager() { + final manager = GameManager(); + manager.characterConfigs['aru2'] = CharacterConfig( + id: 'aru2', + name: '鸦露露', + resourceId: 'aru2', + defaultPoseId: 'pose', + slotId: 'aru', + ); + manager.characterConfigs['aru_unknown'] = CharacterConfig( + id: 'aru_unknown', + name: '粉色的少女', + resourceId: 'aru', + defaultPoseId: 'pose', + slotId: 'aru', + ); + addTearDown(manager.dispose); + return manager; + } + + GameState aru2SurprisedState() => GameState( + characters: { + 'slot:aru': CharacterState( + resourceId: 'aru2', + pose: 'pose1', + expression: 'surprised', + positionId: 'pose', + ), + }, + ); + + test('resource switch resets an omitted incompatible expression', () async { + final manager = createManager(); + + await manager.startTestScript( + ScriptNode([ + SayNode( + character: 'aru_unknown', + dialogue: '「“美少女游戏”还常被分为“泣系”和“致郁系”对吧?」', + ), + ]), + initialState: aru2SurprisedState(), + ); + + final aru = manager.currentState.characters['slot:aru']; + expect(aru?.resourceId, 'aru'); + expect(aru?.pose, 'pose1'); + expect(aru?.expression, 'happy'); + }); + + test('same resource keeps its current expression when omitted', () async { + final manager = createManager(); + + await manager.startTestScript( + ScriptNode([SayNode(character: 'aru2', dialogue: '继续说话。')]), + initialState: aru2SurprisedState(), + ); + + expect( + manager.currentState.characters['slot:aru']?.expression, + 'surprised', + ); + }); + + test('explicit expression wins when switching resources', () async { + final manager = createManager(); + + await manager.startTestScript( + ScriptNode([ + SayNode( + character: 'aru_unknown', + dialogue: '明确指定表情。', + expression: 'sad', + ), + ]), + initialState: aru2SurprisedState(), + ); + + expect(manager.currentState.characters['slot:aru']?.expression, 'sad'); + }); +} diff --git a/Engine/test/character_sprite_transition_test.dart b/Engine/test/character_sprite_transition_test.dart new file mode 100644 index 00000000..de9629f2 --- /dev/null +++ b/Engine/test/character_sprite_transition_test.dart @@ -0,0 +1,116 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sakiengine/src/rendering/composite_cg_renderer.dart'; +import 'package:sakiengine/src/screens/game_play_screen.dart'; +import 'package:sakiengine/src/utils/engine_asset_loader.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('different sprite ratios keep independent dissolve geometry', () { + const canvasSize = Size(423, 1049); + final fromRect = calculateDissolveImageRect( + canvasSize, + 334, + 984, + BoxFit.fitHeight, + ); + final toRect = calculateDissolveImageRect( + canvasSize, + 423, + 1049, + BoxFit.fitHeight, + ); + + expect(fromRect.height, canvasSize.height); + expect(toRect.height, canvasSize.height); + expect(fromRect.width / fromRect.height, closeTo(334 / 984, 0.000001)); + expect(toRect.width / toRect.height, closeTo(423 / 1049, 0.000001)); + }); + + test('aspect-preserving dissolve shader compiles', () async { + final program = await EngineAssetLoader.loadFragmentProgram( + 'assets/shaders/dissolve.frag', + ); + expect(program, isNotNull); + }); + + Future createImage(Color color) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawRect(const Rect.fromLTWH(0, 0, 4, 4), Paint()..color = color); + return recorder.endRecording().toImage(4, 4); + } + + Widget buildSprite({ + required ui.Image image, + required String resourceId, + String characterKey = 'slot:aru', + }) { + return MaterialApp( + home: Center( + child: SizedBox( + width: 100, + height: 100, + child: KeyedSubtree( + key: characterPositionedRenderKey(characterKey), + child: DirectCgDisplay( + key: characterCompositeRenderKey(characterKey), + image: image, + resourceId: resourceId, + ), + ), + ), + ), + ); + } + + testWidgets( + 'different resources in the same character slot reuse the dissolve state', + (tester) async { + final firstImage = await createImage(Colors.pink); + final secondImage = await createImage(Colors.purple); + addTearDown(firstImage.dispose); + addTearDown(secondImage.dispose); + + await tester.pumpWidget( + buildSprite(image: firstImage, resourceId: 'aru'), + ); + await tester.pumpAndSettle(); + final firstState = tester.state(find.byType(DirectCgDisplay)); + + await tester.pumpWidget( + buildSprite(image: secondImage, resourceId: 'aru3'), + ); + await tester.pump(); + + expect(tester.state(find.byType(DirectCgDisplay)), same(firstState)); + + await tester.pump(const Duration(milliseconds: 300)); + }, + ); + + testWidgets('different character slots keep independent render state', ( + tester, + ) async { + final image = await createImage(Colors.pink); + addTearDown(image.dispose); + + await tester.pumpWidget(buildSprite(image: image, resourceId: 'aru')); + await tester.pumpAndSettle(); + final aruState = tester.state(find.byType(DirectCgDisplay)); + + await tester.pumpWidget( + buildSprite( + image: image, + resourceId: 'aru', + characterKey: 'slot:aru_shadow_1', + ), + ); + await tester.pump(); + + expect(tester.state(find.byType(DirectCgDisplay)), isNot(same(aruState))); + }); +} diff --git a/Engine/test/exit_confirmation_dialog_test.dart b/Engine/test/exit_confirmation_dialog_test.dart new file mode 100644 index 00000000..ff8815ba --- /dev/null +++ b/Engine/test/exit_confirmation_dialog_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sakiengine/src/widgets/common/exit_confirmation_dialog.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const windowManagerChannel = MethodChannel('window_manager'); + + test('application exit destroys the process-level window session', () async { + final methodCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(windowManagerChannel, (call) async { + methodCalls.add(call.method); + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(windowManagerChannel, null); + }); + + await ExitConfirmationDialog.closeApplication(); + + expect( + methodCalls, + containsAllInOrder(['setPreventClose', 'hide', 'destroy']), + ); + expect(methodCalls, isNot(contains('close'))); + }); +} diff --git a/Engine/test/game_manager_character_position_test.dart b/Engine/test/game_manager_character_position_test.dart new file mode 100644 index 00000000..196ef7fe --- /dev/null +++ b/Engine/test/game_manager_character_position_test.dart @@ -0,0 +1,504 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sakiengine/src/config/config_models.dart'; +import 'package:sakiengine/src/game/game_manager.dart'; +import 'package:sakiengine/src/sks_parser/sks_ast.dart'; +import 'package:sakiengine/src/sks_parser/sks_parser.dart'; +import 'package:sakiengine/src/utils/animation_manager.dart'; +import 'package:sakiengine/src/utils/character_auto_distribution.dart'; + +Map _characterConfigs() => { + for (final entry in {'a': 'alpha', 'b': 'beta', 'c': 'gamma'}.entries) + entry.key: CharacterConfig( + id: entry.key, + name: entry.key, + resourceId: entry.value, + defaultPoseId: 'auto', + ), +}; + +Map _poseConfigs() => { + 'auto': PoseConfig(id: 'auto', scale: 1.3, ycenter: 0.7, anchor: 'auto'), + 'close': PoseConfig( + id: 'close', + scale: 1.55, + xcenter: 0.35, + ycenter: 0.65, + anchor: 'center', + ), +}; + +CharacterState _character( + String resourceId, { + Map? animationProperties, +}) => CharacterState( + resourceId: resourceId, + positionId: 'auto', + animationProperties: animationProperties, +); + +Map> _renderedProperties(GameManager manager) { + final characters = manager.currentState.characters; + final distributed = CharacterAutoDistribution.calculateAutoDistribution( + characters, + manager.poseConfigs, + characters.keys.toList(), + ); + return { + for (final entry in characters.entries) + entry.key: { + 'xcenter': + (distributed['${entry.key}_auto_distributed'] ?? + manager.poseConfigs[entry.value.positionId]!) + .xcenter, + 'ycenter': manager.poseConfigs[entry.value.positionId]!.ycenter, + 'scale': manager.poseConfigs[entry.value.positionId]!.scale, + 'alpha': 1, + ...?entry.value.animationProperties, + }, + }; +} + +void _expectPositions(GameManager manager, Map expected) { + final actual = _renderedProperties(manager); + expect(actual.keys, unorderedEquals(expected.keys)); + for (final entry in expected.entries) { + expect( + actual[entry.key]!['xcenter'], + closeTo(entry.value, 0.000001), + reason: '${entry.key} should occupy its current distributed position', + ); + } +} + +Future _pumpFor(WidgetTester tester, Duration duration) async { + var remaining = duration; + while (remaining > Duration.zero) { + final step = remaining < const Duration(milliseconds: 20) + ? remaining + : const Duration(milliseconds: 20); + await tester.pump(step); + remaining -= step; + } +} + +Future _createManager(WidgetTester tester) async { + late BuildContext context; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (buildContext) { + context = buildContext; + return const SizedBox(); + }, + ), + ), + ); + final manager = GameManager(); + manager.setContext(context, const TestVSync()); + addTearDown(manager.dispose); + return manager; +} + +void main() { + tearDown(AnimationManager.clearCache); + + testWidgets('completed jump stays distributed when two characters enter', ( + tester, + ) async { + final manager = await _createManager(tester); + AnimationManager.loadAnimationsFromStringForTesting(''' +jump +ease 0.3 ycenter-0.05 +ease 0.3 ycenter+0 +'''); + + // The reported scene's ordering: jump, dialogue/expression changes, then + // two consecutive shows. These generic fixtures require no game assets. + await manager.startTestScript( + SksParser().parse(''' +show alpha happy at auto an jump +"scissors" +"winner" +a surprised "surprised" +a sad "catcher" +"friends-enter" +show beta at auto +show gamma at auto +b "after-show" +'''), + characterConfigs: _characterConfigs(), + poseConfigs: _poseConfigs(), + initialState: GameState(characters: {'alpha': _character('alpha')}), + ); + await _pumpFor(tester, const Duration(milliseconds: 750)); + expect(manager.currentState.dialogue, 'scissors'); + expect( + manager.currentState.characters['alpha']!.animationProperties, + containsPair('xcenter', 0.5), + reason: 'The completed jump must leave the original centered snapshot', + ); + + for (final dialogue in [ + 'winner', + 'surprised', + 'catcher', + 'friends-enter', + 'after-show', + ]) { + manager.next(); + await tester.pump(); + await _pumpFor(tester, const Duration(milliseconds: 1300)); + expect(manager.currentState.dialogue, dialogue); + } + + _expectPositions(manager, {'alpha': 0.2, 'beta': 0.5, 'gamma': 0.8}); + final alpha = _renderedProperties(manager)['alpha']!; + expect(alpha['ycenter'], closeTo(0.7, 0.000001)); + expect(alpha['scale'], closeTo(1.3, 0.000001)); + expect(manager.currentState.characters['alpha']!.expression, 'sad'); + }); + + for (final enterViaDialogue in [false, true]) { + final entryDescription = enterViaDialogue ? 'dialogue' : 'show'; + + testWidgets( + 'fade completion during $entryDescription entrance keeps advancing and removes old cast', + (tester) async { + final manager = await _createManager(tester); + await manager.startTestScript( + ScriptNode([ + HideNode('alpha'), + SayNode(dialogue: 'before-show'), + if (enterViaDialogue) + SayNode(character: 'c', dialogue: 'after-show') + else ...[ + ShowNode('gamma', position: 'auto'), + SayNode(dialogue: 'after-show'), + ], + SayNode(dialogue: 'after-click'), + ]), + characterConfigs: _characterConfigs(), + poseConfigs: _poseConfigs(), + initialState: GameState( + characters: { + 'alpha': _character('alpha'), + 'beta': _character( + 'beta', + animationProperties: { + 'ycenter': 0.64, + 'scale': 1.45, + 'alpha': 0.9, + }, + ), + }, + ), + ); + expect(manager.currentState.dialogue, 'before-show'); + expect(manager.currentState.characters['alpha']!.isFadingOut, isTrue); + + manager.next(); + await tester.pump(); + await _pumpFor(tester, const Duration(milliseconds: 220)); + // Renderer completion arrives during the awaited 500 ms placement. + manager.removeCharacterAfterFadeOut('alpha'); + await _pumpFor(tester, const Duration(milliseconds: 1500)); + + expect(manager.currentState.dialogue, 'after-show'); + _expectPositions(manager, {'beta': 0.25, 'gamma': 0.75}); + final beta = _renderedProperties(manager)['beta']!; + expect(beta['ycenter'], closeTo(0.64, 0.000001)); + expect(beta['scale'], closeTo(1.45, 0.000001)); + expect(beta['alpha'], closeTo(0.9, 0.000001)); + manager.next(); + await tester.pump(); + expect(manager.currentState.dialogue, 'after-click'); + expect(manager.currentState.characters.containsKey('alpha'), isFalse); + }, + ); + + testWidgets( + '$entryDescription distribution preserves non-horizontal transforms', + (tester) async { + final manager = await _createManager(tester); + await manager.startTestScript( + ScriptNode([ + SayNode(dialogue: 'before-entry'), + if (enterViaDialogue) + SayNode(character: 'b', dialogue: 'after-entry') + else ...[ + ShowNode('beta', position: 'auto'), + SayNode(dialogue: 'after-entry'), + ], + ]), + characterConfigs: _characterConfigs(), + poseConfigs: _poseConfigs(), + initialState: GameState( + characters: { + 'alpha': _character( + 'alpha', + animationProperties: { + 'xcenter': 0.5, + 'ycenter': 0.63, + 'scale': 1.55, + 'alpha': 0.8, + }, + ), + }, + ), + ); + manager.next(); + await tester.pump(); + await _pumpFor(tester, const Duration(milliseconds: 750)); + + expect(manager.currentState.dialogue, 'after-entry'); + _expectPositions(manager, {'alpha': 0.25, 'beta': 0.75}); + final alpha = _renderedProperties(manager)['alpha']!; + expect(alpha['ycenter'], closeTo(0.63, 0.000001)); + expect(alpha['scale'], closeTo(1.55, 0.000001)); + expect(alpha['alpha'], closeTo(0.8, 0.000001)); + }, + ); + } + + testWidgets( + 'fade completion during explicit pose change does not restore removed cast', + (tester) async { + final manager = await _createManager(tester); + await manager.startTestScript( + ScriptNode([ + HideNode('alpha'), + SayNode(dialogue: 'before-pose'), + SayNode(character: 'b', position: 'close', dialogue: 'after-pose'), + SayNode(dialogue: 'after-click'), + ]), + characterConfigs: _characterConfigs(), + poseConfigs: _poseConfigs(), + initialState: GameState( + characters: { + 'alpha': _character('alpha'), + 'beta': _character('beta'), + }, + ), + ); + manager.next(); + await tester.pump(); + await _pumpFor(tester, const Duration(milliseconds: 220)); + manager.removeCharacterAfterFadeOut('alpha'); + await _pumpFor(tester, const Duration(milliseconds: 1500)); + + expect(manager.currentState.dialogue, 'after-pose'); + _expectPositions(manager, {'beta': 0.35}); + expect(manager.currentState.characters['beta']!.positionId, 'close'); + final beta = _renderedProperties(manager)['beta']!; + expect(beta['ycenter'], closeTo(0.65, 0.000001)); + expect(beta['scale'], closeTo(1.55, 0.000001)); + manager.next(); + await tester.pump(); + expect(manager.currentState.dialogue, 'after-click'); + expect(manager.currentState.characters.containsKey('alpha'), isFalse); + }, + ); + + testWidgets( + 'fast-forward applies final distribution without clearing other transforms', + (tester) async { + final manager = await _createManager(tester); + await manager.startTestScript( + ScriptNode([ + SayNode(dialogue: 'before-entry'), + ShowNode('beta', position: 'auto'), + ShowNode('gamma', position: 'auto'), + SayNode(dialogue: 'after-entry'), + ]), + characterConfigs: _characterConfigs(), + poseConfigs: _poseConfigs(), + initialState: GameState( + characters: { + 'alpha': _character( + 'alpha', + animationProperties: { + 'xcenter': 0.5, + 'ycenter': 0.63, + 'scale': 1.55, + 'alpha': 0.8, + }, + ), + }, + ), + ); + manager.setFastForwardMode(true); + manager.next(); + await tester.pump(); + + expect(manager.currentState.dialogue, 'after-entry'); + _expectPositions(manager, {'alpha': 0.2, 'beta': 0.5, 'gamma': 0.8}); + final alpha = _renderedProperties(manager)['alpha']!; + expect(alpha['ycenter'], closeTo(0.63, 0.000001)); + expect(alpha['scale'], closeTo(1.55, 0.000001)); + expect(alpha['alpha'], closeTo(0.8, 0.000001)); + await _pumpFor(tester, const Duration(milliseconds: 60)); + }, + ); + + testWidgets( + 'running jump cannot restore old horizontal position after entrance', + (tester) async { + final manager = await _createManager(tester); + AnimationManager.loadAnimationsFromStringForTesting(''' +long_jump +ease 2 ycenter-0.05 +ease 2 ycenter+0 +'''); + await manager.startTestScript( + SksParser().parse(''' +show alpha happy at auto an long_jump +"before-entry" +show beta at auto +show gamma at auto +"after-entry" +'''), + characterConfigs: _characterConfigs(), + poseConfigs: _poseConfigs(), + initialState: GameState(characters: {'alpha': _character('alpha')}), + ); + await _pumpFor(tester, const Duration(milliseconds: 220)); + manager.next(); + await tester.pump(); + await _pumpFor(tester, const Duration(milliseconds: 1300)); + expect(manager.currentState.dialogue, 'after-entry'); + final history = manager.getDialogueHistory().last; + expect(history.dialogue, 'after-entry'); + expect( + history + .stateSnapshot + .currentState + .characters['alpha']! + .animationProperties?['xcenter'], + closeTo(0.2, 0.000001), + reason: 'History must keep the new slot even before the jump finishes', + ); + // Continue through the long jump's remaining frames and final callback. + await _pumpFor(tester, const Duration(seconds: 3)); + + _expectPositions(manager, {'alpha': 0.2, 'beta': 0.5, 'gamma': 0.8}); + final alpha = _renderedProperties(manager)['alpha']!; + expect(alpha['ycenter'], closeTo(0.7, 0.000001)); + expect(alpha['scale'], closeTo(1.3, 0.000001)); + }, + ); + + testWidgets( + 'conditional dialogue entrance respects a concurrent fade completion', + (tester) async { + final manager = await _createManager(tester); + await manager.startTestScript( + ScriptNode([ + HideNode('alpha'), + SayNode(dialogue: 'before-entry'), + ConditionalSayNode( + character: 'c', + dialogue: 'after-entry', + conditionVariable: 'position_test_disabled', + conditionValue: false, + ), + SayNode(dialogue: 'after-click'), + ]), + characterConfigs: _characterConfigs(), + poseConfigs: _poseConfigs(), + initialState: GameState( + characters: { + 'alpha': _character('alpha'), + 'beta': _character('beta'), + }, + ), + ); + manager.next(); + await tester.pump(); + await _pumpFor(tester, const Duration(milliseconds: 220)); + manager.removeCharacterAfterFadeOut('alpha'); + await _pumpFor(tester, const Duration(milliseconds: 1500)); + + expect(manager.currentState.dialogue, 'after-entry'); + _expectPositions(manager, {'beta': 0.25, 'gamma': 0.75}); + manager.next(); + await tester.pump(); + expect(manager.currentState.dialogue, 'after-click'); + }, + ); + + testWidgets( + 'disposing during entrance finishes the pending script without mutation', + (tester) async { + final manager = await _createManager(tester); + var executionCompleted = false; + final execution = manager + .startTestScript( + ScriptNode([ + ShowNode('beta', position: 'auto'), + SayNode(dialogue: 'must-not-display'), + ]), + characterConfigs: _characterConfigs(), + poseConfigs: _poseConfigs(), + initialState: GameState(characters: {'alpha': _character('alpha')}), + ) + .then((_) => executionCompleted = true); + + await tester.pump(); + await _pumpFor(tester, const Duration(milliseconds: 220)); + expect(executionCompleted, isFalse); + manager.dispose(); + final stateAtDisposal = manager.currentState; + await _pumpFor(tester, const Duration(milliseconds: 750)); + + expect(executionCompleted, isTrue); + await execution; + expect(manager.currentState, same(stateAtDisposal)); + expect(manager.currentState.dialogue, isNot('must-not-display')); + }, + ); + + testWidgets( + 'restarting during entrance ignores the old script continuation', + (tester) async { + final manager = await _createManager(tester); + var oldExecutionCompleted = false; + final oldExecution = manager + .startTestScript( + ScriptNode([ + ShowNode('beta', position: 'auto'), + SayNode(dialogue: 'old-dialogue'), + SayNode(dialogue: 'old-next'), + ]), + characterConfigs: _characterConfigs(), + poseConfigs: _poseConfigs(), + initialState: GameState(characters: {'alpha': _character('alpha')}), + ) + .then((_) => oldExecutionCompleted = true); + + await tester.pump(); + await _pumpFor(tester, const Duration(milliseconds: 220)); + expect(oldExecutionCompleted, isFalse); + await manager.startTestScript( + ScriptNode([ + SayNode(dialogue: 'replacement-dialogue'), + SayNode(dialogue: 'replacement-next'), + ]), + characterConfigs: _characterConfigs(), + poseConfigs: _poseConfigs(), + initialState: GameState(characters: {'gamma': _character('gamma')}), + ); + await _pumpFor(tester, const Duration(milliseconds: 750)); + + expect(oldExecutionCompleted, isTrue); + await oldExecution; + expect(manager.currentState.dialogue, 'replacement-dialogue'); + _expectPositions(manager, {'gamma': 0.5}); + manager.next(); + await tester.pump(); + expect(manager.currentState.dialogue, 'replacement-next'); + _expectPositions(manager, {'gamma': 0.5}); + }, + ); +} diff --git a/Engine/test/game_manager_choice_seek_test.dart b/Engine/test/game_manager_choice_seek_test.dart new file mode 100644 index 00000000..8157e720 --- /dev/null +++ b/Engine/test/game_manager_choice_seek_test.dart @@ -0,0 +1,443 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sakiengine/src/config/config_models.dart'; +import 'package:sakiengine/src/game/game_manager.dart'; +import 'package:sakiengine/src/sks_parser/sks_ast.dart'; +import 'package:sakiengine/src/utils/animation_manager.dart'; +import 'package:sakiengine/src/utils/global_variable_manager.dart'; + +final _characters = { + 'alice': CharacterConfig( + id: 'alice', + name: 'Alice', + resourceId: 'alice_front', + defaultPoseId: 'center', + slotId: 'alice', + ), + 'alice2': CharacterConfig( + id: 'alice2', + name: 'Alice', + resourceId: 'alice_side', + defaultPoseId: 'center', + slotId: 'alice', + ), +}; + +MenuNode _menu() => MenuNode([ChoiceOptionNode('Continue', 'after_choice')]); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const pathChannel = MethodChannel('plugins.flutter.io/path_provider'); + late Directory storage; + setUpAll(() async { + storage = await Directory.systemTemp.createTemp('saki-choice-seek-'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(pathChannel, (_) async => storage.path); + }); + tearDownAll(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(pathChannel, null); + await storage.delete(recursive: true); + }); + + test( + 'CG to scene restores cast, aliases, hides and per-line history', + () async { + final manager = GameManager(); + addTearDown(manager.dispose); + await manager.startTestScript( + ScriptNode([ + SayNode(dialogue: 'inside CG'), + BackgroundNode('street-night', transitionType: 'fade', timer: 30), + ShowNode('alice', pose: 'pose1', expression: 'sad', position: 'left'), + ShowNode('extra'), + SayNode(dialogue: 'on the street'), + HideNode('extra'), + SayNode( + character: 'alice2', + pose: 'pose2', + expression: 'happy', + position: 'right', + dialogue: 'choose', + ), + _menu(), + SayNode(dialogue: 'must not execute'), + ]), + characterConfigs: _characters, + initialState: GameState( + cgCharacters: {'__global_cg__': CharacterState(resourceId: 'old_cg')}, + ), + ); + final emitted = []; + final subscription = manager.gameStateStream.listen(emitted.add); + addTearDown(subscription.cancel); + await Future.delayed(Duration.zero); + emitted.clear(); + + expect( + await manager.jumpToNextChoice().timeout(const Duration(seconds: 2)), + isTrue, + ); + await Future.delayed(Duration.zero); + final state = manager.currentState; + expect(state.background, 'street-night'); + expect(state.cgCharacters, isEmpty); + expect(state.characters.keys, ['slot:alice']); + expect(state.characters['slot:alice']!.resourceId, 'alice_side'); + expect(state.characters['slot:alice']!.pose, 'pose2'); + expect(state.characters['slot:alice']!.expression, 'happy'); + expect(state.characters['slot:alice']!.positionId, 'right'); + expect(state.characters['slot:alice']!.isFadingOut, isFalse); + expect(emitted, hasLength(1)); + expect(emitted.single.currentNode, isA()); + expect(manager.currentScriptIndex, 7); + final history = manager.getDialogueHistory(); + expect(history.map((entry) => entry.dialogue), [ + 'inside CG', + 'on the street', + 'choose', + ]); + expect(history[0].stateSnapshot.currentState.cgCharacters, isNotEmpty); + expect(history[1].stateSnapshot.currentState.cgCharacters, isEmpty); + expect( + history[1].stateSnapshot.currentState.characters.keys, + containsAll(['slot:alice', 'extra']), + ); + expect(history.last.stateSnapshot.currentState.characters.keys, [ + 'slot:alice', + ]); + expect(manager.saveStateSnapshot().scriptIndex, 7); + }, + ); + + test( + 'empty scene auto-renders dialogue character and final timed expression', + () async { + final manager = GameManager(); + addTearDown(manager.dispose); + await manager.startTestScript( + ScriptNode([ + SayNode(dialogue: 'empty scene'), + SayNode( + character: 'alice', + dialogue: 'arrives', + pose: 'pose1', + startExpression: 'sad', + switchDelay: 30, + endExpression: 'happy', + ), + SayNode( + dialogue: 'prompt', + tailCharacter: 'alice', + tailPose: 'pose2', + tailExpression: 'smile', + ), + _menu(), + ]), + characterConfigs: _characters, + ); + expect(manager.currentState.characters, isEmpty); + expect(await manager.jumpToNextChoice(), isTrue); + expect(manager.currentState.characters['slot:alice']!.pose, 'pose2'); + expect( + manager.currentState.characters['slot:alice']!.expression, + 'smile', + ); + expect( + manager + .getDialogueHistory()[1] + .stateSnapshot + .currentState + .characters['slot:alice']! + .expression, + 'happy', + ); + }, + ); + + test( + 'destination CG is retained with its final variant and clears sprites', + () async { + final manager = GameManager(); + addTearDown(manager.dispose); + await manager.startTestScript( + ScriptNode([ + SayNode(dialogue: 'before CG'), + CgNode( + 'new_cg', + pose: 'pose1', + expression: 'sad', + transitionType: 'diss', + ), + SayNode(dialogue: 'CG line'), + CgNode('new_cg', pose: 'pose1', expression: 'happy'), + _menu(), + ]), + initialState: GameState( + background: 'street', + characters: {'alice': CharacterState(resourceId: 'alice_front')}, + ), + ); + expect(await manager.jumpToNextChoice(), isTrue); + expect(manager.currentState.background, isNull); + expect(manager.currentState.characters, isEmpty); + expect( + manager.currentState.cgCharacters.values.single.resourceId, + 'new_cg', + ); + expect( + manager.currentState.cgCharacters.values.single.expression, + 'happy', + ); + }, + ); + + test( + 'NVL, persistent overlays, scene animations and audio reach final state', + () async { + AnimationManager.loadAnimationsFromStringForTesting( + 'camera\nlinear 1 scale+0.5', + ); + addTearDown(AnimationManager.clearCache); + final manager = GameManager(); + addTearDown(manager.dispose); + await manager.startTestScript( + ScriptNode([ + SayNode(dialogue: 'before'), + PlayMusicNode('discarded'), + PlaySoundNode('discarded_loop', loop: true), + MovieNode('chapter_movie', timer: 30), + BackgroundNode('destination', animation: 'camera'), + FxNode('nostalgic'), + NvlNode(), + SayNode(dialogue: 'NVL line'), + EndNvlNode(), + CanvasNode('discarded_canvas'), + HideCanvasNode(), + AnimeNode('retained_overlay', keep: true, loop: true), + StopMusicNode(), + StopSoundNode(), + PlaySoundNode('destination_loop', loop: true), + SayNode(dialogue: 'prompt'), + _menu(), + ]), + ); + expect( + await manager.jumpToNextChoice().timeout(const Duration(seconds: 2)), + isTrue, + ); + final state = manager.currentState; + expect(state.movieFile, isNull); + expect(state.background, 'destination'); + expect(state.sceneAnimationProperties!['scale'], 1.5); + expect(state.sceneFilter, isNotNull); + expect(state.isNvlMode, isFalse); + expect(state.nvlDialogues, isEmpty); + expect(state.scriptCanvasId, isNull); + expect(state.animeOverlay, 'retained_overlay'); + expect(state.currentMusicRegion, isNull); + expect(manager.activeLoopingSoundsForTesting, { + 'Assets/sound/destination_loop.mp3', + }); + expect(manager.getDialogueHistory()[1].stateSnapshot.isNvlMode, isTrue); + expect(manager.isFastForwardMode, isFalse); + expect(state.isFastForwarding, isFalse); + }, + ); + + test( + 'bool assignments and project API conditions select the actual first menu', + () async { + final manager = GameManager( + onScriptApiExecute: + ({ + required apiName, + required params, + required gameState, + required scriptIndex, + }) async { + await GlobalVariableManager().setBoolVariable( + 'seek_api_route', + true, + ); + return ScriptApiExecutionResult.handled(); + }, + ); + addTearDown(manager.dispose); + await GlobalVariableManager().setBoolVariable('seek_api_route', false); + await manager.startTestScript( + ScriptNode([ + SayNode(dialogue: 'start'), + BoolNode('seek_route', true), + JumpNode( + 'api', + conditionVariable: 'seek_route', + conditionValue: true, + ), + ReturnNode(), + LabelNode('api'), + ApiCallNode('test.change_route'), + JumpNode( + 'choice', + conditionVariable: 'seek_api_route', + conditionValue: true, + ), + ReturnNode(), + LabelNode('choice'), + ConditionalSayNode( + dialogue: 'conditional prompt', + character: 'alice', + expression: 'happy', + conditionVariable: 'seek_route', + conditionValue: true, + ), + _menu(), + MenuNode([ChoiceOptionNode('wrong', 'wrong')]), + ]), + characterConfigs: _characters, + ); + expect(await manager.jumpToNextChoice(), isTrue); + expect(manager.currentScriptIndex, 10); + expect( + manager.currentState.characters['slot:alice']!.expression, + 'happy', + ); + expect(manager.getDialogueHistory().last.dialogue, 'conditional prompt'); + }, + ); + + test( + 'no reachable choice leaves state and variables untouched, including cycles', + () async { + for (final ending in >[ + [ReturnNode(), _menu()], + [LabelNode('loop'), JumpNode('loop'), _menu()], + [JumpNode('missing'), _menu()], + ]) { + final manager = GameManager(); + addTearDown(manager.dispose); + await GlobalVariableManager().setBoolVariable('seek_unchanged', false); + await manager.startTestScript( + ScriptNode([ + SayNode(dialogue: 'stay here'), + BoolNode('seek_unchanged', true), + BackgroundNode('must not be displayed'), + ...ending, + ]), + ); + final before = manager.currentState; + expect(await manager.jumpToNextChoice(), isFalse); + expect(manager.currentState, same(before)); + expect(manager.currentScriptIndex, 1); + expect( + GlobalVariableManager().getBoolVariableSync('seek_unchanged'), + isFalse, + ); + } + }, + ); + + test( + 'already running seek excludes duplicate input and disposal releases it', + () async { + final entered = Completer(); + final release = Completer(); + var calls = 0; + final manager = GameManager( + onScriptApiExecute: + ({ + required apiName, + required params, + required gameState, + required scriptIndex, + }) async { + calls++; + entered.complete(); + await release.future; + return ScriptApiExecutionResult.handled( + nextState: gameState.copyWith( + sceneTopRightStatusText: 'finished', + ), + ); + }, + ); + await manager.startTestScript( + ScriptNode([ + SayNode(dialogue: 'start'), + ApiCallNode('test.wait'), + _menu(), + ]), + ); + final seeking = manager.jumpToNextChoice(); + await entered.future; + expect(await manager.jumpToNextChoice(), isFalse); + manager.next(); + manager.dispose(); + release.complete(); + expect(await seeking, isFalse); + expect(calls, 1); + }, + ); + + test( + 'pending API wait resolves and old expression timer cannot overwrite arrival', + () async { + final manager = GameManager( + onScriptApiExecute: + ({ + required apiName, + required params, + required gameState, + required scriptIndex, + }) async { + return ScriptApiExecutionResult.handled( + nextState: gameState.copyWith(scriptOverlayText: 'waiting'), + waitDuration: const Duration(seconds: 30), + stateAfterWait: gameState.copyWith( + clearScriptOverlay: true, + sceneTopRightStatusText: 'resolved', + ), + ); + }, + ); + addTearDown(manager.dispose); + await manager.startTestScript( + ScriptNode([ + ApiCallNode('test.wait'), + SayNode(character: 'alice', dialogue: 'prompt', expression: 'happy'), + _menu(), + ]), + characterConfigs: _characters, + ); + expect(manager.currentState.scriptOverlayText, 'waiting'); + expect(await manager.jumpToNextChoice(), isTrue); + expect(manager.currentState.scriptOverlayText, isNull); + expect(manager.currentState.sceneTopRightStatusText, 'resolved'); + + await manager.startTestScript( + ScriptNode([ + SayNode( + character: 'alice', + dialogue: 'old timed line', + startExpression: 'old_start', + endExpression: 'old_end', + switchDelay: .03, + ), + SayNode( + character: 'alice', + dialogue: 'new prompt', + expression: 'new', + ), + _menu(), + ]), + characterConfigs: _characters, + ); + expect(await manager.jumpToNextChoice(), isTrue); + await Future.delayed(const Duration(milliseconds: 80)); + expect(manager.currentState.characters['slot:alice']!.expression, 'new'); + }, + ); +} diff --git a/Engine/test/game_manager_disposal_test.dart b/Engine/test/game_manager_disposal_test.dart new file mode 100644 index 00000000..0a813896 --- /dev/null +++ b/Engine/test/game_manager_disposal_test.dart @@ -0,0 +1,42 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sakiengine/src/game/game_manager.dart'; +import 'package:sakiengine/src/sks_parser/sks_ast.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('async script execution stops when its manager is disposed', () async { + final apiStarted = Completer(); + final releaseApi = Completer(); + final manager = GameManager( + onScriptApiExecute: + ({ + required apiName, + required params, + required gameState, + required scriptIndex, + }) async { + apiStarted.complete(); + await releaseApi.future; + return ScriptApiExecutionResult.handled(); + }, + ); + + final execution = manager.startTestScript( + ScriptNode([ + ApiCallNode('test.wait'), + NvlMovieNode(), + SayNode(dialogue: '不应在销毁后显示。'), + ]), + ); + + await apiStarted.future; + manager.dispose(); + releaseApi.complete(); + + await expectLater(execution, completes); + expect(manager.currentScriptIndex, 1); + }); +} diff --git a/Engine/test/game_manager_scene_timer_test.dart b/Engine/test/game_manager_scene_timer_test.dart new file mode 100644 index 00000000..06535bc3 --- /dev/null +++ b/Engine/test/game_manager_scene_timer_test.dart @@ -0,0 +1,173 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sakiengine/src/effects/scene_filter.dart'; +import 'package:sakiengine/src/game/game_manager.dart'; +import 'package:sakiengine/src/sks_parser/sks_ast.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + for (final sameBackground in [false, true]) { + for (final withFilter in [false, true]) { + testWidgets('${sameBackground ? 'same' : 'initial'} scene timer ' + '${withFilter ? 'with fx' : 'without fx'} holds then resumes once', ( + tester, + ) async { + final manager = GameManager(); + addTearDown(manager.dispose); + final nextIndex = withFilter ? 2 : 1; + await manager.startTestScript( + ScriptNode([ + BackgroundNode('sky', timer: 0.1), + if (withFilter) FxNode('nostalgic'), + SayNode(dialogue: 'after scene'), + SayNode(dialogue: 'next line'), + ]), + initialState: sameBackground + ? GameState.initial().copyWith(background: 'sky') + : null, + ); + + expect(manager.currentState.background, 'sky'); + expect(manager.currentScriptIndex, nextIndex); + expect(manager.currentState.dialogue, isNull); + if (withFilter) { + expect(manager.currentState.sceneFilter?.type, FilterType.nostalgic); + } + + await tester.pump(const Duration(milliseconds: 40)); + for (var i = 0; i < 10; i++) { + manager.next(); + } + await tester.pump(); + expect(manager.currentScriptIndex, nextIndex); + expect(manager.currentState.dialogue, isNull); + + // Repeated clicks must neither bypass nor restart the original timer. + await tester.pump(const Duration(milliseconds: 60)); + expect(manager.currentState.dialogue, 'after scene'); + expect(manager.currentScriptIndex, nextIndex + 1); + await tester.pump(const Duration(seconds: 1)); + expect(manager.currentState.dialogue, 'after scene'); + expect(manager.getDialogueHistory().map((entry) => entry.dialogue), [ + 'after scene', + ]); + + manager.next(); + await tester.pump(); + expect(manager.currentState.dialogue, 'next line'); + }); + } + } + + testWidgets('anime timer ignores repeated clicks and resumes once', ( + tester, + ) async { + final manager = GameManager(); + addTearDown(manager.dispose); + await manager.startTestScript( + ScriptNode([ + AnimeNode('intro', timer: 0.1), + SayNode(dialogue: 'after anime'), + SayNode(dialogue: 'next line'), + ]), + ); + expect(manager.currentState.animeOverlay, 'intro'); + expect(manager.currentScriptIndex, 1); + + await tester.pump(const Duration(milliseconds: 40)); + for (var i = 0; i < 10; i++) { + manager.next(); + } + await tester.pump(); + expect(manager.currentState.animeOverlay, 'intro'); + expect(manager.currentState.dialogue, isNull); + expect(manager.currentScriptIndex, 1); + + await tester.pump(const Duration(milliseconds: 60)); + expect(manager.currentState.dialogue, 'after anime'); + expect(manager.currentScriptIndex, 2); + await tester.pump(const Duration(seconds: 1)); + expect(manager.currentState.dialogue, 'after anime'); + expect(manager.getDialogueHistory().map((entry) => entry.dialogue), [ + 'after anime', + ]); + + manager.next(); + await tester.pump(); + expect(manager.currentState.animeOverlay, isNull); + expect(manager.currentState.dialogue, 'next line'); + }); + + for (final isAnime in [false, true]) { + SksNode timedNode() => isAnime + ? AnimeNode('intro', timer: 30) + : BackgroundNode('sky', timer: 30); + final nodeName = isAnime ? 'anime' : 'scene'; + + testWidgets('fast-forward completes active $nodeName timer once', ( + tester, + ) async { + final manager = GameManager(); + addTearDown(manager.dispose); + await manager.startTestScript( + ScriptNode([ + timedNode(), + SayNode(dialogue: 'after timer'), + SayNode(dialogue: 'next line'), + ]), + ); + manager.setFastForwardMode(true); + await tester.pump(); + expect(manager.currentState.dialogue, 'after timer'); + expect(manager.currentScriptIndex, 2); + manager.setFastForwardMode(false); + await tester.pump(const Duration(seconds: 31)); + expect(manager.currentState.dialogue, 'after timer'); + expect(manager.getDialogueHistory(), hasLength(1)); + }); + + testWidgets('fast-forward skips $nodeName timer before it starts', ( + tester, + ) async { + final manager = GameManager(); + addTearDown(manager.dispose); + await manager.startTestScript( + ScriptNode([ + SayNode(dialogue: 'before timer'), + timedNode(), + SayNode(dialogue: 'after timer'), + SayNode(dialogue: 'next line'), + ]), + ); + manager.setFastForwardMode(true); + manager.next(); + await tester.pump(); + expect(manager.currentState.dialogue, 'after timer'); + expect(manager.currentScriptIndex, 3); + manager.setFastForwardMode(false); + await tester.pump(const Duration(seconds: 31)); + expect(manager.currentState.dialogue, 'after timer'); + expect(manager.getDialogueHistory(), hasLength(2)); + }); + + testWidgets('replacing script cancels pending $nodeName timer', ( + tester, + ) async { + final manager = GameManager(); + addTearDown(manager.dispose); + await manager.startTestScript( + ScriptNode([timedNode(), SayNode(dialogue: 'old dialogue')]), + ); + await manager.startTestScript( + ScriptNode([ + SayNode(dialogue: 'new dialogue'), + SayNode(dialogue: 'must still await click'), + ]), + ); + await tester.pump(const Duration(seconds: 31)); + expect(manager.currentState.dialogue, 'new dialogue'); + expect(manager.currentScriptIndex, 1); + expect(manager.getDialogueHistory(), hasLength(1)); + }); + } +} diff --git a/Engine/test/mouse_parallax_bleed_test.dart b/Engine/test/mouse_parallax_bleed_test.dart new file mode 100644 index 00000000..308a98e4 --- /dev/null +++ b/Engine/test/mouse_parallax_bleed_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sakiengine/src/effects/mouse_parallax.dart'; + +void main() { + test('bleed scale covers the full parallax travel without distortion', () { + const viewport = Size(1280, 720); + const maxOffset = Offset(26, 16); + const depth = 0.55; + const padding = 1.0; + + final scale = resolveParallaxBleedScale( + viewportSize: viewport, + maxOffset: maxOffset, + depth: depth, + padding: padding, + ); + final horizontalBleed = viewport.width * (scale - 1.0) / 2.0; + final verticalBleed = viewport.height * (scale - 1.0) / 2.0; + + expect(scale, closeTo(1.02723, 0.00001)); + expect(horizontalBleed, greaterThanOrEqualTo(26 * depth + padding)); + expect(verticalBleed, greaterThanOrEqualTo(16 * depth + padding)); + }); + + testWidgets('reserved bleed remains while parallax input is disabled', ( + tester, + ) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + const MaterialApp( + home: MouseParallax( + enabled: false, + maxOffset: Offset(26, 16), + child: ParallaxAware( + depth: 0.55, + reserveBleed: true, + child: SizedBox.expand(key: ValueKey('cg')), + ), + ), + ), + ); + + final scaleTransforms = tester + .widgetList( + find.descendant( + of: find.byType(ParallaxAware), + matching: find.byType(Transform), + ), + ) + .where((transform) => transform.transform.getMaxScaleOnAxis() > 1.0) + .toList(); + + expect(scaleTransforms, hasLength(1)); + expect( + scaleTransforms.single.transform.getMaxScaleOnAxis(), + closeTo(1.02723, 0.00001), + ); + }); +} diff --git a/Engine/test/saki_audio_player_test.dart b/Engine/test/saki_audio_player_test.dart new file mode 100644 index 00000000..31a3d7c1 --- /dev/null +++ b/Engine/test/saki_audio_player_test.dart @@ -0,0 +1,282 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sakiengine/src/utils/saki_audio_player.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final channels = []; + final calls = []; + String? playerId; + bool autoPrepare = true; + bool failLoad = false; + + void mock(String name, Future Function(MethodCall) handler) { + final channel = MethodChannel(name); + channels.add(channel); + messenger.setMockMethodCallHandler(channel, handler); + } + + Future emit(String name, Map event) async { + await messenger.handlePlatformMessage( + name, + const StandardMethodCodec().encodeSuccessEnvelope(event), + (_) {}, + ); + await Future.delayed(Duration.zero); + } + + Future prepared() => emit('xyz.luan/audioplayers/events/$playerId', { + 'event': 'audio.onPrepared', + 'value': true, + }); + + Future linuxError() async { + await messenger.handlePlatformMessage( + 'xyz.luan/audioplayers/events/$playerId', + const StandardMethodCodec().encodeErrorEnvelope( + code: 'LinuxAudioError', + message: 'GStreamer could not decode the source', + ), + (_) {}, + ); + await Future.delayed(Duration.zero); + } + + setUp(() { + calls.clear(); + autoPrepare = true; + failLoad = false; + playerId = null; + mock( + 'erika_flutter/player', + (_) async => throw StateError('Audio must not use Erika'), + ); + mock( + 'com.ryanheise.audio_session', + (call) async => call.method == 'setActive' ? true : null, + ); + mock('com.ryanheise.just_audio.methods', (call) async { + calls.add(call); + if (call.method == 'init') { + playerId = (call.arguments as Map)['id'] as String; + mock('com.ryanheise.just_audio.events.$playerId', (_) async => null); + mock('com.ryanheise.just_audio.data.$playerId', (_) async => null); + mock('com.ryanheise.just_audio.methods.$playerId', (call) async { + calls.add(call); + if (call.method == 'load') { + await emit('com.ryanheise.just_audio.events.$playerId', { + 'processingState': 3, + 'updateTime': DateTime.now().millisecondsSinceEpoch, + 'updatePosition': 0, + 'bufferedPosition': 1000000, + 'duration': 1000000, + 'currentIndex': 0, + }); + return {'duration': 1000000}; + } + return {}; + }); + } + return {}; + }); + mock('xyz.luan/audioplayers', (call) async { + calls.add(call); + if (call.method == 'create') { + playerId = (call.arguments as Map)['playerId'] as String; + mock('xyz.luan/audioplayers/events/$playerId', (_) async => null); + } else if (call.method == 'setSourceUrl') { + if (failLoad) throw PlatformException(code: 'LinuxAudioError'); + if (autoPrepare) await prepared(); + } + return null; + }); + }); + + tearDown(() async { + await Future.delayed(Duration.zero); + for (final channel in channels) { + messenger.setMockMethodCallHandler(channel, null); + } + channels.clear(); + debugDefaultTargetPlatformOverride = null; + }); + + for (final platform in [ + TargetPlatform.windows, + TargetPlatform.macOS, + TargetPlatform.iOS, + TargetPlatform.android, + ]) { + test( + '$platform audio loads and plays through just_audio without a surface', + () async { + debugDefaultTargetPlatformOverride = platform; + final player = AudioPlayer(); + addTearDown(player.dispose); + await player.setLoopMode(LoopMode.one); + await player.setVolume(0.35); + await player.setUrl('file:///C:/Game/Assets/music/bgm_008.mp3'); + expect(player.processingState, ProcessingState.ready); + await player.play(); + expect(player.playing, isTrue); + expect(calls.any((call) => call.method == 'init'), isTrue); + final load = calls.singleWhere((call) => call.method == 'load'); + expect( + (load.arguments as Map)['audioSource']['uri'], + contains('bgm_008.mp3'), + ); + expect( + calls.where((call) => call.method == 'setVolume').last.arguments, + {'volume': 0.35}, + ); + expect( + calls.where((call) => call.method == 'setLoopMode').last.arguments, + {'loopMode': 1}, + ); + await player.pause(); + expect(player.playing, isFalse); + await player.stop(); + }, + ); + } + + test( + 'Linux waits for GStreamer preroll, then plays independently of EOF', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + autoPrepare = false; + final player = AudioPlayer(); + addTearDown(player.dispose); + await player.stop(); + expect(calls, isEmpty); + var loaded = false; + final loading = player + .setFilePath('/tmp/Game 音频/ui click.mp3') + .then((_) => loaded = true); + await Future.delayed(Duration.zero); + expect(loaded, isFalse); + expect(player.processingState, ProcessingState.loading); + final source = calls.singleWhere((call) => call.method == 'setSourceUrl'); + expect( + (source.arguments as Map)['url'], + Uri.file('/tmp/Game 音频/ui click.mp3').toString(), + ); + await prepared(); + await loading; + await player.play(); + expect(player.playing, isTrue); + expect(player.processingState, ProcessingState.ready); + await emit('xyz.luan/audioplayers/events/$playerId', { + 'event': 'audio.onComplete', + 'value': true, + }); + expect(player.playing, isFalse); + expect(player.processingState, ProcessingState.completed); + }, + ); + + test( + 'Linux native loops stay playing and stop/dispose are explicit', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + final player = AudioPlayer(); + await player.setLoopMode(LoopMode.one); + await player.setVolume(2); + await player.setUrl('https://example.invalid/music.mp3'); + await player.play(); + await emit('xyz.luan/audioplayers/events/$playerId', { + 'event': 'audio.onComplete', + 'value': true, + }); + expect(player.playing, isTrue); + expect( + calls.where((call) => call.method == 'setReleaseMode').last.arguments, + containsPair('releaseMode', 'ReleaseMode.loop'), + ); + expect( + calls.singleWhere((call) => call.method == 'setVolume').arguments, + containsPair('volume', 1.0), + ); + await player.pause(); + expect(player.playing, isFalse); + await player.stop(); + expect(player.processingState, ProcessingState.idle); + await player.dispose(); + await player.dispose(); + expect(calls.where((call) => call.method == 'dispose'), hasLength(1)); + }, + ); + + test( + 'Linux source errors and interrupted loads complete without hanging', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + final player = AudioPlayer(); + addTearDown(player.dispose); + failLoad = true; + await expectLater( + player.setUrl('invalid://audio'), + throwsA(isA()), + ); + expect(player.processingState, ProcessingState.idle); + failLoad = false; + autoPrepare = false; + final loading = player.setUrl('https://example.invalid/audio.mp3'); + final interrupted = expectLater(loading, throwsStateError); + await Future.delayed(Duration.zero); + await player.stop(); + await interrupted; + autoPrepare = true; + await player.setUrl('https://example.invalid/retry.mp3'); + expect(player.processingState, ProcessingState.ready); + }, + ); + + test('Linux decoder errors reach the load or playback listener', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + autoPrepare = false; + final player = AudioPlayer(); + addTearDown(player.dispose); + final loading = expectLater( + player.setUrl('file:///tmp/broken.mp3'), + throwsA(isA()), + ); + await Future.delayed(Duration.zero); + await linuxError(); + await loading; + expect(player.processingState, ProcessingState.idle); + + autoPrepare = true; + await player.setUrl('file:///tmp/music.mp3'); + await player.play(); + final playbackError = expectLater( + player.playbackEventStream, + emitsError(isA()), + ); + await linuxError(); + await playbackError; + expect(player.playing, isFalse); + expect(player.processingState, ProcessingState.idle); + }); + + test('Linux disposal interrupts pending preroll and rejects reuse', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + autoPrepare = false; + final player = AudioPlayer(); + final loading = expectLater( + player.setUrl('file:///tmp/music.mp3'), + throwsStateError, + ); + await Future.delayed(Duration.zero); + await player.dispose(); + await loading; + await expectLater(player.play(), throwsStateError); + await expectLater(player.setUrl('file:///tmp/music.mp3'), throwsStateError); + expect(player.playing, isFalse); + }); +} diff --git a/Engine/test/sks_parser_dialogue_punctuation_test.dart b/Engine/test/sks_parser_dialogue_punctuation_test.dart new file mode 100644 index 00000000..c0a709b0 --- /dev/null +++ b/Engine/test/sks_parser_dialogue_punctuation_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sakiengine/src/sks_parser/sks_ast.dart'; +import 'package:sakiengine/src/sks_parser/sks_parser.dart'; + +void main() { + test('dialogue punctuation is preserved exactly as authored', () { + final script = SksParser().parse(''' +x "「原稿有括号。」" +x "原稿没有括号。" +x "(内心话保持原样。)" +"旁白保持原样。" +'''); + + final dialogues = script.children + .whereType() + .map((node) => node.dialogue) + .toList(); + + expect(dialogues, ['「原稿有括号。」', '原稿没有括号。', '(内心话保持原样。)', '旁白保持原样。']); + }); +} diff --git a/Engine/windows/flutter/generated_plugin_registrant.cc b/Engine/windows/flutter/generated_plugin_registrant.cc index 1471105c..6a83c7d3 100644 --- a/Engine/windows/flutter/generated_plugin_registrant.cc +++ b/Engine/windows/flutter/generated_plugin_registrant.cc @@ -10,7 +10,7 @@ #include #include #include -#include +#include #include #include @@ -23,8 +23,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSteamworksPluginCApi")); HotkeyManagerWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi")); - MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); + JustAudioWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("JustAudioWindowsPlugin")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); WindowManagerPluginRegisterWithRegistrar( diff --git a/Engine/windows/flutter/generated_plugins.cmake b/Engine/windows/flutter/generated_plugins.cmake index 64c6d3cd..7d8b85be 100644 --- a/Engine/windows/flutter/generated_plugins.cmake +++ b/Engine/windows/flutter/generated_plugins.cmake @@ -7,7 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_avif_windows flutter_steamworks hotkey_manager_windows - media_kit_libs_windows_video + just_audio_windows screen_retriever_windows window_manager ) diff --git a/Game/SakiEngine/linux/flutter/generated_plugin_registrant.cc b/Game/SakiEngine/linux/flutter/generated_plugin_registrant.cc index 18debd62..df30d6d7 100644 --- a/Game/SakiEngine/linux/flutter/generated_plugin_registrant.cc +++ b/Game/SakiEngine/linux/flutter/generated_plugin_registrant.cc @@ -10,9 +10,7 @@ #include #include #include -#include #include -#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -28,15 +26,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); - g_autoptr(FlPluginRegistrar) media_kit_video_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); - media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); - g_autoptr(FlPluginRegistrar) volume_controller_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "VolumeControllerPlugin"); - volume_controller_plugin_register_with_registrar(volume_controller_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/Game/SakiEngine/linux/flutter/generated_plugins.cmake b/Game/SakiEngine/linux/flutter/generated_plugins.cmake index 49b011f3..0a868ce0 100644 --- a/Game/SakiEngine/linux/flutter/generated_plugins.cmake +++ b/Game/SakiEngine/linux/flutter/generated_plugins.cmake @@ -7,13 +7,12 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_steamworks hotkey_manager_linux media_kit_libs_linux - media_kit_video screen_retriever_linux - volume_controller window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + saki_native ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/Game/SakiEngine/linux/runner/my_application.cc b/Game/SakiEngine/linux/runner/my_application.cc index d974ec4c..cc50d86d 100644 --- a/Game/SakiEngine/linux/runner/my_application.cc +++ b/Game/SakiEngine/linux/runner/my_application.cc @@ -1,6 +1,8 @@ #include "my_application.h" #include +#include +#include #ifdef GDK_WINDOWING_X11 #include #endif @@ -14,6 +16,30 @@ struct _MyApplication { G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) +static gchar* read_bundle_setting(const gchar* file_name, + const gchar* fallback) { + gchar executable_path[PATH_MAX + 1] = {}; + const ssize_t length = + readlink("/proc/self/exe", executable_path, PATH_MAX); + if (length <= 0) { + return g_strdup(fallback); + } + executable_path[length] = '\0'; + g_autofree gchar* executable_dir = g_path_get_dirname(executable_path); + g_autofree gchar* setting_path = + g_build_filename(executable_dir, file_name, nullptr); + gchar* contents = nullptr; + if (!g_file_get_contents(setting_path, &contents, nullptr, nullptr)) { + return g_strdup(fallback); + } + g_strchomp(contents); + if (contents[0] == '\0') { + g_free(contents); + return g_strdup(fallback); + } + return contents; +} + // Called when first Flutter frame received. static void first_frame_cb(MyApplication* self, FlView* view) { gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); @@ -24,6 +50,8 @@ static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + g_autofree gchar* product_name = + read_bundle_setting("saki_product_name.txt", "SakiEngine"); // Use a header bar when running in GNOME as this is the common style used // by applications and is the setup most users will be using (e.g. Ubuntu @@ -45,11 +73,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "SakiEngine"); + gtk_header_bar_set_title(header_bar, product_name); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "SakiEngine"); + gtk_window_set_title(window, product_name); } gtk_window_set_default_size(window, 1280, 720); @@ -140,9 +168,15 @@ MyApplication* my_application_new() { // like GTK and desktop environments map this running application to its // corresponding .desktop file. This ensures better integration by allowing // the application to be recognized beyond its binary name. - g_set_prgname(APPLICATION_ID); + g_autofree gchar* application_id = + read_bundle_setting("saki_application_id.txt", APPLICATION_ID); + if (!g_application_id_is_valid(application_id)) { + g_clear_pointer(&application_id, g_free); + application_id = g_strdup(APPLICATION_ID); + } + g_set_prgname(application_id); return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, "flags", + "application-id", application_id, "flags", G_APPLICATION_NON_UNIQUE, nullptr)); } diff --git a/Game/SakiEngine/macos/Flutter/GeneratedPluginRegistrant.swift b/Game/SakiEngine/macos/Flutter/GeneratedPluginRegistrant.swift index 7abc1bea..4af2657e 100644 --- a/Game/SakiEngine/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/Game/SakiEngine/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,31 +6,23 @@ import FlutterMacOS import Foundation import audio_session +import erika_flutter import flutter_avif_macos import flutter_steamworks import hotkey_manager_macos import just_audio -import media_kit_libs_macos_video -import media_kit_video -import package_info_plus import screen_retriever_macos import sqflite_darwin -import volume_controller -import wakelock_plus import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + ErikaFlutterPlugin.register(with: registry.registrar(forPlugin: "ErikaFlutterPlugin")) FlutterAvifPlugin.register(with: registry.registrar(forPlugin: "FlutterAvifPlugin")) FlutterSteamworksPlugin.register(with: registry.registrar(forPlugin: "FlutterSteamworksPlugin")) HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) - MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) - MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) - FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) - VolumeControllerPlugin.register(with: registry.registrar(forPlugin: "VolumeControllerPlugin")) - WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/Game/SakiEngine/macos/Podfile.lock b/Game/SakiEngine/macos/Podfile.lock index 4e5d9d56..2a06a771 100644 --- a/Game/SakiEngine/macos/Podfile.lock +++ b/Game/SakiEngine/macos/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - erika_flutter (0.1.7): + - FlutterMacOS - flutter_avif_macos (0.0.1): - FlutterMacOS - flutter_steamworks (0.0.1): @@ -8,20 +10,18 @@ PODS: - hotkey_manager_macos (0.0.1): - FlutterMacOS - HotKey - - media_kit_libs_macos_video (1.0.4): - - FlutterMacOS - - media_kit_video (0.0.1): + - saki_native (0.0.1): - FlutterMacOS - screen_retriever_macos (0.0.1): - FlutterMacOS DEPENDENCIES: + - erika_flutter (from `Flutter/ephemeral/.symlinks/plugins/erika_flutter/macos`) - flutter_avif_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos`) - flutter_steamworks (from `Flutter/ephemeral/.symlinks/plugins/flutter_steamworks/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`) - - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) - - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) + - saki_native (from `Flutter/ephemeral/.symlinks/plugins/saki_native/macos`) - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) SPEC REPOS: @@ -29,6 +29,8 @@ SPEC REPOS: - HotKey EXTERNAL SOURCES: + erika_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/erika_flutter/macos flutter_avif_macos: :path: Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos flutter_steamworks: @@ -37,21 +39,19 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral hotkey_manager_macos: :path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos - media_kit_libs_macos_video: - :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos - media_kit_video: - :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos + saki_native: + :path: Flutter/ephemeral/.symlinks/plugins/saki_native/macos screen_retriever_macos: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos SPEC CHECKSUMS: + erika_flutter: 524bfa291bf2311d40eeef4cd12f572f3e7f1311 flutter_avif_macos: 9ed61d67adfbd6964eccb59971020fb55c31fc11 flutter_steamworks: 24d022b3907352a560fb585b4951e09ce9a031d0 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe - media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 - media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + saki_native: 22e7602119a1cf9184f56d663c3c45e01365be88 screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/Game/SakiEngine/pubspec.lock b/Game/SakiEngine/pubspec.lock index 4aef5ea2..6c8c6f49 100644 --- a/Game/SakiEngine/pubspec.lock +++ b/Game/SakiEngine/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" characters: dependency: transitive description: @@ -121,6 +129,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + erika_flutter: + dependency: transitive + description: + path: "../../third_party/erika_flutter" + relative: true + source: path + version: "0.1.7" exif: dependency: transitive description: @@ -267,6 +282,22 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_rust_bridge: + dependency: transitive + description: + name: flutter_rust_bridge + sha256: e87d6b9ee934dcd24a128ccb2bd91905d2d5fe5c06245d6a8f5477d4907a437a + url: "https://pub.dev" + source: hosted + version: "2.12.0" flutter_screenutil: dependency: transitive description: @@ -726,6 +757,13 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + saki_native: + dependency: transitive + description: + path: "../../Engine/packages/saki_native" + relative: true + source: path + version: "0.1.0" sakiengine: dependency: "direct main" description: diff --git a/Game/SakiEngine/windows/flutter/generated_plugin_registrant.cc b/Game/SakiEngine/windows/flutter/generated_plugin_registrant.cc index 287ed8f0..1471105c 100644 --- a/Game/SakiEngine/windows/flutter/generated_plugin_registrant.cc +++ b/Game/SakiEngine/windows/flutter/generated_plugin_registrant.cc @@ -6,16 +6,17 @@ #include "generated_plugin_registrant.h" +#include #include #include #include #include -#include #include -#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ErikaFlutterPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ErikaFlutterPluginCApi")); FlutterAvifWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterAvifWindowsPlugin")); FlutterSteamworksPluginCApiRegisterWithRegistrar( @@ -24,12 +25,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi")); MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); - MediaKitVideoPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); - VolumeControllerPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("VolumeControllerPluginCApi")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/Game/SakiEngine/windows/flutter/generated_plugins.cmake b/Game/SakiEngine/windows/flutter/generated_plugins.cmake index e0742702..64c6d3cd 100644 --- a/Game/SakiEngine/windows/flutter/generated_plugins.cmake +++ b/Game/SakiEngine/windows/flutter/generated_plugins.cmake @@ -3,17 +3,17 @@ # list(APPEND FLUTTER_PLUGIN_LIST + erika_flutter flutter_avif_windows flutter_steamworks hotkey_manager_windows media_kit_libs_windows_video - media_kit_video screen_retriever_windows - volume_controller window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + saki_native ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/Game/SakiEngine/windows/runner/main.cpp b/Game/SakiEngine/windows/runner/main.cpp index c83d1305..1668a421 100644 --- a/Game/SakiEngine/windows/runner/main.cpp +++ b/Game/SakiEngine/windows/runner/main.cpp @@ -2,9 +2,55 @@ #include #include +#include +#include + #include "flutter_window.h" #include "utils.h" +namespace { + +std::wstring Utf16FromUtf8(const std::string& value) { + if (value.empty()) return std::wstring(); + const int length = ::MultiByteToWideChar( + CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), nullptr, 0); + if (length <= 0) return std::wstring(); + std::wstring converted(length, L'\0'); + if (::MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), converted.data(), + length) <= 0) { + return std::wstring(); + } + return converted; +} + +std::wstring ReadBundleProductName() { + wchar_t executable_path[MAX_PATH] = {}; + const DWORD length = + ::GetModuleFileNameW(nullptr, executable_path, MAX_PATH); + if (length == 0 || length >= MAX_PATH) return L"SakiEngine"; + std::wstring setting_path(executable_path, length); + const size_t separator = setting_path.find_last_of(L"\\/"); + if (separator == std::wstring::npos) return L"SakiEngine"; + setting_path.resize(separator + 1); + setting_path.append(L"saki_product_name.txt"); + + std::ifstream input(setting_path, std::ios::binary); + if (!input) return L"SakiEngine"; + std::ostringstream contents; + contents << input.rdbuf(); + std::string value = contents.str(); + while (!value.empty() && + (value.back() == '\n' || value.back() == '\r' || value.back() == ' ')) { + value.pop_back(); + } + const std::wstring converted = Utf16FromUtf8(value); + return converted.empty() ? L"SakiEngine" : converted; +} + +} // namespace + int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t *command_line, _In_ int show_command) { // Attach to console when present (e.g., 'flutter run') or create a @@ -30,7 +76,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"SakiEngine", origin, size)) { + if (!window.Create(ReadBundleProductName(), origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true); diff --git a/Launcher/.flutter-plugins-dependencies b/Launcher/.flutter-plugins-dependencies index 235f8730..e8977d20 100644 --- a/Launcher/.flutter-plugins-dependencies +++ b/Launcher/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[],"android":[],"macos":[{"name":"screen_retriever_macos","path":"/Users/dfsteve/.pub-cache/hosted/pub.dev/screen_retriever_macos-0.2.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"window_manager","path":"/Users/dfsteve/.pub-cache/hosted/pub.dev/window_manager-0.5.1/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"screen_retriever_linux","path":"/Users/dfsteve/.pub-cache/hosted/pub.dev/screen_retriever_linux-0.2.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"window_manager","path":"/Users/dfsteve/.pub-cache/hosted/pub.dev/window_manager-0.5.1/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"screen_retriever_windows","path":"/Users/dfsteve/.pub-cache/hosted/pub.dev/screen_retriever_windows-0.2.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"window_manager","path":"/Users/dfsteve/.pub-cache/hosted/pub.dev/window_manager-0.5.1/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"screen_retriever","dependencies":["screen_retriever_linux","screen_retriever_macos","screen_retriever_windows"]},{"name":"screen_retriever_linux","dependencies":[]},{"name":"screen_retriever_macos","dependencies":[]},{"name":"screen_retriever_windows","dependencies":[]},{"name":"window_manager","dependencies":["screen_retriever"]}],"date_created":"2026-05-28 16:00:22.083951","version":"3.44.0","swift_package_manager_enabled":{"ios":false,"macos":true}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[],"android":[],"macos":[{"name":"screen_retriever_macos","path":"/Users/dfsteve/.pub-cache/hosted/pub.dev/screen_retriever_macos-0.2.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"window_manager","path":"/Users/dfsteve/.pub-cache/hosted/pub.dev/window_manager-0.5.1/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"screen_retriever_linux","path":"/Users/dfsteve/.pub-cache/hosted/pub.dev/screen_retriever_linux-0.2.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"window_manager","path":"/Users/dfsteve/.pub-cache/hosted/pub.dev/window_manager-0.5.1/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"screen_retriever_windows","path":"/Users/dfsteve/.pub-cache/hosted/pub.dev/screen_retriever_windows-0.2.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"window_manager","path":"/Users/dfsteve/.pub-cache/hosted/pub.dev/window_manager-0.5.1/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"screen_retriever","dependencies":["screen_retriever_linux","screen_retriever_macos","screen_retriever_windows"]},{"name":"screen_retriever_linux","dependencies":[]},{"name":"screen_retriever_macos","dependencies":[]},{"name":"screen_retriever_windows","dependencies":[]},{"name":"window_manager","dependencies":["screen_retriever"]}],"date_created":"2026-09-01 21:25:44.816849","version":"3.44.0","swift_package_manager_enabled":{"ios":false,"macos":true}} \ No newline at end of file diff --git a/Launcher/README.md b/Launcher/README.md index 578d7202..ca24a64d 100644 --- a/Launcher/README.md +++ b/Launcher/README.md @@ -7,7 +7,7 @@ - 扫描 `Game/*` 并选择默认项目 - GUI 创建新项目(非交互桥接 `scripts/create-new-project.js`) - 运行项目(支持内置控制台和系统终端两种模式) -- 发布构建(含 `.sks` 预编译与发布资源清单生成) +- 发布构建(构建前自动执行 `flutter clean`,再进行 `.sks` 预编译与发布资源清单生成) ## 启动方式 diff --git a/Launcher/lib/main.dart b/Launcher/lib/main.dart index 2bee27c6..5d289a9c 100644 --- a/Launcher/lib/main.dart +++ b/Launcher/lib/main.dart @@ -318,7 +318,9 @@ CompiledSksBundle? loadGeneratedCompiledSksBundle() { } List get _buildTargets { - if (Platform.isMacOS) return ['macos', 'ios', 'android', 'web']; + if (Platform.isMacOS) { + return ['macos', 'linux', 'windows', 'ios', 'android', 'web']; + } if (Platform.isLinux) return ['linux', 'android', 'web']; if (Platform.isWindows) return ['windows', 'android', 'web']; return _allBuildTargets; @@ -1925,6 +1927,26 @@ CompiledSksBundle? loadGeneratedCompiledSksBundle() { ); try { + _appendLog('正在清理旧构建产物,避免残留依赖进入新构建...'); + final cleanCode = await _runCommand( + executable: 'flutter', + arguments: const ['clean'], + workingDirectory: gameDir.path, + ); + if (cleanCode != 0) { + throw _TaskFailure('flutter clean 失败,已中止构建'); + } + final staleCleanPaths = [ + _joinPath(gameDir.path, 'build'), + _joinPath(gameDir.path, '.dart_tool'), + ].where((path) => Directory(path).existsSync()).toList(); + if (staleCleanPaths.isNotEmpty) { + throw _TaskFailure( + 'flutter clean 未完全清除旧构建,请关闭正在运行的游戏或占用文件后重试: ' + '${staleCleanPaths.join(', ')}', + ); + } + await _prepareProjectForExecution(game, generateIcons: false); final firstPubGet = await _runCommand( @@ -2009,12 +2031,37 @@ CompiledSksBundle? loadGeneratedCompiledSksBundle() { } } - final buildArgs = _buildArgsFor(platform, _buildMode, game); - final buildCode = await _runCommand( - executable: 'flutter', - arguments: buildArgs, - workingDirectory: gameDir.path, - ); + final useOfflineDesktopCrossCompiler = + Platform.isMacOS && (platform == 'linux' || platform == 'windows'); + final int buildCode; + if (useOfflineDesktopCrossCompiler) { + final crossArgs = [ + 'scripts/cross-desktop.js', + '--game-dir', + gameDir.path, + '--target', + platform, + if (_buildMode == BuildMode.showcase) ...[ + '--dart-define', + 'SAKI_SHOW_MODE=true', + '--dart-define', + 'SAKI_SHOWCASE_GAME_DIR=Game/$game', + ], + ]; + _appendLog('使用仓库内置离线目标包交叉编译 $platform'); + buildCode = await _runCommand( + executable: Platform.environment['SAKI_NODE_BIN'] ?? 'node', + arguments: crossArgs, + workingDirectory: _repoRoot.path, + ); + } else { + final buildArgs = _buildArgsFor(platform, _buildMode, game); + buildCode = await _runCommand( + executable: 'flutter', + arguments: buildArgs, + workingDirectory: gameDir.path, + ); + } if (buildCode != 0) { throw _TaskFailure('flutter build 失败'); diff --git a/README.md b/README.md index 768058f2..97d79efb 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,44 @@ flutter run -d macos --dart-define=SAKI_GAME_PATH="$PWD" - 不带 `[build]` 的提交不会触发构建 - 如果仓库不是独立游戏项目仓库,流程会自动跳过构建/发布 +#### macOS 单机交叉构建桌面发布版 + +SakiEngine 的命令行和 Launcher 支持在一台 macOS 设备上生成三种桌面发布包: + +- macOS:使用 Flutter/Xcode 原生构建。 +- Linux x64:使用仓库内置 Linux Runner、原生插件和 macOS AOT snapshotter。 +- Windows x64:使用仓库内置 Windows Runner、原生插件和 macOS AOT snapshotter。 + +Linux/Windows 交叉构建不会下载目标 Runner、Flutter Embedder、插件 DLL/SO 或 Erika +运行库;缺少文件或 SHA-256 不匹配时会直接失败。Erika 的 C API 产物随目标包进入仓库; +维护者也可在目标包工作流勾选 `erika_source_build`,使用 Erika 自身的 target/profile +交叉构建链从源码重建。构建机仍需预先具备清单指定的 Flutter SDK 和已经解析好的 Dart +依赖,目标包会严格检查 Flutter Engine revision,不能混用其他 Flutter 版本。 + +`erika_flutter` 桥接源码也已固定在 `third_party/erika_flutter`,其中包含经过 SHA-256 +校验的 macOS universal 与 Windows x64 Erika 运行库。普通桌面构建只会使用这些仓库内文件, +不会退回在线下载;维护者更新 Erika 时可运行 `scripts/vendor-erika-flutter.js` 重新导入桥接层 +与许可证文件。macOS 运行库同时包含 arm64/x86_64,Windows 与交叉目标包当前为 x64。 + +当前内置目标包架构为 Linux x64 和 Windows x64。游戏若新增桌面原生插件,构建器会拒绝 +复用旧 Runner,并提示维护者重新运行 +`.github/workflows/build-cross-target-packs.yml`,避免生成启动后缺插件的静默坏包。 + +命令行示例: + +```bash +# 以下三条命令均可在 macOS 上执行 +./build.sh SakiEngine macos +./build.sh SakiEngine linux +./build.sh SakiEngine windows +``` + +Launcher 在 macOS 上也会同时显示 macOS、Linux、Windows;Linux/Windows 自动进入离线 +交叉编译路径,输出位置与 Flutter 原生构建保持一致: + +- `Game/<项目>/build/linux/x64/release/bundle` +- `Game/<项目>/build/windows/x64/runner/Release` + #### 本地构建 ```bash diff --git a/scripts/build.js b/scripts/build.js index 7ac49ba6..75d3c0b0 100755 --- a/scripts/build.js +++ b/scripts/build.js @@ -7,6 +7,7 @@ const readline = require('readline'); const { spawnSync } = require('child_process'); const assetUtils = require('./asset-utils.js'); +const { buildCrossDesktop } = require('./cross-desktop.js'); const colors = { reset: '\x1b[0m', @@ -325,7 +326,7 @@ async function chooseGameProject() { async function choosePlatform() { const host = detectHostPlatform(); let options = []; - if (host === 'macos') options = ['macos', 'ios', 'android', 'web']; + if (host === 'macos') options = ['macos', 'linux', 'windows', 'ios', 'android', 'web']; else if (host === 'linux') options = ['linux', 'android', 'web']; else if (host === 'windows') options = ['windows', 'android', 'web']; else options = ['macos', 'linux', 'windows', 'android', 'ios', 'web']; @@ -462,7 +463,16 @@ async function main() { colorLog(`正在构建 ${platform} ...`, 'yellow'); if (platform === 'macos') runFlutter(['build', 'macos', '--release'], gameDir); - else if (platform === 'linux') runFlutter(['build', 'linux', '--release'], gameDir); + else if ( + detectHostPlatform() === 'macos' && + (platform === 'linux' || platform === 'windows') + ) { + buildCrossDesktop({ + gameDir, + target: platform, + flutterBin: process.env.SAKI_FLUTTER_BIN || 'flutter', + }); + } else if (platform === 'linux') runFlutter(['build', 'linux', '--release'], gameDir); else if (platform === 'windows') runFlutter(['build', 'windows', '--release'], gameDir); else if (platform === 'android') { runFlutter(['build', 'apk', '--release', '--target-platform', 'android-arm64'], gameDir); diff --git a/scripts/cross-desktop.js b/scripts/cross-desktop.js new file mode 100644 index 00000000..565fc189 --- /dev/null +++ b/scripts/cross-desktop.js @@ -0,0 +1,401 @@ +#!/usr/bin/env node + +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const repoRoot = path.dirname(__dirname); +const manifestPath = path.join(repoRoot, 'toolchains', 'cross', 'manifest.json'); + +function fail(message) { + throw new Error(`SakiEngine 交叉编译: ${message}`); +} + +function sanitizeBinaryName(value) { + return String(value || '') + .replace(/[^A-Za-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') || 'saki_game'; +} + +function readGameIdentity(gameDir) { + const configPath = path.join(gameDir, 'game_config.txt'); + if (!fs.existsSync(configPath)) { + return { + appName: path.basename(gameDir), + applicationId: 'com.sakiengine.game', + binaryName: sanitizeBinaryName(path.basename(gameDir)), + }; + } + const lines = fs + .readFileSync(configPath, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const appName = lines[0] || path.basename(gameDir); + return { + appName, + applicationId: lines[1] || 'com.sakiengine.game', + binaryName: sanitizeBinaryName(appName), + }; +} + +function resolveExecutable(command) { + if (!command) return null; + const hasSeparator = command.includes('/') || command.includes('\\'); + if (hasSeparator) { + const absolute = path.resolve(command); + return fs.existsSync(absolute) ? fs.realpathSync(absolute) : null; + } + const result = spawnSync('which', [command], { encoding: 'utf8' }); + if (result.status !== 0) return null; + const candidate = String(result.stdout || '').split(/\r?\n/).find(Boolean); + return candidate && fs.existsSync(candidate) ? fs.realpathSync(candidate) : null; +} + +function resolveFlutter(flutterBin = process.env.SAKI_FLUTTER_BIN || 'flutter') { + const executable = resolveExecutable(flutterBin); + if (!executable) { + fail('未找到 Flutter。请先安装项目要求的 Flutter SDK;交叉目标包不会联网下载 SDK。'); + } + const flutterRoot = path.dirname(path.dirname(executable)); + const engineStamp = path.join(flutterRoot, 'bin', 'cache', 'engine.stamp'); + if (!fs.existsSync(engineStamp)) { + fail(`Flutter Engine 标记不存在: ${engineStamp}`); + } + return { + executable, + flutterRoot, + engineRevision: fs.readFileSync(engineStamp, 'utf8').trim(), + }; +} + +function readManifest(filePath = manifestPath) { + if (!fs.existsSync(filePath)) { + fail(`仓库内置目标包清单不存在: ${filePath}`); + } + const manifest = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (manifest.schemaVersion !== 1 || !manifest.targets || !manifest.engineRevision) { + fail(`目标包清单格式无效: ${filePath}`); + } + return manifest; +} + +function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + const fd = fs.openSync(filePath, 'r'); + const buffer = Buffer.allocUnsafe(1024 * 1024); + try { + while (true) { + const read = fs.readSync(fd, buffer, 0, buffer.length, null); + if (read === 0) break; + hash.update(buffer.subarray(0, read)); + } + } finally { + fs.closeSync(fd); + } + return hash.digest('hex'); +} + +function verifyEmbeddedFiles(manifest, targetConfig) { + const required = new Set([ + manifest.snapshotter, + ...Object.keys(manifest.checksums || {}).filter((relative) => + relative.startsWith(`${targetConfig.template}/`), + ), + ]); + if (required.size < 2) { + fail('目标包校验清单为空;拒绝使用未校验的二进制。'); + } + for (const relative of required) { + const absolute = path.join(repoRoot, relative); + if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) { + fail(`内置二进制缺失: ${relative}`); + } + const expected = manifest.checksums[relative]; + if (!expected) { + fail(`内置二进制没有 SHA-256: ${relative}`); + } + const actual = sha256File(absolute); + if (actual !== expected) { + fail(`内置二进制校验失败: ${relative}`); + } + } +} + +function copyDirectory(source, destination) { + fs.mkdirSync(destination, { recursive: true }); + const entries = fs.readdirSync(source, { withFileTypes: true }); + for (const entry of entries) { + const from = path.join(source, entry.name); + const to = path.join(destination, entry.name); + if (entry.isDirectory()) { + copyDirectory(from, to); + } else if (entry.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(from), to); + } else if (entry.isFile()) { + fs.copyFileSync(from, to); + fs.chmodSync(to, fs.statSync(from).mode); + } + } +} + +function nativePluginsFor(gameDir, target) { + const dependenciesPath = path.join(gameDir, '.flutter-plugins-dependencies'); + if (!fs.existsSync(dependenciesPath)) { + fail('缺少 .flutter-plugins-dependencies;请先执行 flutter pub get。'); + } + const dependencies = JSON.parse(fs.readFileSync(dependenciesPath, 'utf8')); + const plugins = dependencies.plugins && dependencies.plugins[target]; + if (!Array.isArray(plugins)) return []; + return plugins + .filter((plugin) => plugin && plugin.native_build === true && plugin.dev_dependency !== true) + .map((plugin) => plugin.name) + .sort(); +} + +function validatePluginProfile(gameDir, target, targetConfig) { + const available = new Set(targetConfig.nativePlugins || []); + const required = nativePluginsFor(gameDir, target); + const missing = required.filter((name) => !available.has(name)); + if (missing.length > 0) { + fail( + `${target} 游戏包含目标包未预构建的原生插件: ${missing.join(', ')}。` + + '请重新运行仓库的离线目标包工作流。', + ); + } +} + +function run(executable, args, cwd, env = process.env) { + const result = spawnSync(executable, args, { + cwd, + env, + stdio: 'inherit', + }); + if (result.error) throw result.error; + if (result.status !== 0) { + fail(`命令失败 (${result.status}): ${executable} ${args.join(' ')}`); + } +} + +function writeLinuxCompilerProbe(outputDir) { + const find = (tool) => { + const result = spawnSync('xcrun', ['-f', tool], { encoding: 'utf8' }); + if (result.status !== 0) fail(`xcrun 无法找到 ${tool}`); + return String(result.stdout).trim(); + }; + const cacheDir = path.join(outputDir, 'linux', 'x64', 'release'); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync( + path.join(cacheDir, 'CMakeCache.txt'), + [ + `CMAKE_AR:FILEPATH=${find('ar')}`, + `CMAKE_CXX_COMPILER:FILEPATH=${find('clang++')}`, + `CMAKE_LINKER:FILEPATH=${find('ld')}`, + '', + ].join('\n'), + ); +} + +function withFlutterOverlay(flutter, manifest, target, callback) { + const engineCache = path.join(flutter.flutterRoot, 'bin', 'cache'); + const targetConfig = manifest.targets[target]; + const snapshotDirectory = + targetConfig.flutterSnapshotDirectory || `${targetConfig.targetPlatform}-release`; + const snapshotDestination = path.join( + engineCache, + 'artifacts', + 'engine', + snapshotDirectory, + 'gen_snapshot', + ); + const stampPath = path.join(engineCache, `${target}-sdk.stamp`); + const sourceSnapshot = path.join(repoRoot, manifest.snapshotter); + const backups = []; + + const overlay = (destination, sourceBuffer, mode) => { + const existed = fs.existsSync(destination); + backups.push({ + destination, + existed, + contents: existed ? fs.readFileSync(destination) : null, + mode: existed ? fs.statSync(destination).mode : null, + }); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, sourceBuffer); + if (mode) fs.chmodSync(destination, mode); + }; + + overlay(snapshotDestination, fs.readFileSync(sourceSnapshot), 0o755); + overlay(stampPath, Buffer.from(`${manifest.engineRevision}\n`, 'utf8')); + try { + return callback(); + } finally { + for (const backup of backups.reverse()) { + if (backup.existed) { + fs.writeFileSync(backup.destination, backup.contents); + if (backup.mode) fs.chmodSync(backup.destination, backup.mode); + } else { + fs.rmSync(backup.destination, { force: true }); + } + } + } +} + +function stageBundle({ gameDir, target, targetConfig, buildDir, identity }) { + const outputDir = path.join(gameDir, targetConfig.output); + const stagingDir = `${outputDir}.saki-staging-${process.pid}`; + fs.rmSync(stagingDir, { recursive: true, force: true }); + copyDirectory(path.join(repoRoot, targetConfig.template), stagingDir); + + const flutterAssets = path.join(buildDir, 'flutter_assets'); + const appSo = path.join(buildDir, 'app.so'); + if (!fs.existsSync(flutterAssets) || !fs.existsSync(appSo)) { + fail('Flutter AOT 输出不完整(缺少 flutter_assets 或 app.so)。'); + } + copyDirectory(flutterAssets, path.join(stagingDir, targetConfig.assetsDestination)); + const aotDestination = path.join(stagingDir, targetConfig.aotDestination); + fs.mkdirSync(path.dirname(aotDestination), { recursive: true }); + fs.copyFileSync(appSo, aotDestination); + + const originalRunner = path.join(stagingDir, targetConfig.runnerExecutable); + const runnerName = target === 'windows' ? `${identity.binaryName}.exe` : identity.binaryName; + const finalRunner = path.join(stagingDir, runnerName); + if (!fs.existsSync(originalRunner)) { + fail(`Runner 模板入口不存在: ${targetConfig.runnerExecutable}`); + } + if (originalRunner !== finalRunner) fs.renameSync(originalRunner, finalRunner); + if (target !== 'windows') fs.chmodSync(finalRunner, 0o755); + + fs.writeFileSync(path.join(stagingDir, 'saki_product_name.txt'), `${identity.appName}\n`); + fs.writeFileSync(path.join(stagingDir, 'saki_application_id.txt'), `${identity.applicationId}\n`); + + fs.rmSync(outputDir, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(outputDir), { recursive: true }); + fs.renameSync(stagingDir, outputDir); + return outputDir; +} + +function buildCrossDesktop({ + gameDir, + target, + dartDefines = [], + flutterBin = process.env.SAKI_FLUTTER_BIN || 'flutter', +}) { + if (process.platform !== 'darwin') { + fail('当前离线交叉目标包只支持 macOS 主机。'); + } + if (target !== 'linux' && target !== 'windows') { + fail(`不支持的交叉目标: ${target}`); + } + const absoluteGameDir = path.resolve(gameDir); + if (!fs.existsSync(path.join(absoluteGameDir, 'pubspec.yaml'))) { + fail(`无效 Flutter 游戏目录: ${absoluteGameDir}`); + } + + const manifest = readManifest(); + const targetConfig = manifest.targets[target]; + if (!targetConfig) fail(`仓库没有内置 ${target} 目标包。`); + const flutter = resolveFlutter(flutterBin); + if (flutter.engineRevision !== manifest.engineRevision) { + fail( + `Flutter Engine 版本不匹配。需要 ${manifest.flutterVersion} ` + + `(${manifest.engineRevision}),当前为 ${flutter.engineRevision}。`, + ); + } + verifyEmbeddedFiles(manifest, targetConfig); + validatePluginProfile(absoluteGameDir, target, targetConfig); + + const targetPlatform = targetConfig.targetPlatform; + const assembleOutput = path.join( + absoluteGameDir, + '.saki_cache', + 'cross', + targetPlatform, + ); + fs.rmSync(assembleOutput, { recursive: true, force: true }); + fs.mkdirSync(assembleOutput, { recursive: true }); + if (target === 'linux') writeLinuxCompilerProbe(assembleOutput); + + const env = { + ...process.env, + SAKI_CROSS_OFFLINE: '1', + FLUTTER_DISABLE_ANALYTICS: 'true', + // A missing embedded artifact must fail instead of silently reaching the network. + FLUTTER_STORAGE_BASE_URL: 'https://127.0.0.1:9', + }; + if (target === 'windows' && !env['PROGRAMFILES(X86)']) { + const emptyProgramFiles = path.join(repoRoot, '.saki_toolchain', 'empty-program-files-x86'); + fs.mkdirSync(emptyProgramFiles, { recursive: true }); + env['PROGRAMFILES(X86)'] = emptyProgramFiles; + } + const args = [ + '--no-version-check', + '--suppress-analytics', + 'assemble', + `--output=${assembleOutput}`, + `-dTargetPlatform=${targetPlatform}`, + '-dBuildMode=release', + '-dTargetFile=lib/main.dart', + '-dTrackWidgetCreation=false', + '-dTreeShakeIcons=true', + '-dDartObfuscation=false', + ...dartDefines.map((value) => `--dart-define=${value}`), + 'copy_assets', + 'aot_elf_release', + ]; + + withFlutterOverlay(flutter, manifest, target, () => { + run(flutter.executable, args, absoluteGameDir, env); + }); + + const buildIdPath = path.join(assembleOutput, '.last_build_id'); + if (!fs.existsSync(buildIdPath)) fail('Flutter 没有写入 .last_build_id。'); + const buildId = fs.readFileSync(buildIdPath, 'utf8').trim(); + const flutterBuildDir = path.join(absoluteGameDir, '.dart_tool', 'flutter_build', buildId); + const identity = readGameIdentity(absoluteGameDir); + const outputDir = stageBundle({ + gameDir: absoluteGameDir, + target, + targetConfig, + buildDir: flutterBuildDir, + identity, + }); + process.stdout.write(`SakiEngine 离线交叉构建完成: ${outputDir}\n`); + return outputDir; +} + +function parseCli(argv) { + const parsed = { dartDefines: [] }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--game-dir') parsed.gameDir = argv[++index]; + else if (arg === '--target') parsed.target = argv[++index]; + else if (arg === '--flutter') parsed.flutterBin = argv[++index]; + else if (arg === '--dart-define') parsed.dartDefines.push(argv[++index]); + else fail(`未知参数: ${arg}`); + } + if (!parsed.gameDir || !parsed.target) { + fail('用法: node scripts/cross-desktop.js --game-dir <目录> --target '); + } + return parsed; +} + +if (require.main === module) { + try { + buildCrossDesktop(parseCli(process.argv.slice(2))); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exit(1); + } +} + +module.exports = { + buildCrossDesktop, + nativePluginsFor, + readGameIdentity, + sanitizeBinaryName, + sha256File, + validatePluginProfile, +}; diff --git a/scripts/cross-desktop.test.js b/scripts/cross-desktop.test.js new file mode 100644 index 00000000..cfb34d39 --- /dev/null +++ b/scripts/cross-desktop.test.js @@ -0,0 +1,48 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const test = require('node:test'); + +const { + nativePluginsFor, + readGameIdentity, + sanitizeBinaryName, + validatePluginProfile, +} = require('./cross-desktop.js'); + +test('sanitizeBinaryName creates portable desktop executable names', () => { + assert.strictEqual(sanitizeBinaryName('樱花 Story! 2026'), 'Story_2026'); + assert.strictEqual(sanitizeBinaryName('---'), 'saki_game'); +}); + +test('readGameIdentity reads the two-line Saki config', () => { + const gameDir = fs.mkdtempSync(path.join(os.tmpdir(), 'saki-identity-')); + fs.writeFileSync(path.join(gameDir, 'game_config.txt'), 'My Game\ncom.aimes.my_game\n'); + assert.deepStrictEqual(readGameIdentity(gameDir), { + appName: 'My Game', + applicationId: 'com.aimes.my_game', + binaryName: 'My_Game', + }); +}); + +test('plugin profile rejects target native plugins absent from the embedded runner', () => { + const gameDir = fs.mkdtempSync(path.join(os.tmpdir(), 'saki-plugins-')); + fs.writeFileSync( + path.join(gameDir, '.flutter-plugins-dependencies'), + JSON.stringify({ + plugins: { + windows: [ + { name: 'base_plugin', native_build: true, dev_dependency: false }, + { name: 'game_plugin', native_build: true, dev_dependency: false }, + { name: 'dev_plugin', native_build: true, dev_dependency: true }, + ], + }, + }), + ); + assert.deepStrictEqual(nativePluginsFor(gameDir, 'windows'), ['base_plugin', 'game_plugin']); + assert.throws( + () => validatePluginProfile(gameDir, 'windows', { nativePlugins: ['base_plugin'] }), + /game_plugin/, + ); +}); diff --git a/scripts/import-cross-target-packs.js b/scripts/import-cross-target-packs.js new file mode 100644 index 00000000..9bea3c86 --- /dev/null +++ b/scripts/import-cross-target-packs.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.dirname(__dirname); + +function copyDirectory(source, destination) { + fs.mkdirSync(destination, { recursive: true }); + for (const entry of fs.readdirSync(source, { withFileTypes: true })) { + const from = path.join(source, entry.name); + const to = path.join(destination, entry.name); + if (entry.isDirectory()) copyDirectory(from, to); + else if (entry.isFile()) { + fs.copyFileSync(from, to); + fs.chmodSync(to, fs.statSync(from).mode); + } + } +} + +function sha256(filePath) { + const hash = crypto.createHash('sha256'); + const fd = fs.openSync(filePath, 'r'); + const buffer = Buffer.allocUnsafe(1024 * 1024); + try { + while (true) { + const count = fs.readSync(fd, buffer, 0, buffer.length, null); + if (count === 0) break; + hash.update(buffer.subarray(0, count)); + } + } finally { + fs.closeSync(fd); + } + return hash.digest('hex'); +} + +function listFiles(root) { + const files = []; + function walk(current) { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) walk(absolute); + else if (entry.isFile()) files.push(absolute); + } + } + walk(root); + return files.sort(); +} + +function readJson(filePath) { + if (!fs.existsSync(filePath)) throw new Error(`缺少工作流产物: ${filePath}`); + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function relativeToRepo(filePath) { + return path.relative(repoRoot, filePath).split(path.sep).join('/'); +} + +function main() { + const artifactRoot = path.resolve(process.argv[2] || ''); + if (!artifactRoot || !fs.existsSync(artifactRoot)) { + throw new Error('用法: node scripts/import-cross-target-packs.js '); + } + + const snapshotSource = path.join(artifactRoot, 'saki-cross-snapshotter'); + const linuxSource = path.join(artifactRoot, 'saki-cross-linux-x64'); + const windowsSource = path.join(artifactRoot, 'saki-cross-windows-x64'); + const snapshotMetadata = readJson(path.join(snapshotSource, 'metadata.json')); + const linuxMetadata = readJson(path.join(linuxSource, 'metadata.json')); + const windowsMetadata = readJson(path.join(windowsSource, 'metadata.json')); + + const packName = `${snapshotMetadata.flutterVersion}-${snapshotMetadata.engineRevision}`; + const packRoot = path.join(repoRoot, 'toolchains', 'cross', 'packs', packName); + fs.rmSync(packRoot, { recursive: true, force: true }); + fs.mkdirSync(packRoot, { recursive: true }); + copyDirectory(snapshotSource, path.join(packRoot, 'snapshotter')); + copyDirectory(linuxSource, path.join(packRoot, 'linux-x64')); + copyDirectory(windowsSource, path.join(packRoot, 'windows-x64')); + + const checksums = {}; + for (const filePath of listFiles(packRoot)) { + if (path.basename(filePath) === 'metadata.json') continue; + checksums[relativeToRepo(filePath)] = sha256(filePath); + } + + const rootRelative = relativeToRepo(packRoot); + const manifest = { + schemaVersion: 1, + flutterVersion: snapshotMetadata.flutterVersion, + engineRevision: snapshotMetadata.engineRevision, + host: 'darwin', + hostArchitectures: ['arm64', 'x64'], + flutterSnapshotDirectory: snapshotMetadata.flutterSnapshotDirectory, + snapshotter: `${rootRelative}/snapshotter/gen_snapshot`, + targets: { + linux: { + targetPlatform: linuxMetadata.targetPlatform, + flutterSnapshotDirectory: 'linux-x64-release', + template: `${rootRelative}/linux-x64/template`, + runnerExecutable: linuxMetadata.runnerExecutable, + nativePlugins: linuxMetadata.nativePlugins, + output: 'build/linux/x64/release/bundle', + assetsDestination: 'data/flutter_assets', + aotDestination: 'lib/libapp.so', + }, + windows: { + targetPlatform: windowsMetadata.targetPlatform, + flutterSnapshotDirectory: 'windows-x64-release', + template: `${rootRelative}/windows-x64/template`, + runnerExecutable: windowsMetadata.runnerExecutable, + nativePlugins: windowsMetadata.nativePlugins, + output: 'build/windows/x64/runner/Release', + assetsDestination: 'data/flutter_assets', + aotDestination: 'data/app.so', + }, + }, + checksums, + }; + const manifestPath = path.join(repoRoot, 'toolchains', 'cross', 'manifest.json'); + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + process.stdout.write(`已导入离线目标包: ${packRoot}\n`); + process.stdout.write(`文件数量: ${Object.keys(checksums).length}\n`); +} + +try { + main(); +} catch (error) { + process.stderr.write(`${error.message}\n`); + process.exit(1); +} diff --git a/scripts/vendor-erika-flutter.js b/scripts/vendor-erika-flutter.js new file mode 100644 index 00000000..c2f2adb4 --- /dev/null +++ b/scripts/vendor-erika-flutter.js @@ -0,0 +1,219 @@ +#!/usr/bin/env node + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const repoRoot = path.dirname(__dirname); +const destination = path.join(repoRoot, 'third_party', 'erika_flutter'); + +function fail(message) { + throw new Error(`Erika vendoring: ${message}`); +} + +function parseArgs(argv) { + const parsed = {}; + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index]; + const value = argv[++index]; + if (!value || !key.startsWith('--')) fail(`无效参数: ${key}`); + parsed[key.slice(2)] = path.resolve(value); + } + for (const required of ['source', 'erika-root', 'macos-runtime', 'windows-runtime']) { + if (!parsed[required]) fail(`缺少 --${required}`); + } + return parsed; +} + +function copyDirectory(source, target) { + fs.mkdirSync(target, { recursive: true }); + for (const entry of fs.readdirSync(source, { withFileTypes: true })) { + const from = path.join(source, entry.name); + const to = path.join(target, entry.name); + if (entry.isDirectory()) copyDirectory(from, to); + else if (entry.isFile()) { + fs.copyFileSync(from, to); + fs.chmodSync(to, fs.statSync(from).mode); + } + } +} + +function copyItem(sourceRoot, relative) { + const source = path.join(sourceRoot, relative); + const target = path.join(destination, relative); + if (!fs.existsSync(source)) fail(`源文件不存在: ${source}`); + if (fs.statSync(source).isDirectory()) copyDirectory(source, target); + else { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(source, target); + fs.chmodSync(target, fs.statSync(source).mode); + } +} + +function sha256(filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} + +function requireBinarySymbol(filePath, symbol) { + const binary = fs.readFileSync(filePath); + if (!binary.includes(Buffer.from(symbol, 'ascii'))) { + fail(`原生运行库缺少必需导出 ${symbol}: ${filePath}`); + } +} + +function patchMacosPodspec(expectedSha256) { + const podspecPath = path.join(destination, 'macos', 'erika_flutter.podspec'); + const podspec = fs.readFileSync(podspecPath, 'utf8'); + const prebuiltBranch = /elif \[ "\$USE_SOURCE_BUILD" != "1" \]; then\n\s+sh "\$PACKAGE_ROOT\/native\/prepare_apple_prebuilt\.sh"\s+macos macos "\$PREBUILT_ARCH" "\$DEST_DYLIB"\nelse/; + if (!prebuiltBranch.test(podspec)) { + fail('无法定位 macOS podspec 的预构建下载分支'); + } + const offlineBranch = `elif [ "$USE_SOURCE_BUILD" != "1" ]; then + SOURCE_DYLIB="$PACKAGE_ROOT/native/macos/liberika_capi.dylib" + if [ ! -f "$SOURCE_DYLIB" ]; then + echo "error: bundled Erika runtime is missing: $SOURCE_DYLIB" >&2 + exit 1 + fi + ACTUAL_SHA256="$(shasum -a 256 "$SOURCE_DYLIB" | awk '{print $1}')" + if [ "$ACTUAL_SHA256" != "${expectedSha256}" ]; then + echo "error: bundled Erika runtime checksum mismatch" >&2 + exit 1 + fi +else`; + fs.writeFileSync(podspecPath, podspec.replace(prebuiltBranch, offlineBranch)); +} + +function patchWindowsRuntimeBuild(expectedSha256) { + const cmakePath = path.join(destination, 'windows', 'build_erika_runtime.cmake'); + const cmake = fs.readFileSync(cmakePath, 'utf8'); + const marker = 'set(ERIKA_ARTIFACT_MANIFEST\n'; + const index = cmake.indexOf(marker); + if (index < 0) fail('无法定位 Windows Erika runtime 构建入口'); + const offlineBranch = `if(NOT "$ENV{ERIKA_FORCE_SOURCE_BUILD}" STREQUAL "1") + if(ERIKA_NATIVE_TARGET STREQUAL "x86_64-pc-windows-msvc") + set(ERIKA_BUNDLED_RUNTIME + "\${ERIKA_PACKAGE_ROOT}/native/windows/x64/erika_capi.dll") + set(ERIKA_BUNDLED_SHA256 "${expectedSha256}") + else() + set(ERIKA_BUNDLED_RUNTIME + "\${ERIKA_PACKAGE_ROOT}/native/windows/arm64/erika_capi.dll") + set(ERIKA_BUNDLED_SHA256 "") + endif() + if(NOT EXISTS "\${ERIKA_BUNDLED_RUNTIME}") + message(FATAL_ERROR + "Bundled Erika runtime is missing: \${ERIKA_BUNDLED_RUNTIME}. " + "SakiEngine desktop builds never download native binaries.") + endif() + file(SHA256 "\${ERIKA_BUNDLED_RUNTIME}" ERIKA_BUNDLED_ACTUAL_SHA256) + if(ERIKA_BUNDLED_SHA256 STREQUAL "" OR + NOT ERIKA_BUNDLED_ACTUAL_SHA256 STREQUAL ERIKA_BUNDLED_SHA256) + message(FATAL_ERROR "Bundled Erika runtime checksum mismatch") + endif() + get_filename_component(ERIKA_RUNTIME_DIR "\${ERIKA_RUNTIME_OUT}" DIRECTORY) + file(MAKE_DIRECTORY "\${ERIKA_RUNTIME_DIR}") + configure_file("\${ERIKA_BUNDLED_RUNTIME}" "\${ERIKA_RUNTIME_OUT}" COPYONLY) + message(STATUS "Erika: using bundled offline runtime -> \${ERIKA_RUNTIME_OUT}") + return() +endif() + +`; + fs.writeFileSync(cmakePath, `${cmake.slice(0, index)}${offlineBranch}${cmake.slice(index)}`); +} + +function gitRevision(erikaRoot) { + const result = spawnSync('git', ['-C', erikaRoot, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + }); + if (result.status !== 0) fail('无法读取 Erika Git revision'); + return result.stdout.trim(); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + requireBinarySymbol( + args['macos-runtime'], + 'erika_presenter_attach_flutter_texture', + ); + requireBinarySymbol( + args['windows-runtime'], + 'erika_presenter_windows_composition_swapchain_iunknown', + ); + const items = [ + 'CHANGELOG.md', + 'LICENSE', + 'README.md', + 'README.ja.md', + 'README.zh.md', + 'pubspec.yaml', + 'native_artifacts.properties', + 'android', + 'ios', + 'lib', + 'macos', + 'ohos', + 'tvos', + 'windows', + 'native/include', + ]; + + fs.rmSync(destination, { recursive: true, force: true }); + for (const item of items) copyItem(args.source, item); + fs.rmSync(path.join(destination, 'tvos', 'native'), { recursive: true, force: true }); + + const macosTarget = path.join(destination, 'native', 'macos', 'liberika_capi.dylib'); + const windowsTarget = path.join( + destination, + 'native', + 'windows', + 'x64', + 'erika_capi.dll', + ); + fs.mkdirSync(path.dirname(macosTarget), { recursive: true }); + fs.mkdirSync(path.dirname(windowsTarget), { recursive: true }); + fs.copyFileSync(args['macos-runtime'], macosTarget); + fs.copyFileSync(args['windows-runtime'], windowsTarget); + + const licensesTarget = path.join(destination, 'native', 'licenses'); + fs.mkdirSync(licensesTarget, { recursive: true }); + for (const entry of fs.readdirSync(path.join(args['erika-root'], 'packaging'))) { + if (entry === 'THIRD_PARTY_NOTICES.md' || entry.startsWith('LICENSE.')) { + fs.copyFileSync( + path.join(args['erika-root'], 'packaging', entry), + path.join(licensesTarget, entry), + ); + } + } + + patchMacosPodspec(sha256(macosTarget)); + patchWindowsRuntimeBuild(sha256(windowsTarget)); + const provenance = { + schemaVersion: 1, + packageVersion: + fs.readFileSync(path.join(destination, 'pubspec.yaml'), 'utf8') + .match(/^version:\s*(.+)$/m)?.[1]?.trim() || 'unknown', + erikaRevision: gitRevision(args['erika-root']), + runtimes: { + macosUniversal: { + path: 'native/macos/liberika_capi.dylib', + sha256: sha256(macosTarget), + }, + windowsX64: { + path: 'native/windows/x64/erika_capi.dll', + sha256: sha256(windowsTarget), + }, + }, + }; + fs.writeFileSync( + path.join(destination, 'VENDORED_FROM.json'), + `${JSON.stringify(provenance, null, 2)}\n`, + ); + process.stdout.write(`已内嵌 Erika Flutter 桥接层: ${destination}\n`); +} + +try { + main(); +} catch (error) { + process.stderr.write(`${error.message}\n`); + process.exit(1); +} diff --git a/third_party/erika_flutter/CHANGELOG.md b/third_party/erika_flutter/CHANGELOG.md new file mode 100644 index 00000000..a6583284 --- /dev/null +++ b/third_party/erika_flutter/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +## Unreleased + +- Made routine native playback diagnostics opt-in through + `ERIKA_DIAGNOSTICS=1` or the existing trace environment flags, keeping + normal application logs quiet while retaining explicit troubleshooting. +- Added a packed-alpha video mode that stores color and alpha side by side, + reconstructs premultiplied transparency in the GPU renderer, and propagates + the mode through the C ABI and Flutter platform integrations. +- Added a macOS `ErikaTextureVideoView` backed by IOSurface and Metal for + Flutter-composited opacity, clipping, transforms, and color filters without + per-frame CPU pixel readback. +- Added native macOS opacity and overlay compositing for transparent video + platform views when backdrop-aware blending is required. +- Added Windows DirectComposition presentation for transparent video, with a + premultiplied-alpha swap chain attached directly to the Flutter HWND and + native overlay blending/opacity instead of a covering popup window. +- Raised the native playback-rate ceiling to 16× for short-form video effects. + +## 0.1.7 + +- Published `erika_flutter` as a standalone pub.dev package with package-local + license, changelog, metadata, and runnable iOS and macOS examples. +- Made verified, version-pinned native bundles the default for Android, Apple + platforms, Windows, and OpenHarmony. +- Split Flutter Android runtimes by ABI so app builds download only the selected + architecture and omit native-embedder static libraries. +- Added explicit `ERIKA_FORCE_SOURCE_BUILD=1` source builds without silent + fallback when a prebuilt download or checksum fails. +- Added isolated package and cross-platform consumer validation in GitHub + Actions. + +## 0.1.6 + +- Added the ArtCNN C4F16 DS denoising and sharpening upscaler. +- Added source-aware SDR and EDR output selection on Apple platforms. +- Moved Android and macOS presentation work off the application UI thread. +- Exposed renderer resource status on Android, Windows, and OpenHarmony. +- Restored Windows system media controls and tightened the OpenHarmony bridge. + +See the [repository changelog](https://github.com/AimesSoft/Erika/blob/main/CHANGELOG.md) +for native engine and earlier release details. diff --git a/third_party/erika_flutter/LICENSE b/third_party/erika_flutter/LICENSE new file mode 100644 index 00000000..d0a1fa14 --- /dev/null +++ b/third_party/erika_flutter/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/third_party/erika_flutter/README.ja.md b/third_party/erika_flutter/README.ja.md new file mode 100644 index 00000000..72704459 --- /dev/null +++ b/third_party/erika_flutter/README.ja.md @@ -0,0 +1,332 @@ +# erika_flutter + +[中文](README.zh.md) | [English](README.md) | [日本語](README.ja.md) + +Erika メディア再生エンジン向けの Flutter plugin です。 + +この plugin は Dart を hot path から外します。 + +- Dart は低頻度の player command と event stream だけを公開します。 +- 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 出力を行います。 +- Erika は `ErikaPresenterHandle` を通じて playback、rendering、audio、timing、overlay を担当します。 + +## Video Surfaces + +フルプレイヤーの 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 モデルに従います。 + +標準的な Flutter platform view が必要な場合は `ErikaVideoView` を使います。Android の SDR video surface は native `TextureView` です。`ErikaOutputMode.extendedLinear` player は `PlatformViewLink`/Hybrid Composition の `SurfaceView` を作ります。scRGB を Flutter texture-layer composition に通さないためです。plugin は borrowed `Surface`、lifecycle、resize、audio focus、HDR eligibility、vsync tick を Erika に接続します。 + +## macOS Setup + +macOS CocoaPods build は既定で arm64+x86_64 universal dynamic library を生成します。依存 project は `ERIKA_MACOS_ARCHS=arm64`、`ERIKA_MACOS_ARCHS=x86_64`、または `ERIKA_MACOS_ARCHS=arm64,x86_64` で artifact architecture を選択できます。既定値は `universal` です。prebuilt mode は対応する `macos-arm64`、`macos-x64`、`macos-universal` archive を取得します。ローカル開発では plugin が `dlopen` で Erika を読み込み、`ERIKA_CAPI_DYLIB` で path を上書きできます。 + +dynamic library を build するには: + +```sh +cargo run -p xtask -- deps build --all --profile lgpl +cargo build -p erika_capi +``` + +## Prebuilt package と source build + +plugin は既定で現在の version に対応する `v0.1.7` native library を download し、SHA-256 を検証します。download または検証の失敗は明示的な error になり、source build へ暗黙に fallback しません。Erika checkout の local source を debug するときだけ `ERIKA_FORCE_SOURCE_BUILD=1` を設定してください。custom `ERIKA_PREBUILT_TAG` の single-ABI build には対応する `ERIKA_PREBUILT_SHA256` が必要です。Android multi-ABI build では `ERIKA_PREBUILT_SHA256_ARM64_V8A`、`ERIKA_PREBUILT_SHA256_ARMEABI_V7A`、`ERIKA_PREBUILT_SHA256_X86_64`、`ERIKA_PREBUILT_SHA256_X86` を ABI ごとに指定します。詳細は [release guide](https://github.com/AimesSoft/Erika/blob/main/docs/releasing.ja.md) を参照してください。 +Android は要求された ABI ごとに約 20–22MB の runtime archive だけを download し、4 ABI combined C API bundle や static library は取得しません。 + +source build の architecture は macOS では `ERIKA_MACOS_ARCHS=arm64|x86_64|universal`、Windows では `ERIKA_WINDOWS_ARCH=x64|arm64`、Android では `ERIKA_ANDROID_ABIS=arm64-v8a,armeabi-v7a,x86_64,x86` で選択します。native library を直接 build する場合、`xtask --target`、`ERIKA_NATIVE_TARGET`、`cargo build --target` は同じ target にしてください。詳細は [build guide](https://github.com/AimesSoft/Erika/blob/main/docs/building.ja.md) を参照してください。 + +macOS plugin は Now Playing を通じてタイトル、アーティスト、アルバム、artwork、再生状態、timeline を公開し、Remote Command Center からの再生、一時停止、停止、seek を処理します。 + +## iOS Setup + +iOS の CocoaPod script phase が、Xcode build 中に Erika の native dependency と C ABI static library を自動 build します。対応する iOS target の Rust toolchain が必要です。 + +- `rustup target add aarch64-apple-ios` + +host app では、Xcode の Signing & Capabilities で **Background Modes > Audio, AirPlay, and Picture in Picture** を有効にするか、`Info.plist` の `UIBackgroundModes` に `audio` を追加してください。player は Now Playing 情報と再生 control を Control Center に登録します。タイトル、アーティスト、アルバム、エンコード済み artwork bytes を表示するには `ErikaMediaMetadata` を指定してください。 + +バックグラウンド再生はデフォルトで無効です。バックグラウンドで音声を継続する場合は、`ErikaPlayer(allowBackgroundPlayback: true)` を指定してください。host app で上記の Background Mode が有効でない場合、この option を指定しても iOS はバックグラウンド再生の継続を保証しません。 + +```dart +final player = ErikaPlayer( + allowBackgroundPlayback: true, +); + +final artwork = await rootBundle.load('assets/cover.jpg'); +await player.open( + mediaUrl, + metadata: ErikaMediaMetadata( + title: 'タイトル', + artist: 'アーティスト', + album: 'アルバム', + artwork: artwork.buffer.asUint8List(), + ), +); +await player.play(); +``` + +`allowBackgroundPlayback` は player 作成時の option であり、native player の作成後には変更できません。`false` の場合、App がバックグラウンドに入ると再生を一時停止し、foreground に戻っても一時停止状態を維持します。`true` の場合、バックグラウンドでは動画 decode を停止して音声のみを継続し、App が active になると動画を再開します。Control Center から再生、一時停止、再生位置の変更ができます。artwork には raw pixel ではなく、JPEG や PNG など `UIImage` が対応する形式の完全な encoded image bytes を指定してください。 + +## System Media の前後移動 + +playlist app は active item に応じて system media panel の前へ・次へ button を有効に +できます。Erika 自身は次の media item を選択せず、 +`systemMediaNavigationRequested` event を発行するため、Dart を playlist の唯一の +source of truth にできます。active item が変わるたびに capability を更新してください。 + +```dart +import 'dart:async'; + +import 'package:erika_flutter/erika_flutter.dart'; + +class PlaylistController { + final ErikaPlayer player = ErikaPlayer(allowBackgroundPlayback: true); + final List<({String title, String url})> items = <({String title, String url})>[ + (title: 'エピソード 1', url: 'https://example.com/episode-1.mp4'), + (title: 'エピソード 2', url: 'https://example.com/episode-2.mp4'), + ]; + + StreamSubscription? subscription; + int index = 0; + bool switching = false; + + Future initialize() async { + subscription = player.events.listen((ErikaPlayerEvent event) async { + if (event.kind != ErikaEventKind.systemMediaNavigationRequested) { + return; + } + switch (event.systemMediaCommand) { + case ErikaSystemMediaCommand.previous: + await openAt(index - 1); + case ErikaSystemMediaCommand.next: + await openAt(index + 1); + case null: + break; + } + }); + await openAt(0); + } + + Future openAt(int newIndex) async { + if (switching || newIndex < 0 || newIndex >= items.length) { + return; + } + switching = true; + await player.setSystemMediaNavigation( + previousEnabled: false, + nextEnabled: false, + ); + try { + final item = items[newIndex]; + await player.open( + item.url, + metadata: ErikaMediaMetadata(title: item.title), + ); + await player.play(); + index = newIndex; + } finally { + switching = false; + await player.setSystemMediaNavigation( + previousEnabled: index > 0, + nextEnabled: index + 1 < items.length, + ); + } + } + + Future dispose() async { + await subscription?.cancel(); + await player.dispose(); + } +} +``` + +capability は既定で無効で、iOS、tvOS、macOS、Android、Windows、HarmonyOS に対応します。 +item の切り替え中は両方の button を一時的に無効化して重複 request を拒否し、切り替え +成功後に index、metadata、capability を更新してください。この API が通知するのは +`previous` と `next` のみです。再生、一時停止、停止、seek は引き続き各 platform の +native system-media integration が直接処理します。 + +## tvOS Setup + +tvOS の CocoaPod script phase が、Apple TV 実機または simulator 向けの native +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` も指定できます。必要なもの: + +- 対応する MSVC target の Rust toolchain(`rustup target add x86_64-pc-windows-msvc` または `rustup target add aarch64-pc-windows-msvc`) +- Visual Studio Build Tools の x64/ARM64 C++ tools + Windows SDK +- `third_party/dist//` に build 済みの native dependency(リポジトリの `xtask deps build` フロー) + +plugin が Erika checkout を自動検出できない場合は `ERIKA_REPO_ROOT` を設定してください。 + +Windows plugin は System Media Transport Controls(SMTC)を通じてタイトル、アーティスト、アルバム、artwork、再生状態、timeline を公開し、system の再生、一時停止、seek を処理します。C++/WinRT を含む Windows SDK が必要で、必要な WinRT system library は plugin が自動的に link します。 + +## Android Setup + +Android Gradle build は Erika の `xtask` で native dependency を構築し、選択した ABI 向けに Cargo で `erika_capi` を build します。Android API 26 以降、Android NDK、対応する Rust target が必要です。生成される `jniLibs` には `liberika_capi.so` と ABI に対応する NDK の `libc++_shared.so` が含まれます。既定は arm64 と x86_64 で、`-PerikaAndroidAbis=arm64-v8a,x86_64` または `ERIKA_ANDROID_ABIS` で変更できます。 + +Android の `content://` media/subtitle URI は `ContentResolver` で開いて detach し、provider の offset/length を含む所有権付き `fd://` source として Erika に渡します。 + +Android は MediaSession と media notification を使って lock screen、Bluetooth、system media control に接続します。`allowBackgroundPlayback: true` の場合は `mediaPlayback` foreground Service を起動し、video decode を停止したままバックグラウンドで音声を継続します。plugin Manifest には foreground service と Android 13+ の notification permission が宣言されていますが、host app は product flow に応じて `POST_NOTIFICATIONS` runtime permission を要求する必要があります。permission が拒否された場合も media session は動作しますが、notification の表示は Android version と system policy に依存します。 + +Android minimum は API 26 のままです。Extended-linear は native-window dataspace API +(API 28+)も必要で、API 26/27 は SDR playback を継続して該当 fallback を報告します。 +API 34+ では plugin が `Display.registerHdrSdrRatioChangedListener` を監視し、実際の ratio +change を Erika に publish します。wgpu は surface を reattach せず後続 frame target と +output status を更新します。API 35 では host の global Window を変更せず、`SurfaceView` +ごとに desired HDR headroom も設定します。 + +## HarmonyOS Setup + +HarmonyOS module には DevEco Studio の OpenHarmony Native SDK が必要です。CMake は +既定で `liberika_capi.so` を download、検証し、`liberika_flutter.so` と一緒に package +します。Rust の `aarch64-unknown-linux-ohos` target は +`ERIKA_FORCE_SOURCE_BUILD=1` を明示した source build でのみ必要です。 + +HarmonyOS は AVSession を通じて metadata、artwork、再生状態、位置、再生速度を公開し、system の再生、一時停止、停止、seek command を処理します。 + +HarmonyOS では `ErikaVideoView` を使ってください。Flutter external texture を登録し、 +その texture surface を `OHNativeWindow` として取得して、wgpu Vulkan で描画します。 +音声は OHAudio の interleaved f32 PCM です。 + +video decode は既定で HarmonyOS AVCodec の hardware decode(H.264 / HEVC)です。 +AVCodec は Surface に描画し、その `OHNativeBuffer` を Vulkan external image として +import して Vulkan YCbCr sampler で解決するため、フレームは CPU コピーなしで +compositor に届きます。必要な Vulkan extension を持たない端末は FFmpeg software +decode と CPU upload に fallback します。fallback は再生を失敗させず、 +`VideoDecoderChanged` event と presenter diagnostics から報告されます。 + +## HTTP ヘッダー + +HTTP(S) video を再生する場合は、`httpHeaders` で request header を渡せます: + +```dart +await player.open( + 'https://example.com/video.mp4', + httpHeaders: { + 'Authorization': 'Bearer token', + 'Referer': 'https://example.com/', + }, +); +``` + +header は HEAD、Range GET、prefetch request とともに送信され、HTTP(S) URL にだけ適用されます。 +`content://` と local file の再生では header は無視されます。Authorization や Cookie などの +機密値を application log に出力しないでください。 + +playback engine 自身が生成する header は merge されず reject されます:`Range`、`Host`、 +`Content-Length`、`Transfer-Encoding`、`Connection`(大文字小文字を区別しない)は `open` を +throw させます。HTTP field として不正な名前や値も同様です。同梱の native library が +0.1.3 以前の prebuilt(HTTP header 対応より前)の場合、header 付きの `open` は黙って +header を捨てずに throw します。 + +header が適用されるのは media source だけです。外部 subtitle track と danmaku sidecar は +まだ header なしで取得されます。 + +## Output Mode + +`ErikaPlayer()` は Apple plugin に現在の screen と environment から SDR か Apple EDR を +選ばせ、Android は SDR が default です。Dart から Apple EDR を強制するには: + +```dart +final player = ErikaPlayer( + outputMode: ErikaOutputMode.appleEdr, + edrHeadroom: 4.0, +); +``` + +`ErikaOutputMode.sdr` で SDR 出力を強制できます。 + +Android の high-headroom mode は FP16 **extended-linear scRGB** で、HDR10/PQ ではありません。 + +```dart +final player = ErikaPlayer( + outputMode: ErikaOutputMode.extendedLinear, + edrHeadroom: 4.0, +); +``` + +`edrHeadroom` は content-headroom ceiling です。extended-linear player で省略すると Erika +は default 4x content ceiling を使い、`SurfaceView` の desired headroom は `0`(system auto) +になります。明示値は API 35 の per-`SurfaceView` desired headroom にも使います。current +display HDR/SDR ratio が available なら wgpu effective target をさらに制限します。 + +display/surface が HDR capable、view が Hybrid Composition `SurfaceView`、wgpu が Vulkan、 +surface が `Rgba16Float` を公開し、configured native window の readback が +`ADATASPACE_SCRGB_LINEAR`(`406913024`、`0x18410000`)の場合だけ active になります。 +GLES、`TextureView`、FP16 不在、dataspace verification failure は SDR に明示 fallback します。 +Android scRGB は BT.709 primaries、`1.0 = 80 nit` で、PQ/HDR10 metadata は使いません。 + +request ではなく negotiated state を必ず確認します。 + +```dart +final status = await player.getOutputStatus(); +if (!status.extendedLinearActive) { + debugPrint( + 'Erika output fallback: ' + '${status.fallbackReason.label} (${status.fallbackReason.nativeValue})', + ); +} +``` + +`ErikaOutputStatus` の 13 field は `requestedMode`、`activeEncoding`、 +`surfaceFormat`、`nativeDataSpace`、`requestedHeadroom`、`activeHeadroom`、 +`activeHeadroomKnown`、`extendedLinearActive`、`fallbackReason`、 +`fallbackCount`、`dataSpaceFailures`、`headroomUpdates`、 +`extendedLinearFrames` です。active Android scRGB は +`androidExtendedLinearScRgb + sixteenBitFloat + nativeDataSpace 406913024` です。 +API 34+ で Android が valid ratio を公開すると、`activeHeadroom` は current display HDR/SDR +ratio、`activeHeadroomKnown` は true です。ratio unavailable の場合、この値は fallback +のみで `activeHeadroomKnown` は false です。known state または ratio が実際に変わった +場合だけ `headroomUpdates` が増え、duplicate listener notification は無視されます。 + +`ErikaOutputFallbackReason` は stable ABI code です。 + +| Code | Dart value | Stable label | +|------|------------|--------------| +| 0 | `none` | `none` | +| 1 | `displayHdrUnsupported` | `display_hdr_unsupported` | +| 2 | `hybridCompositionRequired` | `hybrid_composition_required` | +| 3 | `wgpuBackendNotVulkan` | `wgpu_backend_not_vulkan` | +| 4 | `rgba16FloatSurfaceFormatUnavailable` | `rgba16float_surface_format_unavailable` | +| 5 | `nativeWindowDataSpaceApiUnavailable` | `native_window_dataspace_api_unavailable` | +| 6 | `scrgbDataSpaceVerificationFailed` | `scrgb_dataspace_verification_failed` | +| 7 | `surfaceConfigureFailed` | `surface_configure_failed` | +| 8 | `legacyAppleEdrUnsupported` | `legacy_apple_edr_unsupported` | + +`player.screenshot()` は current composited frame(video + subtitle + danmaku)の raw SDR +RGBA8 を返し、display が Apple EDR / Android extended-linear の場合も SDR のままです。 +Metal と Android/wgpu は capture 実装済みですが、現在の Windows D3D11 Flutter path は +screenshot byte を返しません。 + +non-HDR emulator/device coverage は明示的 SDR fallback と reason を検証します。Active +extended-linear はまだ実機検証済みとは claim せず、API 35 HDR device で +`Rgba16Float + SCRGB_LINEAR`、live HDR/SDR-ratio update、rotation/background recovery、 +multiple player、SDR screenshot の acceptance が必要です。 + +## Upscaler + +作成時に ArtCNN を選択することも、runtime で切り替えることもできます。 + +```dart +final player = ErikaPlayer(upscaler: ErikaUpscalerMode.artCnnC4F16Ds); +await player.setUpscaler(ErikaUpscalerMode.artCnnC4F16Ds); +``` + +`ErikaUpscalerMode.off` で無効化します。`player.getUpscalerStatus()` では要求モード、実行 backend、fallback 回数、upscaled frame 数、最近の GPU timing を確認できます。Apple は Metal、Android は planar と MediaCodec Surface frame の両方で wgpu/Vulkan compute を使います。GLES 3.0 は通常再生を維持し、明示的な `inactive` fallback を報告します。 diff --git a/third_party/erika_flutter/README.md b/third_party/erika_flutter/README.md new file mode 100644 index 00000000..ef3404e5 --- /dev/null +++ b/third_party/erika_flutter/README.md @@ -0,0 +1,411 @@ +# erika_flutter + +Flutter plugin for the Erika media playback engine. + +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/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`. +- The HarmonyOS plugin registers a Flutter external texture, attaches its + `OHNativeWindow` to Erika, and uses OHAudio for low-latency PCM output. +- Erika owns playback, rendering, audio, timing, and overlays through + `ErikaPresenterHandle`. + +## Video Surfaces + +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. + +On Windows `ErikaWindowOverlayVideoView` hosts a window-level Direct3D 11 +swapchain as a sibling surface, following the same overlay model. + +Use `ErikaVideoView` when a standard Flutter platform view is required for a +small embedder, compatibility path, or diagnostics. + +On Android the SDR video surface is a native `TextureView`. An +`ErikaOutputMode.extendedLinear` player instead creates a `SurfaceView` through +`PlatformViewLink`/Hybrid Composition, because scRGB must bypass Flutter's +texture-layer composition. The plugin forwards the borrowed `Surface` to Erika +and handles creation, resize, destruction, audio focus, HDR eligibility, and +vsync ticks. + +## macOS Setup + +The macOS plugin's podspec build phase downloads and bundles a verified, +codesigned +`liberika_capi.dylib`. It defaults to an arm64+x86_64 universal library. A +consuming project can set `ERIKA_MACOS_ARCHS=arm64`, `x86_64`, or +`arm64,x86_64`; `universal` remains the default. Prebuilt mode selects the +matching `macos-arm64`, `macos-x64`, or `macos-universal` archive. At runtime +the plugin loads the library via `dlopen`. + +The macOS plugin publishes title, artist, album, artwork, playback state, and +timeline through Now Playing, and handles system play, pause, stop, and seek +commands through Remote Command Center. + +Overrides: `ERIKA_CAPI_DYLIB` forces the runtime dylib path; `ERIKA_MACOS_CAPI_DYLIB` +points the build phase at an explicit dylib to bundle instead of building. + +## Native binaries + +The plugin downloads the matching `v0.1.7` native runtime by default on macOS, +Windows, iOS, tvOS, Android, and OpenHarmony. Every archive is pinned by SHA-256; +a missing or invalid archive fails with an explicit error instead of silently +requiring a Rust, FFmpeg, or NDK toolchain. See the +[release guide](https://github.com/AimesSoft/Erika/blob/main/docs/releasing.md). +Android downloads one approximately 20–22 MB runtime archive per requested ABI; +it does not fetch the four-ABI C API bundle or its static libraries. + +When debugging local Erika changes from an Erika checkout, set +`ERIKA_FORCE_SOURCE_BUILD=1`. A custom `ERIKA_PREBUILT_TAG` must be accompanied +by the matching `ERIKA_PREBUILT_SHA256`. A custom multi-ABI Android build uses +the per-ABI variables `ERIKA_PREBUILT_SHA256_ARM64_V8A`, +`ERIKA_PREBUILT_SHA256_ARMEABI_V7A`, `ERIKA_PREBUILT_SHA256_X86_64`, and +`ERIKA_PREBUILT_SHA256_X86` instead. + +For source builds, select macOS with `ERIKA_MACOS_ARCHS=arm64|x86_64|universal`, +Windows with `ERIKA_WINDOWS_ARCH=x64|arm64`, and Android with +`ERIKA_ANDROID_ABIS=arm64-v8a,armeabi-v7a,x86_64,x86`. For direct native builds, +`xtask --target`, `ERIKA_NATIVE_TARGET`, and `cargo build --target` must name the +same target. See the +[build guide](https://github.com/AimesSoft/Erika/blob/main/docs/building.md). + +## iOS Setup + +The iOS CocoaPod script phase downloads the verified C ABI static library. +Rust is required only for an explicit source build. + +The host app must enable **Background Modes > Audio, AirPlay, and Picture in Picture** under Xcode's Signing & Capabilities, or add `audio` to `UIBackgroundModes` in `Info.plist`. The player registers Now Playing metadata and playback controls with Control Center. Pass an `ErikaMediaMetadata` value to provide the title, artist, album, and encoded artwork bytes. + +Background playback is disabled by default. Create the player with `ErikaPlayer(allowBackgroundPlayback: true)` to keep audio playing in the background. iOS does not guarantee continued background playback unless the host app also enables the Background Mode described above. + +```dart +final player = ErikaPlayer( + allowBackgroundPlayback: true, +); + +final artwork = await rootBundle.load('assets/cover.jpg'); +await player.open( + mediaUrl, + metadata: ErikaMediaMetadata( + title: 'Title', + artist: 'Artist', + album: 'Album', + artwork: artwork.buffer.asUint8List(), + ), +); +await player.play(); +``` + +`allowBackgroundPlayback` is a player creation option and cannot be changed after the native player has been created. When it is `false`, playback pauses as the app enters the background and remains paused on return. When it is `true`, video decoding is suspended while audio continues in the background, and video resumes when the app becomes active. Control Center supports play, pause, and position changes. Artwork must contain complete encoded image bytes in a format supported by `UIImage`, such as JPEG or PNG, rather than raw pixels. + +## System Media Navigation + +Playlist apps can enable the system previous and next buttons for the active +item. Erika emits a `systemMediaNavigationRequested` event instead of choosing +the next media item itself, so Dart remains the source of truth for the +playlist. Update the capabilities whenever the active item changes. + +```dart +import 'dart:async'; + +import 'package:erika_flutter/erika_flutter.dart'; + +class PlaylistController { + final ErikaPlayer player = ErikaPlayer(allowBackgroundPlayback: true); + final List<({String title, String url})> items = <({String title, String url})>[ + (title: 'Episode 1', url: 'https://example.com/episode-1.mp4'), + (title: 'Episode 2', url: 'https://example.com/episode-2.mp4'), + ]; + + StreamSubscription? subscription; + int index = 0; + bool switching = false; + + Future initialize() async { + subscription = player.events.listen((ErikaPlayerEvent event) async { + if (event.kind != ErikaEventKind.systemMediaNavigationRequested) { + return; + } + switch (event.systemMediaCommand) { + case ErikaSystemMediaCommand.previous: + await openAt(index - 1); + case ErikaSystemMediaCommand.next: + await openAt(index + 1); + case null: + break; + } + }); + await openAt(0); + } + + Future openAt(int newIndex) async { + if (switching || newIndex < 0 || newIndex >= items.length) { + return; + } + switching = true; + await player.setSystemMediaNavigation( + previousEnabled: false, + nextEnabled: false, + ); + try { + final item = items[newIndex]; + await player.open( + item.url, + metadata: ErikaMediaMetadata(title: item.title), + ); + await player.play(); + index = newIndex; + } finally { + switching = false; + await player.setSystemMediaNavigation( + previousEnabled: index > 0, + nextEnabled: index + 1 < items.length, + ); + } + } + + Future dispose() async { + await subscription?.cancel(); + await player.dispose(); + } +} +``` + +The capabilities default to disabled and work on iOS, tvOS, macOS, Android, +Windows, and HarmonyOS. Disable both buttons and reject duplicate requests while an item +is switching, then update the index, metadata, and capabilities after a +successful switch. Only `previous` and `next` are emitted by this API. Play, +pause, stop, and seek continue to be handled directly by the native +system-media integration. + +## tvOS Setup + +The tvOS CocoaPod script phase downloads the verified C ABI static library for +Apple TV devices and simulators. An explicit source build uses Rust's tier-3 +tvOS targets and requires 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`) downloads the verified Erika C ABI +runtime (`erika_capi.dll`) during the CMake build, +automatically following the CMake generator's x64 or ARM64 architecture and +staging the DLL next to the app. A consuming project can explicitly select the +architecture with the `ERIKA_WINDOWS_ARCH=x64|arm64` CMake cache entry or +environment variable. Advanced integrations can set +`ERIKA_NATIVE_TARGET=x86_64-pc-windows-msvc|aarch64-pc-windows-msvc` directly. +Normal consumers only need Visual Studio Build Tools with C++/WinRT and a +Windows SDK. An explicit source build additionally requires: + +- Rust toolchain with the matching MSVC target (`rustup target add x86_64-pc-windows-msvc` or `rustup target add aarch64-pc-windows-msvc`) +- Visual Studio Build Tools with x64/ARM64 C++ tools + Windows SDK +- Native dependencies built into `third_party/dist//` + (via the repo `xtask deps build` flow) + +Set `ERIKA_REPO_ROOT` together with `ERIKA_FORCE_SOURCE_BUILD=1` when developing +against an Erika checkout. + +The Windows plugin publishes title, artist, album, artwork, playback state, and +timeline through System Media Transport Controls (SMTC), and handles system +play, pause, and seek commands. A Windows SDK with C++/WinRT is required; the +plugin links the required WinRT system libraries automatically. + +## Android Setup + +The Android Gradle plugin downloads the verified runtime for each selected ABI. +Android API 26 or newer is required. The generated `jniLibs` include both +`liberika_capi.so` and the matching NDK `libc++_shared.so`. By default arm64 and +x86_64 are selected; override +this with `-PerikaAndroidAbis=arm64-v8a,x86_64` or `ERIKA_ANDROID_ABIS`. + +Android `content://` media and subtitle URIs are opened through +`ContentResolver`, detached, and passed to Erika as owned `fd://` sources with +their provider offset and length. + +Android uses MediaSession and a media notification for lock-screen, Bluetooth, +and system media controls. With `allowBackgroundPlayback: true`, a +`mediaPlayback` foreground service keeps audio running while video decoding is +suspended. The plugin manifest declares the foreground-service and Android 13+ +notification permissions, but the host app must request `POST_NOTIFICATIONS` +at runtime as appropriate for its product flow. If permission is denied, the +media session remains available while notification visibility depends on the +Android version and system policy. + +Android's minimum remains API 26. Extended-linear output additionally needs the +native-window dataspace API (API 28+); API 26/27 continue in SDR and report the +specific fallback. On API 34+, the plugin observes +`Display.registerHdrSdrRatioChangedListener` and publishes real ratio changes +to Erika, allowing wgpu to update subsequent frame targets and output status +without reattaching the surface. On API 35 it also applies per-`SurfaceView` +desired HDR headroom without changing the host window globally. + +## HarmonyOS Setup + +The HarmonyOS module requires DevEco Studio's OpenHarmony Native SDK. Its CMake +build downloads and verifies `liberika_capi.so`, then packages it beside +`liberika_flutter.so`. Rust and the `aarch64-unknown-linux-ohos` target are only +required when `ERIKA_FORCE_SOURCE_BUILD=1` is explicitly selected. + +HarmonyOS uses AVSession to publish metadata, artwork, playback state, position, +and playback rate, and handles system play, pause, stop, and seek commands. + +Use `ErikaVideoView` on HarmonyOS. It registers a Flutter external texture, +obtains the texture surface as an `OHNativeWindow`, and renders through wgpu +Vulkan. Audio uses OHAudio with interleaved f32 PCM. + +Video decoding defaults to HarmonyOS AVCodec hardware decoding for H.264 and +HEVC. AVCodec renders into a Surface whose `OHNativeBuffer` is imported as a +Vulkan external image and resolved with a Vulkan YCbCr sampler, so frames reach +the compositor without a CPU copy. Devices that do not expose the required +Vulkan extensions fall back to FFmpeg software decoding and CPU upload; the +fallback is reported through `VideoDecoderChanged` events and the presenter +diagnostics rather than failing playback. + +## HTTP Headers + +For HTTP(S) video playback, pass request headers through `httpHeaders`: + +```dart +await player.open( + 'https://example.com/video.mp4', + httpHeaders: { + 'Authorization': 'Bearer token', + 'Referer': 'https://example.com/', + }, +); +``` + +Headers are sent with HEAD, Range GET, and prefetch requests, and only apply to +HTTP(S) URLs. Headers are ignored for `content://` and local-file playback. +Avoid writing sensitive values such as Authorization and Cookie to application +logs. + +Headers the playback engine derives itself are rejected instead of merged: +`Range`, `Host`, `Content-Length`, `Transfer-Encoding`, and `Connection` +(case-insensitive) make `open` throw, as do names and values that are not valid +HTTP field tokens. If the bundled native library is a 0.1.3-or-earlier prebuilt +that predates HTTP header support, an `open` that carries headers throws rather +than silently dropping them. + +Headers apply to the media source only — external subtitle tracks and danmaku +sidecar files are still fetched without them. + +## Output Mode + +`ErikaPlayer()` lets the Apple plugins choose SDR or Apple EDR from the current +screen and environment; Android defaults to SDR. To force Apple EDR from Dart: + +```dart +final player = ErikaPlayer( + outputMode: ErikaOutputMode.appleEdr, + edrHeadroom: 4.0, +); +``` + +Use `ErikaOutputMode.sdr` to force SDR output. + +Android's high-headroom mode is FP16 **extended-linear scRGB**, not HDR10/PQ: + +```dart +final player = ErikaPlayer( + outputMode: ErikaOutputMode.extendedLinear, + edrHeadroom: 4.0, +); +``` + +`edrHeadroom` is a content-headroom ceiling. If it is omitted for an +extended-linear player, Erika uses a 4x content ceiling while the +`SurfaceView` receives desired headroom `0` (system auto). An explicit value is +also applied as the per-`SurfaceView` desired headroom on API 35. The current +display HDR/SDR ratio, when available, further bounds the effective wgpu target. + +The mode activates only when all of these hold: the display/surface is +HDR-capable, the view is a Hybrid-Composition `SurfaceView`, wgpu selected +Vulkan, the surface exposes `Rgba16Float`, and the configured native window +reads back `ADATASPACE_SCRGB_LINEAR` (`406913024`, `0x18410000`). GLES, +`TextureView`, missing FP16 support, and dataspace failures explicitly fall +back to SDR. Android scRGB uses BT.709 primaries with `1.0 = 80 nit`; it does +not use PQ or HDR10 metadata. + +Always query the negotiated state instead of trusting the request: + +```dart +final status = await player.getOutputStatus(); +if (!status.extendedLinearActive) { + debugPrint( + 'Erika output fallback: ' + '${status.fallbackReason.label} (${status.fallbackReason.nativeValue})', + ); +} +``` + +`ErikaOutputStatus` contains 13 fields: `requestedMode`, `activeEncoding`, +`surfaceFormat`, `nativeDataSpace`, `requestedHeadroom`, `activeHeadroom`, +`activeHeadroomKnown`, `extendedLinearActive`, `fallbackReason`, +`fallbackCount`, `dataSpaceFailures`, `headroomUpdates`, and +`extendedLinearFrames`. Active Android scRGB is +`androidExtendedLinearScRgb + sixteenBitFloat + nativeDataSpace 406913024`. +On API 34+, `activeHeadroom` is the current display HDR/SDR ratio and +`activeHeadroomKnown` is true when Android exposes a valid ratio. If the ratio +is unavailable, the value is only a fallback and `activeHeadroomKnown` is +false. `headroomUpdates` increments only when the known state or ratio really +changes; duplicate listener notifications are ignored. + +`ErikaOutputFallbackReason` values are stable ABI codes: + +| Code | Dart value | Stable label | +|------|------------|--------------| +| 0 | `none` | `none` | +| 1 | `displayHdrUnsupported` | `display_hdr_unsupported` | +| 2 | `hybridCompositionRequired` | `hybrid_composition_required` | +| 3 | `wgpuBackendNotVulkan` | `wgpu_backend_not_vulkan` | +| 4 | `rgba16FloatSurfaceFormatUnavailable` | `rgba16float_surface_format_unavailable` | +| 5 | `nativeWindowDataSpaceApiUnavailable` | `native_window_dataspace_api_unavailable` | +| 6 | `scrgbDataSpaceVerificationFailed` | `scrgb_dataspace_verification_failed` | +| 7 | `surfaceConfigureFailed` | `surface_configure_failed` | +| 8 | `legacyAppleEdrUnsupported` | `legacy_apple_edr_unsupported` | + +`player.screenshot()` returns raw SDR RGBA8 for the current composited frame +(video + subtitle + danmaku), even when the display is Apple EDR or Android +extended-linear. Metal and Android/wgpu implement capture; the current Windows +D3D11 Flutter path does not return screenshot bytes. + +Non-HDR emulator/device coverage verifies the explicit SDR fallback and its +reason. Active extended-linear output is not yet claimed as device-validated; +acceptance still requires an API 35 HDR device with `Rgba16Float + +SCRGB_LINEAR`, live HDR/SDR-ratio updates, rotation/background recovery, +multiple players, and SDR screenshot checks. + +## Upscaler + +Select ArtCNN at creation time, or switch it later at runtime: + +```dart +final player = ErikaPlayer(upscaler: ErikaUpscalerMode.artCnnC4F16Ds); +await player.setUpscaler(ErikaUpscalerMode.artCnnC4F16Ds); +``` + +Use `ErikaUpscalerMode.off` to disable it. Call +`player.getUpscalerStatus()` to inspect the requested mode, active backend, +fallback count, upscaled frame count, and recent GPU timings. Apple uses Metal; +Android uses wgpu/Vulkan compute for both planar and MediaCodec Surface frames. +GLES 3.0 keeps normal playback and reports an explicit `inactive` fallback. diff --git a/third_party/erika_flutter/README.zh.md b/third_party/erika_flutter/README.zh.md new file mode 100644 index 00000000..d36fd61d --- /dev/null +++ b/third_party/erika_flutter/README.zh.md @@ -0,0 +1,318 @@ +# erika_flutter + +[中文](README.zh.md) | [English](README.md) | [日本語](README.ja.md) + +Erika 媒体播放引擎的 Flutter plugin。 + +插件让 Dart 不进入热路径: + +- Dart 只暴露低频播放器命令和事件流。 +- 原生插件提供两种 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 输出。 +- Erika 通过 `ErikaPresenterHandle` 负责播放、渲染、音频、时序和 overlay。 + +## Video Surfaces + +全播放器 macOS/iOS/tvOS UI 推荐使用 `ErikaWindowOverlayVideoView`。它会在 Flutter 布局中预留矩形区域,同时插件在旁边托管一个原生 `CAMetalLayer`,让视频保持在 Flutter platform-view compositor 之外。 + +Windows 上 `ErikaWindowOverlayVideoView` 以 sibling surface 的形式托管一个 window-level Direct3D 11 swapchain,遵循同样的 overlay 模型。 + +需要标准 Flutter platform view 时则使用 `ErikaVideoView`。Android 的 SDR 视频 surface 是原生 `TextureView`;`ErikaOutputMode.extendedLinear` player 则通过 `PlatformViewLink`/Hybrid Composition 创建 `SurfaceView`,因为 scRGB 不能经过 Flutter texture-layer composition。插件把借用的 `Surface` 交给 Erika,并完整处理创建、resize、销毁、音频焦点、HDR eligibility 和 vsync tick。 + +## macOS Setup + +macOS CocoaPods 构建默认生成 arm64+x86_64 universal 动态库。依赖项目可设置 `ERIKA_MACOS_ARCHS=arm64`、`ERIKA_MACOS_ARCHS=x86_64` 或 `ERIKA_MACOS_ARCHS=arm64,x86_64` 控制产物架构;`universal` 是默认值。预构建模式会对应下载 `macos-arm64`、`macos-x64` 或 `macos-universal` 包。本地开发时插件通过 `dlopen` 加载 Erika,也可设置 `ERIKA_CAPI_DYLIB` 覆盖运行时动态库路径。 + +构建动态库: + +```sh +cargo run -p xtask -- deps build --all --profile lgpl +cargo build -p erika_capi +``` + +## 预构建包与源码构建 + +插件默认下载与当前版本对应的 `v0.1.7` 原生库,并校验 SHA-256。下载失败或校验不一致会明确报错,不会静默回退源码构建。只有在 Erika checkout 中调试源码时才设置 `ERIKA_FORCE_SOURCE_BUILD=1`。自定义 `ERIKA_PREBUILT_TAG` 时,单 ABI 构建须提供对应的 `ERIKA_PREBUILT_SHA256`;Android 多 ABI 构建则须分别提供 `ERIKA_PREBUILT_SHA256_ARM64_V8A`、`ERIKA_PREBUILT_SHA256_ARMEABI_V7A`、`ERIKA_PREBUILT_SHA256_X86_64` 和 `ERIKA_PREBUILT_SHA256_X86`。完整发布方式见 [发布指南](https://github.com/AimesSoft/Erika/blob/main/docs/releasing.zh.md)。 +Android 会为每个实际请求的 ABI 只下载一个约 20–22MB 的 runtime 归档,不会获取四 ABI 合并 C API 包或其中的静态库。 + +源码构建时,macOS 使用 `ERIKA_MACOS_ARCHS=arm64|x86_64|universal`,Windows 使用 `ERIKA_WINDOWS_ARCH=x64|arm64`,Android 使用 `ERIKA_ANDROID_ABIS=arm64-v8a,armeabi-v7a,x86_64,x86`。直接构建原生库时,`xtask --target`、`ERIKA_NATIVE_TARGET` 和 `cargo build --target` 必须使用同一个 target。详细示例见 [构建指南](https://github.com/AimesSoft/Erika/blob/main/docs/building.zh.md)。 + +## iOS Setup + +iOS CocoaPod script phase 会在 Xcode 构建期间自动构建 Erika 原生依赖和 C ABI static library。需要安装对应 iOS target 的 Rust toolchain: + +- `rustup target add aarch64-apple-ios` + +宿主应用必须在 Xcode 的 Signing & Capabilities 中启用 Background Modes > Audio, AirPlay, and Picture in Picture,或在 `Info.plist` 的 `UIBackgroundModes` 中加入 `audio`。iOS、tvOS 和 macOS 会注册 Now Playing 信息及系统播放控制;建议通过 `ErikaMediaMetadata` 提供标题、作者、专辑和封面图片字节。 + +后台播放默认关闭。需要后台继续播放音频时,创建播放器时设置 `ErikaPlayer(allowBackgroundPlayback: true)`。宿主未启用上述 Background Mode 时,即使设置该选项,iOS 也不会保证后台持续播放。 + +```dart +final player = ErikaPlayer( + allowBackgroundPlayback: true, +); + +final artwork = await rootBundle.load('assets/cover.jpg'); +await player.open( + mediaUrl, + metadata: ErikaMediaMetadata( + title: '标题', + artist: '作者', + album: '专辑', + artwork: artwork.buffer.asUint8List(), + ), +); +await player.play(); +``` + +`allowBackgroundPlayback` 是播放器创建选项,播放器创建后不能动态修改。设为 `false` 时,App 进入后台会暂停播放,返回前台后保持暂停;设为 `true` 时,后台暂停视频解码但继续播放音频,返回前台后恢复视频。系统媒体面板支持播放、暂停和进度调整。封面应传入 JPEG、PNG 等完整编码图片字节,而不是原始像素数据。 + +## 系统媒体上一项与下一项 + +播放列表应用可以按当前条目启用系统媒体面板的上一项和下一项按钮。Erika 不会自行选择 +媒体,而是发出 `systemMediaNavigationRequested` 事件,让 Dart 始终作为播放列表的唯一 +数据源。当前条目变化后应同步更新按钮能力。 + +```dart +import 'dart:async'; + +import 'package:erika_flutter/erika_flutter.dart'; + +class PlaylistController { + final ErikaPlayer player = ErikaPlayer(allowBackgroundPlayback: true); + final List<({String title, String url})> items = <({String title, String url})>[ + (title: '第 1 集', url: 'https://example.com/episode-1.mp4'), + (title: '第 2 集', url: 'https://example.com/episode-2.mp4'), + ]; + + StreamSubscription? subscription; + int index = 0; + bool switching = false; + + Future initialize() async { + subscription = player.events.listen((ErikaPlayerEvent event) async { + if (event.kind != ErikaEventKind.systemMediaNavigationRequested) { + return; + } + switch (event.systemMediaCommand) { + case ErikaSystemMediaCommand.previous: + await openAt(index - 1); + case ErikaSystemMediaCommand.next: + await openAt(index + 1); + case null: + break; + } + }); + await openAt(0); + } + + Future openAt(int newIndex) async { + if (switching || newIndex < 0 || newIndex >= items.length) { + return; + } + switching = true; + await player.setSystemMediaNavigation( + previousEnabled: false, + nextEnabled: false, + ); + try { + final item = items[newIndex]; + await player.open( + item.url, + metadata: ErikaMediaMetadata(title: item.title), + ); + await player.play(); + index = newIndex; + } finally { + switching = false; + await player.setSystemMediaNavigation( + previousEnabled: index > 0, + nextEnabled: index + 1 < items.length, + ); + } + } + + Future dispose() async { + await subscription?.cancel(); + await player.dispose(); + } +} +``` + +该能力默认关闭,并适用于 iOS、tvOS、macOS、Android、Windows 和 HarmonyOS。切集期间应暂时 +关闭两个按钮并阻止重复请求;切换成功后再更新索引、metadata 和按钮能力。该 API 只上报 +`previous` 和 `next`,播放、暂停、停止与进度调整仍由各平台的原生系统媒体集成直接处理。 + +## tvOS Setup + +tvOS CocoaPod script phase 会在 Xcode 构建期间自动为 Apple TV 真机或模拟器构建 +原生依赖和 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`。需要: + +- 安装对应 MSVC target 的 Rust toolchain(`rustup target add x86_64-pc-windows-msvc` 或 `rustup target add aarch64-pc-windows-msvc`) +- Visual Studio Build Tools 的 x64/ARM64 C++ 工具 + Windows SDK +- 原生依赖已构建到 `third_party/dist//`(见仓库的 `xtask deps build` 流程) + +若插件无法自动定位 Erika checkout,可设置 `ERIKA_REPO_ROOT`。 + +Windows 通过 System Media Transport Controls 发布标题、作者、专辑、封面、播放状态和时间线,并支持系统播放、暂停和进度调整。需要包含 C++/WinRT 的 Windows SDK;插件会自动链接所需的 WinRT 系统库。 + +## Android Setup + +Android Gradle 构建会先调用 Erika 的 `xtask` 构建原生依赖,再用 Cargo 为选定 ABI 构建 `erika_capi`。需要 Android API 26 或更高版本,并安装 Android NDK 和对应 Rust target。生成的 `jniLibs` 会同时包含 `liberika_capi.so` 与匹配 ABI 的 NDK `libc++_shared.so`。默认构建 arm64 与 x86_64;可通过 `-PerikaAndroidAbis=arm64-v8a,x86_64` 或 `ERIKA_ANDROID_ABIS` 指定。 + +Android `content://` 媒体和字幕 URI 会通过 `ContentResolver` 打开并 detach,连同 provider 的 offset/length 作为由 Rust 接管所有权的 `fd://` source 传入 Erika。 + +Android 使用 MediaSession 和媒体通知接入锁屏、蓝牙耳机及系统媒体面板。`allowBackgroundPlayback: true` 时会启动 `mediaPlayback` 前台 Service,并在后台仅驱动音频;插件 Manifest 已声明前台服务和 Android 13+ 通知权限,但宿主应用仍需按产品流程向用户请求 `POST_NOTIFICATIONS` 运行时权限。该权限被拒绝时,系统媒体会话仍可工作,但通知展示取决于 Android 版本和系统策略。 + +Android 最低版本仍为 API 26。Extended-linear 还要求 native-window dataspace API(API +28+);API 26/27 会继续 SDR 播放并报告对应 fallback。API 34+ 上,插件会监听 +`Display.registerHdrSdrRatioChangedListener`,把真实 ratio 变化发布给 Erika,让 wgpu 无需 +重新 attach surface 就能更新后续帧 target 和输出状态。API 35 上插件还会按 +`SurfaceView` 设置 desired HDR headroom,不修改宿主的全局 Window。 + +## HarmonyOS Setup + +HarmonyOS 模块需要 DevEco Studio 的 OpenHarmony Native SDK。CMake 默认下载并校验 +`liberika_capi.so`,再与 `liberika_flutter.so` 一起打包。只有显式设置 +`ERIKA_FORCE_SOURCE_BUILD=1` 时才需要 Rust 的 `aarch64-unknown-linux-ohos` target。 + +HarmonyOS 使用 AVSession 发布媒体元数据、封面、播放状态、进度和倍速,并接收系统播放、暂停、停止及进度调整命令。 + +HarmonyOS 上请使用 `ErikaVideoView`。它注册 Flutter 外部纹理,把纹理 surface 取为 +`OHNativeWindow`,并通过 wgpu Vulkan 渲染。音频走 OHAudio,交错 f32 PCM。 + +视频解码默认使用 HarmonyOS AVCodec 硬解,支持 H.264 和 HEVC。AVCodec 渲染到 +Surface,其 `OHNativeBuffer` 作为 Vulkan 外部图像导入,再由 Vulkan YCbCr sampler +解析,因此帧无需 CPU 拷贝即可到达合成器。不具备所需 Vulkan 扩展的设备回退到 +FFmpeg 软解 + CPU 上传;回退会通过 `VideoDecoderChanged` 事件和 presenter 诊断 +上报,而不是让播放失败。 + +## HTTP 请求头 + +播放 HTTP(S) 视频时,可以通过 `httpHeaders` 传递请求头: + +```dart +await player.open( + 'https://example.com/video.mp4', + httpHeaders: { + 'Authorization': 'Bearer token', + 'Referer': 'https://example.com/', + }, +); +``` + +请求头会随 HEAD、Range GET 和预取请求发送,仅对 HTTP(S) URL 生效;`content://` 和本地 +文件播放不使用这些请求头。请避免在应用日志中输出 Authorization、Cookie 等敏感值。 + +播放引擎自己生成的请求头会被拒绝而不是合并:`Range`、`Host`、`Content-Length`、 +`Transfer-Encoding`、`Connection`(大小写不敏感)会让 `open` 抛出异常,不符合 HTTP +字段规则的名称和值同样如此。若打包的 native library 是 0.1.3 或更早的预编译产物(早于 +HTTP 请求头支持),带请求头的 `open` 会抛出异常,而不是静默丢弃它们。 + +请求头只作用于媒体 source——外挂字幕轨道和弹幕 sidecar 文件仍然不带这些请求头拉取。 + +## Output Mode + +`ErikaPlayer()` 会让 Apple 插件根据当前屏幕和环境选择 SDR 或 Apple EDR;Android 默认 +为 SDR。若要从 Dart 强制 Apple EDR: + +```dart +final player = ErikaPlayer( + outputMode: ErikaOutputMode.appleEdr, + edrHeadroom: 4.0, +); +``` + +使用 `ErikaOutputMode.sdr` 可强制 SDR 输出。 + +Android 的高 headroom 模式是 FP16 **extended-linear scRGB**,不是 HDR10/PQ: + +```dart +final player = ErikaPlayer( + outputMode: ErikaOutputMode.extendedLinear, + edrHeadroom: 4.0, +); +``` + +`edrHeadroom` 是内容 headroom 上限。Extended-linear player 未传该参数时,Erika 使用默认 +4x 内容上限,同时给 `SurfaceView` 传 desired headroom `0`(系统 auto)。显式值在 API 35 +上还会作为 per-`SurfaceView` desired headroom。显示器当前 HDR/SDR ratio 可用时,会进一步 +约束 wgpu 的有效 target。 + +只有显示器/surface 支持 HDR、view 是 Hybrid Composition `SurfaceView`、wgpu 选择 +Vulkan、surface 暴露 `Rgba16Float`,且配置后的 native window 回读为 +`ADATASPACE_SCRGB_LINEAR`(`406913024`、`0x18410000`)时,该模式才会激活。GLES、 +`TextureView`、缺少 FP16 或 dataspace 验证失败都会明确回退 SDR。Android scRGB 使用 +BT.709 primaries,`1.0 = 80 nit`;不使用 PQ 或 HDR10 metadata。 + +始终查询协商结果,不要把请求值当成实际输出: + +```dart +final status = await player.getOutputStatus(); +if (!status.extendedLinearActive) { + debugPrint( + 'Erika output fallback: ' + '${status.fallbackReason.label} (${status.fallbackReason.nativeValue})', + ); +} +``` + +`ErikaOutputStatus` 有 13 个字段:`requestedMode`、`activeEncoding`、 +`surfaceFormat`、`nativeDataSpace`、`requestedHeadroom`、`activeHeadroom`、 +`activeHeadroomKnown`、`extendedLinearActive`、`fallbackReason`、 +`fallbackCount`、`dataSpaceFailures`、`headroomUpdates`、 +`extendedLinearFrames`。Android scRGB 真正激活时为 +`androidExtendedLinearScRgb + sixteenBitFloat + nativeDataSpace 406913024`。 +API 34+ 上,Android 暴露有效 ratio 时,`activeHeadroom` 是当前显示器 HDR/SDR ratio, +`activeHeadroomKnown` 为 true;ratio 不可用时,该值只是 fallback, +`activeHeadroomKnown` 为 false。只有 known 状态或 ratio 真实变化时,`headroomUpdates` +才增长;重复 listener 通知会被忽略。 + +`ErikaOutputFallbackReason` 是稳定 ABI 数值: + +| 码 | Dart 值 | 稳定 label | +|----|---------|------------| +| 0 | `none` | `none` | +| 1 | `displayHdrUnsupported` | `display_hdr_unsupported` | +| 2 | `hybridCompositionRequired` | `hybrid_composition_required` | +| 3 | `wgpuBackendNotVulkan` | `wgpu_backend_not_vulkan` | +| 4 | `rgba16FloatSurfaceFormatUnavailable` | `rgba16float_surface_format_unavailable` | +| 5 | `nativeWindowDataSpaceApiUnavailable` | `native_window_dataspace_api_unavailable` | +| 6 | `scrgbDataSpaceVerificationFailed` | `scrgb_dataspace_verification_failed` | +| 7 | `surfaceConfigureFailed` | `surface_configure_failed` | +| 8 | `legacyAppleEdrUnsupported` | `legacy_apple_edr_unsupported` | + +`player.screenshot()` 返回当前合成帧(视频 + 字幕 + 弹幕)的原始 SDR RGBA8;即使显示为 +Apple EDR 或 Android extended-linear 也一样。Metal 与 Android/wgpu 已实现截图;当前 +Windows D3D11 Flutter 路径不返回截图字节。 + +非 HDR 模拟器/设备覆盖验证的是明确 SDR 回退及其 reason。Active extended-linear 尚不宣称 +已通过真机验证;仍需在 API 35 HDR 真机上验收 `Rgba16Float + SCRGB_LINEAR`、旋转/前后台 +恢复、动态 HDR/SDR ratio 更新、多 player 和 SDR 截图。 + +## Upscaler + +可以在创建时选择 ArtCNN,也可以在运行时切换: + +```dart +final player = ErikaPlayer(upscaler: ErikaUpscalerMode.artCnnC4F16Ds); +await player.setUpscaler(ErikaUpscalerMode.artCnnC4F16Ds); +``` + +使用 `ErikaUpscalerMode.off` 关闭。`player.getUpscalerStatus()` 会返回请求模式、当前后端、fallback 次数、超分帧数和最近 GPU timing。Apple 使用 Metal;Android 对 planar 与 MediaCodec Surface 帧都使用 wgpu/Vulkan compute。GLES 3.0 会保持普通播放,并明确报告 `inactive` 回退。 diff --git a/third_party/erika_flutter/VENDORED_FROM.json b/third_party/erika_flutter/VENDORED_FROM.json new file mode 100644 index 00000000..a86c0c4f --- /dev/null +++ b/third_party/erika_flutter/VENDORED_FROM.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "packageVersion": "0.1.7", + "erikaRevision": "2c5e470fd33b4a7297fa5b15bb22cb65ace1899e", + "localPatches": [ + { + "path": "native/patches/quiet-default-diagnostics.patch", + "description": "Make routine native diagnostics opt-in through ERIKA_DIAGNOSTICS or an existing trace flag." + } + ], + "runtimes": { + "macosUniversal": { + "path": "native/macos/liberika_capi.dylib", + "sha256": "0a52f3a242f30a6451083fcc66bc81cf51e94da7c0619f9463a32d177696a66b" + }, + "windowsX64": { + "path": "native/windows/x64/erika_capi.dll", + "sha256": "c8d916224d8f5bf34937f5c4762efc0c82786171e349029b52f7557b5de01108" + } + } +} diff --git a/third_party/erika_flutter/android/build.gradle b/third_party/erika_flutter/android/build.gradle new file mode 100644 index 00000000..c4b4ab8c --- /dev/null +++ b/third_party/erika_flutter/android/build.gradle @@ -0,0 +1,55 @@ +group = "dev.aimesoft.erika_flutter" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "1.9.24" + repositories { + google() + mavenCentral() + } + dependencies { + classpath "com.android.tools.build:gradle:8.7.3" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + namespace = "dev.aimesoft.erika_flutter" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + consumerProguardFiles "consumer-rules.pro" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + implementation "androidx.lifecycle:lifecycle-common:2.8.7" + testImplementation "junit:junit:4.13.2" + // android.jar ships throwing org.json stubs for local JVM tests. Use the + // real implementation so NativeJson tests exercise the production decoder. + testImplementation "org.json:json:20180813" +} + +apply from: "erika-native.gradle" diff --git a/third_party/erika_flutter/android/consumer-rules.pro b/third_party/erika_flutter/android/consumer-rules.pro new file mode 100644 index 00000000..f5630341 --- /dev/null +++ b/third_party/erika_flutter/android/consumer-rules.pro @@ -0,0 +1 @@ +-keep class dev.aimesoft.erika_flutter.ErikaNative { *; } diff --git a/third_party/erika_flutter/android/erika-native.gradle b/third_party/erika_flutter/android/erika-native.gradle new file mode 100644 index 00000000..f3d19bd7 --- /dev/null +++ b/third_party/erika_flutter/android/erika-native.gradle @@ -0,0 +1,523 @@ +import java.util.Locale +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import org.gradle.process.ExecOperations + +def execOperations = project.services.get(ExecOperations) + +def artifactPropertiesFile = new File(project.projectDir.parentFile, "native_artifacts.properties") +if (!artifactPropertiesFile.isFile()) { + throw new GradleException("Erika native artifact manifest is missing: $artifactPropertiesFile") +} +def artifactProperties = new Properties() +artifactPropertiesFile.withInputStream { artifactProperties.load(it) } +def nativeVersion = artifactProperties.getProperty("ERIKA_NATIVE_VERSION") +def defaultPrebuiltTag = "v$nativeVersion" +if (nativeVersion == null) { + throw new GradleException("Erika native artifact manifest is incomplete: $artifactPropertiesFile") +} + +def sha256 = { File input -> + def digest = MessageDigest.getInstance("SHA-256") + input.withInputStream { stream -> + byte[] buffer = new byte[1024 * 1024] + int read + while ((read = stream.read(buffer)) != -1) { + digest.update(buffer, 0, read) + } + } + return digest.digest().collect { String.format("%02x", it & 0xff) }.join() +} + +def findErikaRepoRoot = { + def configured = System.getenv("ERIKA_REPO_ROOT") + if (configured != null && !configured.trim().isEmpty()) { + def root = file(configured) + if (new File(root, "crates/erika_capi/Cargo.toml").isFile()) { + return root + } + throw new GradleException("ERIKA_REPO_ROOT does not point to an Erika checkout: $root") + } + + def cursor = project.projectDir + while (cursor != null) { + if (new File(cursor, "crates/erika_capi/Cargo.toml").isFile()) { + return cursor + } + cursor = cursor.parentFile + } + throw new GradleException( + "Unable to locate the Erika repository from ${project.projectDir}; set ERIKA_REPO_ROOT", + ) +} + +def abiDefinitions = [ + "arm64-v8a": [ + rustTarget: "aarch64-linux-android", + clangTarget: "aarch64-linux-android", + cxxRuntimeTarget: "aarch64-linux-android", + shaProperty: "ERIKA_ANDROID_ARM64_V8A_SHA256", + ], + "armeabi-v7a": [ + rustTarget: "armv7-linux-androideabi", + clangTarget: "armv7a-linux-androideabi", + cxxRuntimeTarget: "arm-linux-androideabi", + shaProperty: "ERIKA_ANDROID_ARMEABI_V7A_SHA256", + ], + "x86_64": [ + rustTarget: "x86_64-linux-android", + clangTarget: "x86_64-linux-android", + cxxRuntimeTarget: "x86_64-linux-android", + shaProperty: "ERIKA_ANDROID_X86_64_SHA256", + ], + "x86": [ + rustTarget: "i686-linux-android", + clangTarget: "i686-linux-android", + cxxRuntimeTarget: "i686-linux-android", + shaProperty: "ERIKA_ANDROID_X86_SHA256", + ], +] + +def flutterTargetToAbi = [ + "android-arm": "armeabi-v7a", + "android-arm64": "arm64-v8a", + "android-x64": "x86_64", + "android-x86": "x86", +] + +def requestedAbis = { + def explicit = project.findProperty("erikaAndroidAbis") ?: System.getenv("ERIKA_ANDROID_ABIS") + if (explicit != null && !explicit.toString().trim().isEmpty()) { + return explicit.toString().split(/[,;\s]+/).findAll { !it.isEmpty() }.unique() + } + + def injectedAbi = project.findProperty("android.injected.build.abi") + if (injectedAbi != null && abiDefinitions.containsKey(injectedAbi.toString())) { + return [injectedAbi.toString()] + } + + def flutterTarget = project.findProperty("target-platform") + if (flutterTarget != null) { + def mapped = flutterTarget.toString() + .split(/[,;\s]+/) + .collect { flutterTargetToAbi[it] } + .findAll { it != null } + .unique() + if (!mapped.isEmpty()) { + return mapped + } + } + + return ["arm64-v8a", "x86_64"] +} + +def selectedAbis = requestedAbis() +selectedAbis.each { abi -> + if (!abiDefinitions.containsKey(abi)) { + throw new GradleException( + "Unsupported Erika Android ABI '$abi'; expected one of ${abiDefinitions.keySet()}", + ) + } + def shaProperty = abiDefinitions[abi].shaProperty + if (artifactProperties.getProperty(shaProperty) == null) { + throw new GradleException( + "Erika native artifact manifest is missing $shaProperty: $artifactPropertiesFile", + ) + } +} +def skipNativeBuild = (project.findProperty("erikaSkipNativeBuild") + ?: System.getenv("ERIKA_ANDROID_SKIP_NATIVE_BUILD") + ?: "false").toString().toBoolean() +def forceSourceBuild = (System.getenv("ERIKA_FORCE_SOURCE_BUILD") ?: "0") == "1" +def usePrebuilt = !forceSourceBuild +def prebuiltTag = System.getenv("ERIKA_PREBUILT_TAG") ?: defaultPrebuiltTag +def configuredPrebuiltSha256 = System.getenv("ERIKA_PREBUILT_SHA256") +def prebuiltCacheKey = prebuiltTag.replaceAll(/[^A-Za-z0-9._-]/, "_") +def prebuiltCacheDirectory = new File( + gradle.gradleUserHomeDir, + "caches/erika/$prebuiltCacheKey", +) +def prebuiltSha256ForAbi = { String abi -> + def abiKey = abi.toUpperCase(Locale.ROOT).replaceAll(/[^A-Z0-9]/, "_") + def configuredForAbi = System.getenv("ERIKA_PREBUILT_SHA256_$abiKey") + if (configuredForAbi != null && !configuredForAbi.trim().isEmpty()) { + return configuredForAbi + } + if (selectedAbis.size() == 1 && configuredPrebuiltSha256 != null && + !configuredPrebuiltSha256.trim().isEmpty()) { + return configuredPrebuiltSha256 + } + if (prebuiltTag == defaultPrebuiltTag) { + return artifactProperties.getProperty(abiDefinitions[abi].shaProperty) + } + throw new GradleException( + "ERIKA_PREBUILT_SHA256_$abiKey is required when ERIKA_PREBUILT_TAG " + + "overrides $defaultPrebuiltTag for $abi", + ) +} + +android.defaultConfig.ndk.abiFilters.addAll(selectedAbis) + +def generatedJniLibs = layout.buildDirectory.dir("generated/erika/jniLibs") +android.sourceSets.main.jniLibs.srcDir(generatedJniLibs.get().asFile) + +def hostIsWindows = System.getProperty("os.name") + .toLowerCase(Locale.ROOT) + .contains("windows") + +def executableIn = { File directory, String base, boolean clangWrapper -> + def candidates = hostIsWindows + ? (clangWrapper ? ["${base}.cmd", "${base}.exe", base] : ["${base}.exe", base]) + : [base] + def resolved = candidates.collect { new File(directory, it) }.find { it.isFile() } + if (resolved == null) { + throw new GradleException("Android NDK tool was not found under $directory: $base") + } + return resolved +} + +def containsLibclang = { File directory -> + directory.isDirectory() && (directory.listFiles()?.any { candidate -> + def name = candidate.name + candidate.isFile() && ( + name == "libclang.dll" || + name == "libclang.dylib" || + name == "libclang.so" || + name.startsWith("libclang.so.") + ) + } ?: false) +} + +def resolveLibclangDirectory = { File toolchainRoot -> + def configured = System.getenv("LIBCLANG_PATH") + if (configured != null && !configured.trim().isEmpty()) { + def directory = file(configured) + if (containsLibclang(directory)) { + return directory + } + throw new GradleException("LIBCLANG_PATH does not contain libclang: $directory") + } + def directory = [ + new File(toolchainRoot, "bin"), + new File(toolchainRoot, "lib64"), + new File(toolchainRoot, "lib"), + ].find(containsLibclang) + if (directory == null) { + throw new GradleException("libclang was not found under Android NDK toolchain $toolchainRoot") + } + return directory +} + +def ndkContainsLibclang = { File ndk -> + def prebuiltRoot = new File(ndk, "toolchains/llvm/prebuilt") + return prebuiltRoot.listFiles()?.any { hostToolchain -> + ["bin", "lib64", "lib"].any { child -> + containsLibclang(new File(hostToolchain, child)) + } + } ?: false +} + +def resolveNdkDirectory = { + def configured = System.getenv("ANDROID_NDK_HOME") ?: System.getenv("ANDROID_NDK_ROOT") + if (configured != null && !configured.trim().isEmpty()) { + def ndk = file(configured) + if (ndk.isDirectory() && ndkContainsLibclang(ndk)) { + return ndk + } + throw new GradleException( + "Configured Android NDK does not contain the libclang required by bindgen: $ndk", + ) + } + + def agpNdk = android.ndkDirectory + if (agpNdk != null && agpNdk.isDirectory() && ndkContainsLibclang(agpNdk)) { + return agpNdk + } + + def ndkRoot = new File(android.sdkDirectory, "ndk") + def installed = ndkRoot.listFiles() + ?.findAll { it.isDirectory() && ndkContainsLibclang(it) } + ?.sort { left, right -> right.name <=> left.name } + if (installed != null && !installed.isEmpty()) { + return installed.first() + } + throw new GradleException( + "No installed Android NDK contains the libclang required by bindgen; " + + "set ANDROID_NDK_HOME to a complete NDK or install one through the Android SDK manager", + ) +} + +def resolveCargoTargetDirectory = { File repoRoot -> + def configured = System.getenv("CARGO_TARGET_DIR") + if (configured == null || configured.trim().isEmpty()) { + return new File(repoRoot, "target") + } + def target = new File(configured) + return target.isAbsolute() ? target : new File(repoRoot, configured) +} + +def hostTag = { + def os = System.getProperty("os.name").toLowerCase(Locale.ROOT) + if (os.contains("windows")) { + return "windows-x86_64" + } + if (os.contains("mac")) { + return "darwin-x86_64" + } + return "linux-x86_64" +} + +tasks.register("buildErikaAndroidRuntime") { + group = "build" + description = "Stage a prebuilt or source-built Erika runtime for the selected Android ABIs" + outputs.dir(generatedJniLibs) + // Cargo and xtask own their incremental caches. Always enter the native + // build so Rust/source or ABI changes cannot leave stale packaged .so files. + outputs.upToDateWhen { false } + onlyIf { + if (skipNativeBuild) { + logger.lifecycle("Skipping Erika Android native build by explicit request") + return false + } + return true + } + + doLast { + project.delete(generatedJniLibs.get().asFile) + + if (usePrebuilt) { + try { + prebuiltCacheDirectory.mkdirs() + selectedAbis.each { abi -> + def prebuiltSha256 = prebuiltSha256ForAbi(abi) + def prebuiltName = "erika-flutter-android-$abi" + def prebuiltArchive = new File( + prebuiltCacheDirectory, + "${prebuiltName}.zip", + ) + def prebuiltUrl = + "https://github.com/AimesSoft/Erika/releases/download/" + + "$prebuiltTag/${prebuiltName}.zip" + if (prebuiltArchive.isFile()) { + def cachedSha256 = sha256(prebuiltArchive) + if (!cachedSha256.equalsIgnoreCase(prebuiltSha256)) { + logger.warn( + "Discarding Erika Android prebuilt with invalid checksum: $prebuiltArchive", + ) + project.delete(prebuiltArchive) + } + } + if (!prebuiltArchive.isFile()) { + def temporaryArchive = new File( + prebuiltCacheDirectory, + "${prebuiltArchive.name}.part", + ) + project.delete(temporaryArchive) + logger.lifecycle( + "Downloading Erika Android $abi prebuilt $prebuiltTag from $prebuiltUrl", + ) + ant.get( + src: prebuiltUrl, + dest: temporaryArchive, + verbose: true, + retries: 3, + ) + try { + Files.move( + temporaryArchive.toPath(), + prebuiltArchive.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { + Files.move( + temporaryArchive.toPath(), + prebuiltArchive.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } + } else { + logger.lifecycle( + "Using cached Erika Android $abi prebuilt $prebuiltTag from $prebuiltArchive", + ) + } + + def actualSha256 = sha256(prebuiltArchive) + if (!actualSha256.equalsIgnoreCase(prebuiltSha256)) { + project.delete(prebuiltArchive) + throw new GradleException( + "Erika Android $abi prebuilt checksum mismatch: " + + "expected $prebuiltSha256, got $actualSha256", + ) + } + + def destination = generatedJniLibs.get().dir(abi).asFile + project.copy { + from(project.zipTree(prebuiltArchive)) { + include "lib/liberika_capi.so" + include "lib/libc++_shared.so" + include "${prebuiltName}/lib/liberika_capi.so" + include "${prebuiltName}/lib/libc++_shared.so" + eachFile { details -> details.path = details.name } + includeEmptyDirs = false + } + into destination + } + ["liberika_capi.so", "libc++_shared.so"].each { library -> + def staged = new File(destination, library) + if (!staged.isFile() || staged.length() == 0L) { + throw new GradleException( + "Erika Android prebuilt $prebuiltTag is missing $abi/$library", + ) + } + } + } + + logger.lifecycle( + "Staged Erika Android prebuilt $prebuiltTag for ${selectedAbis.join(', ')}", + ) + return + } catch (Exception error) { + project.delete(generatedJniLibs.get().asFile) + throw new GradleException( + "Unable to use Erika Android prebuilt $prebuiltTag. " + + "Set ERIKA_FORCE_SOURCE_BUILD=1 only when building from an Erika checkout. " + + "Cause: ${error.message}", + error, + ) + } + } + + def repoRoot = findErikaRepoRoot() + def cargoTargetDirectory = resolveCargoTargetDirectory(repoRoot) + def ndkDirectory = resolveNdkDirectory() + def toolchainBin = new File( + ndkDirectory, + "toolchains/llvm/prebuilt/${hostTag()}/bin", + ) + if (!toolchainBin.isDirectory()) { + throw new GradleException("Android NDK LLVM toolchain was not found: $toolchainBin") + } + def toolchainRoot = toolchainBin.parentFile + def sysroot = new File(toolchainRoot, "sysroot") + def libclangDirectory = resolveLibclangDirectory(toolchainRoot) + + logger.lifecycle( + "Using Android NDK ${ndkDirectory.name} from ${ndkDirectory.absolutePath} " + + "with libclang at ${libclangDirectory.absolutePath}", + ) + + def configuredMinSdk = android.defaultConfig.minSdk + def minSdkLevel = configuredMinSdk instanceof Number + ? configuredMinSdk.intValue() + : configuredMinSdk.apiLevel + def apiLevel = Math.max(26, minSdkLevel) + def nativeProfile = System.getenv("ERIKA_NATIVE_PROFILE") ?: "lgpl" + def cargo = System.getenv("CARGO") ?: (hostIsWindows ? "cargo.exe" : "cargo") + + selectedAbis.each { abi -> + def definition = abiDefinitions[abi] + def rustTarget = definition.rustTarget + def targetKey = rustTarget.toUpperCase(Locale.ROOT).replace('-', '_') + def clang = executableIn( + toolchainBin, + "${definition.clangTarget}${apiLevel}-clang", + true, + ) + def clangxx = executableIn( + toolchainBin, + "${definition.clangTarget}${apiLevel}-clang++", + true, + ) + def llvmAr = executableIn(toolchainBin, "llvm-ar", false) + def llvmRanlib = executableIn(toolchainBin, "llvm-ranlib", false) + def llvmStrip = executableIn(toolchainBin, "llvm-strip", false) + def targetEnvKey = rustTarget.replace('-', '_') + def bindgenKey = "BINDGEN_EXTRA_CLANG_ARGS_${targetEnvKey}" + def cflagsKey = "CFLAGS_${targetEnvKey}" + def cxxflagsKey = "CXXFLAGS_${targetEnvKey}" + def nativeEnvironment = [ + "ANDROID_NDK_HOME": ndkDirectory.absolutePath, + "ANDROID_NDK_ROOT": ndkDirectory.absolutePath, + "ANDROID_API_LEVEL": apiLevel.toString(), + "ERIKA_NATIVE_TARGET": rustTarget, + "ERIKA_NATIVE_PROFILE": nativeProfile, + "CARGO_TARGET_DIR": cargoTargetDirectory.absolutePath, + "LIBCLANG_PATH": libclangDirectory.absolutePath, + "PATH": toolchainBin.absolutePath + File.pathSeparator + System.getenv("PATH"), + (bindgenKey): System.getenv(bindgenKey) + ?: "--target=${definition.clangTarget}${apiLevel} --sysroot=\"${sysroot.absolutePath}\"", + "CARGO_TARGET_${targetKey}_LINKER": clang.absolutePath, + "CC_${targetEnvKey}": clang.absolutePath, + "CXX_${targetEnvKey}": clangxx.absolutePath, + "AR_${targetEnvKey}": llvmAr.absolutePath, + "RANLIB_${targetEnvKey}": llvmRanlib.absolutePath, + "STRIP_${targetEnvKey}": llvmStrip.absolutePath, + (cflagsKey): System.getenv(cflagsKey) ?: "-fPIC", + (cxxflagsKey): System.getenv(cxxflagsKey) ?: "-fPIC", + ].collectEntries { key, value -> + [(key.toString()): value.toString()] + } + + logger.lifecycle("Building Erika Android native dependencies for $abi ($rustTarget)") + execOperations.exec { + workingDir repoRoot + environment(nativeEnvironment) + commandLine( + cargo, + "run", + "-p", + "xtask", + "--", + "deps", + "build", + "--all", + "--profile", + nativeProfile, + "--target", + rustTarget, + ) + }.assertNormalExitValue() + + logger.lifecycle("Building erika_capi for $abi ($rustTarget)") + execOperations.exec { + workingDir repoRoot + environment(nativeEnvironment) + commandLine( + cargo, + "build", + "-p", + "erika_capi", + "--release", + "--target", + rustTarget, + ) + }.assertNormalExitValue() + + def source = new File( + cargoTargetDirectory, + "$rustTarget/release/liberika_capi.so", + ) + if (!source.isFile()) { + throw new GradleException("Erika Android runtime was not produced: $source") + } + def destination = generatedJniLibs.get().dir(abi).asFile + def cxxRuntime = new File( + toolchainRoot, + "sysroot/usr/lib/${definition.cxxRuntimeTarget}/libc++_shared.so", + ) + if (!cxxRuntime.isFile()) { + throw new GradleException("Android NDK C++ runtime was not found: $cxxRuntime") + } + project.copy { + from source, cxxRuntime + into destination + } + } + } +} + +tasks.named("preBuild").configure { + dependsOn(tasks.named("buildErikaAndroidRuntime")) +} diff --git a/third_party/erika_flutter/android/gradle.properties b/third_party/erika_flutter/android/gradle.properties new file mode 100644 index 00000000..2c6ed4d4 --- /dev/null +++ b/third_party/erika_flutter/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official diff --git a/third_party/erika_flutter/android/settings.gradle b/third_party/erika_flutter/android/settings.gradle new file mode 100644 index 00000000..03bcca23 --- /dev/null +++ b/third_party/erika_flutter/android/settings.gradle @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "erika_flutter_android" diff --git a/third_party/erika_flutter/android/src/main/AndroidManifest.xml b/third_party/erika_flutter/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..c48c2893 --- /dev/null +++ b/third_party/erika_flutter/android/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidActivityLifecycle.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidActivityLifecycle.kt new file mode 100644 index 00000000..cc5cffd8 --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidActivityLifecycle.kt @@ -0,0 +1,13 @@ +package dev.aimesoft.erika_flutter + +import androidx.lifecycle.Lifecycle + +internal fun androidActivityIsActive(state: Lifecycle.State): Boolean = + state.isAtLeast(Lifecycle.State.STARTED) + +internal fun androidActivityActiveForEvent(event: Lifecycle.Event): Boolean? = when (event) { + Lifecycle.Event.ON_START -> true + Lifecycle.Event.ON_STOP, + Lifecycle.Event.ON_DESTROY -> false + else -> null +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidContentSource.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidContentSource.kt new file mode 100644 index 00000000..7bb634de --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidContentSource.kt @@ -0,0 +1,471 @@ +package dev.aimesoft.erika_flutter + +import java.io.EOFException +import java.io.Closeable +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.Future + +internal const val ANDROID_CONTENT_SPOOL_MAX_BYTES = 8L * 1024L * 1024L * 1024L +internal const val ANDROID_CONTENT_SPOOL_MIN_FREE_BYTES = 128L * 1024L * 1024L +internal const val ANDROID_CONTENT_SPOOL_DIRECTORY = "erika-content-spool" +internal const val ANDROID_CONTENT_SPOOL_PREFIX = "source-" +internal const val ANDROID_CONTENT_SPOOL_SUFFIX = ".tmp" + +internal data class AndroidContentSpoolScavengeStats( + val files: Int, + val bytes: Long, + val deleteFailures: Int, +) + +internal fun isAndroidContentSpoolFileName(name: String): Boolean = + name.startsWith(ANDROID_CONTENT_SPOOL_PREFIX) && + name.endsWith(ANDROID_CONTENT_SPOOL_SUFFIX) && + name.length > ANDROID_CONTENT_SPOOL_PREFIX.length + ANDROID_CONTENT_SPOOL_SUFFIX.length + +/** Deletes only cache files created by File.createTempFile for Android content spooling. */ +internal fun scavengeAndroidContentSpoolDirectory( + directory: File, + deleteFile: (File) -> Boolean = ::deleteContentSpoolFile, +): AndroidContentSpoolScavengeStats { + var deletedFiles = 0 + var deletedBytes = 0L + var deleteFailures = 0 + directory.listFiles().orEmpty().forEach { file -> + if (!file.isFile || !isAndroidContentSpoolFileName(file.name)) { + return@forEach + } + val length = file.length().coerceAtLeast(0L) + val deleted = runCatching { deleteFile(file) }.getOrDefault(false) + if (deleted) { + deletedFiles += 1 + deletedBytes = saturatedByteCount(deletedBytes, length) + } else { + deleteFailures += 1 + } + } + return AndroidContentSpoolScavengeStats( + files = deletedFiles, + bytes = deletedBytes, + deleteFailures = deleteFailures, + ) +} + +private fun saturatedByteCount(current: Long, additional: Long): Long = + if (additional > Long.MAX_VALUE - current) Long.MAX_VALUE else current + additional + +internal data class AndroidContentSpoolPolicy( + val maxBytes: Long = ANDROID_CONTENT_SPOOL_MAX_BYTES, + val minFreeBytes: Long = ANDROID_CONTENT_SPOOL_MIN_FREE_BYTES, +) { + init { + require(maxBytes > 0L) { "maxBytes must be positive" } + require(minFreeBytes >= 0L) { "minFreeBytes must be non-negative" } + } +} + +internal class AndroidContentSpoolException( + val reasonCode: String, + message: String, +) : IOException(message) + +internal fun androidContentSourceFailureReason(error: Throwable): String = when (error) { + is AndroidContentSpoolException -> error.reasonCode + is AndroidContentPreparationCancelledException -> "cancelled" + is EOFException -> "provider_eof" + else -> "io_failure" +} + +internal class AndroidContentPreparationCancelledException(message: String) : IOException(message) + +/** + * Cross-thread cancellation for one content-provider preparation. + * + * Closing the registered provider stream wakes a worker blocked in a pipe read. + * Tracked temporary files are unlinked immediately on cancellation and again by + * the worker's `finally` path, so a late or uninterruptible provider cannot leave + * a named cache entry behind. + */ +internal class AndroidContentPreparationCancellation { + private val monitor = Any() + private var cancelled = false + private var future: Future<*>? = null + private val closeables = linkedSetOf() + private val temporaryFiles = linkedSetOf() + + val isCancelled: Boolean + get() = synchronized(monitor) { cancelled } + + fun attachFuture(value: Future<*>) { + val cancelImmediately = synchronized(monitor) { + if (cancelled) { + true + } else { + future = value + false + } + } + if (cancelImmediately) { + value.cancel(true) + } + } + + fun register(closeable: Closeable) { + val closeImmediately = synchronized(monitor) { + if (cancelled) { + true + } else { + closeables.add(closeable) + false + } + } + if (closeImmediately) { + runCatching(closeable::close) + throw AndroidContentPreparationCancelledException( + "Android content preparation was cancelled before the provider stream opened", + ) + } + } + + fun unregister(closeable: Closeable) { + synchronized(monitor) { closeables.remove(closeable) } + } + + fun trackTemporaryFile(file: File) { + val deleteImmediately = synchronized(monitor) { + if (cancelled) { + true + } else { + temporaryFiles.add(file) + false + } + } + if (deleteImmediately) { + deleteContentSpoolFile(file) + throw AndroidContentPreparationCancelledException( + "Android content preparation was cancelled before cache spooling started", + ) + } + } + + fun releaseTemporaryFile(file: File) { + synchronized(monitor) { temporaryFiles.remove(file) } + } + + fun throwIfCancelled() { + if (isCancelled || Thread.currentThread().isInterrupted) { + throw AndroidContentPreparationCancelledException( + "Android content preparation was cancelled", + ) + } + } + + fun cancel() { + val resources: List + val files: List + val task: Future<*>? + synchronized(monitor) { + if (cancelled) { + return + } + cancelled = true + resources = closeables.toList() + closeables.clear() + files = temporaryFiles.toList() + temporaryFiles.clear() + task = future + future = null + } + resources.forEach { closeable -> runCatching(closeable::close) } + files.forEach(::deleteContentSpoolFile) + task?.cancel(true) + } +} + +internal data class AndroidContentPreparationToken( + val generation: Long, + val commandId: Long, +) + +/** Main-thread registry that prevents a late FD handoff from overtaking open/close/dispose. */ +internal class AndroidContentPreparationRegistry { + private var generation = 1L + private var nextCommandId = 1L + private val pending = linkedMapOf Unit>() + + val pendingCount: Int + get() = pending.size + + fun begin(onCancel: (String) -> Unit): AndroidContentPreparationToken { + val commandId = nextCommandId + nextCommandId = if (nextCommandId == Long.MAX_VALUE) 1L else nextCommandId + 1L + pending[commandId] = onCancel + return AndroidContentPreparationToken(generation, commandId) + } + + fun finish(token: AndroidContentPreparationToken): Boolean { + val existed = pending.remove(token.commandId) != null + return existed && token.generation == generation + } + + fun invalidate(reason: String): Int { + generation = if (generation == Long.MAX_VALUE) 1L else generation + 1L + val callbacks = pending.values.toList() + pending.clear() + callbacks.forEach { callback -> callback(reason) } + return callbacks.size + } +} + +internal enum class AndroidContentTransport { + OWNED_DESCRIPTOR, + CACHE_SPOOL, +} + +internal enum class AndroidContentDescriptorKind { + REGULAR_FILE, + FIFO, + SOCKET, + CHARACTER_DEVICE, + BLOCK_DEVICE, + OTHER, + UNKNOWN, +} + +internal fun androidContentTransport( + kind: AndroidContentDescriptorKind, + statSize: Long?, +): AndroidContentTransport = + if (kind == AndroidContentDescriptorKind.REGULAR_FILE && statSize != null && statSize >= 0L) { + AndroidContentTransport.OWNED_DESCRIPTOR + } else { + AndroidContentTransport.CACHE_SPOOL + } + +internal fun androidContentFallbackReason(kind: AndroidContentDescriptorKind): String = when (kind) { + AndroidContentDescriptorKind.FIFO -> "fifo_descriptor" + AndroidContentDescriptorKind.SOCKET -> "socket_descriptor" + AndroidContentDescriptorKind.CHARACTER_DEVICE -> "character_device_descriptor" + AndroidContentDescriptorKind.BLOCK_DEVICE -> "block_device_descriptor" + AndroidContentDescriptorKind.OTHER -> "non_regular_descriptor" + AndroidContentDescriptorKind.UNKNOWN -> "descriptor_stat_unavailable" + AndroidContentDescriptorKind.REGULAR_FILE -> "regular_descriptor_size_unavailable" +} + +/** Streaming copy used for non-seekable Android content-provider descriptors. */ +internal object AndroidContentSpooler { + private const val BUFFER_SIZE = 256 * 1024 + + /** + * Copies without buffering the complete source in memory and returns the exact byte count. + * Empty and truncated provider streams are errors instead of becoming zero-length fd sources. + */ + fun copy( + input: InputStream, + output: OutputStream, + expectedLength: Long?, + policy: AndroidContentSpoolPolicy = AndroidContentSpoolPolicy(), + availableBytes: () -> Long = { Long.MAX_VALUE }, + cancelled: () -> Boolean = { false }, + onProgress: (Long) -> Unit = {}, + ): Long { + require(expectedLength == null || expectedLength >= 0L) { + "expectedLength must be null or non-negative" + } + if (expectedLength != null && expectedLength > policy.maxBytes) { + throw AndroidContentSpoolException( + "max_bytes_exceeded", + "Android content provider declared $expectedLength bytes, exceeding the " + + "${policy.maxBytes}-byte spool limit", + ) + } + ensureDiskBudget( + bytesRequired = expectedLength ?: 1L, + policy = policy, + availableBytes = availableBytes, + ) + val buffer = ByteArray(BUFFER_SIZE) + var total = 0L + while (true) { + throwIfCancelled(cancelled) + val read = input.read(buffer) + if (read < 0) { + break + } + if (read == 0) { + // InputStream permits a zero-byte read. Reading one byte prevents a faulty + // provider implementation from causing a tight, non-progressing loop. + val singleByte = input.read() + if (singleByte < 0) { + break + } + val nextTotal = checkedByteCount(total, 1) + validateNextWrite( + nextTotal = nextTotal, + writeBytes = 1, + expectedLength = expectedLength, + policy = policy, + availableBytes = availableBytes, + ) + output.write(singleByte) + total = nextTotal + onProgress(total) + } else { + val nextTotal = checkedByteCount(total, read) + validateNextWrite( + nextTotal = nextTotal, + writeBytes = read, + expectedLength = expectedLength, + policy = policy, + availableBytes = availableBytes, + ) + output.write(buffer, 0, read) + total = nextTotal + onProgress(total) + } + } + throwIfCancelled(cancelled) + if (total == 0L) { + throw EOFException("Android content provider returned an empty stream") + } + if (expectedLength != null && total != expectedLength) { + throw EOFException( + "Android content provider stream was truncated: " + + "expected $expectedLength bytes, received $total", + ) + } + return total + } + + private fun validateNextWrite( + nextTotal: Long, + writeBytes: Int, + expectedLength: Long?, + policy: AndroidContentSpoolPolicy, + availableBytes: () -> Long, + ) { + if (expectedLength != null && nextTotal > expectedLength) { + throw AndroidContentSpoolException( + "declared_length_exceeded", + "Android content provider returned more than its declared $expectedLength bytes", + ) + } + if (nextTotal > policy.maxBytes) { + throw AndroidContentSpoolException( + "max_bytes_exceeded", + "Android content provider exceeded the ${policy.maxBytes}-byte spool limit", + ) + } + ensureDiskBudget(writeBytes.toLong(), policy, availableBytes) + } + + private fun ensureDiskBudget( + bytesRequired: Long, + policy: AndroidContentSpoolPolicy, + availableBytes: () -> Long, + ) { + val available = availableBytes().coerceAtLeast(0L) + if (available == Long.MAX_VALUE) { + return + } + val required = try { + Math.addExact(policy.minFreeBytes, bytesRequired) + } catch (_: ArithmeticException) { + Long.MAX_VALUE + } + if (available < required) { + throw AndroidContentSpoolException( + "insufficient_disk_budget", + "Android content spool needs $bytesRequired bytes while preserving " + + "${policy.minFreeBytes} free bytes, but only $available bytes are available", + ) + } + } + + private fun throwIfCancelled(cancelled: () -> Boolean) { + if (cancelled() || Thread.currentThread().isInterrupted) { + throw AndroidContentPreparationCancelledException( + "Android content preparation was cancelled while spooling", + ) + } + } + + private fun checkedByteCount(total: Long, read: Int): Long = try { + Math.addExact(total, read.toLong()) + } catch (error: ArithmeticException) { + throw IOException("Android content provider stream exceeded Long.MAX_VALUE bytes", error) + } +} + +internal fun deleteContentSpoolFile(file: File): Boolean { + if (!file.exists()) { + return true + } + repeat(3) { + if (!file.exists() || file.delete()) { + return true + } + Thread.yield() + } + return !file.exists() +} + +internal fun androidContentSourceEvent( + stage: String, + authority: String?, + fields: Map = emptyMap(), +): String = buildString { + append('{') + appendJsonField("event", "android_content_source") + append(',') + appendJsonField("stage", stage) + append(',') + appendJsonField("authority", authority) + fields.forEach { (name, value) -> + append(',') + appendJsonField(name, value) + } + append('}') +} + +private fun StringBuilder.appendJsonField(name: String, value: Any?) { + appendJsonString(name) + append(':') + when (value) { + null -> append("null") + is Boolean, + is Byte, + is Short, + is Int, + is Long, + is Float, + is Double -> append(value) + else -> appendJsonString(value.toString()) + } +} + +private fun StringBuilder.appendJsonString(value: String) { + append('"') + value.forEach { character -> + when (character) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> { + if (character.code < 0x20) { + append("\\u") + append(character.code.toString(16).padStart(4, '0')) + } else { + append(character) + } + } + } + } + append('"') +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidEventPollPolicy.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidEventPollPolicy.kt new file mode 100644 index 00000000..c9883970 --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidEventPollPolicy.kt @@ -0,0 +1,331 @@ +package dev.aimesoft.erika_flutter + +import java.util.concurrent.atomic.AtomicLong + +internal const val ANDROID_MAX_EVENT_POLL_IDLE_ROUNDS = 6 +internal const val ANDROID_MAX_EVENT_POLL_FAILURE_ROUNDS = 5 +internal const val ANDROID_DURATION_CHANGED_EVENT_KIND = 2 +internal const val ANDROID_POSITION_CHANGED_EVENT_KIND = 3 +internal const val ANDROID_TRACKS_CHANGED_EVENT_KIND = 4 +internal const val ANDROID_BUFFERING_CHANGED_EVENT_KIND = 5 +internal const val ANDROID_VIDEO_PARAMS_CHANGED_EVENT_KIND = 6 +internal const val ANDROID_SURFACE_ATTACHED_EVENT_KIND = 7 +internal const val ANDROID_SURFACE_DETACHED_EVENT_KIND = 8 +internal const val ANDROID_TRACK_SELECTION_CHANGED_EVENT_KIND = 10 + +internal fun androidPendingEventContentGeneration( + eventKind: Int?, + contentGeneration: Long, +): Long? = when (eventKind) { + ANDROID_SURFACE_ATTACHED_EVENT_KIND, + ANDROID_SURFACE_DETACHED_EVENT_KIND, + -> null + else -> contentGeneration +} + +private val ANDROID_SURFACE_EVENT_OPERATIONS = setOf( + "attachSurface", + "detachSurface", + "resizeSurface", +) + +internal class AndroidImmediateEventPollLatch { + var pending: Boolean = false + private set + + fun request(immediate: Boolean) { + pending = pending || immediate + } + + fun takeIfReady(pollInFlight: Boolean): Boolean { + if (pollInFlight || !pending) { + return false + } + pending = false + return true + } + + fun clear() { + pending = false + } +} + +/** Suppresses repeated delivery of the same persistent native polling failure. */ +internal class AndroidEventPollFailureDeduplicator { + private var lastSignature: String? = null + + fun shouldReport(signature: String?, canDeliver: Boolean = true): Boolean { + if (signature == null) { + lastSignature = null + return false + } + // Do not consume a signature for a failure that policy rejected + // before it could even be queued for Dart. + if (!canDeliver) { + return false + } + if (signature == lastSignature) { + return false + } + lastSignature = signature + return true + } + + fun clear() { + lastSignature = null + } +} + +/** Keeps a failing presenter from slowing event delivery for healthy players. */ +internal class AndroidEventPollBackoff { + var failureRounds: Int = 0 + private set + private var retryAtMillis: Long = 0L + + fun record(failed: Boolean, nowMillis: Long) { + if (!failed) { + reset() + return + } + failureRounds = (failureRounds + 1) + .coerceAtMost(ANDROID_MAX_EVENT_POLL_FAILURE_ROUNDS) + retryAtMillis = nowMillis + androidEventPollFailureDelayMillis(failureRounds) + } + + fun delayMillis(nowMillis: Long): Long = (retryAtMillis - nowMillis).coerceAtLeast(0L) + + fun reset() { + failureRounds = 0 + retryAtMillis = 0L + } +} + +internal fun androidAuthoritativeStateEvent( + lastCompleteEvent: Map?, + playerId: Long, + stateChangedEventKind: Int, + state: Int, + durationMicros: Long, + positionMicros: Long, +): LinkedHashMap { + val event = linkedMapOf() + lastCompleteEvent?.forEach(event::put) + event["playerId"] = playerId + event["kind"] = stateChangedEventKind + event["state"] = state + event["durationMicros"] = durationMicros + event["positionMicros"] = positionMicros + event.putIfAbsent("buffering", false) + event.putIfAbsent("video", emptyMap()) + event.putIfAbsent("tracks", emptyMap()) + event.putIfAbsent("trackList", emptyList>()) + event.putIfAbsent("trackSelection", emptyMap()) + event["status"] = 0 + return event +} + +/** + * Builds a persistent snapshot from the one payload field that each native event kind owns. + * The JNI schema includes defaults for every other field, so copying a raw event wholesale + * would turn an unrelated surface/position notification into false zero-valued media state. + */ +internal fun androidUpdatedNativeEventSnapshot( + previous: Map?, + event: Map<*, *>, +): Map { + val snapshot = linkedMapOf() + previous?.forEach(snapshot::put) + event["playerId"]?.let { snapshot["playerId"] = it } + when ((event["kind"] as? Number)?.toInt()) { + STATE_CHANGED_EVENT_KIND -> + event.copySnapshotFieldTo(snapshot, "state") + ANDROID_DURATION_CHANGED_EVENT_KIND -> + event.copySnapshotFieldTo(snapshot, "durationMicros") + ANDROID_POSITION_CHANGED_EVENT_KIND -> + event.copySnapshotFieldTo(snapshot, "positionMicros") + ANDROID_TRACKS_CHANGED_EVENT_KIND -> { + event.copySnapshotFieldTo(snapshot, "tracks") + event.copySnapshotFieldTo(snapshot, "trackList") + event.copySnapshotFieldTo(snapshot, "trackSelection") + } + ANDROID_BUFFERING_CHANGED_EVENT_KIND -> + event.copySnapshotFieldTo(snapshot, "buffering") + ANDROID_VIDEO_PARAMS_CHANGED_EVENT_KIND -> + event.copySnapshotFieldTo(snapshot, "video") + ANDROID_TRACK_SELECTION_CHANGED_EVENT_KIND -> { + event.copySnapshotFieldTo(snapshot, "trackList") + event.copySnapshotFieldTo(snapshot, "trackSelection") + } + } + return snapshot +} + +private fun Map<*, *>.copySnapshotFieldTo( + destination: MutableMap, + field: String, +) { + if (containsKey(field)) { + destination[field] = this[field] + } +} + +/** A state snapshot may not overtake events left behind by a truncated/failed drain. */ +internal fun androidCanSynthesizeAuthoritativeState(eventQueueDrained: Boolean): Boolean = + eventQueueDrained + +/** Suppress the real queued StateChanged if an earlier authoritative snapshot already sent it. */ +internal fun androidStateChangedEventIsDuplicate( + currentPlaybackState: Int, + eventPlaybackState: Int?, +): Boolean = eventPlaybackState != null && currentPlaybackState == eventPlaybackState + +/** Separates a requested media switch from the point where native begins executing it. */ +internal class AndroidContentGenerationTracker { + private val requestedGeneration = AtomicLong(0L) + private val executedGeneration = AtomicLong(0L) + + val currentGeneration: Long + get() = requestedGeneration.get() + + val latestExecutedGeneration: Long + get() = executedGeneration.get() + + fun requestNewContent(): Long = requestedGeneration.incrementAndGet() + + fun markExecuted(generation: Long) { + executedGeneration.accumulateAndGet(generation) { current, candidate -> + maxOf(current, candidate) + } + } +} + +/** Keeps active playback responsive while backing stable paused players off. */ +internal fun androidEventPollDelayMillis( + hasActivePlayers: Boolean, + idleRounds: Int, +): Long { + if (hasActivePlayers) { + return 50L + } + return when (idleRounds.coerceAtLeast(0)) { + 0 -> 50L + 1 -> 100L + 2 -> 250L + 3 -> 500L + 4 -> 1_000L + 5 -> 2_500L + else -> 5_000L + } +} + +/** Persistent JNI failures back off even while playback intent remains active. */ +internal fun androidEventPollFailureDelayMillis(failureRounds: Int): Long = when ( + failureRounds.coerceAtLeast(0) +) { + 0 -> 0L + 1 -> 250L + 2 -> 500L + 3 -> 1_000L + 4 -> 2_500L + else -> 5_000L +} + +internal fun androidNextEventPollDelayMillis( + idleDelayMillis: Long, + hostRetryDelaysMillis: Iterable, +): Long = maxOf( + idleDelayMillis, + hostRetryDelaysMillis.minOrNull() ?: 0L, +) + +/** Native surface lifecycle events should not wait for a paused player's idle backoff. */ +internal fun androidSurfaceOperationNeedsImmediateEventPoll( + operation: String, + responseOk: Boolean, +): Boolean = responseOk && operation in ANDROID_SURFACE_EVENT_OPERATIONS + +/** Only the native command generation that still owns playback intent may change host state. */ +internal fun androidEventBatchAcceptsPlaybackState( + eventGeneration: Long, + currentIntentGeneration: Long, +): Boolean = eventGeneration == currentIntentGeneration + +/** No event from the previously executed media may cross a requested Open boundary. */ +internal fun androidEventBatchAcceptsContent( + eventGeneration: Long, + currentContentGeneration: Long, +): Boolean = eventGeneration == currentContentGeneration + +/** + * Surface lifecycle belongs to the presenter rather than the currently opened media, so + * those events survive a content-generation boundary. Within the current content, stale + * playback commands suppress only state rollback while retaining position/error/track events. + */ +internal fun androidEventShouldBeDelivered( + eventKind: Int?, + stateChangedEventKind: Int, + acceptsContent: Boolean, + acceptsPlaybackState: Boolean, + pendingPlayTransition: Boolean, +): Boolean { + val contentIndependent = eventKind == ANDROID_SURFACE_ATTACHED_EVENT_KIND || + eventKind == ANDROID_SURFACE_DETACHED_EVENT_KIND + return (acceptsContent || contentIndependent) && ( + eventKind != stateChangedEventKind || + (acceptsPlaybackState && !pendingPlayTransition) + ) +} + +/** A queued Play may be visible before the Rust worker commits actual Playing state. */ +internal fun androidPlaybackStateIsPendingPlayTransition( + playbackState: Int, + playbackIntentState: Int?, + playingState: Int, +): Boolean = playbackState != playingState && playbackIntentState == playingState + +/** MediaSession follows the accepted Playing intent while native commits asynchronously. */ +internal fun androidMediaSessionPlaybackState( + playbackState: Int, + playbackIntentState: Int?, + playingState: Int, + acceptsPlaybackState: Boolean, +): Int = if ( + acceptsPlaybackState && androidPlaybackStateIsPendingPlayTransition( + playbackState, + playbackIntentState, + playingState, + ) +) { + playingState +} else { + playbackState +} + +/** Only the callback that still owns an accepted Play may pause it during UI recovery. */ +internal fun androidAsyncPlayCallbackNeedsRollback( + nativePlayAccepted: Boolean, + isCurrentHost: Boolean, + ownsCurrentIntent: Boolean, +): Boolean = nativePlayAccepted && isCurrentHost && ownsCurrentIntent + +/** A failed current Open becomes an explicit native Close content boundary. */ +internal fun androidFailedContentOpenShouldClose( + method: String, + hostDestroyed: Boolean, + failedGeneration: Long?, + currentGeneration: Long, +): Boolean = method == "open" && + !hostDestroyed && + failedGeneration != null && + failedGeneration == currentGeneration + +/** + * A decoded Stop/Close response proves that JNI dispatched the content command even when + * the command reports a business failure. A failed Open is different: validation may fail + * before Rust establishes its Opening boundary, so its replacement Close owns the marker. + */ +internal fun androidContentCommandEstablishedBoundary( + method: String, + responseDecoded: Boolean, + responseOk: Boolean, +): Boolean = responseDecoded && (method != "open" || responseOk) diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidLatestTaskCoalescer.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidLatestTaskCoalescer.kt new file mode 100644 index 00000000..23be175a --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidLatestTaskCoalescer.kt @@ -0,0 +1,37 @@ +package dev.aimesoft.erika_flutter + +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** Keeps at most the newest task while one serial drain is active. */ +internal class AndroidLatestTaskCoalescer { + private val latest = AtomicReference(null) + private val drainScheduled = AtomicBoolean(false) + + /** Returns true when the caller must schedule a new drain. */ + fun submit(value: T): Boolean { + latest.set(value) + return drainScheduled.compareAndSet(false, true) + } + + fun takeLatest(): T? = latest.getAndSet(null) + + /** + * Ends one drain. Returns true when a task raced with drain completion and + * the current serial worker should immediately continue draining. + */ + fun finishDrain(): Boolean { + drainScheduled.set(false) + return latest.get() != null && drainScheduled.compareAndSet(false, true) + } + + fun cancelPending() { + latest.set(null) + } + + /** Drops pending work and releases a drain that could not be scheduled. */ + fun abortDrain() { + latest.set(null) + drainScheduled.set(false) + } +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidMediaState.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidMediaState.kt new file mode 100644 index 00000000..68ab4bfd --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidMediaState.kt @@ -0,0 +1,107 @@ +package dev.aimesoft.erika_flutter + +internal data class AndroidMediaMetadata( + val title: String, + val artist: String?, + val album: String?, + val artwork: ByteArray?, +) + +internal data class AndroidMediaState( + val playerId: Long, + val metadata: AndroidMediaMetadata? = null, + val playbackState: Int = 0, + val positionMicros: Long = 0L, + val durationMicros: Long = 0L, + val playbackRate: Float = 1f, + val allowBackgroundPlayback: Boolean = false, + val previousEnabled: Boolean = false, + val nextEnabled: Boolean = false, +) + +internal fun AndroidMediaState.canPlay(activityActive: Boolean): Boolean = + activityActive || allowBackgroundPlayback + +internal fun androidMediaMetadata(arguments: Map): AndroidMediaMetadata { + val raw = arguments["metadata"] as? Map<*, *> + ?: throw IllegalArgumentException("metadata is required") + val title = (raw["title"] as? String)?.trim().orEmpty() + require(title.isNotEmpty()) { "metadata.title is required" } + return AndroidMediaMetadata( + title = title, + artist = (raw["artist"] as? String)?.takeIf(String::isNotBlank), + album = (raw["album"] as? String)?.takeIf(String::isNotBlank), + artwork = raw["artwork"] as? ByteArray, + ) +} + +internal fun updatedSystemMediaNavigation( + state: AndroidMediaState, + arguments: Map, +): AndroidMediaState = state.copy( + previousEnabled = arguments["previousEnabled"] as? Boolean ?: false, + nextEnabled = arguments["nextEnabled"] as? Boolean ?: false, +) + +internal fun systemMediaNavigationEvent( + state: AndroidMediaState, + navigation: String, +): Map? { + val enabled = when (navigation) { + SYSTEM_MEDIA_NAVIGATION_PREVIOUS -> state.previousEnabled + SYSTEM_MEDIA_NAVIGATION_NEXT -> state.nextEnabled + else -> false + } + if (!enabled) { + return null + } + return linkedMapOf( + "playerId" to state.playerId, + "kind" to SYSTEM_MEDIA_NAVIGATION_EVENT_KIND, + "navigation" to navigation, + ) +} + +internal const val SYSTEM_MEDIA_NAVIGATION_EVENT_KIND = 13 +internal const val SYSTEM_MEDIA_NAVIGATION_PREVIOUS = "previous" +internal const val SYSTEM_MEDIA_NAVIGATION_NEXT = "next" + +internal fun updatedAndroidMediaState( + state: AndroidMediaState, + event: Map<*, *>, +): AndroidMediaState { + val kind = (event["kind"] as? Number)?.toInt() + val playbackState = if (kind == STATE_CHANGED_EVENT_KIND) { + (event["state"] as? Number)?.toInt() ?: state.playbackState + } else { + state.playbackState + } + if (kind == STATE_CHANGED_EVENT_KIND && playbackState == ANDROID_CLOSED_PLAYBACK_STATE) { + return closedAndroidMediaState(state, playbackState) + } + return state.copy( + playbackState = playbackState, + positionMicros = if (kind == 3) { + ((event["positionMicros"] as? Number)?.toLong() ?: state.positionMicros).coerceAtLeast(0L) + } else { + state.positionMicros + }, + durationMicros = if (kind == ANDROID_DURATION_CHANGED_EVENT_KIND) { + ((event["durationMicros"] as? Number)?.toLong() ?: state.durationMicros).coerceAtLeast(0L) + } else { + state.durationMicros + }, + ) +} + +internal fun closedAndroidMediaState( + state: AndroidMediaState, + playbackState: Int = ANDROID_CLOSED_PLAYBACK_STATE, +): AndroidMediaState = state.copy( + metadata = null, + playbackState = playbackState, + positionMicros = 0L, + durationMicros = 0L, +) + +internal const val ANDROID_CLOSED_PLAYBACK_STATE = 6 diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidNativeEventState.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidNativeEventState.kt new file mode 100644 index 00000000..5034be1e --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidNativeEventState.kt @@ -0,0 +1,20 @@ +package dev.aimesoft.erika_flutter + +internal const val STATE_CHANGED_EVENT_KIND = 1 + +/** + * Retains the latest authoritative playback state while draining native events. + * + * Every native event includes a state field for its payload schema, but only a + * StateChanged event represents a playback transition. PositionChanged and + * other events may carry the schema default and must not overwrite EOS/closed. + */ +internal fun updatedPlaybackState( + latestPlaybackState: Int?, + event: Map<*, *>, +): Int? { + if ((event["kind"] as? Number)?.toInt() != STATE_CHANGED_EVENT_KIND) { + return latestPlaybackState + } + return (event["state"] as? Number)?.toInt() ?: latestPlaybackState +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidOutputCapabilities.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidOutputCapabilities.kt new file mode 100644 index 00000000..3a7dff62 --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidOutputCapabilities.kt @@ -0,0 +1,87 @@ +package dev.aimesoft.erika_flutter + +internal const val OUTPUT_FALLBACK_NONE = 0 +internal const val OUTPUT_FALLBACK_DISPLAY_HDR_UNSUPPORTED = 1 +internal const val OUTPUT_FALLBACK_HYBRID_COMPOSITION_REQUIRED = 2 +internal const val OUTPUT_FALLBACK_WGPU_BACKEND_NOT_VULKAN = 3 +internal const val OUTPUT_FALLBACK_RGBA16FLOAT_SURFACE_FORMAT_UNAVAILABLE = 4 +internal const val OUTPUT_FALLBACK_NATIVE_WINDOW_DATASPACE_API_UNAVAILABLE = 5 +internal const val OUTPUT_FALLBACK_SCRGB_DATASPACE_VERIFICATION_FAILED = 6 +internal const val OUTPUT_FALLBACK_SURFACE_CONFIGURE_FAILED = 7 +internal const val OUTPUT_FALLBACK_LEGACY_APPLE_EDR_UNSUPPORTED = 8 + +internal data class AndroidOutputCapabilityDecision( + val extendedLinearEligible: Boolean, + val fallbackReason: Int, +) + +internal data class AndroidHdrHeadroomState( + val headroom: Float, + val known: Boolean, +) + +internal fun androidDesiredHdrHeadroom(value: Float?): Float { + if (value == null || !value.isFinite()) { + return 0f + } + return if (value == 0f || value in 1f..10_000f) value else 0f +} + +internal fun androidHdrHeadroomState( + ratioAvailable: Boolean, + ratio: Float, +): AndroidHdrHeadroomState = if (ratioAvailable && ratio.isFinite() && ratio >= 1f) { + AndroidHdrHeadroomState(headroom = ratio, known = true) +} else { + AndroidHdrHeadroomState(headroom = 1f, known = false) +} + +internal fun androidOutputCapabilityDecision( + extendedLinearRequested: Boolean, + sdkInt: Int, + displayHdrSupported: Boolean, + directComposition: Boolean, +): AndroidOutputCapabilityDecision { + if (!extendedLinearRequested) { + return AndroidOutputCapabilityDecision( + extendedLinearEligible = false, + fallbackReason = OUTPUT_FALLBACK_NONE, + ) + } + if (sdkInt < 28) { + return AndroidOutputCapabilityDecision( + extendedLinearEligible = false, + fallbackReason = OUTPUT_FALLBACK_NATIVE_WINDOW_DATASPACE_API_UNAVAILABLE, + ) + } + if (!displayHdrSupported) { + return AndroidOutputCapabilityDecision( + extendedLinearEligible = false, + fallbackReason = OUTPUT_FALLBACK_DISPLAY_HDR_UNSUPPORTED, + ) + } + return AndroidOutputCapabilityDecision( + extendedLinearEligible = true, + fallbackReason = if (directComposition) { + OUTPUT_FALLBACK_NONE + } else { + OUTPUT_FALLBACK_HYBRID_COMPOSITION_REQUIRED + }, + ) +} + +internal fun androidOutputFallbackReasonLabel(reason: Int): String = when (reason) { + OUTPUT_FALLBACK_NONE -> "none" + OUTPUT_FALLBACK_DISPLAY_HDR_UNSUPPORTED -> "display_hdr_unsupported" + OUTPUT_FALLBACK_HYBRID_COMPOSITION_REQUIRED -> "hybrid_composition_required" + OUTPUT_FALLBACK_WGPU_BACKEND_NOT_VULKAN -> "wgpu_backend_not_vulkan" + OUTPUT_FALLBACK_RGBA16FLOAT_SURFACE_FORMAT_UNAVAILABLE -> + "rgba16float_surface_format_unavailable" + OUTPUT_FALLBACK_NATIVE_WINDOW_DATASPACE_API_UNAVAILABLE -> + "native_window_dataspace_api_unavailable" + OUTPUT_FALLBACK_SCRGB_DATASPACE_VERIFICATION_FAILED -> + "scrgb_dataspace_verification_failed" + OUTPUT_FALLBACK_SURFACE_CONFIGURE_FAILED -> "surface_configure_failed" + OUTPUT_FALLBACK_LEGACY_APPLE_EDR_UNSUPPORTED -> "legacy_apple_edr_unsupported" + else -> "unknown($reason)" +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPendingEventQueue.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPendingEventQueue.kt new file mode 100644 index 00000000..8556ea31 --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPendingEventQueue.kt @@ -0,0 +1,71 @@ +package dev.aimesoft.erika_flutter + +internal sealed interface AndroidPendingEvent { + /** Null marks a presenter/Surface event that may cross an Open boundary. */ + val contentGeneration: Long? + + data class Success( + val value: Map, + override val contentGeneration: Long?, + ) : AndroidPendingEvent + + data class Error( + val code: String, + val message: String, + val details: Map, + override val contentGeneration: Long?, + ) : AndroidPendingEvent +} + +internal data class AndroidPendingEventOverflow( + val dropped: AndroidPendingEvent, + val droppedTotal: Long, + val capacity: Int, +) + +/** + * Per-player FIFO used while Flutter has no active EventChannel listener. + * + * The queue deliberately drops the oldest item on overflow. This preserves the + * most recent playback/error state while keeping memory bounded if Dart stays + * detached for an extended playback session. + */ +internal class AndroidPendingEventQueue( + val capacity: Int, +) { + private val events = ArrayDeque() + private var droppedTotal = 0L + + init { + require(capacity > 0) { "Pending event queue capacity must be positive" } + } + + val size: Int + get() = events.size + + fun enqueue(event: AndroidPendingEvent): AndroidPendingEventOverflow? { + val dropped = if (events.size >= capacity) events.removeFirst() else null + events.addLast(event) + if (dropped == null) { + return null + } + droppedTotal += 1 + return AndroidPendingEventOverflow(dropped, droppedTotal, capacity) + } + + fun firstOrNull(): AndroidPendingEvent? = events.firstOrNull() + + fun removeFirst(): AndroidPendingEvent = events.removeFirst() + + fun discardStaleContentEvents(currentContentGeneration: Long): Int { + val originalSize = events.size + events.removeAll { event -> + event.contentGeneration?.let { it != currentContentGeneration } == true + } + return originalSize - events.size + } + + fun clear() { + events.clear() + } +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPlaybackTracker.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPlaybackTracker.kt new file mode 100644 index 00000000..233e233c --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPlaybackTracker.kt @@ -0,0 +1,164 @@ +package dev.aimesoft.erika_flutter + +internal enum class AndroidPlaybackPhase { + PAUSED, + PENDING, + PLAYING, +} + +internal fun androidAsyncPlayCanStart( + phase: AndroidPlaybackPhase, + canPlayInCurrentActivityState: Boolean, + audioFocusGranted: Boolean, +): Boolean = phase == AndroidPlaybackPhase.PENDING && + canPlayInCurrentActivityState && + audioFocusGranted + +/** + * Main-thread playback and render intent for one Android player. + * + * Native playback state remains owned by Erika. This tracker records only the + * Android host's intent, including whether a delayed focus gain may resume the + * player and whether the attached surface needs another render tick. + */ +internal class AndroidPlaybackTracker { + var phase: AndroidPlaybackPhase = AndroidPlaybackPhase.PAUSED + private set + + @Volatile + var surfaceAttached: Boolean = false + private set + + @Volatile + private var renderRequestGeneration: Long = 0L + @Volatile + private var acknowledgedRenderGeneration: Long = 0L + private var playbackIntentGeneration: Long = 0L + private var playInvocationGeneration: Long? = null + + val currentRenderRequestGeneration: Long + get() = renderRequestGeneration + + val currentPlaybackIntentGeneration: Long + get() = playbackIntentGeneration + + val renderRequested: Boolean + get() = acknowledgedRenderGeneration < renderRequestGeneration + + val shouldTick: Boolean + get() = phase == AndroidPlaybackPhase.PLAYING || + (surfaceAttached && renderRequested) + + fun requestPlayback(): Long { + if (phase != AndroidPlaybackPhase.PENDING) { + playbackIntentGeneration += 1L + } + phase = AndroidPlaybackPhase.PENDING + return playbackIntentGeneration + } + + /** Invalidates a queued transient-loss Pause before resuming the pending Play. */ + fun renewPendingPlaybackIntent(): Long? { + if (phase != AndroidPlaybackPhase.PENDING) { + return null + } + playbackIntentGeneration += 1L + return playbackIntentGeneration + } + + fun tryBeginPlayInvocation(): Long? { + if (phase != AndroidPlaybackPhase.PENDING || playInvocationGeneration != null) { + return null + } + return playbackIntentGeneration.also { generation -> + playInvocationGeneration = generation + } + } + + /** Returns true only when the completed invocation still represents the latest intent. */ + fun finishPlayInvocation(generation: Long): Boolean { + if (playInvocationGeneration != generation) { + return false + } + playInvocationGeneration = null + return playbackIntentGeneration == generation && + phase != AndroidPlaybackPhase.PAUSED + } + + fun playbackStarted(): Boolean { + if (phase != AndroidPlaybackPhase.PENDING) { + return false + } + phase = AndroidPlaybackPhase.PLAYING + requestRender() + return true + } + + /** Returns true when native playback was running and must be paused. */ + fun suspendPlayback(): Boolean { + val wasPlaying = phase == AndroidPlaybackPhase.PLAYING + if (phase != AndroidPlaybackPhase.PAUSED) { + phase = AndroidPlaybackPhase.PENDING + } + return wasPlaying + } + + /** Returns true when native playback is or may soon be running and must be paused. */ + fun handleFocusLoss(mayResume: Boolean): Boolean { + val nativeMayBePlaying = phase == AndroidPlaybackPhase.PLAYING || + playInvocationGeneration != null + if (phase != AndroidPlaybackPhase.PAUSED) { + playbackIntentGeneration += 1L + } + phase = if (mayResume && phase != AndroidPlaybackPhase.PAUSED) { + AndroidPlaybackPhase.PENDING + } else { + AndroidPlaybackPhase.PAUSED + } + return nativeMayBePlaying + } + + /** Returns true when native playback is or may soon be running and must be paused. */ + fun cancelPlaybackIntent(forceNewGeneration: Boolean = false): Boolean { + val nativeMayBePlaying = phase == AndroidPlaybackPhase.PLAYING || + playInvocationGeneration != null + if (forceNewGeneration || phase != AndroidPlaybackPhase.PAUSED) { + playbackIntentGeneration += 1L + } + phase = AndroidPlaybackPhase.PAUSED + return nativeMayBePlaying + } + + /** Reconciles a native terminal/paused state without inventing a command generation. */ + fun reconcileNativePlaybackStopped() { + phase = AndroidPlaybackPhase.PAUSED + } + + fun attachSurface() { + surfaceAttached = true + requestRender() + } + + fun resizeSurface() { + if (surfaceAttached) { + requestRender() + } + } + + fun detachSurface() { + surfaceAttached = false + } + + @Synchronized + fun requestRender(): Long { + renderRequestGeneration += 1L + return renderRequestGeneration + } + + @Synchronized + fun markRenderAttempted(generation: Long) { + if (generation > acknowledgedRenderGeneration) { + acknowledgedRenderGeneration = generation.coerceAtMost(renderRequestGeneration) + } + } +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPlayerHost.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPlayerHost.kt new file mode 100644 index 00000000..9e3efb1d --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPlayerHost.kt @@ -0,0 +1,486 @@ +package dev.aimesoft.erika_flutter + +import android.view.Surface +import java.util.concurrent.atomic.AtomicLong + +internal class AndroidPlayerHost( + val handle: Long, + val requestedOutputMode: Int, + allowBackgroundPlayback: Boolean, + private val presenterThread: AndroidPresenterThread, +) { + val requiresExtendedLinearSurface: Boolean + get() = requestedOutputMode == 2 + var attachedView: ErikaAndroidVideoView? = null + private val playbackTracker = AndroidPlaybackTracker() + private val contentGenerationTracker = AndroidContentGenerationTracker() + private val contentPreparations = AndroidContentPreparationRegistry() + private val pendingEvents = AndroidPendingEventQueue(MAX_PENDING_EVENTS) + val eventPollFailures = AndroidEventPollFailureDeduplicator() + val eventPollBackoff = AndroidEventPollBackoff() + private var lastCompleteNativeEvent: Map? = null + var mediaState = AndroidMediaState( + playerId = handle, + allowBackgroundPlayback = allowBackgroundPlayback, + ) + private set + val playbackPhase: AndroidPlaybackPhase + get() = playbackTracker.phase + val surfaceAttached: Boolean + get() = playbackTracker.surfaceAttached + val shouldTick: Boolean + get() = playbackTracker.shouldTick + val currentRenderRequestGeneration: Long + get() = playbackTracker.currentRenderRequestGeneration + val currentPlaybackIntentGeneration: Long + get() = playbackTracker.currentPlaybackIntentGeneration + private val executedPlaybackIntentGeneration = AtomicLong(0L) + val latestExecutedPlaybackIntentGeneration: Long + get() = executedPlaybackIntentGeneration.get() + val currentContentGeneration: Long + get() = contentGenerationTracker.currentGeneration + val latestExecutedContentGeneration: Long + get() = contentGenerationTracker.latestExecutedGeneration + var lastRenderError: String? = null + var lastRenderErrorContentGeneration: Long? = null + var lastSurfaceError: String? = null + @Volatile + private var destroyed = false + @Volatile + private var nativeDestroyPending = false + private var viewAwaitingNativeDestroy: ErikaAndroidVideoView? = null + + val isDestroyed: Boolean + get() = destroyed + + val isNativeDestroyPending: Boolean + get() = nativeDestroyPending + + fun enqueuePendingEvent(event: AndroidPendingEvent): AndroidPendingEventOverflow? = + pendingEvents.enqueue(event) + + fun firstPendingEvent(): AndroidPendingEvent? = pendingEvents.firstOrNull() + + fun removeFirstPendingEvent(): AndroidPendingEvent = pendingEvents.removeFirst() + + fun discardStalePendingEvents(): Int = + pendingEvents.discardStaleContentEvents(currentContentGeneration) + + fun requestPlayback(): Long = playbackTracker.requestPlayback() + + fun renewPendingPlaybackIntent(): Long? = playbackTracker.renewPendingPlaybackIntent() + + fun tryBeginPlayInvocation(): Long? = playbackTracker.tryBeginPlayInvocation() + + fun finishPlayInvocation(generation: Long): Boolean = + playbackTracker.finishPlayInvocation(generation) + + fun markPlaybackIntentExecuted(generation: Long) { + executedPlaybackIntentGeneration.accumulateAndGet(generation) { current, candidate -> + maxOf(current, candidate) + } + } + + fun playbackStarted(): Boolean = playbackTracker.playbackStarted() + + fun suspendPlayback(): Boolean = playbackTracker.suspendPlayback() + + fun handleFocusLoss(mayResume: Boolean): Boolean = + playbackTracker.handleFocusLoss(mayResume) + + fun cancelPlaybackIntent(forceNewGeneration: Boolean = false): Boolean = + playbackTracker.cancelPlaybackIntent(forceNewGeneration) + + /** Cancels an intent that will not be followed by a native command. */ + fun cancelPlaybackIntentLocally(forceNewGeneration: Boolean = false): Boolean = + playbackTracker.cancelPlaybackIntent(forceNewGeneration).also { + markPlaybackIntentExecuted(currentPlaybackIntentGeneration) + } + + fun reconcileNativePlaybackStopped() { + playbackTracker.reconcileNativePlaybackStopped() + } + + fun setMediaMetadata(metadata: AndroidMediaMetadata?) { + mediaState = mediaState.copy(metadata = metadata) + } + + fun prepareForOpen(metadata: AndroidMediaMetadata?): Long { + lastCompleteNativeEvent = null + mediaState = mediaState.copy( + metadata = metadata, + playbackState = 0, + positionMicros = 0L, + durationMicros = 0L, + ) + return contentGenerationTracker.requestNewContent().also { + // Content-scoped events can remain queued while Dart has no + // listener or after a sink exception. Never let media A roll back + // media B once its Open boundary has been requested. + discardStalePendingEvents() + } + } + + fun markContentGenerationExecuted(generation: Long) { + contentGenerationTracker.markExecuted(generation) + } + + fun setSystemMediaNavigation(arguments: Map) { + mediaState = updatedSystemMediaNavigation(mediaState, arguments) + } + + fun setPlaybackRate(rate: Float) { + mediaState = mediaState.copy(playbackRate = rate) + } + + fun closeMediaState() { + lastCompleteNativeEvent = null + mediaState = closedAndroidMediaState(mediaState) + } + + fun updateMediaState(event: Map<*, *>, rememberCompleteEvent: Boolean = false) { + mediaState = updatedAndroidMediaState(mediaState, event) + if (rememberCompleteEvent) { + lastCompleteNativeEvent = androidUpdatedNativeEventSnapshot( + lastCompleteNativeEvent, + event, + ) + } + } + + fun completeNativeEventSnapshot(): Map? = lastCompleteNativeEvent + + fun requestRender() = playbackTracker.requestRender() + + fun markRenderAttempted(generation: Long) = playbackTracker.markRenderAttempted(generation) + + fun beginContentPreparation( + onCancel: (String) -> Unit, + ): AndroidContentPreparationToken = contentPreparations.begin(onCancel) + + fun finishContentPreparation(token: AndroidContentPreparationToken): Boolean = + !destroyed && contentPreparations.finish(token) + + fun cancelContentPreparations(reason: String): Int = contentPreparations.invalidate(reason) + + fun invoke(method: String, arguments: Map): NativeResponse { + return invokeEncoded(method, NativeJson.encodeArguments(arguments)) + } + + fun invokeEncoded( + method: String, + argumentsJson: String, + ownedFd: Int = NO_OWNED_FD, + ): NativeResponse = NativeJson.decodeResponse( + invokeEncodedRaw(method, argumentsJson, ownedFd), + ) + + /** + * Returns the raw JNI response so callers transferring an fd can separate + * failures before native dispatch from JSON decoding failures after Rust + * has already taken ownership. + */ + fun invokeEncodedRaw( + method: String, + argumentsJson: String, + ownedFd: Int = NO_OWNED_FD, + ): String { + if (destroyed) { + throw AndroidPlayerDestroyedException(handle) + } + return presenterThread.call { + ErikaNative.nativeInvoke(handle, method, argumentsJson, ownedFd) + } + } + + fun attachSurface( + surface: Surface, + width: Int, + height: Int, + scale: Double, + extendedLinear: Boolean, + directComposition: Boolean, + desiredHeadroom: Float, + fallbackReason: Int, + ): NativeResponse { + check(!destroyed) { "Erika player $handle has been destroyed" } + val response = presenterThread.call { + NativeJson.decodeResponse( + ErikaNative.nativeAttachSurface( + handle, + surface, + width, + height, + scale, + extendedLinear, + directComposition, + desiredHeadroom, + fallbackReason, + ), + ) + } + if (response.ok) { + playbackTracker.attachSurface() + } + return response + } + + fun attachSurfaceAsync( + surface: Surface, + width: Int, + height: Int, + scale: Double, + extendedLinear: Boolean, + directComposition: Boolean, + desiredHeadroom: Float, + fallbackReason: Int, + onComplete: (Result) -> Unit, + ): Boolean = presenterThread.post { + onComplete( + runCatching { + attachSurface( + surface, + width, + height, + scale, + extendedLinear, + directComposition, + desiredHeadroom, + fallbackReason, + ) + }, + ) + } + + fun resizeSurface(width: Int, height: Int, scale: Double): NativeResponse { + if (!surfaceAttached || destroyed) { + return NativeResponse.success() + } + val response = presenterThread.call { + NativeJson.decodeResponse( + ErikaNative.nativeResizeSurface(handle, width, height, scale), + ) + } + if (response.ok) { + playbackTracker.resizeSurface() + } + return response + } + + fun resizeSurfaceAsync( + width: Int, + height: Int, + scale: Double, + onComplete: (Result) -> Unit, + ): Boolean = presenterThread.post { + onComplete(runCatching { resizeSurface(width, height, scale) }) + } + + fun setOutputHeadroom(headroom: Float, known: Boolean): NativeResponse = + invoke( + "setOutputHeadroom", + mapOf( + "headroom" to headroom, + "known" to known, + ), + ) + + fun setOutputHeadroomAsync( + headroom: Float, + known: Boolean, + onComplete: (Result) -> Unit, + ): Boolean = presenterThread.post { + onComplete(runCatching { setOutputHeadroom(headroom, known) }) + } + + fun detachSurface(): NativeResponse { + if (!surfaceAttached || destroyed) { + return NativeResponse.success() + } + val response = presenterThread.call { + NativeJson.decodeResponse(ErikaNative.nativeDetachSurface(handle)) + } + if (response.ok) { + playbackTracker.detachSurface() + } + return response + } + + /** + * Queues a detach behind any in-flight render without making Android's UI thread wait. + * The callback runs on the presenter owner thread; callers must marshal UI work back to + * the main looper. + */ + fun detachSurfaceAsync( + onComplete: (Result) -> Unit, + ): Boolean = presenterThread.post { + onComplete(runCatching(::detachSurface)) + } + + /** + * SurfaceHolder invalidates its Surface as soon as surfaceDestroyed returns. Place the + * native detach behind already queued presenter work and wait for a short, bounded + * lifecycle barrier. Keep native attachment state until success so a timeout or failure + * cannot let replacement binding skip the serialized retry. + */ + fun detachSurfaceForSystemDestroy(): NativeResponse { + if (destroyed) { + if (!nativeDestroyPending) { + return NativeResponse.success() + } + return try { + // nativeDestroy is already queued on the same serial owner. A no-op + // barrier therefore proves it has finished dropping the Surface. + presenterThread.callForSurfaceDestroy(SURFACE_DESTROY_TIMEOUT_MILLIS) { Unit } + NativeResponse.success() + } catch (error: Throwable) { + NativeResponse( + false, + -1, + error.message ?: "Unable to retire Android SurfaceView output", + null, + ) + } + } + if (!surfaceAttached) { + return NativeResponse.success() + } + val response = try { + presenterThread.callForSurfaceDestroy(SURFACE_DESTROY_TIMEOUT_MILLIS) { + NativeJson.decodeResponse(ErikaNative.nativeDetachSurface(handle)) + } + } catch (error: Throwable) { + NativeResponse( + false, + -1, + error.message ?: "Unable to detach Android SurfaceView output", + null, + ) + } + if (response.ok) { + playbackTracker.detachSurface() + } + return response + } + + fun renderTick(timeSeconds: Double): NativeResponse { + check(!destroyed) { "Erika player $handle has been destroyed" } + return presenterThread.call { + NativeJson.decodeResponse(ErikaNative.nativeRenderTick(handle, timeSeconds)) + } + } + + fun audioOnlyTick(): NativeResponse { + check(!destroyed) { "Erika player $handle has been destroyed" } + return presenterThread.call { + NativeJson.decodeResponse(ErikaNative.nativeAudioOnlyTick(handle)) + } + } + + fun pollEvent(): NativeResponse? { + if (destroyed) { + return null + } + return presenterThread.call { + NativeJson.decodeOptionalEventResponse(ErikaNative.nativePollEvent(handle)) + } + } + + fun playbackState(): Int { + check(!destroyed) { "Erika player $handle has been destroyed" } + return presenterThread.call { + ErikaNative.nativePlaybackState(handle).also { state -> + check(state >= 0) { "Unable to read Erika player $handle playback state" } + } + } + } + + fun playbackIntentState(): Int { + check(!destroyed) { "Erika player $handle has been destroyed" } + return presenterThread.call { + ErikaNative.nativePlaybackIntentState(handle).also { state -> + check(state >= 0) { "Unable to read Erika player $handle playback intent state" } + } + } + } + + fun captureFrame(width: Int, height: Int): ByteArray? { + check(!destroyed) { "Erika player $handle has been destroyed" } + return presenterThread.call { + ErikaNative.nativeCaptureFrame(handle, width, height) + } + } + + fun captureFrameAsync( + width: Int, + height: Int, + onComplete: (Result) -> Unit, + ): Boolean = presenterThread.post { + onComplete(runCatching { captureFrame(width, height) }) + } + + fun registerSubtitleMemoryFont(data: ByteArray): NativeResponse { + check(!destroyed) { "Erika player $handle has been destroyed" } + return presenterThread.call { + NativeJson.decodeResponse( + ErikaNative.nativeRegisterSubtitleMemoryFont(handle, data), + ) + } + } + + fun registerSubtitleMemoryFontAsync( + data: ByteArray, + onComplete: (Result) -> Unit, + ): Boolean = presenterThread.post { + onComplete(runCatching { registerSubtitleMemoryFont(data) }) + } + + fun destroyAsync(onComplete: (Result) -> Unit = {}): Boolean { + if (destroyed) { + onComplete(Result.success(Unit)) + return true + } + // Publish logical destruction before the queued native boundary so a render drain + // requeued behind nativeDestroy cannot race the already-freed presenter handle. + destroyed = true + nativeDestroyPending = true + val posted = presenterThread.post { + val result = runCatching { ErikaNative.nativeDestroy(handle) } + nativeDestroyPending = false + onComplete(result) + } + if (!posted) { + // No native work started. Keep the host retryable for a failed Dispose call. + destroyed = false + nativeDestroyPending = false + return false + } + cancelContentPreparations("player_disposed") + val view = attachedView + attachedView = null + viewAwaitingNativeDestroy = view + view?.onPlayerDestroyQueued(this) + pendingEvents.clear() + playbackTracker.detachSurface() + return true + } + + /** Runs on Android's main thread only after nativeDestroy has completed. */ + fun finishDestroyOnMain() { + val view = viewAwaitingNativeDestroy + viewAwaitingNativeDestroy = null + view?.onPlayerDestroyed(this) + } + + private companion object { + const val NO_OWNED_FD = -1 + const val MAX_PENDING_EVENTS = 1024 + const val SURFACE_DESTROY_TIMEOUT_MILLIS = 250L + } +} + +internal class AndroidPlayerDestroyedException(handle: Long) : + IllegalStateException("Erika player $handle has been destroyed") + +internal fun androidNativeInvokeDidNotStart(error: Throwable): Boolean = + error is AndroidPlayerDestroyedException || error is UnsatisfiedLinkError diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPresenterCreateRegistry.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPresenterCreateRegistry.kt new file mode 100644 index 00000000..712ba5f4 --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPresenterCreateRegistry.kt @@ -0,0 +1,79 @@ +package dev.aimesoft.erika_flutter + +internal data class AndroidPendingPresenterCreate( + val handle: Long, + val owner: T, + val generation: Long, +) + +/** + * Serializes native presenter creation with Flutter-engine attachment changes. + * + * Native handles are registered here on their owner thread before completion is + * posted to Android's main looper. Engine detach retires every unclaimed handle + * before closing that owner thread, so a late completion can never publish a + * handle owned by a previous attachment. + */ +internal class AndroidPresenterCreateRegistry { + private var generation = 0L + private var currentOwner: T? = null + private val pending = linkedMapOf>() + + @Synchronized + fun attach(owner: T): Long { + generation = generation.saturatingIncrement() + currentOwner = owner + return generation + } + + @Synchronized + fun registerIfCurrent(handle: Long, owner: T, attachmentGeneration: Long): Boolean { + if (currentOwner !== owner || generation != attachmentGeneration) { + return false + } + pending[handle] = AndroidPendingPresenterCreate(handle, owner, attachmentGeneration) + return true + } + + @Synchronized + fun claimIfCurrent(handle: Long, owner: T, attachmentGeneration: Long): Boolean { + val entry = pending[handle] ?: return false + if ( + entry.owner !== owner || + entry.generation != attachmentGeneration || + currentOwner !== owner || + generation != attachmentGeneration + ) { + return false + } + pending.remove(handle) + return true + } + + @Synchronized + fun abandon(handle: Long, owner: T): Boolean { + val entry = pending[handle] ?: return false + if (entry.owner !== owner) { + return false + } + pending.remove(handle) + return true + } + + @Synchronized + fun detach(owner: T): List> { + if (currentOwner === owner) { + generation = generation.saturatingIncrement() + currentOwner = null + } + val retired = pending.values.filter { it.owner === owner } + retired.forEach { pending.remove(it.handle) } + return retired + } + + @Synchronized + fun isCurrent(owner: T, attachmentGeneration: Long): Boolean = + currentOwner === owner && generation == attachmentGeneration +} + +private fun Long.saturatingIncrement(): Long = if (this == Long.MAX_VALUE) 1L else this + 1L diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPresenterThread.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPresenterThread.kt new file mode 100644 index 00000000..cba70283 --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidPresenterThread.kt @@ -0,0 +1,151 @@ +package dev.aimesoft.erika_flutter + +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.os.Process +import android.util.Log +import java.util.concurrent.Callable +import java.util.concurrent.ExecutionException +import java.util.concurrent.FutureTask +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicBoolean + +internal fun androidPresenterCallMustBeAsync( + isOwnerThread: Boolean, + isAndroidMainThread: Boolean, +): Boolean = !isOwnerThread && isAndroidMainThread + +internal inline fun androidPresenterTaskResult(block: () -> Unit): Result = runCatching(block) + +/** + * Serial owner thread for Android presenter handles. + * + * Rust records the JNI thread that creates a presenter and rejects every call + * from a different thread. Keeping creation, commands, surface operations, + * rendering, capture, event polling, and destruction on this dispatcher makes + * that ownership explicit while leaving Flutter's platform thread responsive. + */ +internal class AndroidPresenterThread( + name: String = "erika-presenter", +) : AutoCloseable { + private val closed = AtomicBoolean(false) + private val thread = HandlerThread(name, Process.THREAD_PRIORITY_DISPLAY).apply { start() } + private val handler = Handler(thread.looper) + + val isOwnerThread: Boolean + get() = Looper.myLooper() === thread.looper + + fun post(block: () -> Unit): Boolean { + if (closed.get()) { + return false + } + return handler.post { + androidPresenterTaskResult(block).onFailure { error -> + Log.e(TAG, "Unhandled Android presenter task failure", error) + } + } + } + + fun call(block: () -> T): T { + if (isOwnerThread) { + return block() + } + check(!androidPresenterCallMustBeAsync( + isOwnerThread = false, + isAndroidMainThread = Looper.myLooper() === Looper.getMainLooper(), + )) { + "Android UI thread must post presenter work asynchronously" + } + check(!closed.get()) { "Android presenter thread is closed" } + val task = FutureTask(Callable(block)) + check(handler.post(task)) { "Android presenter thread rejected a task" } + return try { + task.get() + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + throw IllegalStateException("Interrupted while waiting for Android presenter thread", error) + } catch (error: ExecutionException) { + val cause = error.cause ?: error + when (cause) { + is RuntimeException -> throw cause + is Error -> throw cause + else -> throw IllegalStateException("Android presenter task failed", cause) + } + } + } + + /** + * SurfaceView does not let its owner retain the underlying buffer queue after + * surfaceDestroyed returns. Queue a native detach behind earlier presenter work and + * wait for only this lifecycle boundary; regular rendering never uses this path. + * + * A timed-out task deliberately remains queued. This stops the UI thread from waiting + * indefinitely behind a slow Open while still ensuring native eventually drops the + * retired window before any subsequently posted render work. + */ + fun callForSurfaceDestroy(timeoutMillis: Long, block: () -> T): T { + if (isOwnerThread) { + return block() + } + check(Looper.myLooper() === Looper.getMainLooper()) { + "Surface destroy barriers may only wait from Android's UI thread" + } + check(timeoutMillis > 0L) { "Surface destroy timeout must be positive" } + check(!closed.get()) { "Android presenter thread is closed" } + val task = FutureTask(Callable(block)) + check(handler.post(task)) { "Android presenter thread rejected a surface destroy barrier" } + return try { + task.get(timeoutMillis, TimeUnit.MILLISECONDS) + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + throw IllegalStateException("Interrupted while detaching an Android surface", error) + } catch (error: TimeoutException) { + throw IllegalStateException( + "Timed out after ${timeoutMillis}ms detaching an Android surface", + error, + ) + } catch (error: ExecutionException) { + val cause = error.cause ?: error + when (cause) { + is RuntimeException -> throw cause + is Error -> throw cause + else -> throw IllegalStateException("Android surface detach failed", cause) + } + } + } + + override fun close() { + if (!closed.compareAndSet(false, true)) { + return + } + thread.quitSafely() + // Flutter detaches plugins on Android's UI thread. Joining a presenter + // that is retiring a slow Open would recreate the ANR this dispatcher + // exists to avoid; quitSafely drains the already-queued native destroys. + if (Looper.myLooper() === Looper.getMainLooper()) { + return + } + if (!isOwnerThread) { + try { + thread.join(SHUTDOWN_TIMEOUT_MILLIS) + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + } + if (thread.isAlive) { + thread.quit() + try { + thread.join(SHUTDOWN_TIMEOUT_MILLIS) + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + } + } + } + } + + private companion object { + const val TAG = "AndroidPresenterThread" + const val SHUTDOWN_TIMEOUT_MILLIS = 2_000L + } +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidSurfaceLifecycle.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidSurfaceLifecycle.kt new file mode 100644 index 00000000..a4831524 --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidSurfaceLifecycle.kt @@ -0,0 +1,185 @@ +package dev.aimesoft.erika_flutter + +internal data class AndroidSurfaceDestroyDecision( + val releaseSurfaceTexture: Boolean, + val retryNativeDetach: Boolean, +) + +internal fun androidSurfaceDestroyDecision( + nativeDetachSucceeded: Boolean, +): AndroidSurfaceDestroyDecision = AndroidSurfaceDestroyDecision( + // Native detach is asynchronous for TextureView. Retain ownership and release the + // SurfaceTexture explicitly only after that presenter boundary has completed. + releaseSurfaceTexture = false, + // Native attachment state is committed only on success, so a failed detach + // remains explicit and can be retried before binding the next SurfaceTexture. + retryNativeDetach = !nativeDetachSucceeded, +) + +/** A failed detach remains explicit until a retry or native destroy confirms retirement. */ +internal fun androidSurfaceDestroyNeedsRetry( + nativeDetachSucceeded: Boolean, + hostDestroying: Boolean, +): Boolean = !hostDestroying && !nativeDetachSucceeded + +internal const val ANDROID_SURFACE_RECOVERY_MAX_RETRIES = 6 + +private val androidSurfaceRecoveryDelaysMillis = listOf(16L, 32L, 64L, 128L, 256L, 512L) + +/** Returns null once the bounded recovery budget has been exhausted. */ +internal fun androidSurfaceRecoveryDelayMillis(retryAttempt: Int): Long? { + if (retryAttempt <= 0) { + return null + } + return androidSurfaceRecoveryDelaysMillis.getOrNull(retryAttempt - 1) +} + +/** Counts completed native failures, rather than merely queued retry tasks. */ +internal class AndroidSurfaceRecoveryAttemptTracker { + private var generation: Long? = null + private var operation: String? = null + private var failures = 0 + private var exhaustionReported = false + + fun recordFailure(generation: Long, operation: String): Int { + if (this.generation != generation || this.operation != operation) { + this.generation = generation + this.operation = operation + failures = 0 + exhaustionReported = false + } + failures += 1 + return failures + } + + fun complete(generation: Long, operation: String): Boolean { + if (this.generation != generation || this.operation != operation || failures == 0) { + return false + } + reset() + return true + } + + fun markExhaustionReported(generation: Long, operation: String): Boolean { + if (this.generation != generation || this.operation != operation || exhaustionReported) { + return false + } + exhaustionReported = true + return true + } + + fun reset() { + generation = null + operation = null + failures = 0 + exhaustionReported = false + } +} + +internal fun androidShouldRefreshHdrHeadroomAfterRecovery( + hostStillBound: Boolean, + surfaceAttached: Boolean, + disposed: Boolean, + disposeRequested: Boolean, + unbindRequested: Boolean, +): Boolean = hostStillBound && + surfaceAttached && + !disposed && + !disposeRequested && + !unbindRequested + +internal fun androidShouldResumePendingViewBind( + hostDestroyed: Boolean, + targetDisposed: Boolean, + targetDisposeRequested: Boolean, + targetAcceptsHost: Boolean, + hostAcceptsTarget: Boolean, +): Boolean = !hostDestroyed && + !targetDisposed && + !targetDisposeRequested && + targetAcceptsHost && + hostAcceptsTarget + +/** A successful detach still completes an unbind that a newer bind superseded. */ +internal fun androidDetachCompletesSupersededUnbind( + nativeDetachSucceeded: Boolean, + unbindRequested: Boolean, + disposeRequested: Boolean, +): Boolean = nativeDetachSucceeded && !unbindRequested && !disposeRequested + +/** A detach that cannot recover must retire the presenter before Java releases its buffers. */ +internal fun androidSurfaceRecoveryExhaustionRequiresHostRetirement( + failedOperation: String, + nativeDetachRetryPending: Boolean, + unbindRequested: Boolean, + disposeRequested: Boolean, +): Boolean = failedOperation == "detachSurface" && + (nativeDetachRetryPending || unbindRequested || disposeRequested) + +/** + * A live TextureView keeps the same SurfaceTexture while an activity is merely + * stopped (for example, after pressing Home). Detaching and immediately + * recreating a wgpu surface around that still-live buffer queue can leave some + * Android Vulkan drivers acquiring from the retired native window. Keep the + * native attachment until TextureView reports an actual surface destruction. + */ +internal fun androidShouldRetainSurfaceDuringActivityStop( + usesTextureView: Boolean, + outputSurfaceValid: Boolean, +): Boolean = usesTextureView && outputSurfaceValid + +internal class AndroidSurfaceRecoveryTokenSource { + var currentToken: Long = 0L + private set + + fun invalidate() { + currentToken += 1L + } + + fun isCurrent(token: Long): Boolean = token == currentToken +} + +/** Separates callbacks belonging to retired Surface/bind requests. */ +internal class AndroidSurfaceBindingGenerationTracker { + var currentGeneration: Long = 0L + private set + + fun advance(): Long { + currentGeneration = if (currentGeneration == Long.MAX_VALUE) 1L else currentGeneration + 1L + return currentGeneration + } + + fun isCurrent(generation: Long): Boolean = generation == currentGeneration +} + +/** A callback may mutate recovery state only while it still owns the active surface binding. */ +internal fun androidSurfaceCallbackIsCurrent( + callbackGeneration: Long, + currentGeneration: Long, + hostStillBound: Boolean, + surfaceStillCurrent: Boolean, +): Boolean = callbackGeneration == currentGeneration && hostStillBound && surfaceStillCurrent + +/** A null generation deliberately settles all physical detach waiters for the same host. */ +internal fun androidSurfaceCompletionMatchesGeneration( + completionGeneration: Long, + callbackGeneration: Long?, +): Boolean = callbackGeneration == null || completionGeneration == callbackGeneration + +/** A posted asynchronous JNI operation has no result until its callback runs. */ +internal fun androidSurfaceOperationIsPending( + operation: String, + nativeAttachPending: Boolean, + nativeDetachPending: Boolean, + nativeResizePending: Boolean, +): Boolean = when (operation) { + "attachSurface" -> nativeAttachPending + "detachSurface" -> nativeDetachPending + "resizeSurface" -> nativeResizePending + else -> false +} + +/** An existing lifecycle detach is the serialized native boundary for unbind. */ +internal fun androidUnbindNeedsNewSurfaceDetach( + lifecycleDetachPending: Boolean, +): Boolean = !lifecycleDetachPending diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidSurfaceMetrics.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidSurfaceMetrics.kt new file mode 100644 index 00000000..80b1c713 --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/AndroidSurfaceMetrics.kt @@ -0,0 +1,22 @@ +package dev.aimesoft.erika_flutter + +import kotlin.math.max + +internal data class AndroidSurfaceMetrics( + val width: Int, + val height: Int, + val scale: Double, +) + +internal fun resolveAndroidSurfaceMetrics( + pixelWidth: Int, + pixelHeight: Int, + density: Double, +): AndroidSurfaceMetrics { + val contentScale = if (density.isFinite() && density > 0.0) density else 1.0 + return AndroidSurfaceMetrics( + width = max(1, pixelWidth), + height = max(1, pixelHeight), + scale = contentScale, + ) +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaAudioFocus.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaAudioFocus.kt new file mode 100644 index 00000000..feb4bac2 --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaAudioFocus.kt @@ -0,0 +1,175 @@ +package dev.aimesoft.erika_flutter + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import android.os.Handler +import android.os.Looper + +internal enum class AudioFocusGrant { + GRANTED, + DELAYED, + DENIED, +} + +internal class ErikaAudioFocus( + context: Context, + private val onFocusLoss: (mayResume: Boolean) -> Unit, + private val onFocusGain: () -> Unit, +) { + private val applicationContext = context.applicationContext + private val audioManager = + applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private val mainHandler = Handler(Looper.getMainLooper()) + private var focusRequest: AudioFocusRequest? = null + var focusGranted: Boolean = false + private set + var focusRequested: Boolean = false + private set + private var resumeOnGain = false + private var noisyReceiverRegistered = false + + private val noisyReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != AudioManager.ACTION_AUDIO_BECOMING_NOISY) { + return + } + if (!focusRequested) { + return + } + resumeOnGain = false + onFocusLoss(false) + abandon() + } + } + + private val listener = AudioManager.OnAudioFocusChangeListener { change -> + mainHandler.post { handleFocusChange(change) } + } + + fun request(): AudioFocusGrant { + if (focusRequested) { + return if (focusGranted) AudioFocusGrant.GRANTED else AudioFocusGrant.DELAYED + } + resumeOnGain = false + val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val request = focusRequest ?: AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) + .build(), + ) + .setAcceptsDelayedFocusGain(true) + .setOnAudioFocusChangeListener(listener, mainHandler) + .build() + .also { focusRequest = it } + audioManager.requestAudioFocus(request) + } else { + @Suppress("DEPRECATION") + audioManager.requestAudioFocus( + listener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN, + ) + } + return when (result) { + AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> { + focusRequested = true + focusGranted = true + registerNoisyReceiver() + AudioFocusGrant.GRANTED + } + AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> { + focusRequested = true + focusGranted = false + resumeOnGain = true + registerNoisyReceiver() + AudioFocusGrant.DELAYED + } + else -> { + focusRequested = false + focusGranted = false + unregisterNoisyReceiver() + AudioFocusGrant.DENIED + } + } + } + + fun abandon() { + if (!focusRequested) { + return + } + focusRequested = false + focusGranted = false + resumeOnGain = false + unregisterNoisyReceiver() + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + focusRequest?.let(audioManager::abandonAudioFocusRequest) + } else { + @Suppress("DEPRECATION") + audioManager.abandonAudioFocus(listener) + } + } + } + + private fun handleFocusChange(change: Int) { + if (!focusRequested) { + return + } + when (change) { + AudioManager.AUDIOFOCUS_GAIN -> { + focusGranted = true + if (resumeOnGain) { + resumeOnGain = false + onFocusGain() + } + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + focusGranted = false + resumeOnGain = true + onFocusLoss(true) + } + AudioManager.AUDIOFOCUS_LOSS -> { + focusRequested = false + focusGranted = false + resumeOnGain = false + unregisterNoisyReceiver() + onFocusLoss(false) + } + } + } + + private fun registerNoisyReceiver() { + if (noisyReceiverRegistered) { + return + } + val filter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + applicationContext.registerReceiver( + noisyReceiver, + filter, + Context.RECEIVER_NOT_EXPORTED, + ) + } else { + @Suppress("DEPRECATION") + applicationContext.registerReceiver(noisyReceiver, filter) + } + noisyReceiverRegistered = true + } + + private fun unregisterNoisyReceiver() { + if (!noisyReceiverRegistered) { + return + } + runCatching { applicationContext.unregisterReceiver(noisyReceiver) } + noisyReceiverRegistered = false + } +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt new file mode 100644 index 00000000..c7914587 --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt @@ -0,0 +1,3125 @@ +package dev.aimesoft.erika_flutter + +import android.content.Context +import android.content.res.AssetFileDescriptor +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.os.ParcelFileDescriptor +import android.os.Process +import android.os.SystemClock +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants +import android.util.Log +import android.view.Choreographer +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.embedding.engine.plugins.lifecycle.FlutterLifecycleAdapter +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.EOFException +import java.io.File +import java.io.FileDescriptor +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.util.concurrent.CancellationException +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.max + +class ErikaFlutterPlugin : + FlutterPlugin, + ActivityAware, + LifecycleEventObserver, + MethodChannel.MethodCallHandler, + EventChannel.StreamHandler { + private lateinit var applicationContext: Context + private lateinit var methodChannel: MethodChannel + private lateinit var eventChannel: EventChannel + private lateinit var choreographer: Choreographer + private lateinit var audioFocus: ErikaAudioFocus + private lateinit var mediaSession: ErikaMediaSession + private lateinit var mainHandler: Handler + private lateinit var presenterThread: AndroidPresenterThread + private lateinit var contentPreparationExecutor: ExecutorService + private val presenterCreates = AndroidPresenterCreateRegistry() + private var engineAttachmentGeneration = 0L + @Volatile + private var contentSpoolScavengeFuture: Future<*>? = null + private val players = linkedMapOf() + private val videoViews = linkedMapOf() + private var eventSink: EventChannel.EventSink? = null + private var frameScheduled = false + private var attachedToEngine = false + private var activityLifecycle: Lifecycle? = null + private var activityActive = false + private var activeMediaPlayerId: Long? = null + private val renderRequests = AndroidLatestTaskCoalescer() + private val renderGeneration = AtomicLong(0L) + private val renderThreadReported = AtomicBoolean(false) + private val backgroundTickQueued = AtomicBoolean(false) + private val eventPollQueued = AtomicBoolean(false) + private val immediateEventPollLatch = AndroidImmediateEventPollLatch() + private val pendingPlayResults = mutableMapOf>() + private var eventPollTimerScheduled = false + private var eventPollIdleRounds = 0 + + internal val isActivityActive: Boolean + get() = attachedToEngine && activityActive + + private val frameCallback = Choreographer.FrameCallback { frameTimeNanos -> + frameScheduled = false + if (!isActivityActive) { + return@FrameCallback + } + val renderTargets = players.values + .filter(AndroidPlayerHost::shouldTick) + .map { host -> + AndroidRenderTarget( + host, + host.currentRenderRequestGeneration, + ) + } + if (renderTargets.isEmpty()) { + return@FrameCallback + } + val timeSeconds = frameTimeNanos.toDouble() / 1_000_000_000.0 + enqueueRenderTick(renderTargets, timeSeconds) + refreshFrameScheduling() + } + + private val eventPollRunnable = object : Runnable { + override fun run() { + eventPollTimerScheduled = false + scheduleEventPoll() + } + } + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + applicationContext = binding.applicationContext + choreographer = Choreographer.getInstance() + mainHandler = Handler(Looper.getMainLooper()) + presenterThread = AndroidPresenterThread() + engineAttachmentGeneration = presenterCreates.attach(presenterThread) + renderThreadReported.set(false) + backgroundTickQueued.set(false) + eventPollQueued.set(false) + immediateEventPollLatch.clear() + contentPreparationExecutor = newContentPreparationExecutor() + contentSpoolScavengeFuture = scheduleContentSpoolStartupScavenge() + audioFocus = ErikaAudioFocus( + applicationContext, + onFocusLoss = ::handleAudioFocusLoss, + onFocusGain = ::handleAudioFocusGain, + ) + mediaSession = ErikaMediaSession( + applicationContext, + object : ErikaMediaCommandHandler { + override fun play(playerId: Long) = performSystemMediaCommand(playerId, "play") + override fun pause(playerId: Long) = performSystemMediaCommand(playerId, "pause") + override fun stop(playerId: Long) = performSystemMediaCommand(playerId, "stop") + override fun seek(playerId: Long, positionMicros: Long) = + performSystemMediaCommand(playerId, "seek", mapOf("positionMicros" to positionMicros)) + override fun previous(playerId: Long) = + emitSystemMediaNavigation(playerId, SYSTEM_MEDIA_NAVIGATION_PREVIOUS) + override fun next(playerId: Long) = + emitSystemMediaNavigation(playerId, SYSTEM_MEDIA_NAVIGATION_NEXT) + }, + ) + ErikaMediaCommandReceiver.register(this, mediaSession::dispatch) + ErikaMediaPlaybackService.registerTickHandler(this, ::performBackgroundPlaybackTick) + methodChannel = MethodChannel(binding.binaryMessenger, PLAYER_CHANNEL) + eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) + methodChannel.setMethodCallHandler(this) + eventChannel.setStreamHandler(this) + binding.platformViewRegistry.registerViewFactory( + VIDEO_VIEW_TYPE, + ErikaAndroidVideoViewFactory(this), + ) + binding.platformViewRegistry.registerViewFactory( + HDR_VIDEO_VIEW_TYPE, + ErikaAndroidVideoViewFactory(this, useHdrSurface = true), + ) + attachedToEngine = true + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + detachFromActivity() + attachedToEngine = false + val retiringPresenterThread = presenterThread + presenterCreates.detach(retiringPresenterThread).forEach { pending -> + if (!retiringPresenterThread.post { ErikaNative.nativeDestroy(pending.handle) }) { + Log.e( + TAG, + "Unable to retire pending presenter ${pending.handle} before engine detach", + ) + } + } + cancelFrameCallback() + mainHandler.removeCallbacks(eventPollRunnable) + eventPollTimerScheduled = false + eventPollIdleRounds = 0 + eventPollQueued.set(false) + immediateEventPollLatch.clear() + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + eventSink = null + videoViews.values.toList().forEach(ErikaAndroidVideoView::dispose) + videoViews.clear() + players.values.toList().forEach(::destroyPlayer) + players.clear() + if (::presenterThread.isInitialized) { + retiringPresenterThread.close() + } + if (::contentPreparationExecutor.isInitialized) { + contentPreparationExecutor.shutdownNow() + } + audioFocus.abandon() + ErikaMediaCommandReceiver.unregister(this) + ErikaMediaPlaybackService.unregisterTickHandler(this) + mediaSession.release() + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + attachToActivity(binding) + } + + override fun onDetachedFromActivityForConfigChanges() { + detachFromActivity() + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + attachToActivity(binding) + } + + override fun onDetachedFromActivity() { + detachFromActivity() + } + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + val lifecycle = activityLifecycle + if (lifecycle == null || source.lifecycle !== lifecycle) { + return + } + val active = androidActivityActiveForEvent(event) ?: return + Log.i( + TAG, + "activityLifecycleEvent event=$event state=${lifecycle.currentState} active=$active", + ) + setActivityActive(active) + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + eventSink = events + players.values.forEach(::drainEvents) + refreshFrameScheduling() + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + try { + when (call.method) { + "create" -> createPlayer(arguments(call), result) + "dispose" -> disposePlayer(arguments(call), result) + "attachView" -> attachView(arguments(call), result) + "detachView" -> detachView(arguments(call), result) + "attachOverlay" -> attachOverlay(arguments(call), result) + "detachOverlay" -> detachOverlay(arguments(call), result) + "setOverlayFrame" -> setOverlayFrame(arguments(call), result) + "screenshot" -> captureFrame(arguments(call), result) + "setMediaMetadata" -> setMediaMetadata(arguments(call), result) + "setSystemMediaNavigation" -> setSystemMediaNavigation(arguments(call), result) + "registerSubtitleMemoryFont" -> registerSubtitleMemoryFont(arguments(call), result) + in NATIVE_METHODS -> invokePlayer(call.method, arguments(call), result) + else -> result.notImplemented() + } + } catch (error: Throwable) { + Log.e(TAG, "Method ${call.method} failed", error) + result.error( + "ERIKA_ERROR", + error.message ?: "Erika Android method ${call.method} failed", + null, + ) + } + } + + internal fun registerVideoView(view: ErikaAndroidVideoView) { + videoViews.put(view.viewId, view)?.takeIf { it !== view }?.dispose() + } + + internal fun unregisterVideoView(view: ErikaAndroidVideoView) { + if (videoViews[view.viewId] === view) { + videoViews.remove(view.viewId) + } + } + + internal fun onPlayerRenderStateChanged() { + refreshFrameScheduling() + } + + internal fun reportSurfaceResponse( + host: AndroidPlayerHost, + operation: String, + response: NativeResponse, + ) { + if (response.ok) { + host.lastSurfaceError = null + if (androidSurfaceOperationNeedsImmediateEventPoll(operation, responseOk = true)) { + drainEvents(host) + } + } else { + val signature = "$operation:${response.status}:${response.error.orEmpty()}" + Log.e( + TAG, + "$operation failed for player ${host.handle}: status=${response.status} ${response.error.orEmpty()}", + ) + if (host.lastSurfaceError != signature) { + host.lastSurfaceError = signature + enqueueHostError( + host, + operation, + response.status, + response.error ?: "$operation failed", + contentGeneration = null, + ) + } + } + refreshFrameScheduling() + } + + internal fun reportSurfaceRecoveryExhausted( + host: AndroidPlayerHost, + viewId: Int, + operation: String, + generation: Long, + retryAttempts: Int, + response: NativeResponse, + ) { + val failedAttempts = retryAttempts + 1 + val error = response.error ?: "$operation failed without a native error" + Log.e( + TAG, + "surfaceRecoveryExhausted playerId=${host.handle} viewId=$viewId " + + "operation=$operation generation=$generation " + + "failedAttempts=$failedAttempts retryAttempts=$retryAttempts " + + "status=${response.status} error=$error", + ) + enqueueHostError( + host, + "surfaceRecovery", + response.status, + "$operation recovery exhausted after $failedAttempts failed attempts: $error", + mapOf( + "surfaceOperation" to operation, + "surfaceViewId" to viewId, + "surfaceRecoveryGeneration" to generation, + "surfaceRecoveryFailedAttempts" to failedAttempts, + "surfaceRecoveryRetryAttempts" to retryAttempts, + ), + contentGeneration = null, + ) + } + + internal fun retirePlayerAfterSurfaceRecoveryExhausted(host: AndroidPlayerHost) { + if (host.isDestroyed) { + return + } + players.remove(host.handle, host) + stopEventPollingIfIdle() + Log.e( + TAG, + "Retiring player ${host.handle} after unrecoverable Android surface detach", + ) + destroyPlayer(host) + } + + private fun createPlayer(arguments: Map, result: MethodChannel.Result) { + val outputMode = arguments.int("outputMode") ?: 0 + val defaultHeadroom = if (outputMode == 2) 4f else 1f + val edrHeadroom = + (arguments.number("edrHeadroom")?.toFloat() ?: defaultHeadroom).coerceAtLeast(1f) + val upscaler = arguments.int("upscaler") ?: arguments.int("lumaUpscaler") ?: 0 + val videoAlphaMode = arguments.int("videoAlphaMode") ?: 0 + val ownerThread = presenterThread + val attachmentGeneration = engineAttachmentGeneration + val posted = ownerThread.post { + val createResult = runCatching { + ErikaNative.nativeCreate( + outputMode, + edrHeadroom, + upscaler, + videoAlphaMode, + ) + } + val createFailure = createResult.exceptionOrNull() + if (createFailure != null) { + postPresenterCreateFailure( + ownerThread, + attachmentGeneration, + result, + createFailure.message ?: "Erika Android presenter creation threw", + createFailure, + ) + return@post + } + val createdHandle = createResult.getOrThrow() + val error = if (createdHandle == 0L) { + runCatching(ErikaNative::nativeLastError).getOrNull().orEmpty() + } else { + "" + } + if (createdHandle == 0L) { + postPresenterCreateFailure( + ownerThread, + attachmentGeneration, + result, + error.ifBlank { "Erika C ABI did not provide a presenter creation error" }, + ) + return@post + } + if (!presenterCreates.registerIfCurrent( + createdHandle, + ownerThread, + attachmentGeneration, + ) + ) { + ErikaNative.nativeDestroy(createdHandle) + return@post + } + postMainSafely( + source = "presenter create completion", + onFailure = { callbackError -> + retireFailedPresenterCreate(createdHandle, ownerThread) + runCatching { + result.error( + "ERIKA_ERROR", + callbackError.message + ?: "Erika Android presenter creation callback failed", + mapOf("stage" to "presenter_create_completion"), + ) + } + }, + ) { + if (!presenterCreates.claimIfCurrent( + createdHandle, + ownerThread, + attachmentGeneration, + ) + ) { + return@postMainSafely + } + val host = AndroidPlayerHost( + createdHandle, + outputMode, + arguments["allowBackgroundPlayback"] == true, + ownerThread, + ) + players[createdHandle] = host + requestEventPoll(immediate = true) + runCatching { result.success(createdHandle) } + .onFailure { callbackError -> + players.remove(createdHandle, host) + destroyPlayer(host) + throw callbackError + } + } + } + if (!posted) { + result.error( + "ERIKA_ERROR", + "Android presenter thread is unavailable", + mapOf("stage" to "presenter_create"), + ) + } + } + + private fun postPresenterCreateFailure( + ownerThread: AndroidPresenterThread, + attachmentGeneration: Long, + result: MethodChannel.Result, + reason: String, + cause: Throwable? = null, + ) { + Log.e(TAG, "Erika Android presenter creation failed: $reason", cause) + postMainSafely("presenter create failure") { + if (!presenterCreates.isCurrent(ownerThread, attachmentGeneration)) { + return@postMainSafely + } + result.error( + "ERIKA_ERROR", + "Erika Android presenter creation failed: $reason", + mapOf("stage" to "presenter_create", "reason" to reason), + ) + } + } + + private fun retireFailedPresenterCreate( + handle: Long, + ownerThread: AndroidPresenterThread, + ) { + if (!presenterCreates.abandon(handle, ownerThread)) { + return + } + if (ownerThread.isOwnerThread) { + ErikaNative.nativeDestroy(handle) + } else if (!ownerThread.post { ErikaNative.nativeDestroy(handle) }) { + Log.e(TAG, "Unable to retire failed presenter $handle on its owner thread") + } + } + + private fun disposePlayer(arguments: Map, result: MethodChannel.Result) { + val host = player(arguments) + val completionAttempted = AtomicBoolean(false) + val queued = beginDestroyPlayer(host) { destruction -> + postMainSafely( + source = "dispose completion", + onFailure = { error -> + if (completionAttempted.compareAndSet(false, true)) { + runCatching { + result.error( + "ERIKA_ERROR", + error.message ?: "Android dispose completion failed", + mapOf("stage" to "dispose_completion"), + ) + } + } + }, + ) { + if (!completionAttempted.compareAndSet(false, true)) { + return@postMainSafely + } + destruction.fold( + onSuccess = { result.success(null) }, + onFailure = { error -> + result.error( + "ERIKA_ERROR", + error.message ?: "Unable to destroy Erika Android player", + mapOf("stage" to "presenter_destroy"), + ) + }, + ) + } + } + if (queued) { + players.remove(host.handle, host) + stopEventPollingIfIdle() + } else if (completionAttempted.compareAndSet(false, true)) { + result.error( + "ERIKA_ERROR", + "Android presenter thread rejected player destruction", + mapOf("stage" to "presenter_destroy", "reason" to "queue_rejected"), + ) + } + } + + private fun destroyPlayer(host: AndroidPlayerHost) { + if (!beginDestroyPlayer(host) { result -> + result.onFailure { error -> + Log.e(TAG, "Unable to destroy Erika player ${host.handle}", error) + } + } + ) { + Log.e(TAG, "Unable to queue Erika player ${host.handle} destruction") + } + } + + private fun beginDestroyPlayer( + host: AndroidPlayerHost, + onComplete: (Result) -> Unit, + ): Boolean { + failAllPendingPlayResults( + host, + IllegalStateException("Erika player ${host.handle} was disposed before play completed"), + ) + host.cancelPlaybackIntent() + abandonAudioFocusIfIdle() + val queued = host.destroyAsync { destruction -> + postMainSafely( + source = "player destroy finalization", + onFailure = { error -> onComplete(Result.failure(error)) }, + ) { + host.finishDestroyOnMain() + onComplete(destruction) + } + } + if (!queued) { + refreshFrameScheduling() + return false + } + if (activeMediaPlayerId == host.handle) { + activeMediaPlayerId = null + ErikaMediaCommandReceiver.deactivate(this) + mediaSession.clear(host.handle) + } + refreshFrameScheduling() + return queued + } + + private fun attachView(arguments: Map, result: MethodChannel.Result) { + val host = player(arguments) + val viewId = arguments.requiredInt("viewId") + val view = videoViews[viewId] + if (view == null) { + result.error("ERIKA_ERROR", "Erika Android video view $viewId was not found", null) + return + } + if (view.isExtendedLinearSurface != host.requiresExtendedLinearSurface) { + result.error( + "ERIKA_ERROR", + "Erika Android player ${host.handle} requires " + + if (host.requiresExtendedLinearSurface) { + "an extended-linear SurfaceView" + } else { + "an SDR TextureView" + }, + null, + ) + return + } + view.bindAsync(host) { response -> + deliverSurfaceMethodResult("attachView", result, response) + } + } + + private fun detachView(arguments: Map, result: MethodChannel.Result) { + val host = player(arguments) + val viewId = arguments.requiredInt("viewId") + val view = videoViews[viewId] + if (view != null && host.attachedView === view) { + view.unbindAsync(host) { response -> + deliverSurfaceMethodResult("detachView", result, response) + } + } else { + result.success(null) + } + } + + private fun attachOverlay(arguments: Map, result: MethodChannel.Result) { + val host = player(arguments) + val view = host.attachedView + ?: videoViews.values.lastOrNull { candidate -> + candidate.isExtendedLinearSurface == host.requiresExtendedLinearSurface && + players.values.none { it.attachedView === candidate } + } + if (view == null) { + result.error( + "ERIKA_ERROR", + "Android window-overlay playback requires an Erika TextureView platform view", + null, + ) + return + } + view.bindAsync(host) { response -> + deliverSurfaceMethodResult("attachOverlay", result, response, view.viewId) + } + } + + private fun detachOverlay(arguments: Map, result: MethodChannel.Result) { + val host = player(arguments) + val view = host.attachedView + if (view == null) { + result.success(null) + return + } + view.unbindAsync(host) { response -> + deliverSurfaceMethodResult("detachOverlay", result, response) + } + } + + private fun setOverlayFrame(arguments: Map, result: MethodChannel.Result) { + val host = player(arguments) + val visible = arguments["visible"] as? Boolean ?: true + val debugLabel = arguments["debugLabel"] as? String + val requestedViewId = arguments.int("viewId")?.takeIf { it >= 0 } + val view = if (requestedViewId != null) { + val requestedView = videoViews[requestedViewId] + if (requestedView == null) { + result.error( + "ERIKA_ERROR", + "Erika Android video view $requestedViewId was not found", + mapOf("stage" to "setOverlayFrame", "viewId" to requestedViewId), + ) + return + } + if (host.attachedView !== requestedView) { + result.error( + "ERIKA_ERROR", + "Erika Android video view $requestedViewId is not attached to player ${host.handle}", + mapOf("stage" to "setOverlayFrame", "viewId" to requestedViewId), + ) + return + } + requestedView + } else { + host.attachedView + } + if (view == null) { + if (!visible) { + result.success(null) + return + } + result.error( + "ERIKA_ERROR", + "Erika Android player ${host.handle} has no attached video view", + mapOf("stage" to "setOverlayFrame"), + ) + return + } + view.setFlutterManagedVisibility(visible, debugLabel) + result.success(null) + } + + private fun captureFrame(arguments: Map, result: MethodChannel.Result) { + val host = player(arguments) + val requestedViewId = arguments.int("viewId") + val view = if (requestedViewId != null) { + val requestedView = videoViews[requestedViewId] + if (requestedView == null) { + result.error( + "ERIKA_ERROR", + "Erika Android video view $requestedViewId was not found", + mapOf("stage" to "screenshot", "viewId" to requestedViewId), + ) + return + } + if (host.attachedView !== requestedView) { + result.error( + "ERIKA_ERROR", + "Erika Android video view $requestedViewId is not attached to player ${host.handle}", + mapOf("stage" to "screenshot", "viewId" to requestedViewId), + ) + return + } + requestedView + } else { + host.attachedView + } + val width = arguments.int("width")?.takeIf { it > 0 } + ?: view?.pixelWidth()?.takeIf { it > 0 } + val height = arguments.int("height")?.takeIf { it > 0 } + ?: view?.pixelHeight()?.takeIf { it > 0 } + if (width == null || height == null) { + result.error( + "ERIKA_ERROR", + "Screenshot width and height are required before an Android video surface is attached", + null, + ) + return + } + val posted = host.captureFrameAsync(width, height) { captured -> + postMainSafely( + source = "screenshot completion", + onFailure = { error -> + runCatching { + result.error("ERIKA_ERROR", error.message, mapOf("stage" to "screenshot")) + } + }, + ) { + result.success(captured.getOrThrow()) + } + } + if (!posted) { + result.error( + "ERIKA_ERROR", + "Android presenter thread is unavailable", + mapOf("stage" to "screenshot"), + ) + } + } + + private fun invokePlayer( + method: String, + arguments: Map, + result: MethodChannel.Result, + ) { + val host = player(arguments) + val contentGeneration = when { + method == "open" -> { + val metadata = arguments["metadata"] + if (metadata != null && metadata !is Map<*, *>) { + throw IllegalArgumentException("metadata must be a map") + } + host.prepareForOpen( + metadata?.let { androidMediaMetadata(arguments) }, + ) + } + method in CONTENT_PREPARATION_INVALIDATION_METHODS -> + host.currentContentGeneration + else -> null + } + if (method == "play") { + playWithAudioFocus(host, result) + return + } + + if (method in PLAYBACK_INTENT_CANCEL_METHODS) { + host.cancelPlaybackIntent(forceNewGeneration = true) + abandonAudioFocusIfIdle() + refreshFrameScheduling() + } + val playbackIntentGeneration = method + .takeIf(PLAYBACK_INTENT_CANCEL_METHODS::contains) + ?.let { host.currentPlaybackIntentGeneration } + + if (method in CONTENT_PREPARATION_INVALIDATION_METHODS) { + host.cancelContentPreparations("superseded_by_$method") + } + + if (requiresAsyncContentPreparation(method, arguments)) { + invokePlayerAfterContentPreparation( + host, + method, + arguments, + result, + playbackIntentGeneration, + contentGeneration, + ) + return + } + + val prepared = prepareNativeArguments(method, arguments) + invokePreparedPlayer( + host, + method, + prepared, + result, + playbackIntentGeneration, + contentGeneration, + ) + } + + private fun registerSubtitleMemoryFont( + arguments: Map, + result: MethodChannel.Result, + ) { + val host = player(arguments) + val data = arguments["data"] as? ByteArray + ?: throw IllegalArgumentException("Missing byte array argument 'data'") + val posted = host.registerSubtitleMemoryFontAsync(data) { response -> + postMainSafely( + source = "subtitle memory font completion", + onFailure = { error -> + runCatching { + result.error( + "ERIKA_ERROR", + error.message, + mapOf("stage" to "register_subtitle_memory_font"), + ) + } + }, + ) { + val nativeResponse = response.getOrThrow() + if (nativeResponse.ok) { + host.requestRender() + } + complete(result, nativeResponse) + } + } + if (!posted) { + result.error( + "ERIKA_ERROR", + "Android presenter thread is unavailable", + mapOf("stage" to "register_subtitle_memory_font"), + ) + } + } + + private fun invokePreparedPlayer( + host: AndroidPlayerHost, + method: String, + prepared: PreparedNativeArguments, + result: MethodChannel.Result, + playbackIntentGeneration: Long?, + contentGeneration: Long?, + ) { + val argumentsJson = try { + NativeJson.encodeArguments(prepared.arguments) + } catch (error: Throwable) { + prepared.detachedFd?.let(::closeDetachedFileDescriptor) + throw error + } + val completionAttempted = AtomicBoolean(false) + val posted = presenterThread.post { + playbackIntentGeneration?.let(host::markPlaybackIntentExecuted) + val response = runCatching { + if (host.isDestroyed) { + prepared.detachedFd?.let(::closeDetachedFileDescriptor) + throw IllegalStateException("Erika player ${host.handle} has been destroyed") + } + // Once nativeInvoke is entered, Rust owns every detached fd regardless of + // the returned status and closes it either in the JNI bridge or source Drop. + val rawResponse = try { + host.invokeEncodedRaw( + method, + argumentsJson, + prepared.detachedFd ?: NO_OWNED_FD, + ) + } catch (error: Throwable) { + // Disposal or symbol resolution can fail before Rust owns the fd. + if (androidNativeInvokeDidNotStart(error)) { + prepared.detachedFd?.let(::closeDetachedFileDescriptor) + } + throw error + } + // Decode only after the JNI ownership boundary. A malformed response must + // never make Kotlin close an fd that Rust has already consumed. + NativeJson.decodeResponse(rawResponse) + } + if (androidContentCommandEstablishedBoundary( + method = method, + responseDecoded = response.isSuccess, + responseOk = response.getOrNull()?.ok == true, + ) + ) { + contentGeneration?.let(host::markContentGenerationExecuted) + } + val events = pollEventsOnPresenterThread(host) + postMainSafely( + source = "async $method completion", + onFailure = { error -> + if (completionAttempted.compareAndSet(false, true)) runCatching { + result.error( + "ERIKA_ERROR", + error.message ?: "Erika Android method $method failed", + mapOf("stage" to "main_completion", "method" to method), + ) + }.onFailure { deliveryError -> + Log.w(TAG, "Unable to deliver failed Android $method result", deliveryError) + } + }, + ) { + if (players[host.handle] === host && !host.isDestroyed) { + processPolledEvents(events) + } + val nativeResponse = response.getOrElse { error -> + closeFailedNativeOpen( + host, + method, + contentGeneration, + "native_exception", + ) + throw IllegalStateException( + error.message ?: "Erika Android method $method failed", + error, + ) + } + if (!nativeResponse.ok) { + closeFailedNativeOpen( + host, + method, + contentGeneration, + "native_response_${nativeResponse.status}", + ) + } + finishPreparedPlayerInvocation( + host, + method, + prepared, + nativeResponse, + ) + // Keep delivery last so callback failures cannot strand the Dart Future, + // and no work after delivery can cause a second completion attempt. + if (completionAttempted.compareAndSet(false, true)) { + complete(result, nativeResponse) + } + } + } + if (!posted) { + prepared.detachedFd?.let(::closeDetachedFileDescriptor) + if (completionAttempted.compareAndSet(false, true)) { + result.error( + "ERIKA_ERROR", + "Android presenter thread is unavailable", + mapOf("stage" to "presenter_invoke", "method" to method), + ) + } + } + } + + private fun finishPreparedPlayerInvocation( + host: AndroidPlayerHost, + method: String, + prepared: PreparedNativeArguments, + response: NativeResponse, + ) { + val isCurrentHost = players[host.handle] === host && !host.isDestroyed + if (isCurrentHost) { + if (response.ok && method in RENDER_REQUEST_METHODS) { + host.requestRender() + } + if (response.ok && method == "setPlaybackRate") { + host.setPlaybackRate((prepared.arguments["rate"] as? Number)?.toFloat() ?: 1f) + if (activeMediaPlayerId == host.handle) { + mediaSession.update(host.mediaState) + } + } + if (response.ok && method == "close") { + // Close is terminal for this native player handle. Any concurrently + // requested Open will be rejected, so Closed must win locally as well. + host.closeMediaState() + if (activeMediaPlayerId == host.handle) { + mediaSession.update(host.mediaState) + } + } + drainEvents(host) + refreshFrameScheduling() + } + } + + private fun requiresAsyncContentPreparation( + method: String, + arguments: Map, + ): Boolean { + if (method !in URI_METHODS) { + return false + } + val uri = arguments["uri"] as? String ?: return false + return uri.startsWith("content://", ignoreCase = true) + } + + private fun invokePlayerAfterContentPreparation( + host: AndroidPlayerHost, + method: String, + arguments: Map, + result: MethodChannel.Result, + playbackIntentGeneration: Long?, + contentGeneration: Long?, + ) { + val rawUri = arguments["uri"] as? String + ?: throw IllegalArgumentException("uri is required") + val cancellation = AndroidContentPreparationCancellation() + val command = PendingContentCommand( + host = host, + method = method, + authority = Uri.parse(rawUri).authority, + result = result, + cancellation = cancellation, + playbackIntentGeneration = playbackIntentGeneration, + contentGeneration = contentGeneration, + ) + command.token = host.beginContentPreparation { reason -> + cancelPendingContentCommand(command, reason) + } + + val future = try { + contentPreparationExecutor.submit { + val prepared = runCatching { + prepareNativeArguments(method, arguments, cancellation) + } + val posted = mainHandler.post { + finishContentPreparation(command, prepared) + } + if (!posted) { + prepared.getOrNull()?.detachedFd?.let(::closeDetachedFileDescriptor) + cancellation.cancel() + } + } + } catch (error: RejectedExecutionException) { + host.finishContentPreparation(command.token) + if (command.claimCompletion()) { + cancellation.cancel() + Log.e(TAG, "Android content preparation executor rejected $method", error) + closeFailedContentOpen(command, "executor_unavailable") + result.error( + "ERIKA_ERROR", + "Android content preparation executor is unavailable", + mapOf( + "stage" to "content_prepare", + "method" to method, + "reason" to "executor_unavailable", + ), + ) + } + return + } + cancellation.attachFuture(future) + } + + private fun finishContentPreparation( + command: PendingContentCommand, + prepared: Result, + ) { + val current = command.host.finishContentPreparation(command.token) + if (!current || !command.claimCompletion()) { + prepared.getOrNull()?.detachedFd?.let(::closeDetachedFileDescriptor) + return + } + val failure = prepared.exceptionOrNull() + if (failure != null) { + val reason = androidContentSourceFailureReason(failure) + val cancelled = failure is AndroidContentPreparationCancelledException + Log.e( + TAG, + androidContentSourceEvent( + stage = if (cancelled) "cancelled" else "failed", + authority = command.authority, + fields = linkedMapOf( + "mode" to "background_prepare", + "method" to command.method, + "playerId" to command.host.handle, + "reason" to reason, + "message" to (failure.message ?: failure.javaClass.simpleName), + ), + ), + failure, + ) + if (!cancelled) { + closeFailedContentOpen(command, reason) + } + command.result.error( + if (cancelled) "ERIKA_CONTENT_CANCELLED" else "ERIKA_ERROR", + failure.message ?: "Android content preparation failed", + mapOf( + "stage" to "content_prepare", + "method" to command.method, + "reason" to reason, + ), + ) + return + } + + val ready = checkNotNull(prepared.getOrNull()) + val playbackIntentGeneration = command.playbackIntentGeneration + if (playbackIntentGeneration != null && + playbackIntentGeneration != command.host.currentPlaybackIntentGeneration + ) { + ready.detachedFd?.let(::closeDetachedFileDescriptor) + closeFailedContentOpen(command, "superseded_playback_intent") + command.result.error( + "ERIKA_CONTENT_CANCELLED", + "Android content command ${command.method} was superseded by newer playback intent", + mapOf( + "stage" to "content_invoke", + "method" to command.method, + "reason" to "superseded_playback_intent", + ), + ) + return + } + + try { + invokePreparedPlayer( + command.host, + command.method, + ready, + command.result, + command.playbackIntentGeneration, + command.contentGeneration, + ) + } catch (error: Throwable) { + Log.e(TAG, "Async Erika ${command.method} invocation failed", error) + command.result.error( + "ERIKA_ERROR", + error.message ?: "Erika Android method ${command.method} failed", + mapOf("stage" to "content_invoke", "method" to command.method), + ) + } + } + + private fun cancelPendingContentCommand(command: PendingContentCommand, reason: String) { + command.cancellation.cancel() + if (!command.claimCompletion()) { + return + } + Log.w( + TAG, + androidContentSourceEvent( + stage = "cancelled", + authority = command.authority, + fields = linkedMapOf( + "mode" to "background_prepare", + "method" to command.method, + "playerId" to command.host.handle, + "reason" to reason, + ), + ), + ) + runCatching { + command.result.error( + "ERIKA_CONTENT_CANCELLED", + "Android content preparation for ${command.method} was cancelled: $reason", + mapOf( + "stage" to "content_prepare", + "method" to command.method, + "reason" to reason, + ), + ) + }.onFailure { error -> + // The Flutter messenger may already be detached. One failed result + // delivery must not abort registry invalidation for other players. + Log.w( + TAG, + "Failed to deliver cancelled Android content result for " + + "player ${command.host.handle}, method ${command.method}", + error, + ) + } + } + + private fun closeFailedContentOpen(command: PendingContentCommand, reason: String) { + closeFailedNativeOpen(command.host, command.method, command.contentGeneration, reason) + } + + private fun closeFailedNativeOpen( + host: AndroidPlayerHost, + method: String, + generation: Long?, + reason: String, + ) { + if (!androidFailedContentOpenShouldClose( + method = method, + hostDestroyed = host.isDestroyed, + failedGeneration = generation, + currentGeneration = host.currentContentGeneration, + ) + ) { + return + } + Log.w( + TAG, + "Closing player ${host.handle} after content Open preparation failed: $reason", + ) + host.cancelPlaybackIntent(forceNewGeneration = true) + abandonAudioFocusIfIdle() + postBackgroundCommand(host, "failed content open", "close") + } + + private fun playWithAudioFocus(host: AndroidPlayerHost, result: MethodChannel.Result) { + val intentGeneration = host.requestPlayback() + refreshFrameScheduling() + if (!host.mediaState.canPlay(isActivityActive)) { + host.cancelPlaybackIntentLocally() + result.success(null) + return + } + val focusGrant = try { + audioFocus.request() + } catch (error: Throwable) { + host.cancelPlaybackIntentLocally() + abandonAudioFocusIfIdle() + refreshFrameScheduling() + throw error + } + when (focusGrant) { + AudioFocusGrant.GRANTED -> { + pendingPlayResults + .getOrPut(PendingPlayKey(host, intentGeneration), ::mutableListOf) + .add(result) + startPendingPlayback(host, "method channel") + } + AudioFocusGrant.DELAYED -> result.success(null) + AudioFocusGrant.DENIED -> { + host.cancelPlaybackIntentLocally() + abandonAudioFocusIfIdle() + refreshFrameScheduling() + result.error("ERIKA_AUDIO_FOCUS", "Android audio focus request was denied", null) + } + } + } + + private fun completePendingPlayResults( + host: AndroidPlayerHost, + intentGeneration: Long, + response: NativeResponse, + ) { + pendingPlayResults.remove(PendingPlayKey(host, intentGeneration)).orEmpty().forEach { + runCatching { complete(it, response) } + .onFailure { error -> Log.w(TAG, "Unable to deliver Android play result", error) } + } + } + + private fun failPendingPlayResults( + host: AndroidPlayerHost, + intentGeneration: Long, + error: Throwable, + ) { + pendingPlayResults.remove(PendingPlayKey(host, intentGeneration)).orEmpty().forEach { + runCatching { + it.error( + "ERIKA_ERROR", + error.message ?: "Erika Android play failed", + mapOf("stage" to "presenter_invoke", "method" to "play"), + ) + }.onFailure { deliveryError -> + Log.w(TAG, "Unable to deliver failed Android play result", deliveryError) + } + } + } + + private fun failAllPendingPlayResults(host: AndroidPlayerHost, error: Throwable) { + pendingPlayResults.keys + .filter { key -> key.host === host } + .map(PendingPlayKey::intentGeneration) + .forEach { generation -> failPendingPlayResults(host, generation, error) } + } + + private fun pauseInvalidatedAsyncPlay(host: AndroidPlayerHost) { + postBackgroundCommand(host, "invalidated async", "pause") + } + + private fun activateMediaPlayer(host: AndroidPlayerHost) { + activeMediaPlayerId = host.handle + ErikaMediaCommandReceiver.activate(this) + mediaSession.update(host.mediaState.copy(playbackState = PLAYING_STATE)) + } + + private fun rollbackAcceptedAsyncPlay( + host: AndroidPlayerHost, + source: String, + cause: Throwable, + ) { + Log.e(TAG, "$source play completion failed; rolling native playback back", cause) + host.cancelPlaybackIntent(forceNewGeneration = true) + host.reconcileNativePlaybackStopped() + if (activeMediaPlayerId == host.handle) { + activeMediaPlayerId = null + runCatching { ErikaMediaCommandReceiver.deactivate(this) } + .onFailure { error -> Log.e(TAG, "Unable to deactivate media commands", error) } + runCatching { mediaSession.clear(host.handle) } + .onFailure { error -> Log.e(TAG, "Unable to clear failed media session", error) } + } + abandonAudioFocusIfIdle() + pauseInvalidatedAsyncPlay(host) + } + + private fun postMainSafely( + source: String, + onFailure: (Throwable) -> Unit = {}, + block: () -> Unit, + ): Boolean { + val posted = mainHandler.post { + try { + block() + } catch (error: Throwable) { + Log.e(TAG, "Android main callback failed: $source", error) + runCatching { onFailure(error) } + .onFailure { recoveryError -> + Log.e(TAG, "Android main callback recovery failed: $source", recoveryError) + } + } + } + if (!posted) { + val error = IllegalStateException("Android main callback was rejected: $source") + Log.w(TAG, error.message, error) + runCatching { onFailure(error) } + .onFailure { recoveryError -> + Log.e(TAG, "Android rejected callback recovery failed: $source", recoveryError) + } + } + return posted + } + + private fun postBackgroundCommand( + host: AndroidPlayerHost, + source: String, + method: String, + arguments: Map = emptyMap(), + ) { + val playbackIntentGeneration = method + .takeIf { it == "play" || it in PLAYBACK_INTENT_CANCEL_METHODS } + ?.let { host.currentPlaybackIntentGeneration } + val contentGeneration = method + .takeIf(CONTENT_PREPARATION_INVALIDATION_METHODS::contains) + ?.let { host.currentContentGeneration } + val posted = presenterThread.post { + if (host.isDestroyed) { + return@post + } + playbackIntentGeneration?.let(host::markPlaybackIntentExecuted) + val response = runCatching { host.invoke(method, arguments) } + if (androidContentCommandEstablishedBoundary( + method = method, + responseDecoded = response.isSuccess, + responseOk = response.getOrNull()?.ok == true, + ) + ) { + contentGeneration?.let(host::markContentGenerationExecuted) + } + val events = pollEventsOnPresenterThread(host) + postMainSafely("$source $method completion") main@{ + if (players[host.handle] !== host || host.isDestroyed) { + return@main + } + processPolledEvents(events) + response + .onSuccess { nativeResponse -> + reportBackgroundCommand(host, source, method, nativeResponse) + } + .onFailure { error -> + Log.e(TAG, "$source $method threw for player ${host.handle}", error) + } + drainEvents(host) + refreshFrameScheduling() + } + } + if (!posted) { + Log.w(TAG, "Unable to post $source $method for player ${host.handle}") + } + } + + private fun attachToActivity(binding: ActivityPluginBinding) { + detachFromActivity() + val lifecycle = try { + FlutterLifecycleAdapter.getActivityLifecycle(binding) + } catch (error: Throwable) { + Log.e( + TAG, + "activityLifecycleAttachFailed activity=${binding.activity.javaClass.name} active=false", + error, + ) + setActivityActive(false) + return + } + activityLifecycle = lifecycle + lifecycle.addObserver(this) + val active = androidActivityIsActive(lifecycle.currentState) + Log.i( + TAG, + "activityLifecycleAttached activity=${binding.activity.javaClass.name} " + + "activityIsLifecycleOwner=${binding.activity is LifecycleOwner} " + + "state=${lifecycle.currentState} active=$active", + ) + setActivityActive(active) + } + + private fun detachFromActivity() { + activityLifecycle?.let { lifecycle -> + lifecycle.removeObserver(this) + Log.i(TAG, "activityLifecycleDetached state=${lifecycle.currentState} active=false") + } + activityLifecycle = null + setActivityActive(false) + } + + private fun setActivityActive(active: Boolean) { + if (activityActive == active) { + refreshFrameScheduling() + return + } + activityActive = active + if (active) { + resumeFromActivityStop() + } else { + suspendForActivityStop() + } + } + + private fun suspendForActivityStop() { + cancelFrameCallback() + val hostsToPause = players.values.toList().mapNotNull { host -> + if (host.mediaState.allowBackgroundPlayback) { + return@mapNotNull null + } + if (host.cancelPlaybackIntent()) { + host + } else { + host.markPlaybackIntentExecuted(host.currentPlaybackIntentGeneration) + null + } + } + if (players.values.none { host -> + host.mediaState.allowBackgroundPlayback && + host.playbackPhase != AndroidPlaybackPhase.PAUSED + } + ) { + audioFocus.abandon() + } + hostsToPause.forEach { host -> + postBackgroundCommand(host, "lifecycle", "pause") + } + videoViews.values.toList().forEach { view -> + runCatching(view::suspendSurfaceAsync) + .onFailure { error -> Log.e(TAG, "Lifecycle surface detach threw", error) } + } + players.values.toList().forEach(::drainEvents) + refreshFrameScheduling() + } + + private fun resumeFromActivityStop() { + videoViews.values.toList().forEach { view -> + runCatching(view::resumeSurface) + .onFailure { error -> Log.e(TAG, "Lifecycle surface attach threw", error) } + } + resumePendingPlayback() + refreshFrameScheduling() + } + + private fun resumePendingPlayback() { + if (!isActivityActive) { + return + } + val pendingHosts = players.values.toList() + .filter { it.playbackPhase == AndroidPlaybackPhase.PENDING } + if (pendingHosts.isEmpty()) { + return + } + val focusGrant = try { + audioFocus.request() + } catch (error: Throwable) { + pendingHosts.forEach { host -> host.cancelPlaybackIntentLocally() } + abandonAudioFocusIfIdle() + Log.e(TAG, "Android audio focus request threw while resuming playback", error) + return + } + when (focusGrant) { + AudioFocusGrant.GRANTED -> { + pendingHosts.forEach { host -> + startPendingPlayback(host, "lifecycle") + } + } + AudioFocusGrant.DELAYED -> Unit + AudioFocusGrant.DENIED -> { + pendingHosts.forEach { host -> host.cancelPlaybackIntentLocally() } + abandonAudioFocusIfIdle() + Log.w(TAG, "Android audio focus denied while resuming Erika playback") + } + } + } + + private fun startPendingPlayback(host: AndroidPlayerHost, source: String) { + if (!host.mediaState.canPlay(isActivityActive) || + !audioFocus.focusGranted || + host.playbackPhase != AndroidPlaybackPhase.PENDING + ) { + return + } + val invocationGeneration = host.tryBeginPlayInvocation() ?: return + val posted = presenterThread.post { + host.markPlaybackIntentExecuted(invocationGeneration) + val response = runCatching { host.invoke("play", emptyMap()) } + val events = pollEventsOnPresenterThread(host) + var isCurrentIntent: Boolean? = null + postMainSafely( + source = "$source play completion", + onFailure = { error -> + // Remove the pending result first so even a rollback failure cannot strand + // the MethodChannel Future or cause a second delivery attempt. + failPendingPlayResults(host, invocationGeneration, error) + val isCurrentHost = players[host.handle] === host && !host.isDestroyed + val ownsCurrentIntent = isCurrentIntent + ?: host.finishPlayInvocation(invocationGeneration) + if (isCurrentHost && ownsCurrentIntent) { + if (androidAsyncPlayCallbackNeedsRollback( + nativePlayAccepted = response.getOrNull()?.ok == true, + isCurrentHost = isCurrentHost, + ownsCurrentIntent = ownsCurrentIntent, + ) + ) { + rollbackAcceptedAsyncPlay(host, source, error) + } else { + host.reconcileNativePlaybackStopped() + abandonAudioFocusIfIdle() + } + } + if (isCurrentHost) { + runCatching { startPendingPlayback(host, "queued play intent") } + .onFailure { cleanupError -> + Log.e(TAG, "Unable to resume queued play after callback failure", cleanupError) + } + runCatching { drainEvents(host) } + .onFailure { cleanupError -> + Log.e(TAG, "Unable to drain events after callback failure", cleanupError) + } + runCatching(::refreshFrameScheduling) + .onFailure { cleanupError -> + Log.e(TAG, "Unable to refresh frames after callback failure", cleanupError) + } + } + }, + ) { + val isCurrentHost = players[host.handle] === host && !host.isDestroyed + val ownsCurrentIntent = host.finishPlayInvocation(invocationGeneration) + .also { isCurrentIntent = it } + if (isCurrentHost) { + processPolledEvents(events) + } + val nativeResponse = response.getOrElse { error -> + throw IllegalStateException( + error.message ?: "Erika Android play failed", + error, + ) + } + if (isCurrentHost) { + when { + !nativeResponse.ok && ownsCurrentIntent -> { + host.reconcileNativePlaybackStopped() + abandonAudioFocusIfIdle() + } + !nativeResponse.ok -> Unit + !ownsCurrentIntent && events.playbackIntentState == PLAYING_STATE -> { + pauseInvalidatedAsyncPlay(host) + } + !ownsCurrentIntent -> Unit + host.playbackPhase == AndroidPlaybackPhase.PLAYING -> { + activateMediaPlayer(host) + } + androidAsyncPlayCanStart( + phase = host.playbackPhase, + canPlayInCurrentActivityState = + host.mediaState.canPlay(isActivityActive), + audioFocusGranted = audioFocus.focusGranted, + ) && host.playbackStarted() -> { + activateMediaPlayer(host) + } + else -> pauseInvalidatedAsyncPlay(host) + } + reportBackgroundCommand(host, source, "play", nativeResponse) + } + if (isCurrentHost) { + // A play requested while an older generation was in flight must be + // queued after its already-submitted pause/stop command. + startPendingPlayback(host, "queued play intent") + drainEvents(host) + refreshFrameScheduling() + } + // Delivery is last; any callback exception above is converted to one error. + completePendingPlayResults(host, invocationGeneration, nativeResponse) + } + } + if (!posted) { + val isCurrentIntent = host.finishPlayInvocation(invocationGeneration) + if (isCurrentIntent) { + host.cancelPlaybackIntentLocally() + abandonAudioFocusIfIdle() + } + val error = IllegalStateException("Android presenter thread is unavailable") + failPendingPlayResults(host, invocationGeneration, error) + Log.w(TAG, "Unable to post $source play for player ${host.handle}", error) + } + } + + private fun prepareNativeArguments( + method: String, + arguments: Map, + cancellation: AndroidContentPreparationCancellation? = null, + ): PreparedNativeArguments { + val nativeArguments = arguments.toMutableMap() + nativeArguments.remove("playerId") + if (method == "open") { + nativeArguments.remove("metadata") + } + if (method !in URI_METHODS) { + return PreparedNativeArguments(nativeArguments, null) + } + val rawUri = nativeArguments["uri"] as? String + ?: return PreparedNativeArguments(nativeArguments, null) + if (!rawUri.startsWith("content://", ignoreCase = true)) { + return PreparedNativeArguments(nativeArguments, null) + } + val source = detachContentSource( + Uri.parse(rawUri), + cancellation ?: AndroidContentPreparationCancellation(), + ) + nativeArguments["uri"] = source.uri + return PreparedNativeArguments(nativeArguments, source.fd) + } + + private fun detachContentSource( + uri: Uri, + cancellation: AndroidContentPreparationCancellation, + ): DetachedContentSource { + cancellation.throwIfCancelled() + val resolver = applicationContext.contentResolver + val asset = resolver.openAssetFileDescriptor(uri, "r") + if (asset != null) { + return asset.use { openedAsset -> + cancellation.throwIfCancelled() + val offset = max(0L, openedAsset.startOffset) + val declaredLength = openedAsset.declaredLength.takeIf { it >= 0L } + val reportedLength = openedAsset.length.takeIf { it > 0L } + val probe = probeContentDescriptor(openedAsset.parcelFileDescriptor.fileDescriptor) + when (probe.transport) { + AndroidContentTransport.OWNED_DESCRIPTOR -> { + val length = resolveSeekableContentLength( + uri = uri, + offset = offset, + declaredLength = declaredLength, + reportedLength = reportedLength, + endOffset = probe.endOffset, + ) + Log.i( + TAG, + androidContentSourceEvent( + stage = "zero_copy", + authority = uri.authority, + fields = linkedMapOf( + "offset" to offset, + "length" to length, + ), + ), + ) + detachAssetFileDescriptor(openedAsset, offset, length) + } + AndroidContentTransport.CACHE_SPOOL -> spoolContentSource( + uri = uri, + sourceOffset = offset, + expectedLength = declaredLength, + fallbackReason = probe.fallbackReason, + cancellation = cancellation, + openInput = openedAsset::createInputStream, + ) + } + } + } + val descriptor = resolver.openFileDescriptor(uri, "r") + ?: throw FileNotFoundException("Unable to open Android content URI: $uri") + return descriptor.use { openedDescriptor -> + cancellation.throwIfCancelled() + val reportedLength = openedDescriptor.statSize.takeIf { it > 0L } + val probe = probeContentDescriptor(openedDescriptor.fileDescriptor) + when (probe.transport) { + AndroidContentTransport.OWNED_DESCRIPTOR -> { + val length = resolveSeekableContentLength( + uri = uri, + offset = 0L, + declaredLength = null, + reportedLength = reportedLength, + endOffset = probe.endOffset, + ) + Log.i( + TAG, + androidContentSourceEvent( + stage = "zero_copy", + authority = uri.authority, + fields = linkedMapOf( + "offset" to 0L, + "length" to length, + ), + ), + ) + val fd = openedDescriptor.detachFd() + detachedContentSource(fd, 0L, length) + } + AndroidContentTransport.CACHE_SPOOL -> spoolContentSource( + uri = uri, + sourceOffset = 0L, + expectedLength = null, + fallbackReason = probe.fallbackReason, + cancellation = cancellation, + openInput = { ParcelFileDescriptor.AutoCloseInputStream(openedDescriptor) }, + ) + } + } + } + + private fun detachAssetFileDescriptor( + asset: AssetFileDescriptor, + offset: Long, + length: Long?, + ): DetachedContentSource { + val fd = asset.parcelFileDescriptor.detachFd() + return detachedContentSource(fd, offset, length) + } + + private fun probeContentDescriptor(fileDescriptor: FileDescriptor): ContentDescriptorProbe { + val stat = try { + Os.fstat(fileDescriptor) + } catch (error: ErrnoException) { + return ContentDescriptorProbe( + transport = AndroidContentTransport.CACHE_SPOOL, + endOffset = null, + fallbackReason = "fstat_errno_${error.errno}", + ) + } + val kind = when { + OsConstants.S_ISREG(stat.st_mode) -> AndroidContentDescriptorKind.REGULAR_FILE + OsConstants.S_ISFIFO(stat.st_mode) -> AndroidContentDescriptorKind.FIFO + OsConstants.S_ISSOCK(stat.st_mode) -> AndroidContentDescriptorKind.SOCKET + OsConstants.S_ISCHR(stat.st_mode) -> AndroidContentDescriptorKind.CHARACTER_DEVICE + OsConstants.S_ISBLK(stat.st_mode) -> AndroidContentDescriptorKind.BLOCK_DEVICE + else -> AndroidContentDescriptorKind.OTHER + } + val statSize = stat.st_size.takeIf { it >= 0L } + val transport = androidContentTransport(kind, statSize) + return ContentDescriptorProbe( + transport = transport, + endOffset = statSize.takeIf { + transport == AndroidContentTransport.OWNED_DESCRIPTOR + }, + fallbackReason = if (transport == AndroidContentTransport.CACHE_SPOOL) { + androidContentFallbackReason(kind) + } else { + null + }, + ) + } + + private fun resolveSeekableContentLength( + uri: Uri, + offset: Long, + declaredLength: Long?, + reportedLength: Long?, + endOffset: Long?, + ): Long? { + if (endOffset != null && endOffset < offset) { + logEmptyOrInvalidDescriptor(uri, offset, "offset_beyond_descriptor") + throw EOFException( + "Android content descriptor offset $offset exceeds its end $endOffset", + ) + } + if (declaredLength != null && endOffset != null) { + if (declaredLength > endOffset - offset) { + logEmptyOrInvalidDescriptor(uri, offset, "declared_slice_truncated") + throw EOFException( + "Android content descriptor slice is truncated: offset=$offset, " + + "length=$declaredLength, end=$endOffset", + ) + } + } + val length = declaredLength + ?: endOffset?.minus(offset) + ?: reportedLength + if (length == 0L) { + logEmptyOrInvalidDescriptor(uri, offset, "empty_descriptor") + throw EOFException("Android content descriptor is empty") + } + if (length == null) { + Log.w( + TAG, + androidContentSourceEvent( + stage = "length_unknown", + authority = uri.authority, + fields = linkedMapOf( + "mode" to "zero_copy", + "reason" to "provider_length_unavailable", + "offset" to offset, + ), + ), + ) + } + return length + } + + private fun logEmptyOrInvalidDescriptor(uri: Uri, offset: Long, reason: String) { + Log.e( + TAG, + androidContentSourceEvent( + stage = "failed", + authority = uri.authority, + fields = linkedMapOf( + "mode" to "zero_copy", + "reason" to reason, + "offset" to offset, + ), + ), + ) + } + + private fun spoolContentSource( + uri: Uri, + sourceOffset: Long, + expectedLength: Long?, + fallbackReason: String?, + cancellation: AndroidContentPreparationCancellation, + openInput: () -> InputStream, + ): DetachedContentSource { + cancellation.throwIfCancelled() + awaitContentSpoolStartupScavenge(cancellation) + val startedAt = SystemClock.elapsedRealtime() + val policy = AndroidContentSpoolPolicy() + Log.w( + TAG, + androidContentSourceEvent( + stage = "fallback", + authority = uri.authority, + fields = linkedMapOf( + "mode" to "cache_spool", + "reason" to (fallbackReason ?: "non_seekable_descriptor"), + "sourceOffset" to sourceOffset, + "declaredLength" to expectedLength, + "execution" to "background", + "maxBytes" to policy.maxBytes, + "minFreeBytes" to policy.minFreeBytes, + ), + ), + ) + var cacheFile: File? = null + var bytesWritten: Long? = null + try { + val cacheDirectory = File(applicationContext.cacheDir, ANDROID_CONTENT_SPOOL_DIRECTORY) + if (!cacheDirectory.isDirectory && + !cacheDirectory.mkdirs() && + !cacheDirectory.isDirectory + ) { + throw IOException("Unable to create Erika Android content spool directory") + } + cancellation.throwIfCancelled() + val outputFile = File.createTempFile( + ANDROID_CONTENT_SPOOL_PREFIX, + ANDROID_CONTENT_SPOOL_SUFFIX, + cacheDirectory, + ) + cacheFile = outputFile + cancellation.trackTemporaryFile(outputFile) + val input = openInput() + cancellation.register(input) + try { + input.use { + FileOutputStream(outputFile).use { output -> + cancellation.register(output) + try { + bytesWritten = AndroidContentSpooler.copy( + input = input, + output = output, + expectedLength = expectedLength, + policy = policy, + availableBytes = { cacheDirectory.usableSpace }, + cancelled = { cancellation.isCancelled }, + onProgress = { bytes -> bytesWritten = bytes }, + ) + cancellation.throwIfCancelled() + output.fd.sync() + } finally { + cancellation.unregister(output) + } + } + } + } finally { + cancellation.unregister(input) + } + cancellation.throwIfCancelled() + val length = checkNotNull(bytesWritten) + val source = detachCachedContentSource(outputFile, length) + cancellation.releaseTemporaryFile(outputFile) + try { + Log.i( + TAG, + androidContentSourceEvent( + stage = "spool_complete", + authority = uri.authority, + fields = linkedMapOf( + "bytes" to length, + "elapsedMs" to SystemClock.elapsedRealtime() - startedAt, + "cachePathRetained" to false, + ), + ), + ) + } catch (error: Throwable) { + closeDetachedFileDescriptor(source.fd) + throw error + } + return source + } catch (error: Throwable) { + val partialFile = cacheFile + val deleted = partialFile == null || deleteContentSpoolFile(partialFile) + partialFile?.let(cancellation::releaseTemporaryFile) + Log.e( + TAG, + androidContentSourceEvent( + stage = "failed", + authority = uri.authority, + fields = linkedMapOf( + "mode" to "cache_spool", + "reason" to androidContentSourceFailureReason(error), + "message" to (error.message ?: error.javaClass.simpleName), + "bytes" to bytesWritten, + "partialCacheDeleted" to deleted, + "elapsedMs" to SystemClock.elapsedRealtime() - startedAt, + ), + ), + error, + ) + throw error + } + } + + private fun detachCachedContentSource(cacheFile: File, length: Long): DetachedContentSource { + val descriptor = ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY) + val fd = try { + descriptor.detachFd() + } catch (error: Throwable) { + descriptor.close() + throw error + } + val unlinked = try { + cacheFile.delete() + } catch (error: Throwable) { + closeDetachedFileDescriptor(fd) + throw error + } + if (!unlinked) { + closeDetachedFileDescriptor(fd) + throw IOException( + "Unable to unlink Erika Android content spool file ${cacheFile.name}", + ) + } + // The path is now gone, but the detached descriptor keeps the inode alive until Rust + // drops its OwnedFileDescriptorSource. No cache path can leak across player lifetimes. + return detachedContentSource(fd, 0L, length) + } + + private fun detachedContentSource( + fd: Int, + offset: Long, + length: Long?, + ): DetachedContentSource = try { + DetachedContentSource(fd, fdUri(fd, offset, length)) + } catch (error: Throwable) { + closeDetachedFileDescriptor(fd) + throw error + } + + private fun fdUri(fd: Int, offset: Long, length: Long?): String = buildString { + append("fd://") + append(fd) + append("?offset=") + append(max(0L, offset)) + if (length != null) { + append("&length=") + append(length) + } + } + + private fun closeDetachedFileDescriptor(fd: Int) { + runCatching { ParcelFileDescriptor.adoptFd(fd).close() } + .onFailure { error -> Log.w(TAG, "Unable to close detached content fd $fd", error) } + } + + private fun handleAudioFocusLoss(mayResume: Boolean) { + players.values.toList().forEach { host -> + if (host.playbackPhase == AndroidPlaybackPhase.PAUSED) { + return@forEach + } + val shouldPause = host.handleFocusLoss(mayResume) + if (shouldPause) { + postBackgroundCommand(host, "audio focus", "pause") + } else { + // Delayed focus can be cancelled before native Play is ever + // invoked, so there is no presenter command to acknowledge it. + host.markPlaybackIntentExecuted(host.currentPlaybackIntentGeneration) + } + drainEvents(host) + } + if (!mayResume) { + abandonAudioFocusIfIdle() + } + refreshFrameScheduling() + } + + private fun handleAudioFocusGain() { + if (!audioFocus.focusGranted) { + return + } + players.values.toList() + .filter { + it.playbackPhase == AndroidPlaybackPhase.PENDING && + (isActivityActive || it.mediaState.allowBackgroundPlayback) + } + .forEach { host -> + // The transient-loss Pause may still be queued on the presenter. Give the + // resumed Play a fresh generation so that Pause cannot roll host state back. + host.renewPendingPlaybackIntent() + startPendingPlayback(host, "audio focus") + } + refreshFrameScheduling() + } + + private fun abandonAudioFocusIfIdle() { + if (players.values.none { it.playbackPhase != AndroidPlaybackPhase.PAUSED }) { + audioFocus.abandon() + } + } + + private fun reportBackgroundCommand( + host: AndroidPlayerHost, + source: String, + method: String, + response: NativeResponse, + ) { + if (!response.ok) { + Log.e( + TAG, + "$source $method failed for player ${host.handle}: " + + "status=${response.status} ${response.error.orEmpty()}", + ) + } + } + + private fun refreshFrameScheduling() { + videoViews.values.forEach { view -> + val phase = view.boundPlayerHost?.playbackPhase + view.setPlaybackKeepsScreenOn( + isActivityActive && phase != null && phase != AndroidPlaybackPhase.PAUSED, + ) + } + val needsFrame = isActivityActive && players.values.any(AndroidPlayerHost::shouldTick) + if (!needsFrame) { + cancelFrameCallback() + return + } + if (!frameScheduled) { + frameScheduled = true + choreographer.postFrameCallback(frameCallback) + } + } + + private fun enqueueRenderTick(targets: List, timeSeconds: Double) { + val request = AndroidRenderRequest( + timeSeconds = timeSeconds, + targets = targets, + generation = renderGeneration.get(), + ) + if (renderRequests.submit(request)) { + postRenderDrain() + } + } + + private fun postRenderDrain() { + if (!presenterThread.post(::drainRenderRequests)) { + renderRequests.abortDrain() + } + } + + private fun drainRenderRequests() { + val request = renderRequests.takeLatest() + if (request != null && request.generation == renderGeneration.get()) { + if (renderThreadReported.compareAndSet(false, true)) { + Log.i( + TAG, + "presenterRenderThread tid=${Process.myTid()} " + + "mainThread=${Looper.myLooper() === Looper.getMainLooper()}", + ) + } + val outcomes = request.targets.mapNotNull { target -> + val host = target.host + if (host.isDestroyed) { + null + } else { + val contentGeneration = host.latestExecutedContentGeneration + val result = runCatching { host.renderTick(request.timeSeconds) } + AndroidRenderOutcome( + host, + target.renderRequestGeneration, + contentGeneration, + result.getOrNull(), + result.exceptionOrNull(), + ) + } + } + postMainSafely("video render completion") main@{ + if (request.generation != renderGeneration.get()) { + return@main + } + outcomes.forEach { outcome -> + val host = outcome.host + if (players[host.handle] !== host || host.isDestroyed) { + return@forEach + } + try { + val response = outcome.response + if (response != null) { + reportRenderResponse(host, outcome.contentGeneration, response) + } else { + reportRenderException( + host, + outcome.contentGeneration, + outcome.error + ?: IllegalStateException("renderTick failed without an error"), + ) + } + } finally { + host.markRenderAttempted(outcome.renderRequestGeneration) + } + } + } + } + if (renderRequests.finishDrain()) { + // Requeue at the tail so surface, command, event, and destroy work + // cannot be starved by a continuously overloaded render loop. + postRenderDrain() + } + } + + private fun performBackgroundPlaybackTick(@Suppress("UNUSED_PARAMETER") timeSeconds: Double) { + if (isActivityActive) { + return + } + val tickingPlayers = players.values.toList() + .filter { + it.mediaState.allowBackgroundPlayback && + it.playbackPhase == AndroidPlaybackPhase.PLAYING + } + .map { host -> AndroidRenderTarget(host, 0L) } + if (tickingPlayers.isEmpty() || !backgroundTickQueued.compareAndSet(false, true)) { + return + } + val posted = presenterThread.post { + val outcomes = tickingPlayers.mapNotNull { target -> + val host = target.host + if (host.isDestroyed) { + null + } else { + val contentGeneration = host.latestExecutedContentGeneration + val result = runCatching { host.audioOnlyTick() } + AndroidRenderOutcome( + host, + 0L, + contentGeneration, + result.getOrNull(), + result.exceptionOrNull(), + ) + } + } + backgroundTickQueued.set(false) + postMainSafely("background audio render completion") { + outcomes.forEach { outcome -> + val host = outcome.host + if (players[host.handle] !== host || host.isDestroyed) { + return@forEach + } + outcome.response?.let { + reportRenderResponse(host, outcome.contentGeneration, it) + } + ?: reportRenderException( + host, + outcome.contentGeneration, + outcome.error + ?: IllegalStateException("audioOnlyTick failed without an error"), + ) + } + } + } + if (!posted) { + backgroundTickQueued.set(false) + } + } + + private fun cancelFrameCallback() { + renderGeneration.incrementAndGet() + renderRequests.cancelPending() + if (frameScheduled) { + choreographer.removeFrameCallback(frameCallback) + frameScheduled = false + } + } + + private fun reportRenderResponse( + host: AndroidPlayerHost, + contentGeneration: Long, + response: NativeResponse, + ) { + if (response.ok) { + if (host.lastRenderErrorContentGeneration == contentGeneration) { + host.lastRenderError = null + host.lastRenderErrorContentGeneration = null + } + return + } + val signature = "${response.status}:${response.error.orEmpty()}" + if (host.lastRenderError != signature || + host.lastRenderErrorContentGeneration != contentGeneration + ) { + host.lastRenderError = signature + host.lastRenderErrorContentGeneration = contentGeneration + Log.e(TAG, "renderTick failed for player ${host.handle}: $signature") + enqueueHostError( + host, + "renderTick", + response.status, + response.error ?: "renderTick failed", + contentGeneration = contentGeneration, + ) + } + } + + private fun reportRenderException( + host: AndroidPlayerHost, + contentGeneration: Long, + error: Throwable, + ) { + val signature = "exception:${error.message.orEmpty()}" + if (host.lastRenderError != signature || + host.lastRenderErrorContentGeneration != contentGeneration + ) { + host.lastRenderError = signature + host.lastRenderErrorContentGeneration = contentGeneration + Log.e(TAG, "renderTick threw for player ${host.handle}", error) + enqueueHostError( + host, + "renderTick", + -1, + error.message ?: "renderTick threw", + contentGeneration = contentGeneration, + ) + } + } + + private fun enqueueHostError( + host: AndroidPlayerHost, + stage: String, + status: Int, + error: String, + details: Map = emptyMap(), + contentGeneration: Long?, + ) { + val event = linkedMapOf( + "playerId" to host.handle, + "kind" to ERROR_EVENT_KIND, + "state" to ERROR_STATE, + "status" to status, + "error" to error, + "message" to "Android host failure during $stage", + "hostStage" to stage, + ) + event.putAll(details) + enqueuePendingEvent( + host, + AndroidPendingEvent.Success(event, contentGeneration), + ) + flushPendingEvents(host) + } + + private fun enqueuePendingEvent(host: AndroidPlayerHost, event: AndroidPendingEvent) { + val overflow = host.enqueuePendingEvent(event) ?: return + if (overflow.droppedTotal == 1L || + overflow.droppedTotal % EVENT_OVERFLOW_LOG_INTERVAL == 0L + ) { + val droppedType = when (val dropped = overflow.dropped) { + is AndroidPendingEvent.Success -> + "success(kind=${(dropped.value["kind"] as? Number)?.toInt()})" + is AndroidPendingEvent.Error -> "error(code=${dropped.code})" + } + Log.w( + TAG, + "pendingEventQueueOverflow playerId=${host.handle} " + + "policy=drop_oldest capacity=${overflow.capacity} " + + "droppedTotal=${overflow.droppedTotal} dropped=$droppedType", + ) + } + } + + private fun flushPendingEvents(host: AndroidPlayerHost) { + val sink = eventSink ?: return + // `prepareForOpen` prunes eagerly; repeat here as a defensive boundary + // for events retained after a sink exception. + host.discardStalePendingEvents() + while (eventSink === sink) { + val event = host.firstPendingEvent() ?: return + try { + when (event) { + is AndroidPendingEvent.Success -> sink.success(event.value) + is AndroidPendingEvent.Error -> + sink.error(event.code, event.message, event.details) + } + } catch (error: Throwable) { + Log.e( + TAG, + "EventChannel delivery failed for player ${host.handle}; retaining event", + error, + ) + return + } + host.removeFirstPendingEvent() + } + } + + private fun drainEvents(host: AndroidPlayerHost) { + flushPendingEvents(host) + host.eventPollBackoff.reset() + eventPollIdleRounds = 0 + requestEventPoll(immediate = true) + } + + private fun requestEventPoll(immediate: Boolean = false) { + if (!attachedToEngine || players.isEmpty()) { + return + } + immediateEventPollLatch.request(immediate) + if (eventPollQueued.get()) { + return + } + val runImmediately = immediateEventPollLatch.takeIfReady(pollInFlight = false) + if (eventPollTimerScheduled) { + if (!runImmediately) { + return + } + mainHandler.removeCallbacks(eventPollRunnable) + eventPollTimerScheduled = false + } + eventPollTimerScheduled = true + if (runImmediately) { + mainHandler.post(eventPollRunnable) + } else { + mainHandler.postDelayed(eventPollRunnable, nextEventPollDelayMillis()) + } + } + + private fun stopEventPollingIfIdle() { + if (players.isNotEmpty()) { + return + } + mainHandler.removeCallbacks(eventPollRunnable) + eventPollTimerScheduled = false + eventPollIdleRounds = 0 + immediateEventPollLatch.clear() + } + + private fun nextEventPollDelayMillis(): Long { + val idleDelay = androidEventPollDelayMillis( + hasActivePlayers = hasLowLatencyEventPollingPlayers(), + idleRounds = eventPollIdleRounds, + ) + val nowMillis = SystemClock.uptimeMillis() + val hostRetryDelays = players.values + .asSequence() + .filterNot { host -> host.isDestroyed } + .map { host -> host.eventPollBackoff.delayMillis(nowMillis) } + .toList() + return androidNextEventPollDelayMillis(idleDelay, hostRetryDelays) + } + + private fun hasLowLatencyEventPollingPlayers(): Boolean = players.values.any { host -> + host.playbackPhase == AndroidPlaybackPhase.PLAYING || + (host.playbackPhase == AndroidPlaybackPhase.PENDING && + host.mediaState.canPlay(isActivityActive)) + } + + private fun scheduleEventPoll() { + if (!attachedToEngine || !eventPollQueued.compareAndSet(false, true)) { + return + } + val pollStartedAtMillis = SystemClock.uptimeMillis() + val pollingPlayers = players.values.toList().filter { host -> + !host.isDestroyed && host.eventPollBackoff.delayMillis(pollStartedAtMillis) == 0L + } + if (pollingPlayers.isEmpty()) { + eventPollQueued.set(false) + if (players.values.any { host -> !host.isDestroyed }) { + requestEventPoll() + } + return + } + val posted = presenterThread.post { + val batches = pollingPlayers.map(::pollEventsOnPresenterThread) + postMainSafely( + source = "event poll completion", + onFailure = { + eventPollQueued.set(false) + requestEventPoll() + }, + ) main@{ + eventPollQueued.set(false) + if (!attachedToEngine) { + return@main + } + var observedEvent = false + val pollCompletedAtMillis = SystemClock.uptimeMillis() + batches.forEach { batch -> + val host = batch.host + if (players[host.handle] !== host || host.isDestroyed) { + return@forEach + } + observedEvent = observedEvent || batch.responses.any(NativeResponse::ok) + host.eventPollBackoff.record( + failed = eventPollFailureSignature(batch) != null, + nowMillis = pollCompletedAtMillis, + ) + processPolledEvents(batch) + } + eventPollIdleRounds = if (observedEvent || + hasLowLatencyEventPollingPlayers() + ) { + 0 + } else { + (eventPollIdleRounds + 1).coerceAtMost(ANDROID_MAX_EVENT_POLL_IDLE_ROUNDS) + } + requestEventPoll() + } + } + if (!posted) { + eventPollQueued.set(false) + } + } + + private fun pollEventsOnPresenterThread(host: AndroidPlayerHost): AndroidPolledEvents { + val responses = ArrayList() + var failure: Throwable? = null + var eventQueueDrained = false + for (index in 0 until MAX_EVENTS_PER_POLL) { + val response = try { + host.pollEvent() + } catch (error: Throwable) { + failure = error + null + } + if (response == null) { + eventQueueDrained = failure == null + break + } + responses += response + if (!response.ok) { + break + } + } + val playbackState = try { + host.playbackState() + } catch (error: Throwable) { + if (failure == null) { + failure = error + } + null + } + val playbackIntentState = try { + host.playbackIntentState() + } catch (error: Throwable) { + if (failure == null) { + failure = error + } + null + } + return AndroidPolledEvents( + host, + responses, + failure, + host.latestExecutedContentGeneration, + host.latestExecutedPlaybackIntentGeneration, + playbackState, + playbackIntentState, + eventQueueDrained, + ) + } + + private fun processPolledEvents(batch: AndroidPolledEvents) { + val host = batch.host + val acceptsContent = androidEventBatchAcceptsContent( + eventGeneration = batch.contentGeneration, + currentContentGeneration = host.currentContentGeneration, + ) + val acceptsPlaybackState = acceptsContent && + androidEventBatchAcceptsPlaybackState( + eventGeneration = batch.playbackIntentGeneration, + currentIntentGeneration = host.currentPlaybackIntentGeneration, + ) + if (!acceptsContent) { + Log.d( + TAG, + "Ignoring stale content events for player ${host.handle}: " + + "eventGeneration=${batch.contentGeneration} " + + "currentGeneration=${host.currentContentGeneration}", + ) + } + if (!acceptsPlaybackState) { + Log.d( + TAG, + "Ignoring stale playback state for player ${host.handle}: " + + "eventGeneration=${batch.playbackIntentGeneration} " + + "currentGeneration=${host.currentPlaybackIntentGeneration}", + ) + } + val failureSignature = eventPollFailureSignature(batch) + // Poll failures describe the presenter/host, not one media item. Queue + // them independently of content generation so a failure observed + // during Open cannot be permanently swallowed by stale-content + // filtering. `shouldReport` commits the signature only when this policy + // allows the failure to be queued. + val reportPollFailure = host.eventPollFailures.shouldReport( + signature = failureSignature, + canDeliver = true, + ) + if (reportPollFailure) { + batch.error?.let { error -> + Log.e(TAG, "pollEvent threw for player ${host.handle}", error) + } + val failedResponse = batch.responses.firstOrNull { response -> + !response.ok && response.status != NO_EVENT_STATUS + } + val error = batch.error + enqueuePendingEvent( + host, + AndroidPendingEvent.Error( + code = "ERIKA_ERROR", + message = error?.message + ?: failedResponse?.error + ?: "Erika event polling failed", + details = buildMap { + put("playerId", host.handle) + put("status", failedResponse?.status ?: -1) + error?.let { put("exception", it.javaClass.name) } + }, + contentGeneration = null, + ), + ) + } + val authoritativePlaybackState = batch.playbackState.takeIf { + androidCanSynthesizeAuthoritativeState(batch.eventQueueDrained) + } + val pendingPlayTransition = acceptsPlaybackState && + authoritativePlaybackState?.let { playbackState -> + androidPlaybackStateIsPendingPlayTransition( + playbackState = playbackState, + playbackIntentState = batch.playbackIntentState, + playingState = PLAYING_STATE, + ) + } == true + var latestPlaybackState: Int? = null + var deliveredAuthoritativeState = false + for (response in batch.responses) { + if (!response.ok) { + break + } + val rawEvent = response.value as? Map<*, *> ?: break + val event = linkedMapOf() + rawEvent.forEach { (key, value) -> + if (key != null) { + event[key.toString()] = value + } + } + event.putIfAbsent("playerId", host.handle) + val eventKind = (event["kind"] as? Number)?.toInt() + val stateChanged = eventKind == STATE_CHANGED_EVENT_KIND + val eventPlaybackState = (event["state"] as? Number)?.toInt() + if (acceptsContent && eventKind == ERROR_EVENT_KIND) { + val status = (event["status"] as? Number)?.toInt() ?: -1 + val error = event["error"] as? String + ?: event["message"] as? String + ?: "unknown native error" + Log.e( + TAG, + "Erika error event: playerId=${host.handle} status=$status error=$error", + ) + } + if (!androidEventShouldBeDelivered( + eventKind = eventKind, + stateChangedEventKind = STATE_CHANGED_EVENT_KIND, + acceptsContent = acceptsContent, + acceptsPlaybackState = acceptsPlaybackState, + pendingPlayTransition = pendingPlayTransition, + ) + ) { + continue + } + latestPlaybackState = updatedPlaybackState(latestPlaybackState, event) + if (stateChanged && eventPlaybackState == authoritativePlaybackState) { + deliveredAuthoritativeState = true + } + val duplicateStateChanged = stateChanged && + androidStateChangedEventIsDuplicate( + currentPlaybackState = host.mediaState.playbackState, + eventPlaybackState = eventPlaybackState, + ) + host.updateMediaState(event, rememberCompleteEvent = acceptsContent) + if (!duplicateStateChanged) { + enqueuePendingEvent( + host, + AndroidPendingEvent.Success( + value = event, + contentGeneration = androidPendingEventContentGeneration( + eventKind, + batch.contentGeneration, + ), + ), + ) + } + } + if (acceptsPlaybackState && authoritativePlaybackState != null && !pendingPlayTransition) { + val authoritativeEvent = androidAuthoritativeStateEvent( + lastCompleteEvent = host.completeNativeEventSnapshot(), + playerId = host.handle, + stateChangedEventKind = STATE_CHANGED_EVENT_KIND, + state = authoritativePlaybackState, + durationMicros = host.mediaState.durationMicros, + positionMicros = host.mediaState.positionMicros, + ) + val stateChanged = host.mediaState.playbackState != authoritativePlaybackState + host.updateMediaState(authoritativeEvent) + if (stateChanged && !deliveredAuthoritativeState) { + enqueuePendingEvent( + host, + AndroidPendingEvent.Success( + value = authoritativeEvent, + contentGeneration = batch.contentGeneration, + ), + ) + } + } + if (acceptsPlaybackState) { + (authoritativePlaybackState ?: latestPlaybackState)?.let { state -> + observeNativePlaybackState(host, state, batch.playbackIntentState) + } + } + if (acceptsContent && activeMediaPlayerId == host.handle) { + val mediaSessionPlaybackState = androidMediaSessionPlaybackState( + playbackState = host.mediaState.playbackState, + playbackIntentState = batch.playbackIntentState, + playingState = PLAYING_STATE, + acceptsPlaybackState = acceptsPlaybackState, + ) + mediaSession.update( + if (mediaSessionPlaybackState == host.mediaState.playbackState) { + host.mediaState + } else { + host.mediaState.copy(playbackState = mediaSessionPlaybackState) + }, + ) + } + flushPendingEvents(host) + } + + private fun eventPollFailureSignature(batch: AndroidPolledEvents): String? { + batch.error?.let { error -> + return "exception:${error.javaClass.name}:${error.message.orEmpty()}" + } + val response = batch.responses.firstOrNull { candidate -> + !candidate.ok && candidate.status != NO_EVENT_STATUS + } ?: return null + return "response:${response.status}:${response.error.orEmpty()}" + } + + private fun setMediaMetadata(arguments: Map, result: MethodChannel.Result) { + val host = player(arguments) + host.setMediaMetadata(androidMediaMetadata(arguments)) + if (activeMediaPlayerId == host.handle) { + mediaSession.update(host.mediaState) + } + result.success(null) + } + + private fun setSystemMediaNavigation( + arguments: Map, + result: MethodChannel.Result, + ) { + val host = player(arguments) + host.setSystemMediaNavigation(arguments) + if (activeMediaPlayerId == host.handle) { + mediaSession.update(host.mediaState) + } + result.success(null) + } + + private fun emitSystemMediaNavigation(playerId: Long, navigation: String) { + val host = players[playerId] ?: return + val event = systemMediaNavigationEvent(host.mediaState, navigation) ?: return + enqueuePendingEvent( + host, + AndroidPendingEvent.Success( + value = event, + contentGeneration = host.currentContentGeneration, + ), + ) + flushPendingEvents(host) + } + + private fun performSystemMediaCommand( + playerId: Long, + method: String, + arguments: Map = emptyMap(), + ) { + val host = players[playerId] ?: return + if (method == "play") { + if (!host.mediaState.canPlay(isActivityActive)) { + return + } + host.requestPlayback() + refreshFrameScheduling() + when (runCatching { audioFocus.request() }.getOrNull()) { + AudioFocusGrant.GRANTED -> startPendingPlayback(host, "system media") + AudioFocusGrant.DELAYED -> Unit + else -> { + host.cancelPlaybackIntentLocally() + abandonAudioFocusIfIdle() + refreshFrameScheduling() + } + } + return + } + if (method in PLAYBACK_INTENT_CANCEL_METHODS) { + host.cancelPlaybackIntent(forceNewGeneration = true) + abandonAudioFocusIfIdle() + } + if (method in CONTENT_PREPARATION_INVALIDATION_METHODS) { + host.cancelContentPreparations("superseded_by_system_$method") + } + refreshFrameScheduling() + postBackgroundCommand(host, "system media", method, arguments) + } + + private fun observeNativePlaybackState( + host: AndroidPlayerHost, + state: Int, + playbackIntentState: Int?, + ) { + if ( + androidPlaybackStateIsPendingPlayTransition( + playbackState = state, + playbackIntentState = playbackIntentState, + playingState = PLAYING_STATE, + ) + ) { + // Player::play is accepted synchronously but committed by the Rust + // playback worker. Ready/Paused/Stopped is therefore a legitimate + // transient actual state while the latest native intent is Playing. + refreshFrameScheduling() + return + } + when (state) { + PLAYING_STATE -> { + if (isActivityActive && + audioFocus.focusGranted && + host.playbackPhase == AndroidPlaybackPhase.PENDING + ) { + host.playbackStarted() + } + } + PAUSED_STATE -> { + if (host.playbackPhase == AndroidPlaybackPhase.PLAYING) { + host.reconcileNativePlaybackStopped() + abandonAudioFocusIfIdle() + } + } + STOPPED_STATE, + CLOSED_STATE, + ERROR_STATE -> { + host.reconcileNativePlaybackStopped() + abandonAudioFocusIfIdle() + } + } + refreshFrameScheduling() + } + + private fun complete(result: MethodChannel.Result, response: NativeResponse) { + if (response.ok) { + result.success(response.value) + } else { + result.error( + "ERIKA_ERROR", + response.error ?: "Erika native call failed with status ${response.status}", + mapOf("status" to response.status), + ) + } + } + + private fun deliverSurfaceMethodResult( + operation: String, + result: MethodChannel.Result, + response: NativeResponse, + successValue: Any? = null, + ) { + runCatching { + if (response.ok) { + result.success(successValue) + } else { + complete(result, response) + } + }.onFailure { error -> + Log.e(TAG, "Unable to deliver asynchronous $operation result", error) + } + } + + private fun player(arguments: Map): AndroidPlayerHost { + val playerId = arguments.requiredLong("playerId") + return players[playerId] + ?: throw IllegalStateException("Erika Android player $playerId was not found") + } + + private fun arguments(call: MethodCall): Map { + val raw = call.arguments as? Map<*, *> ?: return emptyMap() + return buildMap { + raw.forEach { (key, value) -> + if (key != null) { + put(key.toString(), value) + } + } + } + } + + private fun newContentPreparationExecutor(): ExecutorService = + Executors.newFixedThreadPool(CONTENT_PREPARATION_THREADS) { runnable -> + Thread( + runnable, + "erika-content-${CONTENT_PREPARATION_THREAD_IDS.getAndIncrement()}", + ).apply { + isDaemon = true + } + } + + private fun scheduleContentSpoolStartupScavenge(): Future<*>? = try { + contentPreparationExecutor.submit { + val startedAt = SystemClock.elapsedRealtime() + try { + // cacheDir access and directory enumeration both stay off the platform thread. + val directory = File(applicationContext.cacheDir, ANDROID_CONTENT_SPOOL_DIRECTORY) + val stats = scavengeAndroidContentSpoolDirectory(directory) + val event = androidContentSourceEvent( + stage = "startup_scavenge", + authority = null, + fields = linkedMapOf( + "mode" to "cache_spool", + "execution" to "background", + "files" to stats.files, + "bytes" to stats.bytes, + "deleteFailures" to stats.deleteFailures, + "elapsedMs" to SystemClock.elapsedRealtime() - startedAt, + ), + ) + if (stats.deleteFailures == 0) { + Log.i(TAG, event) + } else { + Log.w(TAG, event) + } + } catch (error: Throwable) { + Log.e( + TAG, + androidContentSourceEvent( + stage = "startup_scavenge", + authority = null, + fields = linkedMapOf( + "mode" to "cache_spool", + "execution" to "background", + "files" to 0, + "bytes" to 0L, + "deleteFailures" to 0, + "reason" to "scan_failed", + "message" to (error.message ?: error.javaClass.simpleName), + "elapsedMs" to SystemClock.elapsedRealtime() - startedAt, + ), + ), + error, + ) + } + } + } catch (error: RejectedExecutionException) { + Log.e( + TAG, + androidContentSourceEvent( + stage = "startup_scavenge", + authority = null, + fields = linkedMapOf( + "mode" to "cache_spool", + "execution" to "not_started", + "files" to 0, + "bytes" to 0L, + "deleteFailures" to 0, + "reason" to "executor_unavailable", + ), + ), + error, + ) + null + } + + private fun awaitContentSpoolStartupScavenge( + cancellation: AndroidContentPreparationCancellation, + ) { + val future = contentSpoolScavengeFuture ?: return + try { + future.get() + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + throw AndroidContentPreparationCancelledException( + "Android content preparation was interrupted before startup cache cleanup", + ) + } catch (error: CancellationException) { + throw AndroidContentPreparationCancelledException( + "Android content startup cache cleanup was cancelled", + ) + } catch (error: Throwable) { + throw IOException("Android content startup cache cleanup failed", error) + } + cancellation.throwIfCancelled() + } + + private class PendingContentCommand( + val host: AndroidPlayerHost, + val method: String, + val authority: String?, + val result: MethodChannel.Result, + val cancellation: AndroidContentPreparationCancellation, + val playbackIntentGeneration: Long?, + val contentGeneration: Long?, + ) { + lateinit var token: AndroidContentPreparationToken + private var completed = false + + fun claimCompletion(): Boolean { + if (completed) { + return false + } + completed = true + return true + } + } + + private data class PreparedNativeArguments( + val arguments: Map, + val detachedFd: Int?, + ) + + private data class DetachedContentSource( + val fd: Int, + val uri: String, + ) + + private data class ContentDescriptorProbe( + val transport: AndroidContentTransport, + val endOffset: Long?, + val fallbackReason: String?, + ) + + private data class AndroidRenderRequest( + val timeSeconds: Double, + val targets: List, + val generation: Long, + ) + + private data class AndroidRenderTarget( + val host: AndroidPlayerHost, + val renderRequestGeneration: Long, + ) + + private data class AndroidRenderOutcome( + val host: AndroidPlayerHost, + val renderRequestGeneration: Long, + val contentGeneration: Long, + val response: NativeResponse?, + val error: Throwable?, + ) + + private data class AndroidPolledEvents( + val host: AndroidPlayerHost, + val responses: List, + val error: Throwable?, + val contentGeneration: Long, + val playbackIntentGeneration: Long, + val playbackState: Int?, + val playbackIntentState: Int?, + val eventQueueDrained: Boolean, + ) + + private data class PendingPlayKey( + val host: AndroidPlayerHost, + val intentGeneration: Long, + ) + + companion object { + private const val TAG = "ErikaFlutterPlugin" + private const val PLAYER_CHANNEL = "erika_flutter/player" + private const val EVENT_CHANNEL = "erika_flutter/events" + private const val VIDEO_VIEW_TYPE = "erika_flutter/video_view" + private const val HDR_VIDEO_VIEW_TYPE = "erika_flutter/hdr_video_view" + private const val MAX_EVENTS_PER_POLL = 256 + private const val EVENT_OVERFLOW_LOG_INTERVAL = 256L + private const val NO_EVENT_STATUS = 5 + private const val ERROR_EVENT_KIND = 9 + private const val PLAYING_STATE = 3 + private const val PAUSED_STATE = 4 + private const val STOPPED_STATE = 5 + private const val CLOSED_STATE = 6 + private const val ERROR_STATE = 7 + private const val NO_OWNED_FD = -1 + private const val CONTENT_PREPARATION_THREADS = 2 + private val CONTENT_PREPARATION_THREAD_IDS = AtomicInteger(1) + + private val URI_METHODS = setOf( + "open", + "addExternalSubtitle", + "loadDanmakuFile", + "addDanmakuTrackFile", + ) + + private val PLAYBACK_INTENT_CANCEL_METHODS = setOf( + "open", + "pause", + "stop", + "close", + ) + + private val CONTENT_PREPARATION_INVALIDATION_METHODS = setOf( + "open", + "stop", + "close", + ) + + private val RENDER_REQUEST_METHODS = setOf( + "open", + "stop", + "close", + "seek", + "setUpscaler", + "setSubtitleScale", + "setSubtitleStyle", + "selectSubtitleMemoryFonts", + "clearSubtitleMemoryFonts", + "addExternalSubtitle", + "removeSubtitleTrack", + "loadDanmakuFile", + "loadDanmakuJson", + "addDanmakuTrackFile", + "addDanmakuTrackJson", + "removeDanmakuTrack", + "setDanmakuTrackEnabled", + "setDanmakuTrackOffset", + "setDanmakuGlobalOffset", + "clearDanmaku", + "setDanmakuEnabled", + "setDanmakuConfig", + "setDebugHudEnabled", + "selectAudioTrack", + "selectSubtitleTrack", + ) + + private val NATIVE_METHODS = setOf( + "open", + "play", + "pause", + "stop", + "close", + "seek", + "setPlaybackRate", + "setVolume", + "setUpscaler", + "setSubtitleScale", + "setSubtitleStyle", + "selectSubtitleMemoryFonts", + "clearSubtitleMemoryFonts", + "getSubtitleMemoryFontStatus", + "getUpscalerStatus", + "getOutputStatus", + "getPresenterStats", + "getResourceStatus", + "setDebugHudEnabled", + "addExternalSubtitle", + "removeSubtitleTrack", + "loadDanmakuFile", + "loadDanmakuJson", + "addDanmakuTrackFile", + "addDanmakuTrackJson", + "removeDanmakuTrack", + "setDanmakuTrackEnabled", + "setDanmakuTrackOffset", + "setDanmakuGlobalOffset", + "danmakuTracks", + "clearDanmaku", + "setDanmakuEnabled", + "setDanmakuConfig", + "selectAudioTrack", + "selectSubtitleTrack", + "tracks", + ) + } +} + +private fun Map.number(key: String): Number? = this[key] as? Number + +private fun Map.int(key: String): Int? = number(key)?.toInt() + +private fun Map.requiredInt(key: String): Int = + int(key) ?: throw IllegalArgumentException("Missing integer argument '$key'") + +private fun Map.requiredLong(key: String): Long = + number(key)?.toLong() ?: throw IllegalArgumentException("Missing integer argument '$key'") diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaCommandReceiver.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaCommandReceiver.kt new file mode 100644 index 00000000..d4c9a6db --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaCommandReceiver.kt @@ -0,0 +1,52 @@ +package dev.aimesoft.erika_flutter + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class ErikaMediaCommandReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + ErikaMediaSession.ACTION_PLAY -> dispatch(ErikaMediaSession.ACTION_PLAY) + ErikaMediaSession.ACTION_PAUSE -> dispatch(ErikaMediaSession.ACTION_PAUSE) + ErikaMediaSession.ACTION_STOP -> dispatch(ErikaMediaSession.ACTION_STOP) + } + } + + internal companion object { + private val handlers = LinkedHashMap Unit>() + private val activationOrder = LinkedHashSet() + + @Synchronized + fun register(owner: Any, handler: (String) -> Unit) { + handlers[owner] = handler + } + + @Synchronized + fun activate(owner: Any) { + if (!handlers.containsKey(owner)) { + return + } + activationOrder.remove(owner) + activationOrder.add(owner) + } + + @Synchronized + fun deactivate(owner: Any) { + activationOrder.remove(owner) + } + + @Synchronized + fun unregister(owner: Any) { + activationOrder.remove(owner) + handlers.remove(owner) + } + + private fun dispatch(action: String) { + val handler = synchronized(this) { + activationOrder.lastOrNull()?.let(handlers::get) + } + handler?.invoke(action) + } + } +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaPlaybackService.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaPlaybackService.kt new file mode 100644 index 00000000..22c8e0ed --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaPlaybackService.kt @@ -0,0 +1,93 @@ +package dev.aimesoft.erika_flutter + +import android.app.Notification +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.SystemClock + +class ErikaMediaPlaybackService : Service() { + private val handler = Handler(Looper.getMainLooper()) + private val tick = object : Runnable { + override fun run() { + dispatchTick(SystemClock.elapsedRealtimeNanos().toDouble() / 1_000_000_000.0) + handler.postDelayed(this, TICK_INTERVAL_MILLIS) + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> { + val notification = intent.notification() ?: return START_NOT_STICKY + startForeground(NOTIFICATION_ID, notification) + handler.removeCallbacks(tick) + handler.post(tick) + } + ACTION_STOP -> stopPlaybackService() + } + return START_NOT_STICKY + } + + override fun onDestroy() { + handler.removeCallbacks(tick) + super.onDestroy() + } + + private fun stopPlaybackService() { + handler.removeCallbacks(tick) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun Intent.notification(): Notification? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(EXTRA_NOTIFICATION, Notification::class.java) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(EXTRA_NOTIFICATION) + } + + companion object { + private const val ACTION_START = "dev.aimesoft.erika_flutter.action.START_MEDIA_PLAYBACK" + private const val ACTION_STOP = "dev.aimesoft.erika_flutter.action.STOP_MEDIA_PLAYBACK" + private const val EXTRA_NOTIFICATION = "notification" + private const val NOTIFICATION_ID = 0x4552494B + private const val TICK_INTERVAL_MILLIS = 16L + private val tickHandlers = LinkedHashMap Unit>() + + @Synchronized + fun registerTickHandler(owner: Any, handler: (Double) -> Unit) { + tickHandlers[owner] = handler + } + + @Synchronized + fun unregisterTickHandler(owner: Any) { + tickHandlers.remove(owner) + } + + private fun dispatchTick(timeSeconds: Double) { + val handlers = synchronized(this) { tickHandlers.values.toList() } + handlers.forEach { it(timeSeconds) } + } + + fun start(context: Context, notification: Notification) { + val intent = Intent(context, ErikaMediaPlaybackService::class.java) + .setAction(ACTION_START) + .putExtra(EXTRA_NOTIFICATION, notification) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + context.stopService(Intent(context, ErikaMediaPlaybackService::class.java)) + } + } +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaSession.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaSession.kt new file mode 100644 index 00000000..4b220933 --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaMediaSession.kt @@ -0,0 +1,297 @@ +package dev.aimesoft.erika_flutter + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadata +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.os.Handler +import android.os.Looper + +internal interface ErikaMediaCommandHandler { + fun play(playerId: Long) + fun pause(playerId: Long) + fun stop(playerId: Long) + fun seek(playerId: Long, positionMicros: Long) + fun previous(playerId: Long) + fun next(playerId: Long) +} + +internal class ErikaMediaSession( + context: Context, + private val commands: ErikaMediaCommandHandler, +) { + private val applicationContext = context.applicationContext + private val notificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val session = MediaSession(applicationContext, SESSION_TAG) + private var activeState: AndroidMediaState? = null + private var publishedState: AndroidMediaState? = null + private var cachedArtworkBytes: ByteArray? = null + private var cachedArtwork: Bitmap? = null + private var playbackServiceActive = false + + init { + notificationManager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + "Media playback", + NotificationManager.IMPORTANCE_LOW, + ), + ) + session.setCallback(object : MediaSession.Callback() { + override fun onPlay() = activeState?.let { commands.play(it.playerId) } ?: Unit + override fun onPause() = activeState?.let { commands.pause(it.playerId) } ?: Unit + override fun onStop() = activeState?.let { commands.stop(it.playerId) } ?: Unit + override fun onSeekTo(pos: Long) = + activeState?.let { commands.seek(it.playerId, pos.coerceAtLeast(0L) * 1_000L) } ?: Unit + override fun onSkipToPrevious() = + activeState?.takeIf(AndroidMediaState::previousEnabled) + ?.let { commands.previous(it.playerId) } ?: Unit + override fun onSkipToNext() = + activeState?.takeIf(AndroidMediaState::nextEnabled) + ?.let { commands.next(it.playerId) } ?: Unit + }, Handler(Looper.getMainLooper())) + } + + fun update(state: AndroidMediaState) { + activeState = state + val previous = publishedState + val metadataChanged = previous == null || + !sameMetadata(previous.metadata, state.metadata) || + previous.durationMicros != state.durationMicros + if (metadataChanged) { + val metadata = state.metadata + val metadataBuilder = MediaMetadata.Builder() + .putString( + MediaMetadata.METADATA_KEY_TITLE, + metadata?.title + ?: applicationContext.applicationInfo.loadLabel(applicationContext.packageManager).toString(), + ) + .putLong(MediaMetadata.METADATA_KEY_DURATION, state.durationMicros / 1_000L) + metadata?.artist?.let { metadataBuilder.putString(MediaMetadata.METADATA_KEY_ARTIST, it) } + metadata?.album?.let { metadataBuilder.putString(MediaMetadata.METADATA_KEY_ALBUM, it) } + artworkBitmap(metadata?.artwork)?.let { + metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it) + } + session.setMetadata(metadataBuilder.build()) + } + + val playbackChanged = previous == null || + previous.playbackState != state.playbackState || + previous.positionMicros != state.positionMicros || + previous.playbackRate != state.playbackRate || + previous.previousEnabled != state.previousEnabled || + previous.nextEnabled != state.nextEnabled + if (playbackChanged) { + session.setPlaybackState( + PlaybackState.Builder() + .setActions(state.androidPlaybackActions()) + .setState( + state.playbackState.toAndroidPlaybackState(), + state.positionMicros / 1_000L, + if (state.playbackState == PLAYING_STATE) state.playbackRate else 0f, + ) + .build(), + ) + } + + val wasActive = previous?.playbackState !in setOf(null, CLOSED_STATE, ERROR_STATE) + val isActive = state.playbackState !in setOf(CLOSED_STATE, ERROR_STATE) + if (wasActive != isActive) { + session.isActive = isActive + } + val notificationChanged = previous == null || metadataChanged || + previous.playbackState != state.playbackState || + previous.allowBackgroundPlayback != state.allowBackgroundPlayback + if (isActive && notificationChanged) { + val notification = notification(state) + if (state.shouldUsePlaybackService()) { + if (playbackServiceActive) { + notificationManager.notify(NOTIFICATION_ID, notification) + } else { + ErikaMediaPlaybackService.start(applicationContext, notification) + playbackServiceActive = true + } + } else { + stopPlaybackService() + notificationManager.notify(NOTIFICATION_ID, notification) + } + } else if (!isActive && wasActive) { + stopPlaybackService() + notificationManager.cancel(NOTIFICATION_ID) + } + publishedState = state + } + + fun dispatch(action: String) { + val state = activeState ?: return + when (action) { + ACTION_PLAY -> commands.play(state.playerId) + ACTION_PAUSE -> commands.pause(state.playerId) + ACTION_STOP -> commands.stop(state.playerId) + } + } + + fun clear(playerId: Long) { + if (activeState?.playerId != playerId) { + return + } + activeState = null + publishedState = null + cachedArtworkBytes = null + cachedArtwork = null + stopPlaybackService() + notificationManager.cancel(NOTIFICATION_ID) + session.setMetadata(null) + session.setPlaybackState( + PlaybackState.Builder().setState(PlaybackState.STATE_NONE, 0L, 0f).build(), + ) + session.isActive = false + } + + fun release() { + activeState = null + publishedState = null + cachedArtworkBytes = null + cachedArtwork = null + stopPlaybackService() + notificationManager.cancel(NOTIFICATION_ID) + session.isActive = false + session.release() + } + + private fun stopPlaybackService() { + if (!playbackServiceActive) { + return + } + ErikaMediaPlaybackService.stop(applicationContext) + playbackServiceActive = false + } + + private fun notification(state: AndroidMediaState): Notification { + val playing = state.playbackState == PLAYING_STATE + val builder = Notification.Builder(applicationContext, CHANNEL_ID) + .setSmallIcon( + applicationContext.applicationInfo.icon.takeIf { it != 0 } + ?: android.R.drawable.ic_media_play, + ) + .setContentTitle(state.metadata?.title ?: applicationContext.applicationInfo.loadLabel(applicationContext.packageManager)) + .setContentText(state.metadata?.artist ?: state.metadata?.album) + .setCategory(Notification.CATEGORY_TRANSPORT) + .setOnlyAlertOnce(true) + .setOngoing(playing) + .setShowWhen(false) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setStyle(Notification.MediaStyle().setMediaSession(session.sessionToken).setShowActionsInCompactView(0, 1)) + .addAction( + Notification.Action.Builder( + android.R.drawable.ic_delete, + "Stop", + commandIntent(ACTION_STOP), + ).build(), + ) + .addAction( + Notification.Action.Builder( + if (playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play, + if (playing) "Pause" else "Play", + commandIntent(if (playing) ACTION_PAUSE else ACTION_PLAY), + ).build(), + ) + artworkBitmap(state.metadata?.artwork)?.let(builder::setLargeIcon) + applicationContext.packageManager.getLaunchIntentForPackage(applicationContext.packageName)?.let { + builder.setContentIntent( + PendingIntent.getActivity( + applicationContext, + 0, + it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ), + ) + } + return builder.build() + } + + private fun artworkBitmap(bytes: ByteArray?): Bitmap? { + if (bytes == null) { + cachedArtworkBytes = null + cachedArtwork = null + return null + } + if (cachedArtworkBytes?.contentEquals(bytes) == true) { + return cachedArtwork + } + cachedArtworkBytes = bytes.copyOf() + cachedArtwork = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + return cachedArtwork + } + + private fun sameMetadata(left: AndroidMediaMetadata?, right: AndroidMediaMetadata?): Boolean { + if (left === right) { + return true + } + if (left == null || right == null) { + return false + } + return left.title == right.title && + left.artist == right.artist && + left.album == right.album && + when { + left.artwork === right.artwork -> true + left.artwork == null || right.artwork == null -> false + else -> left.artwork.contentEquals(right.artwork) + } + } + + private fun commandIntent(action: String): PendingIntent = PendingIntent.getBroadcast( + applicationContext, + action.hashCode(), + Intent(applicationContext, ErikaMediaCommandReceiver::class.java).setAction(action), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + companion object { + const val ACTION_PLAY = "dev.aimesoft.erika_flutter.action.PLAY" + const val ACTION_PAUSE = "dev.aimesoft.erika_flutter.action.PAUSE" + const val ACTION_STOP = "dev.aimesoft.erika_flutter.action.STOP" + const val PLAYING_STATE = 3 + const val CLOSED_STATE = 6 + const val ERROR_STATE = 7 + private const val SESSION_TAG = "ErikaMediaSession" + private const val CHANNEL_ID = "erika_media_playback" + private const val NOTIFICATION_ID = 0x4552494B + } +} + +internal fun AndroidMediaState.shouldUsePlaybackService(): Boolean = + allowBackgroundPlayback && playbackState == ErikaMediaSession.PLAYING_STATE + +internal fun AndroidMediaState.androidPlaybackActions(): Long { + var actions = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or + PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_STOP or + PlaybackState.ACTION_SEEK_TO + if (previousEnabled) { + actions = actions or PlaybackState.ACTION_SKIP_TO_PREVIOUS + } + if (nextEnabled) { + actions = actions or PlaybackState.ACTION_SKIP_TO_NEXT + } + return actions +} + +internal fun Int.toAndroidPlaybackState(): Int = when (this) { + 1 -> PlaybackState.STATE_CONNECTING + 2 -> PlaybackState.STATE_PAUSED + 3 -> PlaybackState.STATE_PLAYING + 4 -> PlaybackState.STATE_PAUSED + 5 -> PlaybackState.STATE_STOPPED + 6 -> PlaybackState.STATE_NONE + 7 -> PlaybackState.STATE_ERROR + else -> PlaybackState.STATE_NONE +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaNative.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaNative.kt new file mode 100644 index 00000000..ea0256f6 --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaNative.kt @@ -0,0 +1,76 @@ +package dev.aimesoft.erika_flutter + +import android.view.Surface + +internal object ErikaNative { + init { + System.loadLibrary("erika_capi") + } + + @JvmStatic + external fun nativeCreate( + outputMode: Int, + edrHeadroom: Float, + upscaler: Int, + videoAlphaMode: Int, + ): Long + + @JvmStatic + external fun nativeLastError(): String + + @JvmStatic + external fun nativeDestroy(handle: Long) + + @JvmStatic + external fun nativeInvoke( + handle: Long, + method: String, + argsJson: String, + ownedFd: Int, + ): String + + @JvmStatic + external fun nativeRegisterSubtitleMemoryFont(handle: Long, data: ByteArray): String + + @JvmStatic + external fun nativeAttachSurface( + handle: Long, + surface: Surface, + width: Int, + height: Int, + scale: Double, + extendedLinear: Boolean, + directComposition: Boolean, + desiredHeadroom: Float, + fallbackReason: Int, + ): String + + @JvmStatic + external fun nativeResizeSurface( + handle: Long, + width: Int, + height: Int, + scale: Double, + ): String + + @JvmStatic + external fun nativeDetachSurface(handle: Long): String + + @JvmStatic + external fun nativeRenderTick(handle: Long, timeSeconds: Double): String + + @JvmStatic + external fun nativeAudioOnlyTick(handle: Long): String + + @JvmStatic + external fun nativePollEvent(handle: Long): String? + + @JvmStatic + external fun nativePlaybackState(handle: Long): Int + + @JvmStatic + external fun nativePlaybackIntentState(handle: Long): Int + + @JvmStatic + external fun nativeCaptureFrame(handle: Long, width: Int, height: Int): ByteArray? +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaTextureView.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaTextureView.kt new file mode 100644 index 00000000..38001b5e --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaTextureView.kt @@ -0,0 +1,1682 @@ +package dev.aimesoft.erika_flutter + +import android.annotation.TargetApi +import android.content.Context +import android.graphics.SurfaceTexture +import android.graphics.PixelFormat +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.Display +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.TextureView +import android.view.View +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory +import java.util.function.Consumer +import kotlin.math.max + +internal class ErikaAndroidVideoViewFactory( + private val plugin: ErikaFlutterPlugin, + private val useHdrSurface: Boolean = false, +) : PlatformViewFactory(StandardMessageCodec.INSTANCE) { + override fun create(context: Context, viewId: Int, args: Any?): PlatformView { + @Suppress("UNCHECKED_CAST") + val creationParams = args as? Map ?: emptyMap() + return ErikaAndroidVideoView( + context, + viewId, + creationParams, + plugin, + useHdrSurface, + ) + } +} + +internal class ErikaAndroidVideoView( + context: Context, + val viewId: Int, + creationParams: Map, + private val plugin: ErikaFlutterPlugin, + private val useHdrSurface: Boolean, +) : PlatformView, + TextureView.SurfaceTextureListener, + SurfaceHolder.Callback2 { + private val textureView = if (useHdrSurface) null else TextureView(context) + private val surfaceView = if (useHdrSurface) SurfaceView(context) else null + private val nativeView: View = surfaceView ?: requireNotNull(textureView) + private val rawRequestedHdrHeadroom = + (creationParams["requestedHdrHeadroom"] as? Number)?.toFloat() + private val requestedHdrHeadroom = androidDesiredHdrHeadroom(rawRequestedHdrHeadroom) + private val hybridComposition = creationParams["composition"] == "hybrid" + private val videoAlphaMode = + (creationParams["videoAlphaMode"] as? Number)?.toInt() ?: 0 + private val mainHandler = Handler(Looper.getMainLooper()) + private var outputSurface: Surface? = null + private var ownsOutputSurface = false + private val deferredSurfaceReleases = mutableListOf() + private var outputSurfaceTexture: SurfaceTexture? = null + private val deferredSurfaceTextureReleases = mutableListOf() + private var surfacePixelWidth = 0 + private var surfacePixelHeight = 0 + private var boundHost: AndroidPlayerHost? = null + private val hostsAwaitingNativeDestroy = mutableSetOf() + private var pendingBind: PendingViewBind? = null + private val pendingBindCompletions = mutableListOf() + private val pendingUnbindCompletions = mutableListOf() + private var nativeAttachPending = false + private var nativeDetachPending = false + private var nativeResizePending = false + private var pendingResizeRequest: PendingSurfaceResize? = null + private var nativeDetachRetryPending = false + private var lifecycleSurfaceSuspended = false + private var lifecycleDetachPending = false + private var unbindRequested = false + private var disposeRequested = false + private var disposed = false + private val surfaceBindingGenerations = AndroidSurfaceBindingGenerationTracker() + private val surfaceRecoveryTokens = AndroidSurfaceRecoveryTokenSource() + private val surfaceRecoveryAttempts = AndroidSurfaceRecoveryAttemptTracker() + private var surfaceRecoveryRunnable: Runnable? = null + private var observedHdrDisplay: Display? = null + private var hdrRatioListenerRegistered = false + private var attachedDisplayId: Int? = null + private var attachedDisplayHdrSupported: Boolean? = null + private var lastPublishedHdrHeadroom: AndroidHdrHeadroomState? = null + private val hdrRatioListener = Consumer { + mainHandler.post { refreshHdrHeadroomObservation() } + } + private val attachStateListener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(view: View) { + mainHandler.post { + if (disposed || disposeRequested) { + return@post + } + val host = boundHost + if (host != null && !host.surfaceAttached) { + val attempt = attachIfReady(host) + handleImmediateAttempt(host, attempt) + } + refreshHdrHeadroomObservation() + } + } + + override fun onViewDetachedFromWindow(view: View) { + stopHdrHeadroomObservation(publishUnknown = true) + } + } + + internal val boundPlayerHost: AndroidPlayerHost? + get() = boundHost + + internal val isExtendedLinearSurface: Boolean + get() = useHdrSurface + + init { + if (rawRequestedHdrHeadroom != null && rawRequestedHdrHeadroom != requestedHdrHeadroom) { + Log.w( + TAG, + "invalid requestedHdrHeadroom=$rawRequestedHdrHeadroom for viewId=$viewId; " + + "using 0 (system auto), expected 0 or [1, 10000]", + ) + } + textureView?.apply { + isOpaque = videoAlphaMode == 0 + surfaceTextureListener = this@ErikaAndroidVideoView + } + surfaceView?.apply { + holder.setFormat(PixelFormat.RGBA_F16) + holder.addCallback(this@ErikaAndroidVideoView) + if (Build.VERSION.SDK_INT >= 35) { + runCatching { setDesiredHdrHeadroom(requestedHdrHeadroom) } + .onFailure { error -> + Log.w( + TAG, + "setDesiredHdrHeadroom failed viewId=$viewId requested=$requestedHdrHeadroom", + error, + ) + } + } + } + nativeView.addOnAttachStateChangeListener(attachStateListener) + nativeView.contentDescription = creationParams["debugLabel"] as? String + plugin.registerVideoView(this) + } + + override fun getView(): View = nativeView + + override fun dispose() { + if (disposed) { + return + } + disposeRequested = true + val host = boundHost + if (host == null) { + cancelSurfaceRecovery() + finishDispose() + return + } + unbind(host) + } + + private fun finishDispose() { + if (disposed) { + return + } + stopHdrHeadroomObservation(publishUnknown = false) + cancelSurfaceRecovery() + disposed = true + failPendingBind( + NativeResponse(false, -1, "Android video view $viewId was disposed", null), + ) + failAllViewCompletions("Android video view $viewId was disposed") + nativeDetachRetryPending = false + lifecycleDetachPending = false + lifecycleSurfaceSuspended = false + unbindRequested = false + releaseSurface() + releaseDeferredSurfacesIfIdle() + textureView?.surfaceTextureListener = null + surfaceView?.holder?.removeCallback(this) + nativeView.removeOnAttachStateChangeListener(attachStateListener) + plugin.unregisterVideoView(this) + } + + fun bind(host: AndroidPlayerHost): NativeResponse { + val renewingBinding = boundHost === host && + (unbindRequested || lifecycleSurfaceSuspended || lifecycleDetachPending) + if (renewingBinding) { + advanceSurfaceBindingGeneration(host) + } + lifecycleSurfaceSuspended = false + if (disposed || disposeRequested) { + return NativeResponse(false, -1, "Android video view $viewId is disposed", null) + } + if (boundHost !== host) { + boundHost?.let { currentHost -> + clearPendingBind() + val response = unbind(currentHost) + if (!response.ok) { + return response + } + if (boundHost === currentHost) { + queuePendingBind(host, this) + return NativeResponse.success() + } + } + host.attachedView?.takeIf { it !== this }?.let { previousView -> + previousView.clearPendingBind() + val response = previousView.unbind(host) + if (!response.ok) { + return response + } + if (host.attachedView === previousView) { + previousView.queuePendingBind(host, this) + return NativeResponse.success() + } + } + boundHost = host + host.attachedView = this + lastPublishedHdrHeadroom = null + advanceSurfaceBindingGeneration(host) + } + unbindRequested = false + cancelSurfaceRecovery() + val attempt = attachIfReady(host) + handleImmediateAttempt(host, attempt) + refreshHdrHeadroomObservation() + plugin.onPlayerRenderStateChanged() + return attempt.response + } + + fun bindAsync( + host: AndroidPlayerHost, + onComplete: (NativeResponse) -> Unit, + ) { + val response = bind(host) + when { + !response.ok -> onComplete(response) + boundHost === host && + !nativeAttachPending && + !nativeDetachPending && + !nativeDetachRetryPending && + !lifecycleDetachPending -> + onComplete(NativeResponse.success()) + else -> pendingBindCompletions += PendingViewCompletion( + host, + surfaceBindingGenerations.currentGeneration, + onComplete, + ) + } + } + + fun unbind(expectedHost: AndroidPlayerHost? = null): NativeResponse { + val host = boundHost + if (host == null) { + cancelSurfaceRecovery() + if (disposeRequested) { + finishDispose() + } + return NativeResponse.success() + } + if (expectedHost != null && host !== expectedHost) { + return NativeResponse.success() + } + if (!unbindRequested) { + advanceSurfaceBindingGeneration() + completeBindCompletions( + host, + NativeResponse(false, -1, "Android surface bind was superseded by unbind", null), + generation = null, + ) + } + unbindRequested = true + stopHdrHeadroomObservation(publishUnknown = true) + cancelSurfaceRecovery() + if (!androidUnbindNeedsNewSurfaceDetach(lifecycleDetachPending)) { + // The already queued lifecycle detach owns this unbind. Its real + // callback will complete the unbind or report the native failure. + return NativeResponse.success() + } + val response = detachNativeSurface(host) + reportImmediateSurfaceAttempt(host, SurfaceAttempt("detachSurface", response)) + if (!response.ok) { + startSurfaceRecovery(host, "detachSurface", response) + return response + } + completeUnbind(host) + return response + } + + fun unbindAsync( + expectedHost: AndroidPlayerHost, + onComplete: (NativeResponse) -> Unit, + ) { + val host = boundHost + if (host == null || host !== expectedHost) { + onComplete(NativeResponse.success()) + return + } + val response = unbind(host) + when { + !response.ok -> onComplete(response) + boundHost !== host -> + onComplete(NativeResponse.success()) + else -> pendingUnbindCompletions += PendingViewCompletion( + host, + surfaceBindingGenerations.currentGeneration, + onComplete, + ) + } + } + + fun suspendSurface(): NativeResponse { + val host = boundHost ?: return NativeResponse.success() + if (unbindRequested || disposeRequested) { + return unbind(host) + } + unbindRequested = false + stopHdrHeadroomObservation(publishUnknown = true) + cancelSurfaceRecovery() + val response = detachNativeSurface(host) + reportImmediateSurfaceAttempt(host, SurfaceAttempt("detachSurface", response)) + if (!response.ok) { + startSurfaceRecovery(host, "detachSurface", response) + } + plugin.onPlayerRenderStateChanged() + return response + } + + fun suspendSurfaceAsync() { + val host = boundHost ?: return + lifecycleSurfaceSuspended = true + if (unbindRequested || disposeRequested) { + unbind(host) + return + } + unbindRequested = false + // The detach queued below owns the lifecycle transition. Avoid a synchronous + // setOutputHeadroom JNI call on the UI thread while the presenter may still be + // finishing an in-flight frame. + stopHdrHeadroomObservation(publishUnknown = false) + cancelSurfaceRecovery() + if ( + androidShouldRetainSurfaceDuringActivityStop( + usesTextureView = textureView != null, + outputSurfaceValid = outputSurface?.isValid == true, + ) + ) { + // TextureView will call onSurfaceTextureDestroyed if the buffer + // queue is actually retired. Until then, retaining the native + // surface avoids a detach/reattach cycle against the same queue. + plugin.onPlayerRenderStateChanged() + return + } + if (lifecycleDetachPending) { + return + } + advanceSurfaceBindingGeneration(host) + lifecycleDetachPending = true + val posted = host.detachSurfaceAsync { result -> + mainHandler.post { + lifecycleDetachPending = false + if (boundHost !== host || host.isDestroyed) { + if (host.isDestroyed) { + completeUnbindCompletions( + host, + NativeResponse(false, -1, "Erika player ${host.handle} was destroyed", null), + ) + } + return@post + } + val response = result.getOrElse { error -> + surfaceOperationException(host, "detachSurface", error) + } + nativeDetachRetryPending = !response.ok && host.surfaceAttached + releaseDeferredSurfacesIfIdle() + if (response.ok) { + attachedDisplayId = null + attachedDisplayHdrSupported = null + } + plugin.reportSurfaceResponse(host, "detachSurface", response) + if (!response.ok) { + completeUnbindCompletions(host, response) + failPendingBind(response) + } + if (androidDetachCompletesSupersededUnbind( + nativeDetachSucceeded = response.ok, + unbindRequested = unbindRequested, + disposeRequested = disposeRequested, + ) + ) { + completeUnbindCompletions(host, NativeResponse.success()) + } + when { + !response.ok -> { + startSurfaceRecovery(host, "detachSurface", response) + } + unbindRequested || disposeRequested -> completeUnbind(host) + !lifecycleSurfaceSuspended && plugin.isActivityActive -> resumeSurface() + } + plugin.onPlayerRenderStateChanged() + } + } + if (!posted) { + lifecycleDetachPending = false + val response = NativeResponse( + false, + -1, + "Android presenter thread rejected lifecycle surface detach", + null, + ) + plugin.reportSurfaceResponse(host, "detachSurface", response) + startSurfaceRecovery(host, "detachSurface", response) + } + } + + fun resumeSurface(): NativeResponse { + val host = boundHost ?: return NativeResponse.success() + lifecycleSurfaceSuspended = false + if (lifecycleDetachPending) { + return NativeResponse.success() + } + if (unbindRequested || disposeRequested) { + return unbind(host) + } + unbindRequested = false + cancelSurfaceRecovery() + val attempt = attachIfReady(host) + handleImmediateAttempt(host, attempt) + refreshHdrHeadroomObservation() + plugin.onPlayerRenderStateChanged() + return attempt.response + } + + fun setFlutterManagedVisibility(visible: Boolean, debugLabel: String?) { + nativeView.visibility = if (visible) View.VISIBLE else View.INVISIBLE + if (debugLabel != null) { + nativeView.contentDescription = debugLabel + } + } + + fun setPlaybackKeepsScreenOn(enabled: Boolean) { + nativeView.keepScreenOn = enabled + } + + fun pixelWidth(): Int = surfacePixelWidth.takeIf { it > 0 } ?: nativeView.width + + fun pixelHeight(): Int = surfacePixelHeight.takeIf { it > 0 } ?: nativeView.height + + internal fun onPlayerDestroyed(host: AndroidPlayerHost) { + hostsAwaitingNativeDestroy -= host + val response = NativeResponse( + false, + -1, + "Erika player ${host.handle} was destroyed", + null, + ) + if (pendingBind?.host === host) { + failPendingBind(response) + } + if (boundHost !== host) { + completeBindCompletions(host, response, generation = null) + completeUnbindCompletions(host, response, generation = null) + releaseDeferredSurfacesIfIdle() + return + } + val deferredBind = takePendingBind() + cancelSurfaceRecovery() + nativeDetachRetryPending = false + lifecycleDetachPending = false + lifecycleSurfaceSuspended = false + unbindRequested = false + boundHost = null + completeBindCompletions(host, response, generation = null) + completeUnbindCompletions(host, response, generation = null) + if (disposeRequested) { + finishDispose() + } + releaseDeferredSurfacesIfIdle() + resumePendingBind(deferredBind) + plugin.onPlayerRenderStateChanged() + } + + internal fun onPlayerDestroyQueued(host: AndroidPlayerHost) { + hostsAwaitingNativeDestroy += host + } + + override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) { + surfaceTexture.setDefaultBufferSize(max(1, width), max(1, height)) + onNativeSurfaceAvailable( + Surface(surfaceTexture), + width, + height, + ownsSurface = true, + surfaceTexture = surfaceTexture, + ) + } + + override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) { + surfaceTexture.setDefaultBufferSize(max(1, width), max(1, height)) + onNativeSurfaceSizeChanged(width, height) + } + + override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean = + onNativeSurfaceDestroyed(surfaceTexture) + + override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit + + override fun surfaceCreated(holder: SurfaceHolder) { + onNativeSurfaceAvailable( + holder.surface, + nativeView.width, + nativeView.height, + ownsSurface = false, + surfaceTexture = null, + ) + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + if (outputSurface == null) { + onNativeSurfaceAvailable( + holder.surface, + width, + height, + ownsSurface = false, + surfaceTexture = null, + ) + } else { + onNativeSurfaceSizeChanged(width, height) + } + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + onNativeSurfaceDestroyed(null) + } + + override fun surfaceRedrawNeeded(holder: SurfaceHolder) { + boundHost?.requestRender() + plugin.onPlayerRenderStateChanged() + } + + private fun onNativeSurfaceAvailable( + surface: Surface, + width: Int, + height: Int, + ownsSurface: Boolean, + surfaceTexture: SurfaceTexture?, + ) { + advanceSurfaceBindingGeneration(boundHost) + cancelSurfaceRecovery() + surfacePixelWidth = max(1, width) + surfacePixelHeight = max(1, height) + var detachResponse = NativeResponse.success() + val host = boundHost + if (host != null && + (host.surfaceAttached || nativeAttachPending || nativeDetachRetryPending) + ) { + detachResponse = detachNativeSurface(host) + reportImmediateSurfaceAttempt( + host, + SurfaceAttempt("detachSurface", detachResponse), + ) + } + val previousSurfaceTexture = outputSurfaceTexture + releaseSurface() + if (previousSurfaceTexture != null && previousSurfaceTexture !== surfaceTexture) { + deferOrReleaseSurfaceTexture(previousSurfaceTexture) + } + outputSurface = surface + ownsOutputSurface = ownsSurface + outputSurfaceTexture = surfaceTexture + if (!detachResponse.ok) { + if (host != null) { + startSurfaceRecovery(host, "detachSurface", detachResponse) + } + plugin.onPlayerRenderStateChanged() + return + } + if (host != null && (unbindRequested || disposeRequested)) { + completeUnbind(host) + return + } + host?.let { currentHost -> + val attempt = attachIfReady(currentHost) + handleImmediateAttempt(currentHost, attempt) + } + refreshHdrHeadroomObservation() + plugin.onPlayerRenderStateChanged() + } + + private fun onNativeSurfaceSizeChanged(width: Int, height: Int) { + cancelSurfaceRecovery() + surfacePixelWidth = max(1, width) + surfacePixelHeight = max(1, height) + val host = boundHost ?: return + if (unbindRequested || disposeRequested) { + val response = detachNativeSurface(host) + reportImmediateSurfaceAttempt(host, SurfaceAttempt("detachSurface", response)) + if (response.ok) { + completeUnbind(host) + } else { + startSurfaceRecovery(host, "detachSurface", response) + } + return + } + val metrics = surfaceMetrics(width, height) + if (nativeDetachRetryPending) { + val attempt = attachIfReady(host) + handleImmediateAttempt(host, attempt) + } else if (host.surfaceAttached) { + handleImmediateAttempt(host, resizeNativeSurface(host, metrics)) + } else { + val attempt = attachIfReady(host) + handleImmediateAttempt(host, attempt) + } + refreshHdrHeadroomObservation() + plugin.onPlayerRenderStateChanged() + } + + private fun onNativeSurfaceDestroyed(surfaceTexture: SurfaceTexture?): Boolean { + advanceSurfaceBindingGeneration(boundHost) + stopHdrHeadroomObservation(publishUnknown = true) + cancelSurfaceRecovery() + val host = boundHost + val response = host?.let { host -> + val response = if (surfaceTexture != null) { + detachNativeSurface(host) + } else { + host.detachSurfaceForSystemDestroy() + } + if (surfaceTexture == null) { + plugin.reportSurfaceResponse(host, "detachSurface", response) + } else { + reportImmediateSurfaceAttempt( + host, + SurfaceAttempt("detachSurface", response), + ) + } + response + } ?: NativeResponse.success() + val decision = androidSurfaceDestroyDecision(response.ok) + // A timed-out SurfaceView barrier remains queued on the serial presenter. Keep the + // native attachment explicit as well: the serialized retry then observes the first + // detach's final state before any replacement attach or unbind can complete. + val retryNativeDetach = androidSurfaceDestroyNeedsRetry( + nativeDetachSucceeded = response.ok, + hostDestroying = host?.isDestroyed == true, + ) + nativeDetachRetryPending = retryNativeDetach + releaseSurface() + if (outputSurfaceTexture === surfaceTexture) { + outputSurfaceTexture = null + } + surfaceTexture?.let(::deferOrReleaseSurfaceTexture) + surfacePixelWidth = 0 + surfacePixelHeight = 0 + if (host != null) { + if (response.ok && (unbindRequested || disposeRequested)) { + completeUnbind(host) + } else if (retryNativeDetach) { + startSurfaceRecovery(host, "detachSurface", response) + } + } + plugin.onPlayerRenderStateChanged() + return decision.releaseSurfaceTexture + } + + private fun attachIfReady(host: AndroidPlayerHost): SurfaceAttempt { + if (nativeDetachRetryPending) { + val response = detachNativeSurface(host) + if (!response.ok) { + return SurfaceAttempt("detachSurface", response) + } + } + if (nativeDetachPending) { + return SurfaceAttempt("detachSurface", NativeResponse.success()) + } + if (!plugin.isActivityActive || host.surfaceAttached) { + return SurfaceAttempt("attachSurface", NativeResponse.success()) + } + val surface = outputSurface + ?: return SurfaceAttempt("attachSurface", NativeResponse.success()) + if (!surface.isValid) { + return SurfaceAttempt("attachSurface", NativeResponse.success()) + } + val display = nativeView.display + if (useHdrSurface && display == null) { + Log.i( + TAG, + "surfaceOutputCapability pending playerId=${host.handle} viewId=$viewId " + + "reason=display_not_attached_yet", + ) + return SurfaceAttempt("attachSurface", NativeResponse.success()) + } + val metrics = surfaceMetrics(pixelWidth(), pixelHeight()) + val displayHdrSupported = display?.let(::displaySupportsHdr) == true + val directComposition = useHdrSurface && hybridComposition + val outputCapability = androidOutputCapabilityDecision( + extendedLinearRequested = useHdrSurface, + sdkInt = Build.VERSION.SDK_INT, + displayHdrSupported = displayHdrSupported, + directComposition = directComposition, + ) + Log.i( + TAG, + "surfaceOutputCapability playerId=${host.handle} viewId=$viewId " + + "requestedExtendedLinear=$useHdrSurface " + + "eligible=${outputCapability.extendedLinearEligible} " + + "directComposition=$directComposition sdk=${Build.VERSION.SDK_INT} " + + "requestedHeadroom=$requestedHdrHeadroom " + + "fallbackReason=${androidOutputFallbackReasonLabel(outputCapability.fallbackReason)}" + + "(${outputCapability.fallbackReason})", + ) + if (nativeAttachPending) { + return SurfaceAttempt("attachSurface", NativeResponse.success()) + } + nativeAttachPending = true + val bindingGeneration = surfaceBindingGenerations.currentGeneration + val posted = host.attachSurfaceAsync( + surface, + metrics.width, + metrics.height, + metrics.scale, + outputCapability.extendedLinearEligible, + directComposition, + requestedHdrHeadroom, + outputCapability.fallbackReason, + ) { result -> + mainHandler.post { + nativeAttachPending = false + releaseDeferredSurfacesIfIdle() + if (host.isDestroyed) { + completeBindCompletions( + host, + NativeResponse(false, -1, "Erika player ${host.handle} was destroyed", null), + ) + return@post + } + val response = result.getOrElse { error -> + surfaceOperationException(host, "attachSurface", error) + } + val callbackIsCurrent = surfaceBindingGenerations.isCurrent(bindingGeneration) && + boundHost === host && outputSurface === surface + if (!callbackIsCurrent) { + handleStaleAttachCompletion(host, response) + return@post + } + if (response.ok) { + finishSurfaceRecovery(host, "attachSurface") + attachedDisplayId = display?.displayId + attachedDisplayHdrSupported = displayHdrSupported + val latestMetrics = surfaceMetrics(pixelWidth(), pixelHeight()) + if (boundHost === host && latestMetrics != metrics) { + handleImmediateAttempt(host, resizeNativeSurface(host, latestMetrics)) + } + } + plugin.reportSurfaceResponse(host, "attachSurface", response) + completeBindCompletions(host, response, bindingGeneration) + when { + !response.ok && boundHost === host -> + startSurfaceRecovery(host, "attachSurface", response) + boundHost === host && (unbindRequested || disposeRequested) -> + unbind(host) + boundHost === host && outputSurface !== surface -> { + val detach = detachNativeSurface(host) + if (!detach.ok) { + startSurfaceRecovery(host, "detachSurface", detach) + } + } + } + plugin.onPlayerRenderStateChanged() + } + } + if (!posted) { + nativeAttachPending = false + return SurfaceAttempt( + "attachSurface", + NativeResponse(false, -1, "Android presenter thread is unavailable", null), + ) + } + return SurfaceAttempt("attachSurface", NativeResponse.success()) + } + + /** + * A retired attach still changed native attachment state, but it no longer + * owns any Dart completion or error reporting. Reconcile that physical + * result into the newest binding instead of letting it pollute the request + * that replaced it. + */ + private fun handleStaleAttachCompletion( + host: AndroidPlayerHost, + response: NativeResponse, + ) { + Log.i( + TAG, + "surfaceAttachCompletionIgnored playerId=${host.handle} viewId=$viewId " + + "status=${response.status} error=${response.error.orEmpty()}", + ) + if (host.isDestroyed || boundHost !== host) { + return + } + if (response.ok && host.surfaceAttached) { + val detach = detachNativeSurface(host) + handleImmediateAttempt(host, SurfaceAttempt("detachSurface", detach)) + return + } + if (!unbindRequested && !disposeRequested && !lifecycleSurfaceSuspended) { + handleImmediateAttempt(host, attachIfReady(host)) + } + plugin.onPlayerRenderStateChanged() + } + + private fun displaySupportsHdr(display: Display): Boolean { + // Display.isHdr is derived from getHdrCapabilities(), so it respects + // user-disabled HDR output types. Display.Mode.supportedHdrTypes is + // only the raw hardware list and can incorrectly keep FP16 output + // eligible after the user disables every HDR type. + return runCatching { display.isHdr } + .onFailure { error -> + Log.w( + TAG, + "display HDR capability query failed viewId=$viewId " + + "displayId=${display.displayId}", + error, + ) + } + .getOrDefault(false) + } + + private fun refreshHdrHeadroomObservation() { + if ( + !useHdrSurface || + Build.VERSION.SDK_INT < 34 || + disposed || + unbindRequested || + disposeRequested || + !plugin.isActivityActive || + !nativeView.isAttachedToWindow + ) { + stopHdrHeadroomObservation(publishUnknown = true) + return + } + val host = boundHost ?: run { + stopHdrHeadroomObservation(publishUnknown = false) + return + } + val display = nativeView.display ?: run { + stopHdrHeadroomObservation(publishUnknown = true) + return + } + val displayHdrSupported = displaySupportsHdr(display) + val displayCapabilityChanged = host.surfaceAttached && + (attachedDisplayId != display.displayId || + attachedDisplayHdrSupported != displayHdrSupported) + if (displayCapabilityChanged) { + Log.i( + TAG, + "surfaceDisplayChanged playerId=${host.handle} viewId=$viewId " + + "oldDisplayId=$attachedDisplayId newDisplayId=${display.displayId} " + + "oldHdr=$attachedDisplayHdrSupported newHdr=$displayHdrSupported " + + "action=detach_and_reattach", + ) + stopHdrHeadroomObservation(publishUnknown = false) + val detachResponse = detachNativeSurface(host) + reportImmediateSurfaceAttempt( + host, + SurfaceAttempt("detachSurface", detachResponse), + ) + if (!detachResponse.ok) { + startSurfaceRecovery(host, "detachSurface", detachResponse) + return + } + val attachAttempt = attachIfReady(host) + handleImmediateAttempt(host, attachAttempt) + if (!attachAttempt.response.ok || !host.surfaceAttached) { + return + } + } + + if (observedHdrDisplay !== display) { + stopHdrHeadroomObservation(publishUnknown = false) + observedHdrDisplay = display + } + val ratioAvailable = runCatching { display.isHdrSdrRatioAvailable } + .onFailure { error -> + Log.w( + TAG, + "isHdrSdrRatioAvailable failed playerId=${host.handle} " + + "viewId=$viewId displayId=${display.displayId}", + error, + ) + } + .getOrDefault(false) + if (ratioAvailable && !hdrRatioListenerRegistered) { + runCatching { + display.registerHdrSdrRatioChangedListener( + nativeView.context.mainExecutor, + hdrRatioListener, + ) + }.onSuccess { + hdrRatioListenerRegistered = true + }.onFailure { error -> + Log.w( + TAG, + "registerHdrSdrRatioChangedListener failed playerId=${host.handle} " + + "viewId=$viewId displayId=${display.displayId}", + error, + ) + } + } else if (!ratioAvailable && hdrRatioListenerRegistered) { + stopHdrHeadroomObservation(publishUnknown = false) + observedHdrDisplay = display + } + publishHdrHeadroom(host, display, ratioAvailable) + } + + @TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + private fun publishHdrHeadroom( + host: AndroidPlayerHost, + display: Display, + ratioAvailable: Boolean, + ) { + val ratio = if (ratioAvailable) { + runCatching { display.hdrSdrRatio }.getOrElse { error -> + Log.w( + TAG, + "getHdrSdrRatio failed playerId=${host.handle} viewId=$viewId " + + "displayId=${display.displayId}", + error, + ) + Float.NaN + } + } else { + Float.NaN + } + val state = androidHdrHeadroomState(ratioAvailable, ratio) + if (lastPublishedHdrHeadroom == state) { + return + } + val posted = host.setOutputHeadroomAsync(state.headroom, state.known) { result -> + mainHandler.post { + if (boundHost !== host || host.isDestroyed) { + return@post + } + val response = result.getOrElse { error -> + surfaceOperationException(host, "setOutputHeadroom", error) + } + if (!response.ok) { + plugin.reportSurfaceResponse(host, "setOutputHeadroom", response) + } else { + lastPublishedHdrHeadroom = state + } + Log.i( + TAG, + "surfaceHeadroom playerId=${host.handle} viewId=$viewId " + + "displayId=${display.displayId} ratio=${state.headroom} " + + "known=${state.known} requested=$requestedHdrHeadroom " + + "status=${response.status} error=${response.error.orEmpty()}", + ) + } + } + if (!posted) { + plugin.reportSurfaceResponse( + host, + "setOutputHeadroom", + NativeResponse(false, -1, "Android presenter thread is unavailable", null), + ) + } + } + + private fun stopHdrHeadroomObservation(publishUnknown: Boolean) { + val display = observedHdrDisplay + if (Build.VERSION.SDK_INT >= 34 && hdrRatioListenerRegistered && display != null) { + runCatching { display.unregisterHdrSdrRatioChangedListener(hdrRatioListener) } + .onFailure { error -> + Log.w( + TAG, + "unregisterHdrSdrRatioChangedListener failed viewId=$viewId " + + "displayId=${display.displayId}", + error, + ) + } + } + hdrRatioListenerRegistered = false + observedHdrDisplay = null + if (publishUnknown && useHdrSurface) { + boundHost?.let { host -> + val unknown = AndroidHdrHeadroomState(1f, false) + if (lastPublishedHdrHeadroom == unknown) { + return@let + } + host.setOutputHeadroomAsync(unknown.headroom, unknown.known) { result -> + mainHandler.post { + if (boundHost !== host || host.isDestroyed) { + return@post + } + val response = result.getOrElse { error -> + surfaceOperationException(host, "setOutputHeadroom", error) + } + if (response.ok) { + lastPublishedHdrHeadroom = unknown + } else { + plugin.reportSurfaceResponse(host, "setOutputHeadroom", response) + } + } + } + } + } + } + + private fun detachNativeSurface(host: AndroidPlayerHost): NativeResponse { + if ((!host.surfaceAttached && !nativeAttachPending) || host.isDestroyed) { + return NativeResponse.success() + } + if (nativeDetachPending) { + return NativeResponse.success() + } + nativeDetachPending = true + val posted = host.detachSurfaceAsync { result -> + mainHandler.post { + nativeDetachPending = false + val response = result.getOrElse { error -> + surfaceOperationException(host, "detachSurface", error) + } + nativeDetachRetryPending = !response.ok && host.surfaceAttached + if (response.ok) { + finishSurfaceRecovery(host, "detachSurface") + attachedDisplayId = null + attachedDisplayHdrSupported = null + } + plugin.reportSurfaceResponse(host, "detachSurface", response) + releaseDeferredSurfacesIfIdle() + if (!response.ok) { + completeUnbindCompletions(host, response) + failPendingBind(response) + } + if (androidDetachCompletesSupersededUnbind( + nativeDetachSucceeded = response.ok, + unbindRequested = unbindRequested, + disposeRequested = disposeRequested, + ) + ) { + completeUnbindCompletions(host, NativeResponse.success()) + } + if (boundHost === host && !host.isDestroyed) { + when { + !response.ok -> { + startSurfaceRecovery(host, "detachSurface", response) + } + unbindRequested || disposeRequested -> completeUnbind(host) + !lifecycleSurfaceSuspended -> { + val attempt = attachIfReady(host) + handleImmediateAttempt(host, attempt) + if (attempt.response.ok && !nativeAttachPending) { + completeBindCompletions(host, NativeResponse.success()) + } + } + } + } + plugin.onPlayerRenderStateChanged() + } + } + if (!posted) { + nativeDetachPending = false + return NativeResponse( + false, + -1, + "Android presenter thread rejected surface detach", + null, + ) + } + return NativeResponse.success() + } + + private fun resizeNativeSurface( + host: AndroidPlayerHost, + metrics: AndroidSurfaceMetrics, + ): SurfaceAttempt { + if (host.isDestroyed) { + pendingResizeRequest = null + return SurfaceAttempt("resizeSurface", NativeResponse.success()) + } + pendingResizeRequest = PendingSurfaceResize( + host = host, + surface = outputSurface, + generation = surfaceBindingGenerations.currentGeneration, + metrics = metrics, + ) + if (nativeResizePending) { + return SurfaceAttempt("resizeSurface", NativeResponse.success()) + } + val next = pendingResizeRequest + ?: return SurfaceAttempt("resizeSurface", NativeResponse.success()) + pendingResizeRequest = null + nativeResizePending = true + val posted = host.resizeSurfaceAsync( + next.metrics.width, + next.metrics.height, + next.metrics.scale, + ) { result -> + mainHandler.post { + nativeResizePending = false + val response = if (host.isDestroyed) { + null + } else { + result.getOrElse { error -> + surfaceOperationException(host, "resizeSurface", error) + } + } + val callbackIsCurrent = androidSurfaceCallbackIsCurrent( + callbackGeneration = next.generation, + currentGeneration = surfaceBindingGenerations.currentGeneration, + hostStillBound = boundHost === next.host, + surfaceStillCurrent = outputSurface === next.surface, + ) + if (callbackIsCurrent && response != null) { + plugin.reportSurfaceResponse(host, "resizeSurface", response) + if (response.ok) { + finishSurfaceRecovery(host, "resizeSurface") + } else { + startSurfaceRecovery(host, "resizeSurface", response, next.metrics) + } + } + val pending = pendingResizeRequest + if (pending != null) { + val pendingIsCurrent = androidSurfaceCallbackIsCurrent( + callbackGeneration = pending.generation, + currentGeneration = surfaceBindingGenerations.currentGeneration, + hostStillBound = boundHost === pending.host, + surfaceStillCurrent = outputSurface === pending.surface, + ) + pendingResizeRequest = null + if (pendingIsCurrent) { + handleImmediateAttempt( + pending.host, + resizeNativeSurface(pending.host, pending.metrics), + ) + } + } + plugin.onPlayerRenderStateChanged() + } + } + if (!posted) { + nativeResizePending = false + return SurfaceAttempt( + "resizeSurface", + NativeResponse(false, -1, "Android presenter thread is unavailable", null), + ) + } + return SurfaceAttempt("resizeSurface", NativeResponse.success()) + } + + private fun surfaceOperationException( + host: AndroidPlayerHost, + operation: String, + error: Throwable, + ): NativeResponse { + val message = error.message ?: "$operation threw without an error message" + Log.e( + TAG, + "surfaceOperationException playerId=${host.handle} viewId=$viewId " + + "operation=$operation error=$message", + error, + ) + return NativeResponse(false, -1, "$operation threw: $message", null) + } + + private fun handleImmediateAttempt(host: AndroidPlayerHost, attempt: SurfaceAttempt) { + if (!reportImmediateSurfaceAttempt(host, attempt)) { + return + } + if (!attempt.response.ok) { + startSurfaceRecovery(host, attempt.operation, attempt.response) + } + } + + /** Returns false while the native operation is merely queued/in flight. */ + private fun reportImmediateSurfaceAttempt( + host: AndroidPlayerHost, + attempt: SurfaceAttempt, + ): Boolean { + if ( + androidSurfaceOperationIsPending( + operation = attempt.operation, + nativeAttachPending = nativeAttachPending, + nativeDetachPending = nativeDetachPending, + nativeResizePending = nativeResizePending, + ) + ) { + return false + } + plugin.reportSurfaceResponse(host, attempt.operation, attempt.response) + return true + } + + private fun startSurfaceRecovery( + host: AndroidPlayerHost, + operation: String, + response: NativeResponse, + resizeMetrics: AndroidSurfaceMetrics? = null, + ) { + if (disposed || boundHost !== host) { + return + } + val generation = surfaceRecoveryTokens.currentToken + val retryAttempt = surfaceRecoveryAttempts.recordFailure(generation, operation) + scheduleSurfaceRecovery( + host = host, + generation = generation, + failedOperation = operation, + failedResponse = response, + retryAttempt = retryAttempt, + resizeMetrics = resizeMetrics, + ) + } + + private fun scheduleSurfaceRecovery( + host: AndroidPlayerHost, + generation: Long, + failedOperation: String, + failedResponse: NativeResponse, + retryAttempt: Int, + resizeMetrics: AndroidSurfaceMetrics?, + ) { + if (disposed || boundHost !== host || !surfaceRecoveryTokens.isCurrent(generation)) { + return + } + val delayMillis = androidSurfaceRecoveryDelayMillis(retryAttempt) + if (delayMillis == null) { + surfaceRecoveryRunnable = null + reportSurfaceRecoveryExhaustedOnce( + host, + generation, + failedOperation, + retryAttempt - 1, + failedResponse, + ) + return + } + + Log.w( + TAG, + "surfaceRecoveryScheduled playerId=${host.handle} viewId=$viewId " + + "operation=$failedOperation generation=$generation " + + "retryAttempt=$retryAttempt delayMs=$delayMillis " + + "status=${failedResponse.status} error=${failedResponse.error.orEmpty()}", + ) + surfaceRecoveryRunnable?.let(mainHandler::removeCallbacks) + val runnable = Runnable { + if (disposed || boundHost !== host || !surfaceRecoveryTokens.isCurrent(generation)) { + return@Runnable + } + surfaceRecoveryRunnable = null + when (val result = performSurfaceRecovery(host, failedOperation, resizeMetrics)) { + SurfaceRecoveryResult.Complete -> finishSurfaceRecovery(host, failedOperation) + SurfaceRecoveryResult.Pending -> Log.d( + TAG, + "surfaceRecoveryDispatched playerId=${host.handle} viewId=$viewId " + + "operation=$failedOperation generation=$generation " + + "retryAttempt=$retryAttempt", + ) + is SurfaceRecoveryResult.Failed -> startSurfaceRecovery( + host = host, + operation = result.attempt.operation, + response = result.attempt.response, + resizeMetrics = resizeMetrics.takeIf { + result.attempt.operation == "resizeSurface" + }, + ) + } + plugin.onPlayerRenderStateChanged() + } + surfaceRecoveryRunnable = runnable + if (!mainHandler.postDelayed(runnable, delayMillis)) { + surfaceRecoveryRunnable = null + reportSurfaceRecoveryExhaustedOnce( + host, + generation, + failedOperation, + retryAttempt - 1, + failedResponse, + ) + } + } + + private fun reportSurfaceRecoveryExhaustedOnce( + host: AndroidPlayerHost, + generation: Long, + failedOperation: String, + retryAttempts: Int, + failedResponse: NativeResponse, + ) { + if (!surfaceRecoveryAttempts.markExhaustionReported(generation, failedOperation)) { + return + } + plugin.reportSurfaceRecoveryExhausted( + host = host, + viewId = viewId, + operation = failedOperation, + generation = generation, + retryAttempts = retryAttempts, + response = failedResponse, + ) + retireHostAfterSurfaceRecoveryExhaustionIfNeeded(host, failedOperation) + plugin.onPlayerRenderStateChanged() + } + + private fun retireHostAfterSurfaceRecoveryExhaustionIfNeeded( + host: AndroidPlayerHost, + failedOperation: String, + ) { + if ( + androidSurfaceRecoveryExhaustionRequiresHostRetirement( + failedOperation = failedOperation, + nativeDetachRetryPending = nativeDetachRetryPending, + unbindRequested = unbindRequested, + disposeRequested = disposeRequested, + ) + ) { + plugin.retirePlayerAfterSurfaceRecoveryExhausted(host) + } + } + + private fun performSurfaceRecovery( + host: AndroidPlayerHost, + failedOperation: String, + resizeMetrics: AndroidSurfaceMetrics?, + ): SurfaceRecoveryResult { + if (nativeAttachPending || nativeDetachPending || nativeResizePending) { + return SurfaceRecoveryResult.Pending + } + if (nativeDetachRetryPending || unbindRequested || disposeRequested) { + val detachResponse = detachNativeSurface(host) + reportImmediateSurfaceAttempt( + host, + SurfaceAttempt("detachSurface", detachResponse), + ) + if (!detachResponse.ok) { + return SurfaceRecoveryResult.Failed( + SurfaceAttempt("detachSurface", detachResponse), + ) + } + if (nativeDetachPending) { + return SurfaceRecoveryResult.Pending + } + if (unbindRequested || disposeRequested) { + completeUnbind(host) + return SurfaceRecoveryResult.Complete + } + } + + if (failedOperation == "resizeSurface" && host.surfaceAttached) { + val attempt = resizeNativeSurface( + host, + resizeMetrics ?: surfaceMetrics(pixelWidth(), pixelHeight()), + ) + reportImmediateSurfaceAttempt(host, attempt) + if (!attempt.response.ok) { + return SurfaceRecoveryResult.Failed(attempt) + } + return if (nativeResizePending) { + SurfaceRecoveryResult.Pending + } else { + SurfaceRecoveryResult.Complete + } + } + + val attachAttempt = attachIfReady(host) + reportImmediateSurfaceAttempt(host, attachAttempt) + if (!attachAttempt.response.ok) { + return SurfaceRecoveryResult.Failed(attachAttempt) + } + return if (nativeAttachPending) { + SurfaceRecoveryResult.Pending + } else { + SurfaceRecoveryResult.Complete + } + } + + private fun finishSurfaceRecovery(host: AndroidPlayerHost, operation: String) { + val generation = surfaceRecoveryTokens.currentToken + if (!surfaceRecoveryAttempts.complete(generation, operation)) { + return + } + surfaceRecoveryRunnable?.let(mainHandler::removeCallbacks) + surfaceRecoveryRunnable = null + Log.i( + TAG, + "surfaceRecoverySucceeded playerId=${host.handle} viewId=$viewId " + + "operation=$operation generation=$generation", + ) + if ( + androidShouldRefreshHdrHeadroomAfterRecovery( + hostStillBound = boundHost === host, + surfaceAttached = host.surfaceAttached, + disposed = disposed, + disposeRequested = disposeRequested, + unbindRequested = unbindRequested, + ) + ) { + refreshHdrHeadroomObservation() + } + } + + private fun completeUnbind(host: AndroidPlayerHost) { + if (nativeAttachPending || nativeDetachPending) { + return + } + val deferredBind = takePendingBind() + stopHdrHeadroomObservation(publishUnknown = false) + cancelSurfaceRecovery() + nativeDetachRetryPending = false + if (host.attachedView === this) { + host.attachedView = null + } + if (boundHost === host) { + boundHost = null + } + lastPublishedHdrHeadroom = null + unbindRequested = false + lifecycleSurfaceSuspended = false + completeUnbindCompletions(host, NativeResponse.success()) + if (disposeRequested) { + finishDispose() + } + resumePendingBind(deferredBind) + plugin.onPlayerRenderStateChanged() + } + + private fun queuePendingBind(host: AndroidPlayerHost, targetView: ErikaAndroidVideoView) { + pendingBind = PendingViewBind(host, targetView) + Log.w( + TAG, + "surfaceBindDeferred playerId=${host.handle} sourceViewId=$viewId " + + "targetViewId=${targetView.viewId} reason=native_detach_recovery", + ) + } + + private fun clearPendingBind() { + failPendingBind( + NativeResponse(false, -1, "Android surface bind was superseded", null), + ) + } + + private fun failPendingBind(response: NativeResponse) { + val deferred = takePendingBind() ?: return + deferred.targetView.completeBindCompletions(deferred.host, response) + } + + private fun takePendingBind(): PendingViewBind? { + val deferredBind = pendingBind + pendingBind = null + return deferredBind + } + + private fun resumePendingBind(deferredBind: PendingViewBind?) { + val pending = deferredBind ?: return + val targetView = pending.targetView + val targetAcceptsHost = targetView.boundHost == null || + targetView.boundHost === pending.host + val hostAcceptsTarget = pending.host.attachedView == null || + pending.host.attachedView === targetView + if ( + !androidShouldResumePendingViewBind( + hostDestroyed = pending.host.isDestroyed, + targetDisposed = targetView.disposed, + targetDisposeRequested = targetView.disposeRequested, + targetAcceptsHost = targetAcceptsHost, + hostAcceptsTarget = hostAcceptsTarget, + ) + ) { + Log.i( + TAG, + "surfaceBindDeferredCancelled playerId=${pending.host.handle} " + + "sourceViewId=$viewId targetViewId=${targetView.viewId} " + + "hostDestroyed=${pending.host.isDestroyed} " + + "targetDisposed=${targetView.disposed} " + + "targetDisposeRequested=${targetView.disposeRequested} " + + "targetAcceptsHost=$targetAcceptsHost " + + "hostAcceptsTarget=$hostAcceptsTarget", + ) + targetView.completeBindCompletions( + pending.host, + NativeResponse(false, -1, "Deferred surface bind was cancelled", null), + ) + return + } + Log.i( + TAG, + "surfaceBindDeferredResume playerId=${pending.host.handle} " + + "sourceViewId=$viewId targetViewId=${targetView.viewId}", + ) + runCatching { targetView.bind(pending.host) } + .onSuccess { response -> + if (!response.ok) { + targetView.completeBindCompletions(pending.host, response) + Log.w( + TAG, + "surfaceBindDeferredStillPending playerId=${pending.host.handle} " + + "sourceViewId=$viewId targetViewId=${targetView.viewId} " + + "status=${response.status} error=${response.error.orEmpty()}", + ) + } else if (!targetView.nativeAttachPending) { + targetView.completeBindCompletions( + pending.host, + NativeResponse.success(), + ) + } + } + .onFailure { error -> + targetView.completeBindCompletions( + pending.host, + NativeResponse(false, -1, error.message ?: "Deferred surface bind threw", null), + ) + Log.e( + TAG, + "surfaceBindDeferredFailed playerId=${pending.host.handle} " + + "sourceViewId=$viewId targetViewId=${targetView.viewId}", + error, + ) + } + } + + private fun completeBindCompletions( + host: AndroidPlayerHost, + response: NativeResponse, + generation: Long? = surfaceBindingGenerations.currentGeneration, + ) { + completeViewCompletions(pendingBindCompletions, host, response, generation) + } + + private fun completeUnbindCompletions( + host: AndroidPlayerHost, + response: NativeResponse, + generation: Long? = null, + ) { + completeViewCompletions(pendingUnbindCompletions, host, response, generation) + } + + private fun completeViewCompletions( + completions: MutableList, + host: AndroidPlayerHost, + response: NativeResponse, + generation: Long?, + ) { + val callbacks = mutableListOf<(NativeResponse) -> Unit>() + val iterator = completions.iterator() + while (iterator.hasNext()) { + val completion = iterator.next() + if (completion.host === host && androidSurfaceCompletionMatchesGeneration( + completionGeneration = completion.generation, + callbackGeneration = generation, + ) + ) { + iterator.remove() + callbacks += completion.onComplete + } + } + callbacks.forEach { callback -> callback(response) } + } + + private fun advanceSurfaceBindingGeneration( + migratePendingBindForHost: AndroidPlayerHost? = null, + ): Long { + val generation = surfaceBindingGenerations.advance() + pendingResizeRequest = pendingResizeRequest + ?.takeIf { request -> request.generation == generation } + if (migratePendingBindForHost != null) { + pendingBindCompletions + .filter { completion -> completion.host === migratePendingBindForHost } + .forEach { completion -> completion.generation = generation } + } + return generation + } + + private fun failAllViewCompletions(message: String) { + val response = NativeResponse(false, -1, message, null) + val callbacks = (pendingBindCompletions + pendingUnbindCompletions) + .map(PendingViewCompletion::onComplete) + pendingBindCompletions.clear() + pendingUnbindCompletions.clear() + callbacks.forEach { callback -> callback(response) } + } + + private fun cancelSurfaceRecovery() { + surfaceRecoveryTokens.invalidate() + surfaceRecoveryAttempts.reset() + surfaceRecoveryRunnable?.let(mainHandler::removeCallbacks) + surfaceRecoveryRunnable = null + } + + private fun surfaceMetrics(pixelWidth: Int, pixelHeight: Int): AndroidSurfaceMetrics { + // Surface callbacks already report exact physical pixels. Density is + // carried separately so native logical UI content (danmaku/subtitles) + // scales like Flutter without resizing the wgpu swapchain. + return resolveAndroidSurfaceMetrics( + pixelWidth = pixelWidth, + pixelHeight = pixelHeight, + density = nativeView.resources.displayMetrics.density.toDouble(), + ) + } + + private fun releaseSurface() { + if (ownsOutputSurface) { + outputSurface?.let { surface -> + if (surfaceReleaseMustWait()) { + deferredSurfaceReleases += surface + } else { + surface.release() + } + } + } + outputSurface = null + ownsOutputSurface = false + } + + private fun releaseDeferredSurfacesIfIdle() { + if (surfaceReleaseMustWait()) { + return + } + deferredSurfaceReleases.forEach(Surface::release) + deferredSurfaceReleases.clear() + deferredSurfaceTextureReleases.forEach(SurfaceTexture::release) + deferredSurfaceTextureReleases.clear() + } + + private fun deferOrReleaseSurfaceTexture(surfaceTexture: SurfaceTexture) { + if (surfaceReleaseMustWait()) { + if (deferredSurfaceTextureReleases.none { it === surfaceTexture }) { + deferredSurfaceTextureReleases += surfaceTexture + } + } else { + surfaceTexture.release() + } + } + + private fun surfaceReleaseMustWait(): Boolean = + nativeAttachPending || + nativeDetachPending || + lifecycleDetachPending || + nativeDetachRetryPending || + boundHost?.surfaceAttached == true || + boundHost?.isNativeDestroyPending == true || + hostsAwaitingNativeDestroy.isNotEmpty() + + private data class SurfaceAttempt( + val operation: String, + val response: NativeResponse, + ) + + private sealed interface SurfaceRecoveryResult { + data object Complete : SurfaceRecoveryResult + data object Pending : SurfaceRecoveryResult + data class Failed(val attempt: SurfaceAttempt) : SurfaceRecoveryResult + } + + private data class PendingViewBind( + val host: AndroidPlayerHost, + val targetView: ErikaAndroidVideoView, + ) + + private data class PendingViewCompletion( + val host: AndroidPlayerHost, + var generation: Long, + val onComplete: (NativeResponse) -> Unit, + ) + + private data class PendingSurfaceResize( + val host: AndroidPlayerHost, + val surface: Surface?, + val generation: Long, + val metrics: AndroidSurfaceMetrics, + ) + + private companion object { + const val TAG = "ErikaAndroidVideoView" + } +} diff --git a/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/NativeJson.kt b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/NativeJson.kt new file mode 100644 index 00000000..7faf3d9b --- /dev/null +++ b/third_party/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/NativeJson.kt @@ -0,0 +1,89 @@ +package dev.aimesoft.erika_flutter + +import org.json.JSONArray +import org.json.JSONObject + +internal data class NativeResponse( + val ok: Boolean, + val status: Int, + val error: String?, + val value: Any?, +) { + companion object { + fun success(value: Any? = null) = NativeResponse(true, 0, null, value) + } +} + +internal fun normalizedOptionalEventResponse(response: NativeResponse?): NativeResponse? = + response?.takeUnless { it.ok && it.value == null } + +internal object NativeJson { + fun encodeArguments(arguments: Map): String { + val json = JSONObject() + arguments.forEach { (key, value) -> json.put(key, toJsonValue(value)) } + return json.toString() + } + + fun decodeResponse(raw: String): NativeResponse { + val json = JSONObject(raw) + val ok = json.optBoolean("ok", false) + val status = when (val value = json.opt("status")) { + is Number -> value.toInt() + is String -> value.toIntOrNull() ?: if (ok) 0 else -1 + else -> if (ok) 0 else -1 + } + val error = if (json.has("error") && !json.isNull("error")) { + json.optString("error").takeIf { it.isNotBlank() } + } else { + null + } + val value = if (json.has("value")) fromJsonValue(json.opt("value")) else null + return NativeResponse(ok, status, error, value) + } + + /** + * Event polling uses a successful null payload to represent an empty native queue. + * Normalize both that representation and an actual JNI null to Kotlin null so callers + * do not spin while treating empty responses as real events. + */ + fun decodeOptionalEventResponse(raw: String?): NativeResponse? { + if (raw == null) { + return null + } + return normalizedOptionalEventResponse(decodeResponse(raw)) + } + + private fun toJsonValue(value: Any?): Any = when (value) { + null -> JSONObject.NULL + is Boolean, is Number, is String -> value + is Map<*, *> -> JSONObject().also { objectValue -> + value.forEach { (key, child) -> + if (key != null) { + objectValue.put(key.toString(), toJsonValue(child)) + } + } + } + is Iterable<*> -> JSONArray().also { array -> + value.forEach { child -> array.put(toJsonValue(child)) } + } + is Array<*> -> JSONArray().also { array -> + value.forEach { child -> array.put(toJsonValue(child)) } + } + else -> value.toString() + } + + private fun fromJsonValue(value: Any?): Any? = when (value) { + null, JSONObject.NULL -> null + is JSONObject -> linkedMapOf().also { result -> + val keys = value.keys() + while (keys.hasNext()) { + val key = keys.next() + result[key] = fromJsonValue(value.opt(key)) + } + } + is JSONArray -> List(value.length()) { index -> + fromJsonValue(value.opt(index)) + } + else -> value + } +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidActivityLifecycleTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidActivityLifecycleTest.kt new file mode 100644 index 00000000..a0c2868d --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidActivityLifecycleTest.kt @@ -0,0 +1,28 @@ +package dev.aimesoft.erika_flutter + +import androidx.lifecycle.Lifecycle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidActivityLifecycleTest { + @Test + fun `only started and resumed lifecycle states are active`() { + assertFalse(androidActivityIsActive(Lifecycle.State.DESTROYED)) + assertFalse(androidActivityIsActive(Lifecycle.State.INITIALIZED)) + assertFalse(androidActivityIsActive(Lifecycle.State.CREATED)) + assertTrue(androidActivityIsActive(Lifecycle.State.STARTED)) + assertTrue(androidActivityIsActive(Lifecycle.State.RESUMED)) + } + + @Test + fun `start stop and destroy events drive activity state`() { + assertEquals(true, androidActivityActiveForEvent(Lifecycle.Event.ON_START)) + assertEquals(false, androidActivityActiveForEvent(Lifecycle.Event.ON_STOP)) + assertEquals(false, androidActivityActiveForEvent(Lifecycle.Event.ON_DESTROY)) + assertNull(androidActivityActiveForEvent(Lifecycle.Event.ON_RESUME)) + assertNull(androidActivityActiveForEvent(Lifecycle.Event.ON_PAUSE)) + } +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidContentSourceTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidContentSourceTest.kt new file mode 100644 index 00000000..55626ba3 --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidContentSourceTest.kt @@ -0,0 +1,250 @@ +package dev.aimesoft.erika_flutter + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.Closeable +import java.io.EOFException +import java.io.File +import java.io.IOException +import java.nio.file.Files +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class AndroidContentSourceTest { + @Test + fun `regular descriptor with trusted stat size keeps the owned fd zero copy path`() { + assertEquals( + AndroidContentTransport.OWNED_DESCRIPTOR, + androidContentTransport(AndroidContentDescriptorKind.REGULAR_FILE, statSize = 42L), + ) + } + + @Test + fun `non regular or unstatable descriptors always spool`() { + assertEquals( + AndroidContentTransport.CACHE_SPOOL, + androidContentTransport(AndroidContentDescriptorKind.FIFO, statSize = 0L), + ) + assertEquals( + AndroidContentTransport.CACHE_SPOOL, + androidContentTransport(AndroidContentDescriptorKind.SOCKET, statSize = 4096L), + ) + assertEquals( + AndroidContentTransport.CACHE_SPOOL, + androidContentTransport(AndroidContentDescriptorKind.REGULAR_FILE, statSize = null), + ) + } + + @Test + fun `descriptor fallback reasons are structured and specific`() { + assertEquals( + "fifo_descriptor", + androidContentFallbackReason(AndroidContentDescriptorKind.FIFO), + ) + assertEquals( + "descriptor_stat_unavailable", + androidContentFallbackReason(AndroidContentDescriptorKind.UNKNOWN), + ) + } + + @Test + fun `spooler streams every byte and validates declared length`() { + val source = ByteArray(900_000) { index -> (index * 31).toByte() } + val destination = ByteArrayOutputStream() + + val copied = AndroidContentSpooler.copy( + ByteArrayInputStream(source), + destination, + expectedLength = source.size.toLong(), + ) + + assertEquals(source.size.toLong(), copied) + assertArrayEquals(source, destination.toByteArray()) + } + + @Test(expected = EOFException::class) + fun `spooler rejects an empty provider stream`() { + AndroidContentSpooler.copy( + ByteArrayInputStream(byteArrayOf()), + ByteArrayOutputStream(), + expectedLength = null, + ) + } + + @Test(expected = EOFException::class) + fun `spooler rejects a provider stream shorter than declared`() { + AndroidContentSpooler.copy( + ByteArrayInputStream(byteArrayOf(1, 2, 3)), + ByteArrayOutputStream(), + expectedLength = 4L, + ) + } + + @Test(expected = IOException::class) + fun `spooler rejects a provider stream longer than declared`() { + AndroidContentSpooler.copy( + ByteArrayInputStream(byteArrayOf(1, 2, 3)), + ByteArrayOutputStream(), + expectedLength = 2L, + ) + } + + @Test + fun `spooler enforces the configured maximum before accepting an oversized source`() { + val error = try { + AndroidContentSpooler.copy( + ByteArrayInputStream(byteArrayOf(1, 2, 3, 4)), + ByteArrayOutputStream(), + expectedLength = null, + policy = AndroidContentSpoolPolicy(maxBytes = 3L, minFreeBytes = 0L), + ) + fail("expected max-byte rejection") + null + } catch (error: AndroidContentSpoolException) { + error + } + + assertEquals("max_bytes_exceeded", error?.reasonCode) + } + + @Test + fun `spooler preserves the configured free disk budget`() { + val error = try { + AndroidContentSpooler.copy( + ByteArrayInputStream(byteArrayOf(1, 2, 3, 4)), + ByteArrayOutputStream(), + expectedLength = 4L, + policy = AndroidContentSpoolPolicy(maxBytes = 10L, minFreeBytes = 2L), + availableBytes = { 5L }, + ) + fail("expected disk-budget rejection") + null + } catch (error: AndroidContentSpoolException) { + error + } + + assertEquals("insufficient_disk_budget", error?.reasonCode) + } + + @Test + fun `spool cancellation is observed between bounded copy chunks`() { + val source = ByteArray(600_000) { 7 } + var cancel = false + val error = try { + AndroidContentSpooler.copy( + ByteArrayInputStream(source), + ByteArrayOutputStream(), + expectedLength = null, + policy = AndroidContentSpoolPolicy( + maxBytes = source.size.toLong(), + minFreeBytes = 0L, + ), + cancelled = { cancel }, + onProgress = { cancel = true }, + ) + fail("expected cancellation") + null + } catch (error: AndroidContentPreparationCancelledException) { + error + } + + assertEquals("cancelled", androidContentSourceFailureReason(checkNotNull(error))) + } + + @Test + fun `cancelling a preparation closes provider resources and deletes its temp file`() { + val cancellation = AndroidContentPreparationCancellation() + var closed = false + val resource = Closeable { closed = true } + val temporaryFile = File.createTempFile("erika-content-test-", ".tmp") + cancellation.register(resource) + cancellation.trackTemporaryFile(temporaryFile) + + cancellation.cancel() + + assertTrue(closed) + assertFalse(temporaryFile.exists()) + assertTrue(cancellation.isCancelled) + } + + @Test + fun `invalidating a player preparation generation cancels and rejects stale completion`() { + val registry = AndroidContentPreparationRegistry() + val cancellationReasons = mutableListOf() + val first = registry.begin(cancellationReasons::add) + val second = registry.begin(cancellationReasons::add) + + assertEquals(2, registry.invalidate("superseded_by_open")) + + assertEquals(listOf("superseded_by_open", "superseded_by_open"), cancellationReasons) + assertEquals(0, registry.pendingCount) + assertFalse(registry.finish(first)) + assertFalse(registry.finish(second)) + } + + @Test + fun `startup scavenger accepts only Erika source temp file names`() { + assertTrue(isAndroidContentSpoolFileName("source-123.tmp")) + assertTrue(isAndroidContentSpoolFileName("source-provider-cache.tmp")) + assertFalse(isAndroidContentSpoolFileName("source-.tmp")) + assertFalse(isAndroidContentSpoolFileName("source-123.part")) + assertFalse(isAndroidContentSpoolFileName("other-source-123.tmp")) + assertFalse(isAndroidContentSpoolFileName("SOURCE-123.tmp")) + } + + @Test + fun `startup scavenger deletes only matching files and reports reclaimed bytes`() { + val directory = Files.createTempDirectory("erika-content-scavenge-").toFile() + try { + val deleted = File(directory, "source-delete.tmp").apply { + writeBytes(byteArrayOf(1, 2, 3)) + } + val failed = File(directory, "source-fail.tmp").apply { + writeBytes(byteArrayOf(4, 5, 6, 7)) + } + val unrelated = File(directory, "unrelated.tmp").apply { + writeBytes(byteArrayOf(8, 9)) + } + val matchingDirectory = File(directory, "source-directory.tmp").apply { + assertTrue(mkdir()) + } + + val stats = scavengeAndroidContentSpoolDirectory(directory) { file -> + if (file == failed) false else deleteContentSpoolFile(file) + } + + assertEquals(1, stats.files) + assertEquals(3L, stats.bytes) + assertEquals(1, stats.deleteFailures) + assertFalse(deleted.exists()) + assertTrue(failed.exists()) + assertTrue(unrelated.exists()) + assertTrue(matchingDirectory.isDirectory) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `structured content log escapes provider data`() { + val event = androidContentSourceEvent( + stage = "fallback", + authority = "provider\"name", + fields = linkedMapOf( + "reason" to "pipe\nsource", + "length" to null, + "retained" to false, + ), + ) + + assertTrue(event.startsWith("{\"event\":\"android_content_source\"")) + assertTrue(event.contains("\"authority\":\"provider\\\"name\"")) + assertTrue(event.contains("\"reason\":\"pipe\\nsource\"")) + assertTrue(event.contains("\"length\":null")) + assertTrue(event.contains("\"retained\":false")) + } +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidEventPollPolicyTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidEventPollPolicyTest.kt new file mode 100644 index 00000000..e26ed83e --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidEventPollPolicyTest.kt @@ -0,0 +1,467 @@ +package dev.aimesoft.erika_flutter + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AndroidEventPollPolicyTest { + @Test + fun activePlaybackAlwaysUsesTheLowLatencyInterval() { + for (idleRounds in 0..20) { + assertEquals(50L, androidEventPollDelayMillis(true, idleRounds)) + } + } + + @Test + fun pausedPlaybackBacksOffToFiveSeconds() { + assertEquals( + listOf(50L, 100L, 250L, 500L, 1_000L, 2_500L, 5_000L), + (0..ANDROID_MAX_EVENT_POLL_IDLE_ROUNDS).map { idleRounds -> + androidEventPollDelayMillis(false, idleRounds) + }, + ) + assertEquals(5_000L, androidEventPollDelayMillis(false, 100)) + } + + @Test + fun persistentPollingFailuresBackOffEvenForActivePlayback() { + assertEquals( + listOf(0L, 250L, 500L, 1_000L, 2_500L, 5_000L), + (0..ANDROID_MAX_EVENT_POLL_FAILURE_ROUNDS) + .map(::androidEventPollFailureDelayMillis), + ) + assertEquals(5_000L, androidEventPollFailureDelayMillis(100)) + } + + @Test + fun aFailingPlayerDoesNotBackOffAHealthyPlayer() { + val failed = AndroidEventPollBackoff() + val healthy = AndroidEventPollBackoff() + failed.record(failed = true, nowMillis = 1_000L) + + assertEquals(250L, failed.delayMillis(1_000L)) + assertEquals(0L, healthy.delayMillis(1_000L)) + assertEquals( + 50L, + androidNextEventPollDelayMillis( + idleDelayMillis = 50L, + hostRetryDelaysMillis = listOf( + failed.delayMillis(1_000L), + healthy.delayMillis(1_000L), + ), + ), + ) + assertEquals( + 250L, + androidNextEventPollDelayMillis( + idleDelayMillis = 50L, + hostRetryDelaysMillis = listOf(failed.delayMillis(1_000L)), + ), + ) + + failed.record(failed = false, nowMillis = 1_100L) + assertEquals(0, failed.failureRounds) + assertEquals(0L, failed.delayMillis(1_100L)) + } + + @Test + fun authoritativeStateEventsPreserveTheLastCompleteNativeSnapshot() { + val video = mapOf("width" to 3840, "height" to 2160, "fps" to 60.0) + val tracks = mapOf("video" to 1, "audio" to 2, "subtitle" to 3) + val selection = mapOf("video" to 0, "audio" to 1, "subtitle" to -1) + val event = androidAuthoritativeStateEvent( + lastCompleteEvent = mapOf( + "playerId" to 7L, + "kind" to 3, + "state" to 2, + "durationMicros" to 20_000_000L, + "positionMicros" to 8_000_000L, + "buffering" to true, + "video" to video, + "tracks" to tracks, + "trackList" to listOf(mapOf("id" to 0)), + "trackSelection" to selection, + "status" to 17, + ), + playerId = 7L, + stateChangedEventKind = 1, + state = 3, + durationMicros = 20_000_000L, + positionMicros = 9_000_000L, + ) + + assertEquals(1, event["kind"]) + assertEquals(3, event["state"]) + assertEquals(9_000_000L, event["positionMicros"]) + assertEquals(true, event["buffering"]) + assertEquals(video, event["video"]) + assertEquals(tracks, event["tracks"]) + assertEquals(selection, event["trackSelection"]) + assertEquals(0, event["status"]) + } + + @Test + fun sparseNativeEventsUpdateOnlyTheirAuthoritativeSnapshotFields() { + val video = mapOf("width" to 3840, "height" to 2160) + val tracks = mapOf("video" to 1, "audio" to 2, "subtitle" to 0) + val selection = mapOf("video" to 0, "audio" to 1, "subtitle" to -1) + var snapshot: Map? = null + snapshot = androidUpdatedNativeEventSnapshot( + snapshot, + mapOf( + "playerId" to 7L, + "kind" to ANDROID_VIDEO_PARAMS_CHANGED_EVENT_KIND, + "video" to video, + "tracks" to emptyMap(), + "positionMicros" to 0L, + ), + ) + snapshot = androidUpdatedNativeEventSnapshot( + snapshot, + mapOf( + "playerId" to 7L, + "kind" to ANDROID_TRACKS_CHANGED_EVENT_KIND, + "tracks" to tracks, + "trackList" to listOf(mapOf("id" to 0)), + "trackSelection" to selection, + "video" to emptyMap(), + ), + ) + snapshot = androidUpdatedNativeEventSnapshot( + snapshot, + mapOf( + "playerId" to 7L, + "kind" to ANDROID_SURFACE_ATTACHED_EVENT_KIND, + "state" to 0, + "durationMicros" to -1L, + "positionMicros" to 0L, + "buffering" to false, + "video" to emptyMap(), + "tracks" to emptyMap(), + ), + ) + + assertEquals(video, snapshot["video"]) + assertEquals(tracks, snapshot["tracks"]) + assertEquals(selection, snapshot["trackSelection"]) + assertEquals(false, snapshot.containsKey("state")) + assertEquals(false, snapshot.containsKey("durationMicros")) + assertEquals(false, snapshot.containsKey("positionMicros")) + assertEquals(false, snapshot.containsKey("buffering")) + } + + @Test + fun authoritativeStateNeverOvertakesATruncatedEventBatch() { + assertEquals(true, androidCanSynthesizeAuthoritativeState(eventQueueDrained = true)) + assertEquals(false, androidCanSynthesizeAuthoritativeState(eventQueueDrained = false)) + } + + @Test + fun queuedStateMatchingAnAlreadyPublishedSnapshotIsDeduplicated() { + assertEquals( + true, + androidStateChangedEventIsDuplicate( + currentPlaybackState = 3, + eventPlaybackState = 3, + ), + ) + assertEquals( + false, + androidStateChangedEventIsDuplicate( + currentPlaybackState = 2, + eventPlaybackState = 3, + ), + ) + assertEquals( + false, + androidStateChangedEventIsDuplicate( + currentPlaybackState = 3, + eventPlaybackState = null, + ), + ) + } + + @Test + fun pollFailureDeliveryIsDeduplicatedUntilASuccessfulPoll() { + val failures = AndroidEventPollFailureDeduplicator() + + assertEquals( + false, + failures.shouldReport("response:-1:disconnected", canDeliver = false), + ) + assertEquals(true, failures.shouldReport("response:-1:disconnected")) + assertEquals(false, failures.shouldReport("response:-1:disconnected")) + assertEquals(true, failures.shouldReport("response:-2:closed")) + assertEquals(false, failures.shouldReport(null)) + assertEquals(true, failures.shouldReport("response:-1:disconnected")) + } + + @Test + fun onlySurfaceLifecycleEventsCanCrossAContentBoundary() { + val generation = 7L + assertEquals( + null, + androidPendingEventContentGeneration( + ANDROID_SURFACE_ATTACHED_EVENT_KIND, + generation, + ), + ) + assertEquals( + null, + androidPendingEventContentGeneration( + ANDROID_SURFACE_DETACHED_EVENT_KIND, + generation, + ), + ) + assertEquals( + generation, + androidPendingEventContentGeneration(ANDROID_POSITION_CHANGED_EVENT_KIND, generation), + ) + assertEquals(generation, androidPendingEventContentGeneration(null, generation)) + } + + @Test + fun successfulSurfaceLifecycleOperationsPollImmediately() { + for (operation in listOf("attachSurface", "detachSurface", "resizeSurface")) { + assertEquals( + true, + androidSurfaceOperationNeedsImmediateEventPoll(operation, responseOk = true), + ) + } + assertEquals( + false, + androidSurfaceOperationNeedsImmediateEventPoll( + "setOutputHeadroom", + responseOk = true, + ), + ) + assertEquals( + false, + androidSurfaceOperationNeedsImmediateEventPoll( + "attachSurface", + responseOk = false, + ), + ) + } + + @Test + fun immediatePollRequestSurvivesAnInFlightPoll() { + val latch = AndroidImmediateEventPollLatch() + + latch.request(immediate = true) + assertEquals(false, latch.takeIfReady(pollInFlight = true)) + assertEquals(true, latch.pending) + + assertEquals(true, latch.takeIfReady(pollInFlight = false)) + assertEquals(false, latch.pending) + assertEquals(false, latch.takeIfReady(pollInFlight = false)) + } + + @Test + fun delayedRequestsDoNotArmTheImmediatePollLatch() { + val latch = AndroidImmediateEventPollLatch() + latch.request(immediate = false) + assertEquals(false, latch.pending) + assertEquals(false, latch.takeIfReady(pollInFlight = false)) + } + + @Test + fun onlyTheCurrentCommandGenerationMayApplyPlaybackState() { + assertEquals(true, androidEventBatchAcceptsPlaybackState(7L, 7L)) + assertEquals(false, androidEventBatchAcceptsPlaybackState(6L, 7L)) + assertEquals(false, androidEventBatchAcceptsPlaybackState(8L, 7L)) + } + + @Test + fun onlyTheExecutedCurrentContentMayDeliverMediaEvents() { + assertEquals(true, androidEventBatchAcceptsContent(2L, 2L)) + assertEquals(false, androidEventBatchAcceptsContent(1L, 2L)) + assertEquals(false, androidEventBatchAcceptsContent(3L, 2L)) + } + + @Test + fun stalePlaybackBatchesDropOnlyStateChangesAndKeepOtherEvents() { + assertEquals( + false, + androidEventShouldBeDelivered( + eventKind = 1, + stateChangedEventKind = 1, + acceptsContent = true, + acceptsPlaybackState = false, + pendingPlayTransition = false, + ), + ) + for (eventKind in listOf(3, 7, 9)) { + assertEquals( + true, + androidEventShouldBeDelivered( + eventKind = eventKind, + stateChangedEventKind = 1, + acceptsContent = true, + acceptsPlaybackState = false, + pendingPlayTransition = false, + ), + ) + } + } + + @Test + fun staleContentBatchesDropMediaEventsButKeepSurfaceLifecycle() { + for (eventKind in listOf(null, 1, 3, 9)) { + assertEquals( + false, + androidEventShouldBeDelivered( + eventKind = eventKind, + stateChangedEventKind = 1, + acceptsContent = false, + acceptsPlaybackState = true, + pendingPlayTransition = false, + ), + ) + } + for (eventKind in listOf( + ANDROID_SURFACE_ATTACHED_EVENT_KIND, + ANDROID_SURFACE_DETACHED_EVENT_KIND, + )) { + assertEquals( + true, + androidEventShouldBeDelivered( + eventKind = eventKind, + stateChangedEventKind = 1, + acceptsContent = false, + acceptsPlaybackState = false, + pendingPlayTransition = false, + ), + ) + } + } + + @Test + fun contentGenerationChangesAtRequestAndAcknowledgesExecutionMonotonically() { + val tracker = AndroidContentGenerationTracker() + val first = tracker.requestNewContent() + val second = tracker.requestNewContent() + + assertEquals(2L, tracker.currentGeneration) + assertEquals(0L, tracker.latestExecutedGeneration) + + tracker.markExecuted(second) + tracker.markExecuted(first) + assertEquals(2L, tracker.latestExecutedGeneration) + } + + @Test + fun callbackFailureRollsBackOnlyTheAcceptedPlayThatStillOwnsIntent() { + assertEquals( + true, + androidAsyncPlayCallbackNeedsRollback( + nativePlayAccepted = true, + isCurrentHost = true, + ownsCurrentIntent = true, + ), + ) + assertEquals( + false, + androidAsyncPlayCallbackNeedsRollback( + nativePlayAccepted = false, + isCurrentHost = true, + ownsCurrentIntent = true, + ), + ) + assertEquals( + false, + androidAsyncPlayCallbackNeedsRollback( + nativePlayAccepted = true, + isCurrentHost = false, + ownsCurrentIntent = true, + ), + ) + assertEquals( + false, + androidAsyncPlayCallbackNeedsRollback( + nativePlayAccepted = true, + isCurrentHost = true, + ownsCurrentIntent = false, + ), + ) + } + + @Test + fun failedCurrentContentOpenClosesButSupersededOrDestroyedOpenDoesNot() { + assertEquals(true, androidFailedContentOpenShouldClose("open", false, 3L, 3L)) + assertEquals(false, androidFailedContentOpenShouldClose("open", false, 2L, 3L)) + assertEquals(false, androidFailedContentOpenShouldClose("open", true, 3L, 3L)) + assertEquals(false, androidFailedContentOpenShouldClose("stop", false, 3L, 3L)) + assertEquals(false, androidFailedContentOpenShouldClose("open", false, null, 3L)) + } + + @Test + fun contentBoundaryMarkerDefersOnlyFailedOrUndecodedOpen() { + assertEquals(true, androidContentCommandEstablishedBoundary("open", true, true)) + assertEquals(false, androidContentCommandEstablishedBoundary("open", true, false)) + assertEquals(false, androidContentCommandEstablishedBoundary("open", false, false)) + assertEquals(true, androidContentCommandEstablishedBoundary("stop", true, false)) + assertEquals(true, androidContentCommandEstablishedBoundary("close", true, false)) + } + + @Test + fun queuedPlayKeepsTransientActualStateFromCancellingIntent() { + for (actualState in listOf(2, 4, 5)) { + assertEquals( + true, + androidPlaybackStateIsPendingPlayTransition( + playbackState = actualState, + playbackIntentState = 3, + playingState = 3, + ), + ) + } + assertEquals( + false, + androidPlaybackStateIsPendingPlayTransition( + playbackState = 3, + playbackIntentState = 3, + playingState = 3, + ), + ) + assertEquals( + false, + androidPlaybackStateIsPendingPlayTransition( + playbackState = 4, + playbackIntentState = 4, + playingState = 3, + ), + ) + } + + @Test + fun queuedPlayKeepsMediaSessionPlayingUntilNativeCommitOrFailure() { + assertEquals( + 3, + androidMediaSessionPlaybackState( + playbackState = 4, + playbackIntentState = 3, + playingState = 3, + acceptsPlaybackState = true, + ), + ) + assertEquals( + 4, + androidMediaSessionPlaybackState( + playbackState = 4, + playbackIntentState = 4, + playingState = 3, + acceptsPlaybackState = true, + ), + ) + assertEquals( + 4, + androidMediaSessionPlaybackState( + playbackState = 4, + playbackIntentState = 3, + playingState = 3, + acceptsPlaybackState = false, + ), + ) + } + +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidLatestTaskCoalescerTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidLatestTaskCoalescerTest.kt new file mode 100644 index 00000000..c803437b --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidLatestTaskCoalescerTest.kt @@ -0,0 +1,102 @@ +package dev.aimesoft.erika_flutter + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidLatestTaskCoalescerTest { + @Test + fun replacesQueuedWorkWithTheLatestTask() { + val coalescer = AndroidLatestTaskCoalescer() + + assertTrue(coalescer.submit(1)) + assertFalse(coalescer.submit(2)) + assertFalse(coalescer.submit(3)) + assertEquals(3, coalescer.takeLatest()) + assertNull(coalescer.takeLatest()) + assertFalse(coalescer.finishDrain()) + } + + @Test + fun schedulesAgainAfterDrainCompletion() { + val coalescer = AndroidLatestTaskCoalescer() + + assertTrue(coalescer.submit(1)) + assertEquals(1, coalescer.takeLatest()) + assertFalse(coalescer.finishDrain()) + assertTrue(coalescer.submit(2)) + assertEquals(2, coalescer.takeLatest()) + } + + @Test + fun workArrivingDuringDrainRequestsAQueuedFollowUp() { + val coalescer = AndroidLatestTaskCoalescer() + + assertTrue(coalescer.submit(1)) + assertEquals(1, coalescer.takeLatest()) + assertFalse(coalescer.submit(2)) + assertTrue(coalescer.finishDrain()) + assertEquals(2, coalescer.takeLatest()) + assertFalse(coalescer.finishDrain()) + } + + @Test + fun concurrentArrivalAndDrainCompletionNeverLoseTheWakeup() { + val executor = Executors.newFixedThreadPool(2) + try { + repeat(1_000) { + val coalescer = AndroidLatestTaskCoalescer() + assertTrue(coalescer.submit(1)) + assertEquals(1, coalescer.takeLatest()) + val start = CountDownLatch(1) + val submitScheduled = AtomicBoolean(false) + val drainContinues = AtomicBoolean(false) + val submit = executor.submit { + start.await() + submitScheduled.set(coalescer.submit(2)) + } + val finish = executor.submit { + start.await() + drainContinues.set(coalescer.finishDrain()) + } + + start.countDown() + submit.get() + finish.get() + + assertTrue(submitScheduled.get() xor drainContinues.get()) + assertEquals(2, coalescer.takeLatest()) + assertFalse(coalescer.finishDrain()) + } + } finally { + executor.shutdownNow() + } + } + + @Test + fun cancellationDropsOnlyPendingWork() { + val coalescer = AndroidLatestTaskCoalescer() + + assertTrue(coalescer.submit(1)) + coalescer.cancelPending() + assertNull(coalescer.takeLatest()) + assertFalse(coalescer.finishDrain()) + } + + @Test + fun abortedDrainCanBeScheduledAgain() { + val coalescer = AndroidLatestTaskCoalescer() + + assertTrue(coalescer.submit(1)) + coalescer.abortDrain() + + assertNull(coalescer.takeLatest()) + assertTrue(coalescer.submit(2)) + assertEquals(2, coalescer.takeLatest()) + } +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidMediaStateTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidMediaStateTest.kt new file mode 100644 index 00000000..d9b7047d --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidMediaStateTest.kt @@ -0,0 +1,166 @@ +package dev.aimesoft.erika_flutter + +import android.media.session.PlaybackState +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class AndroidMediaStateTest { + @Test + fun `metadata accepts supported system media fields`() { + val artwork = byteArrayOf(1, 2, 3) + + val metadata = androidMediaMetadata( + mapOf( + "metadata" to mapOf( + "title" to "Episode 1", + "artist" to "Erika", + "album" to "Season 1", + "artwork" to artwork, + ), + ), + ) + + assertEquals("Episode 1", metadata.title) + assertEquals("Erika", metadata.artist) + assertEquals("Season 1", metadata.album) + assertArrayEquals(artwork, metadata.artwork) + } + + @Test + fun `metadata requires a non blank title`() { + assertThrows(IllegalArgumentException::class.java) { + androidMediaMetadata(mapOf("metadata" to mapOf("title" to " "))) + } + } + + @Test + fun `native media events update only their authoritative fields`() { + var state = AndroidMediaState(playerId = 7L) + state = updatedAndroidMediaState( + state, + mapOf("kind" to 2, "durationMicros" to 9_000_000L), + ) + state = updatedAndroidMediaState( + state, + mapOf("kind" to 1, "state" to 3, "durationMicros" to -1L), + ) + state = updatedAndroidMediaState( + state, + mapOf("kind" to 3, "positionMicros" to 2_500_000L, "state" to 7), + ) + + assertEquals(3, state.playbackState) + assertEquals(9_000_000L, state.durationMicros) + assertEquals(2_500_000L, state.positionMicros) + } + + @Test + fun `closed state clears content metadata position and duration`() { + val state = updatedAndroidMediaState( + AndroidMediaState( + playerId = 7L, + metadata = AndroidMediaMetadata("Episode 1", "Erika", null, null), + playbackState = 3, + positionMicros = 2_500_000L, + durationMicros = 9_000_000L, + ), + mapOf("kind" to STATE_CHANGED_EVENT_KIND, "state" to ANDROID_CLOSED_PLAYBACK_STATE), + ) + + assertNull(state.metadata) + assertEquals(ANDROID_CLOSED_PLAYBACK_STATE, state.playbackState) + assertEquals(0L, state.positionMicros) + assertEquals(0L, state.durationMicros) + } + + @Test + fun `erika playback states map to Android media session states`() { + assertEquals(PlaybackState.STATE_PLAYING, 3.toAndroidPlaybackState()) + assertEquals(PlaybackState.STATE_PAUSED, 4.toAndroidPlaybackState()) + assertEquals(PlaybackState.STATE_STOPPED, 5.toAndroidPlaybackState()) + assertEquals(PlaybackState.STATE_NONE, 6.toAndroidPlaybackState()) + assertEquals(PlaybackState.STATE_ERROR, 7.toAndroidPlaybackState()) + } + + @Test + fun `foreground playback service requires playing background enabled media`() { + val state = AndroidMediaState( + playerId = 7L, + playbackState = ErikaMediaSession.PLAYING_STATE, + allowBackgroundPlayback = true, + ) + + assertEquals(true, state.shouldUsePlaybackService()) + assertEquals(false, state.copy(playbackState = 4).shouldUsePlaybackService()) + assertEquals(false, state.copy(allowBackgroundPlayback = false).shouldUsePlaybackService()) + } + + @Test + fun `playback outside an active activity requires background opt in`() { + val foregroundOnly = AndroidMediaState(playerId = 7L) + val backgroundAllowed = foregroundOnly.copy(allowBackgroundPlayback = true) + + assertEquals(true, foregroundOnly.canPlay(activityActive = true)) + assertEquals(false, foregroundOnly.canPlay(activityActive = false)) + assertEquals(true, backgroundAllowed.canPlay(activityActive = false)) + } + + @Test + fun `system media navigation updates capabilities independently`() { + val state = updatedSystemMediaNavigation( + AndroidMediaState(playerId = 7L, nextEnabled = true), + mapOf("previousEnabled" to true, "nextEnabled" to false), + ) + + assertEquals(true, state.previousEnabled) + assertEquals(false, state.nextEnabled) + } + + @Test + fun `system media navigation defaults missing capabilities to disabled`() { + val state = updatedSystemMediaNavigation( + AndroidMediaState(playerId = 7L, previousEnabled = true, nextEnabled = true), + emptyMap(), + ) + + assertEquals(false, state.previousEnabled) + assertEquals(false, state.nextEnabled) + } + + @Test + fun `Android playback actions reflect navigation capabilities`() { + val base = AndroidMediaState(playerId = 7L) + + assertEquals(0L, base.androidPlaybackActions() and PlaybackState.ACTION_SKIP_TO_PREVIOUS) + assertEquals(0L, base.androidPlaybackActions() and PlaybackState.ACTION_SKIP_TO_NEXT) + assertEquals( + PlaybackState.ACTION_SKIP_TO_PREVIOUS, + base.copy(previousEnabled = true).androidPlaybackActions() and + PlaybackState.ACTION_SKIP_TO_PREVIOUS, + ) + assertEquals( + PlaybackState.ACTION_SKIP_TO_NEXT, + base.copy(nextEnabled = true).androidPlaybackActions() and + PlaybackState.ACTION_SKIP_TO_NEXT, + ) + } + + @Test + fun `enabled system media navigation creates kind 13 event`() { + val state = AndroidMediaState(playerId = 7L, previousEnabled = true) + + assertEquals( + mapOf( + "playerId" to 7L, + "kind" to SYSTEM_MEDIA_NAVIGATION_EVENT_KIND, + "navigation" to SYSTEM_MEDIA_NAVIGATION_PREVIOUS, + ), + systemMediaNavigationEvent(state, SYSTEM_MEDIA_NAVIGATION_PREVIOUS), + ) + assertNull(systemMediaNavigationEvent(state, SYSTEM_MEDIA_NAVIGATION_NEXT)) + assertNull(systemMediaNavigationEvent(state, "unknown")) + } +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidNativeEventStateTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidNativeEventStateTest.kt new file mode 100644 index 00000000..e5dea026 --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidNativeEventStateTest.kt @@ -0,0 +1,36 @@ +package dev.aimesoft.erika_flutter + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AndroidNativeEventStateTest { + @Test + fun `position event cannot overwrite end of stream state`() { + var latestState: Int? = null + + latestState = updatedPlaybackState( + latestState, + mapOf("kind" to STATE_CHANGED_EVENT_KIND, "state" to 5), + ) + latestState = updatedPlaybackState( + latestState, + mapOf("kind" to 3, "state" to 0), + ) + + assertEquals(5, latestState) + } + + @Test + fun `only state changed events establish playback state`() { + assertNull(updatedPlaybackState(null, mapOf("kind" to 3, "state" to 3))) + assertNull(updatedPlaybackState(null, mapOf("kind" to 9, "state" to 7))) + assertEquals( + 3, + updatedPlaybackState( + null, + mapOf("kind" to STATE_CHANGED_EVENT_KIND, "state" to 3), + ), + ) + } +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidNativeInvokeOwnershipTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidNativeInvokeOwnershipTest.kt new file mode 100644 index 00000000..38649a6e --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidNativeInvokeOwnershipTest.kt @@ -0,0 +1,19 @@ +package dev.aimesoft.erika_flutter + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidNativeInvokeOwnershipTest { + @Test + fun `destroy race and missing symbol leave detached fd with Kotlin`() { + assertTrue(androidNativeInvokeDidNotStart(AndroidPlayerDestroyedException(1L))) + assertTrue(androidNativeInvokeDidNotStart(UnsatisfiedLinkError("missing"))) + } + + @Test + fun `post-dispatch decode errors leave detached fd with Rust`() { + assertFalse(androidNativeInvokeDidNotStart(IllegalArgumentException("bad JSON"))) + assertFalse(androidNativeInvokeDidNotStart(IllegalStateException("native response"))) + } +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidOutputCapabilitiesTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidOutputCapabilitiesTest.kt new file mode 100644 index 00000000..41dae8bf --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidOutputCapabilitiesTest.kt @@ -0,0 +1,134 @@ +package dev.aimesoft.erika_flutter + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidOutputCapabilitiesTest { + @Test + fun sdrDoesNotReportAnOutputFallback() { + val decision = androidOutputCapabilityDecision( + extendedLinearRequested = false, + sdkInt = 26, + displayHdrSupported = false, + directComposition = false, + ) + + assertFalse(decision.extendedLinearEligible) + assertEquals(OUTPUT_FALLBACK_NONE, decision.fallbackReason) + } + + @Test + fun apiBelow28ReportsMissingNativeWindowDataspaceApi() { + val decision = androidOutputCapabilityDecision( + extendedLinearRequested = true, + sdkInt = 27, + displayHdrSupported = true, + directComposition = true, + ) + + assertFalse(decision.extendedLinearEligible) + assertEquals( + OUTPUT_FALLBACK_NATIVE_WINDOW_DATASPACE_API_UNAVAILABLE, + decision.fallbackReason, + ) + } + + @Test + fun nonHdrDisplayReportsDisplayCapabilityFailure() { + val decision = androidOutputCapabilityDecision( + extendedLinearRequested = true, + sdkInt = 35, + displayHdrSupported = false, + directComposition = true, + ) + + assertFalse(decision.extendedLinearEligible) + assertEquals(OUTPUT_FALLBACK_DISPLAY_HDR_UNSUPPORTED, decision.fallbackReason) + } + + @Test + fun textureCompositionReportsHybridCompositionRequirement() { + val decision = androidOutputCapabilityDecision( + extendedLinearRequested = true, + sdkInt = 35, + displayHdrSupported = true, + directComposition = false, + ) + + assertTrue(decision.extendedLinearEligible) + assertEquals( + OUTPUT_FALLBACK_HYBRID_COMPOSITION_REQUIRED, + decision.fallbackReason, + ) + } + + @Test + fun hdrSurfaceViewOnSupportedDisplayIsEligible() { + val decision = androidOutputCapabilityDecision( + extendedLinearRequested = true, + sdkInt = 35, + displayHdrSupported = true, + directComposition = true, + ) + + assertTrue(decision.extendedLinearEligible) + assertEquals(OUTPUT_FALLBACK_NONE, decision.fallbackReason) + } + + @Test + fun everyStableReasonHasTheAbiLabel() { + assertEquals("none", androidOutputFallbackReasonLabel(0)) + assertEquals("display_hdr_unsupported", androidOutputFallbackReasonLabel(1)) + assertEquals("hybrid_composition_required", androidOutputFallbackReasonLabel(2)) + assertEquals("wgpu_backend_not_vulkan", androidOutputFallbackReasonLabel(3)) + assertEquals( + "rgba16float_surface_format_unavailable", + androidOutputFallbackReasonLabel(4), + ) + assertEquals( + "native_window_dataspace_api_unavailable", + androidOutputFallbackReasonLabel(5), + ) + assertEquals( + "scrgb_dataspace_verification_failed", + androidOutputFallbackReasonLabel(6), + ) + assertEquals("surface_configure_failed", androidOutputFallbackReasonLabel(7)) + assertEquals("legacy_apple_edr_unsupported", androidOutputFallbackReasonLabel(8)) + assertEquals("unknown(99)", androidOutputFallbackReasonLabel(99)) + } + + @Test + fun desiredHdrHeadroomAcceptsAutoOrApi35RangeOnly() { + assertEquals(0f, androidDesiredHdrHeadroom(null)) + assertEquals(0f, androidDesiredHdrHeadroom(Float.NaN)) + assertEquals(0f, androidDesiredHdrHeadroom(0f)) + assertEquals(0f, androidDesiredHdrHeadroom(0.5f)) + assertEquals(1f, androidDesiredHdrHeadroom(1f)) + assertEquals(4f, androidDesiredHdrHeadroom(4f)) + assertEquals(10_000f, androidDesiredHdrHeadroom(10_000f)) + assertEquals(0f, androidDesiredHdrHeadroom(10_001f)) + } + + @Test + fun hdrRatioMustBeAvailableFiniteAndAtLeastOne() { + assertEquals( + AndroidHdrHeadroomState(1f, false), + androidHdrHeadroomState(ratioAvailable = false, ratio = 4f), + ) + assertEquals( + AndroidHdrHeadroomState(1f, false), + androidHdrHeadroomState(ratioAvailable = true, ratio = Float.NaN), + ) + assertEquals( + AndroidHdrHeadroomState(1f, false), + androidHdrHeadroomState(ratioAvailable = true, ratio = 0.5f), + ) + assertEquals( + AndroidHdrHeadroomState(3.25f, true), + androidHdrHeadroomState(ratioAvailable = true, ratio = 3.25f), + ) + } +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidPendingEventQueueTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidPendingEventQueueTest.kt new file mode 100644 index 00000000..6403408b --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidPendingEventQueueTest.kt @@ -0,0 +1,135 @@ +package dev.aimesoft.erika_flutter + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class AndroidPendingEventQueueTest { + @Test + fun `events remain ordered until a listener can consume them`() { + val queue = AndroidPendingEventQueue(capacity = 3) + val first = successEvent(1) + val second = AndroidPendingEvent.Error( + code = "ERIKA_ERROR", + message = "native poll failed", + details = mapOf("playerId" to 7L), + contentGeneration = null, + ) + val third = successEvent(3) + + assertNull(queue.enqueue(first)) + assertNull(queue.enqueue(second)) + assertNull(queue.enqueue(third)) + + assertEquals(first, queue.firstOrNull()) + assertEquals(first, queue.removeFirst()) + assertEquals(second, queue.removeFirst()) + assertEquals(third, queue.removeFirst()) + assertNull(queue.firstOrNull()) + } + + @Test + fun `overflow drops oldest event and reports cumulative loss`() { + val queue = AndroidPendingEventQueue(capacity = 2) + val first = successEvent(1) + val second = successEvent(2) + val third = successEvent(3) + val fourth = successEvent(4) + + queue.enqueue(first) + queue.enqueue(second) + val firstOverflow = queue.enqueue(third) + val secondOverflow = queue.enqueue(fourth) + + assertEquals(first, firstOverflow?.dropped) + assertEquals(1L, firstOverflow?.droppedTotal) + assertEquals(2, firstOverflow?.capacity) + assertEquals(second, secondOverflow?.dropped) + assertEquals(2L, secondOverflow?.droppedTotal) + assertEquals(third, queue.removeFirst()) + assertEquals(fourth, queue.removeFirst()) + } + + @Test + fun `clear removes events retained across listener cancellation`() { + val queue = AndroidPendingEventQueue(capacity = 2) + queue.enqueue(successEvent(1)) + queue.enqueue(successEvent(2)) + + queue.clear() + + assertEquals(0, queue.size) + assertNull(queue.firstOrNull()) + } + + @Test + fun `content boundary drops stale media events but preserves host events`() { + val queue = AndroidPendingEventQueue(capacity = 5) + val oldState = AndroidPendingEvent.Success( + value = mapOf("kind" to 1, "state" to 3), + contentGeneration = 1L, + ) + val surface = AndroidPendingEvent.Success( + value = mapOf("kind" to ANDROID_SURFACE_ATTACHED_EVENT_KIND), + contentGeneration = null, + ) + val hostError = AndroidPendingEvent.Error( + code = "ERIKA_ERROR", + message = "poll failed", + details = mapOf("playerId" to 7L), + contentGeneration = null, + ) + val staleRenderError = AndroidPendingEvent.Success( + value = mapOf("kind" to 9, "hostStage" to "renderTick"), + contentGeneration = 1L, + ) + val currentPosition = AndroidPendingEvent.Success( + value = mapOf("kind" to ANDROID_POSITION_CHANGED_EVENT_KIND), + contentGeneration = 2L, + ) + queue.enqueue(oldState) + queue.enqueue(surface) + queue.enqueue(hostError) + queue.enqueue(staleRenderError) + queue.enqueue(currentPosition) + + assertEquals(2, queue.discardStaleContentEvents(currentContentGeneration = 2L)) + assertEquals(surface, queue.removeFirst()) + assertEquals(hostError, queue.removeFirst()) + assertEquals(currentPosition, queue.removeFirst()) + assertNull(queue.firstOrNull()) + } + + @Test + fun `events queued before listen and after cancel flush in original order`() { + val queue = AndroidPendingEventQueue(capacity = 4) + val delivered = mutableListOf() + queue.enqueue(successEvent(1)) + + delivered += queue.removeFirst() + queue.enqueue(successEvent(2)) + queue.enqueue(successEvent(3)) + while (queue.firstOrNull() != null) { + delivered += queue.removeFirst() + } + + assertEquals( + listOf(successEvent(1), successEvent(2), successEvent(3)), + delivered, + ) + } + + @Test + fun `capacity must be positive`() { + assertThrows(IllegalArgumentException::class.java) { + AndroidPendingEventQueue(capacity = 0) + } + } + + private fun successEvent(sequence: Int): AndroidPendingEvent.Success = + AndroidPendingEvent.Success( + value = mapOf("sequence" to sequence), + contentGeneration = null, + ) +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidPlaybackTrackerTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidPlaybackTrackerTest.kt new file mode 100644 index 00000000..7fa39c55 --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidPlaybackTrackerTest.kt @@ -0,0 +1,314 @@ +package dev.aimesoft.erika_flutter + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidPlaybackTrackerTest { + @Test + fun `explicit pause cancels delayed playback intent`() { + val tracker = AndroidPlaybackTracker() + + tracker.requestPlayback() + assertEquals(AndroidPlaybackPhase.PENDING, tracker.phase) + + tracker.cancelPlaybackIntent() + + assertEquals(AndroidPlaybackPhase.PAUSED, tracker.phase) + assertFalse(tracker.playbackStarted()) + assertEquals(AndroidPlaybackPhase.PAUSED, tracker.phase) + } + + @Test + fun `lifecycle stop preserves resumable playback intent`() { + val tracker = AndroidPlaybackTracker() + tracker.requestPlayback() + assertTrue(tracker.playbackStarted()) + + assertTrue(tracker.suspendPlayback()) + assertEquals(AndroidPlaybackPhase.PENDING, tracker.phase) + + assertTrue(tracker.playbackStarted()) + assertEquals(AndroidPlaybackPhase.PLAYING, tracker.phase) + } + + @Test + fun `foreground lifecycle cancellation stays paused until an explicit new play`() { + val tracker = AndroidPlaybackTracker() + tracker.requestPlayback() + assertTrue(tracker.playbackStarted()) + + assertTrue(tracker.cancelPlaybackIntent()) + assertEquals(AndroidPlaybackPhase.PAUSED, tracker.phase) + assertFalse(tracker.playbackStarted()) + + tracker.requestPlayback() + assertEquals(AndroidPlaybackPhase.PENDING, tracker.phase) + assertTrue(tracker.playbackStarted()) + } + + @Test + fun `explicit stop or close cancels pending playback`() { + val tracker = AndroidPlaybackTracker() + + tracker.requestPlayback() + assertFalse(tracker.cancelPlaybackIntent()) + assertEquals(AndroidPlaybackPhase.PAUSED, tracker.phase) + + tracker.requestPlayback() + assertFalse(tracker.cancelPlaybackIntent()) + assertEquals(AndroidPlaybackPhase.PAUSED, tracker.phase) + } + + @Test + fun `explicit native cancellation advances generation even while already paused`() { + val tracker = AndroidPlaybackTracker() + val initialGeneration = tracker.currentPlaybackIntentGeneration + + assertFalse(tracker.cancelPlaybackIntent(forceNewGeneration = true)) + + assertNotEquals(initialGeneration, tracker.currentPlaybackIntentGeneration) + } + + @Test + fun `native terminal state reconciles without advancing command generation`() { + val tracker = AndroidPlaybackTracker() + tracker.requestPlayback() + tracker.playbackStarted() + val playingGeneration = tracker.currentPlaybackIntentGeneration + + tracker.reconcileNativePlaybackStopped() + + assertEquals(AndroidPlaybackPhase.PAUSED, tracker.phase) + assertEquals(playingGeneration, tracker.currentPlaybackIntentGeneration) + } + + @Test + fun `transient focus loss is resumable but permanent loss is not`() { + val tracker = AndroidPlaybackTracker() + tracker.requestPlayback() + tracker.playbackStarted() + + assertTrue(tracker.handleFocusLoss(mayResume = true)) + assertEquals(AndroidPlaybackPhase.PENDING, tracker.phase) + assertTrue(tracker.playbackStarted()) + + assertTrue(tracker.handleFocusLoss(mayResume = false)) + assertEquals(AndroidPlaybackPhase.PAUSED, tracker.phase) + assertFalse(tracker.playbackStarted()) + } + + @Test + fun `focus gain renews pending intent so an older pause cannot own state`() { + val tracker = AndroidPlaybackTracker() + tracker.requestPlayback() + assertTrue(tracker.playbackStarted()) + + assertTrue(tracker.handleFocusLoss(mayResume = true)) + val pauseGeneration = tracker.currentPlaybackIntentGeneration + val resumedGeneration = tracker.renewPendingPlaybackIntent() + + assertNotEquals(pauseGeneration, resumedGeneration) + assertEquals(resumedGeneration, tracker.tryBeginPlayInvocation()) + assertTrue(tracker.finishPlayInvocation(checkNotNull(resumedGeneration))) + } + + @Test + fun `ticking supports headless playback and surface render requests`() { + val tracker = AndroidPlaybackTracker() + + assertFalse(tracker.shouldTick) + tracker.requestRender() + assertFalse(tracker.shouldTick) + + tracker.attachSurface() + assertTrue(tracker.shouldTick) + tracker.markRenderAttempted(tracker.currentRenderRequestGeneration) + assertFalse(tracker.shouldTick) + + tracker.requestPlayback() + assertFalse(tracker.shouldTick) + tracker.playbackStarted() + assertTrue(tracker.shouldTick) + tracker.markRenderAttempted(tracker.currentRenderRequestGeneration) + assertTrue(tracker.shouldTick) + + tracker.suspendPlayback() + assertFalse(tracker.shouldTick) + tracker.requestRender() + assertTrue(tracker.shouldTick) + tracker.detachSurface() + assertFalse(tracker.shouldTick) + + tracker.requestPlayback() + tracker.playbackStarted() + assertTrue(tracker.shouldTick) + } + + @Test + fun `players keep independent playback and render state`() { + val first = AndroidPlaybackTracker() + val second = AndroidPlaybackTracker() + + first.attachSurface() + first.markRenderAttempted(first.currentRenderRequestGeneration) + first.requestPlayback() + first.playbackStarted() + + second.attachSurface() + second.markRenderAttempted(second.currentRenderRequestGeneration) + second.requestPlayback() + second.playbackStarted() + + assertEquals(AndroidPlaybackPhase.PLAYING, first.phase) + assertTrue(first.shouldTick) + assertEquals(AndroidPlaybackPhase.PLAYING, second.phase) + assertTrue(second.shouldTick) + + first.cancelPlaybackIntent() + assertEquals(AndroidPlaybackPhase.PAUSED, first.phase) + assertEquals(AndroidPlaybackPhase.PLAYING, second.phase) + assertTrue(second.shouldTick) + } + + @Test + fun `old render completion cannot clear a newer request`() { + val tracker = AndroidPlaybackTracker() + tracker.attachSurface() + val firstGeneration = tracker.currentRenderRequestGeneration + + tracker.requestRender() + tracker.markRenderAttempted(firstGeneration) + + assertTrue(tracker.renderRequested) + assertTrue(tracker.shouldTick) + tracker.markRenderAttempted(tracker.currentRenderRequestGeneration) + assertFalse(tracker.renderRequested) + assertFalse(tracker.shouldTick) + } + + @Test + fun `acknowledging generations is monotonic`() { + val tracker = AndroidPlaybackTracker() + tracker.attachSurface() + val firstGeneration = tracker.currentRenderRequestGeneration + val secondGeneration = tracker.requestRender() + + tracker.markRenderAttempted(secondGeneration) + tracker.markRenderAttempted(firstGeneration) + + assertFalse(tracker.renderRequested) + } + + @Test + fun `async play completion requires pending intent activity permission and focus`() { + assertTrue( + androidAsyncPlayCanStart( + AndroidPlaybackPhase.PENDING, + canPlayInCurrentActivityState = true, + audioFocusGranted = true, + ), + ) + assertFalse( + androidAsyncPlayCanStart( + AndroidPlaybackPhase.PENDING, + canPlayInCurrentActivityState = false, + audioFocusGranted = true, + ), + ) + assertFalse( + androidAsyncPlayCanStart( + AndroidPlaybackPhase.PENDING, + canPlayInCurrentActivityState = true, + audioFocusGranted = false, + ), + ) + assertFalse( + androidAsyncPlayCanStart( + AndroidPlaybackPhase.PAUSED, + canPlayInCurrentActivityState = true, + audioFocusGranted = true, + ), + ) + } + + @Test + fun `only one native play invocation can be in flight`() { + val tracker = AndroidPlaybackTracker() + val generation = tracker.requestPlayback() + + assertEquals(generation, tracker.tryBeginPlayInvocation()) + assertNull(tracker.tryBeginPlayInvocation()) + + // A transient focus cycle leaves the intent pending but must not submit again. + assertTrue(tracker.handleFocusLoss(mayResume = true)) + assertEquals(AndroidPlaybackPhase.PENDING, tracker.phase) + assertNull(tracker.tryBeginPlayInvocation()) + + assertFalse(tracker.finishPlayInvocation(generation)) + assertFalse(tracker.finishPlayInvocation(generation)) + val resumedGeneration = tracker.tryBeginPlayInvocation() + assertNotNull(resumedGeneration) + assertEquals(tracker.currentPlaybackIntentGeneration, resumedGeneration) + assertNotEquals(generation, resumedGeneration) + assertTrue(tracker.finishPlayInvocation(checkNotNull(resumedGeneration))) + } + + @Test + fun `play pause play starts the latest generation after the old invocation finishes`() { + val tracker = AndroidPlaybackTracker() + val firstGeneration = tracker.requestPlayback() + assertEquals(firstGeneration, tracker.tryBeginPlayInvocation()) + + tracker.cancelPlaybackIntent() + assertNull(tracker.tryBeginPlayInvocation()) + val latestGeneration = tracker.requestPlayback() + assertNotEquals(firstGeneration, latestGeneration) + assertNull(tracker.tryBeginPlayInvocation()) + + assertFalse(tracker.finishPlayInvocation(firstGeneration)) + assertEquals(latestGeneration, tracker.tryBeginPlayInvocation()) + assertTrue(tracker.finishPlayInvocation(latestGeneration)) + } + + @Test + fun `duplicate pending play shares the active intent generation`() { + val tracker = AndroidPlaybackTracker() + val firstGeneration = tracker.requestPlayback() + assertEquals(firstGeneration, tracker.tryBeginPlayInvocation()) + + val duplicateGeneration = tracker.requestPlayback() + assertEquals(firstGeneration, duplicateGeneration) + assertNull(tracker.tryBeginPlayInvocation()) + assertTrue(tracker.finishPlayInvocation(firstGeneration)) + } + + @Test + fun `play requested from locally playing state starts a new intent generation`() { + val tracker = AndroidPlaybackTracker() + val firstGeneration = tracker.requestPlayback() + assertTrue(tracker.playbackStarted()) + + val replayGeneration = tracker.requestPlayback() + + assertNotEquals(firstGeneration, replayGeneration) + assertEquals(AndroidPlaybackPhase.PENDING, tracker.phase) + } + + @Test + fun `finishing before a main callback failure always clears the invocation`() { + val tracker = AndroidPlaybackTracker() + val generation = tracker.requestPlayback() + assertEquals(generation, tracker.tryBeginPlayInvocation()) + + assertTrue(tracker.finishPlayInvocation(generation)) + + // The callback may now fail while processing events or updating MediaSession. A + // retry can still begin because completion released the in-flight slot first. + assertEquals(generation, tracker.tryBeginPlayInvocation()) + } +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidPresenterCreateRegistryTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidPresenterCreateRegistryTest.kt new file mode 100644 index 00000000..020d8d8f --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidPresenterCreateRegistryTest.kt @@ -0,0 +1,35 @@ +package dev.aimesoft.erika_flutter + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidPresenterCreateRegistryTest { + @Test + fun `detach retires unclaimed handles and rejects late registration`() { + val registry = AndroidPresenterCreateRegistry() + val owner = Any() + val generation = registry.attach(owner) + + assertTrue(registry.registerIfCurrent(11L, owner, generation)) + assertEquals(listOf(11L), registry.detach(owner).map { it.handle }) + assertFalse(registry.claimIfCurrent(11L, owner, generation)) + assertFalse(registry.registerIfCurrent(12L, owner, generation)) + } + + @Test + fun `new attachment cannot claim a previous owner handle`() { + val registry = AndroidPresenterCreateRegistry() + val oldOwner = Any() + val oldGeneration = registry.attach(oldOwner) + assertTrue(registry.registerIfCurrent(21L, oldOwner, oldGeneration)) + assertEquals(listOf(21L), registry.detach(oldOwner).map { it.handle }) + + val newOwner = Any() + val newGeneration = registry.attach(newOwner) + assertFalse(registry.claimIfCurrent(21L, newOwner, newGeneration)) + assertTrue(registry.registerIfCurrent(22L, newOwner, newGeneration)) + assertTrue(registry.claimIfCurrent(22L, newOwner, newGeneration)) + } +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidPresenterThreadPolicyTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidPresenterThreadPolicyTest.kt new file mode 100644 index 00000000..8ba7d912 --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidPresenterThreadPolicyTest.kt @@ -0,0 +1,38 @@ +package dev.aimesoft.erika_flutter + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidPresenterThreadPolicyTest { + @Test + fun `Android main thread must never synchronously wait for presenter work`() { + assertTrue( + androidPresenterCallMustBeAsync( + isOwnerThread = false, + isAndroidMainThread = true, + ), + ) + assertFalse( + androidPresenterCallMustBeAsync( + isOwnerThread = true, + isAndroidMainThread = true, + ), + ) + assertFalse( + androidPresenterCallMustBeAsync( + isOwnerThread = false, + isAndroidMainThread = false, + ), + ) + } + + @Test + fun `presenter task boundary catches linkage errors`() { + val result = androidPresenterTaskResult { + throw UnsatisfiedLinkError("missing native symbol") + } + + assertTrue(result.exceptionOrNull() is UnsatisfiedLinkError) + } +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidSurfaceLifecycleTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidSurfaceLifecycleTest.kt new file mode 100644 index 00000000..3ea76170 --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidSurfaceLifecycleTest.kt @@ -0,0 +1,397 @@ +package dev.aimesoft.erika_flutter + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidSurfaceLifecycleTest { + @Test + fun `activity stop retains only a live texture surface`() { + assertTrue( + androidShouldRetainSurfaceDuringActivityStop( + usesTextureView = true, + outputSurfaceValid = true, + ), + ) + assertFalse( + androidShouldRetainSurfaceDuringActivityStop( + usesTextureView = true, + outputSurfaceValid = false, + ), + ) + assertFalse( + androidShouldRetainSurfaceDuringActivityStop( + usesTextureView = false, + outputSurfaceValid = true, + ), + ) + } + + @Test + fun `failed native detach retains texture ownership and schedules retry`() { + val decision = androidSurfaceDestroyDecision(nativeDetachSucceeded = false) + + assertFalse(decision.releaseSurfaceTexture) + assertTrue(decision.retryNativeDetach) + } + + @Test + fun `successful native detach needs no retry`() { + val decision = androidSurfaceDestroyDecision(nativeDetachSucceeded = true) + + assertFalse(decision.releaseSurfaceTexture) + assertFalse(decision.retryNativeDetach) + } + + @Test + fun `failed detach keeps recovery state until native destroy owns retirement`() { + assertTrue( + androidSurfaceDestroyNeedsRetry( + nativeDetachSucceeded = false, + hostDestroying = false, + ), + ) + assertFalse( + androidSurfaceDestroyNeedsRetry( + nativeDetachSucceeded = true, + hostDestroying = false, + ), + ) + assertFalse( + androidSurfaceDestroyNeedsRetry( + nativeDetachSucceeded = false, + hostDestroying = true, + ), + ) + } + + @Test + fun `surface recovery uses bounded exponential backoff`() { + val delays = (1..ANDROID_SURFACE_RECOVERY_MAX_RETRIES) + .map(::androidSurfaceRecoveryDelayMillis) + + assertEquals(listOf(16L, 32L, 64L, 128L, 256L, 512L), delays) + assertNull(androidSurfaceRecoveryDelayMillis(0)) + assertNull( + androidSurfaceRecoveryDelayMillis(ANDROID_SURFACE_RECOVERY_MAX_RETRIES + 1), + ) + } + + @Test + fun `surface recovery counts actual failures within one operation`() { + val attempts = AndroidSurfaceRecoveryAttemptTracker() + + val recorded = (1..ANDROID_SURFACE_RECOVERY_MAX_RETRIES + 1).map { + attempts.recordFailure(4L, "attachSurface") + } + assertEquals((1..7).toList(), recorded) + assertEquals( + listOf(16L, 32L, 64L, 128L, 256L, 512L, null), + recorded.map(::androidSurfaceRecoveryDelayMillis), + ) + assertTrue(attempts.markExhaustionReported(4L, "attachSurface")) + assertFalse(attempts.markExhaustionReported(4L, "attachSurface")) + assertTrue(attempts.complete(4L, "attachSurface")) + assertFalse(attempts.complete(4L, "attachSurface")) + assertEquals(1, attempts.recordFailure(4L, "attachSurface")) + } + + @Test + fun `new surface generation or operation gets a fresh recovery budget`() { + val attempts = AndroidSurfaceRecoveryAttemptTracker() + + assertEquals(1, attempts.recordFailure(4L, "attachSurface")) + assertEquals(2, attempts.recordFailure(4L, "attachSurface")) + assertEquals(1, attempts.recordFailure(4L, "resizeSurface")) + assertEquals(1, attempts.recordFailure(5L, "resizeSurface")) + } + + @Test + fun `invalidating recovery token rejects stale callbacks`() { + val tokens = AndroidSurfaceRecoveryTokenSource() + val initial = tokens.currentToken + + assertTrue(tokens.isCurrent(initial)) + tokens.invalidate() + assertFalse(tokens.isCurrent(initial)) + + val replacement = tokens.currentToken + assertTrue(tokens.isCurrent(replacement)) + tokens.invalidate() + assertFalse(tokens.isCurrent(replacement)) + } + + @Test + fun `binding generation rejects a retired surface callback`() { + val generations = AndroidSurfaceBindingGenerationTracker() + val firstSurface = generations.advance() + + assertTrue(generations.isCurrent(firstSurface)) + val replacementSurface = generations.advance() + + assertFalse(generations.isCurrent(firstSurface)) + assertTrue(generations.isCurrent(replacementSurface)) + } + + @Test + fun `stale resize callback cannot recover a replacement surface`() { + assertTrue( + androidSurfaceCallbackIsCurrent( + callbackGeneration = 7L, + currentGeneration = 7L, + hostStillBound = true, + surfaceStillCurrent = true, + ), + ) + assertFalse( + androidSurfaceCallbackIsCurrent( + callbackGeneration = 7L, + currentGeneration = 8L, + hostStillBound = true, + surfaceStillCurrent = true, + ), + ) + assertFalse( + androidSurfaceCallbackIsCurrent( + callbackGeneration = 7L, + currentGeneration = 7L, + hostStillBound = true, + surfaceStillCurrent = false, + ), + ) + } + + @Test + fun `physical detach settles unbind waiters across a rebind generation`() { + assertTrue( + androidSurfaceCompletionMatchesGeneration( + completionGeneration = 4L, + callbackGeneration = null, + ), + ) + assertFalse( + androidSurfaceCompletionMatchesGeneration( + completionGeneration = 4L, + callbackGeneration = 5L, + ), + ) + } + + @Test + fun `queued surface dispatch has no reportable native result`() { + assertTrue( + androidSurfaceOperationIsPending( + operation = "attachSurface", + nativeAttachPending = true, + nativeDetachPending = false, + nativeResizePending = false, + ), + ) + assertTrue( + androidSurfaceOperationIsPending( + operation = "detachSurface", + nativeAttachPending = false, + nativeDetachPending = true, + nativeResizePending = false, + ), + ) + assertTrue( + androidSurfaceOperationIsPending( + operation = "resizeSurface", + nativeAttachPending = false, + nativeDetachPending = false, + nativeResizePending = true, + ), + ) + assertFalse( + androidSurfaceOperationIsPending( + operation = "attachSurface", + nativeAttachPending = false, + nativeDetachPending = false, + nativeResizePending = false, + ), + ) + } + + @Test + fun `unbind waits for an in flight lifecycle detach`() { + assertFalse(androidUnbindNeedsNewSurfaceDetach(lifecycleDetachPending = true)) + assertTrue(androidUnbindNeedsNewSurfaceDetach(lifecycleDetachPending = false)) + } + + @Test + fun `successful attached recovery resumes hdr headroom observation`() { + assertTrue( + androidShouldRefreshHdrHeadroomAfterRecovery( + hostStillBound = true, + surfaceAttached = true, + disposed = false, + disposeRequested = false, + unbindRequested = false, + ), + ) + } + + @Test + fun `recovery does not resume hdr observation without a live attached binding`() { + val inactiveStates = listOf( + androidShouldRefreshHdrHeadroomAfterRecovery( + hostStillBound = false, + surfaceAttached = true, + disposed = false, + disposeRequested = false, + unbindRequested = false, + ), + androidShouldRefreshHdrHeadroomAfterRecovery( + hostStillBound = true, + surfaceAttached = false, + disposed = false, + disposeRequested = false, + unbindRequested = false, + ), + androidShouldRefreshHdrHeadroomAfterRecovery( + hostStillBound = true, + surfaceAttached = true, + disposed = true, + disposeRequested = false, + unbindRequested = false, + ), + androidShouldRefreshHdrHeadroomAfterRecovery( + hostStillBound = true, + surfaceAttached = true, + disposed = false, + disposeRequested = true, + unbindRequested = false, + ), + androidShouldRefreshHdrHeadroomAfterRecovery( + hostStillBound = true, + surfaceAttached = true, + disposed = false, + disposeRequested = false, + unbindRequested = true, + ), + ) + + assertTrue(inactiveStates.all { shouldRefresh -> !shouldRefresh }) + } + + @Test + fun `pending view bind resumes only for a live host and target`() { + assertTrue( + androidShouldResumePendingViewBind( + hostDestroyed = false, + targetDisposed = false, + targetDisposeRequested = false, + targetAcceptsHost = true, + hostAcceptsTarget = true, + ), + ) + assertFalse( + androidShouldResumePendingViewBind( + hostDestroyed = true, + targetDisposed = false, + targetDisposeRequested = false, + targetAcceptsHost = true, + hostAcceptsTarget = true, + ), + ) + assertFalse( + androidShouldResumePendingViewBind( + hostDestroyed = false, + targetDisposed = true, + targetDisposeRequested = false, + targetAcceptsHost = true, + hostAcceptsTarget = true, + ), + ) + assertFalse( + androidShouldResumePendingViewBind( + hostDestroyed = false, + targetDisposed = false, + targetDisposeRequested = true, + targetAcceptsHost = true, + hostAcceptsTarget = true, + ), + ) + assertFalse( + androidShouldResumePendingViewBind( + hostDestroyed = false, + targetDisposed = false, + targetDisposeRequested = false, + targetAcceptsHost = false, + hostAcceptsTarget = true, + ), + ) + assertFalse( + androidShouldResumePendingViewBind( + hostDestroyed = false, + targetDisposed = false, + targetDisposeRequested = false, + targetAcceptsHost = true, + hostAcceptsTarget = false, + ), + ) + } + + @Test + fun `successful detach completes an unbind superseded by rebind`() { + assertTrue( + androidDetachCompletesSupersededUnbind( + nativeDetachSucceeded = true, + unbindRequested = false, + disposeRequested = false, + ), + ) + assertFalse( + androidDetachCompletesSupersededUnbind( + nativeDetachSucceeded = false, + unbindRequested = false, + disposeRequested = false, + ), + ) + assertFalse( + androidDetachCompletesSupersededUnbind( + nativeDetachSucceeded = true, + unbindRequested = true, + disposeRequested = false, + ), + ) + assertFalse( + androidDetachCompletesSupersededUnbind( + nativeDetachSucceeded = true, + unbindRequested = false, + disposeRequested = true, + ), + ) + } + + @Test + fun `exhausted detach recovery retires host before releasing buffers`() { + assertTrue( + androidSurfaceRecoveryExhaustionRequiresHostRetirement( + failedOperation = "detachSurface", + nativeDetachRetryPending = true, + unbindRequested = false, + disposeRequested = false, + ), + ) + assertTrue( + androidSurfaceRecoveryExhaustionRequiresHostRetirement( + failedOperation = "detachSurface", + nativeDetachRetryPending = false, + unbindRequested = true, + disposeRequested = false, + ), + ) + assertFalse( + androidSurfaceRecoveryExhaustionRequiresHostRetirement( + failedOperation = "attachSurface", + nativeDetachRetryPending = true, + unbindRequested = true, + disposeRequested = true, + ), + ) + } +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidSurfaceMetricsTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidSurfaceMetricsTest.kt new file mode 100644 index 00000000..17c91098 --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/AndroidSurfaceMetricsTest.kt @@ -0,0 +1,23 @@ +package dev.aimesoft.erika_flutter + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AndroidSurfaceMetricsTest { + @Test + fun keepsExactPhysicalExtentAndFractionalDensity() { + val metrics = resolveAndroidSurfaceMetrics(1081, 607, 2.625) + + assertEquals(1081, metrics.width) + assertEquals(607, metrics.height) + assertEquals(2.625, metrics.scale, 0.0) + } + + @Test + fun preservesLowDensityAndSanitizesInvalidValues() { + assertEquals(0.75, resolveAndroidSurfaceMetrics(320, 180, 0.75).scale, 0.0) + assertEquals(1.0, resolveAndroidSurfaceMetrics(320, 180, Double.NaN).scale, 0.0) + assertEquals(1.0, resolveAndroidSurfaceMetrics(320, 180, 0.0).scale, 0.0) + assertEquals(1.0, resolveAndroidSurfaceMetrics(320, 180, -1.0).scale, 0.0) + } +} diff --git a/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/NativeJsonTest.kt b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/NativeJsonTest.kt new file mode 100644 index 00000000..cb65ad3c --- /dev/null +++ b/third_party/erika_flutter/android/src/test/kotlin/dev/aimesoft/erika_flutter/NativeJsonTest.kt @@ -0,0 +1,39 @@ +package dev.aimesoft.erika_flutter + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +class NativeJsonTest { + @Test + fun optionalEventTreatsJniNullAsNoEvent() { + assertNull(NativeJson.decodeOptionalEventResponse(null)) + } + + @Test + fun optionalEventTreatsSuccessfulNullPayloadAsNoEvent() { + assertNull( + NativeJson.decodeOptionalEventResponse( + """{"ok":true,"status":0,"value":null}""", + ), + ) + } + + @Test + fun optionalEventPreservesEventsAndErrors() { + val event = NativeJson.decodeOptionalEventResponse( + """{"ok":true,"status":0,"value":{"kind":3,"positionMicros":42}}""", + ) + val eventValue = event?.value as Map<*, *> + assertEquals(3, (eventValue["kind"] as Number).toInt()) + assertEquals(42L, (eventValue["positionMicros"] as Number).toLong()) + + val error = NativeJson.decodeOptionalEventResponse( + """{"ok":false,"status":7,"error":"poll failed","value":null}""", + ) + assertFalse(error?.ok ?: true) + assertEquals(7, error?.status) + assertEquals("poll failed", error?.error) + } +} diff --git a/third_party/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift b/third_party/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift new file mode 100644 index 00000000..21cda381 --- /dev/null +++ b/third_party/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift @@ -0,0 +1,3254 @@ +import Darwin +import AVFoundation +import Flutter +import MediaPlayer +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[iOS]: \(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 { + switch config.outputMode { + case 1: + return String(format: "AppleEdr(headroom=%.2f)", config.edrHeadroom) + case 2: + return String(format: "ExtendedLinear(headroom=%.2f)", config.edrHeadroom) + case 3: + return String(format: "Auto(headroom=%.2f)", config.edrHeadroom) + default: + return "Sdr" + } +} + +private func erikaScreenSummary(_ screen: UIScreen?) -> String { + guard let screen else { + return "screen=nil" + } + var parts = [ + "scale=\(screen.scale)", + "nativeScale=\(screen.nativeScale)", + "brightness=\(String(format: "%.3f", screen.brightness))", + "gamut=\(screen.traitCollection.displayGamut.rawValue)", + ] + if #available(iOS 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 || config.outputMode == 2 { + layer.contentsFormat = .RGBA16Float + if #available(iOS 16.0, *) { + layer.wantsExtendedDynamicRangeContent = true + layer.edrMetadata = CAEDRMetadata.hdr10( + minLuminance: 0.02, + maxLuminance: 1200.0, + opticalOutputScale: 203.0 + ) + } + if #available(iOS 18.0, *) { + layer.toneMapMode = .ifSupported + } + if #available(iOS 26.0, *) { + layer.preferredDynamicRange = .high + layer.contentsHeadroom = CGFloat(max(config.edrHeadroom, 1.0)) + } + return + } + + layer.contentsFormat = .RGBA8Uint + if #available(iOS 16.0, *) { + layer.wantsExtendedDynamicRangeContent = false + layer.edrMetadata = nil + } + if #available(iOS 18.0, *) { + layer.toneMapMode = .automatic + } + // Auto is initialized in SDR, but the native renderer owns later changes. + // Do not pin preferredDynamicRange/contentsHeadroom to SDR here. + if config.outputMode != 3 { + if #available(iOS 26.0, *) { + layer.preferredDynamicRange = .standard + layer.contentsHeadroom = 0.0 + } + } +} + +private func erikaLayerSummary(_ layer: CAMetalLayer) -> String { + let wantsEDR: String + if #available(iOS 16.0, *) { + wantsEDR = String(layer.wantsExtendedDynamicRangeContent) + } else { + wantsEDR = "unavailable" + } + let toneMapMode: String + if #available(iOS 18.0, *) { + toneMapMode = String(describing: layer.toneMapMode) + } else { + toneMapMode = "unavailable" + } + let preferredDynamicRange: String + if #available(iOS 26.0, *) { + preferredDynamicRange = String(describing: layer.preferredDynamicRange) + } else { + preferredDynamicRange = "unavailable" + } + let contentsHeadroom: String + if #available(iOS 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 + var lumaUpscaler: Int32 = 0 + var videoAlphaMode: Int32 = 0 + + static let sdr = ErikaPresenterConfigC() + + static func appleEdr(headroom: Float) -> ErikaPresenterConfigC { + ErikaPresenterConfigC(outputMode: 1, edrHeadroom: max(1.0, headroom)) + } + + static func auto(headroom: Float) -> ErikaPresenterConfigC { + ErikaPresenterConfigC(outputMode: 3, 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 +} + +private struct ErikaSubtitleMemoryFontStatusC { + var registeredCount: UInt = 0 + var registeredBytes: UInt = 0 + var selectedCount: UInt = 0 + var generation: UInt64 = 0 + var selectedIds: UnsafeMutablePointer? +} + +// 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 +} + +// Keep field order and types aligned with `ErikaPresenterResourceStatus` in erika.h. +private struct ErikaPresenterResourceStatusC { + var deviceCurrentAllocatedBytes: UInt64 = 0 + var deviceRecommendedWorkingSetBytes: UInt64 = 0 + var drawableEstimatedBytes: UInt64 = 0 + var videoFrameBytes: UInt64 = 0 + var overlayAtlasBytes: UInt64 = 0 + var danmakuAtlasBytes: UInt64 = 0 + var danmakuVertexBufferBytes: UInt64 = 0 + var upscalerBytes: UInt64 = 0 + var rendererTrackedBytes: UInt64 = 0 + var presenterCpuDanmakuAtlasBytes: UInt64 = 0 + var drawableCount: UInt32 = 0 + var outputModeSwitches: 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) + 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): + 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 RegisterSubtitleMemoryFontFn = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UInt, UnsafeMutablePointer?) -> Int32 + typealias SelectSubtitleMemoryFontsFn = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UInt) -> Int32 + typealias GetSubtitleMemoryFontStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias FreeSubtitleMemoryFontStatusFn = @convention(c) (UnsafeMutableRawPointer?) -> Void + typealias GetUpscalerStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias GetOutputStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias GetResourceStatusFn = @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 AudioOnlyTickFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias CaptureFrameRgbaFn = @convention(c) (UnsafeMutableRawPointer?, UInt32, UInt32, UnsafeMutableRawPointer?, Int) -> Int32 + typealias PollEventFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias LastErrorMessageFn = @convention(c) () -> UnsafeMutablePointer? + 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 registerSubtitleMemoryFont: RegisterSubtitleMemoryFontFn? + let selectSubtitleMemoryFonts: SelectSubtitleMemoryFontsFn? + let clearSubtitleMemoryFonts: CommandFn? + let getSubtitleMemoryFontStatus: GetSubtitleMemoryFontStatusFn? + let freeSubtitleMemoryFontStatus: FreeSubtitleMemoryFontStatusFn? + let getUpscalerStatus: GetUpscalerStatusFn? + let getOutputStatus: GetOutputStatusFn? + let getResourceStatus: GetResourceStatusFn? + 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 audioOnlyTick: AudioOnlyTickFn + 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) + registerSubtitleMemoryFont = Self.loadOptional("erika_presenter_register_subtitle_memory_font", from: libraryHandle, as: RegisterSubtitleMemoryFontFn.self) + selectSubtitleMemoryFonts = Self.loadOptional("erika_presenter_select_subtitle_memory_fonts", from: libraryHandle, as: SelectSubtitleMemoryFontsFn.self) + clearSubtitleMemoryFonts = Self.loadOptional("erika_presenter_clear_subtitle_memory_fonts", from: libraryHandle, as: CommandFn.self) + getSubtitleMemoryFontStatus = Self.loadOptional("erika_presenter_get_subtitle_memory_font_status", from: libraryHandle, as: GetSubtitleMemoryFontStatusFn.self) + freeSubtitleMemoryFontStatus = Self.loadOptional("erika_subtitle_memory_font_status_free", from: libraryHandle, as: FreeSubtitleMemoryFontStatusFn.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) + getResourceStatus = Self.loadOptional("erika_presenter_get_resource_status", from: libraryHandle, as: GetResourceStatusFn.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) + audioOnlyTick = try Self.load("erika_presenter_audio_only_tick", from: libraryHandle, as: AudioOnlyTickFn.self) + captureFrameRgba = Self.loadOptional("erika_presenter_capture_frame_rgba", from: libraryHandle, as: CaptureFrameRgbaFn.self) + pollEvent = try Self.load("erika_presenter_poll_event", from: libraryHandle, as: PollEventFn.self) + lastErrorMessage = try Self.load("erika_last_error_message", from: libraryHandle, as: LastErrorMessageFn.self) + 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 let renderQueue: DispatchQueue + private let nativeCallLock = NSRecursiveLock() + private let renderSubmissionLock = NSLock() + 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 let allowBackgroundPlayback: Bool + private var loggedRenderThread = false + private var loggedFirstRenderedVideoFrame = false + private var latestPresenterStats = ErikaPresenterStatsC() + private var fallbackTimer: DispatchSourceTimer? + private var renderTickQueued = false + private var isAppInBackground = false + private(set) var nowPlayingTitle = "" + private(set) var nowPlayingArtist: String? + private(set) var nowPlayingAlbum: String? + private(set) var nowPlayingArtwork: MPMediaItemArtwork? + private(set) var durationSeconds: Double? + private(set) var positionSeconds = 0.0 + private(set) var playbackRate = 1.0 + private(set) var isPlaying = false + var onNowPlayingChanged: ((ErikaPlayerHost) -> Void)? + + init( + id: Int64, + library: ErikaNativeLibrary, + config: ErikaPresenterConfigC, + hdrDebug: Bool, + allowBackgroundPlayback: Bool + ) throws { + self.id = id + self.library = library + self.hdrDebug = hdrDebug + self.allowBackgroundPlayback = allowBackgroundPlayback + renderQueue = DispatchQueue( + label: "dev.aimesoft.erika.render.ios.\(id)", + qos: .userInteractive + ) + 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() + fallbackTimer?.cancel() + withNativeCall { + _ = library.detachSurface(handle) + library.destroy(handle) + } + } + + private func withNativeCall(_ operation: () throws -> T) rethrows -> T { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + return try operation() + } + + func open(uri: String, httpHeaders: [String: String]) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + isPlaying = false + positionSeconds = 0 + durationSeconds = nil + if nowPlayingTitle.isEmpty { + let fallbackTitle = URL(string: uri)?.lastPathComponent.removingPercentEncoding + ?? URL(fileURLWithPath: uri).lastPathComponent + nowPlayingTitle = fallbackTitle.isEmpty ? "Erika" : fallbackTitle + } + try uri.withCString { cString in + guard !httpHeaders.isEmpty else { + try check(library.open(handle, cString), operation: "open") + 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") + } + } + notifyNowPlayingChanged() + } + + func play() throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try configureAudioSessionForPlayback() + try check(library.play(handle), operation: "play") + isPlaying = true + updateTickDriver() + notifyNowPlayingChanged() + } + func pause() throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try check(library.pause(handle), operation: "pause") + isPlaying = false + updateTickDriver() + notifyNowPlayingChanged() + } + func stop() throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try check(library.stop(handle), operation: "stop") + isPlaying = false + positionSeconds = 0 + updateTickDriver() + notifyNowPlayingChanged() + } + func close() throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try check(library.close(handle), operation: "close") + isPlaying = false + positionSeconds = 0 + durationSeconds = nil + updateTickDriver() + notifyNowPlayingChanged() + } + + func seek(positionMicros: UInt64) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try check(library.seek(handle, positionMicros), operation: "seek") + positionSeconds = Double(positionMicros) / 1_000_000 + notifyNowPlayingChanged() + } + + func setPlaybackRate(_ rate: Double) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + guard let setRate = library.setPlaybackRate else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_playback_rate") + } + try check(setRate(handle, rate), operation: "set_playback_rate") + playbackRate = rate + notifyNowPlayingChanged() + } + + func setMediaMetadata(title: String, artist: String?, album: String?, artworkData: Data?) throws { + let artwork: MPMediaItemArtwork? + if let artworkData { + guard let image = UIImage(data: artworkData) else { + throw ErikaPluginError.invalidArguments("metadata.artwork must contain a supported image.") + } + artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + } else { + artwork = nil + } + nowPlayingTitle = title + nowPlayingArtist = artist + nowPlayingAlbum = album + nowPlayingArtwork = artwork + notifyNowPlayingChanged() + } + + func clearMediaMetadata() { + nowPlayingTitle = "" + nowPlayingArtist = nil + nowPlayingAlbum = nil + nowPlayingArtwork = nil + notifyNowPlayingChanged() + } + + func setAppInBackground(_ value: Bool) { + isAppInBackground = value + updateTickDriver() + } + + func prepareForInactiveApp() { + setAppInBackground(true) + scheduleTick(audioOnly: true) + } + + func didEnterBackground() { + setAppInBackground(true) + if !allowBackgroundPlayback && isPlaying { + try? pause() + } + } + + func setVolume(_ volume: Double) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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] { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 registerSubtitleMemoryFont(_ data: Data) throws -> UInt64 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + guard let register = library.registerSubtitleMemoryFont else { + throw ErikaPluginError.symbolMissing("erika_presenter_register_subtitle_memory_font") + } + var fontId: UInt64 = 0 + let status = data.withUnsafeBytes { bytes in + register(handle, bytes.bindMemory(to: UInt8.self).baseAddress, UInt(data.count), &fontId) + } + try check(status, operation: "register_subtitle_memory_font") + return fontId + } + + func selectSubtitleMemoryFonts(_ fontIds: [UInt64]) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + guard let select = library.selectSubtitleMemoryFonts else { + throw ErikaPluginError.symbolMissing("erika_presenter_select_subtitle_memory_fonts") + } + try fontIds.withUnsafeBufferPointer { buffer in + try check(select(handle, buffer.baseAddress, UInt(buffer.count)), operation: "select_subtitle_memory_fonts") + } + } + + func clearSubtitleMemoryFonts() throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + guard let clear = library.clearSubtitleMemoryFonts else { + throw ErikaPluginError.symbolMissing("erika_presenter_clear_subtitle_memory_fonts") + } + try check(clear(handle), operation: "clear_subtitle_memory_fonts") + } + + func subtitleMemoryFontStatus() throws -> [String: Any] { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + guard let getStatus = library.getSubtitleMemoryFontStatus else { + throw ErikaPluginError.symbolMissing("erika_presenter_get_subtitle_memory_font_status") + } + var status = ErikaSubtitleMemoryFontStatusC() + defer { + if let freeStatus = library.freeSubtitleMemoryFontStatus { + withUnsafeMutablePointer(to: &status) { freeStatus(UnsafeMutableRawPointer($0)) } + } + } + try withUnsafeMutablePointer(to: &status) { pointer in + try check(getStatus(handle, UnsafeMutableRawPointer(pointer)), operation: "get_subtitle_memory_font_status") + } + return [ + "registeredCount": Int(status.registeredCount), + "registeredBytes": Int(status.registeredBytes), + "selectedCount": Int(status.selectedCount), + "generation": Int64(clamping: status.generation), + "selectedIds": status.selectedIds.map { pointer in + (0.. [String: Any] { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 resourceStatus() throws -> [String: Any] { + guard let getStatus = library.getResourceStatus else { + throw ErikaPluginError.symbolMissing("erika_presenter_get_resource_status") + } + var status = ErikaPresenterResourceStatusC() + let result = withNativeCall { + withUnsafeMutablePointer(to: &status) { pointer in + getStatus(handle, UnsafeMutableRawPointer(pointer)) + } + } + try check(result, operation: "get_resource_status") + return status.toFlutterMap() + } + + func presenterStats() -> [String: Any] { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + return latestPresenterStats.toFlutterMap() + } + + func addExternalSubtitle(uri: String) throws -> Int64 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try check(library.removeSubtitleTrack(handle, trackId), operation: "remove_subtitle_track") + } + + func loadDanmakuFile(uri: String) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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]] { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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() { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try check(library.selectAudioTrack(handle, trackId ?? -1), operation: "select_audio_track") + } + + func selectSubtitleTrack(trackId: Int64?) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try check(library.selectSubtitleTrack(handle, trackId ?? -1), operation: "select_subtitle_track") + } + + func tracks() throws -> [[String: Any]] { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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] { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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? { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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() + updateTickDriver() + } + + func detach(viewId: Int64?) { + guard viewId == nil || attachedView?.platformViewId == viewId else { return } + attachedView?.attachedPlayerId = nil + attachedView = nil + displayLink?.invalidate() + displayLink = nil + displayLinkProxy = nil + withNativeCall { + _ = library.detachSurface(handle) + } + updateTickDriver() + } + + func resizeFromAttachedView() { + guard let view = attachedView else { return } + do { + try attachOrResize(view: view, attach: false) + } catch { + NSLog("ErikaFlutterPlugin: resize failed: \(error)") + } + } + + func renderTick() { + if !loggedRenderThread { + loggedRenderThread = true + erikaHdrLog( + hdrDebug, + "render driver player=\(id) mainThread=\(Thread.isMainThread)" + ) + } + var stats = ErikaPresenterStatsC() + let status = withNativeCall { + let timeSeconds = CACurrentMediaTime() - startTimeSeconds + let status = withUnsafeMutablePointer(to: &stats) { pointer in + library.renderTick(handle, timeSeconds, UnsafeMutableRawPointer(pointer)) + } + if status == 0 { + latestPresenterStats = stats + } + return status + } + if status != 0 { + NSLog("ErikaFlutterPlugin: render_tick failed with status \(status)") + } + if hdrDebug && stats.renderedVideoFrames > 0 { + let statsSnapshot = stats + DispatchQueue.main.async { [weak self] in + self?.logFirstRenderedVideoFrameIfNeeded(statsSnapshot) + } + } + } + + func audioOnlyTick() { + var stats = ErikaPresenterStatsC() + let status = withNativeCall { + let status = withUnsafeMutablePointer(to: &stats) { pointer in + library.audioOnlyTick(handle, UnsafeMutableRawPointer(pointer)) + } + if status == 0 { + latestPresenterStats = stats + } + return status + } + if status != 0 { + NSLog("ErikaFlutterPlugin: audio_only_tick failed with status \(status)") + } + } + + func pollEvents(sendEvent: (([String: Any]) -> Void)?) { + withNativeCall { + while true { + var event = ErikaEventC() + let status = withUnsafeMutablePointer(to: &event) { pointer in + library.pollEvent(handle, UnsafeMutableRawPointer(pointer)) + } + if status == 0 { + if event.durationMicros >= 0 { + durationSeconds = Double(event.durationMicros) / 1_000_000 + } + if event.kind == 3 { + positionSeconds = Double(event.positionMicros) / 1_000_000 + } + if event.kind == 1 { + isPlaying = event.state == 3 + updateTickDriver() + } + if event.kind == 1 || event.kind == 2 || event.kind == 3 { + notifyNowPlayingChanged() + } + if event.kind == 6 { + erikaHdrLog( + hdrDebug, + "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 scheduleTick(audioOnly: Bool = false) { + renderSubmissionLock.lock() + guard !renderTickQueued else { + renderSubmissionLock.unlock() + return + } + renderTickQueued = true + renderSubmissionLock.unlock() + + renderQueue.async { [weak self] in + guard let self else { return } + if audioOnly { + self.audioOnlyTick() + } else { + self.renderTick() + } + self.renderSubmissionLock.lock() + self.renderTickQueued = false + self.renderSubmissionLock.unlock() + } + } + + private func logFirstRenderedVideoFrameIfNeeded(_ stats: ErikaPresenterStatsC) { + guard hdrDebug, !loggedFirstRenderedVideoFrame else { return } + 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)" + ) + } + + private func attachOrResize(view: ErikaMetalSurfaceView, attach: Bool) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + if attach || presenterConfig.outputMode != 3 { + 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, attachedView != nil, !isAppInBackground else { return } + withNativeCall { + startTimeSeconds = CACurrentMediaTime() + } + let proxy = DisplayLinkProxy { [weak self] in + self?.scheduleTick() + } + let link = CADisplayLink(target: proxy, selector: #selector(DisplayLinkProxy.tick)) + link.preferredFramesPerSecond = resolvedDisplayLinkFps() + link.add(to: .main, forMode: .common) + displayLinkProxy = proxy + displayLink = link + } + + private func updateTickDriver() { + if isAppInBackground || (attachedView == nil && isPlaying) { + displayLink?.invalidate() + displayLink = nil + displayLinkProxy = nil + startFallbackTimerIfNeeded() + } else { + fallbackTimer?.cancel() + fallbackTimer = nil + startDisplayLinkIfNeeded() + } + } + + private func startFallbackTimerIfNeeded() { + guard fallbackTimer == nil, isPlaying else { return } + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now(), repeating: .milliseconds(16), leeway: .milliseconds(2)) + timer.setEventHandler { [weak self] in + guard let self else { return } + if self.isAppInBackground { + self.scheduleTick(audioOnly: true) + } else { + self.scheduleTick() + } + } + fallbackTimer = timer + timer.resume() + } + + private func notifyNowPlayingChanged() { + onNowPlayingChanged?(self) + } + + private func resolvedDisplayLinkFps() -> Int { + if let override = ProcessInfo.processInfo.environment["ERIKA_FLUTTER_TARGET_FPS"], + let fps = Int(override), fps > 0 { + 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) + } + } + + 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? + private var activePlayerId: Int64? + private var interruptedPlayerId: Int64? + private var interruptionResumeWorkItem: DispatchWorkItem? + private var notificationObservers: [NSObjectProtocol] = [] + private var remoteCommandTargets: [(MPRemoteCommand, Any)] = [] + private var systemMediaNavigation: [Int64: (previousEnabled: Bool, nextEnabled: Bool)] = [:] + + deinit { + interruptionResumeWorkItem?.cancel() + notificationObservers.forEach(NotificationCenter.default.removeObserver) + remoteCommandTargets.forEach { command, target in + command.removeTarget(target) + } + } + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = ErikaFlutterPlugin() + instance.configureSystemPlayback() + let playerChannel = FlutterMethodChannel(name: playerChannelName, binaryMessenger: registrar.messenger()) + let eventsChannel = FlutterEventChannel(name: eventsChannelName, binaryMessenger: registrar.messenger()) + registrar.addMethodCallDelegate(instance, channel: playerChannel) + 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) + systemMediaNavigation.removeValue(forKey: playerId) + cancelPendingInterruptionResume(ifPlayer: playerId) + if activePlayerId == playerId { + activePlayerId = nil + clearNowPlayingInfo() + refreshRemoteCommands() + } + 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]) ?? [:] + if let metadata = args["metadata"] as? [String: Any] { + try applyMediaMetadata(metadata, to: host) + } else { + host.clearMediaMetadata() + } + try host.open(uri: uri, httpHeaders: headers) + result(nil) + case "play": + let host = try playerHost(from: try dictionaryArgs(call.arguments)) + try host.play() + activePlayerId = host.id + refreshRemoteCommands() + updateNowPlayingInfo(for: host) + result(nil) + case "pause": + let host = try playerHost(from: try dictionaryArgs(call.arguments)) + // Drop a pending interruption resume first: it is scheduled on the main + // queue and would otherwise fire after this pause and play again. + cancelPendingInterruptionResume(ifPlayer: host.id) + try host.pause() + result(nil) + case "stop": + let host = try playerHost(from: try dictionaryArgs(call.arguments)) + cancelPendingInterruptionResume(ifPlayer: host.id) + try host.stop() + result(nil) + case "close": + let host = try playerHost(from: try dictionaryArgs(call.arguments)) + cancelPendingInterruptionResume(ifPlayer: host.id) + try host.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 "setMediaMetadata": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let metadata = args["metadata"] as? [String: Any] else { + throw ErikaPluginError.invalidArguments("metadata is required.") + } + try applyMediaMetadata(metadata, to: host) + result(nil) + case "setSystemMediaNavigation": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + systemMediaNavigation[host.id] = ( + previousEnabled: boolValue(args["previousEnabled"]) ?? false, + nextEnabled: boolValue(args["nextEnabled"]) ?? false + ) + if activePlayerId == host.id { + refreshRemoteCommands() + } + result(nil) + case "setVolume": + let args = try dictionaryArgs(call.arguments) + guard let volume = doubleValue(args["volume"]) else { + 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 "registerSubtitleMemoryFont": + let args = try dictionaryArgs(call.arguments) + guard let data = args["data"] as? FlutterStandardTypedData else { + throw ErikaPluginError.invalidArguments("data is required.") + } + result(Int64(clamping: try playerHost(from: args).registerSubtitleMemoryFont(data.data))) + case "selectSubtitleMemoryFonts": + let args = try dictionaryArgs(call.arguments) + let ids = (args["fontIds"] as? [NSNumber] ?? []).map { $0.uint64Value } + try playerHost(from: args).selectSubtitleMemoryFonts(ids) + result(nil) + case "clearSubtitleMemoryFonts": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).clearSubtitleMemoryFonts() + result(nil) + case "getSubtitleMemoryFontStatus": + let args = try dictionaryArgs(call.arguments) + result(try playerHost(from: args).subtitleMemoryFontStatus()) + 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 "getResourceStatus": + let args = try dictionaryArgs(call.arguments) + result(try playerHost(from: args).resourceStatus()) + 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 + let host = try ErikaPlayerHost( + id: id, + library: library, + config: config, + hdrDebug: hdrDebug, + allowBackgroundPlayback: boolValue(args?["allowBackgroundPlayback"]) ?? false + ) + host.onNowPlayingChanged = { [weak self] changedHost in + guard self?.activePlayerId == changedHost.id else { return } + self?.updateNowPlayingInfo(for: changedHost) + } + players[id] = host + systemMediaNavigation[id] = (previousEnabled: false, nextEnabled: false) + startPollTimerIfNeeded() + return id + } + + private func configureSystemPlayback() { + let center = NotificationCenter.default + notificationObservers.append(center.addObserver( + forName: UIApplication.willResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.players.values.forEach { $0.prepareForInactiveApp() } + }) + notificationObservers.append(center.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.players.values.forEach { $0.didEnterBackground() } + }) + notificationObservers.append(center.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.players.values.forEach { $0.setAppInBackground(false) } + }) + notificationObservers.append(center.addObserver( + forName: AVAudioSession.interruptionNotification, + object: AVAudioSession.sharedInstance(), + queue: .main + ) { [weak self] notification in + self?.handleAudioInterruption(notification) + }) + + let commands = MPRemoteCommandCenter.shared() + addRemoteTarget(commands.playCommand) { [weak self] _ in self?.performRemotePlay() ?? .commandFailed } + addRemoteTarget(commands.pauseCommand) { [weak self] _ in self?.performRemotePause() ?? .commandFailed } + addRemoteTarget(commands.togglePlayPauseCommand) { [weak self] _ in self?.performRemoteToggle() ?? .commandFailed } + addRemoteTarget(commands.changePlaybackPositionCommand) { [weak self] event in + guard let positionEvent = event as? MPChangePlaybackPositionCommandEvent else { + return .commandFailed + } + return self?.performRemoteSeek(positionEvent.positionTime) ?? .commandFailed + } + addRemoteTarget(commands.previousTrackCommand) { [weak self] _ in + self?.emitSystemMediaNavigation("previous") ?? .commandFailed + } + addRemoteTarget(commands.nextTrackCommand) { [weak self] _ in + self?.emitSystemMediaNavigation("next") ?? .commandFailed + } + refreshRemoteCommands() + } + + private func addRemoteTarget( + _ command: MPRemoteCommand, + handler: @escaping (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus + ) { + let target = command.addTarget(handler: handler) + remoteCommandTargets.append((command, target)) + } + + private func applyMediaMetadata(_ metadata: [String: Any], to host: ErikaPlayerHost) throws { + guard let title = metadata["title"] as? String, !title.isEmpty else { + throw ErikaPluginError.invalidArguments("metadata.title is required.") + } + try host.setMediaMetadata( + title: title, + artist: metadata["artist"] as? String, + album: metadata["album"] as? String, + artworkData: (metadata["artwork"] as? FlutterStandardTypedData)?.data + ) + } + + private func updateNowPlayingInfo(for host: ErikaPlayerHost) { + var info: [String: Any] = [ + MPMediaItemPropertyTitle: host.nowPlayingTitle, + MPNowPlayingInfoPropertyElapsedPlaybackTime: host.positionSeconds, + MPNowPlayingInfoPropertyPlaybackRate: host.isPlaying ? host.playbackRate : 0, + MPNowPlayingInfoPropertyDefaultPlaybackRate: host.playbackRate, + MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.video.rawValue, + ] + if let artist = host.nowPlayingArtist { info[MPMediaItemPropertyArtist] = artist } + if let album = host.nowPlayingAlbum { info[MPMediaItemPropertyAlbumTitle] = album } + if let artwork = host.nowPlayingArtwork { info[MPMediaItemPropertyArtwork] = artwork } + if let duration = host.durationSeconds { info[MPMediaItemPropertyPlaybackDuration] = duration } + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = info + center.playbackState = host.isPlaying ? .playing : .paused + } + + private func clearNowPlayingInfo() { + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = nil + center.playbackState = .stopped + } + + private func performRemotePlay() -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + do { + try host.play() + return .success + } catch { + return .commandFailed + } + } + + private func performRemotePause() -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + cancelPendingInterruptionResume(ifPlayer: host.id) + do { + try host.pause() + return .success + } catch { + return .commandFailed + } + } + + private func performRemoteToggle() -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + return host.isPlaying ? performRemotePause() : performRemotePlay() + } + + private func performRemoteSeek(_ position: TimeInterval) -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + do { + try host.seek(positionMicros: UInt64(max(0, position) * 1_000_000)) + return .success + } catch { + return .commandFailed + } + } + + private func emitSystemMediaNavigation(_ navigation: String) -> MPRemoteCommandHandlerStatus { + performOnMain { + guard let playerId = self.activePlayerId, + self.players[playerId] != nil, + let capabilities = self.systemMediaNavigation[playerId] else { return .noSuchContent } + let enabled = navigation == "previous" + ? capabilities.previousEnabled + : capabilities.nextEnabled + guard enabled else { return .noSuchContent } + Self.sharedEventSink?([ + "playerId": playerId, + "kind": 13, + "navigation": navigation, + ]) + return .success + } + } + + private func performOnMain( + _ work: @escaping () -> MPRemoteCommandHandlerStatus + ) -> MPRemoteCommandHandlerStatus { + if Thread.isMainThread { + return work() + } + return DispatchQueue.main.sync(execute: work) + } + + private func refreshRemoteCommands() { + let commands = MPRemoteCommandCenter.shared() + let enabled = activePlayerId.flatMap { players[$0] } != nil + remoteCommandTargets.forEach { command, _ in + command.isEnabled = enabled + } + let capabilities = activePlayerId.flatMap { systemMediaNavigation[$0] } + commands.previousTrackCommand.isEnabled = enabled && capabilities?.previousEnabled == true + commands.nextTrackCommand.isEnabled = enabled && capabilities?.nextEnabled == true + } + + private func handleAudioInterruption(_ notification: Notification) { + guard let rawType = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: rawType), + let host = activePlayerId.flatMap({ players[$0] }) else { return } + if type == .began { + let wasPlaying = host.isPlaying + cancelPendingInterruptionResume() + interruptedPlayerId = wasPlaying ? host.id : nil + if wasPlaying { + do { + try host.pause() + } catch { + NSLog("ErikaFlutterPlugin: audio interruption pause failed: \(error)") + } + } + return + } + guard let rawOptions = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt, + AVAudioSession.InterruptionOptions(rawValue: rawOptions).contains(.shouldResume), + interruptedPlayerId == host.id else { + cancelPendingInterruptionResume() + return + } + resumeInterruptedPlayback(host, attempt: 0) + } + + /// Drops a scheduled interruption resume, whichever player it targets. + private func cancelPendingInterruptionResume() { + interruptionResumeWorkItem?.cancel() + interruptionResumeWorkItem = nil + interruptedPlayerId = nil + } + + /// Drops a scheduled interruption resume only when it targets `playerId`, so + /// an explicit pause/stop/close on one player leaves another player's + /// pending resume alone. + private func cancelPendingInterruptionResume(ifPlayer playerId: Int64) { + guard interruptedPlayerId == playerId else { return } + cancelPendingInterruptionResume() + } + + private func resumeInterruptedPlayback(_ host: ErikaPlayerHost, attempt: Int) { + // A retry can fire after the active player changed. Drop the recovery + // instead of returning with `interruptedPlayerId` still set, which would + // leave a resume owed to a player that will never be resumed. + guard interruptedPlayerId == host.id, activePlayerId == host.id else { + cancelPendingInterruptionResume(ifPlayer: host.id) + return + } + do { + // ErikaPlayerHost.play() configures the playback category and calls + // setActive(true) before sending the native play command. + try host.play() + cancelPendingInterruptionResume() + } catch { + let maxAttempts = 3 + guard attempt < maxAttempts else { + cancelPendingInterruptionResume() + NSLog("ErikaFlutterPlugin: audio interruption resume failed after \(maxAttempts + 1) attempts: \(error)") + return + } + NSLog("ErikaFlutterPlugin: audio interruption resume attempt \(attempt + 1) failed: \(error)") + let workItem = DispatchWorkItem { [weak self, weak host] in + guard let self, let host else { return } + self.interruptionResumeWorkItem = nil + self.resumeInterruptedPlayback(host, attempt: attempt + 1) + } + interruptionResumeWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + .milliseconds(100), + execute: workItem + ) + } + } + + 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: ErikaPresenterConfigC + switch explicitMode { + case 1: + config = .appleEdr(headroom: headroom) + case 2: + config = ErikaPresenterConfigC(outputMode: 2, edrHeadroom: max(1.0, headroom)) + case 3: + config = .auto(headroom: headroom) + default: + config = .sdr + } + erikaHdrLog( + hdrDebug, + "create explicit outputMode=\(explicitMode) requestedHeadroom=\(String(format: "%.3f", headroom)) selected=\(erikaOutputModeLabel(config))" + ) + return config + } + let headroom = resolvedEdrHeadroom(hdrDebug: hdrDebug) + let config = ErikaPresenterConfigC.auto(headroom: headroom) + 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.05, 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 ErikaPresenterResourceStatusC { + func toFlutterMap() -> [String: Any] { + [ + "deviceCurrentAllocatedBytes": Int64(clamping: deviceCurrentAllocatedBytes), + "deviceRecommendedWorkingSetBytes": Int64(clamping: deviceRecommendedWorkingSetBytes), + "drawableEstimatedBytes": Int64(clamping: drawableEstimatedBytes), + "videoFrameBytes": Int64(clamping: videoFrameBytes), + "overlayAtlasBytes": Int64(clamping: overlayAtlasBytes), + "danmakuAtlasBytes": Int64(clamping: danmakuAtlasBytes), + "danmakuVertexBufferBytes": Int64(clamping: danmakuVertexBufferBytes), + "upscalerBytes": Int64(clamping: upscalerBytes), + "rendererTrackedBytes": Int64(clamping: rendererTrackedBytes), + "presenterCpuDanmakuAtlasBytes": Int64(clamping: presenterCpuDanmakuAtlasBytes), + "drawableCount": Int(drawableCount), + "outputModeSwitches": Int64(clamping: outputModeSwitches), + ] + } +} + +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/third_party/erika_flutter/ios/erika_flutter.podspec b/third_party/erika_flutter/ios/erika_flutter.podspec new file mode 100644 index 00000000..f3571437 --- /dev/null +++ b/third_party/erika_flutter/ios/erika_flutter.podspec @@ -0,0 +1,182 @@ +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_register_subtitle_memory_font + erika_presenter_render_tick + erika_presenter_resize_surface + erika_presenter_seek + erika_presenter_select_audio_track + erika_presenter_select_subtitle_track + erika_presenter_select_subtitle_memory_fonts + erika_presenter_clear_subtitle_memory_fonts + erika_presenter_get_subtitle_memory_font_status + 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_subtitle_memory_font_status_free + ] + erika_cabi_undefined_flags = erika_cabi_symbols + .map { |symbol| "-Wl,-u,_#{symbol}" } + .join(' ') + + s.name = 'erika_flutter' + s.version = '0.1.7' + s.summary = 'Flutter embedder glue for the Erika Rust media engine.' + s.description = <<-DESC +Flutter iOS 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/**/*' + s.dependency 'Flutter' + s.platform = :ios, '13.0' + s.swift_version = '5.0' + 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_IOS_DIR="$(cd "$PODS_TARGET_SRCROOT" && pwd -P)" +PACKAGE_ROOT="$(cd "$PLUGIN_IOS_DIR/.." && pwd -P)" +OUTPUT_LIB="$PODS_TARGET_SRCROOT/native/liberika_capi.a" +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:-iphoneos}" in + iphoneos) + RUST_TARGET="aarch64-apple-ios" + BINDGEN_CLANG_TARGET="arm64-apple-ios" + BINDGEN_SDK="iphoneos" + ;; + iphonesimulator) + if [ "$ARCH" = "x86_64" ]; then + RUST_TARGET="x86_64-apple-ios" + BINDGEN_CLANG_TARGET="x86_64-apple-ios-simulator" + else + RUST_TARGET="aarch64-apple-ios-sim" + BINDGEN_CLANG_TARGET="arm64-apple-ios-simulator" + fi + BINDGEN_SDK="iphonesimulator" + ;; + *) + echo "error: unsupported Erika iOS platform: ${PLATFORM_NAME:-unknown}" >&2 + exit 1 + ;; +esac + +if [ -n "${ERIKA_IOS_CAPI_PROFILE:-}" ]; then + CARGO_PROFILE="$ERIKA_IOS_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_IOS_CAPI_PROFILE=$CARGO_PROFILE" >&2 + exit 1 +fi + +mkdir -p "$PODS_TARGET_SRCROOT/native" +if [ -n "${ERIKA_IOS_CAPI_STATICLIB:-}" ]; then + cp "$ERIKA_IOS_CAPI_STATICLIB" "$OUTPUT_LIB" +elif [ "${ERIKA_FORCE_SOURCE_BUILD:-0}" != "1" ]; then + sh "$PACKAGE_ROOT/native/prepare_apple_prebuilt.sh" ios "${PLATFORM_NAME:-iphoneos}" "$ARCH" "$OUTPUT_LIB" +else + if [ -n "${ERIKA_REPO_ROOT:-}" ]; then + SOURCE_ROOT="$ERIKA_REPO_ROOT" + elif [ -n "${ERIKA_ROOT:-}" ]; then + SOURCE_ROOT="$ERIKA_ROOT" + else + SOURCE_ROOT="$(cd "$PACKAGE_ROOT/../.." && pwd -P)" + fi + if [ ! -f "$SOURCE_ROOT/crates/erika_capi/Cargo.toml" ]; then + echo "error: ERIKA_FORCE_SOURCE_BUILD=1 requires an Erika checkout; set ERIKA_REPO_ROOT" >&2 + exit 1 + fi + if command -v rustup >/dev/null 2>&1; then + rustup target add "$RUST_TARGET" + fi + 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" + + ERIKA_TARGET_DIST="$SOURCE_ROOT/third_party/dist/$RUST_TARGET/$ERIKA_NATIVE_PROFILE" + ERIKA_FFMPEG_DIR="${ERIKA_FFMPEG_DIR:-$ERIKA_TARGET_DIST/ffmpeg}" + 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="$SOURCE_ROOT/third_party/build/$RUST_TARGET/$ERIKA_NATIVE_PROFILE/dav1d/dav1d-built.txt" + + 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 + (cd "$SOURCE_ROOT" && cargo run -p xtask -- deps build --all --profile "$ERIKA_NATIVE_PROFILE" --target "$RUST_TARGET" --jobs "$HOST_JOBS") + fi + (cd "$SOURCE_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 rustc -p erika_capi --target "$RUST_TARGET" --no-default-features --features libass $CARGO_ARGS --lib --crate-type staticlib) + cp "$SOURCE_ROOT/target/$RUST_TARGET/$CARGO_PROFILE/liberika_capi.a" "$OUTPUT_LIB" +fi + +if [ ! -f "$OUTPUT_LIB" ]; then + echo "error: Erika C ABI static library not found: $OUTPUT_LIB" >&2 + exit 1 +fi +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=iphonesimulator*]' => '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/third_party/erika_flutter/lib/erika_flutter.dart b/third_party/erika_flutter/lib/erika_flutter.dart new file mode 100644 index 00000000..166b8b4f --- /dev/null +++ b/third_party/erika_flutter/lib/erika_flutter.dart @@ -0,0 +1,5 @@ +library erika_flutter; + +export 'src/erika_event.dart'; +export 'src/erika_player.dart'; +export 'src/erika_video_view.dart'; diff --git a/third_party/erika_flutter/lib/src/erika_event.dart b/third_party/erika_flutter/lib/src/erika_event.dart new file mode 100644 index 00000000..b92f5e2d --- /dev/null +++ b/third_party/erika_flutter/lib/src/erika_event.dart @@ -0,0 +1,473 @@ +enum ErikaPlaybackState { + idle, + opening, + ready, + playing, + paused, + stopped, + closed, + error, +} + +enum ErikaEventKind { + none, + stateChanged, + durationChanged, + positionChanged, + tracksChanged, + bufferingChanged, + videoParamsChanged, + surfaceAttached, + surfaceDetached, + error, + trackSelectionChanged, + videoDecoderChanged, + audioOutputChanged, + systemMediaNavigationRequested, +} + +enum ErikaSystemMediaCommand { + previous, + next, +} + +enum ErikaTrackKind { + video, + audio, + subtitle, +} + +enum ErikaTrackSource { + embedded, + external, +} + +class ErikaVideoParams { + const ErikaVideoParams({ + required this.width, + required this.height, + required this.primaries, + required this.transfer, + }); + + factory ErikaVideoParams.fromMap(Map? map) { + return ErikaVideoParams( + width: _asInt(map?['width']), + height: _asInt(map?['height']), + primaries: _asInt(map?['primaries']), + transfer: _asInt(map?['transfer']), + ); + } + + final int width; + final int height; + final int primaries; + final int transfer; + + static int _asInt(Object? value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return 0; + } +} + +class ErikaVideoDecoderInfo { + const ErikaVideoDecoderInfo({ + required this.stage, + required this.requestedBackend, + required this.activeBackend, + required this.fallbackCount, + this.previousBackend, + this.codec, + this.pixelFormat, + this.lineSizes = const [], + this.reason, + }); + + factory ErikaVideoDecoderInfo.fromMap(Map map) { + return ErikaVideoDecoderInfo( + stage: map['stage'] as String? ?? '', + requestedBackend: map['requestedBackend'] as String? ?? '', + previousBackend: map['previousBackend'] as String?, + activeBackend: map['activeBackend'] as String? ?? '', + fallbackCount: _asInt(map['fallbackCount']), + codec: map['codec'] as String?, + pixelFormat: map['pixelFormat'] as String?, + lineSizes: switch (map['lineSizes']) { + final List values => values + .whereType() + .map((value) => value.toInt()) + .toList(growable: false), + _ => const [], + }, + reason: map['reason'] as String?, + ); + } + + final String stage; + final String requestedBackend; + final String? previousBackend; + final String activeBackend; + final int fallbackCount; + final String? codec; + final String? pixelFormat; + final List lineSizes; + final String? reason; + + static int _asInt(Object? value) { + if (value is num) { + return value.toInt(); + } + return 0; + } +} + +class ErikaAudioOutputInfo { + const ErikaAudioOutputInfo({ + required this.recoveryState, + required this.lastErrorCode, + required this.recoveryAttempts, + required this.recoveryCount, + required this.recoveryFailures, + required this.transitionSequence, + }); + + factory ErikaAudioOutputInfo.fromMap(Map map) { + return ErikaAudioOutputInfo( + recoveryState: map['recoveryState'] as String? ?? 'unknown', + lastErrorCode: _asInt(map['lastErrorCode']), + recoveryAttempts: _asInt(map['recoveryAttempts']), + recoveryCount: _asInt(map['recoveryCount']), + recoveryFailures: _asInt(map['recoveryFailures']), + transitionSequence: _asInt(map['transitionSequence']), + ); + } + + final String recoveryState; + final int lastErrorCode; + final int recoveryAttempts; + final int recoveryCount; + final int recoveryFailures; + final int transitionSequence; + + bool get isStable => recoveryState == 'stable'; + bool get isDisconnected => recoveryState == 'disconnected'; + bool get isRecovering => recoveryState == 'recovering'; + bool get isFailed => recoveryState == 'failed'; + + static int _asInt(Object? value) { + if (value is num) { + return value.toInt(); + } + return 0; + } +} + +class ErikaTrackCounts { + const ErikaTrackCounts({ + required this.video, + required this.audio, + required this.subtitle, + }); + + factory ErikaTrackCounts.fromMap(Map? map) { + return ErikaTrackCounts( + video: _asInt(map?['video']), + audio: _asInt(map?['audio']), + subtitle: _asInt(map?['subtitle']), + ); + } + + final int video; + final int audio; + final int subtitle; + + static int _asInt(Object? value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return 0; + } +} + +class ErikaTrackSelection { + const ErikaTrackSelection({ + this.video, + this.audio, + this.subtitle, + }); + + factory ErikaTrackSelection.fromMap(Map? map) { + return ErikaTrackSelection( + video: _trackId(map?['video']), + audio: _trackId(map?['audio']), + subtitle: _trackId(map?['subtitle']), + ); + } + + final int? video; + final int? audio; + final int? subtitle; + + static int? _trackId(Object? value) { + final id = _asInt(value); + return id >= 0 ? id : null; + } + + static int _asInt(Object? value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return -1; + } +} + +class ErikaTrackInfo { + const ErikaTrackInfo({ + required this.id, + required this.kind, + required this.source, + required this.selected, + required this.canRemove, + this.title, + this.language, + this.codec, + this.width = 0, + this.height = 0, + this.sampleRate = 0, + this.channels = 0, + this.pixelFormat, + this.sampleFormat, + this.profile, + this.level = 0, + this.bitRate, + this.frameRateNumerator = 0, + this.frameRateDenominator = 0, + }); + + factory ErikaTrackInfo.fromMap(Map map) { + return ErikaTrackInfo( + id: _asInt(map['id']), + kind: _trackKindFromIndex(_asInt(map['kind'])), + source: _trackSourceFromIndex(_asInt(map['source'])), + selected: map['selected'] == true, + canRemove: map['canRemove'] == true, + title: map['title'] as String?, + language: map['language'] as String?, + codec: map['codec'] as String?, + width: _asInt(map['width']), + height: _asInt(map['height']), + sampleRate: _asInt(map['sampleRate']), + channels: _asInt(map['channels']), + pixelFormat: map['pixelFormat'] as String?, + sampleFormat: map['sampleFormat'] as String?, + profile: map['profile'] as String?, + level: _asInt(map['level']), + bitRate: _asPositiveInt(map['bitRate']), + frameRateNumerator: _asInt(map['frameRateNumerator']), + frameRateDenominator: _asInt(map['frameRateDenominator']), + ); + } + + final int id; + final ErikaTrackKind kind; + final ErikaTrackSource source; + final bool selected; + final bool canRemove; + final String? title; + final String? language; + final String? codec; + final int width; + final int height; + final int sampleRate; + final int channels; + final String? pixelFormat; + final String? sampleFormat; + final String? profile; + final int level; + final int? bitRate; + final int frameRateNumerator; + final int frameRateDenominator; + + double? get framesPerSecond { + if (frameRateNumerator <= 0 || frameRateDenominator <= 0) { + return null; + } + return frameRateNumerator / frameRateDenominator; + } + + Map toMap() { + return { + 'id': id, + 'kind': kind.name, + 'source': source.name, + 'selected': selected, + 'canRemove': canRemove, + 'title': title, + 'language': language, + 'codec': codec, + 'width': width, + 'height': height, + 'sampleRate': sampleRate, + 'channels': channels, + 'pixelFormat': pixelFormat, + 'sampleFormat': sampleFormat, + 'profile': profile, + 'level': level, + 'bitRate': bitRate, + 'frameRateNumerator': frameRateNumerator, + 'frameRateDenominator': frameRateDenominator, + }; + } + + bool get isEmbedded => source == ErikaTrackSource.embedded; + bool get isExternal => source == ErikaTrackSource.external; + + static int _asInt(Object? value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return 0; + } + + static int? _asPositiveInt(Object? value) { + final parsed = _asInt(value); + return parsed > 0 ? parsed : null; + } + + static ErikaTrackKind _trackKindFromIndex(int index) { + if (index >= 0 && index < ErikaTrackKind.values.length) { + return ErikaTrackKind.values[index]; + } + return ErikaTrackKind.video; + } + + static ErikaTrackSource _trackSourceFromIndex(int index) { + if (index >= 0 && index < ErikaTrackSource.values.length) { + return ErikaTrackSource.values[index]; + } + return ErikaTrackSource.embedded; + } +} + +class ErikaPlayerEvent { + const ErikaPlayerEvent({ + required this.playerId, + required this.kind, + required this.state, + required this.duration, + required this.position, + required this.buffering, + required this.video, + required this.tracks, + required this.trackList, + required this.trackSelection, + this.status = 0, + this.error, + this.message, + this.decoder, + this.audio, + this.systemMediaCommand, + }); + + factory ErikaPlayerEvent.fromMap(Map map) { + return ErikaPlayerEvent( + playerId: _asInt(map['playerId']), + kind: _eventKindFromIndex(_asInt(map['kind'])), + state: _stateFromIndex(_asInt(map['state'])), + duration: Duration(microseconds: _asInt(map['durationMicros'])), + position: Duration(microseconds: _asInt(map['positionMicros'])), + buffering: map['buffering'] == true, + video: ErikaVideoParams.fromMap(map['video'] as Map?), + tracks: ErikaTrackCounts.fromMap( + map['tracks'] as Map?, + ), + trackList: _trackListFromValue(map['trackList']), + trackSelection: ErikaTrackSelection.fromMap( + map['trackSelection'] as Map?, + ), + status: _asInt(map['status']), + error: map['error'] as String?, + message: map['message'] as String?, + decoder: switch (map['decoder']) { + final Map value => + ErikaVideoDecoderInfo.fromMap(value), + _ => null, + }, + audio: switch (map['audio']) { + final Map value => + ErikaAudioOutputInfo.fromMap(value), + _ => null, + }, + systemMediaCommand: switch (map['navigation']) { + 'previous' => ErikaSystemMediaCommand.previous, + 'next' => ErikaSystemMediaCommand.next, + _ => null, + }, + ); + } + + final int playerId; + final ErikaEventKind kind; + final ErikaPlaybackState state; + final Duration duration; + final Duration position; + final bool buffering; + final ErikaVideoParams video; + final ErikaTrackCounts tracks; + final List trackList; + final ErikaTrackSelection trackSelection; + final int status; + final String? error; + final String? message; + final ErikaVideoDecoderInfo? decoder; + final ErikaAudioOutputInfo? audio; + final ErikaSystemMediaCommand? systemMediaCommand; + + static int _asInt(Object? value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return 0; + } + + static ErikaEventKind _eventKindFromIndex(int index) { + if (index >= 0 && index < ErikaEventKind.values.length) { + return ErikaEventKind.values[index]; + } + return ErikaEventKind.none; + } + + static ErikaPlaybackState _stateFromIndex(int index) { + if (index >= 0 && index < ErikaPlaybackState.values.length) { + return ErikaPlaybackState.values[index]; + } + return ErikaPlaybackState.error; + } + + static List _trackListFromValue(Object? value) { + if (value is! List) { + return const []; + } + return value + .whereType>() + .map(ErikaTrackInfo.fromMap) + .toList(growable: false); + } +} diff --git a/third_party/erika_flutter/lib/src/erika_player.dart b/third_party/erika_flutter/lib/src/erika_player.dart new file mode 100644 index 00000000..4823133c --- /dev/null +++ b/third_party/erika_flutter/lib/src/erika_player.dart @@ -0,0 +1,1739 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'erika_event.dart'; + +@immutable +class ErikaMediaMetadata { + const ErikaMediaMetadata({ + required this.title, + this.artist, + this.album, + this.artwork, + }); + + final String title; + final String? artist; + final String? album; + final Uint8List? artwork; + + Map toMap() => { + 'title': title, + if (artist != null) 'artist': artist!, + if (album != null) 'album': album!, + if (artwork != null) 'artwork': artwork!, + }; +} + +/// Subtitle text colour Erika falls back to, as `0xRRGGBBAA`: opaque white. +const int kErikaDefaultSubtitlePrimaryColorRgba = 0xFFFFFFFF; + +/// Subtitle outline colour Erika falls back to: half-transparent black. +const int kErikaDefaultSubtitleOutlineColorRgba = 0x0000007F; + +/// Base subtitle font size in ASS script units, before [ErikaPlayer.setSubtitleScale]. +const double kErikaDefaultSubtitleFontSize = 48.0; + +/// Base subtitle outline width in ASS script units, before the subtitle scale. +const double kErikaDefaultSubtitleOutlineWidth = 2.0; + +const int kErikaSubtitleOverrideFontSizeFields = 1 << 2; +const int kErikaSubtitleOverrideFontName = 1 << 3; +const int kErikaSubtitleOverrideColors = 1 << 4; +const int kErikaSubtitleOverrideAttributes = 1 << 5; +const int kErikaSubtitleOverrideBorder = 1 << 6; +const int kErikaSubtitleOverrideAlignment = 1 << 7; +const int kErikaSubtitleOverrideMargins = 1 << 8; +const int kErikaSubtitleOverrideBlur = 1 << 11; +const int kErikaSubtitleOverrideAll = + kErikaSubtitleOverrideFontSizeFields | + kErikaSubtitleOverrideFontName | + kErikaSubtitleOverrideColors | + kErikaSubtitleOverrideAttributes | + kErikaSubtitleOverrideBorder | + kErikaSubtitleOverrideAlignment | + kErikaSubtitleOverrideMargins | + kErikaSubtitleOverrideBlur; + +enum ErikaOutputMode { + sdr(0), + appleEdr(1), + extendedLinear(2), + auto(3); + + const ErikaOutputMode(this.nativeValue); + + final int nativeValue; + + static ErikaOutputMode fromNativeValue(int value) { + return switch (value) { + 1 => ErikaOutputMode.appleEdr, + 2 => ErikaOutputMode.extendedLinear, + 3 => ErikaOutputMode.auto, + _ => ErikaOutputMode.sdr, + }; + } +} + +/// How transparency is encoded in a video frame. +enum ErikaVideoAlphaMode { + /// The decoded video is fully opaque. + opaque(0), + + /// The left half is colour and the right half is a grayscale alpha mask. + /// Erika presents the video at half of its encoded width and reconstructs a + /// premultiplied-alpha frame in the GPU presentation shader. + packedAlphaRight(1); + + const ErikaVideoAlphaMode(this.nativeValue); + + final int nativeValue; +} + +enum ErikaActiveOutputEncoding { + sdrSrgb(0), + appleEdr(1), + androidExtendedLinearScRgb(2), + hdr10Pq(3); + + const ErikaActiveOutputEncoding(this.nativeValue); + + final int nativeValue; + + static ErikaActiveOutputEncoding fromNativeValue(int value) { + return switch (value) { + 1 => ErikaActiveOutputEncoding.appleEdr, + 2 => ErikaActiveOutputEncoding.androidExtendedLinearScRgb, + 3 => ErikaActiveOutputEncoding.hdr10Pq, + _ => ErikaActiveOutputEncoding.sdrSrgb, + }; + } +} + +enum ErikaOutputSurfaceFormat { + eightBitUnorm(0), + tenBitUnorm(1), + sixteenBitFloat(2); + + const ErikaOutputSurfaceFormat(this.nativeValue); + + final int nativeValue; + + static ErikaOutputSurfaceFormat fromNativeValue(int value) { + return switch (value) { + 1 => ErikaOutputSurfaceFormat.tenBitUnorm, + 2 => ErikaOutputSurfaceFormat.sixteenBitFloat, + _ => ErikaOutputSurfaceFormat.eightBitUnorm, + }; + } +} + +enum ErikaOutputFallbackReason { + none(0, 'none'), + displayHdrUnsupported(1, 'display_hdr_unsupported'), + hybridCompositionRequired(2, 'hybrid_composition_required'), + wgpuBackendNotVulkan(3, 'wgpu_backend_not_vulkan'), + rgba16FloatSurfaceFormatUnavailable( + 4, + 'rgba16float_surface_format_unavailable', + ), + nativeWindowDataSpaceApiUnavailable( + 5, + 'native_window_dataspace_api_unavailable', + ), + scrgbDataSpaceVerificationFailed(6, 'scrgb_dataspace_verification_failed'), + surfaceConfigureFailed(7, 'surface_configure_failed'), + legacyAppleEdrUnsupported(8, 'legacy_apple_edr_unsupported'), + unknown(-1, 'unknown'); + + const ErikaOutputFallbackReason(this.nativeValue, this.label); + + final int nativeValue; + final String label; + + static ErikaOutputFallbackReason fromNativeValue(int value) { + return switch (value) { + 0 => ErikaOutputFallbackReason.none, + 1 => ErikaOutputFallbackReason.displayHdrUnsupported, + 2 => ErikaOutputFallbackReason.hybridCompositionRequired, + 3 => ErikaOutputFallbackReason.wgpuBackendNotVulkan, + 4 => ErikaOutputFallbackReason.rgba16FloatSurfaceFormatUnavailable, + 5 => ErikaOutputFallbackReason.nativeWindowDataSpaceApiUnavailable, + 6 => ErikaOutputFallbackReason.scrgbDataSpaceVerificationFailed, + 7 => ErikaOutputFallbackReason.surfaceConfigureFailed, + 8 => ErikaOutputFallbackReason.legacyAppleEdrUnsupported, + _ => ErikaOutputFallbackReason.unknown, + }; + } +} + +class ErikaOutputStatus { + const ErikaOutputStatus({ + required this.requestedMode, + required this.activeEncoding, + required this.surfaceFormat, + required this.nativeDataSpace, + required this.requestedHeadroom, + required this.activeHeadroom, + required this.activeHeadroomKnown, + required this.extendedLinearActive, + required this.fallbackReason, + required this.fallbackCount, + required this.dataSpaceFailures, + required this.headroomUpdates, + required this.extendedLinearFrames, + }); + + final ErikaOutputMode requestedMode; + final ErikaActiveOutputEncoding activeEncoding; + final ErikaOutputSurfaceFormat surfaceFormat; + final int nativeDataSpace; + final double requestedHeadroom; + final double activeHeadroom; + final bool activeHeadroomKnown; + final bool extendedLinearActive; + final ErikaOutputFallbackReason fallbackReason; + final int fallbackCount; + final int dataSpaceFailures; + final int headroomUpdates; + final int extendedLinearFrames; + + factory ErikaOutputStatus.fromMap(Map map) { + return ErikaOutputStatus( + requestedMode: ErikaOutputMode.fromNativeValue( + (map['requestedMode'] as num?)?.toInt() ?? 0, + ), + activeEncoding: ErikaActiveOutputEncoding.fromNativeValue( + (map['activeEncoding'] as num?)?.toInt() ?? 0, + ), + surfaceFormat: ErikaOutputSurfaceFormat.fromNativeValue( + (map['surfaceFormat'] as num?)?.toInt() ?? 0, + ), + nativeDataSpace: (map['nativeDataSpace'] as num?)?.toInt() ?? -1, + requestedHeadroom: (map['requestedHeadroom'] as num?)?.toDouble() ?? 1.0, + activeHeadroom: (map['activeHeadroom'] as num?)?.toDouble() ?? 1.0, + activeHeadroomKnown: map['activeHeadroomKnown'] == true, + extendedLinearActive: map['extendedLinearActive'] == true, + fallbackReason: ErikaOutputFallbackReason.fromNativeValue( + (map['fallbackReason'] as num?)?.toInt() ?? 0, + ), + fallbackCount: (map['fallbackCount'] as num?)?.toInt() ?? 0, + dataSpaceFailures: (map['dataSpaceFailures'] as num?)?.toInt() ?? 0, + headroomUpdates: (map['headroomUpdates'] as num?)?.toInt() ?? 0, + extendedLinearFrames: (map['extendedLinearFrames'] as num?)?.toInt() ?? 0, + ); + } +} + +class ErikaPresenterResourceStatus { + const ErikaPresenterResourceStatus({ + required this.deviceCurrentAllocatedBytes, + required this.deviceRecommendedWorkingSetBytes, + required this.drawableEstimatedBytes, + required this.videoFrameBytes, + required this.overlayAtlasBytes, + required this.danmakuAtlasBytes, + required this.danmakuVertexBufferBytes, + required this.upscalerBytes, + required this.rendererTrackedBytes, + required this.presenterCpuDanmakuAtlasBytes, + required this.drawableCount, + required this.outputModeSwitches, + }); + + /// Metal's process-wide counter for the current device. This can include + /// allocations from other Erika presenters and other Metal users. + final int deviceCurrentAllocatedBytes; + final int deviceRecommendedWorkingSetBytes; + final int drawableEstimatedBytes; + final int videoFrameBytes; + final int overlayAtlasBytes; + final int danmakuAtlasBytes; + final int danmakuVertexBufferBytes; + final int upscalerBytes; + final int rendererTrackedBytes; + final int presenterCpuDanmakuAtlasBytes; + final int drawableCount; + final int outputModeSwitches; + + factory ErikaPresenterResourceStatus.fromMap(Map map) { + int value(String key) => (map[key] as num?)?.toInt() ?? 0; + + return ErikaPresenterResourceStatus( + deviceCurrentAllocatedBytes: value('deviceCurrentAllocatedBytes'), + deviceRecommendedWorkingSetBytes: value( + 'deviceRecommendedWorkingSetBytes', + ), + drawableEstimatedBytes: value('drawableEstimatedBytes'), + videoFrameBytes: value('videoFrameBytes'), + overlayAtlasBytes: value('overlayAtlasBytes'), + danmakuAtlasBytes: value('danmakuAtlasBytes'), + danmakuVertexBufferBytes: value('danmakuVertexBufferBytes'), + upscalerBytes: value('upscalerBytes'), + rendererTrackedBytes: value('rendererTrackedBytes'), + presenterCpuDanmakuAtlasBytes: value('presenterCpuDanmakuAtlasBytes'), + drawableCount: value('drawableCount'), + outputModeSwitches: value('outputModeSwitches'), + ); + } +} + +class ErikaSubtitleMemoryFontStatus { + const ErikaSubtitleMemoryFontStatus({ + required this.registeredCount, + required this.registeredBytes, + required this.selectedCount, + required this.generation, + required this.selectedIds, + }); + + final int registeredCount; + final int registeredBytes; + final int selectedCount; + final int generation; + final List selectedIds; + + factory ErikaSubtitleMemoryFontStatus.fromMap(Map map) { + return ErikaSubtitleMemoryFontStatus( + registeredCount: (map['registeredCount'] as num?)?.toInt() ?? 0, + registeredBytes: (map['registeredBytes'] as num?)?.toInt() ?? 0, + selectedCount: (map['selectedCount'] as num?)?.toInt() ?? 0, + generation: (map['generation'] as num?)?.toInt() ?? 0, + selectedIds: List.unmodifiable( + (map['selectedIds'] as List? ?? const []) + .whereType() + .map((value) => value.toInt()), + ), + ); + } +} + +enum ErikaUpscalerMode { + off(0), + artCnnC4F16(1), + artCnnC4F32(2), + artCnnC4F16Ds(3); + + const ErikaUpscalerMode(this.nativeValue); + + final int nativeValue; + + static ErikaUpscalerMode fromNativeValue(int value) { + return switch (value) { + 1 => ErikaUpscalerMode.artCnnC4F16, + 2 => ErikaUpscalerMode.artCnnC4F32, + 3 => ErikaUpscalerMode.artCnnC4F16Ds, + _ => ErikaUpscalerMode.off, + }; + } +} + +enum ErikaUpscalerBackendStatus { + off(0), + inactive(1), + building(2), + scalar(3), + simdgroupMatrix(4); + + const ErikaUpscalerBackendStatus(this.nativeValue); + + final int nativeValue; + + static ErikaUpscalerBackendStatus fromNativeValue(int value) { + return switch (value) { + 1 => ErikaUpscalerBackendStatus.inactive, + 2 => ErikaUpscalerBackendStatus.building, + 3 => ErikaUpscalerBackendStatus.scalar, + 4 => ErikaUpscalerBackendStatus.simdgroupMatrix, + _ => ErikaUpscalerBackendStatus.off, + }; + } +} + +class ErikaUpscalerStatus { + const ErikaUpscalerStatus({ + required this.requestedMode, + required this.activeBackend, + required this.fallbackCount, + required this.upscaledFrames, + required this.lastEncodeDuration, + required this.lastGpuDuration, + }); + + final ErikaUpscalerMode requestedMode; + final ErikaUpscalerBackendStatus activeBackend; + final int fallbackCount; + final int upscaledFrames; + final Duration lastEncodeDuration; + final Duration lastGpuDuration; + + factory ErikaUpscalerStatus.fromMap(Map map) { + return ErikaUpscalerStatus( + requestedMode: ErikaUpscalerMode.fromNativeValue( + (map['requestedMode'] as num?)?.toInt() ?? 0, + ), + activeBackend: ErikaUpscalerBackendStatus.fromNativeValue( + (map['activeBackend'] as num?)?.toInt() ?? 0, + ), + fallbackCount: (map['fallbackCount'] as num?)?.toInt() ?? 0, + upscaledFrames: (map['upscaledFrames'] as num?)?.toInt() ?? 0, + lastEncodeDuration: Duration( + microseconds: (map['lastEncodeMicros'] as num?)?.toInt() ?? 0, + ), + lastGpuDuration: Duration( + microseconds: (map['lastGpuMicros'] as num?)?.toInt() ?? 0, + ), + ); + } +} + +class ErikaPresenterStats { + const ErikaPresenterStats({ + required this.decodedVideoFrames, + required this.renderedVideoFrames, + required this.renderedTestFrames, + required this.pushedAudioFrames, + required this.overlayFrames, + required this.danmakuFrames, + required this.danmakuItems, + required this.importFailures, + required this.renderFailures, + required this.audioFailures, + required this.softwareVideoFrames, + required this.hardwareVideoFrames, + required this.zeroCopyVideoFrames, + required this.cpuVideoFrameFallbacks, + required this.lastRenderDuration, + required this.lastRenderCurrentDuration, + required this.audioClockReadFrames, + required this.audioClockQueuedFrames, + required this.audioClockUnderflowFrames, + required this.audioRecoveryState, + required this.audioLastErrorCode, + required this.audioRecoveryAttempts, + required this.audioRecoveryCount, + required this.audioRecoveryFailures, + required this.directZeroCopyVideoFrames, + required this.sharedHandleVideoFrames, + required this.hdrSourceFrames, + required this.hdr10OutputFrames, + required this.sdrTonemapFrames, + required this.hdr10MetadataUpdates, + required this.hdr10MetadataFailures, + required this.hdr10OutputFailures, + required this.hdr10OutputActive, + required this.videoFrameBackpressureDrops, + }); + + final int decodedVideoFrames; + final int renderedVideoFrames; + final int renderedTestFrames; + final int pushedAudioFrames; + final int overlayFrames; + final int danmakuFrames; + final int danmakuItems; + final int importFailures; + final int renderFailures; + final int audioFailures; + final int softwareVideoFrames; + final int hardwareVideoFrames; + final int zeroCopyVideoFrames; + final int cpuVideoFrameFallbacks; + final Duration lastRenderDuration; + final Duration lastRenderCurrentDuration; + final int audioClockReadFrames; + final int audioClockQueuedFrames; + final int audioClockUnderflowFrames; + final int audioRecoveryState; + final int audioLastErrorCode; + final int audioRecoveryAttempts; + final int audioRecoveryCount; + final int audioRecoveryFailures; + final int directZeroCopyVideoFrames; + final int sharedHandleVideoFrames; + final int hdrSourceFrames; + final int hdr10OutputFrames; + final int sdrTonemapFrames; + final int hdr10MetadataUpdates; + final int hdr10MetadataFailures; + final int hdr10OutputFailures; + final bool hdr10OutputActive; + final int videoFrameBackpressureDrops; + + factory ErikaPresenterStats.fromMap(Map map) { + return ErikaPresenterStats( + decodedVideoFrames: _intValue(map['decodedVideoFrames']), + renderedVideoFrames: _intValue(map['renderedVideoFrames']), + renderedTestFrames: _intValue(map['renderedTestFrames']), + pushedAudioFrames: _intValue(map['pushedAudioFrames']), + overlayFrames: _intValue(map['overlayFrames']), + danmakuFrames: _intValue(map['danmakuFrames']), + danmakuItems: _intValue(map['danmakuItems']), + importFailures: _intValue(map['importFailures']), + renderFailures: _intValue(map['renderFailures']), + audioFailures: _intValue(map['audioFailures']), + softwareVideoFrames: _intValue(map['softwareVideoFrames']), + hardwareVideoFrames: _intValue(map['hardwareVideoFrames']), + zeroCopyVideoFrames: _intValue(map['zeroCopyVideoFrames']), + cpuVideoFrameFallbacks: _intValue(map['cpuVideoFrameFallbacks']), + lastRenderDuration: Duration( + microseconds: _intValue(map['lastRenderMicros']), + ), + lastRenderCurrentDuration: Duration( + microseconds: _intValue(map['lastRenderCurrentMicros']), + ), + audioClockReadFrames: _intValue(map['audioClockReadFrames']), + audioClockQueuedFrames: _intValue(map['audioClockQueuedFrames']), + audioClockUnderflowFrames: _intValue(map['audioClockUnderflowFrames']), + audioRecoveryState: _intValue(map['audioRecoveryState']), + audioLastErrorCode: _intValue(map['audioLastErrorCode']), + audioRecoveryAttempts: _intValue(map['audioRecoveryAttempts']), + audioRecoveryCount: _intValue(map['audioRecoveryCount']), + audioRecoveryFailures: _intValue(map['audioRecoveryFailures']), + directZeroCopyVideoFrames: _intValue(map['directZeroCopyVideoFrames']), + sharedHandleVideoFrames: _intValue(map['sharedHandleVideoFrames']), + hdrSourceFrames: _intValue(map['hdrSourceFrames']), + hdr10OutputFrames: _intValue(map['hdr10OutputFrames']), + sdrTonemapFrames: _intValue(map['sdrTonemapFrames']), + hdr10MetadataUpdates: _intValue(map['hdr10MetadataUpdates']), + hdr10MetadataFailures: _intValue(map['hdr10MetadataFailures']), + hdr10OutputFailures: _intValue(map['hdr10OutputFailures']), + hdr10OutputActive: map['hdr10OutputActive'] == true, + videoFrameBackpressureDrops: _intValue( + map['videoFrameBackpressureDrops'], + ), + ); + } + + Map toMap() { + return { + 'decodedVideoFrames': decodedVideoFrames, + 'renderedVideoFrames': renderedVideoFrames, + 'renderedTestFrames': renderedTestFrames, + 'pushedAudioFrames': pushedAudioFrames, + 'overlayFrames': overlayFrames, + 'danmakuFrames': danmakuFrames, + 'danmakuItems': danmakuItems, + 'importFailures': importFailures, + 'renderFailures': renderFailures, + 'audioFailures': audioFailures, + 'softwareVideoFrames': softwareVideoFrames, + 'hardwareVideoFrames': hardwareVideoFrames, + 'zeroCopyVideoFrames': zeroCopyVideoFrames, + 'cpuVideoFrameFallbacks': cpuVideoFrameFallbacks, + 'lastRenderMicros': lastRenderDuration.inMicroseconds, + 'lastRenderCurrentMicros': lastRenderCurrentDuration.inMicroseconds, + 'audioClockReadFrames': audioClockReadFrames, + 'audioClockQueuedFrames': audioClockQueuedFrames, + 'audioClockUnderflowFrames': audioClockUnderflowFrames, + 'audioRecoveryState': audioRecoveryState, + 'audioLastErrorCode': audioLastErrorCode, + 'audioRecoveryAttempts': audioRecoveryAttempts, + 'audioRecoveryCount': audioRecoveryCount, + 'audioRecoveryFailures': audioRecoveryFailures, + 'directZeroCopyVideoFrames': directZeroCopyVideoFrames, + 'sharedHandleVideoFrames': sharedHandleVideoFrames, + 'hdrSourceFrames': hdrSourceFrames, + 'hdr10OutputFrames': hdr10OutputFrames, + 'sdrTonemapFrames': sdrTonemapFrames, + 'hdr10MetadataUpdates': hdr10MetadataUpdates, + 'hdr10MetadataFailures': hdr10MetadataFailures, + 'hdr10OutputFailures': hdr10OutputFailures, + 'hdr10OutputActive': hdr10OutputActive, + 'videoFrameBackpressureDrops': videoFrameBackpressureDrops, + }; + } + + static int _intValue(Object? value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return 0; + } +} + +class ErikaDanmakuTrackInfo { + const ErikaDanmakuTrackInfo({ + required this.id, + required this.enabled, + required this.offset, + required this.itemCount, + this.name, + this.source, + }); + + final int id; + final bool enabled; + final Duration offset; + final int itemCount; + final String? name; + final String? source; + + factory ErikaDanmakuTrackInfo.fromMap(Map map) { + return ErikaDanmakuTrackInfo( + id: (map['id'] as num?)?.toInt() ?? 0, + enabled: map['enabled'] == true, + offset: Duration( + microseconds: (map['offsetMicros'] as num?)?.toInt() ?? 0, + ), + itemCount: (map['itemCount'] as num?)?.toInt() ?? 0, + name: map['name'] as String?, + source: map['source'] as String?, + ); + } +} + +class _ErikaDanmakuConfigPatch { + _ErikaDanmakuConfigPatch({ + this.enabled, + this.fontSize, + this.opacity, + this.displayArea, + this.scrollDurationSeconds, + this.scrollSpeedFactor, + this.trackGapRatio, + this.outlineWidth, + this.shadowOffsetX, + this.shadowOffsetY, + this.shadowStyle, + this.customFontFamily, + this.customFontFilePath, + this.mergeDuplicates, + this.allowStacking, + this.allowScrollOverwrite, + this.maxQuantity, + this.maxLinesPerMode, + this.blockTop, + this.blockBottom, + this.blockScroll, + List? blockWords, + }) : blockWords = blockWords == null + ? null + : List.unmodifiable(blockWords); + + final bool? enabled; + final double? fontSize; + final double? opacity; + final double? displayArea; + final double? scrollDurationSeconds; + final double? scrollSpeedFactor; + final double? trackGapRatio; + final double? outlineWidth; + final double? shadowOffsetX; + final double? shadowOffsetY; + final int? shadowStyle; + final String? customFontFamily; + final String? customFontFilePath; + final bool? mergeDuplicates; + final bool? allowStacking; + final bool? allowScrollOverwrite; + final int? maxQuantity; + final int? maxLinesPerMode; + final bool? blockTop; + final bool? blockBottom; + final bool? blockScroll; + final List? blockWords; + + bool get isEmpty => + enabled == null && + fontSize == null && + opacity == null && + displayArea == null && + scrollDurationSeconds == null && + scrollSpeedFactor == null && + trackGapRatio == null && + outlineWidth == null && + shadowOffsetX == null && + shadowOffsetY == null && + shadowStyle == null && + customFontFamily == null && + customFontFilePath == null && + mergeDuplicates == null && + allowStacking == null && + allowScrollOverwrite == null && + maxQuantity == null && + maxLinesPerMode == null && + blockTop == null && + blockBottom == null && + blockScroll == null && + blockWords == null; + + _ErikaDanmakuConfigPatch merge(_ErikaDanmakuConfigPatch other) { + return _ErikaDanmakuConfigPatch( + enabled: other.enabled ?? enabled, + fontSize: other.fontSize ?? fontSize, + opacity: other.opacity ?? opacity, + displayArea: other.displayArea ?? displayArea, + scrollDurationSeconds: + other.scrollDurationSeconds ?? scrollDurationSeconds, + scrollSpeedFactor: other.scrollSpeedFactor ?? scrollSpeedFactor, + trackGapRatio: other.trackGapRatio ?? trackGapRatio, + outlineWidth: other.outlineWidth ?? outlineWidth, + shadowOffsetX: other.shadowOffsetX ?? shadowOffsetX, + shadowOffsetY: other.shadowOffsetY ?? shadowOffsetY, + shadowStyle: other.shadowStyle ?? shadowStyle, + customFontFamily: other.customFontFamily ?? customFontFamily, + customFontFilePath: other.customFontFilePath ?? customFontFilePath, + mergeDuplicates: other.mergeDuplicates ?? mergeDuplicates, + allowStacking: other.allowStacking ?? allowStacking, + allowScrollOverwrite: other.allowScrollOverwrite ?? allowScrollOverwrite, + maxQuantity: other.maxQuantity ?? maxQuantity, + maxLinesPerMode: other.maxLinesPerMode ?? maxLinesPerMode, + blockTop: other.blockTop ?? blockTop, + blockBottom: other.blockBottom ?? blockBottom, + blockScroll: other.blockScroll ?? blockScroll, + blockWords: other.blockWords ?? blockWords, + ); + } + + _ErikaDanmakuConfigPatch differenceFrom(_ErikaDanmakuConfigPatch? previous) { + return _ErikaDanmakuConfigPatch( + enabled: _changed(enabled, previous?.enabled) ? enabled : null, + fontSize: _changed(fontSize, previous?.fontSize) ? fontSize : null, + opacity: _changed(opacity, previous?.opacity) ? opacity : null, + displayArea: _changed(displayArea, previous?.displayArea) + ? displayArea + : null, + scrollDurationSeconds: + _changed(scrollDurationSeconds, previous?.scrollDurationSeconds) + ? scrollDurationSeconds + : null, + scrollSpeedFactor: + _changed(scrollSpeedFactor, previous?.scrollSpeedFactor) + ? scrollSpeedFactor + : null, + trackGapRatio: _changed(trackGapRatio, previous?.trackGapRatio) + ? trackGapRatio + : null, + outlineWidth: _changed(outlineWidth, previous?.outlineWidth) + ? outlineWidth + : null, + shadowOffsetX: _changed(shadowOffsetX, previous?.shadowOffsetX) + ? shadowOffsetX + : null, + shadowOffsetY: _changed(shadowOffsetY, previous?.shadowOffsetY) + ? shadowOffsetY + : null, + shadowStyle: _changed(shadowStyle, previous?.shadowStyle) + ? shadowStyle + : null, + customFontFamily: _changed(customFontFamily, previous?.customFontFamily) + ? customFontFamily + : null, + customFontFilePath: + _changed(customFontFilePath, previous?.customFontFilePath) + ? customFontFilePath + : null, + mergeDuplicates: _changed(mergeDuplicates, previous?.mergeDuplicates) + ? mergeDuplicates + : null, + allowStacking: _changed(allowStacking, previous?.allowStacking) + ? allowStacking + : null, + allowScrollOverwrite: + _changed(allowScrollOverwrite, previous?.allowScrollOverwrite) + ? allowScrollOverwrite + : null, + maxQuantity: _changed(maxQuantity, previous?.maxQuantity) + ? maxQuantity + : null, + maxLinesPerMode: _changed(maxLinesPerMode, previous?.maxLinesPerMode) + ? maxLinesPerMode + : null, + blockTop: _changed(blockTop, previous?.blockTop) ? blockTop : null, + blockBottom: _changed(blockBottom, previous?.blockBottom) + ? blockBottom + : null, + blockScroll: _changed(blockScroll, previous?.blockScroll) + ? blockScroll + : null, + blockWords: _changedList(blockWords, previous?.blockWords) + ? blockWords + : null, + ); + } + + Map toArguments(int playerId) { + return { + 'playerId': playerId, + if (enabled != null) 'enabled': enabled, + if (fontSize != null) 'fontSize': fontSize, + if (opacity != null) 'opacity': opacity, + if (displayArea != null) 'displayArea': displayArea, + if (scrollDurationSeconds != null) + 'scrollDurationSeconds': scrollDurationSeconds, + if (scrollSpeedFactor != null) 'scrollSpeedFactor': scrollSpeedFactor, + if (trackGapRatio != null) 'trackGapRatio': trackGapRatio, + if (outlineWidth != null) 'outlineWidth': outlineWidth, + if (shadowOffsetX != null) 'shadowOffsetX': shadowOffsetX, + if (shadowOffsetY != null) 'shadowOffsetY': shadowOffsetY, + if (shadowStyle != null) 'shadowStyle': shadowStyle, + if (customFontFamily != null) 'customFontFamily': customFontFamily, + if (customFontFilePath != null) 'customFontFilePath': customFontFilePath, + if (mergeDuplicates != null) 'mergeDuplicates': mergeDuplicates, + if (allowStacking != null) 'allowStacking': allowStacking, + if (allowScrollOverwrite != null) + 'allowScrollOverwrite': allowScrollOverwrite, + if (maxQuantity != null) 'maxQuantity': maxQuantity, + if (maxLinesPerMode != null) 'maxLinesPerMode': maxLinesPerMode, + if (blockTop != null) 'blockTop': blockTop, + if (blockBottom != null) 'blockBottom': blockBottom, + if (blockScroll != null) 'blockScroll': blockScroll, + if (blockWords != null) 'blockWordsJson': jsonEncode(blockWords), + }; + } + + static bool _changed(T? value, T? previous) => + value != null && value != previous; + + static bool _changedList(List? value, List? previous) => + value != null && !listEquals(value, previous); +} + +class ErikaPlayer { + ErikaPlayer({ + this.outputMode, + this.edrHeadroom, + this.upscaler, + this.videoAlphaMode = ErikaVideoAlphaMode.opaque, + this.hdrDebug = false, + this.allowBackgroundPlayback = false, + }) { + final headroom = edrHeadroom; + if (headroom != null && + (!headroom.isFinite || headroom < 1.0 || headroom > 10000.0)) { + throw ArgumentError.value( + headroom, + 'edrHeadroom', + 'must be finite and in [1, 10000]; omit it for system-auto headroom', + ); + } + _eventSubscription ??= _events.receiveBroadcastStream().listen( + _dispatchNativeEvent, + onError: (Object error, StackTrace stackTrace) { + debugPrint('ErikaPlayer event stream error: $error'); + }, + ); + } + + static const MethodChannel _channel = MethodChannel('erika_flutter/player'); + static const EventChannel _events = EventChannel('erika_flutter/events'); + static const int windowOverlayViewId = -1; + static final Map> _controllers = + >{}; + static StreamSubscription? _eventSubscription; + + int? _id; + Future? _createFuture; + Future? _disposeFuture; + bool _disposed = false; + static const Duration _danmakuConfigCoalesceDelay = Duration( + milliseconds: 50, + ); + Timer? _danmakuConfigTimer; + bool _danmakuConfigInFlight = false; + _ErikaDanmakuConfigPatch? _pendingDanmakuConfig; + _ErikaDanmakuConfigPatch? _lastAppliedDanmakuConfig; + String? _subtitleFontFamily; + String? _subtitleFontFilePath; + int _subtitlePrimaryColorRgba = kErikaDefaultSubtitlePrimaryColorRgba; + int _subtitleOutlineColorRgba = kErikaDefaultSubtitleOutlineColorRgba; + double _subtitleFontSize = kErikaDefaultSubtitleFontSize; + double _subtitleOutlineWidth = kErikaDefaultSubtitleOutlineWidth; + bool _subtitleBold = false; + bool _subtitleItalic = false; + bool _subtitleUnderline = false; + bool _subtitleStrikeOut = false; + double _subtitleSpacing = 0.0; + double _subtitleScaleXPercent = 100.0; + double _subtitleScaleYPercent = 100.0; + int _subtitleBorderStyle = 1; + double _subtitleShadowDepth = 0.0; + double _subtitleBlur = 0.0; + int _subtitleAlignment = 2; + int _subtitleMarginLeft = 48; + int _subtitleMarginRight = 48; + int _subtitleMarginVertical = 54; + int _subtitleOverrideMask = 0; + final List> _pendingDanmakuConfigCompleters = + >[]; + + final ErikaOutputMode? outputMode; + final double? edrHeadroom; + final ErikaUpscalerMode? upscaler; + final ErikaVideoAlphaMode videoAlphaMode; + final bool hdrDebug; + final bool allowBackgroundPlayback; + + int? get id => _id; + + Stream get events async* { + final playerId = await ensureCreated(); + yield* _controllerFor(playerId).stream; + } + + Future ensureCreated() { + if (_disposed) { + throw StateError('ErikaPlayer has been disposed.'); + } + final existing = _id; + final player = existing != null + ? Future.value(existing) + : (_createFuture ??= _create()); + return _requireActiveAfter(player); + } + + Future open( + String uri, { + Map? httpHeaders, + ErikaMediaMetadata? metadata, + }) async { + final playerId = await ensureCreated(); + await _invoke('open', { + 'playerId': playerId, + 'uri': uri, + if (httpHeaders != null && httpHeaders.isNotEmpty) + 'httpHeaders': httpHeaders, + 'metadata': metadata?.toMap(), + }); + } + + Future setMediaMetadata(ErikaMediaMetadata metadata) async { + final playerId = await ensureCreated(); + await _invoke('setMediaMetadata', { + 'playerId': playerId, + 'metadata': metadata.toMap(), + }); + } + + Future setSystemMediaNavigation({ + required bool previousEnabled, + required bool nextEnabled, + }) async { + final playerId = await ensureCreated(); + await _invoke('setSystemMediaNavigation', { + 'playerId': playerId, + 'previousEnabled': previousEnabled, + 'nextEnabled': nextEnabled, + }); + } + + Future play() async { + await _invokeForPlayer('play'); + } + + Future pause() async { + await _invokeForPlayer('pause'); + } + + Future stop() async { + await _invokeForPlayer('stop'); + } + + Future close() async { + await _invokeForPlayer('close'); + } + + Future seek(Duration position) async { + final playerId = await ensureCreated(); + await _invoke('seek', { + 'playerId': playerId, + 'positionMicros': position.inMicroseconds, + }); + } + + Future setPlaybackRate(double rate) async { + final playerId = await ensureCreated(); + await _invoke('setPlaybackRate', { + 'playerId': playerId, + 'rate': rate, + }); + } + + Future setVolume(double volume) async { + final playerId = await ensureCreated(); + await _invoke('setVolume', { + 'playerId': playerId, + 'volume': volume.clamp(0.0, 1.0), + }); + } + + Future setUpscaler(ErikaUpscalerMode mode) async { + final playerId = await ensureCreated(); + await _invoke('setUpscaler', { + 'playerId': playerId, + 'mode': mode.nativeValue, + }); + } + + Future setSubtitleScale(double scale) async { + final playerId = await ensureCreated(); + final clampedScale = scale.isFinite ? scale.clamp(0.25, 4.0) : 1.0; + await _invoke('setSubtitleScale', { + 'playerId': playerId, + 'scale': clampedScale, + }); + } + + Future registerSubtitleMemoryFont(Uint8List data) async { + if (data.isEmpty) { + throw ArgumentError.value(data, 'data', 'must not be empty'); + } + final playerId = await ensureCreated(); + final fontId = await _channel.invokeMethod( + 'registerSubtitleMemoryFont', + {'playerId': playerId, 'data': data}, + ); + if (fontId == null) { + throw StateError( + 'Erika subtitle memory font registration returned null.', + ); + } + return fontId; + } + + Future selectSubtitleMemoryFonts(Iterable fontIds) async { + final ids = List.unmodifiable(fontIds); + if (ids.any((id) => id <= 0) || ids.toSet().length != ids.length) { + throw ArgumentError.value( + fontIds, + 'fontIds', + 'must contain unique positive IDs', + ); + } + final playerId = await ensureCreated(); + await _invoke('selectSubtitleMemoryFonts', { + 'playerId': playerId, + 'fontIds': ids, + }); + } + + Future clearSubtitleMemoryFonts() async { + await _invokeForPlayer('clearSubtitleMemoryFonts'); + } + + Future getSubtitleMemoryFontStatus() async { + final playerId = await ensureCreated(); + final status = await _channel.invokeMethod>( + 'getSubtitleMemoryFontStatus', + {'playerId': playerId}, + ); + if (status == null) { + throw StateError('Erika subtitle memory font status returned null.'); + } + return ErikaSubtitleMemoryFontStatus.fromMap(status); + } + + /// Sets the subtitle style. + /// + /// Values act as fallbacks: an ASS script keeps its own styling, and these + /// only fill in what it leaves open, what the system cannot resolve, and the + /// look of plain-text (SRT/WebVTT) subtitles. Set bits in [overrideMask] to + /// push selected fields onto dialogue that carries its own styling. + /// + /// Colours are `0xRRGGBBAA`. [fontSize] and [outlineWidth] are in ASS script + /// units (clamped to `8..400` and `0..32`), and [setSubtitleScale] still + /// multiplies both. + /// + /// Omitted arguments keep whatever this player last applied, so a single + /// field can be changed on its own. Pass an empty string to clear the font + /// family or file and return to the platform default. + Future setSubtitleStyle({ + String? fontFamily, + String? fontFilePath, + int? primaryColorRgba, + int? outlineColorRgba, + double? fontSize, + double? outlineWidth, + bool? bold, + bool? italic, + bool? underline, + bool? strikeOut, + double? spacing, + double? scaleXPercent, + double? scaleYPercent, + int? borderStyle, + double? shadowDepth, + double? blur, + int? alignment, + int? marginLeft, + int? marginRight, + int? marginVertical, + int? overrideMask, + }) async { + final playerId = await ensureCreated(); + _subtitleFontFamily = fontFamily ?? _subtitleFontFamily; + _subtitleFontFilePath = fontFilePath ?? _subtitleFontFilePath; + _subtitlePrimaryColorRgba = + _clampColorRgba(primaryColorRgba) ?? _subtitlePrimaryColorRgba; + _subtitleOutlineColorRgba = + _clampColorRgba(outlineColorRgba) ?? _subtitleOutlineColorRgba; + _subtitleFontSize = _clampMetric(fontSize, 8.0, 400.0) ?? _subtitleFontSize; + _subtitleOutlineWidth = + _clampMetric(outlineWidth, 0.0, 32.0) ?? _subtitleOutlineWidth; + _subtitleBold = bold ?? _subtitleBold; + _subtitleItalic = italic ?? _subtitleItalic; + _subtitleUnderline = underline ?? _subtitleUnderline; + _subtitleStrikeOut = strikeOut ?? _subtitleStrikeOut; + _subtitleSpacing = _clampMetric(spacing, -100.0, 100.0) ?? _subtitleSpacing; + _subtitleScaleXPercent = + _clampMetric(scaleXPercent, 1.0, 1000.0) ?? _subtitleScaleXPercent; + _subtitleScaleYPercent = + _clampMetric(scaleYPercent, 1.0, 1000.0) ?? _subtitleScaleYPercent; + _subtitleBorderStyle = + _validValue(borderStyle, const {1, 3}) ?? _subtitleBorderStyle; + _subtitleShadowDepth = + _clampMetric(shadowDepth, 0.0, 32.0) ?? _subtitleShadowDepth; + _subtitleBlur = _clampMetric(blur, 0.0, 100.0) ?? _subtitleBlur; + _subtitleAlignment = _clampInt(alignment, 1, 9) ?? _subtitleAlignment; + _subtitleMarginLeft = + _clampInt(marginLeft, 0, 10000) ?? _subtitleMarginLeft; + _subtitleMarginRight = + _clampInt(marginRight, 0, 10000) ?? _subtitleMarginRight; + _subtitleMarginVertical = + _clampInt(marginVertical, 0, 10000) ?? _subtitleMarginVertical; + _subtitleOverrideMask = overrideMask == null + ? _subtitleOverrideMask + : overrideMask & kErikaSubtitleOverrideAll; + await _invoke('setSubtitleStyle', { + 'playerId': playerId, + 'fontFamily': _subtitleFontFamily ?? '', + 'fontFilePath': _subtitleFontFilePath ?? '', + 'primaryColorRgba': _subtitlePrimaryColorRgba, + 'outlineColorRgba': _subtitleOutlineColorRgba, + 'fontSize': _subtitleFontSize, + 'outlineWidth': _subtitleOutlineWidth, + 'bold': _subtitleBold, + 'italic': _subtitleItalic, + 'underline': _subtitleUnderline, + 'strikeOut': _subtitleStrikeOut, + 'spacing': _subtitleSpacing, + 'scaleXPercent': _subtitleScaleXPercent, + 'scaleYPercent': _subtitleScaleYPercent, + 'borderStyle': _subtitleBorderStyle, + 'shadowDepth': _subtitleShadowDepth, + 'blur': _subtitleBlur, + 'alignment': _subtitleAlignment, + 'marginLeft': _subtitleMarginLeft, + 'marginRight': _subtitleMarginRight, + 'marginVertical': _subtitleMarginVertical, + 'overrideMask': _subtitleOverrideMask, + }); + } + + static int? _validValue(int? value, Set validValues) { + return value != null && validValues.contains(value) ? value : null; + } + + static int? _clampInt(int? value, int min, int max) { + return value?.clamp(min, max); + } + + static int? _clampColorRgba(int? value) { + if (value == null) { + return null; + } + return value & 0xFFFFFFFF; + } + + static double? _clampMetric(double? value, double min, double max) { + if (value == null || !value.isFinite) { + return null; + } + return value.clamp(min, max); + } + + Future getUpscalerStatus() async { + final playerId = await ensureCreated(); + final status = await _channel.invokeMethod>( + 'getUpscalerStatus', + {'playerId': playerId}, + ); + if (status == null) { + throw StateError('Erika upscaler status returned null.'); + } + return ErikaUpscalerStatus.fromMap(status); + } + + Future getOutputStatus() async { + final playerId = await ensureCreated(); + final status = await _channel.invokeMethod>( + 'getOutputStatus', + {'playerId': playerId}, + ); + if (status == null) { + throw StateError('Erika output status returned null.'); + } + return ErikaOutputStatus.fromMap(status); + } + + Future getResourceStatus() async { + final playerId = await ensureCreated(); + final status = await _channel.invokeMethod>( + 'getResourceStatus', + {'playerId': playerId}, + ); + if (status == null) { + throw StateError('Erika resource status returned null.'); + } + return ErikaPresenterResourceStatus.fromMap(status); + } + + Future getPresenterStats() async { + final playerId = await ensureCreated(); + final stats = await _channel.invokeMethod>( + 'getPresenterStats', + {'playerId': playerId}, + ); + if (stats == null) { + throw StateError('Erika presenter stats returned null.'); + } + return ErikaPresenterStats.fromMap(stats); + } + + Future setDebugHudEnabled(bool enabled) async { + final playerId = await ensureCreated(); + await _channel.invokeMethod('setDebugHudEnabled', { + 'playerId': playerId, + 'enabled': enabled, + }); + } + + Future screenshot({int? viewId, int? width, int? height}) async { + final playerId = await ensureCreated(); + return _channel.invokeMethod('screenshot', { + 'playerId': playerId, + if (viewId != null) 'viewId': viewId, + if (width != null) 'width': width, + if (height != null) 'height': height, + }); + } + + Future addExternalSubtitle(String uri) async { + final playerId = await ensureCreated(); + final trackId = await _channel.invokeMethod( + 'addExternalSubtitle', + {'playerId': playerId, 'uri': uri}, + ); + if (trackId == null) { + throw StateError('Erika external subtitle add returned no track id.'); + } + return trackId; + } + + Future removeSubtitleTrack(int trackId) async { + final playerId = await ensureCreated(); + await _invoke('removeSubtitleTrack', { + 'playerId': playerId, + 'trackId': trackId, + }); + } + + Future loadDanmakuFile(String uri) async { + final playerId = await ensureCreated(); + await _invoke('loadDanmakuFile', { + 'playerId': playerId, + 'uri': uri, + }); + } + + Future loadDanmakuJson(String json) async { + final playerId = await ensureCreated(); + await _invoke('loadDanmakuJson', { + 'playerId': playerId, + 'json': json, + }); + } + + Future addDanmakuTrackFile( + String uri, { + String? name, + Duration offset = Duration.zero, + }) async { + final playerId = await ensureCreated(); + final trackId = await _channel + .invokeMethod('addDanmakuTrackFile', { + 'playerId': playerId, + 'uri': uri, + if (name != null) 'name': name, + 'offsetMicros': offset.inMicroseconds, + }); + if (trackId == null || trackId <= 0) { + throw StateError('Erika danmaku track add returned no track id.'); + } + return trackId; + } + + Future addDanmakuTrackJson( + String json, { + String? name, + Duration offset = Duration.zero, + }) async { + final playerId = await ensureCreated(); + final trackId = await _channel + .invokeMethod('addDanmakuTrackJson', { + 'playerId': playerId, + 'json': json, + if (name != null) 'name': name, + 'offsetMicros': offset.inMicroseconds, + }); + if (trackId == null || trackId <= 0) { + throw StateError('Erika danmaku track add returned no track id.'); + } + return trackId; + } + + Future removeDanmakuTrack(int trackId) async { + final playerId = await ensureCreated(); + await _invoke('removeDanmakuTrack', { + 'playerId': playerId, + 'trackId': trackId, + }); + } + + Future setDanmakuTrackEnabled(int trackId, bool enabled) async { + final playerId = await ensureCreated(); + await _invoke('setDanmakuTrackEnabled', { + 'playerId': playerId, + 'trackId': trackId, + 'enabled': enabled, + }); + } + + Future setDanmakuTrackOffset(int trackId, Duration offset) async { + final playerId = await ensureCreated(); + await _invoke('setDanmakuTrackOffset', { + 'playerId': playerId, + 'trackId': trackId, + 'offsetMicros': offset.inMicroseconds, + }); + } + + Future setDanmakuGlobalOffset(Duration offset) async { + final playerId = await ensureCreated(); + await _invoke('setDanmakuGlobalOffset', { + 'playerId': playerId, + 'offsetMicros': offset.inMicroseconds, + }); + } + + Future> danmakuTracks() async { + final playerId = await ensureCreated(); + final rawTracks = await _channel.invokeMethod>( + 'danmakuTracks', + {'playerId': playerId}, + ); + if (rawTracks == null) { + return const []; + } + return rawTracks + .whereType>() + .map(ErikaDanmakuTrackInfo.fromMap) + .toList(growable: false); + } + + Future clearDanmaku() async { + await _invokeForPlayer('clearDanmaku'); + } + + Future setDanmakuEnabled(bool enabled) async { + final playerId = await ensureCreated(); + await _invoke('setDanmakuEnabled', { + 'playerId': playerId, + 'enabled': enabled, + }); + } + + Future setDanmakuConfig({ + bool? enabled, + // NipaPlay/Flutter logical danmaku font size. Erika uses the NipaPlay + // default danmaku font and applies the native surface scale internally. + double? fontSize, + double? opacity, + double? displayArea, + double? scrollDurationSeconds, + double? scrollSpeedFactor, + double? trackGapRatio, + double? outlineWidth, + double? shadowOffsetX, + double? shadowOffsetY, + int? shadowStyle, + String? customFontFamily, + String? customFontFilePath, + bool? mergeDuplicates, + bool? allowStacking, + bool? allowScrollOverwrite, + int? maxQuantity, + int? maxLinesPerMode, + bool? blockTop, + bool? blockBottom, + bool? blockScroll, + List? blockWords, + }) async { + if (_disposed) { + return; + } + final playerId = await ensureCreated(); + if (enabled != null) { + // Visibility changes are presentation-critical. Do not hold them behind + // the style coalescing timer; the native presenter can hide immediately. + await _invoke('setDanmakuEnabled', { + 'playerId': playerId, + 'enabled': enabled, + }); + } + final patch = _ErikaDanmakuConfigPatch( + enabled: null, + fontSize: fontSize, + opacity: opacity, + displayArea: displayArea, + scrollDurationSeconds: scrollDurationSeconds, + scrollSpeedFactor: scrollSpeedFactor, + trackGapRatio: trackGapRatio, + outlineWidth: outlineWidth, + shadowOffsetX: shadowOffsetX, + shadowOffsetY: shadowOffsetY, + shadowStyle: shadowStyle, + customFontFamily: customFontFamily, + customFontFilePath: customFontFilePath, + mergeDuplicates: mergeDuplicates, + allowStacking: allowStacking, + allowScrollOverwrite: allowScrollOverwrite, + maxQuantity: maxQuantity, + maxLinesPerMode: maxLinesPerMode, + blockTop: blockTop, + blockBottom: blockBottom, + blockScroll: blockScroll, + blockWords: blockWords, + ); + if (patch.isEmpty) { + return; + } + + final completer = Completer(); + _pendingDanmakuConfig = _pendingDanmakuConfig?.merge(patch) ?? patch; + _pendingDanmakuConfigCompleters.add(completer); + _scheduleDanmakuConfigFlush(playerId); + return completer.future; + } + + void _scheduleDanmakuConfigFlush(int playerId) { + if (_disposed || _danmakuConfigInFlight || _danmakuConfigTimer != null) { + return; + } + _danmakuConfigTimer = Timer(_danmakuConfigCoalesceDelay, () { + _danmakuConfigTimer = null; + unawaited(_flushDanmakuConfig(playerId)); + }); + } + + Future _flushDanmakuConfig(int playerId) async { + if (_disposed || _danmakuConfigInFlight) { + return; + } + + final requestedPatch = _pendingDanmakuConfig; + if (requestedPatch == null) { + return; + } + final completers = List>.of( + _pendingDanmakuConfigCompleters, + ); + _pendingDanmakuConfigCompleters.clear(); + _pendingDanmakuConfig = null; + + final outgoingPatch = requestedPatch.differenceFrom( + _lastAppliedDanmakuConfig, + ); + if (outgoingPatch.isEmpty) { + for (final completer in completers) { + if (!completer.isCompleted) { + completer.complete(); + } + } + if (_pendingDanmakuConfig != null) { + _scheduleDanmakuConfigFlush(playerId); + } + return; + } + + _danmakuConfigInFlight = true; + try { + await _invoke('setDanmakuConfig', outgoingPatch.toArguments(playerId)); + _lastAppliedDanmakuConfig = + _lastAppliedDanmakuConfig?.merge(requestedPatch) ?? requestedPatch; + for (final completer in completers) { + if (!completer.isCompleted) { + completer.complete(); + } + } + } catch (error, stackTrace) { + for (final completer in completers) { + if (!completer.isCompleted) { + completer.completeError(error, stackTrace); + } + } + } finally { + _danmakuConfigInFlight = false; + if (_pendingDanmakuConfig != null) { + _scheduleDanmakuConfigFlush(playerId); + } + } + } + + Future selectAudioTrack(int? trackId) async { + final playerId = await ensureCreated(); + await _invoke('selectAudioTrack', { + 'playerId': playerId, + 'trackId': trackId, + }); + } + + Future selectSubtitleTrack(int? trackId) async { + final playerId = await ensureCreated(); + await _invoke('selectSubtitleTrack', { + 'playerId': playerId, + 'trackId': trackId, + }); + } + + Future> tracks() async { + final playerId = await ensureCreated(); + final rawTracks = await _channel.invokeMethod>( + 'tracks', + {'playerId': playerId}, + ); + if (rawTracks == null) { + return const []; + } + return rawTracks + .whereType>() + .map(ErikaTrackInfo.fromMap) + .toList(growable: false); + } + + Future attachView(int viewId) async { + final playerId = await ensureCreated(); + await _invoke('attachView', { + 'playerId': playerId, + 'viewId': viewId, + }); + } + + Future detachView(int viewId) async { + final playerId = _id; + if (playerId == null || _disposed) { + return; + } + await _invoke('detachView', { + 'playerId': playerId, + 'viewId': viewId, + }); + } + + /// Allocates a native GPU surface exposed through a Flutter texture. + /// + /// This is used on Windows, macOS, and HarmonyOS when the embedder supports + /// compositor-owned texture surfaces. + Future createTextureSurface({ + required int width, + required int height, + required double scale, + }) async { + final textureId = await _channel.invokeMethod( + 'createTexture', + {'width': width, 'height': height, 'scale': scale}, + ); + if (textureId == null || textureId < 0) { + throw StateError('Erika texture allocation failed.'); + } + return textureId; + } + + Future resizeTextureSurface( + int textureId, { + required int width, + required int height, + required double scale, + }) { + return _invoke('resizeTexture', { + 'textureId': textureId, + 'width': width, + 'height': height, + 'scale': scale, + }); + } + + Future releaseTextureSurface(int textureId) { + return _invoke('releaseTexture', {'textureId': textureId}); + } + + /// Attaches the shared native overlay to this player. + /// + /// Multi-view embedders can use [flutterViewId] and [secondaryWindow] to + /// identify the Flutter view that currently hosts the player widget. + Future attachWindowOverlay({ + int? flutterViewId, + bool secondaryWindow = false, + String blendMode = 'srcOver', + double opacity = 1.0, + }) async { + final playerId = await ensureCreated(); + final viewId = await _channel + .invokeMethod('attachOverlay', { + 'playerId': playerId, + if (flutterViewId != null) 'flutterViewId': flutterViewId, + 'secondaryWindow': secondaryWindow, + if (blendMode != 'srcOver') 'blendMode': blendMode, + if (opacity != 1.0) 'opacity': opacity, + }); + return viewId ?? windowOverlayViewId; + } + + Future detachWindowOverlay({int? generation}) async { + final playerId = _id; + if (playerId == null || _disposed) { + return; + } + await _invoke('detachOverlay', { + 'playerId': playerId, + if (generation != null) 'generation': generation, + }); + } + + Future setWindowOverlayFrame({ + required Rect frame, + required bool visible, + required int generation, + int? flutterViewId, + bool secondaryWindow = false, + String? debugLabel, + String blendMode = 'srcOver', + double opacity = 1.0, + }) async { + final playerId = await ensureCreated(); + await _invoke('setOverlayFrame', { + 'playerId': playerId, + 'viewId': windowOverlayViewId, + 'generation': generation, + 'x': frame.left, + 'y': frame.top, + 'width': frame.width, + 'height': frame.height, + 'visible': visible, + if (flutterViewId != null) 'flutterViewId': flutterViewId, + 'secondaryWindow': secondaryWindow, + if (debugLabel != null) 'debugLabel': debugLabel, + if (blendMode != 'srcOver') 'blendMode': blendMode, + if (opacity != 1.0) 'opacity': opacity, + }); + } + + Future dispose() { + final existing = _disposeFuture; + if (existing != null) { + return existing; + } + _disposed = true; + return _disposeFuture = _dispose(); + } + + Future _dispose() async { + _danmakuConfigTimer?.cancel(); + _danmakuConfigTimer = null; + for (final completer in _pendingDanmakuConfigCompleters) { + if (!completer.isCompleted) { + completer.complete(); + } + } + _pendingDanmakuConfigCompleters.clear(); + _pendingDanmakuConfig = null; + + final createFuture = _createFuture; + if (createFuture != null) { + try { + await createFuture; + } catch (_) { + // Creation callers retain the original error. Disposal only needs to + // clean up a native player if creation produced one. + } + } + + final playerId = _id; + _id = null; + _createFuture = null; + if (playerId == null) { + return; + } + try { + await _invoke('dispose', {'playerId': playerId}); + } finally { + final controller = _controllers.remove(playerId); + await controller?.close(); + } + } + + Future _create() async { + final requestedHeadroom = + edrHeadroom ?? + (outputMode == ErikaOutputMode.extendedLinear ? 4.0 : null); + final arguments = { + if (outputMode case final mode?) 'outputMode': mode.nativeValue, + if (requestedHeadroom case final headroom?) 'edrHeadroom': headroom, + if (upscaler case final mode?) 'upscaler': mode.nativeValue, + if (videoAlphaMode != ErikaVideoAlphaMode.opaque) + 'videoAlphaMode': videoAlphaMode.nativeValue, + if (hdrDebug) 'hdrDebug': true, + if (allowBackgroundPlayback) 'allowBackgroundPlayback': true, + }; + if (hdrDebug) { + debugPrint('ErikaHDR[Dart]: create arguments=$arguments'); + } + final playerId = await _channel.invokeMethod('create', arguments); + if (playerId == null || playerId <= 0) { + throw StateError('Erika presenter creation failed.'); + } + _id = playerId; + _controllerFor(playerId); + return playerId; + } + + Future _requireActiveAfter(Future player) async { + final playerId = await player; + if (_disposed) { + throw StateError('ErikaPlayer has been disposed.'); + } + return playerId; + } + + Future _invokeForPlayer(String method) async { + final playerId = await ensureCreated(); + await _invoke(method, {'playerId': playerId}); + } + + Future _invoke(String method, Map arguments) async { + await _channel.invokeMethod(method, arguments); + } + + static StreamController _controllerFor(int playerId) { + return _controllers.putIfAbsent( + playerId, + () => StreamController.broadcast(), + ); + } + + static void _dispatchNativeEvent(dynamic rawEvent) { + if (rawEvent is! Map) { + return; + } + final event = ErikaPlayerEvent.fromMap(rawEvent); + final controller = _controllers[event.playerId]; + controller?.add(event); + } +} diff --git a/third_party/erika_flutter/lib/src/erika_video_view.dart b/third_party/erika_flutter/lib/src/erika_video_view.dart new file mode 100644 index 00000000..234f81b9 --- /dev/null +++ b/third_party/erika_flutter/lib/src/erika_video_view.dart @@ -0,0 +1,954 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'erika_player.dart'; + +bool get _supportsWindowOverlayVideoView => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows); + +bool get _usesAndroidTextureView => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + +bool get _usesOhosTextureView => + !kIsWeb && defaultTargetPlatform.name == 'ohos'; + +bool get _supportsFlutterTextureVideoView => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows || + _usesOhosTextureView); + +/// Flutter platform-view video surface backed by AppKit/UIKit. +/// +/// This is the compatibility surface. Full-player Apple hosts should usually +/// prefer [ErikaWindowOverlayVideoView] so Erika owns the native Metal video +/// plane outside Flutter's platform-view compositor. +class ErikaVideoView extends StatefulWidget { + const ErikaVideoView({ + super.key, + required this.player, + this.debugLabel, + this.onPlatformViewIdChanged, + this.blendMode = BlendMode.srcOver, + this.opacity = 1.0, + }) : assert(opacity >= 0.0 && opacity <= 1.0); + + final ErikaPlayer player; + final String? debugLabel; + final ValueChanged? onPlatformViewIdChanged; + final BlendMode blendMode; + final double opacity; + + @override + State createState() => _ErikaVideoViewState(); +} + +/// Video surface composited as a Flutter [Texture]. +/// +/// On macOS this uses an IOSurface-backed Metal texture, and on Windows it uses +/// a shareable D3D11 texture. Regular Flutter effects such as opacity, clipping +/// and color filters therefore apply to the video on both desktop platforms. +class ErikaTextureVideoView extends StatelessWidget { + const ErikaTextureVideoView({ + super.key, + required this.player, + this.debugLabel, + this.onTextureIdChanged, + this.blendMode = BlendMode.srcOver, + this.opacity = 1.0, + }) : assert(opacity >= 0.0 && opacity <= 1.0); + + final ErikaPlayer player; + final String? debugLabel; + final ValueChanged? onTextureIdChanged; + final BlendMode blendMode; + final double opacity; + + @override + Widget build(BuildContext context) { + if (!kIsWeb && + defaultTargetPlatform == TargetPlatform.macOS && + blendMode != BlendMode.srcOver) { + return ErikaVideoView( + player: player, + debugLabel: debugLabel, + onPlatformViewIdChanged: onTextureIdChanged, + blendMode: blendMode, + opacity: opacity, + ); + } + if (!kIsWeb && + defaultTargetPlatform == TargetPlatform.windows && + blendMode == BlendMode.overlay) { + return ErikaWindowOverlayVideoView( + player: player, + debugLabel: debugLabel, + onPlatformViewIdChanged: onTextureIdChanged, + blendMode: blendMode, + opacity: opacity, + ); + } + if (_supportsFlutterTextureVideoView) { + return _ErikaTextureBlendLayer( + blendMode: blendMode, + child: Opacity( + opacity: opacity, + child: _ErikaOhosVideoView( + player: player, + onPlatformViewIdChanged: onTextureIdChanged, + ), + ), + ); + } + return ErikaVideoView( + player: player, + debugLabel: debugLabel, + onPlatformViewIdChanged: onTextureIdChanged, + blendMode: blendMode, + opacity: opacity, + ); + } +} + +class _ErikaTextureBlendLayer extends SingleChildRenderObjectWidget { + const _ErikaTextureBlendLayer({ + required this.blendMode, + required super.child, + }); + + final BlendMode blendMode; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderErikaTextureBlendLayer(blendMode); + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderErikaTextureBlendLayer renderObject, + ) { + renderObject.blendMode = blendMode; + } +} + +class _RenderErikaTextureBlendLayer extends RenderProxyBox { + _RenderErikaTextureBlendLayer(this._blendMode); + + BlendMode _blendMode; + + set blendMode(BlendMode value) { + if (_blendMode == value) { + return; + } + _blendMode = value; + markNeedsPaint(); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (child == null || size.isEmpty) { + return; + } + if (_blendMode == BlendMode.srcOver) { + super.paint(context, offset); + return; + } + context.canvas.saveLayer(offset & size, Paint()..blendMode = _blendMode); + super.paint(context, offset); + context.canvas.restore(); + } +} + +class _ErikaVideoViewState extends State { + int? _viewId; + + @override + void didUpdateWidget(covariant ErikaVideoView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.player != widget.player) { + final viewId = _viewId; + if (viewId != null) { + unawaited(oldWidget.player.detachView(viewId)); + unawaited(widget.player.attachView(viewId)); + } + } + } + + @override + void dispose() { + final viewId = _viewId; + widget.onPlatformViewIdChanged?.call(null); + if (viewId != null) { + unawaited(widget.player.detachView(viewId)); + } + super.dispose(); + } + + void _handlePlatformViewCreated(int id) { + if (!mounted) { + return; + } + _viewId = id; + widget.onPlatformViewIdChanged?.call(id); + unawaited(widget.player.attachView(id)); + } + + @override + Widget build(BuildContext context) { + if (kIsWeb) { + return const SizedBox.shrink(); + } + final creationParams = { + if (widget.debugLabel case final label?) 'debugLabel': label, + if (widget.player.videoAlphaMode != ErikaVideoAlphaMode.opaque) + 'videoAlphaMode': widget.player.videoAlphaMode.nativeValue, + if (widget.blendMode != BlendMode.srcOver) + 'blendMode': widget.blendMode.name, + if (widget.opacity != 1.0) 'opacity': widget.opacity, + }; + if (_usesOhosTextureView) { + return _ErikaOhosVideoView( + player: widget.player, + onPlatformViewIdChanged: widget.onPlatformViewIdChanged, + ); + } + switch (defaultTargetPlatform) { + case TargetPlatform.macOS: + return AppKitView( + key: ValueKey(( + widget.player.videoAlphaMode, + widget.blendMode, + widget.opacity, + )), + viewType: 'erika_flutter/video_view', + layoutDirection: TextDirection.ltr, + creationParamsCodec: const StandardMessageCodec(), + creationParams: creationParams, + onPlatformViewCreated: _handlePlatformViewCreated, + ); + case TargetPlatform.iOS: + return UiKitView( + viewType: 'erika_flutter/video_view', + layoutDirection: TextDirection.ltr, + creationParamsCodec: const StandardMessageCodec(), + creationParams: creationParams, + onPlatformViewCreated: _handlePlatformViewCreated, + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + gestureRecognizers: const >{}, + ); + case TargetPlatform.windows: + return _ErikaOhosVideoView( + player: widget.player, + onPlatformViewIdChanged: widget.onPlatformViewIdChanged, + ); + case TargetPlatform.android: + return _ErikaAndroidVideoView( + player: widget.player, + debugLabel: widget.debugLabel, + onPlatformViewIdChanged: widget.onPlatformViewIdChanged, + ); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + return const SizedBox.shrink(); + // The HarmonyOS Flutter fork adds TargetPlatform.ohos. Stock Flutter + // considers the cases above exhaustive, while the fork needs this + // fallback after the name-based OHOS branch at the top of this method. + // ignore: unreachable_switch_default + default: + return const SizedBox.shrink(); + } + } +} + +class _OhosSurfaceMetrics { + const _OhosSurfaceMetrics(this.width, this.height, this.scale); + + final int width; + final int height; + final double scale; + + @override + bool operator ==(Object other) => + other is _OhosSurfaceMetrics && + width == other.width && + height == other.height && + scale == other.scale; + + @override + int get hashCode => Object.hash(width, height, scale); +} + +class _ErikaOhosVideoView extends StatefulWidget { + const _ErikaOhosVideoView({ + required this.player, + this.onPlatformViewIdChanged, + }); + + final ErikaPlayer player; + final ValueChanged? onPlatformViewIdChanged; + + @override + State<_ErikaOhosVideoView> createState() => _ErikaOhosVideoViewState(); +} + +class _ErikaOhosVideoViewState extends State<_ErikaOhosVideoView> { + int? _textureId; + int _generation = 0; + bool _updateInFlight = false; + _OhosSurfaceMetrics? _activeMetrics; + _OhosSurfaceMetrics? _pendingMetrics; + + @override + void didUpdateWidget(covariant _ErikaOhosVideoView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.player == widget.player) { + return; + } + final textureId = _textureId; + if (textureId != null) { + final generation = ++_generation; + unawaited( + _switchPlayer(oldWidget.player, widget.player, textureId, generation), + ); + } + } + + @override + void dispose() { + final generation = ++_generation; + final textureId = _textureId; + _textureId = null; + widget.onPlatformViewIdChanged?.call(null); + if (textureId != null) { + unawaited(_disposeTexture(widget.player, textureId, generation)); + } + super.dispose(); + } + + Future _switchPlayer( + ErikaPlayer oldPlayer, + ErikaPlayer newPlayer, + int textureId, + int generation, + ) async { + try { + await oldPlayer.detachView(textureId); + if (!mounted || + generation != _generation || + !identical(widget.player, newPlayer) || + _textureId != textureId) { + return; + } + await newPlayer.attachView(textureId); + } catch (error) { + debugPrint('ErikaOhosVideoView: player switch failed: $error'); + } + } + + Future _disposeTexture( + ErikaPlayer player, + int textureId, + int generation, + ) async { + try { + await player.detachView(textureId); + } catch (_) { + // releaseTexture is authoritative and detaches any remaining binding. + } + try { + await player.releaseTextureSurface(textureId); + } catch (error) { + debugPrint( + 'ErikaOhosVideoView: texture release failed ' + '(generation $generation): $error', + ); + } + } + + void _queueMetrics(_OhosSurfaceMetrics metrics) { + if (metrics == _activeMetrics && _pendingMetrics == null) { + return; + } + _pendingMetrics = metrics; + if (_updateInFlight) { + return; + } + _updateInFlight = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_flushMetrics()); + }); + } + + Future _flushMetrics() async { + try { + while (mounted) { + final metrics = _pendingMetrics; + _pendingMetrics = null; + if (metrics == null) { + return; + } + final textureId = _textureId; + if (textureId == null) { + final generation = _generation; + try { + final created = await widget.player.createTextureSurface( + width: metrics.width, + height: metrics.height, + scale: metrics.scale, + ); + if (!mounted || generation != _generation) { + await widget.player.releaseTextureSurface(created); + return; + } + _textureId = created; + _activeMetrics = metrics; + widget.onPlatformViewIdChanged?.call(created); + await widget.player.attachView(created); + if (mounted) { + setState(() {}); + } + } catch (error) { + debugPrint('ErikaOhosVideoView: texture creation failed: $error'); + } + } else if (metrics != _activeMetrics) { + try { + await widget.player.resizeTextureSurface( + textureId, + width: metrics.width, + height: metrics.height, + scale: metrics.scale, + ); + _activeMetrics = metrics; + } catch (error) { + debugPrint('ErikaOhosVideoView: texture resize failed: $error'); + } + } + } + } finally { + _updateInFlight = false; + if (mounted && _pendingMetrics != null) { + _queueMetrics(_pendingMetrics!); + } + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final scale = MediaQuery.devicePixelRatioOf(context); + final logicalWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : constraints.minWidth; + final logicalHeight = constraints.hasBoundedHeight + ? constraints.maxHeight + : constraints.minHeight; + final metrics = _OhosSurfaceMetrics( + (logicalWidth * scale).round().clamp(1, 16384), + (logicalHeight * scale).round().clamp(1, 16384), + scale, + ); + _queueMetrics(metrics); + final textureId = _textureId; + if (textureId == null) { + return const SizedBox.expand(); + } + return Texture(textureId: textureId); + }, + ); + } +} + +class _ErikaAndroidVideoView extends StatefulWidget { + const _ErikaAndroidVideoView({ + required this.player, + this.debugLabel, + this.onPlatformViewIdChanged, + }); + + final ErikaPlayer player; + final String? debugLabel; + final ValueChanged? onPlatformViewIdChanged; + + @override + State<_ErikaAndroidVideoView> createState() => _ErikaAndroidVideoViewState(); +} + +class _ErikaAndroidVideoViewState extends State<_ErikaAndroidVideoView> { + static const _attachRetryDelays = [ + Duration(milliseconds: 16), + Duration(milliseconds: 32), + Duration(milliseconds: 64), + Duration(milliseconds: 128), + Duration(milliseconds: 256), + Duration(milliseconds: 512), + Duration(seconds: 1), + ]; + + int? _viewId; + int _attachmentGeneration = 0; + int _surfaceGeneration = 0; + + bool _usesExtendedLinearSurface(ErikaPlayer player) => + player.outputMode == ErikaOutputMode.extendedLinear; + + Object _surfaceConfigurationKey(ErikaPlayer player) { + if (!_usesExtendedLinearSurface(player)) { + return 'sdr'; + } + final headroom = player.edrHeadroom; + return headroom == null + ? 'extended-linear:auto' + : 'extended-linear:$headroom'; + } + + @override + void didUpdateWidget(covariant _ErikaAndroidVideoView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.player == widget.player) { + return; + } + final surfaceConfigurationChanged = + _surfaceConfigurationKey(oldWidget.player) != + _surfaceConfigurationKey(widget.player); + if (surfaceConfigurationChanged) { + // Invalidate callbacks from a PlatformView whose asynchronous create + // has not completed yet; in that state _viewId is still null. + _surfaceGeneration += 1; + } + final viewId = _viewId; + if (viewId != null) { + final generation = ++_attachmentGeneration; + if (!surfaceConfigurationChanged) { + unawaited( + _switchPlayer( + oldPlayer: oldWidget.player, + newPlayer: widget.player, + viewId: viewId, + generation: generation, + ), + ); + } else { + unawaited( + _detachIgnoringFailure(oldWidget.player, viewId, 'view rebuild'), + ); + _viewId = null; + widget.onPlatformViewIdChanged?.call(null); + } + } + } + + @override + void dispose() { + _attachmentGeneration += 1; + _surfaceGeneration += 1; + final viewId = _viewId; + widget.onPlatformViewIdChanged?.call(null); + if (viewId != null) { + unawaited(_detachIgnoringFailure(widget.player, viewId, 'dispose')); + } + super.dispose(); + } + + void _handlePlatformViewCreated(int viewId, int surfaceGeneration) { + if (!mounted || surfaceGeneration != _surfaceGeneration) { + return; + } + final generation = ++_attachmentGeneration; + _viewId = viewId; + widget.onPlatformViewIdChanged?.call(viewId); + unawaited(_attachWithRetry(widget.player, viewId, generation)); + } + + bool _attachmentIsCurrent(ErikaPlayer player, int viewId, int generation) => + mounted && + generation == _attachmentGeneration && + identical(widget.player, player) && + _viewId == viewId; + + Future _switchPlayer({ + required ErikaPlayer oldPlayer, + required ErikaPlayer newPlayer, + required int viewId, + required int generation, + }) async { + await _detachIgnoringFailure(oldPlayer, viewId, 'player switch'); + if (!_attachmentIsCurrent(newPlayer, viewId, generation)) { + return; + } + await _attachWithRetry(newPlayer, viewId, generation); + } + + Future _attachWithRetry( + ErikaPlayer player, + int viewId, + int generation, + ) async { + Object? lastError; + for (var attempt = 0; ; attempt += 1) { + if (!_attachmentIsCurrent(player, viewId, generation)) { + return; + } + try { + await player.attachView(viewId); + return; + } catch (error) { + lastError = error; + } + if (attempt >= _attachRetryDelays.length) { + debugPrint( + 'ErikaAndroidVideoView: attach failed after ' + '${attempt + 1} attempts for view $viewId: $lastError', + ); + return; + } + await Future.delayed(_attachRetryDelays[attempt]); + } + } + + Future _detachIgnoringFailure( + ErikaPlayer player, + int viewId, + String reason, + ) async { + try { + await player.detachView(viewId); + } catch (error) { + debugPrint( + 'ErikaAndroidVideoView: detach failed during $reason for ' + 'view $viewId; native recovery remains active: $error', + ); + } + } + + @override + Widget build(BuildContext context) { + final extendedLinear = _usesExtendedLinearSurface(widget.player); + final surfaceGeneration = _surfaceGeneration; + final creationParams = { + if (widget.debugLabel case final label?) 'debugLabel': label, + 'outputMode': extendedLinear + ? ErikaOutputMode.extendedLinear.nativeValue + : ErikaOutputMode.sdr.nativeValue, + if (extendedLinear) + 'requestedHdrHeadroom': widget.player.edrHeadroom ?? 0.0, + if (extendedLinear) 'composition': 'hybrid', + if (widget.player.videoAlphaMode != ErikaVideoAlphaMode.opaque) + 'videoAlphaMode': widget.player.videoAlphaMode.nativeValue, + }; + if (!extendedLinear) { + return AndroidView( + key: const ValueKey('erika-android-sdr-texture-view'), + viewType: 'erika_flutter/video_view', + layoutDirection: TextDirection.ltr, + creationParamsCodec: const StandardMessageCodec(), + creationParams: creationParams, + onPlatformViewCreated: (int viewId) => + _handlePlatformViewCreated(viewId, surfaceGeneration), + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + gestureRecognizers: const >{}, + ); + } + + return PlatformViewLink( + key: ValueKey(_surfaceConfigurationKey(widget.player)), + viewType: 'erika_flutter/hdr_video_view', + surfaceFactory: + (BuildContext context, PlatformViewController controller) => + AndroidViewSurface( + controller: controller as AndroidViewController, + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + gestureRecognizers: + const >{}, + ), + onCreatePlatformView: (PlatformViewCreationParams params) { + final controller = PlatformViewsService.initExpensiveAndroidView( + id: params.id, + viewType: 'erika_flutter/hdr_video_view', + layoutDirection: TextDirection.ltr, + creationParamsCodec: const StandardMessageCodec(), + creationParams: creationParams, + onFocus: () => params.onFocusChanged(true), + ); + controller + ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) + ..addOnPlatformViewCreatedListener( + (int viewId) => + _handlePlatformViewCreated(viewId, surfaceGeneration), + ) + ..create(); + return controller; + }, + ); + } +} + +/// Window-hosted native video surface for macOS, iOS, and Windows. +/// +/// The widget reserves a Flutter layout rect while the platform plugin hosts a +/// sibling native view and keeps its frame in sync. +class ErikaWindowOverlayVideoView extends StatefulWidget { + const ErikaWindowOverlayVideoView({ + super.key, + required this.player, + this.debugLabel, + this.onPlatformViewIdChanged, + this.onFrameRectChanged, + this.blendMode = BlendMode.srcOver, + this.opacity = 1.0, + }) : assert(opacity >= 0.0 && opacity <= 1.0); + + final ErikaPlayer player; + final String? debugLabel; + final ValueChanged? onPlatformViewIdChanged; + final ValueChanged? onFrameRectChanged; + final BlendMode blendMode; + final double opacity; + + @override + State createState() => + _ErikaWindowOverlayVideoViewState(); +} + +class _ErikaWindowOverlayVideoViewState + extends State + with WidgetsBindingObserver { + Timer? _retryTimer; + Timer? _frameTimer; + int _bindAttempts = 0; + bool _isBound = false; + late final int _surfaceGeneration; + String? _lastFrameSignature; + + @override + void initState() { + super.initState(); + if (_usesAndroidTextureView) { + return; + } + WidgetsBinding.instance.addObserver(this); + _surfaceGeneration = identityHashCode(this); + widget.onPlatformViewIdChanged?.call(ErikaPlayer.windowOverlayViewId); + _startFrameTimer(); + _scheduleAttach(); + } + + @override + void didUpdateWidget(covariant ErikaWindowOverlayVideoView oldWidget) { + super.didUpdateWidget(oldWidget); + if (_usesAndroidTextureView) { + return; + } + if (oldWidget.player != widget.player) { + _retryTimer?.cancel(); + _bindAttempts = 0; + _isBound = false; + _lastFrameSignature = null; + unawaited( + oldWidget.player.detachWindowOverlay(generation: _surfaceGeneration), + ); + widget.onPlatformViewIdChanged?.call(ErikaPlayer.windowOverlayViewId); + _scheduleAttach(); + } else if (oldWidget.blendMode != widget.blendMode || + oldWidget.opacity != widget.opacity) { + _scheduleFrameUpdate(force: true); + } + } + + @override + void didChangeMetrics() { + if (_usesAndroidTextureView) { + return; + } + _scheduleFrameUpdate(force: true); + } + + @override + void dispose() { + if (_usesAndroidTextureView) { + widget.onFrameRectChanged?.call(null); + super.dispose(); + return; + } + WidgetsBinding.instance.removeObserver(this); + _retryTimer?.cancel(); + _frameTimer?.cancel(); + widget.onPlatformViewIdChanged?.call(null); + unawaited(_hideOverlayFrame()); + unawaited( + widget.player.detachWindowOverlay(generation: _surfaceGeneration), + ); + super.dispose(); + } + + void _startFrameTimer() { + _frameTimer?.cancel(); + final interval = defaultTargetPlatform == TargetPlatform.windows + ? const Duration(milliseconds: 16) + : const Duration(milliseconds: 250); + _frameTimer = Timer.periodic(interval, (_) => _scheduleFrameUpdate()); + } + + void _scheduleAttach() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + unawaited(_attachOverlaySurface()); + _scheduleFrameUpdate(force: true); + }); + } + + Future _attachOverlaySurface() async { + if (!mounted || _isBound || !_supportsWindowOverlayVideoView) { + return; + } + + try { + await widget.player.attachWindowOverlay( + flutterViewId: View.of(context).viewId, + blendMode: widget.blendMode.name, + opacity: widget.opacity, + ); + _isBound = true; + _scheduleFrameUpdate(force: true); + } catch (error) { + debugPrint('ErikaWindowOverlayVideoView: bind failed: $error'); + _scheduleRetry(); + } + } + + void _scheduleRetry() { + if (_isBound || !mounted) { + return; + } + final attempt = _bindAttempts; + _bindAttempts += 1; + final delay = switch (attempt) { + 0 => const Duration(milliseconds: 150), + 1 => const Duration(milliseconds: 300), + 2 => const Duration(milliseconds: 600), + 3 => const Duration(milliseconds: 1200), + _ => const Duration(seconds: 2), + }; + _retryTimer?.cancel(); + _retryTimer = Timer(delay, () => unawaited(_attachOverlaySurface())); + } + + void _scheduleFrameUpdate({bool force = false}) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + unawaited(_sendOverlayFrame(visible: true, force: force)); + }); + } + + Future _sendOverlayFrame({ + required bool visible, + bool force = false, + }) async { + if (!_supportsWindowOverlayVideoView) { + return; + } + + final Rect rect; + if (visible) { + if (!mounted) { + return; + } + final renderObject = context.findRenderObject(); + if (renderObject is! RenderBox) { + return; + } + final box = renderObject; + if (!box.hasSize || box.size.isEmpty) { + return; + } + rect = MatrixUtils.transformRect( + box.getTransformTo(null), + Offset.zero & box.size, + ); + } else { + rect = Rect.zero; + } + + final signature = [ + visible, + rect.left.toStringAsFixed(2), + rect.top.toStringAsFixed(2), + rect.width.toStringAsFixed(2), + rect.height.toStringAsFixed(2), + widget.blendMode.name, + widget.opacity.toStringAsFixed(4), + ].join('|'); + if (!force && signature == _lastFrameSignature) { + return; + } + _lastFrameSignature = signature; + widget.onFrameRectChanged?.call(visible ? rect : null); + + try { + await widget.player.setWindowOverlayFrame( + frame: rect, + visible: visible, + generation: _surfaceGeneration, + flutterViewId: View.of(context).viewId, + debugLabel: widget.debugLabel, + blendMode: widget.blendMode.name, + opacity: widget.opacity, + ); + } catch (error) { + debugPrint('ErikaWindowOverlayVideoView: frame update failed: $error'); + } + } + + Future _hideOverlayFrame() async { + try { + await widget.player.setWindowOverlayFrame( + frame: Rect.zero, + visible: false, + generation: _surfaceGeneration, + debugLabel: widget.debugLabel, + blendMode: widget.blendMode.name, + opacity: widget.opacity, + ); + } catch (error) { + debugPrint('ErikaWindowOverlayVideoView: hide overlay failed: $error'); + } + } + + @override + Widget build(BuildContext context) { + if (_usesAndroidTextureView) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final renderObject = context.findRenderObject(); + if (renderObject is RenderBox && renderObject.hasSize) { + final origin = renderObject.localToGlobal(Offset.zero); + widget.onFrameRectChanged?.call(origin & renderObject.size); + } + }); + return _ErikaAndroidVideoView( + player: widget.player, + debugLabel: widget.debugLabel, + onPlatformViewIdChanged: widget.onPlatformViewIdChanged, + ); + } + if (!_supportsWindowOverlayVideoView) { + return const SizedBox.shrink(); + } + _scheduleFrameUpdate(); + return const SizedBox.expand(); + } +} diff --git a/third_party/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift b/third_party/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift new file mode 100644 index 00000000..fdca48c3 --- /dev/null +++ b/third_party/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift @@ -0,0 +1,3695 @@ +import AppKit +import CoreVideo +import Darwin +import FlutterMacOS +import Metal +import MediaPlayer +import ObjectiveC.runtime +import QuartzCore + +private let erikaWindowHostedVideoSurfaceId: Int64 = -1 +private let erikaDebugLabelsEnabled = + ProcessInfo.processInfo.environment["ERIKA_DEBUG_LABELS"] == "1" +private let erikaWindowOverlayTraceEnabled = + ProcessInfo.processInfo.environment["ERIKA_WINDOW_OVERLAY_TRACE"] == "1" +private let erikaDefaultDisplayFps = 60.0 +private let erikaDisplayFpsEpsilon = 0.5 + +private func erikaWindowOverlayTrace(_ message: @autoclosure () -> String) { + guard erikaWindowOverlayTraceEnabled else { + return + } + NSLog("[ErikaWindowOverlay] %@", message()) +} + +/// Drives rendering from the display's vertical refresh instead of a main-run-loop +/// `Timer`. `CVDisplayLink` already owns a dedicated high-priority callback +/// thread, so render directly there instead of waking a second GCD worker for +/// every frame. The callback stays coalesced while a render is in progress so a +/// slow `CAMetalLayer.nextDrawable()` cannot build up callbacks and replay them. +private final class ErikaDisplayLinkDriver { + private let onTick: () -> Void + private let stateLock = NSLock() + private var displayLink: CVDisplayLink? + private var tickQueued = false + private var invalidated = false + + init?(displayID: CGDirectDisplayID, onTick: @escaping () -> Void) { + self.onTick = onTick + + var link: CVDisplayLink? + guard CVDisplayLinkCreateWithCGDisplay(displayID, &link) == kCVReturnSuccess, + let link else { + return nil + } + displayLink = link + + let status = CVDisplayLinkSetOutputCallback( + link, + { _, _, _, _, _, context in + guard let context else { + return kCVReturnError + } + let driver = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + driver.displayDidRefresh() + return kCVReturnSuccess + }, + Unmanaged.passUnretained(self).toOpaque() + ) + guard status == kCVReturnSuccess else { + displayLink = nil + return nil + } + } + + deinit { + invalidate() + } + + func start() -> Bool { + guard let displayLink else { + return false + } + return CVDisplayLinkStart(displayLink) == kCVReturnSuccess + } + + func invalidate() { + stateLock.lock() + invalidated = true + stateLock.unlock() + + if let displayLink, CVDisplayLinkIsRunning(displayLink) { + CVDisplayLinkStop(displayLink) + } + displayLink = nil + } + + private func displayDidRefresh() { + stateLock.lock() + guard !invalidated, !tickQueued else { + stateLock.unlock() + return + } + tickQueued = true + stateLock.unlock() + + stateLock.lock() + let shouldRender = !invalidated + stateLock.unlock() + + if shouldRender { + onTick() + } + + // Keep tickQueued set throughout onTick(). Any display refresh received + // while Metal is blocked is deliberately dropped rather than replayed. + stateLock.lock() + tickQueued = false + stateLock.unlock() + } +} + +/// A Flutter texture whose backing CVPixelBuffer is also exposed as a Metal +/// texture. Erika renders into the IOSurface-backed Metal texture directly; +/// Flutter then composites the same buffer without a CPU readback. +private final class ErikaFlutterTextureSurface: NSObject, FlutterTexture { + struct PreparedFrame { + let generation: UInt64 + let pixelBuffer: CVPixelBuffer + let cvMetalTexture: CVMetalTexture + let metalTexture: MTLTexture + let width: UInt32 + let height: UInt32 + } + + private let registry: FlutterTextureRegistry + private let lock = NSLock() + private let device: MTLDevice + private var textureCache: CVMetalTextureCache + private var pixelBufferPool: CVPixelBufferPool + private var readyPixelBuffer: CVPixelBuffer? + private var generation: UInt64 = 1 + private(set) var textureId: Int64 = 0 + private(set) var width: UInt32 + private(set) var height: UInt32 + private(set) var scale: Double + weak var attachedPlayer: ErikaPlayerHost? + + init?( + registry: FlutterTextureRegistry, + width: UInt32, + height: UInt32, + scale: Double + ) { + guard let device = MTLCreateSystemDefaultDevice(), + let resources = Self.makeResources(device: device, width: width, height: height) else { + return nil + } + self.registry = registry + self.device = device + textureCache = resources.textureCache + pixelBufferPool = resources.pixelBufferPool + self.width = width + self.height = height + self.scale = scale + super.init() + } + + func register() -> Int64 { + let id = registry.register(self) + textureId = id + return id + } + + func resize(width: UInt32, height: UInt32, scale: Double) -> Bool { + guard let resources = Self.makeResources(device: device, width: width, height: height) else { + return false + } + lock.lock() + generation &+= 1 + textureCache = resources.textureCache + pixelBufferPool = resources.pixelBufferPool + readyPixelBuffer = nil + self.width = width + self.height = height + self.scale = scale + lock.unlock() + return true + } + + func metrics() -> (width: UInt32, height: UInt32, scale: Double) { + lock.lock() + defer { lock.unlock() } + return (width, height, scale) + } + + func prepareFrame() -> PreparedFrame? { + lock.lock() + let cache = textureCache + let pool = pixelBufferPool + let frameWidth = width + let frameHeight = height + let frameGeneration = generation + lock.unlock() + + var pixelBuffer: CVPixelBuffer? + guard CVPixelBufferPoolCreatePixelBuffer( + kCFAllocatorDefault, + pool, + &pixelBuffer + ) == kCVReturnSuccess, let pixelBuffer else { + return nil + } + var cvMetalTexture: CVMetalTexture? + guard CVMetalTextureCacheCreateTextureFromImage( + kCFAllocatorDefault, + cache, + pixelBuffer, + nil, + .bgra8Unorm, + Int(frameWidth), + Int(frameHeight), + 0, + &cvMetalTexture + ) == kCVReturnSuccess, + let cvMetalTexture, + let metalTexture = CVMetalTextureGetTexture(cvMetalTexture) else { + return nil + } + return PreparedFrame( + generation: frameGeneration, + pixelBuffer: pixelBuffer, + cvMetalTexture: cvMetalTexture, + metalTexture: metalTexture, + width: frameWidth, + height: frameHeight + ) + } + + func publish(_ frame: PreparedFrame) { + lock.lock() + guard frame.generation == generation else { + lock.unlock() + return + } + readyPixelBuffer = frame.pixelBuffer + let id = textureId + lock.unlock() + if id != 0 { + registry.textureFrameAvailable(id) + } + } + + func copyPixelBuffer() -> Unmanaged? { + lock.lock() + defer { lock.unlock() } + guard let readyPixelBuffer else { + return nil + } + return Unmanaged.passRetained(readyPixelBuffer) + } + + private static func makeResources( + device: MTLDevice, + width: UInt32, + height: UInt32 + ) -> (textureCache: CVMetalTextureCache, pixelBufferPool: CVPixelBufferPool)? { + var textureCache: CVMetalTextureCache? + guard CVMetalTextureCacheCreate( + kCFAllocatorDefault, + nil, + device, + nil, + &textureCache + ) == kCVReturnSuccess, let textureCache else { + return nil + } + let poolAttributes: [CFString: Any] = [ + kCVPixelBufferPoolMinimumBufferCountKey: 3, + ] + let pixelBufferAttributes: [CFString: Any] = [ + kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey: Int(width), + kCVPixelBufferHeightKey: Int(height), + kCVPixelBufferIOSurfacePropertiesKey: [:], + kCVPixelBufferMetalCompatibilityKey: true, + ] + var pixelBufferPool: CVPixelBufferPool? + guard CVPixelBufferPoolCreate( + kCFAllocatorDefault, + poolAttributes as CFDictionary, + pixelBufferAttributes as CFDictionary, + &pixelBufferPool + ) == kCVReturnSuccess, let pixelBufferPool else { + return nil + } + return (textureCache, pixelBufferPool) + } +} + +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 ErikaTrackSelectionC { + var video: Int64 = -1 + var audio: Int64 = -1 + var subtitle: Int64 = -1 +} + +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 + var lumaUpscaler: Int32 = 0 + var videoAlphaMode: Int32 = 0 + + static let sdr = ErikaPresenterConfigC() + + static func appleEdr(headroom: Float) -> ErikaPresenterConfigC { + ErikaPresenterConfigC(outputMode: 1, edrHeadroom: max(1.0, headroom)) + } + + static func auto(headroom: Float) -> ErikaPresenterConfigC { + ErikaPresenterConfigC(outputMode: 3, 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 +} + +private struct ErikaSubtitleMemoryFontStatusC { + var registeredCount: UInt = 0 + var registeredBytes: UInt = 0 + var selectedCount: UInt = 0 + var generation: UInt64 = 0 + var selectedIds: UnsafeMutablePointer? +} + +// 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 +} + +// Keep field order and types aligned with `ErikaPresenterResourceStatus` in erika.h. +private struct ErikaPresenterResourceStatusC { + var deviceCurrentAllocatedBytes: UInt64 = 0 + var deviceRecommendedWorkingSetBytes: UInt64 = 0 + var drawableEstimatedBytes: UInt64 = 0 + var videoFrameBytes: UInt64 = 0 + var overlayAtlasBytes: UInt64 = 0 + var danmakuAtlasBytes: UInt64 = 0 + var danmakuVertexBufferBytes: UInt64 = 0 + var upscalerBytes: UInt64 = 0 + var rendererTrackedBytes: UInt64 = 0 + var presenterCpuDanmakuAtlasBytes: UInt64 = 0 + var drawableCount: UInt32 = 0 + var outputModeSwitches: UInt64 = 0 +} + +private struct ErikaDanmakuConfigC { + var enabled: UInt8 = 1 + // NipaPlay/Flutter logical font size; Erika applies the surface scale internally. + 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) + case libraryLoadFailed(String, String?) + + var description: String { + switch self { + case .libraryNotFound(let paths): + return "Unable to load liberika_capi.dylib. 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): + 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 { + // This symbol was added after ErikaTrackInfo gained its extended media + // metadata fields. Loading an older dylib with the current Swift struct + // would use a different record stride and corrupt the following pointers. + private static let currentTrackInfoAbiSymbol = "erika_presenter_get_output_status" + + typealias CreateFn = @convention(c) () -> UnsafeMutableRawPointer? + typealias CreateWithOutputModeFn = @convention(c) (Int32, Float) -> UnsafeMutableRawPointer? + typealias CreateWithOutputModeAndAlphaFn = @convention(c) ( + Int32, Float, Int32 + ) -> 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 RegisterSubtitleMemoryFontFn = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UInt, UnsafeMutablePointer?) -> Int32 + typealias SelectSubtitleMemoryFontsFn = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UInt) -> Int32 + typealias GetSubtitleMemoryFontStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias FreeSubtitleMemoryFontStatusFn = @convention(c) (UnsafeMutableRawPointer?) -> Void + typealias GetUpscalerStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias GetOutputStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias GetResourceStatusFn = @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 AttachFlutterTextureFn = @convention(c) ( + UnsafeMutableRawPointer?, Int32, Int64, UInt32, UInt32, Double + ) -> Int32 + typealias SetFlutterTextureBufferFn = @convention(c) ( + UnsafeMutableRawPointer?, UInt64, UInt32, UInt32 + ) -> 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 createWithOutputModeAndAlpha: CreateWithOutputModeAndAlphaFn? + 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 registerSubtitleMemoryFont: RegisterSubtitleMemoryFontFn? + let selectSubtitleMemoryFonts: SelectSubtitleMemoryFontsFn? + let clearSubtitleMemoryFonts: CommandFn? + let getSubtitleMemoryFontStatus: GetSubtitleMemoryFontStatusFn? + let freeSubtitleMemoryFontStatus: FreeSubtitleMemoryFontStatusFn? + let getUpscalerStatus: GetUpscalerStatusFn? + let getOutputStatus: GetOutputStatusFn? + let getResourceStatus: GetResourceStatusFn? + 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 attachFlutterTexture: AttachFlutterTextureFn + let setFlutterTextureBuffer: SetFlutterTextureBufferFn + 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 + + private init() throws { + let loaded = try Self.openLibrary() + libraryHandle = loaded.handle + + 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) + createWithOutputModeAndAlpha = Self.loadOptional( + "erika_presenter_create_with_output_mode_and_alpha", + from: libraryHandle, + as: CreateWithOutputModeAndAlphaFn.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) + registerSubtitleMemoryFont = Self.loadOptional("erika_presenter_register_subtitle_memory_font", from: libraryHandle, as: RegisterSubtitleMemoryFontFn.self) + selectSubtitleMemoryFonts = Self.loadOptional("erika_presenter_select_subtitle_memory_fonts", from: libraryHandle, as: SelectSubtitleMemoryFontsFn.self) + clearSubtitleMemoryFonts = Self.loadOptional("erika_presenter_clear_subtitle_memory_fonts", from: libraryHandle, as: CommandFn.self) + getSubtitleMemoryFontStatus = Self.loadOptional("erika_presenter_get_subtitle_memory_font_status", from: libraryHandle, as: GetSubtitleMemoryFontStatusFn.self) + freeSubtitleMemoryFontStatus = Self.loadOptional("erika_subtitle_memory_font_status_free", from: libraryHandle, as: FreeSubtitleMemoryFontStatusFn.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) + getResourceStatus = Self.loadOptional("erika_presenter_get_resource_status", from: libraryHandle, as: GetResourceStatusFn.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) + attachFlutterTexture = try Self.load("erika_presenter_attach_flutter_texture", from: libraryHandle, as: AttachFlutterTextureFn.self) + setFlutterTextureBuffer = try Self.load("erika_presenter_set_flutter_texture_buffer", from: libraryHandle, as: SetFlutterTextureBufferFn.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) + } + + deinit { + dlclose(libraryHandle) + } + + private static func openLibrary() throws -> (handle: UnsafeMutableRawPointer, path: String) { + let environment = ProcessInfo.processInfo.environment + let bundle = Bundle(for: ErikaFlutterPlugin.self) + var candidates: [String] = [] + + if let explicitPath = environment["ERIKA_CAPI_DYLIB"], !explicitPath.isEmpty { + candidates.append(explicitPath) + } + // CocoaPods builds the dylib inside the plugin framework. Prefer that + // freshly built copy over an app-level dylib that may be left behind by an + // older build. + if let appFrameworksPath = Bundle.main.privateFrameworksPath { + candidates.append( + URL(fileURLWithPath: appFrameworksPath) + .appendingPathComponent( + "erika_flutter.framework/Versions/A/Frameworks/liberika_capi.dylib" + ) + .path + ) + } + if let pluginExecutablePath = bundle.executablePath { + candidates.append( + URL(fileURLWithPath: pluginExecutablePath) + .deletingLastPathComponent() + .appendingPathComponent("Frameworks/liberika_capi.dylib") + .path + ) + } + if let pluginFrameworksPath = bundle.privateFrameworksPath { + candidates.append( + URL(fileURLWithPath: pluginFrameworksPath) + .appendingPathComponent("liberika_capi.dylib") + .path + ) + } + if let resourcePath = bundle.path(forResource: "liberika_capi", ofType: "dylib") { + candidates.append(resourcePath) + } + if let frameworkPath = Bundle.main.privateFrameworksPath { + candidates.append(URL(fileURLWithPath: frameworkPath).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) + } + if let sourceTreePath = Self.sourceTreeDebugLibraryPath() { + candidates.append(sourceTreePath) + } + candidates.append("liberika_capi.dylib") + + var failures: [ErikaPluginError] = [] + for path in candidates { + if let handle = dlopen(path, RTLD_NOW | RTLD_LOCAL) { + guard dlsym(handle, currentTrackInfoAbiSymbol) != nil else { + dlclose(handle) + failures.append( + .libraryLoadFailed( + path, + "incompatible Erika C ABI: missing \(currentTrackInfoAbiSymbol)" + ) + ) + continue + } + NSLog("ErikaFlutterPlugin: loaded Erika C API from \(path)") + return (handle, path) + } + let detail = dlerror().map { String(cString: $0) } + failures.append(.libraryLoadFailed(path, detail)) + } + throw ErikaPluginError.libraryNotFound(failures.map(String.init(describing:))) + } + + fileprivate static func sourceTreeDebugLibraryPath() -> String? { + let sourceFile = URL(fileURLWithPath: #filePath) + let erikaRoot = sourceFile + .deletingLastPathComponent() // Classes + .deletingLastPathComponent() // macos + .deletingLastPathComponent() // erika_flutter + .deletingLastPathComponent() // packages + .deletingLastPathComponent() // Erika repo root + return erikaRoot + .appendingPathComponent("target") + .appendingPathComponent("debug") + .appendingPathComponent("liberika_capi.dylib") + .path + } + + 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 createWithOutputModeAndAlpha { + return createWithOutputModeAndAlpha( + config.outputMode, + config.edrHeadroom, + config.videoAlphaMode + ) + } + if config.videoAlphaMode != 0 { + return nil + } + 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 let renderQueue: DispatchQueue + private let nativeCallLock = NSRecursiveLock() + private weak var attachedView: ErikaMetalSurfaceView? + private weak var attachedTexture: ErikaFlutterTextureSurface? + private var attachedViewId: Int64? + private var displayLinkDriver: ErikaDisplayLinkDriver? + private var displayLinkDisplayID: CGDirectDisplayID? + private var displayConfigurationObservers: [NSObjectProtocol] = [] + private var displayTimer: Timer? + private var displayTimerFps: Double = 0.0 + private var loggedRenderThread = false + private var startTimeSeconds: CFTimeInterval = CACurrentMediaTime() + private var currentDanmakuConfig = ErikaDanmakuConfigC() + private var latestPresenterStats = ErikaPresenterStatsC() + private(set) var nowPlayingTitle = "" + private(set) var nowPlayingArtist: String? + private(set) var nowPlayingAlbum: String? + private(set) var nowPlayingArtwork: MPMediaItemArtwork? + private(set) var durationSeconds: Double? + private(set) var positionSeconds = 0.0 + private(set) var playbackRate = 1.0 + private(set) var playbackState = 0 + private var positionUpdateTime = ProcessInfo.processInfo.systemUptime + var onNowPlayingChanged: ((ErikaPlayerHost) -> Void)? + + var isPlaying: Bool { playbackState == 3 } + + var isStopped: Bool { playbackState == 5 || playbackState == 6 || playbackState == 7 } + + var nowPlayingPositionSeconds: Double { + guard isPlaying else { return positionSeconds } + return positionSeconds + + max(0, ProcessInfo.processInfo.systemUptime - positionUpdateTime) * playbackRate + } + + init(id: Int64, library: ErikaNativeLibrary, config: ErikaPresenterConfigC) throws { + self.id = id + self.library = library + renderQueue = DispatchQueue( + label: "dev.aimesoft.erika.render.\(id)", + qos: .userInteractive + ) + guard let handle = library.createPresenter(config: config) else { + throw ErikaPluginError.presenterCreateFailed + } + self.handle = handle + refreshDanmakuConfigSnapshot() + } + + deinit { + stopDisplayDriver() + withNativeCall { + _ = library.detachSurface(handle) + library.destroy(handle) + } + } + + private func withNativeCall(_ operation: () throws -> T) rethrows -> T { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + return try operation() + } + + func open(uri: String, httpHeaders: [String: String]) throws { + playbackState = 0 + positionSeconds = 0 + durationSeconds = nil + positionUpdateTime = ProcessInfo.processInfo.systemUptime + if nowPlayingTitle.isEmpty { + let fallbackTitle = URL(string: uri)?.lastPathComponent.removingPercentEncoding + ?? URL(fileURLWithPath: uri).lastPathComponent + nowPlayingTitle = fallbackTitle.isEmpty ? "Erika" : fallbackTitle + } + try withNativeCall { + 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") + } + } + } + onNowPlayingChanged?(self) + } + + func play() throws { + try withNativeCall { + try check(library.play(handle), operation: "play") + } + } + + func pause() throws { + try withNativeCall { + try check(library.pause(handle), operation: "pause") + } + } + + func stop() throws { + try withNativeCall { + try check(library.stop(handle), operation: "stop") + } + } + + func close() throws { + try withNativeCall { + try check(library.close(handle), operation: "close") + } + } + + func seek(positionMicros: UInt64) throws { + try withNativeCall { + try check(library.seek(handle, positionMicros), operation: "seek") + } + positionSeconds = Double(positionMicros) / 1_000_000 + positionUpdateTime = ProcessInfo.processInfo.systemUptime + onNowPlayingChanged?(self) + } + + func setPlaybackRate(_ rate: Double) throws { + guard let setRate = library.setPlaybackRate else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_playback_rate") + } + try withNativeCall { + try check(setRate(handle, rate), operation: "set_playback_rate") + } + positionSeconds = nowPlayingPositionSeconds + positionUpdateTime = ProcessInfo.processInfo.systemUptime + playbackRate = rate + onNowPlayingChanged?(self) + } + + func setMediaMetadata(title: String, artist: String?, album: String?, artworkData: Data?) throws { + let artwork: MPMediaItemArtwork? + if let artworkData { + guard let image = NSImage(data: artworkData) else { + throw ErikaPluginError.invalidArguments("metadata.artwork must contain a supported image.") + } + artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + } else { + artwork = nil + } + nowPlayingTitle = title + nowPlayingArtist = artist + nowPlayingAlbum = album + nowPlayingArtwork = artwork + onNowPlayingChanged?(self) + } + + func clearMediaMetadata() { + nowPlayingTitle = "" + nowPlayingArtist = nil + nowPlayingAlbum = nil + nowPlayingArtwork = nil + onNowPlayingChanged?(self) + } + + func setVolume(_ volume: Double) throws { + 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 withNativeCall { + 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 withNativeCall { + 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 withNativeCall { + 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 = withNativeCall { + 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 = withNativeCall { + 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 = withNativeCall { + withUnsafeMutablePointer(to: &status) { pointer in + getStatus(handle, UnsafeMutableRawPointer(pointer)) + } + } + try check(result, operation: "get_upscaler_status") + return status.toFlutterMap() + } + + func registerSubtitleMemoryFont(_ data: Data) throws -> UInt64 { + guard let register = library.registerSubtitleMemoryFont else { throw ErikaPluginError.symbolMissing("erika_presenter_register_subtitle_memory_font") } + var fontId: UInt64 = 0 + let status = withNativeCall { + data.withUnsafeBytes { register(handle, $0.bindMemory(to: UInt8.self).baseAddress, UInt(data.count), &fontId) } + } + try check(status, operation: "register_subtitle_memory_font") + return fontId + } + + func selectSubtitleMemoryFonts(_ fontIds: [UInt64]) throws { + guard let select = library.selectSubtitleMemoryFonts else { throw ErikaPluginError.symbolMissing("erika_presenter_select_subtitle_memory_fonts") } + try withNativeCall { + try fontIds.withUnsafeBufferPointer { try check(select(handle, $0.baseAddress, UInt($0.count)), operation: "select_subtitle_memory_fonts") } + } + } + + func clearSubtitleMemoryFonts() throws { + guard let clear = library.clearSubtitleMemoryFonts else { throw ErikaPluginError.symbolMissing("erika_presenter_clear_subtitle_memory_fonts") } + try withNativeCall { + try check(clear(handle), operation: "clear_subtitle_memory_fonts") + } + } + + func subtitleMemoryFontStatus() throws -> [String: Any] { + guard let getStatus = library.getSubtitleMemoryFontStatus else { throw ErikaPluginError.symbolMissing("erika_presenter_get_subtitle_memory_font_status") } + var status = ErikaSubtitleMemoryFontStatusC() + defer { + if let freeStatus = library.freeSubtitleMemoryFontStatus { withUnsafeMutablePointer(to: &status) { freeStatus(UnsafeMutableRawPointer($0)) } } + } + try withNativeCall { + try withUnsafeMutablePointer(to: &status) { try check(getStatus(handle, UnsafeMutableRawPointer($0)), operation: "get_subtitle_memory_font_status") } + } + return ["registeredCount": Int(status.registeredCount), "registeredBytes": Int(status.registeredBytes), "selectedCount": Int(status.selectedCount), "generation": Int64(clamping: status.generation), "selectedIds": status.selectedIds.map { pointer in (0.. [String: Any] { + guard let getStatus = library.getOutputStatus else { + throw ErikaPluginError.symbolMissing("erika_presenter_get_output_status") + } + var status = ErikaOutputStatusC() + let result = withNativeCall { + withUnsafeMutablePointer(to: &status) { pointer in + getStatus(handle, UnsafeMutableRawPointer(pointer)) + } + } + try check(result, operation: "get_output_status") + return status.toFlutterMap() + } + + func resourceStatus() throws -> [String: Any] { + guard let getStatus = library.getResourceStatus else { + throw ErikaPluginError.symbolMissing("erika_presenter_get_resource_status") + } + var status = ErikaPresenterResourceStatusC() + let result = withNativeCall { + withUnsafeMutablePointer(to: &status) { pointer in + getStatus(handle, UnsafeMutableRawPointer(pointer)) + } + } + try check(result, operation: "get_resource_status") + return status.toFlutterMap() + } + + func presenterStats() -> [String: Any] { + withNativeCall { + latestPresenterStats.toFlutterMap() + } + } + + func addExternalSubtitle(uri: String) throws -> Int64 { + var trackId: Int64 = 0 + try withNativeCall { + try uri.withCString { cString in + try check( + library.addExternalSubtitle(handle, cString, &trackId), + operation: "add_external_subtitle" + ) + } + } + return trackId + } + + func removeSubtitleTrack(trackId: Int64) throws { + try withNativeCall { + 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 withNativeCall { + 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 withNativeCall { + 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 = withNativeCall { + 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 = withNativeCall { + 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 withNativeCall { + 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 withNativeCall { + 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 withNativeCall { + 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 withNativeCall { + 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") + } + return try withNativeCall { + 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 withNativeCall { + 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 withNativeCall { + 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 withNativeCall { + 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 = withNativeCall { + 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 = withNativeCall { + 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 family = family ?? "" + let filePath = filePath ?? "" + let status = withNativeCall { + 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 withNativeCall { + try json.withCString { cString in + try check(setBlockWords(handle, cString), operation: "set_danmaku_block_words") + } + } + refreshDanmakuConfigSnapshot() + } + + func selectAudioTrack(trackId: Int64?) throws { + try withNativeCall { + try check( + library.selectAudioTrack(handle, trackId ?? -1), + operation: "select_audio_track" + ) + } + } + + func selectSubtitleTrack(trackId: Int64?) throws { + try withNativeCall { + try check( + library.selectSubtitleTrack(handle, trackId ?? -1), + operation: "select_subtitle_track" + ) + } + } + + func tracks() throws -> [[String: Any]] { + return try withNativeCall { + 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 = withNativeCall { + 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 = withNativeCall { + 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 { + // Moving the surface between windows re-attaches an already running + // player. Only a genuinely new attachment restarts the host clock; a + // migration must not rewind the timeline the engine is already on. + let isReattach = attachedView != nil || attachedTexture != nil + attachedTexture?.attachedPlayer = nil + attachedTexture = nil + attachedView = view + attachedViewId = view.platformViewId + view.attachedPlayerId = id + erikaWindowOverlayTrace( + "attach player=\(id) surface=\(view.platformViewId) " + + "window=\((view as? NSView)?.window?.windowNumber ?? -1) " + + "drawable=\(view.metalLayer.drawableSize) reattach=\(isReattach)" + ) + try attachOrResize(view: view, attach: true) + startDisplayDriverIfNeeded(resetClock: !isReattach) + } + + func attach(texture: ErikaFlutterTextureSurface) throws { + let isReattach = attachedView != nil || attachedTexture != nil + attachedView?.attachedPlayerId = nil + attachedView = nil + attachedTexture?.attachedPlayer = nil + attachedTexture = texture + attachedViewId = texture.textureId + texture.attachedPlayer = self + let metrics = texture.metrics() + try withNativeCall { + try check( + library.attachFlutterTexture( + handle, + 1, // ErikaFlutterTextureKind_MacOsTextureRegistrar + texture.textureId, + metrics.width, + metrics.height, + metrics.scale + ), + operation: "attach_flutter_texture" + ) + } + startDisplayDriverIfNeeded(resetClock: !isReattach) + } + + func detach(viewId: Int64?) { + guard viewId == nil || attachedViewId == viewId else { + return + } + attachedView?.attachedPlayerId = nil + attachedTexture?.attachedPlayer = nil + attachedView = nil + attachedTexture = nil + attachedViewId = nil + stopDisplayDriver() + withNativeCall { + _ = library.detachSurface(handle) + } + } + + func resizeFromAttachedView() { + guard let view = attachedView else { + return + } + do { + try attachOrResize(view: view, attach: false) + startDisplayDriverIfNeeded(resetClock: false) + } catch { + NSLog("ErikaFlutterPlugin: resize failed: \(error)") + } + } + + func resizeFromAttachedTexture() { + guard let texture = attachedTexture else { + return + } + let metrics = texture.metrics() + do { + try withNativeCall { + try check( + library.resizeSurface(handle, metrics.width, metrics.height, metrics.scale), + operation: "resize_flutter_texture" + ) + } + startDisplayDriverIfNeeded(resetClock: false) + } catch { + NSLog("ErikaFlutterPlugin: texture resize failed: \(error)") + } + } + + func renderTick() { + if !loggedRenderThread { + loggedRenderThread = true + erikaWindowOverlayTrace( + "render driver player=\(id) mainThread=\(Thread.isMainThread)" + ) + } + let timeSeconds = CACurrentMediaTime() - startTimeSeconds + let textureSurface = attachedTexture + let preparedFrame = textureSurface?.prepareFrame() + if textureSurface != nil && preparedFrame == nil { + return + } + let previousStats = latestPresenterStats + var stats = ErikaPresenterStatsC() + let status = withNativeCall { + if let preparedFrame { + let rawTexture = UInt64(UInt(bitPattern: Unmanaged.passUnretained( + preparedFrame.metalTexture as AnyObject + ).toOpaque())) + let bufferStatus = library.setFlutterTextureBuffer( + handle, + rawTexture, + preparedFrame.width, + preparedFrame.height + ) + if bufferStatus != 0 { + return bufferStatus + } + } + let status = withUnsafeMutablePointer(to: &stats) { pointer in + library.renderTick(handle, timeSeconds, UnsafeMutableRawPointer(pointer)) + } + if status == 0 { + latestPresenterStats = stats + } + return status + } + if status != 0 { + NSLog("ErikaFlutterPlugin: render_tick failed with status \(status)") + } else if let textureSurface, let preparedFrame { + let renderedThisTick = + stats.renderedVideoFrames > previousStats.renderedVideoFrames || + stats.renderedTestFrames > previousStats.renderedTestFrames || + stats.overlayFrames > previousStats.overlayFrames || + stats.danmakuFrames > previousStats.danmakuFrames + guard renderedThisTick else { + return + } + textureSurface.publish(preparedFrame) + } + } + + func pollEvents(sendEvent: (([String: Any]) -> Void)?) { + withNativeCall { + while true { + var event = ErikaEventC() + let status = withUnsafeMutablePointer(to: &event) { pointer in + library.pollEvent(handle, UnsafeMutableRawPointer(pointer)) + } + if status == 0 { + if event.kind == 1 { + positionSeconds = nowPlayingPositionSeconds + positionUpdateTime = ProcessInfo.processInfo.systemUptime + playbackState = Int(event.state) + if isStopped { + positionSeconds = 0 + } + if playbackState == 6 { + durationSeconds = nil + } + } else if event.kind == 2 { + durationSeconds = event.durationMicros >= 0 + ? Double(event.durationMicros) / 1_000_000 + : nil + } else if event.kind == 3 { + positionSeconds = Double(event.positionMicros) / 1_000_000 + positionUpdateTime = ProcessInfo.processInfo.systemUptime + } + if event.kind == 1 || event.kind == 2 || event.kind == 3 { + onNowPlayingChanged?(self) + } + let message = event.kind == 9 || event.kind == 11 || event.kind == 12 + ? library.currentEventMessage() + : nil + sendEvent?(event.toFlutterMap(playerId: id, host: self, structuredMessage: message)) + continue + } + if status != 5 { + NSLog("ErikaFlutterPlugin: poll_event failed with status \(status)") + } + break + } + } + } + + private func attachOrResize(view: ErikaMetalSurfaceView, attach: Bool) throws { + 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.currentBackingScale + if attach { + let rawLayer = UInt64(UInt(bitPattern: Unmanaged.passUnretained(view.metalLayer).toOpaque())) + try withNativeCall { + try check( + library.attachMetalLayer(handle, rawLayer, width, height, scale), + operation: "attach_metal_layer" + ) + } + } else { + try withNativeCall { + try check( + library.resizeSurface(handle, width, height, scale), + operation: "resize_surface" + ) + } + } + } + + private func startDisplayDriverIfNeeded(resetClock: Bool) { + if resetClock { + startTimeSeconds = CACurrentMediaTime() + } + + // Keep the explicit FPS override as a diagnostic escape hatch. Normal + // playback follows the actual display refresh through CVDisplayLink. + if ProcessInfo.processInfo.environment["ERIKA_FLUTTER_TARGET_FPS"] == nil { + let displayID = resolvedDisplayID() + if displayLinkDriver != nil && displayLinkDisplayID == displayID { + return + } + + stopDisplayDriver() + let driver = ErikaDisplayLinkDriver(displayID: displayID) { [weak self] in + guard let self else { + return + } + self.renderTick() + } + if let driver, driver.start() { + displayLinkDriver = driver + displayLinkDisplayID = displayID + observeDisplayConfigurationChanges() + return + } + } + + startFallbackDisplayTimerIfNeeded() + } + + /// Re-targets the display link when the window changes screen, or when the + /// screen layout itself changes. + /// + /// A `CVDisplayLink` is bound to the display it was created for. Without + /// this, dragging the window to a second screen keeps it ticking at the old + /// refresh rate, and unplugging that screen stops the callbacks entirely — + /// with the fallback timer already torn down, rendering would simply stop. + private func observeDisplayConfigurationChanges() { + guard displayConfigurationObservers.isEmpty else { + return + } + let center = NotificationCenter.default + for name in [ + NSWindow.didChangeScreenNotification, + NSApplication.didChangeScreenParametersNotification, + ] { + let observer = center.addObserver( + forName: name, + object: nil, + queue: .main + ) { [weak self] _ in + self?.retargetDisplayDriverIfScreenChanged() + } + displayConfigurationObservers.append(observer) + } + } + + private func retargetDisplayDriverIfScreenChanged() { + guard displayLinkDriver != nil else { + return + } + let displayID = resolvedDisplayID() + guard displayID != displayLinkDisplayID else { + return + } + // Rebuild on the new display; never reset the host clock, this is not a + // new playback session. + startDisplayDriverIfNeeded(resetClock: false) + } + + private func stopObservingDisplayConfigurationChanges() { + for observer in displayConfigurationObservers { + NotificationCenter.default.removeObserver(observer) + } + displayConfigurationObservers.removeAll() + } + + private func startFallbackDisplayTimerIfNeeded() { + let targetFps = resolvedDisplayTimerFps() + if displayTimer != nil && abs(displayTimerFps - targetFps) <= erikaDisplayFpsEpsilon { + return + } + stopDisplayDriver() + let timer = Timer(timeInterval: 1.0 / targetFps, repeats: true) { [weak self] _ in + guard let self else { + return + } + self.renderQueue.async { [weak self] in + self?.renderTick() + } + } + displayTimer = timer + displayTimerFps = targetFps + RunLoop.main.add(timer, forMode: .common) + } + + private func stopDisplayDriver() { + stopObservingDisplayConfigurationChanges() + displayLinkDriver?.invalidate() + displayLinkDriver = nil + displayLinkDisplayID = nil + displayTimer?.invalidate() + displayTimer = nil + displayTimerFps = 0.0 + } + + private func resolvedDisplayID() -> CGDirectDisplayID { + let screen = (attachedView as? NSView)?.window?.screen ?? NSScreen.main + let screenNumberKey = NSDeviceDescriptionKey("NSScreenNumber") + if let number = screen?.deviceDescription[screenNumberKey] as? NSNumber { + return CGDirectDisplayID(number.uint32Value) + } + return CGMainDisplayID() + } + + private func resolvedDisplayTimerFps() -> Double { + if let override = ProcessInfo.processInfo.environment["ERIKA_FLUTTER_TARGET_FPS"], + let fps = Double(override), fps.isFinite, fps > 0.0 { + return min(max(fps, 1.0), 1000.0) + } + let screen = (attachedView as? NSView)?.window?.screen ?? NSScreen.main + if #available(macOS 12.0, *), let screen = screen { + let fps = Double(screen.maximumFramesPerSecond) + if fps.isFinite && fps > 0.0 { + return fps + } + } + return erikaDefaultDisplayFps + } + + private func check(_ status: Int32, operation: String) throws { + if status != 0 { + throw ErikaPluginError.erikaStatus(operation, status) + } + } +} + +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 ErikaPresenterResourceStatusC { + func toFlutterMap() -> [String: Any] { + [ + "deviceCurrentAllocatedBytes": Int64(clamping: deviceCurrentAllocatedBytes), + "deviceRecommendedWorkingSetBytes": Int64(clamping: deviceRecommendedWorkingSetBytes), + "drawableEstimatedBytes": Int64(clamping: drawableEstimatedBytes), + "videoFrameBytes": Int64(clamping: videoFrameBytes), + "overlayAtlasBytes": Int64(clamping: overlayAtlasBytes), + "danmakuAtlasBytes": Int64(clamping: danmakuAtlasBytes), + "danmakuVertexBufferBytes": Int64(clamping: danmakuVertexBufferBytes), + "upscalerBytes": Int64(clamping: upscalerBytes), + "rendererTrackedBytes": Int64(clamping: rendererTrackedBytes), + "presenterCpuDanmakuAtlasBytes": Int64(clamping: presenterCpuDanmakuAtlasBytes), + "drawableCount": Int(drawableCount), + "outputModeSwitches": Int64(clamping: outputModeSwitches), + ] + } +} + +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) + } +} + +private protocol ErikaMetalSurfaceView: AnyObject { + var platformViewId: Int64 { get } + var metalLayer: CAMetalLayer { get } + var attachedPlayerId: Int64? { get set } + var bounds: NSRect { get } + var currentBackingScale: Double { get } + + func updateDrawableSize() + func pngSnapshotData() -> Data? +} + +private final class WeakErikaVideoPlatformViewBox { + weak var view: (NSView & ErikaMetalSurfaceView)? + + init(view: NSView & ErikaMetalSurfaceView) { + self.view = view + } +} + +final class ErikaVideoPlatformView: NSView, ErikaMetalSurfaceView { + let platformViewId: Int64 + let metalLayer: CAMetalLayer + + weak var plugin: ErikaFlutterPlugin? + var attachedPlayerId: Int64? + + var currentBackingScale: Double { + let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0 + return Double(max(1.0, scale)) + } + + init(viewId: Int64, arguments: Any?, plugin: ErikaFlutterPlugin?) { + platformViewId = viewId + self.plugin = plugin + metalLayer = CAMetalLayer() + super.init(frame: .zero) + + wantsLayer = true + metalLayer.pixelFormat = .bgra8Unorm + metalLayer.framebufferOnly = true + let params = arguments as? [String: Any] + let alphaVideo = (params?["videoAlphaMode"] as? NSNumber)?.intValue != 0 + metalLayer.isOpaque = !alphaVideo + metalLayer.backgroundColor = alphaVideo + ? NSColor.clear.cgColor + : NSColor.black.cgColor + if params?["blendMode"] as? String == "overlay" { + metalLayer.compositingFilter = "overlayBlendMode" + } + if let opacity = (params?["opacity"] as? NSNumber)?.doubleValue { + metalLayer.opacity = Float(min(max(opacity, 0.0), 1.0)) + } + layer = metalLayer + layerContentsRedrawPolicy = .duringViewResize + autoresizingMask = [.width, .height] + + if let params, + let debugLabel = params["debugLabel"] as? String, + !debugLabel.isEmpty, + ProcessInfo.processInfo.environment["ERIKA_DEBUG_LABELS"] == "1" { + let label = NSTextField(labelWithString: debugLabel) + label.textColor = NSColor(white: 1.0, alpha: 0.4) + label.font = NSFont.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), + ]) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + plugin?.unregisterView(viewId: platformViewId) + } + + override func layout() { + super.layout() + updateDrawableSize() + plugin?.resizePlayerAttachedToView(viewId: platformViewId) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateDrawableSize() + plugin?.resizePlayerAttachedToView(viewId: platformViewId) + } + + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + updateDrawableSize() + plugin?.resizePlayerAttachedToView(viewId: platformViewId) + } + + func updateDrawableSize() { + let scale = CGFloat(currentBackingScale) + let width = max(1.0, bounds.width * scale) + let height = max(1.0, bounds.height * scale) + metalLayer.frame = bounds + metalLayer.drawableSize = CGSize(width: width, height: height) + } + + func pngSnapshotData() -> Data? { + snapshotPngData(of: self) + } +} + +final class ErikaWindowOverlayView: NSView, ErikaMetalSurfaceView { + let platformViewId: Int64 = erikaWindowHostedVideoSurfaceId + let metalLayer: CAMetalLayer + + weak var plugin: ErikaFlutterPlugin? + var attachedPlayerId: Int64? + + private var overlayFrameGeneration: Int64? + private var lastTraceSignature: String? + + /// Generation of the widget that currently owns this shared overlay surface. + /// Used to reject stale detach calls from disposed widgets. + var activeGeneration: Int64? { overlayFrameGeneration } + + var currentBackingScale: Double { + let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0 + return Double(max(1.0, scale)) + } + + init(plugin: ErikaFlutterPlugin?) { + self.plugin = plugin + metalLayer = CAMetalLayer() + super.init(frame: .zero) + + wantsLayer = true + metalLayer.pixelFormat = .bgra8Unorm + metalLayer.framebufferOnly = true + metalLayer.isOpaque = true + metalLayer.backgroundColor = NSColor.black.cgColor + layer = metalLayer + layerContentsRedrawPolicy = .duringViewResize + autoresizingMask = [.width, .height] + isHidden = true + 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 hitTest(_ point: NSPoint) -> NSView? { + nil + } + + override func layout() { + super.layout() + updateDrawableSize() + plugin?.resizePlayerAttachedToView(viewId: platformViewId) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + erikaWindowOverlayTrace( + "surface moved window=\(window?.windowNumber ?? -1) frame=\(frame)" + ) + updateDrawableSize() + plugin?.resizePlayerAttachedToView(viewId: platformViewId) + } + + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + 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 + } + + toolTip = erikaDebugLabelsEnabled ? debugLabel : nil + let shouldShow = visible && + (frame?.width ?? 0) > 0 && + (frame?.height ?? 0) > 0 + if erikaWindowOverlayTraceEnabled { + let signature = + "\(window?.windowNumber ?? -1)|\(visible)|\(generation ?? -1)|\(String(describing: frame))" + if signature != lastTraceSignature { + lastTraceSignature = signature + erikaWindowOverlayTrace( + "frame window=\(window?.windowNumber ?? -1) visible=\(visible) " + + "generation=\(generation ?? -1) requested=\(String(describing: frame))" + ) + } + } + + 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(currentBackingScale) + let width = max(1.0, bounds.width * scale) + let height = max(1.0, bounds.height * scale) + metalLayer.frame = bounds + metalLayer.drawableSize = CGSize(width: width, height: height) + } + + func pngSnapshotData() -> Data? { + snapshotPngData(of: self) + } +} + +private func snapshotPngData(of view: NSView) -> Data? { + guard view.bounds.width > 0, view.bounds.height > 0 else { + return nil + } + guard let representation = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { + return nil + } + view.cacheDisplay(in: view.bounds, to: representation) + return representation.representation(using: .png, properties: [:]) +} + +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(withViewIdentifier viewId: Int64, arguments args: Any?) -> NSView { + let view = ErikaVideoPlatformView(viewId: viewId, arguments: args, plugin: plugin) + plugin?.registerView(view, viewId: viewId) + return view + } +} + +private enum ErikaAssociatedObjectKeys { + static var windowOverlayView: UInt8 = 0 +} + +private extension NSWindow { + 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 let textureRegistry: FlutterTextureRegistry + private var textures: [Int64: ErikaFlutterTextureSurface] = [:] + private weak var flutterHostView: NSView? + private weak var flutterHostViewController: NSViewController? + private var requestedFlutterViewIdentifier: Int64? + private var requestedSecondaryWindow = false + private var windowOverlayPlayerIds: Set = [] + private var nextPlayerId: Int64 = 1 + private var pollTimer: Timer? + private var activePlayerId: Int64? + private var remoteCommandTargets: [(MPRemoteCommand, Any)] = [] + private var systemMediaNavigation: [Int64: (previousEnabled: Bool, nextEnabled: Bool)] = [:] + + init( + flutterHostView: NSView?, + flutterHostViewController: NSViewController?, + textureRegistry: FlutterTextureRegistry + ) { + self.flutterHostView = flutterHostView + self.flutterHostViewController = flutterHostViewController + self.textureRegistry = textureRegistry + super.init() + } + + deinit { + pollTimer?.invalidate() + clearNowPlayingInfo() + remoteCommandTargets.forEach { command, target in + command.isEnabled = false + command.removeTarget(target) + } + for textureId in textures.keys { + textureRegistry.unregisterTexture(textureId) + } + } + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = ErikaFlutterPlugin( + flutterHostView: registrar.view, + flutterHostViewController: registrar.viewController, + textureRegistry: registrar.textures + ) + instance.configureSystemPlayback() + 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") + windowOverlayPlayerIds.remove(playerId) + players.removeValue(forKey: playerId) + stopPollTimerIfIdle() + systemMediaNavigation.removeValue(forKey: playerId) + if activePlayerId == playerId { + activePlayerId = nil + clearNowPlayingInfo() + refreshRemoteCommands() + } + 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]) ?? [:] + if let metadata = args["metadata"] as? [String: Any] { + try applyMediaMetadata(metadata, to: host) + } else { + host.clearMediaMetadata() + } + try host.open(uri: uri, httpHeaders: headers) + result(nil) + case "play": + let host = try playerHost(from: try dictionaryArgs(call.arguments)) + try host.play() + activePlayerId = host.id + refreshRemoteCommands() + publishNowPlayingInfo(for: host) + result(nil) + case "pause": + try playerHost(from: try dictionaryArgs(call.arguments)).pause() + 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 "createTexture": + let args = try dictionaryArgs(call.arguments) + let width = UInt32(clamping: max(1, int64Value(args["width"]) ?? 1)) + let height = UInt32(clamping: max(1, int64Value(args["height"]) ?? 1)) + let scale = max(0.1, doubleValue(args["scale"]) ?? 1.0) + guard width <= 16384, height <= 16384, + let texture = ErikaFlutterTextureSurface( + registry: textureRegistry, + width: width, + height: height, + scale: scale + ) else { + throw ErikaPluginError.invalidArguments("Unable to create Flutter texture surface.") + } + let textureId = texture.register() + guard textureId != 0 else { + throw ErikaPluginError.invalidArguments("Flutter rejected the texture surface.") + } + textures[textureId] = texture + result(textureId) + case "resizeTexture": + let args = try dictionaryArgs(call.arguments) + let textureId = try requiredInt64(args["textureId"], name: "textureId") + guard let texture = textures[textureId] else { + throw ErikaPluginError.viewNotFound(textureId) + } + let width = UInt32(clamping: max(1, int64Value(args["width"]) ?? 1)) + let height = UInt32(clamping: max(1, int64Value(args["height"]) ?? 1)) + let scale = max(0.1, doubleValue(args["scale"]) ?? 1.0) + guard width <= 16384, height <= 16384, + texture.resize(width: width, height: height, scale: scale) else { + throw ErikaPluginError.invalidArguments("Unable to resize Flutter texture surface.") + } + texture.attachedPlayer?.resizeFromAttachedTexture() + result(nil) + case "releaseTexture": + let args = try dictionaryArgs(call.arguments) + let textureId = try requiredInt64(args["textureId"], name: "textureId") + if let texture = textures.removeValue(forKey: textureId) { + texture.attachedPlayer?.detach(viewId: textureId) + textureRegistry.unregisterTexture(textureId) + } + result(nil) + case "seek": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + let positionMicros = try requiredUInt64(args["positionMicros"], name: "positionMicros") + try host.seek(positionMicros: positionMicros) + result(nil) + case "setPlaybackRate": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let rate = doubleValue(args["rate"]) else { + throw ErikaPluginError.invalidArguments("rate is required.") + } + try host.setPlaybackRate(rate) + result(nil) + case "setMediaMetadata": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let metadata = args["metadata"] as? [String: Any] else { + throw ErikaPluginError.invalidArguments("metadata is required.") + } + try applyMediaMetadata(metadata, to: host) + result(nil) + case "setSystemMediaNavigation": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + systemMediaNavigation[host.id] = ( + previousEnabled: boolValue(args["previousEnabled"]) ?? false, + nextEnabled: boolValue(args["nextEnabled"]) ?? false + ) + if activePlayerId == host.id { + refreshRemoteCommands() + publishNowPlayingInfo(for: host) + } + result(nil) + case "setVolume": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let volume = doubleValue(args["volume"]) else { + throw ErikaPluginError.invalidArguments("volume is required.") + } + try host.setVolume(volume) + result(nil) + case "setUpscaler": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let mode = int32Value(args["mode"]) else { + throw ErikaPluginError.invalidArguments("mode is required.") + } + try host.setUpscaler(mode: mode) + result(nil) + case "setSubtitleScale": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let scale = doubleValue(args["scale"]) else { + throw ErikaPluginError.invalidArguments("scale is required.") + } + try host.setSubtitleScale(scale) + result(nil) + case "registerSubtitleMemoryFont": + let args = try dictionaryArgs(call.arguments) + guard let data = args["data"] as? FlutterStandardTypedData else { throw ErikaPluginError.invalidArguments("data is required.") } + result(Int64(clamping: try playerHost(from: args).registerSubtitleMemoryFont(data.data))) + case "selectSubtitleMemoryFonts": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).selectSubtitleMemoryFonts((args["fontIds"] as? [NSNumber] ?? []).map { $0.uint64Value }) + result(nil) + case "clearSubtitleMemoryFonts": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).clearSubtitleMemoryFonts() + result(nil) + case "getSubtitleMemoryFontStatus": + let args = try dictionaryArgs(call.arguments) + result(try playerHost(from: args).subtitleMemoryFontStatus()) + 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 "getResourceStatus": + let args = try dictionaryArgs(call.arguments) + result(try playerHost(from: args).resourceStatus()) + 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) + let host = try playerHost(from: args) + guard let uri = args["uri"] as? String, !uri.isEmpty else { + throw ErikaPluginError.invalidArguments("uri is required.") + } + result(try host.addExternalSubtitle(uri: uri)) + case "removeSubtitleTrack": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + let trackId = try requiredInt64(args["trackId"], name: "trackId") + try host.removeSubtitleTrack(trackId: trackId) + result(nil) + case "loadDanmakuFile": + 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.") + } + try host.loadDanmakuFile(uri: uri) + result(nil) + case "loadDanmakuJson": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let json = args["json"] as? String, !json.isEmpty else { + throw ErikaPluginError.invalidArguments("json is required.") + } + try host.loadDanmakuJson(json) + result(nil) + case "addDanmakuTrackFile": + 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 offsetMicros = int64Value(args["offsetMicros"]) ?? 0 + result(Int64(clamping: try host.addDanmakuTrackFile( + uri: uri, + name: args["name"] as? String, + offsetMicros: offsetMicros + ))) + case "addDanmakuTrackJson": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let json = args["json"] as? String, !json.isEmpty else { + throw ErikaPluginError.invalidArguments("json is required.") + } + let offsetMicros = int64Value(args["offsetMicros"]) ?? 0 + result(Int64(clamping: try host.addDanmakuTrackJson( + json, + name: args["name"] as? String, + offsetMicros: offsetMicros + ))) + case "removeDanmakuTrack": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + try host.removeDanmakuTrack(trackId: try requiredUInt64(args["trackId"], name: "trackId")) + result(nil) + case "setDanmakuTrackEnabled": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + try host.setDanmakuTrackEnabled( + trackId: try requiredUInt64(args["trackId"], name: "trackId"), + enabled: boolValue(args["enabled"]) ?? true + ) + result(nil) + case "setDanmakuTrackOffset": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + try host.setDanmakuTrackOffset( + trackId: try requiredUInt64(args["trackId"], name: "trackId"), + offsetMicros: int64Value(args["offsetMicros"]) ?? 0 + ) + result(nil) + case "setDanmakuGlobalOffset": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + try host.setDanmakuGlobalOffset(offsetMicros: int64Value(args["offsetMicros"]) ?? 0) + result(nil) + case "danmakuTracks": + let host = try playerHost(from: try dictionaryArgs(call.arguments)) + result(try host.danmakuTracks()) + case "clearDanmaku": + let host = try playerHost(from: try dictionaryArgs(call.arguments)) + try host.clearDanmaku() + result(nil) + case "setDanmakuEnabled": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + try host.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) + let host = try playerHost(from: args) + try host.selectAudioTrack(trackId: optionalTrackId(args["trackId"])) + result(nil) + case "selectSubtitleTrack": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + try host.selectSubtitleTrack(trackId: optionalTrackId(args["trackId"])) + result(nil) + case "tracks": + let host = try playerHost(from: try dictionaryArgs(call.arguments)) + result(try host.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") + if let texture = textures[viewId] { + try host.attach(texture: texture) + } else if let view = views[viewId]?.view { + try host.attach(view: view) + } else { + throw ErikaPluginError.viewNotFound(viewId) + } + 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) + if viewId == erikaWindowHostedVideoSurfaceId { + windowOverlayPlayerIds.remove(host.id) + } + result(nil) + case "attachOverlay": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + updateRequestedFlutterView(from: args) + let installation = try ensureWindowOverlayInstalled() + erikaWindowOverlayTrace( + "attach request player=\(host.id) flutterView=\(requestedFlutterViewIdentifier ?? -1) " + + "secondary=\(requestedSecondaryWindow) targetWindow=\(installation.hostWindow.windowNumber)" + ) + let overlay = installation.overlay + try host.attach(view: overlay) + windowOverlayPlayerIds.insert(host.id) + 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) + windowOverlayPlayerIds.remove(host.id) + overlay?.updateOverlayFrame(nil, visible: false, debugLabel: nil, generation: generation) + result(nil) + case "setOverlayFrame": + let args = try dictionaryArgs(call.arguments) + let visible = boolValue(args["visible"]) ?? true + let generation = int64Value(args["generation"]) + if !visible, + let generation, + let activeGeneration = resolveWindowOverlay()?.activeGeneration, + generation != activeGeneration { + result(nil) + return + } + updateRequestedFlutterView(from: args) + let installation = try ensureWindowOverlayInstalled() + let overlay = installation.overlay + let frame = convertedOverlayRect( + from: args, + sourceView: installation.flutterHostView, + targetView: overlay + ) + overlay.updateOverlayFrame( + frame, + visible: visible, + debugLabel: args["debugLabel"] as? String, + generation: generation + ) + if installation.movedBetweenWindows { + // A wgpu/Metal surface retains the CAMetalLayer pointer, but moving + // that layer into another NSWindow invalidates its drawable chain on + // some macOS/Flutter combinations. Re-attach after the new frame and + // backing scale are known so rendering resumes in the destination. + for playerId in windowOverlayPlayerIds { + if let attachedHost = players[playerId] { + try attachedHost.attach(view: overlay) + } + } + } + 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 + return nil + } + + fileprivate func registerView(_ view: NSView & 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 struct WindowOverlayInstallation { + let overlay: ErikaWindowOverlayView + let flutterHostView: NSView + let hostWindow: NSWindow + let movedBetweenWindows: Bool + } + + private func ensureWindowOverlayInstalled() throws -> WindowOverlayInstallation { + guard let activeFlutterHostView else { + erikaWindowOverlayTrace( + "target unavailable flutterView=\(requestedFlutterViewIdentifier ?? -1) " + + "secondary=\(requestedSecondaryWindow); refusing main-window fallback" + ) + throw ErikaPluginError.overlayNotAvailable + } + guard let hostWindow = activeFlutterHostView.window else { + throw ErikaPluginError.overlayNotAvailable + } + guard let hostSuperview = activeFlutterHostView.superview else { + throw ErikaPluginError.overlayNotAvailable + } + + let existingOverlay = resolveWindowOverlay() + // `setOverlayFrame` runs on every geometry update. Once the overlay is + // already mounted in the right place there is nothing to install, and + // re-adding it would restart the whole attach path (viewDidMoveToWindow → + // updateDrawableSize → resize → display driver) on every resize frame. + if let existingOverlay, + existingOverlay.superview === hostSuperview, + existingOverlay.window === hostWindow { + existingOverlay.plugin = self + return WindowOverlayInstallation( + overlay: existingOverlay, + flutterHostView: activeFlutterHostView, + hostWindow: hostWindow, + movedBetweenWindows: false + ) + } + + prepareFlutterHostViewForWindowOverlay(activeFlutterHostView) + + let overlay = existingOverlay ?? + hostWindow.erikaWindowOverlayView ?? + ErikaWindowOverlayView(plugin: self) + let previousWindow = overlay.window + overlay.plugin = self + + if overlay.superview !== hostSuperview { + overlay.removeFromSuperview() + overlay.frame = .zero + overlay.translatesAutoresizingMaskIntoConstraints = true + } + hostSuperview.addSubview( + overlay, + positioned: shouldPlaceWindowOverlayAboveFlutter() ? .above : .below, + relativeTo: activeFlutterHostView + ) + + if previousWindow !== hostWindow { + previousWindow?.erikaWindowOverlayView = nil + erikaWindowOverlayTrace( + "move surface flutterView=\(requestedFlutterViewIdentifier ?? -1) " + + "secondary=\(requestedSecondaryWindow) " + + "window=\(previousWindow?.windowNumber ?? -1)->\(hostWindow.windowNumber)" + ) + } + hostWindow.erikaWindowOverlayView = overlay + registerView(overlay, viewId: overlay.platformViewId) + if existingOverlay == nil { + for playerId in windowOverlayPlayerIds { + if let host = players[playerId] { + try host.attach(view: overlay) + } + } + } + return WindowOverlayInstallation( + overlay: overlay, + flutterHostView: activeFlutterHostView, + hostWindow: hostWindow, + movedBetweenWindows: existingOverlay != nil && previousWindow !== hostWindow + ) + } + + private func updateRequestedFlutterView(from args: [String: Any]) { + if let flutterViewIdentifier = int64Value(args["flutterViewId"]) { + requestedFlutterViewIdentifier = flutterViewIdentifier + } + if let secondaryWindow = boolValue(args["secondaryWindow"]) { + requestedSecondaryWindow = secondaryWindow + } + } + + /// The player widget can move between FlutterViews without rebuilding its + /// State. Resolve the native host from the view id sent by Dart so the same + /// Metal overlay follows the widget into (and back out of) a detached window. + private var activeFlutterHostView: NSView? { + if let requestedFlutterViewIdentifier { + return NSApp.windows + .compactMap({ $0.contentViewController as? FlutterViewController }) + .first(where: { + Int64($0.viewIdentifier) == requestedFlutterViewIdentifier + })? + .view + } + if let keyController = NSApp.keyWindow?.contentViewController + as? FlutterViewController { + return keyController.view + } + if let mainController = NSApp.mainWindow?.contentViewController + as? FlutterViewController { + return mainController.view + } + return flutterHostView ?? flutterHostViewController?.view + } + + private func resolveWindowOverlay() -> ErikaWindowOverlayView? { + if let hostWindow = activeFlutterHostView?.window, + let overlay = hostWindow.erikaWindowOverlayView { + return overlay + } + if let hostWindow = flutterHostView?.window, + let overlay = hostWindow.erikaWindowOverlayView { + return overlay + } + if let hostWindow = flutterHostViewController?.view.window, + let overlay = hostWindow.erikaWindowOverlayView { + return overlay + } + if let overlay = NSApp.keyWindow?.erikaWindowOverlayView { + return overlay + } + if let overlay = NSApp.mainWindow?.erikaWindowOverlayView { + return overlay + } + return NSApp.windows.compactMap(\.erikaWindowOverlayView).first + } + + 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 prepareFlutterHostViewForWindowOverlay(_ view: NSView) { + if shouldPlaceWindowOverlayAboveFlutter() { + return + } + view.wantsLayer = true + view.layer?.isOpaque = false + view.layer?.backgroundColor = NSColor.clear.cgColor + if let targetController = view.window?.contentViewController + as? FlutterViewController { + targetController.backgroundColor = .clear + } + view.window?.isOpaque = false + view.window?.backgroundColor = .clear + } + + private func convertedOverlayRect( + from args: [String: Any], + sourceView: NSView, + targetView: NSView + ) -> 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 targetSuperview = targetView.superview else { + return CGRect(x: x, y: y, width: width, height: height) + } + let sourceY = sourceView.isFlipped + ? y + : sourceView.bounds.height - y - height + let rect = CGRect(x: x, y: sourceY, width: width, height: height) + return sourceView.convert(rect, to: targetSuperview) + } + + private func createPlayer(arguments: Any?) throws -> Int64 { + guard let library = ErikaNativeLibrary.shared else { + throw ErikaPluginError.libraryNotFound([ + ProcessInfo.processInfo.environment["ERIKA_CAPI_DYLIB"] ?? "", + ErikaNativeLibrary.sourceTreeDebugLibraryPath() ?? "", + "liberika_capi.dylib", + ].filter { !$0.isEmpty }) + } + let id = nextPlayerId + nextPlayerId += 1 + let host = try ErikaPlayerHost( + id: id, + library: library, + config: presenterConfigForNewPlayer(arguments: arguments) + ) + host.onNowPlayingChanged = { [weak self] changedHost in + guard self?.activePlayerId == changedHost.id else { return } + self?.handleNowPlayingChanged(for: changedHost) + } + players[id] = host + systemMediaNavigation[id] = (previousEnabled: false, nextEnabled: false) + startPollTimerIfNeeded() + return id + } + + private func configureSystemPlayback() { + let commands = MPRemoteCommandCenter.shared() + addRemoteTarget(commands.playCommand) { [weak self] _ in + self?.performRemoteCommand { try $0.play() } ?? .commandFailed + } + addRemoteTarget(commands.pauseCommand) { [weak self] _ in + self?.performRemoteCommand { try $0.pause() } ?? .commandFailed + } + addRemoteTarget(commands.togglePlayPauseCommand) { [weak self] _ in + self?.performRemoteCommand { host in + if host.isPlaying { + try host.pause() + } else { + try host.play() + } + } ?? .commandFailed + } + addRemoteTarget(commands.stopCommand) { [weak self] _ in + self?.performRemoteCommand { try $0.stop() } ?? .commandFailed + } + addRemoteTarget(commands.changePlaybackPositionCommand) { [weak self] event in + guard let positionEvent = event as? MPChangePlaybackPositionCommandEvent else { + return .commandFailed + } + return self?.performRemoteSeek(positionEvent.positionTime) ?? .commandFailed + } + addRemoteTarget(commands.previousTrackCommand) { [weak self] _ in + self?.emitSystemMediaNavigation("previous") ?? .commandFailed + } + addRemoteTarget(commands.nextTrackCommand) { [weak self] _ in + self?.emitSystemMediaNavigation("next") ?? .commandFailed + } + refreshRemoteCommands() + } + + private func addRemoteTarget( + _ command: MPRemoteCommand, + handler: @escaping (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus + ) { + command.isEnabled = false + let target = command.addTarget(handler: handler) + remoteCommandTargets.append((command, target)) + } + + private func applyMediaMetadata(_ metadata: [String: Any], to host: ErikaPlayerHost) throws { + guard let title = metadata["title"] as? String, !title.isEmpty else { + throw ErikaPluginError.invalidArguments("metadata.title is required.") + } + try host.setMediaMetadata( + title: title, + artist: metadata["artist"] as? String, + album: metadata["album"] as? String, + artworkData: (metadata["artwork"] as? FlutterStandardTypedData)?.data + ) + } + + private func handleNowPlayingChanged(for host: ErikaPlayerHost) { + if host.playbackState == 6 { + activePlayerId = nil + clearNowPlayingInfo() + refreshRemoteCommands() + return + } + publishNowPlayingInfo(for: host) + } + + private func publishNowPlayingInfo( + for host: ErikaPlayerHost, + playbackState: MPNowPlayingPlaybackState? = nil + ) { + let resolvedPlaybackState = playbackState ?? + (host.isPlaying ? .playing : host.isStopped ? .stopped : .paused) + var info: [String: Any] = [ + MPMediaItemPropertyTitle: host.nowPlayingTitle, + MPNowPlayingInfoPropertyElapsedPlaybackTime: host.nowPlayingPositionSeconds, + MPNowPlayingInfoPropertyPlaybackRate: resolvedPlaybackState == .playing ? host.playbackRate : 0, + MPNowPlayingInfoPropertyDefaultPlaybackRate: host.playbackRate, + MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.video.rawValue, + ] + if let artist = host.nowPlayingArtist { info[MPMediaItemPropertyArtist] = artist } + if let album = host.nowPlayingAlbum { info[MPMediaItemPropertyAlbumTitle] = album } + if let artwork = host.nowPlayingArtwork { info[MPMediaItemPropertyArtwork] = artwork } + if let duration = host.durationSeconds { info[MPMediaItemPropertyPlaybackDuration] = duration } + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = info + center.playbackState = resolvedPlaybackState + } + + private func clearNowPlayingInfo() { + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = nil + center.playbackState = .stopped + } + + private func activePlayer() -> ErikaPlayerHost? { + activePlayerId.flatMap { players[$0] } + } + + private func performRemoteCommand( + _ command: @escaping (ErikaPlayerHost) throws -> Void + ) -> MPRemoteCommandHandlerStatus { + performOnMain { + guard let host = self.activePlayer() else { return .noSuchContent } + do { + try command(host) + return .success + } catch { + return .commandFailed + } + } + } + + private func performRemoteSeek(_ position: TimeInterval) -> MPRemoteCommandHandlerStatus { + guard position.isFinite else { return .commandFailed } + return performRemoteCommand { host in + let duration = host.durationSeconds ?? position + let boundedPosition = min(max(0, position), max(0, duration)) + try host.seek(positionMicros: UInt64(boundedPosition * 1_000_000)) + } + } + + private func emitSystemMediaNavigation(_ navigation: String) -> MPRemoteCommandHandlerStatus { + performOnMain { + guard let playerId = self.activePlayerId, + self.players[playerId] != nil, + let capabilities = self.systemMediaNavigation[playerId] else { return .noSuchContent } + let enabled = navigation == "previous" + ? capabilities.previousEnabled + : capabilities.nextEnabled + guard enabled else { return .noSuchContent } + Self.sharedEventSink?([ + "playerId": playerId, + "kind": 13, + "navigation": navigation, + ]) + return .success + } + } + + private func performOnMain( + _ work: @escaping () -> MPRemoteCommandHandlerStatus + ) -> MPRemoteCommandHandlerStatus { + if Thread.isMainThread { + return work() + } + return DispatchQueue.main.sync(execute: work) + } + + private func refreshRemoteCommands() { + let commands = MPRemoteCommandCenter.shared() + let enabled = activePlayer() != nil + remoteCommandTargets.forEach { command, _ in + command.isEnabled = enabled + } + let capabilities = activePlayerId.flatMap { systemMediaNavigation[$0] } + commands.previousTrackCommand.isEnabled = enabled && capabilities?.previousEnabled == true + commands.nextTrackCommand.isEnabled = enabled && capabilities?.nextEnabled == true + } + + private func presenterConfigForNewPlayer(arguments: Any?) throws -> ErikaPresenterConfigC { + let alphaMode = (arguments as? [String: Any]) + .flatMap { int32Value($0["videoAlphaMode"]) } ?? 0 + if let args = arguments as? [String: Any], + let explicitMode = int32Value(args["outputMode"]) { + let headroom = floatValue(args["edrHeadroom"]) ?? 4.0 + var config: ErikaPresenterConfigC + switch explicitMode { + case 1: + config = .appleEdr(headroom: headroom) + case 2: + config = ErikaPresenterConfigC(outputMode: 2, edrHeadroom: max(1.0, headroom)) + case 3: + config = .auto(headroom: headroom) + default: + config = .sdr + } + config.videoAlphaMode = alphaMode + return config + } + + let headroom = resolvedEdrHeadroom() + NSLog("ErikaFlutterPlugin: using automatic Apple output, headroom \(headroom)x") + let config = ErikaPresenterConfigC.auto(headroom: headroom) + var alphaConfig = config + alphaConfig.videoAlphaMode = alphaMode + return alphaConfig + } + + private func resolvedEdrHeadroom() -> Float { + let environment = ProcessInfo.processInfo.environment + if boolEnvironmentFlag("ERIKA_DISABLE_EDR", environment: environment) { + return 1.0 + } + if let override = floatEnvironmentValue("ERIKA_EDR_HEADROOM", environment: environment), + override > 1.0 { + return override + } + + let screenHeadroom = currentScreenEdrHeadroom() + if screenHeadroom > 1.0 { + return screenHeadroom + } + if boolEnvironmentFlag("ERIKA_ENABLE_EDR", environment: environment) { + return 4.0 + } + return 1.0 + } + + private func currentScreenEdrHeadroom() -> Float { + let screen = flutterHostView?.window?.screen ?? + flutterHostViewController?.view.window?.screen ?? + NSApp.keyWindow?.screen ?? + NSApp.mainWindow?.screen ?? + NSScreen.main + guard let screen else { + return 1.0 + } + + let key = "maximumPotentialExtendedDynamicRangeColorComponentValue" + guard screen.responds(to: Selector((key))), + let number = screen.value(forKey: key) as? NSNumber else { + return 1.0 + } + return max(1.0, number.floatValue) + } + + 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 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 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 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 -> (NSView & 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 startPollTimerIfNeeded() { + guard pollTimer == nil else { + return + } + let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in + guard let self else { + return + } + let sink = Self.sharedEventSink + for host in self.players.values { + host.pollEvents(sendEvent: sink) + } + } + // Event delivery does not require a hard 50 ms deadline. Let macOS + // coalesce this maintenance poll with nearby UI/audio work. + timer.tolerance = 0.01 + pollTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + + private func stopPollTimerIfIdle() { + guard players.isEmpty else { + return + } + pollTimer?.invalidate() + pollTimer = nil + } + + 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 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 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 = value as? Int64 { + return value + } + if let value = value as? NSNumber { + return value.int64Value + } + if let value = value as? String, let parsed = Int64(value) { + return parsed + } + 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 flutterError(_ error: Error) -> FlutterError { + FlutterError( + code: "ERIKA_ERROR", + message: String(describing: error), + details: nil + ) + } +} diff --git a/third_party/erika_flutter/macos/erika_flutter.podspec b/third_party/erika_flutter/macos/erika_flutter.podspec new file mode 100644 index 00000000..eb7b96c2 --- /dev/null +++ b/third_party/erika_flutter/macos/erika_flutter.podspec @@ -0,0 +1,164 @@ +Pod::Spec.new do |s| + s.name = 'erika_flutter' + s.version = '0.1.7' + s.summary = 'Flutter embedder glue for the Erika Rust media engine.' + s.description = <<-DESC +Flutter macOS 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/**/*' + s.dependency 'FlutterMacOS' + s.platform = :osx, '10.14' + s.swift_version = '5.7' + s.script_phase = { + :name => 'Build Erika C ABI', + :execution_position => :before_compile, + :script => <<-SCRIPT +set -eu + +export PATH="$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" + +PLUGIN_MACOS_DIR="$(cd "$PODS_TARGET_SRCROOT" && pwd -P)" +PACKAGE_ROOT="$(cd "$PLUGIN_MACOS_DIR/.." && pwd -P)" +ERIKA_NATIVE_PROFILE="${ERIKA_NATIVE_PROFILE:-lgpl}" +HOST_JOBS="$(sysctl -n hw.ncpu 2>/dev/null || echo 4)" +if [ -n "${ERIKA_REPO_ROOT:-}" ]; then + SOURCE_ROOT="$ERIKA_REPO_ROOT" +elif [ -n "${ERIKA_ROOT:-}" ]; then + SOURCE_ROOT="$ERIKA_ROOT" +else + SOURCE_ROOT="$(cd "$PACKAGE_ROOT/../.." && pwd -P)" +fi +USE_SOURCE_BUILD="${ERIKA_FORCE_SOURCE_BUILD:-0}" +if [ "$USE_SOURCE_BUILD" != "1" ] && [ "${ERIKA_FORCE_PREBUILT:-0}" != "1" ] && [ -f "$SOURCE_ROOT/crates/erika_capi/Cargo.toml" ]; then + USE_SOURCE_BUILD=1 +fi + +if [ -n "${ERIKA_MACOS_CAPI_PROFILE:-}" ]; then + CARGO_PROFILE="$ERIKA_MACOS_CAPI_PROFILE" +elif [ "${CONFIGURATION:-Debug}" = "Release" ]; then + CARGO_PROFILE="release" +else + CARGO_PROFILE="debug" +fi +if [ "$CARGO_PROFILE" = "release" ]; then + CARGO_ARGS="--release" +else + CARGO_ARGS="" +fi + +DEST_DIR="$BUILT_PRODUCTS_DIR/$FRAMEWORKS_FOLDER_PATH" +DEST_DYLIB="$DEST_DIR/liberika_capi.dylib" +mkdir -p "$DEST_DIR" + +MACOS_ARCHS="${ERIKA_MACOS_ARCHS:-universal}" +MACOS_ARCHS="$(printf '%s' "$MACOS_ARCHS" | tr ',' ' ')" +RUST_TARGETS="" +PREBUILT_ARCH="" +for MACOS_ARCH in $MACOS_ARCHS; do + case "$MACOS_ARCH" in + universal) + RUST_TARGETS="aarch64-apple-darwin x86_64-apple-darwin" + PREBUILT_ARCH="universal" + break + ;; + arm64|aarch64|aarch64-apple-darwin) + case " $RUST_TARGETS " in + *" aarch64-apple-darwin "*) ;; + *) RUST_TARGETS="$RUST_TARGETS aarch64-apple-darwin" ;; + esac + ;; + x86_64|x64|x86_64-apple-darwin) + case " $RUST_TARGETS " in + *" x86_64-apple-darwin "*) ;; + *) RUST_TARGETS="$RUST_TARGETS x86_64-apple-darwin" ;; + esac + ;; + *) + echo "error: unsupported ERIKA_MACOS_ARCHS value: $MACOS_ARCH" >&2 + exit 1 + ;; + esac +done +RUST_TARGETS="$(printf '%s' "$RUST_TARGETS" | xargs)" +if [ -z "$RUST_TARGETS" ]; then + echo "error: ERIKA_MACOS_ARCHS did not select an architecture" >&2 + exit 1 +fi +if [ -z "$PREBUILT_ARCH" ]; then + case "$RUST_TARGETS" in + "aarch64-apple-darwin") PREBUILT_ARCH="arm64" ;; + "x86_64-apple-darwin") PREBUILT_ARCH="x64" ;; + *) PREBUILT_ARCH="universal" ;; + esac +fi + +SOURCE_DYLIB="" +if [ -n "${ERIKA_MACOS_CAPI_DYLIB:-}" ]; then + SOURCE_DYLIB="$ERIKA_MACOS_CAPI_DYLIB" +elif [ "$USE_SOURCE_BUILD" != "1" ]; then + SOURCE_DYLIB="$PACKAGE_ROOT/native/macos/liberika_capi.dylib" + if [ ! -f "$SOURCE_DYLIB" ]; then + echo "error: bundled Erika runtime is missing: $SOURCE_DYLIB" >&2 + exit 1 + fi + ACTUAL_SHA256="$(shasum -a 256 "$SOURCE_DYLIB" | awk '{print $1}')" + if [ "$ACTUAL_SHA256" != "0a52f3a242f30a6451083fcc66bc81cf51e94da7c0619f9463a32d177696a66b" ]; then + echo "error: bundled Erika runtime checksum mismatch" >&2 + exit 1 + fi +else + if [ ! -f "$SOURCE_ROOT/crates/erika_capi/Cargo.toml" ]; then + echo "error: ERIKA_FORCE_SOURCE_BUILD=1 requires an Erika checkout; set ERIKA_REPO_ROOT" >&2 + exit 1 + fi + if command -v rustup >/dev/null 2>&1; then + rustup target add $RUST_TARGETS + fi + LIPO_INPUTS="" + for RUST_TARGET in $RUST_TARGETS; do + DIST="$SOURCE_ROOT/third_party/dist/$RUST_TARGET/$ERIKA_NATIVE_PROFILE" + DAV1D_DIR="$DIST/dav1d" + DAV1D_MARKER="$SOURCE_ROOT/third_party/build/$RUST_TARGET/$ERIKA_NATIVE_PROFILE/dav1d/dav1d-built.txt" + if [ ! -f "$DIST/ffmpeg/include/libavformat/avformat.h" ] || [ ! -f "$DAV1D_DIR/include/dav1d/dav1d.h" ] || [ ! -f "$DAV1D_DIR/lib/libdav1d.a" ] || [ ! -f "$DAV1D_MARKER" ] || ! grep -qx 'dav1d=1.5.1' "$DAV1D_MARKER" || [ ! -f "$DIST/libass/lib/libass.a" ]; then + (cd "$SOURCE_ROOT" && cargo run -p xtask -- deps build --all --profile "$ERIKA_NATIVE_PROFILE" --target "$RUST_TARGET" --jobs "$HOST_JOBS") + fi + (cd "$SOURCE_ROOT" && ERIKA_NATIVE_PROFILE="$ERIKA_NATIVE_PROFILE" ERIKA_NATIVE_TARGET="$RUST_TARGET" ERIKA_FFMPEG_DIR="$DIST/ffmpeg" ERIKA_DAV1D_DIR="$DAV1D_DIR" ERIKA_LIBASS_DIR="$DIST/libass" ERIKA_FREETYPE_DIR="$DIST/freetype" ERIKA_HARFBUZZ_DIR="$DIST/harfbuzz" ERIKA_FRIBIDI_DIR="$DIST/fribidi" cargo build -p erika_capi --target "$RUST_TARGET" --no-default-features --features libass $CARGO_ARGS) + ARCH_DYLIB="$SOURCE_ROOT/target/$RUST_TARGET/$CARGO_PROFILE/liberika_capi.dylib" + if [ ! -f "$ARCH_DYLIB" ]; then + echo "error: $ARCH_DYLIB was not produced by the Erika build" >&2 + exit 1 + fi + LIPO_INPUTS="$LIPO_INPUTS $ARCH_DYLIB" + done + if [ "$(printf '%s\n' $RUST_TARGETS | wc -l | xargs)" = "1" ]; then + SOURCE_DYLIB="$ARCH_DYLIB" + else + SOURCE_DYLIB="$SOURCE_ROOT/target/erika-macos-universal/liberika_capi.dylib" + mkdir -p "$(dirname "$SOURCE_DYLIB")" + lipo -create $LIPO_INPUTS -output "$SOURCE_DYLIB" + fi +fi + +if [ -n "$SOURCE_DYLIB" ]; then + if [ ! -f "$SOURCE_DYLIB" ]; then + echo "error: Erika C ABI dylib not found: $SOURCE_DYLIB" >&2 + exit 1 + fi + cp "$SOURCE_DYLIB" "$DEST_DYLIB" +fi +if [ ! -f "$DEST_DYLIB" ]; then + echo "error: Erika C ABI dylib not found: $DEST_DYLIB" >&2 + exit 1 +fi +install_name_tool -id "@rpath/liberika_capi.dylib" "$DEST_DYLIB" +codesign --force --sign "${EXPANDED_CODE_SIGN_IDENTITY:--}" "$DEST_DYLIB" + SCRIPT + } + s.pod_target_xcconfig = { + 'OTHER_LDFLAGS' => '$(inherited) -framework QuartzCore -framework Metal -framework MediaPlayer' + } +end diff --git a/third_party/erika_flutter/native/include/erika.h b/third_party/erika_flutter/native/include/erika.h new file mode 100644 index 00000000..f405a4ec --- /dev/null +++ b/third_party/erika_flutter/native/include/erika.h @@ -0,0 +1,789 @@ +#ifndef ERIKA_H +#define ERIKA_H + +/* + * Erika media playback engine — C ABI. + * + * Full reference: docs/capi_reference.md. Embedding walkthrough: + * docs/integration.md. + * + * Two independent entry points; pick one per integration: + * - ErikaHandle: pull model. The host renders and pulls state. + * - ErikaPresenterHandle: push model. Erika owns decode/timing/audio/render; + * the host gives it a surface and calls render_tick. + * Compiled on macOS / iOS / Windows / Android / + * OpenHarmony; on other targets + * erika_presenter_create returns NULL. + * + * Conventions: + * - Every fallible call returns ErikaStatus; Ok (0) and NoEvent are the only + * non-error results. Panics are caught and surface as ErikaStatus_Panic. + * - On a non-Ok/NoEvent result a human-readable message is stored in a + * THREAD-LOCAL slot; read it on the same thread via + * erika_last_error_message() and free with erika_string_free(). + * - Any char* Erika returns is caller-owned: free standalone strings with + * erika_string_free(); free strings inside ErikaTrackInfo / + * ErikaDanmakuTrackInfo with the matching *_info_free() function. + * - const char* arguments are borrowed for the call only and must be + * NUL-terminated UTF-8. + * - List getters use the counted-array idiom: pass (buf, capacity, &len); + * len is set to the total count, at most capacity records are written, and + * capacity 0 with a NULL buffer queries the count. + * - attach and resize functions take exact width/height in physical pixels. + * The scale is the independent logical-content/DPI scale used for UI such + * as danmaku; it never multiplies the surface extent. + * - A handle is not internally synchronized: do not call into one handle + * concurrently from multiple threads. + */ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Opaque handles. ErikaHandle = pull model, ErikaPresenterHandle = push model. */ +typedef struct ErikaHandle ErikaHandle; +typedef struct ErikaPresenterHandle ErikaPresenterHandle; + +typedef struct ErikaHttpHeader { + const char *name; + const char *value; +} ErikaHttpHeader; + +typedef enum ErikaStatus { + ErikaStatus_Ok = 0, + ErikaStatus_NullPointer = 1, + ErikaStatus_InvalidUtf8 = 2, + ErikaStatus_PlayerError = 3, + ErikaStatus_Panic = 4, + ErikaStatus_NoEvent = 5, +} ErikaStatus; + +typedef enum ErikaState { + ErikaState_Idle = 0, + ErikaState_Opening = 1, + ErikaState_Ready = 2, + ErikaState_Playing = 3, + ErikaState_Paused = 4, + ErikaState_Stopped = 5, + ErikaState_Closed = 6, + ErikaState_Error = 7, +} ErikaState; + +typedef enum ErikaEventKind { + ErikaEventKind_None = 0, + ErikaEventKind_StateChanged = 1, + ErikaEventKind_DurationChanged = 2, + ErikaEventKind_PositionChanged = 3, + ErikaEventKind_TracksChanged = 4, + ErikaEventKind_BufferingChanged = 5, + ErikaEventKind_VideoParamsChanged = 6, + ErikaEventKind_SurfaceAttached = 7, + ErikaEventKind_SurfaceDetached = 8, + ErikaEventKind_Error = 9, + ErikaEventKind_TrackSelectionChanged = 10, + ErikaEventKind_VideoDecoderChanged = 11, + ErikaEventKind_AudioOutputChanged = 12, +} ErikaEventKind; + +typedef enum ErikaTrackKind { + ErikaTrackKind_Video = 0, + ErikaTrackKind_Audio = 1, + ErikaTrackKind_Subtitle = 2, +} ErikaTrackKind; + +typedef enum ErikaTrackSource { + ErikaTrackSource_Embedded = 0, + ErikaTrackSource_External = 1, +} ErikaTrackSource; + +typedef enum ErikaWgpuSurfaceKind { + ErikaWgpuSurfaceKind_Unknown = 0, + ErikaWgpuSurfaceKind_MacOsNsView = 1, + ErikaWgpuSurfaceKind_MacOsCaMetalLayer = 2, + ErikaWgpuSurfaceKind_IosUiView = 3, + ErikaWgpuSurfaceKind_WindowsHwnd = 4, + ErikaWgpuSurfaceKind_XlibWindow = 5, + ErikaWgpuSurfaceKind_WaylandSurface = 6, + ErikaWgpuSurfaceKind_AndroidNativeWindow = 7, + ErikaWgpuSurfaceKind_OhosNativeWindow = 8, +} ErikaWgpuSurfaceKind; + +typedef enum ErikaFlutterTextureKind { + ErikaFlutterTextureKind_Unknown = 0, + ErikaFlutterTextureKind_MacOsTextureRegistrar = 1, + ErikaFlutterTextureKind_IosTextureRegistrar = 2, + ErikaFlutterTextureKind_AndroidSurfaceTexture = 3, + ErikaFlutterTextureKind_WindowsTextureRegistrar = 4, + ErikaFlutterTextureKind_LinuxTextureRegistrar = 5, +} ErikaFlutterTextureKind; + +typedef enum ErikaPresenterOutputMode { + ErikaPresenterOutputMode_Sdr = 0, + ErikaPresenterOutputMode_AppleEdr = 1, + ErikaPresenterOutputMode_ExtendedLinear = 2, + ErikaPresenterOutputMode_Auto = 3, +} ErikaPresenterOutputMode; + +typedef enum ErikaActiveOutputEncoding { + ErikaActiveOutputEncoding_SdrSrgb = 0, + ErikaActiveOutputEncoding_AppleEdr = 1, + ErikaActiveOutputEncoding_AndroidExtendedLinearScRgb = 2, + ErikaActiveOutputEncoding_Hdr10Pq = 3, +} ErikaActiveOutputEncoding; + +typedef enum ErikaOutputFallbackReason { + ErikaOutputFallbackReason_None = 0, + ErikaOutputFallbackReason_DisplayHdrUnsupported = 1, + ErikaOutputFallbackReason_HybridCompositionRequired = 2, + ErikaOutputFallbackReason_WgpuBackendNotVulkan = 3, + ErikaOutputFallbackReason_Rgba16FloatSurfaceFormatUnavailable = 4, + ErikaOutputFallbackReason_NativeWindowDataSpaceApiUnavailable = 5, + ErikaOutputFallbackReason_ScrgbDataSpaceVerificationFailed = 6, + ErikaOutputFallbackReason_SurfaceConfigureFailed = 7, + ErikaOutputFallbackReason_LegacyAppleEdrUnsupported = 8, +} ErikaOutputFallbackReason; + +typedef enum ErikaOutputSurfaceFormat { + ErikaOutputSurfaceFormat_EightBitUnorm = 0, + ErikaOutputSurfaceFormat_TenBitUnorm = 1, + ErikaOutputSurfaceFormat_SixteenBitFloat = 2, +} ErikaOutputSurfaceFormat; + +typedef enum ErikaLumaUpscalerMode { + ErikaLumaUpscalerMode_Off = 0, + ErikaLumaUpscalerMode_ArtCnnC4F16 = 1, + ErikaLumaUpscalerMode_ArtCnnC4F32 = 2, + ErikaLumaUpscalerMode_ArtCnnC4F16Ds = 3, +} ErikaLumaUpscalerMode; + +typedef enum ErikaUpscalerBackendStatus { + ErikaUpscalerBackendStatus_Off = 0, + ErikaUpscalerBackendStatus_Inactive = 1, + ErikaUpscalerBackendStatus_Building = 2, + ErikaUpscalerBackendStatus_Scalar = 3, + ErikaUpscalerBackendStatus_SimdgroupMatrix = 4, +} ErikaUpscalerBackendStatus; + +typedef enum ErikaVideoAlphaMode { + ErikaVideoAlphaMode_Opaque = 0, + ErikaVideoAlphaMode_PackedAlphaRight = 1, +} ErikaVideoAlphaMode; + +typedef struct ErikaPresenterConfig { + int32_t output_mode; + float edr_headroom; + int32_t luma_upscaler; + int32_t video_alpha_mode; +} ErikaPresenterConfig; + +#define ERIKA_SUBTITLE_OVERRIDE_FONT_SIZE_FIELDS (1u << 2) +#define ERIKA_SUBTITLE_OVERRIDE_FONT_NAME (1u << 3) +#define ERIKA_SUBTITLE_OVERRIDE_COLORS (1u << 4) +#define ERIKA_SUBTITLE_OVERRIDE_ATTRIBUTES (1u << 5) +#define ERIKA_SUBTITLE_OVERRIDE_BORDER (1u << 6) +#define ERIKA_SUBTITLE_OVERRIDE_ALIGNMENT (1u << 7) +#define ERIKA_SUBTITLE_OVERRIDE_MARGINS (1u << 8) +#define ERIKA_SUBTITLE_OVERRIDE_BLUR (1u << 11) +#define ERIKA_SUBTITLE_OVERRIDE_ALL \ + (ERIKA_SUBTITLE_OVERRIDE_FONT_SIZE_FIELDS | \ + ERIKA_SUBTITLE_OVERRIDE_FONT_NAME | ERIKA_SUBTITLE_OVERRIDE_COLORS | \ + ERIKA_SUBTITLE_OVERRIDE_ATTRIBUTES | ERIKA_SUBTITLE_OVERRIDE_BORDER | \ + ERIKA_SUBTITLE_OVERRIDE_ALIGNMENT | ERIKA_SUBTITLE_OVERRIDE_MARGINS | \ + ERIKA_SUBTITLE_OVERRIDE_BLUR) + +typedef struct ErikaSubtitleStyle { + const char *font_family; + const char *font_file_path; + uint32_t primary_color_rgba; + uint32_t outline_color_rgba; + double font_size; + double outline_width; + bool bold; + bool italic; + bool underline; + bool strike_out; + double spacing; + double scale_x_percent; + double scale_y_percent; + int32_t border_style; + double shadow_depth; + double blur; + int32_t alignment; + int32_t margin_left; + int32_t margin_right; + int32_t margin_vertical; + uint32_t override_mask; +} ErikaSubtitleStyle; + +typedef struct ErikaSurfaceOutputCapabilities { + bool extended_linear; + bool direct_composition; + float desired_headroom; + int32_t fallback_reason; +} ErikaSurfaceOutputCapabilities; + +typedef struct ErikaUpscalerStatus { + int32_t requested_mode; + int32_t active_backend; + uint64_t fallback_count; + uint64_t upscaled_frames; + uint64_t last_encode_micros; + uint64_t last_gpu_micros; +} ErikaUpscalerStatus; + +typedef struct ErikaOutputStatus { + int32_t requested_mode; + int32_t active_encoding; + int32_t surface_format; + int32_t native_data_space; + float requested_headroom; + float active_headroom; + bool active_headroom_known; + bool extended_linear_active; + int32_t fallback_reason; + uint64_t fallback_count; + uint64_t data_space_failures; + uint64_t headroom_updates; + uint64_t extended_linear_frames; +} ErikaOutputStatus; + +/* Renderer memory snapshot. Per-resource fields are allocations tracked by + * one presenter. device_current_allocated_bytes is Metal's device-wide + * process counter and may include allocations owned by other presenters. */ +typedef struct ErikaPresenterResourceStatus { + uint64_t device_current_allocated_bytes; + uint64_t device_recommended_working_set_bytes; + uint64_t drawable_estimated_bytes; + uint64_t video_frame_bytes; + uint64_t overlay_atlas_bytes; + uint64_t danmaku_atlas_bytes; + uint64_t danmaku_vertex_buffer_bytes; + uint64_t upscaler_bytes; + uint64_t renderer_tracked_bytes; + uint64_t presenter_cpu_danmaku_atlas_bytes; + uint32_t drawable_count; + uint64_t output_mode_switches; +} ErikaPresenterResourceStatus; + +typedef struct ErikaSubtitleMemoryFontStatus { + uintptr_t registered_count; + uintptr_t registered_bytes; + uintptr_t selected_count; + uint64_t generation; + uint64_t *selected_ids; +} ErikaSubtitleMemoryFontStatus; + +typedef struct ErikaSubtitleMemoryFontFace { + uint32_t index; + char *families_json; + char *post_script_name; + uint16_t weight; + bool italic; + bool monospaced; +} ErikaSubtitleMemoryFontFace; + +typedef struct ErikaSubtitleMemoryFontInfo { + uint64_t id; + uintptr_t byte_len; + ErikaSubtitleMemoryFontFace *faces; + uintptr_t face_count; +} ErikaSubtitleMemoryFontInfo; + +typedef struct ErikaDanmakuConfig { + bool enabled; + /* NipaPlay/Flutter logical danmaku font size. Erika uses the NipaPlay + * default danmaku font and multiplies by the surface scale for glyph pixels. */ + float font_size; + float opacity; + float display_area; + float scroll_duration_seconds; + float scroll_speed_factor; + float track_gap_ratio; + float outline_width; + float shadow_offset_x; + float shadow_offset_y; + bool merge_duplicates; + bool allow_stacking; + bool allow_scroll_overwrite; + uint32_t max_quantity; + uint32_t max_lines_per_mode; + bool block_top; + bool block_bottom; + bool block_scroll; + int32_t shadow_style; +} ErikaDanmakuConfig; + +typedef struct ErikaDanmakuTrackInfo { + uint64_t id; + bool enabled; + int64_t offset_micros; + uintptr_t item_count; + char *name; + char *source; +} ErikaDanmakuTrackInfo; + +typedef struct ErikaVideoParams { + uint32_t width; + uint32_t height; + uint32_t primaries; + uint32_t transfer; +} ErikaVideoParams; + +typedef struct ErikaTrackCounts { + uint32_t video; + uint32_t audio; + uint32_t subtitle; +} ErikaTrackCounts; + +typedef struct ErikaTrackSelection { + int64_t video; + int64_t audio; + int64_t subtitle; +} ErikaTrackSelection; + +typedef struct ErikaTrackInfo { + int64_t id; + ErikaTrackKind kind; + ErikaTrackSource source; + bool selected; + bool can_remove; + char *title; + char *language; + char *codec; + uint32_t width; + uint32_t height; + uint32_t sample_rate; + uint32_t channels; + char *pixel_format; + char *sample_format; + char *profile; + int32_t level; + uint64_t bit_rate; + uint32_t frame_rate_numerator; + uint32_t frame_rate_denominator; +} ErikaTrackInfo; + +typedef struct ErikaEvent { + ErikaEventKind kind; + ErikaStatus status; + ErikaState state; + int64_t duration_micros; + uint64_t position_micros; + bool buffering; + ErikaVideoParams video; + ErikaTrackCounts tracks; +} ErikaEvent; + +typedef struct ErikaPresenterStats { + uint64_t decoded_video_frames; + uint64_t rendered_video_frames; + uint64_t rendered_test_frames; + uint64_t pushed_audio_frames; + uint64_t overlay_frames; + uint64_t danmaku_frames; + uint64_t danmaku_items; + uint64_t import_failures; + uint64_t render_failures; + uint64_t audio_failures; + uint64_t software_video_frames; + uint64_t hardware_video_frames; + uint64_t zero_copy_video_frames; + uint64_t cpu_video_frame_fallbacks; + uint64_t last_render_micros; + uint64_t last_render_current_micros; + uint64_t audio_clock_read_frames; + uint64_t audio_clock_queued_frames; + uint64_t audio_clock_underflow_frames; + /* 0 stable, 1 disconnected, 2 recovering, 3 failed. */ + int32_t audio_recovery_state; + int32_t audio_last_error_code; + uint64_t audio_recovery_attempts; + uint64_t audio_recovery_count; + uint64_t audio_recovery_failures; + uint64_t direct_zero_copy_video_frames; + uint64_t shared_handle_video_frames; + uint64_t hdr_source_frames; + uint64_t hdr10_output_frames; + uint64_t sdr_tonemap_frames; + uint64_t hdr10_metadata_updates; + uint64_t hdr10_metadata_failures; + uint64_t hdr10_output_failures; + bool hdr10_output_active; + uint64_t video_frame_backpressure_drops; +} ErikaPresenterStats; + +/* ===== ErikaHandle (pull model) ===== */ + +/* Lifecycle and thread-local error retrieval. erika_create never fails. */ +ErikaHandle *erika_create(void); +void erika_destroy(ErikaHandle *handle); +char *erika_last_error_message(void); +void erika_string_free(char *value); + +/* Playback control. uri is a local path or HTTP(S) URL; times are microseconds. + * open() synchronously probes streams and transitions to Ready. */ +ErikaStatus erika_open(ErikaHandle *handle, const char *uri); +ErikaStatus erika_open_with_headers( + ErikaHandle *handle, + const char *uri, + const ErikaHttpHeader *headers, + uintptr_t header_count); +/* play enqueues work; observe StateChanged/Error for the authoritative result. */ +ErikaStatus erika_play(ErikaHandle *handle); +ErikaStatus erika_pause(ErikaHandle *handle); +ErikaStatus erika_stop(ErikaHandle *handle); +ErikaStatus erika_close(ErikaHandle *handle); +ErikaStatus erika_seek(ErikaHandle *handle, uint64_t position_micros); +/* Tracks and subtitles. Subtitle track id -1 disables subtitles. + * erika_tracks uses the counted-array idiom; free each filled record with + * erika_track_info_free. */ +ErikaStatus erika_add_external_subtitle( + ErikaHandle *handle, + const char *uri, + int64_t *out_track_id); +ErikaStatus erika_remove_subtitle_track(ErikaHandle *handle, int64_t track_id); +ErikaStatus erika_select_audio_track(ErikaHandle *handle, int64_t track_id); +ErikaStatus erika_select_subtitle_track(ErikaHandle *handle, int64_t track_id); +ErikaStatus erika_track_selection( + ErikaHandle *handle, + ErikaTrackSelection *out_selection); +ErikaStatus erika_tracks( + ErikaHandle *handle, + ErikaTrackInfo *out_tracks, + uintptr_t capacity, + uintptr_t *out_len); +void erika_track_info_free(ErikaTrackInfo *track); +void erika_danmaku_track_info_free(ErikaDanmakuTrackInfo *track); +/* State and non-blocking event polling (returns NoEvent when the queue is empty). */ +ErikaStatus erika_state(ErikaHandle *handle, ErikaState *out_state); +ErikaStatus erika_poll_event(ErikaHandle *handle, ErikaEvent *out_event); + +/* Host-managed surface attach. raw_layer is a CAMetalLayer* cast to uint64_t; + * for wgpu, raw_window/raw_display are platform handles for the given kind. */ +ErikaStatus erika_attach_metal_layer( + ErikaHandle *handle, + uint64_t raw_layer, + uint32_t width, + uint32_t height, + double scale); + +ErikaStatus erika_attach_wgpu_surface( + ErikaHandle *handle, + ErikaWgpuSurfaceKind kind, + uint64_t raw_window, + uint64_t raw_display, + uint32_t width, + uint32_t height, + double scale); + +ErikaStatus erika_attach_wgpu_surface_with_output_capabilities( + ErikaHandle *handle, + ErikaWgpuSurfaceKind kind, + uint64_t raw_window, + uint64_t raw_display, + uint32_t width, + uint32_t height, + double scale, + ErikaSurfaceOutputCapabilities output_capabilities); + +ErikaStatus erika_attach_flutter_texture( + ErikaHandle *handle, + ErikaFlutterTextureKind kind, + int64_t texture_id, + uint32_t width, + uint32_t height, + double scale); + +ErikaStatus erika_detach_surface(ErikaHandle *handle); + +/* ===== ErikaPresenterHandle (push model) — macOS / iOS / Windows / Android / OpenHarmony ===== */ + +/* Lifecycle and configuration. A NULL return means creation failed; check + * erika_last_error_message. Config selects output mode, EDR headroom, upscaler. */ +ErikaPresenterHandle *erika_presenter_create(void); +ErikaPresenterHandle *erika_presenter_create_with_config(ErikaPresenterConfig config); +ErikaPresenterHandle *erika_presenter_create_with_output_mode( + int32_t output_mode, + float edr_headroom); +ErikaPresenterHandle *erika_presenter_create_with_output_mode_and_alpha( + int32_t output_mode, + float edr_headroom, + int32_t video_alpha_mode); +void erika_presenter_destroy(ErikaPresenterHandle *handle); + +/* Playback and runtime parameters. volume is 0.0–1.0; rate 1.0 is normal speed; + * set_upscaler takes an ErikaLumaUpscalerMode. Metal and capable wgpu/Vulkan + * renderers execute ArtCNN; other backends retain native luma sampling and + * report an explicit Inactive fallback. */ +ErikaStatus erika_presenter_open(ErikaPresenterHandle *handle, const char *uri); +ErikaStatus erika_presenter_open_with_headers( + ErikaPresenterHandle *handle, + const char *uri, + const ErikaHttpHeader *headers, + uintptr_t header_count); +/* play enqueues work; observe StateChanged/Error for the authoritative result. */ +ErikaStatus erika_presenter_play(ErikaPresenterHandle *handle); +ErikaStatus erika_presenter_pause(ErikaPresenterHandle *handle); +ErikaStatus erika_presenter_stop(ErikaPresenterHandle *handle); +ErikaStatus erika_presenter_close(ErikaPresenterHandle *handle); +ErikaStatus erika_presenter_seek(ErikaPresenterHandle *handle, uint64_t position_micros); +ErikaStatus erika_presenter_set_playback_rate(ErikaPresenterHandle *handle, double rate); +ErikaStatus erika_presenter_set_volume(ErikaPresenterHandle *handle, double volume); +ErikaStatus erika_presenter_set_upscaler(ErikaPresenterHandle *handle, int32_t mode); +ErikaStatus erika_presenter_set_subtitle_scale(ErikaPresenterHandle *handle, double scale); +/* Fallback subtitle font. NULL or empty clears that half of the selection. + * A container ASS script keeps its own font unless the font-name override bit + * is passed through erika_presenter_set_subtitle_style. */ +ErikaStatus erika_presenter_set_subtitle_font( + ErikaPresenterHandle *handle, + const char *family, + const char *file_path); +ErikaStatus erika_presenter_register_subtitle_memory_font( + ErikaPresenterHandle *handle, + const uint8_t *data, + uintptr_t data_len, + uint64_t *out_font_id); +ErikaStatus erika_presenter_select_subtitle_memory_fonts( + ErikaPresenterHandle *handle, + const uint64_t *font_ids, + uintptr_t font_count); +ErikaStatus erika_presenter_clear_subtitle_memory_fonts(ErikaPresenterHandle *handle); +ErikaStatus erika_presenter_get_subtitle_memory_font_status( + ErikaPresenterHandle *handle, + ErikaSubtitleMemoryFontStatus *out_status); +ErikaStatus erika_presenter_set_subtitle_style( + ErikaPresenterHandle *handle, + ErikaSubtitleStyle style); +void erika_subtitle_memory_font_status_free( + ErikaSubtitleMemoryFontStatus *status); +ErikaStatus erika_presenter_get_subtitle_memory_font_info( + ErikaPresenterHandle *handle, + uint64_t font_id, + ErikaSubtitleMemoryFontInfo *out_info); +void erika_subtitle_memory_font_info_free(ErikaSubtitleMemoryFontInfo *info); +ErikaStatus erika_presenter_set_output_headroom( + ErikaPresenterHandle *handle, + float headroom, + bool known); +ErikaStatus erika_presenter_get_upscaler_status( + ErikaPresenterHandle *handle, + ErikaUpscalerStatus *out_status); +ErikaStatus erika_presenter_get_output_status( + ErikaPresenterHandle *handle, + ErikaOutputStatus *out_status); +ErikaStatus erika_presenter_get_resource_status( + ErikaPresenterHandle *handle, + ErikaPresenterResourceStatus *out_status); +ErikaStatus erika_presenter_add_external_subtitle( + ErikaPresenterHandle *handle, + const char *uri, + int64_t *out_track_id); +ErikaStatus erika_presenter_remove_subtitle_track( + ErikaPresenterHandle *handle, + int64_t track_id); +ErikaStatus erika_presenter_select_audio_track( + ErikaPresenterHandle *handle, + int64_t track_id); +ErikaStatus erika_presenter_select_subtitle_track( + ErikaPresenterHandle *handle, + int64_t track_id); +/* Danmaku (bullet comments). load_* replaces danmaku with one anonymous track; + * add_*_track builds a named multi-track list. Input is Bilibili XML (*_file, + * by path/URL) or JSON (*_json, inline). offset_micros shifts one track's + * timeline; the global offset shifts all. The inline JSON schema is documented + * in docs/capi_reference.md; layout behavior is in docs/danmaku_architecture.md. */ +ErikaStatus erika_presenter_load_danmaku_file( + ErikaPresenterHandle *handle, + const char *uri); +ErikaStatus erika_presenter_load_danmaku_json( + ErikaPresenterHandle *handle, + const char *json); +ErikaStatus erika_presenter_add_danmaku_track_file( + ErikaPresenterHandle *handle, + const char *uri, + const char *name, + int64_t offset_micros, + uint64_t *out_track_id); +ErikaStatus erika_presenter_add_danmaku_track_json( + ErikaPresenterHandle *handle, + const char *json, + const char *name, + int64_t offset_micros, + uint64_t *out_track_id); +ErikaStatus erika_presenter_remove_danmaku_track( + ErikaPresenterHandle *handle, + uint64_t track_id); +ErikaStatus erika_presenter_set_danmaku_track_enabled( + ErikaPresenterHandle *handle, + uint64_t track_id, + bool enabled); +ErikaStatus erika_presenter_set_danmaku_track_offset( + ErikaPresenterHandle *handle, + uint64_t track_id, + int64_t offset_micros); +ErikaStatus erika_presenter_set_danmaku_global_offset( + ErikaPresenterHandle *handle, + int64_t offset_micros); +ErikaStatus erika_presenter_danmaku_tracks( + ErikaPresenterHandle *handle, + ErikaDanmakuTrackInfo *out_tracks, + uintptr_t capacity, + uintptr_t *out_len); +ErikaStatus erika_presenter_clear_danmaku(ErikaPresenterHandle *handle); +ErikaStatus erika_presenter_set_danmaku_enabled( + ErikaPresenterHandle *handle, + bool enabled); +ErikaStatus erika_presenter_set_debug_hud_enabled( + ErikaPresenterHandle *handle, + bool enabled); +ErikaStatus erika_presenter_set_danmaku_config( + ErikaPresenterHandle *handle, + ErikaDanmakuConfig config); +ErikaStatus erika_presenter_set_danmaku_config_ptr( + ErikaPresenterHandle *handle, + const ErikaDanmakuConfig *config); +ErikaStatus erika_presenter_get_danmaku_config( + ErikaPresenterHandle *handle, + ErikaDanmakuConfig *out_config); +ErikaStatus erika_presenter_set_danmaku_font( + ErikaPresenterHandle *handle, + const char *family, + const char *file_path); +ErikaStatus erika_presenter_set_danmaku_block_words_json( + ErikaPresenterHandle *handle, + const char *json); +ErikaStatus erika_presenter_track_selection( + ErikaPresenterHandle *handle, + ErikaTrackSelection *out_selection); +ErikaStatus erika_presenter_tracks( + ErikaPresenterHandle *handle, + ErikaTrackInfo *out_tracks, + uintptr_t capacity, + uintptr_t *out_len); + +/* Surface and presentation. attach_metal_layer for macOS/iOS (CAMetalLayer*), + * attach_windows_hwnd for Windows (wraps attach_wgpu_surface with WindowsHwnd: + * hwnd = HWND, hinstance = HINSTANCE). The renderer backend (native Metal, + * native D3D11, or wgpu) is chosen by presenter config, not the attach call. + * Call resize_surface on any drawable-size or scale change. */ +ErikaStatus erika_presenter_attach_metal_layer( + ErikaPresenterHandle *handle, + uint64_t raw_layer, + uint32_t width, + uint32_t height, + double scale); + +/* Flutter compositor texture surface. The registrar texture_id identifies the + * surface; before every render_tick, select the host-owned GPU target with + * set_flutter_texture_buffer. On Apple, raw_texture is an id using + * BGRA8Unorm. The texture remains owned by the host. */ +ErikaStatus erika_presenter_attach_flutter_texture( + ErikaPresenterHandle *handle, + ErikaFlutterTextureKind kind, + int64_t texture_id, + uint32_t width, + uint32_t height, + double scale); + +ErikaStatus erika_presenter_set_flutter_texture_buffer( + ErikaPresenterHandle *handle, + uint64_t raw_texture, + uint32_t width, + uint32_t height); + +ErikaStatus erika_presenter_attach_wgpu_surface( + ErikaPresenterHandle *handle, + ErikaWgpuSurfaceKind kind, + uint64_t raw_window, + uint64_t raw_display, + uint32_t width, + uint32_t height, + double scale); + +ErikaStatus erika_presenter_attach_wgpu_surface_with_output_capabilities( + ErikaPresenterHandle *handle, + ErikaWgpuSurfaceKind kind, + uint64_t raw_window, + uint64_t raw_display, + uint32_t width, + uint32_t height, + double scale, + ErikaSurfaceOutputCapabilities output_capabilities); + +ErikaStatus erika_presenter_attach_windows_hwnd( + ErikaPresenterHandle *handle, + uint64_t hwnd, + uint64_t hinstance, + uint32_t width, + uint32_t height, + double scale); + +/* Windows only. Returns an AddRef'd IUnknown for a DirectComposition swap + * chain created by an attachment whose direct_composition capability is true. + * The caller owns the returned COM reference and must Release it. */ +ErikaStatus erika_presenter_windows_composition_swapchain_iunknown( + ErikaPresenterHandle *handle, + void **out_swapchain); + +/* Windows only. Returns an AddRef'd IUnknown for the renderer-owned, + * shareable D3D11 texture attached through WindowsTextureRegistrar. The + * caller owns the returned COM reference and must Release it. */ +ErikaStatus erika_presenter_windows_flutter_texture_iunknown( + ErikaPresenterHandle *handle, + void **out_texture); + +ErikaStatus erika_presenter_resize_surface( + ErikaPresenterHandle *handle, + uint32_t width, + uint32_t height, + double scale); + +ErikaStatus erika_presenter_detach_surface(ErikaPresenterHandle *handle); + +/* Render loop and events. Call render_tick once per display frame from the + * surface's display timer; time_seconds is the host display clock (presentation + * timestamp) for the frame, used for vsync-quantized scheduling. out_stats may + * be NULL. poll_event is non-blocking (NoEvent when idle). */ +ErikaStatus erika_presenter_render_tick( + ErikaPresenterHandle *handle, + double time_seconds, + ErikaPresenterStats *out_stats); +ErikaStatus erika_presenter_audio_only_tick( + ErikaPresenterHandle *handle, + ErikaPresenterStats *out_stats); +ErikaStatus erika_presenter_get_stats( + ErikaPresenterHandle *handle, + ErikaPresenterStats *out_stats); +ErikaStatus erika_presenter_poll_event(ErikaPresenterHandle *handle, ErikaEvent *out_event); + +/* JSON bridge used by embedders whose platform channel already serializes + * structured arguments. Returned strings are owned by Erika and must be + * released with erika_string_free. poll_event_json returns NULL when idle. */ +char *erika_presenter_invoke_json( + ErikaPresenterHandle *handle, + const char *method, + const char *arguments_json); +char *erika_presenter_render_tick_json( + ErikaPresenterHandle *handle, + double time_seconds); +char *erika_presenter_poll_event_json(ErikaPresenterHandle *handle); + +/* Screenshot: render the current composited frame (video + subtitle, no + * danmaku) off-screen into a caller-allocated RGBA8 buffer at the requested + * size. out_capacity must be >= width*height*4. Fails if no frame is + * available yet. */ +ErikaStatus erika_presenter_capture_frame_rgba( + ErikaPresenterHandle *handle, + uint32_t width, + uint32_t height, + uint8_t *out_rgba, + uintptr_t out_capacity); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/third_party/erika_flutter/native/licenses/LICENSE.Apache-2.0 b/third_party/erika_flutter/native/licenses/LICENSE.Apache-2.0 new file mode 100644 index 00000000..9bcbb5a1 --- /dev/null +++ b/third_party/erika_flutter/native/licenses/LICENSE.Apache-2.0 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/erika_flutter/native/licenses/LICENSE.FFmpeg.md b/third_party/erika_flutter/native/licenses/LICENSE.FFmpeg.md new file mode 100644 index 00000000..613070e1 --- /dev/null +++ b/third_party/erika_flutter/native/licenses/LICENSE.FFmpeg.md @@ -0,0 +1,129 @@ +# License + +Most files in FFmpeg are under the GNU Lesser General Public License version 2.1 +or later (LGPL v2.1+). Read the file `COPYING.LGPLv2.1` for details. Some other +files have MIT/X11/BSD-style licenses. In combination the LGPL v2.1+ applies to +FFmpeg. + +Some optional parts of FFmpeg are licensed under the GNU General Public License +version 2 or later (GPL v2+). See the file `COPYING.GPLv2` for details. None of +these parts are used by default, you have to explicitly pass `--enable-gpl` to +configure to activate them. In this case, FFmpeg's license changes to GPL v2+. + +Specifically, the GPL parts of FFmpeg are: + +- libpostproc +- optional x86 optimization in the files + - `libavcodec/x86/flac_dsp_gpl.asm` + - `libavcodec/x86/idct_mmx.c` + - `libavfilter/x86/vf_removegrain.asm` +- the following building and testing tools + - `compat/solaris/make_sunver.pl` + - `doc/t2h.pm` + - `doc/texi2pod.pl` + - `libswresample/tests/swresample.c` + - `tests/checkasm/*` + - `tests/tiny_ssim.c` +- the following filters in libavfilter: + - `signature_lookup.c` + - `vf_blackframe.c` + - `vf_boxblur.c` + - `vf_colormatrix.c` + - `vf_cover_rect.c` + - `vf_cropdetect.c` + - `vf_delogo.c` + - `vf_eq.c` + - `vf_find_rect.c` + - `vf_fspp.c` + - `vf_histeq.c` + - `vf_hqdn3d.c` + - `vf_kerndeint.c` + - `vf_lensfun.c` (GPL version 3 or later) + - `vf_mcdeint.c` + - `vf_mpdecimate.c` + - `vf_nnedi.c` + - `vf_owdenoise.c` + - `vf_perspective.c` + - `vf_phase.c` + - `vf_pp.c` + - `vf_pp7.c` + - `vf_pullup.c` + - `vf_repeatfields.c` + - `vf_sab.c` + - `vf_signature.c` + - `vf_smartblur.c` + - `vf_spp.c` + - `vf_stereo3d.c` + - `vf_super2xsai.c` + - `vf_tinterlace.c` + - `vf_uspp.c` + - `vf_vaguedenoiser.c` + - `vsrc_mptestsrc.c` + +Should you, for whatever reason, prefer to use version 3 of the (L)GPL, then +the configure parameter `--enable-version3` will activate this licensing option +for you. Read the file `COPYING.LGPLv3` or, if you have enabled GPL parts, +`COPYING.GPLv3` to learn the exact legal terms that apply in this case. + +There are a handful of files under other licensing terms, namely: + +* The files `libavcodec/jfdctfst.c`, `libavcodec/jfdctint_template.c` and + `libavcodec/jrevdct.c` are taken from libjpeg, see the top of the files for + licensing details. Specifically note that you must credit the IJG in the + documentation accompanying your program if you only distribute executables. + You must also indicate any changes including additions and deletions to + those three files in the documentation. +* `tests/reference.pnm` is under the expat license. + + +## External libraries + +FFmpeg can be combined with a number of external libraries, which sometimes +affect the licensing of binaries resulting from the combination. + +### Compatible libraries + +The following libraries are under GPL version 2: +- avisynth +- frei0r +- libcdio +- libdavs2 +- librubberband +- libvidstab +- libx264 +- libx265 +- libxavs +- libxavs2 +- libxvid + +When combining them with FFmpeg, FFmpeg needs to be licensed as GPL as well by +passing `--enable-gpl` to configure. + +The following libraries are under LGPL version 3: +- gmp +- libaribb24 +- liblensfun + +When combining them with FFmpeg, use the configure option `--enable-version3` to +upgrade FFmpeg to the LGPL v3. + +The VMAF, mbedTLS, RK MPI, OpenCORE and VisualOn libraries are under the Apache License +2.0. That license is incompatible with the LGPL v2.1 and the GPL v2, but not with +version 3 of those licenses. So to combine these libraries with FFmpeg, the +license version needs to be upgraded by passing `--enable-version3` to configure. + +The smbclient library is under the GPL v3, to combine it with FFmpeg, +the options `--enable-gpl` and `--enable-version3` have to be passed to +configure to upgrade FFmpeg to the GPL v3. + +### Incompatible libraries + +There are certain libraries you can combine with FFmpeg whose licenses are not +compatible with the GPL and/or the LGPL. If you wish to enable these +libraries, even in circumstances that their license may be incompatible, pass +`--enable-nonfree` to configure. This will cause the resulting binary to be +unredistributable. + +The Fraunhofer FDK AAC and OpenSSL libraries are under licenses which are +incompatible with the GPLv2 and v3. To the best of our knowledge, they are +compatible with the LGPL. diff --git a/third_party/erika_flutter/native/licenses/LICENSE.FreeType b/third_party/erika_flutter/native/licenses/LICENSE.FreeType new file mode 100644 index 00000000..c406d150 --- /dev/null +++ b/third_party/erika_flutter/native/licenses/LICENSE.FreeType @@ -0,0 +1,169 @@ + The FreeType Project LICENSE + ---------------------------- + + 2006-Jan-27 + + Copyright 1996-2002, 2006 by + David Turner, Robert Wilhelm, and Werner Lemberg + + + +Introduction +============ + + The FreeType Project is distributed in several archive packages; + some of them may contain, in addition to the FreeType font engine, + various tools and contributions which rely on, or relate to, the + FreeType Project. + + This license applies to all files found in such packages, and + which do not fall under their own explicit license. The license + affects thus the FreeType font engine, the test programs, + documentation and makefiles, at the very least. + + This license was inspired by the BSD, Artistic, and IJG + (Independent JPEG Group) licenses, which all encourage inclusion + and use of free software in commercial and freeware products + alike. As a consequence, its main points are that: + + o We don't promise that this software works. However, we will be + interested in any kind of bug reports. (`as is' distribution) + + o You can use this software for whatever you want, in parts or + full form, without having to pay us. (`royalty-free' usage) + + o You may not pretend that you wrote this software. If you use + it, or only parts of it, in a program, you must acknowledge + somewhere in your documentation that you have used the + FreeType code. (`credits') + + We specifically permit and encourage the inclusion of this + software, with or without modifications, in commercial products. + We disclaim all warranties covering The FreeType Project and + assume no liability related to The FreeType Project. + + + Finally, many people asked us for a preferred form for a + credit/disclaimer to use in compliance with this license. We thus + encourage you to use the following text: + + """ + Portions of this software are copyright © The FreeType + Project (www.freetype.org). All rights reserved. + """ + + Please replace with the value from the FreeType version you + actually use. + + +Legal Terms +=========== + +0. Definitions +-------------- + + Throughout this license, the terms `package', `FreeType Project', + and `FreeType archive' refer to the set of files originally + distributed by the authors (David Turner, Robert Wilhelm, and + Werner Lemberg) as the `FreeType Project', be they named as alpha, + beta or final release. + + `You' refers to the licensee, or person using the project, where + `using' is a generic term including compiling the project's source + code as well as linking it to form a `program' or `executable'. + This program is referred to as `a program using the FreeType + engine'. + + This license applies to all files distributed in the original + FreeType Project, including all source code, binaries and + documentation, unless otherwise stated in the file in its + original, unmodified form as distributed in the original archive. + If you are unsure whether or not a particular file is covered by + this license, you must contact us to verify this. + + The FreeType Project is copyright (C) 1996-2000 by David Turner, + Robert Wilhelm, and Werner Lemberg. All rights reserved except as + specified below. + +1. No Warranty +-------------- + + THE FREETYPE PROJECT IS PROVIDED `AS IS' WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. IN NO EVENT WILL ANY OF THE AUTHORS OR COPYRIGHT HOLDERS + BE LIABLE FOR ANY DAMAGES CAUSED BY THE USE OR THE INABILITY TO + USE, OF THE FREETYPE PROJECT. + +2. Redistribution +----------------- + + This license grants a worldwide, royalty-free, perpetual and + irrevocable right and license to use, execute, perform, compile, + display, copy, create derivative works of, distribute and + sublicense the FreeType Project (in both source and object code + forms) and derivative works thereof for any purpose; and to + authorize others to exercise some or all of the rights granted + herein, subject to the following conditions: + + o Redistribution of source code must retain this license file + (`FTL.TXT') unaltered; any additions, deletions or changes to + the original files must be clearly indicated in accompanying + documentation. The copyright notices of the unaltered, + original files must be preserved in all copies of source + files. + + o Redistribution in binary form must provide a disclaimer that + states that the software is based in part of the work of the + FreeType Team, in the distribution documentation. We also + encourage you to put an URL to the FreeType web page in your + documentation, though this isn't mandatory. + + These conditions apply to any software derived from or based on + the FreeType Project, not just the unmodified files. If you use + our work, you must acknowledge us. However, no fee need be paid + to us. + +3. Advertising +-------------- + + Neither the FreeType authors and contributors nor you shall use + the name of the other for commercial, advertising, or promotional + purposes without specific prior written permission. + + We suggest, but do not require, that you use one or more of the + following phrases to refer to this software in your documentation + or advertising materials: `FreeType Project', `FreeType Engine', + `FreeType library', or `FreeType Distribution'. + + As you have not signed this license, you are not required to + accept it. However, as the FreeType Project is copyrighted + material, only this license, or another one contracted with the + authors, grants you the right to use, distribute, and modify it. + Therefore, by using, distributing, or modifying the FreeType + Project, you indicate that you understand and accept all the terms + of this license. + +4. Contacts +----------- + + There are two mailing lists related to FreeType: + + o freetype@nongnu.org + + Discusses general use and applications of FreeType, as well as + future and wanted additions to the library and distribution. + If you are looking for support, start in this list if you + haven't found anything to help you in the documentation. + + o freetype-devel@nongnu.org + + Discusses bugs, as well as engine internals, design issues, + specific licenses, porting, etc. + + Our home page can be found at + + https://www.freetype.org + + +--- end of FTL.TXT --- diff --git a/third_party/erika_flutter/native/licenses/LICENSE.GPL-3.0 b/third_party/erika_flutter/native/licenses/LICENSE.GPL-3.0 new file mode 100644 index 00000000..94a9ed02 --- /dev/null +++ b/third_party/erika_flutter/native/licenses/LICENSE.GPL-3.0 @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/third_party/erika_flutter/native/licenses/LICENSE.HarfBuzz b/third_party/erika_flutter/native/licenses/LICENSE.HarfBuzz new file mode 100644 index 00000000..1dd917e9 --- /dev/null +++ b/third_party/erika_flutter/native/licenses/LICENSE.HarfBuzz @@ -0,0 +1,42 @@ +HarfBuzz is licensed under the so-called "Old MIT" license. Details follow. +For parts of HarfBuzz that are licensed under different licenses see individual +files names COPYING in subdirectories where applicable. + +Copyright © 2010-2022 Google, Inc. +Copyright © 2015-2020 Ebrahim Byagowi +Copyright © 2019,2020 Facebook, Inc. +Copyright © 2012,2015 Mozilla Foundation +Copyright © 2011 Codethink Limited +Copyright © 2008,2010 Nokia Corporation and/or its subsidiary(-ies) +Copyright © 2009 Keith Stribley +Copyright © 2011 Martin Hosken and SIL International +Copyright © 2007 Chris Wilson +Copyright © 2005,2006,2020,2021,2022,2023 Behdad Esfahbod +Copyright © 2004,2007,2008,2009,2010,2013,2021,2022,2023 Red Hat, Inc. +Copyright © 1998-2005 David Turner and Werner Lemberg +Copyright © 2016 Igalia S.L. +Copyright © 2022 Matthias Clasen +Copyright © 2018,2021 Khaled Hosny +Copyright © 2018,2019,2020 Adobe, Inc +Copyright © 2013-2015 Alexei Podtelezhnikov + +For full copyright notices consult the individual files in the package. + + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. diff --git a/third_party/erika_flutter/native/licenses/LICENSE.LGPL-2.1 b/third_party/erika_flutter/native/licenses/LICENSE.LGPL-2.1 new file mode 100644 index 00000000..20fb9c7d --- /dev/null +++ b/third_party/erika_flutter/native/licenses/LICENSE.LGPL-2.1 @@ -0,0 +1,458 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS diff --git a/third_party/erika_flutter/native/licenses/LICENSE.LGPL-3.0 b/third_party/erika_flutter/native/licenses/LICENSE.LGPL-3.0 new file mode 100644 index 00000000..65c5ca88 --- /dev/null +++ b/third_party/erika_flutter/native/licenses/LICENSE.LGPL-3.0 @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/third_party/erika_flutter/native/licenses/LICENSE.dav1d b/third_party/erika_flutter/native/licenses/LICENSE.dav1d new file mode 100644 index 00000000..875b138e --- /dev/null +++ b/third_party/erika_flutter/native/licenses/LICENSE.dav1d @@ -0,0 +1,23 @@ +Copyright © 2018-2019, VideoLAN and dav1d authors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/erika_flutter/native/licenses/LICENSE.libass b/third_party/erika_flutter/native/licenses/LICENSE.libass new file mode 100644 index 00000000..3ff0e114 --- /dev/null +++ b/third_party/erika_flutter/native/licenses/LICENSE.libass @@ -0,0 +1,15 @@ +ISC License + +Copyright (C) 2006-2016 libass contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/third_party/erika_flutter/native/licenses/LICENSE.zlib b/third_party/erika_flutter/native/licenses/LICENSE.zlib new file mode 100644 index 00000000..ab8ee6f7 --- /dev/null +++ b/third_party/erika_flutter/native/licenses/LICENSE.zlib @@ -0,0 +1,22 @@ +Copyright notice: + + (C) 1995-2022 Jean-loup Gailly and Mark Adler + + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. + + Jean-loup Gailly Mark Adler + jloup@gzip.org madler@alumni.caltech.edu diff --git a/third_party/erika_flutter/native/licenses/THIRD_PARTY_NOTICES.md b/third_party/erika_flutter/native/licenses/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..e25639f8 --- /dev/null +++ b/third_party/erika_flutter/native/licenses/THIRD_PARTY_NOTICES.md @@ -0,0 +1,62 @@ +# Third-Party Notices + +Erika's own source code is licensed under **MPL-2.0** (see `LICENSE`). + +The prebuilt `erika_capi` binaries in this bundle **statically link** the +following third-party native libraries, built with Erika's `lgpl` dependency +profile. Their licenses apply to the corresponding portions of the binary. + +| Component | Version | License | +|-----------|---------|---------| +| FFmpeg (libav*) | 8.1.2 | LGPL v3 (configured `--disable-gpl --enable-version3`) | +| dav1d | 1.5.x | BSD 2-Clause | +| libass | 0.17.5 | ISC | +| FreeType | 2.14.3 | FTL / GPLv2 (FTL used here) | +| HarfBuzz | 14.2.1 | MIT (Old) | +| FriBidi | 1.0.x | LGPL v2.1+ | +| zlib | 1.3.x | zlib | +| SoundTouch | 2.3.2 | LGPL v2.1 | + +The Erika binary also embeds these assets: + +| Asset | Copyright / author | License | +|-------|--------------------|---------| +| Droid Sans Fallback (`subfont.ttf`) | Digitized data copyright Google Corporation © 2006; designed by Steve Matteson / Ascender Corporation | Apache-2.0 | +| ArtCNN C4F16 / C4F32 model weights | Copyright © 2024 João Chrisóstomo | MIT | + +The release archive includes the corresponding attribution and complete license +texts under `licenses/`. + +This software is based in part on the work of the FreeType Team. Portions of +FFmpeg's DCT implementation derive from the Independent JPEG Group's work; see +`licenses/LICENSE.FFmpeg.md` for FFmpeg's complete licensing notes. + +## LGPL compliance (FFmpeg, FriBidi, SoundTouch) + +These binaries link LGPL components statically. To honor the LGPL's relinking +requirement, the complete corresponding source and the reproducible build +system that produced these binaries are publicly available: + +- **Erika source** (this exact build): the Git tag named in the release / the + commit recorded in the bundle's `MANIFEST.txt`, at + . +- **Native dependency build**: `xtask deps build --all --profile lgpl` plus the + per-target build described in `docs/building.md` in that source tree. + +Anyone who receives these binaries can therefore rebuild `erika_capi` against a +modified version of FFmpeg (or any other LGPL component above) by checking out +that source and re-running the build with their replacement library. + +SoundTouch 2.3.2 is bundled by the Cargo packages pinned in `Cargo.lock` +(`soundtouch` 0.5.4 and `soundtouch-ffi` 0.4.1). Its corresponding source ships +inside the `soundtouch-ffi` package downloaded by Cargo. SoundTouch is copyright +© Olli Parviainen 2001–2022. A recipient can patch or replace that dependency +and rebuild the complete Erika static library with Cargo to relink against a +modified SoundTouch. The LGPL v2.1 text is included at +`licenses/LICENSE.LGPL-2.1`. + +The archive's `licenses/` directory contains the applicable FFmpeg LGPLv3/GPLv3 +terms, the dav1d BSD 2-Clause license, the FreeType License, and the libass, +HarfBuzz, and zlib notices in addition to the asset and LGPLv2.1 texts. The same +upstream license files are also distributed with each library's source archive +(retrievable via `xtask deps fetch`). diff --git a/third_party/erika_flutter/native/macos/liberika_capi.dylib b/third_party/erika_flutter/native/macos/liberika_capi.dylib new file mode 100755 index 00000000..f72fc340 Binary files /dev/null and b/third_party/erika_flutter/native/macos/liberika_capi.dylib differ diff --git a/third_party/erika_flutter/native/patches/quiet-default-diagnostics.patch b/third_party/erika_flutter/native/patches/quiet-default-diagnostics.patch new file mode 100644 index 00000000..5ab43aea --- /dev/null +++ b/third_party/erika_flutter/native/patches/quiet-default-diagnostics.patch @@ -0,0 +1,24 @@ +diff --git a/crates/erika/src/trace.rs b/crates/erika/src/trace.rs +index ab5a090..c0a0959 100644 +--- a/crates/erika/src/trace.rs ++++ b/crates/erika/src/trace.rs +@@ -19,10 +19,16 @@ pub(crate) fn log(line: impl AsRef) { + append_line(line.as_ref(), default_trace_path()); + } + +-/// Emits an unconditional operational diagnostic. Decoder/backend transitions +-/// use this path because a hardware fallback must remain visible even when the +-/// optional playback trace is disabled. ++/// Emits an opt-in operational diagnostic. ++/// ++/// Host integrations already receive playback failures through the event and ++/// error APIs. Keeping routine decoder and lifecycle transitions off stderr by ++/// default prevents normal playback from flooding application logs. Set ++/// `ERIKA_DIAGNOSTICS=1` or one of the trace flags to inspect these events. + pub(crate) fn diagnostic(line: impl AsRef) { ++ if !enabled() && !env_flag("ERIKA_DIAGNOSTICS") { ++ return; ++ } + let line = line.as_ref(); + #[cfg(target_os = "android")] + android_log(line); diff --git a/third_party/erika_flutter/native/windows/x64/erika_capi.dll b/third_party/erika_flutter/native/windows/x64/erika_capi.dll new file mode 100644 index 00000000..1daeb9ab Binary files /dev/null and b/third_party/erika_flutter/native/windows/x64/erika_capi.dll differ diff --git a/third_party/erika_flutter/native_artifacts.properties b/third_party/erika_flutter/native_artifacts.properties new file mode 100644 index 00000000..fe492ba8 --- /dev/null +++ b/third_party/erika_flutter/native_artifacts.properties @@ -0,0 +1,15 @@ +# Native artifacts consumed by erika_flutter 0.1.7. +# Keep this version aligned with pubspec.yaml and every platform manifest. +ERIKA_NATIVE_VERSION=0.1.7 +ERIKA_ANDROID_ARM64_V8A_SHA256=a94249f0b83fb9d2c73141c3966d78acf2a34a9892b1eb33661bafb8835cda1f +ERIKA_ANDROID_ARMEABI_V7A_SHA256=6c0f0908e76b511a99a5cd9038c1b81ccd997255eae2ae8e6aac04c3824efdba +ERIKA_ANDROID_X86_64_SHA256=5aa6612afd365cfdf6a837630890bc8031a12714e410794776a5f41a90df5aec +ERIKA_ANDROID_X86_SHA256=0fa58d0bc6581b80bf778767514690576454138f519c6619706cda953e7ad73b +ERIKA_IOS_SHA256=6c75df01158a43bc45b86e2ea031630d59cb589a8860485f19f93768655797ff +ERIKA_TVOS_SHA256=bc6fa5d358b4eb3dbadc51796130c897e4098337e11a9b46f32c0d7117f288d6 +ERIKA_MACOS_ARM64_SHA256=13f185c22ebba631780c2d4eaf6ffe7d76e1b4b8eeb48acc95ffeeba037e4be2 +ERIKA_MACOS_X64_SHA256=3ca075eeefc79b783f0c6e02a4b702dbc5ad996c6bfd84d8fcd8eb4d5705d4ab +ERIKA_MACOS_UNIVERSAL_SHA256=4cacd741ac8f867d880decea3ab7c607203a9fbc13df7fef907c7ba59125dd50 +ERIKA_WINDOWS_X64_SHA256=afe81cf758d2c38de73ab434c738f8cf0af0e310723717c8ca544b666b13136e +ERIKA_WINDOWS_ARM64_SHA256=001768f1b5c079b6f611b2fdeb6f04882e4a706283be1c03767226e44f9c90f2 +ERIKA_OPENHARMONY_ARM64_SHA256=492afbab9ae9f20f55ce6ae2212094ece6f6c0cca1bee96ddffc175068afa396 diff --git a/third_party/erika_flutter/ohos/.gitignore b/third_party/erika_flutter/ohos/.gitignore new file mode 100644 index 00000000..184fb908 --- /dev/null +++ b/third_party/erika_flutter/ohos/.gitignore @@ -0,0 +1,14 @@ +/node_modules +/oh_modules +/local.properties +/.idea +**/build +/.hvigor +.cxx +/.clangd +/.clang-format +/.clang-tidy +**/.test +**.har +**/oh-package-lock.json5 +BuildProfile.ets diff --git a/third_party/erika_flutter/ohos/build-profile.json5 b/third_party/erika_flutter/ohos/build-profile.json5 new file mode 100644 index 00000000..7a6ae9b8 --- /dev/null +++ b/third_party/erika_flutter/ohos/build-profile.json5 @@ -0,0 +1,16 @@ +{ + "apiType": "stageMode", + "buildOption": { + "externalNativeOptions": { + "path": "./src/main/cpp/CMakeLists.txt", + "arguments": "", + "abiFilters": ["arm64-v8a"], + "targets": ["erika_flutter_plugin"] + } + }, + "targets": [ + { + "name": "default" + } + ] +} diff --git a/third_party/erika_flutter/ohos/hvigorfile.ts b/third_party/erika_flutter/ohos/hvigorfile.ts new file mode 100644 index 00000000..8132322e --- /dev/null +++ b/third_party/erika_flutter/ohos/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: harTasks, + plugins: [] +} diff --git a/third_party/erika_flutter/ohos/index.ets b/third_party/erika_flutter/ohos/index.ets new file mode 100644 index 00000000..4a596141 --- /dev/null +++ b/third_party/erika_flutter/ohos/index.ets @@ -0,0 +1,3 @@ +import ErikaFlutterPlugin from './src/main/ets/components/plugin/ErikaFlutterPlugin'; + +export default ErikaFlutterPlugin; diff --git a/third_party/erika_flutter/ohos/oh-package.json5 b/third_party/erika_flutter/ohos/oh-package.json5 new file mode 100644 index 00000000..02034384 --- /dev/null +++ b/third_party/erika_flutter/ohos/oh-package.json5 @@ -0,0 +1,11 @@ +{ + "name": "erika_flutter", + "version": "0.1.7", + "description": "Erika media engine Flutter plugin for OpenHarmony", + "main": "index.ets", + "author": "Aimesoft", + "license": "MPL-2.0", + "dependencies": { + "@ohos/flutter_ohos": "file:./har/flutter.har" + } +} diff --git a/third_party/erika_flutter/ohos/src/main/cpp/CMakeLists.txt b/third_party/erika_flutter/ohos/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..1d94bee7 --- /dev/null +++ b/third_party/erika_flutter/ohos/src/main/cpp/CMakeLists.txt @@ -0,0 +1,224 @@ +cmake_minimum_required(VERSION 3.17) +project(erika_flutter LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(PLUGIN_NAME erika_flutter_plugin) + +get_filename_component(ERIKA_PACKAGE_ROOT + "${CMAKE_CURRENT_SOURCE_DIR}/../../../.." REALPATH) +set(ERIKA_ARTIFACT_MANIFEST + "${ERIKA_PACKAGE_ROOT}/native_artifacts.properties") +if(NOT EXISTS "${ERIKA_ARTIFACT_MANIFEST}") + message(FATAL_ERROR + "Erika native artifact manifest is missing: ${ERIKA_ARTIFACT_MANIFEST}") +endif() + +function(erika_read_artifact_property key output) + file(STRINGS "${ERIKA_ARTIFACT_MANIFEST}" value REGEX "^${key}=") + if(NOT value) + message(FATAL_ERROR "Missing ${key} in ${ERIKA_ARTIFACT_MANIFEST}") + endif() + list(GET value 0 value) + string(REGEX REPLACE "^[^=]+=" "" value "${value}") + set(${output} "${value}" PARENT_SCOPE) +endfunction() + +erika_read_artifact_property(ERIKA_NATIVE_VERSION ERIKA_NATIVE_VERSION) +erika_read_artifact_property( + ERIKA_OPENHARMONY_ARM64_SHA256 ERIKA_DEFAULT_PREBUILT_SHA256) +set(ERIKA_DEFAULT_PREBUILT_TAG "v${ERIKA_NATIVE_VERSION}") +set(ERIKA_TARGET aarch64-unknown-linux-ohos) +set(ERIKA_RUNTIME "") + +set(ERIKA_USE_PREBUILT ON) +if(NOT "$ENV{ERIKA_FORCE_SOURCE_BUILD}" STREQUAL "1") + if("$ENV{ERIKA_PREBUILT_TAG}" STREQUAL "") + set(ERIKA_PREBUILT_TAG "${ERIKA_DEFAULT_PREBUILT_TAG}") + else() + set(ERIKA_PREBUILT_TAG "$ENV{ERIKA_PREBUILT_TAG}") + endif() + if("$ENV{ERIKA_PREBUILT_SHA256}" STREQUAL "") + if(NOT ERIKA_PREBUILT_TAG STREQUAL ERIKA_DEFAULT_PREBUILT_TAG) + message(FATAL_ERROR + "ERIKA_PREBUILT_SHA256 is required when ERIKA_PREBUILT_TAG overrides ${ERIKA_DEFAULT_PREBUILT_TAG}") + endif() + set(ERIKA_PREBUILT_SHA256 "${ERIKA_DEFAULT_PREBUILT_SHA256}") + else() + set(ERIKA_PREBUILT_SHA256 "$ENV{ERIKA_PREBUILT_SHA256}") + endif() + string(REGEX REPLACE "[^A-Za-z0-9._-]" "_" ERIKA_PREBUILT_CACHE_KEY + "${ERIKA_PREBUILT_TAG}" + ) + set(ERIKA_PREBUILT_DIR + "${CMAKE_CURRENT_BINARY_DIR}/erika-prebuilt-${ERIKA_PREBUILT_CACHE_KEY}" + ) + set(ERIKA_PREBUILT_ZIP + "${ERIKA_PREBUILT_DIR}/erika-capi-openharmony-arm64.zip" + ) + set(ERIKA_PREBUILT_RUNTIME + "${ERIKA_PREBUILT_DIR}/erika-capi-openharmony-arm64/lib/liberika_capi.so" + ) + set(ERIKA_PREBUILT_URL + "https://github.com/AimesSoft/Erika/releases/download/${ERIKA_PREBUILT_TAG}/erika-capi-openharmony-arm64.zip" + ) + + if(EXISTS "${ERIKA_PREBUILT_ZIP}") + file(SHA256 "${ERIKA_PREBUILT_ZIP}" ERIKA_CACHED_PREBUILT_SHA256) + if(NOT ERIKA_CACHED_PREBUILT_SHA256 STREQUAL ERIKA_PREBUILT_SHA256) + file(REMOVE "${ERIKA_PREBUILT_ZIP}" "${ERIKA_PREBUILT_RUNTIME}") + endif() + elseif(EXISTS "${ERIKA_PREBUILT_RUNTIME}") + file(REMOVE "${ERIKA_PREBUILT_RUNTIME}") + endif() + if(NOT EXISTS "${ERIKA_PREBUILT_RUNTIME}") + file(MAKE_DIRECTORY "${ERIKA_PREBUILT_DIR}") + message(STATUS "Erika: downloading prebuilt ${ERIKA_PREBUILT_URL}") + file( + DOWNLOAD "${ERIKA_PREBUILT_URL}" "${ERIKA_PREBUILT_ZIP}" + STATUS ERIKA_PREBUILT_DOWNLOAD_STATUS + EXPECTED_HASH "SHA256=${ERIKA_PREBUILT_SHA256}" + SHOW_PROGRESS + TLS_VERIFY ON + TIMEOUT 120 + INACTIVITY_TIMEOUT 30 + ) + list(GET ERIKA_PREBUILT_DOWNLOAD_STATUS 0 ERIKA_PREBUILT_DOWNLOAD_CODE) + if(ERIKA_PREBUILT_DOWNLOAD_CODE EQUAL 0) + execute_process( + COMMAND "${CMAKE_COMMAND}" -E tar xf "${ERIKA_PREBUILT_ZIP}" + WORKING_DIRECTORY "${ERIKA_PREBUILT_DIR}" + RESULT_VARIABLE ERIKA_PREBUILT_EXTRACT_STATUS + ) + if(NOT ERIKA_PREBUILT_EXTRACT_STATUS EQUAL 0) + message(FATAL_ERROR + "Failed to extract Erika prebuilt ${ERIKA_PREBUILT_TAG}") + endif() + else() + list(GET ERIKA_PREBUILT_DOWNLOAD_STATUS 1 ERIKA_PREBUILT_DOWNLOAD_MESSAGE) + message(FATAL_ERROR + "Erika prebuilt download or checksum verification failed: ${ERIKA_PREBUILT_DOWNLOAD_MESSAGE}. Set ERIKA_FORCE_SOURCE_BUILD=1 only from an Erika checkout.") + endif() + endif() + + set(ERIKA_PREBUILT_RUNTIME_VALID OFF) + if(EXISTS "${ERIKA_PREBUILT_RUNTIME}") + file(SIZE "${ERIKA_PREBUILT_RUNTIME}" ERIKA_PREBUILT_RUNTIME_SIZE) + if(ERIKA_PREBUILT_RUNTIME_SIZE GREATER 0) + set(ERIKA_PREBUILT_RUNTIME_VALID ON) + endif() + endif() + if(ERIKA_PREBUILT_RUNTIME_VALID) + set(ERIKA_RUNTIME "${ERIKA_PREBUILT_RUNTIME}") + message(STATUS "Erika: using prebuilt ${ERIKA_PREBUILT_TAG}") + else() + message(FATAL_ERROR + "Erika prebuilt ${ERIKA_PREBUILT_TAG} did not contain liberika_capi.so") + endif() +else() + set(ERIKA_USE_PREBUILT OFF) + if(NOT "$ENV{ERIKA_REPO_ROOT}" STREQUAL "") + file(TO_CMAKE_PATH "$ENV{ERIKA_REPO_ROOT}" ERIKA_ROOT) + else() + get_filename_component(ERIKA_ROOT "${ERIKA_PACKAGE_ROOT}/../.." REALPATH) + endif() + if(NOT EXISTS "${ERIKA_ROOT}/crates/erika_capi/Cargo.toml") + message(FATAL_ERROR + "ERIKA_FORCE_SOURCE_BUILD=1 requires an Erika checkout; set ERIKA_REPO_ROOT") + endif() + set(ERIKA_RUNTIME + "${ERIKA_ROOT}/target/${ERIKA_TARGET}/release/liberika_capi.so") +endif() + +if(NOT DEFINED OHOS_SDK_NATIVE) + message(FATAL_ERROR "OHOS_SDK_NATIVE is required to build Erika for OpenHarmony") +endif() + +set(OHOS_LLVM_BIN "${OHOS_SDK_NATIVE}/llvm/bin") +set(OHOS_CXX_INCLUDE + "${OHOS_SDK_NATIVE}/llvm/include/libcxx-ohos/include/c++/v1" +) +if(NOT EXISTS "${OHOS_CXX_INCLUDE}/vector") + set(OHOS_CXX_INCLUDE "${OHOS_SDK_NATIVE}/llvm/include/c++/v1") +endif() +set(OHOS_BINDGEN_ARGS + "--target=${ERIKA_TARGET} --sysroot=${OHOS_SDK_NATIVE}/sysroot -isystem ${OHOS_CXX_INCLUDE}" +) +if(NOT ERIKA_USE_PREBUILT) + find_program(CARGO_EXECUTABLE cargo REQUIRED) + file(GLOB_RECURSE ERIKA_RUST_SOURCES CONFIGURE_DEPENDS + "${ERIKA_ROOT}/crates/*.rs" + "${ERIKA_ROOT}/third_party/wgpu-hal/*.rs" + "${ERIKA_ROOT}/xtask/*.rs" + ) + list(APPEND ERIKA_RUST_SOURCES + "${ERIKA_ROOT}/Cargo.toml" + "${ERIKA_ROOT}/Cargo.lock" + ) + + add_custom_command( + OUTPUT "${ERIKA_RUNTIME}" + COMMAND + "${CMAKE_COMMAND}" -E env + "OHOS_NDK_HOME=${OHOS_SDK_NATIVE}" + "${CARGO_EXECUTABLE}" run -q -p xtask -- + deps build --profile lgpl --target "${ERIKA_TARGET}" + COMMAND + "${CMAKE_COMMAND}" -E env + "ERIKA_NATIVE_PROFILE=lgpl" + "ERIKA_NATIVE_TARGET=${ERIKA_TARGET}" + "OHOS_NDK_HOME=${OHOS_SDK_NATIVE}" + "LIBCLANG_PATH=${OHOS_SDK_NATIVE}/llvm/lib" + "BINDGEN_EXTRA_CLANG_ARGS_aarch64_unknown_linux_ohos=${OHOS_BINDGEN_ARGS}" + "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_OHOS_LINKER=${OHOS_LLVM_BIN}/aarch64-unknown-linux-ohos-clang" + "CC_aarch64_unknown_linux_ohos=${OHOS_LLVM_BIN}/aarch64-unknown-linux-ohos-clang" + "CXX_aarch64_unknown_linux_ohos=${OHOS_LLVM_BIN}/aarch64-unknown-linux-ohos-clang++" + "AR_aarch64_unknown_linux_ohos=${OHOS_LLVM_BIN}/llvm-ar" + "${CARGO_EXECUTABLE}" build -p erika_capi + --target "${ERIKA_TARGET}" --release + --no-default-features --features wgpu + WORKING_DIRECTORY "${ERIKA_ROOT}" + DEPENDS ${ERIKA_RUST_SOURCES} + COMMENT "Building Erika OpenHarmony runtime" + VERBATIM + ) + add_custom_target(erika_ohos_runtime DEPENDS "${ERIKA_RUNTIME}") +endif() + +add_library(erika_capi SHARED IMPORTED GLOBAL) +set_target_properties(erika_capi PROPERTIES IMPORTED_LOCATION "${ERIKA_RUNTIME}") +if(NOT ERIKA_USE_PREBUILT) + add_dependencies(erika_capi erika_ohos_runtime) +endif() + +add_library(${PLUGIN_NAME} SHARED erika_flutter_plugin.cpp) +if(NOT ERIKA_USE_PREBUILT) + add_dependencies(${PLUGIN_NAME} erika_ohos_runtime) +endif() +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden + OUTPUT_NAME "erika_flutter" +) +target_include_directories(${PLUGIN_NAME} PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${ERIKA_PACKAGE_ROOT}/native/include" +) +target_compile_options(${PLUGIN_NAME} PRIVATE + -fno-exceptions + -ffunction-sections + -fdata-sections +) +target_link_options(${PLUGIN_NAME} PRIVATE -Wl,--gc-sections) +target_link_libraries(${PLUGIN_NAME} PRIVATE + erika_capi + libace_napi.z.so + libnative_window.so + libhilog_ndk.z.so +) +add_custom_command( + TARGET ${PLUGIN_NAME} POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${ERIKA_RUNTIME}" "$/liberika_capi.so" + COMMENT "Staging Erika OpenHarmony runtime beside the Flutter plugin" + VERBATIM +) diff --git a/third_party/erika_flutter/ohos/src/main/cpp/erika_flutter_plugin.cpp b/third_party/erika_flutter/ohos/src/main/cpp/erika_flutter_plugin.cpp new file mode 100644 index 00000000..8deac70b --- /dev/null +++ b/third_party/erika_flutter/ohos/src/main/cpp/erika_flutter_plugin.cpp @@ -0,0 +1,390 @@ +#include "include/erika_flutter/erika_flutter_plugin.h" + +#include +#include + +#include +#include +#include +#include +#include + +#include "erika.h" + +namespace { + +struct OhosPlayer { + ErikaPresenterHandle* presenter = nullptr; + OHNativeWindow* window = nullptr; +}; + +std::unordered_map g_players; + +napi_value Null(napi_env env) { + napi_value value = nullptr; + napi_get_null(env, &value); + return value; +} + +napi_value Int64(napi_env env, int64_t value) { + napi_value result = nullptr; + napi_create_int64(env, value, &result); + return result; +} + +napi_value Int32(napi_env env, int32_t value) { + napi_value result = nullptr; + napi_create_int32(env, value, &result); + return result; +} + +napi_value String(napi_env env, const char* value) { + napi_value result = nullptr; + napi_create_string_utf8(env, value == nullptr ? "" : value, NAPI_AUTO_LENGTH, &result); + return result; +} + +int64_t GetInt64(napi_env env, napi_value value) { + int64_t result = 0; + napi_get_value_int64(env, value, &result); + return result; +} + +int32_t GetInt32(napi_env env, napi_value value) { + int32_t result = 0; + napi_get_value_int32(env, value, &result); + return result; +} + +double GetDouble(napi_env env, napi_value value) { + double result = 0.0; + napi_get_value_double(env, value, &result); + return result; +} + +std::string GetString(napi_env env, napi_value value) { + size_t size = 0; + napi_get_value_string_utf8(env, value, nullptr, 0, &size); + std::string result(size, '\0'); + if (size > 0) { + size_t written = 0; + result.resize(size + 1); + napi_get_value_string_utf8(env, value, result.data(), result.size(), &written); + result.resize(written); + } + return result; +} + +OhosPlayer* FindPlayer(int64_t player_id) { + const auto found = g_players.find(player_id); + return found == g_players.end() ? nullptr : &found->second; +} + +void ReleaseWindow(OhosPlayer& player) { + if (player.presenter != nullptr && player.window != nullptr) { + erika_presenter_detach_surface(player.presenter); + } + if (player.window != nullptr) { + OH_NativeWindow_DestroyNativeWindow(player.window); + player.window = nullptr; + } +} + +napi_value NativeCreate(napi_env env, napi_callback_info info) { + size_t argc = 3; + napi_value args[3] = {}; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc < 3) { + return Int64(env, 0); + } + ErikaPresenterConfig config = {}; + config.output_mode = GetInt32(env, args[0]); + config.edr_headroom = static_cast(GetDouble(env, args[1])); + config.luma_upscaler = GetInt32(env, args[2]); + auto* presenter = erika_presenter_create_with_config(config); + if (presenter == nullptr) { + return Int64(env, 0); + } + const auto player_id = static_cast(reinterpret_cast(presenter)); + g_players.emplace(player_id, OhosPlayer{presenter, nullptr}); + return Int64(env, player_id); +} + +napi_value NativeLastError(napi_env env, napi_callback_info) { + char* message = erika_last_error_message(); + napi_value result = String(env, message); + erika_string_free(message); + return result; +} + +napi_value NativeDestroy(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1] = {}; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc == 0) { + return Null(env); + } + const auto player_id = GetInt64(env, args[0]); + const auto found = g_players.find(player_id); + if (found != g_players.end()) { + ReleaseWindow(found->second); + erika_presenter_destroy(found->second.presenter); + g_players.erase(found); + } + return Null(env); +} + +napi_value NativeInvoke(napi_env env, napi_callback_info info) { + size_t argc = 3; + napi_value args[3] = {}; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc < 3) { + return String(env, R"({"ok":false,"status":1,"error":"missing nativeInvoke argument"})"); + } + OhosPlayer* player = FindPlayer(GetInt64(env, args[0])); + if (player == nullptr) { + return String(env, R"({"ok":false,"status":1,"error":"unknown Erika player"})"); + } + const std::string method = GetString(env, args[1]); + const std::string arguments = GetString(env, args[2]); + char* response = erika_presenter_invoke_json( + player->presenter, method.c_str(), arguments.c_str()); + napi_value result = String(env, response); + erika_string_free(response); + return result; +} + +napi_value NativeRegisterSubtitleMemoryFont(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value args[2] = {}; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc < 2) { + napi_value result = nullptr; + napi_create_array_with_length(env, 2, &result); + napi_set_element(env, result, 0, Int32(env, ErikaStatus_NullPointer)); + napi_set_element(env, result, 1, Int64(env, 0)); + return result; + } + OhosPlayer* player = FindPlayer(GetInt64(env, args[0])); + bool is_typed_array = false; + napi_is_typedarray(env, args[1], &is_typed_array); + napi_typedarray_type array_type = napi_uint8_array; + size_t byte_count = 0; + void* bytes = nullptr; + napi_value array_buffer = nullptr; + size_t byte_offset = 0; + const bool valid_bytes = is_typed_array && + napi_get_typedarray_info( + env, args[1], &array_type, &byte_count, &bytes, &array_buffer, &byte_offset) == napi_ok && + (array_type == napi_uint8_array || array_type == napi_uint8_clamped_array); + uint64_t font_id = 0; + const auto status = player == nullptr || !valid_bytes + ? ErikaStatus_NullPointer + : erika_presenter_register_subtitle_memory_font( + player->presenter, static_cast(bytes), byte_count, &font_id); + napi_value result = nullptr; + napi_create_array_with_length(env, 2, &result); + napi_set_element(env, result, 0, Int32(env, status)); + napi_set_element(env, result, 1, Int64(env, static_cast(font_id))); + return result; +} + +napi_value NativeAttachSurface(napi_env env, napi_callback_info info) { + size_t argc = 5; + napi_value args[5] = {}; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc < 5) { + return Int32(env, ErikaStatus_NullPointer); + } + OhosPlayer* player = FindPlayer(GetInt64(env, args[0])); + if (player == nullptr) { + return Int32(env, ErikaStatus_NullPointer); + } + ReleaseWindow(*player); + const auto surface_id = static_cast(GetInt64(env, args[1])); + OHNativeWindow* window = nullptr; + const int32_t create_status = + OH_NativeWindow_CreateNativeWindowFromSurfaceId(surface_id, &window); + if (create_status != 0 || window == nullptr) { + return Int32(env, ErikaStatus_PlayerError); + } + const uint32_t width = static_cast(GetInt32(env, args[2])); + const uint32_t height = static_cast(GetInt32(env, args[3])); + const double scale = GetDouble(env, args[4]); + const auto status = erika_presenter_attach_wgpu_surface( + player->presenter, + ErikaWgpuSurfaceKind_OhosNativeWindow, + static_cast(reinterpret_cast(window)), + 0, + width, + height, + scale); + if (status != ErikaStatus_Ok) { + OH_NativeWindow_DestroyNativeWindow(window); + return Int32(env, status); + } + player->window = window; + return Int32(env, ErikaStatus_Ok); +} + +napi_value NativeResizeSurface(napi_env env, napi_callback_info info) { + size_t argc = 4; + napi_value args[4] = {}; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc < 4) { + return Int32(env, ErikaStatus_NullPointer); + } + OhosPlayer* player = FindPlayer(GetInt64(env, args[0])); + if (player == nullptr) { + return Int32(env, ErikaStatus_NullPointer); + } + return Int32( + env, + erika_presenter_resize_surface( + player->presenter, + static_cast(GetInt32(env, args[1])), + static_cast(GetInt32(env, args[2])), + GetDouble(env, args[3]))); +} + +napi_value NativeDetachSurface(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1] = {}; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc == 0) { + return Int32(env, ErikaStatus_NullPointer); + } + OhosPlayer* player = FindPlayer(GetInt64(env, args[0])); + if (player == nullptr) { + return Int32(env, ErikaStatus_NullPointer); + } + const auto status = erika_presenter_detach_surface(player->presenter); + if (player->window != nullptr) { + OH_NativeWindow_DestroyNativeWindow(player->window); + player->window = nullptr; + } + return Int32(env, status); +} + +napi_value NativeRenderTick(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value args[2] = {}; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc < 2) { + return String(env, R"({"ok":false,"status":1,"error":"missing render argument"})"); + } + OhosPlayer* player = FindPlayer(GetInt64(env, args[0])); + if (player == nullptr) { + return String(env, R"({"ok":false,"status":1,"error":"unknown Erika player"})"); + } + char* response = + erika_presenter_render_tick_json(player->presenter, GetDouble(env, args[1])); + napi_value result = String(env, response); + erika_string_free(response); + return result; +} + +napi_value NativePollEvent(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1] = {}; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc == 0) { + return Null(env); + } + OhosPlayer* player = FindPlayer(GetInt64(env, args[0])); + if (player == nullptr) { + return Null(env); + } + char* response = erika_presenter_poll_event_json(player->presenter); + if (response == nullptr) { + return Null(env); + } + napi_value result = String(env, response); + erika_string_free(response); + return result; +} + +napi_value NativeCaptureFrame(napi_env env, napi_callback_info info) { + size_t argc = 3; + napi_value args[3] = {}; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc < 3) { + return Null(env); + } + OhosPlayer* player = FindPlayer(GetInt64(env, args[0])); + if (player == nullptr) { + return Null(env); + } + + const int32_t requested_width = GetInt32(env, args[1]); + const int32_t requested_height = GetInt32(env, args[2]); + if (requested_width <= 0 || requested_height <= 0) { + return Null(env); + } + const auto width = static_cast(requested_width); + const auto height = static_cast(requested_height); + const size_t width_size = static_cast(width); + const size_t height_size = static_cast(height); + if (height_size > std::numeric_limits::max() / width_size || + width_size * height_size > + std::numeric_limits::max() / static_cast(4)) { + return Null(env); + } + const size_t byte_count = width_size * height_size * 4; + + napi_value array_buffer = nullptr; + void* rgba = nullptr; + if (napi_create_arraybuffer(env, byte_count, &rgba, &array_buffer) != napi_ok || + rgba == nullptr) { + return Null(env); + } + const auto status = erika_presenter_capture_frame_rgba( + player->presenter, + width, + height, + static_cast(rgba), + byte_count); + if (status != ErikaStatus_Ok) { + return Null(env); + } + + napi_value bytes = nullptr; + if (napi_create_typedarray( + env, + napi_uint8_array, + byte_count, + array_buffer, + 0, + &bytes) != napi_ok) { + return Null(env); + } + return bytes; +} + +napi_value Init(napi_env env, napi_value exports) { + napi_property_descriptor descriptors[] = { + {"nativeCreate", nullptr, NativeCreate, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"nativeLastError", nullptr, NativeLastError, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"nativeDestroy", nullptr, NativeDestroy, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"nativeInvoke", nullptr, NativeInvoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"nativeRegisterSubtitleMemoryFont", nullptr, NativeRegisterSubtitleMemoryFont, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"nativeAttachSurface", nullptr, NativeAttachSurface, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"nativeResizeSurface", nullptr, NativeResizeSurface, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"nativeDetachSurface", nullptr, NativeDetachSurface, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"nativeRenderTick", nullptr, NativeRenderTick, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"nativePollEvent", nullptr, NativePollEvent, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"nativeCaptureFrame", nullptr, NativeCaptureFrame, nullptr, nullptr, nullptr, napi_default, nullptr}, + }; + napi_define_properties( + env, exports, sizeof(descriptors) / sizeof(descriptors[0]), descriptors); + return exports; +} + +} // namespace + +NAPI_MODULE(erika_flutter, Init) + +extern "C" void* ErikaOhosPlayerFromId(int64_t player_id) { + OhosPlayer* player = FindPlayer(player_id); + return player == nullptr ? nullptr : player->presenter; +} diff --git a/third_party/erika_flutter/ohos/src/main/cpp/include/erika_flutter/erika_flutter_plugin.h b/third_party/erika_flutter/ohos/src/main/cpp/include/erika_flutter/erika_flutter_plugin.h new file mode 100644 index 00000000..ba725c08 --- /dev/null +++ b/third_party/erika_flutter/ohos/src/main/cpp/include/erika_flutter/erika_flutter_plugin.h @@ -0,0 +1,16 @@ +#ifndef FLUTTER_PLUGIN_ERIKA_FLUTTER_PLUGIN_H_ +#define FLUTTER_PLUGIN_ERIKA_FLUTTER_PLUGIN_H_ + +#include + +#if defined(__cplusplus) +extern "C" { +#endif + +__attribute__((visibility("default"))) void *ErikaOhosPlayerFromId(int64_t player_id); + +#if defined(__cplusplus) +} +#endif + +#endif diff --git a/third_party/erika_flutter/ohos/src/main/cpp/types/liberika_flutter/Index.d.ts b/third_party/erika_flutter/ohos/src/main/cpp/types/liberika_flutter/Index.d.ts new file mode 100644 index 00000000..ddd85ffc --- /dev/null +++ b/third_party/erika_flutter/ohos/src/main/cpp/types/liberika_flutter/Index.d.ts @@ -0,0 +1,9 @@ +export const nativeCreate: (outputMode: number, headroom: number, upscaler: number) => number; +export const nativeLastError: () => string | null; +export const nativeDestroy: (playerId: number) => void; +export const nativeInvoke: (playerId: number, method: string, argumentsJson: string) => string; +export const nativeAttachSurface: (playerId: number, surfaceId: number, width: number, height: number, scale: number) => number; +export const nativeResizeSurface: (playerId: number, width: number, height: number, scale: number) => number; +export const nativeDetachSurface: (playerId: number) => number; +export const nativeRenderTick: (playerId: number, timeSeconds: number) => string; +export const nativePollEvent: (playerId: number) => string | null; diff --git a/third_party/erika_flutter/ohos/src/main/cpp/types/liberika_flutter/oh-package.json5 b/third_party/erika_flutter/ohos/src/main/cpp/types/liberika_flutter/oh-package.json5 new file mode 100644 index 00000000..61d8dcbd --- /dev/null +++ b/third_party/erika_flutter/ohos/src/main/cpp/types/liberika_flutter/oh-package.json5 @@ -0,0 +1,5 @@ +{ + "name": "liberika_flutter.so", + "version": "0.1.3", + "types": "./Index.d.ts" +} diff --git a/third_party/erika_flutter/ohos/src/main/ets/components/plugin/ErikaFlutterPlugin.ets b/third_party/erika_flutter/ohos/src/main/ets/components/plugin/ErikaFlutterPlugin.ets new file mode 100644 index 00000000..3c838556 --- /dev/null +++ b/third_party/erika_flutter/ohos/src/main/ets/components/plugin/ErikaFlutterPlugin.ets @@ -0,0 +1,916 @@ +import { + AbilityAware, + AbilityPluginBinding, + EventChannel, + EventSink, + FlutterPlugin, + FlutterPluginBinding, + MethodCall, + MethodCallHandler, + MethodChannel, + MethodResult, + StandardMethodCodec, + SurfaceTextureEntry, +} from '@ohos/flutter_ohos'; +import { common, UIAbility } from '@kit.AbilityKit'; +import { avSession } from '@kit.AVSessionKit'; +import { image } from '@kit.ImageKit'; +import erikaNative from 'liberika_flutter.so'; + +interface NativeResponse { + ok: boolean; + status: number; + error?: string; + value?: ESObject; +} + +interface ErikaTexture { + entry: SurfaceTextureEntry; + surfaceId: number; + width: number; + height: number; + scale: number; + playerId: number; +} + +interface ErikaMediaMetadata { + title: string; + artist?: string; + album?: string; + artwork?: Uint8Array; +} + +interface ErikaMediaState { + playbackState: number; + positionMicros: number; + durationMicros: number; + playbackRate: number; +} + +interface ErikaMediaNavigation { + previousEnabled: boolean; + nextEnabled: boolean; +} + +const PLAYER_CHANNEL: string = 'erika_flutter/player'; +const EVENT_CHANNEL: string = 'erika_flutter/events'; +const FRAME_INTERVAL_MS: number = 16; +const STATE_CHANGED_EVENT_KIND: number = 1; +const DURATION_CHANGED_EVENT_KIND: number = 2; +const POSITION_CHANGED_EVENT_KIND: number = 3; +const SYSTEM_MEDIA_NAVIGATION_EVENT_KIND: number = 13; +const PLAYING_STATE: number = 3; +const PAUSED_STATE: number = 4; +const STOPPED_STATE: number = 5; +const CLOSED_STATE: number = 6; +const ERROR_STATE: number = 7; + +export default class ErikaFlutterPlugin implements FlutterPlugin, MethodCallHandler, AbilityAware { + private binding: FlutterPluginBinding | null = null; + private ability: UIAbility | null = null; + private methodChannel: MethodChannel | null = null; + private eventChannel: EventChannel | null = null; + private eventSink: EventSink | null = null; + private players: Set = new Set(); + private textures: Map = new Map(); + private mediaMetadata: Map = new Map(); + private mediaStates: Map = new Map(); + private mediaNavigation: Map = new Map(); + private currentPlayerId: number = 0; + private currentSession: avSession.AVSession | null = null; + private sessionPromise: Promise | null = null; + private sessionGeneration: number = 0; + private frameTimer: number = -1; + + getUniqueClassName(): string { + return 'ErikaFlutterPlugin'; + } + + onAttachedToEngine(binding: FlutterPluginBinding): void { + this.binding = binding; + const messenger = binding.getBinaryMessenger(); + this.methodChannel = new MethodChannel( + messenger, + PLAYER_CHANNEL, + StandardMethodCodec.INSTANCE, + ); + this.methodChannel.setMethodCallHandler(this); + this.eventChannel = new EventChannel( + messenger, + EVENT_CHANNEL, + StandardMethodCodec.INSTANCE, + ); + this.eventChannel.setStreamHandler({ + onListen: (_args: ESObject, sink: EventSink): void => { + this.eventSink = sink; + this.drainAllEvents(); + }, + onCancel: (_args: ESObject): void => { + this.eventSink = null; + }, + }); + this.startFrameLoop(); + } + + onDetachedFromEngine(_binding: FlutterPluginBinding): void { + this.stopFrameLoop(); + this.textures.forEach((texture: ErikaTexture, textureId: number): void => { + if (texture.playerId > 0) { + erikaNative.nativeDetachSurface(texture.playerId); + } + this.binding?.getTextureRegistry()?.unregisterTexture(textureId); + }); + this.textures.clear(); + this.players.forEach((playerId: number): void => { + erikaNative.nativeDestroy(playerId); + }); + this.players.clear(); + this.mediaMetadata.clear(); + this.mediaStates.clear(); + this.mediaNavigation.clear(); + this.currentPlayerId = 0; + this.destroyAVSession(); + this.methodChannel?.setMethodCallHandler(null); + this.eventChannel?.setStreamHandler(null); + this.methodChannel = null; + this.eventChannel = null; + this.eventSink = null; + this.binding = null; + } + + onAttachedToAbility(binding: AbilityPluginBinding): void { + this.ability = binding.getAbility(); + } + + onDetachedFromAbility(): void { + this.ability = null; + this.destroyAVSession(); + } + + onMethodCall(call: MethodCall, result: MethodResult): void { + const args: Map = call.args as Map; + if (call.method === 'create') { + this.createPlayer(args, result); + return; + } + if (call.method === 'dispose') { + this.disposePlayer(args, result); + return; + } + if (call.method === 'createTexture') { + this.createTexture(args, result); + return; + } + if (call.method === 'resizeTexture') { + this.resizeTexture(args, result); + return; + } + if (call.method === 'releaseTexture') { + this.releaseTexture(args, result); + return; + } + if (call.method === 'attachView') { + this.attachView(args, result); + return; + } + if (call.method === 'detachView') { + this.detachView(args, result); + return; + } + if (call.method === 'screenshot') { + this.captureFrame(args, result); + return; + } + if (call.method === 'setMediaMetadata') { + this.setMediaMetadata(args, result); + return; + } + if (call.method === 'setSystemMediaNavigation') { + this.setSystemMediaNavigation(args, result); + return; + } + if (call.method === 'open') { + this.openPlayer(args, result); + return; + } + if (call.method === 'registerSubtitleMemoryFont') { + this.registerSubtitleMemoryFont(args, result); + return; + } + if (this.isNativePlayerMethod(call.method)) { + this.invokePlayer(call.method, args, result); + return; + } + result.notImplemented(); + } + + private createPlayer(args: Map, result: MethodResult): void { + const outputMode = this.numberArg(args, 'outputMode', 0); + const headroom = this.numberArg( + args, + 'edrHeadroom', + outputMode === 2 ? 4.0 : 1.0, + ); + const upscaler = this.numberArg(args, 'upscaler', 0); + const playerId = erikaNative.nativeCreate(outputMode, headroom, upscaler) as number; + if (playerId <= 0) { + result.error( + 'ERIKA_ERROR', + 'Erika OpenHarmony presenter creation failed: ' + + (erikaNative.nativeLastError() as string), + null, + ); + return; + } + this.players.add(playerId); + this.mediaStates.set(playerId, { + playbackState: 0, + positionMicros: 0, + durationMicros: 0, + playbackRate: 1.0, + }); + this.mediaNavigation.set(playerId, { + previousEnabled: false, + nextEnabled: false, + }); + result.success(playerId); + } + + private disposePlayer(args: Map, result: MethodResult): void { + const playerId = this.numberArg(args, 'playerId', 0); + this.textures.forEach((texture: ErikaTexture): void => { + if (texture.playerId === playerId) { + erikaNative.nativeDetachSurface(playerId); + texture.playerId = 0; + } + }); + if (this.players.delete(playerId)) { + erikaNative.nativeDestroy(playerId); + } + this.mediaMetadata.delete(playerId); + this.mediaStates.delete(playerId); + this.mediaNavigation.delete(playerId); + if (this.currentPlayerId === playerId) { + this.currentPlayerId = 0; + this.deactivateAVSession(); + } + result.success(null); + } + + private openPlayer(args: Map, result: MethodResult): void { + const metadataValue: ESObject = args.get('metadata'); + let metadata: ErikaMediaMetadata | null = null; + if (metadataValue !== undefined && metadataValue !== null) { + metadata = this.parseMediaMetadata(metadataValue); + if (metadata === null) { + result.error('INVALID_ARGUMENT', 'metadata.title is required', null); + return; + } + } + const playerId = this.numberArg(args, 'playerId', 0); + const nativeArgs: Map = new Map(args); + nativeArgs.delete('metadata'); + if (!this.invokePlayerNative('open', nativeArgs, result)) { + return; + } + const state = this.mediaStates.get(playerId); + if (state !== undefined) { + state.playbackState = 0; + state.positionMicros = 0; + state.durationMicros = 0; + } + if (metadata !== null) { + this.mediaMetadata.set(playerId, metadata); + if (this.currentPlayerId === playerId) { + this.publishMediaMetadata(playerId, metadata); + } + } else { + this.mediaMetadata.delete(playerId); + if (this.currentPlayerId === playerId) { + this.destroyAVSession(); + } + } + if (this.currentPlayerId === playerId) { + this.publishPlaybackState(playerId); + } + } + + private setMediaMetadata(args: Map, result: MethodResult): void { + const playerId = this.numberArg(args, 'playerId', 0); + if (!this.players.has(playerId)) { + result.error('ERIKA_ERROR', 'Unknown Erika player ' + playerId, null); + return; + } + const metadata = this.parseMediaMetadata(args.get('metadata')); + if (metadata === null) { + result.error('INVALID_ARGUMENT', 'metadata.title is required', null); + return; + } + this.mediaMetadata.set(playerId, metadata); + result.success(null); + if (this.currentPlayerId === playerId) { + this.publishMediaMetadata(playerId, metadata); + } + } + + private setSystemMediaNavigation( + args: Map, + result: MethodResult, + ): void { + const playerId = this.numberArg(args, 'playerId', 0); + if (!this.players.has(playerId)) { + result.error('ERIKA_ERROR', 'Unknown Erika player ' + playerId, null); + return; + } + this.mediaNavigation.set(playerId, { + previousEnabled: args.get('previousEnabled') === true, + nextEnabled: args.get('nextEnabled') === true, + }); + result.success(null); + const metadata = this.mediaMetadata.get(playerId); + if (metadata !== undefined && this.currentPlayerId === playerId) { + this.publishMediaMetadata(playerId, metadata); + } + } + + private createTexture(args: Map, result: MethodResult): void { + const registry = this.binding?.getTextureRegistry(); + if (!registry) { + result.error('NO_TEXTURE_REGISTRY', 'TextureRegistry is unavailable', null); + return; + } + const width = Math.max(1, Math.trunc(this.numberArg(args, 'width', 1))); + const height = Math.max(1, Math.trunc(this.numberArg(args, 'height', 1))); + const scale = Math.max(0.1, this.numberArg(args, 'scale', 1.0)); + const textureId = registry.getTextureId(); + const entry = registry.registerTexture(textureId); + registry.setTextureBufferSize(textureId, width, height); + this.textures.set(textureId, { + entry: entry, + surfaceId: entry.getSurfaceId(), + width: width, + height: height, + scale: scale, + playerId: 0, + }); + result.success(textureId); + } + + private resizeTexture(args: Map, result: MethodResult): void { + const textureId = this.numberArg(args, 'textureId', -1); + const texture = this.textures.get(textureId); + if (!texture) { + result.error('ERIKA_ERROR', 'Unknown Erika texture ' + textureId, null); + return; + } + texture.width = Math.max(1, Math.trunc(this.numberArg(args, 'width', texture.width))); + texture.height = Math.max(1, Math.trunc(this.numberArg(args, 'height', texture.height))); + texture.scale = Math.max(0.1, this.numberArg(args, 'scale', texture.scale)); + this.binding?.getTextureRegistry()?.setTextureBufferSize( + textureId, + texture.width, + texture.height, + ); + if (texture.playerId > 0) { + const status = erikaNative.nativeResizeSurface( + texture.playerId, + texture.width, + texture.height, + texture.scale, + ) as number; + if (!this.completeStatus(status, result, 'resize OpenHarmony surface')) { + return; + } + } + result.success(null); + } + + private releaseTexture(args: Map, result: MethodResult): void { + const textureId = this.numberArg(args, 'textureId', -1); + const texture = this.textures.get(textureId); + if (texture) { + if (texture.playerId > 0) { + erikaNative.nativeDetachSurface(texture.playerId); + } + this.binding?.getTextureRegistry()?.unregisterTexture(textureId); + this.textures.delete(textureId); + } + result.success(null); + } + + private attachView(args: Map, result: MethodResult): void { + const playerId = this.numberArg(args, 'playerId', 0); + const textureId = this.numberArg(args, 'viewId', -1); + const texture = this.textures.get(textureId); + if (!this.players.has(playerId) || !texture) { + result.error('ERIKA_ERROR', 'Unknown Erika player or texture', null); + return; + } + if (texture.playerId > 0 && texture.playerId !== playerId) { + erikaNative.nativeDetachSurface(texture.playerId); + } + const status = erikaNative.nativeAttachSurface( + playerId, + texture.surfaceId, + texture.width, + texture.height, + texture.scale, + ) as number; + if (!this.completeStatus(status, result, 'attach OpenHarmony surface')) { + return; + } + texture.playerId = playerId; + result.success(null); + this.drainEvents(playerId); + } + + private detachView(args: Map, result: MethodResult): void { + const playerId = this.numberArg(args, 'playerId', 0); + const textureId = this.numberArg(args, 'viewId', -1); + const texture = this.textures.get(textureId); + if (!texture || texture.playerId !== playerId) { + result.success(null); + return; + } + const status = erikaNative.nativeDetachSurface(playerId) as number; + texture.playerId = 0; + if (!this.completeStatus(status, result, 'detach OpenHarmony surface')) { + return; + } + result.success(null); + this.drainEvents(playerId); + } + + private captureFrame(args: Map, result: MethodResult): void { + const playerId = this.numberArg(args, 'playerId', 0); + if (!this.players.has(playerId)) { + result.error('ERIKA_ERROR', 'Unknown Erika player ' + playerId, null); + return; + } + + let fallbackWidth = 0; + let fallbackHeight = 0; + this.textures.forEach((texture: ErikaTexture): void => { + if (texture.playerId === playerId) { + fallbackWidth = texture.width; + fallbackHeight = texture.height; + } + }); + const width = Math.trunc(this.numberArg(args, 'width', fallbackWidth)); + const height = Math.trunc(this.numberArg(args, 'height', fallbackHeight)); + if (width <= 0 || height <= 0) { + result.error( + 'ERIKA_ERROR', + 'Screenshot width and height are required before an OpenHarmony surface is attached', + null, + ); + return; + } + + const bytes = erikaNative.nativeCaptureFrame( + playerId, + width, + height, + ) as Uint8Array | null; + result.success(bytes); + } + + private invokePlayer( + method: string, + args: Map, + result: MethodResult, + ): void { + if (!this.invokePlayerNative(method, args, result)) { + return; + } + const playerId = this.numberArg(args, 'playerId', 0); + if (method === 'play') { + this.currentPlayerId = playerId; + const metadata = this.mediaMetadata.get(playerId); + if (metadata !== undefined) { + this.publishMediaMetadata(playerId, metadata); + } + this.publishPlaybackState(playerId); + } else if (method === 'setPlaybackRate') { + const state = this.mediaStates.get(playerId); + if (state !== undefined) { + state.playbackRate = this.numberArg(args, 'rate', 1.0); + } + this.publishPlaybackState(playerId); + } + } + + private invokePlayerNative( + method: string, + args: Map, + result: MethodResult, + ): boolean { + const playerId = this.numberArg(args, 'playerId', 0); + if (!this.players.has(playerId)) { + result.error('ERIKA_ERROR', 'Unknown Erika player ' + playerId, null); + return false; + } + const argumentsJson = JSON.stringify(this.mapToObject(args)); + const raw = erikaNative.nativeInvoke(playerId, method, argumentsJson) as string; + const response = JSON.parse(raw) as NativeResponse; + if (!response.ok) { + result.error( + 'ERIKA_ERROR', + response.error ?? ('Erika native method failed with status ' + response.status), + response, + ); + return false; + } + result.success(response.value ?? null); + this.drainEvents(playerId); + return true; + } + + private parseMediaMetadata(value: ESObject): ErikaMediaMetadata | null { + if (!(value instanceof Map)) { + return null; + } + const metadata: Map = value as Map; + const titleValue: ESObject = metadata.get('title'); + if (typeof titleValue !== 'string' || titleValue.length === 0) { + return null; + } + const parsed: ErikaMediaMetadata = { title: titleValue }; + const artist: ESObject = metadata.get('artist'); + const album: ESObject = metadata.get('album'); + const artwork: ESObject = metadata.get('artwork'); + if (typeof artist === 'string') { + parsed.artist = artist; + } + if (typeof album === 'string') { + parsed.album = album; + } + if (artwork instanceof Uint8Array) { + parsed.artwork = artwork; + } + return parsed; + } + + private async ensureAVSession(): Promise { + if (this.currentSession !== null) { + return this.currentSession; + } + if (this.sessionPromise !== null) { + return this.sessionPromise; + } + const context = this.ability?.context as common.UIAbilityContext | undefined; + if (context === undefined) { + return null; + } + const generation = this.sessionGeneration; + const promise = this.createAVSession(context, generation); + this.sessionPromise = promise; + const session = await promise; + if (this.sessionPromise === promise) { + this.sessionPromise = null; + } + return session; + } + + private async createAVSession( + context: common.UIAbilityContext, + generation: number, + ): Promise { + let session: avSession.AVSession | null = null; + try { + session = await avSession.createAVSession(context, 'ErikaVideoPlayer', 'video'); + if (generation !== this.sessionGeneration || this.ability?.context !== context) { + await session.destroy().catch((): void => {}); + return null; + } + this.registerAVSessionCommands(session); + await session.activate(); + if (generation !== this.sessionGeneration || this.ability?.context !== context) { + await session.destroy().catch((): void => {}); + return null; + } + this.currentSession = session; + return session; + } catch (_) { + await session?.destroy().catch((): void => {}); + return null; + } + } + + private registerAVSessionCommands(session: avSession.AVSession): void { + session.on('play', (): void => this.invokeFromAVSession('play')); + session.on('pause', (): void => this.invokeFromAVSession('pause')); + session.on('stop', (): void => this.invokeFromAVSession('stop')); + session.on('seek', (time: number): void => { + this.invokeFromAVSession('seek', new Map([ + ['positionMicros', time * 1000], + ])); + }); + session.on('playPrevious', (): void => this.emitSystemMediaNavigation('previous')); + session.on('playNext', (): void => this.emitSystemMediaNavigation('next')); + } + + private emitSystemMediaNavigation(navigation: string): void { + const playerId = this.currentPlayerId; + const capabilities = this.mediaNavigation.get(playerId); + const enabled = navigation === 'previous' + ? capabilities?.previousEnabled === true + : capabilities?.nextEnabled === true; + if (!this.players.has(playerId) || !enabled) { + return; + } + this.eventSink?.success({ + playerId: playerId, + kind: SYSTEM_MEDIA_NAVIGATION_EVENT_KIND, + navigation: navigation, + }); + } + + private invokeFromAVSession(method: string, extra?: Map): void { + const playerId = this.currentPlayerId; + if (!this.players.has(playerId)) { + return; + } + const args: Map = + extra ?? new Map(); + args.set('playerId', playerId); + const raw = erikaNative.nativeInvoke( + playerId, + method, + JSON.stringify(this.mapToObject(args)), + ) as string; + const response = JSON.parse(raw) as NativeResponse; + if (response.ok) { + this.drainEvents(playerId); + } + } + + private async publishMediaMetadata( + playerId: number, + metadata: ErikaMediaMetadata, + ): Promise { + const session = await this.ensureAVSession(); + if (session === null || this.currentPlayerId !== playerId || + this.mediaMetadata.get(playerId) !== metadata) { + return; + } + const avMetadata: avSession.AVMetadata = { + assetId: 'erika-' + playerId, + title: metadata.title, + }; + const capabilities = this.mediaNavigation.get(playerId); + if (capabilities?.previousEnabled === true) { + avMetadata.previousAssetId = 'erika-previous-' + playerId; + } + if (capabilities?.nextEnabled === true) { + avMetadata.nextAssetId = 'erika-next-' + playerId; + } + if (metadata.artist !== undefined) { + avMetadata.artist = metadata.artist; + } + if (metadata.album !== undefined) { + avMetadata.album = metadata.album; + } + let mediaImage: image.PixelMap | null = null; + if (metadata.artwork !== undefined) { + let source: image.ImageSource | null = null; + try { + const bytes = metadata.artwork; + const artwork = new Uint8Array(bytes.byteLength); + artwork.set(bytes); + source = image.createImageSource(artwork.buffer as ArrayBuffer); + mediaImage = await source.createPixelMap(); + avMetadata.mediaImage = mediaImage; + } catch (_) { + } finally { + await source?.release().catch((): void => {}); + } + } + const state = this.mediaStates.get(playerId); + if (state !== undefined && state.durationMicros > 0) { + avMetadata.duration = state.durationMicros / 1000.0; + } + if (this.currentPlayerId !== playerId || this.mediaMetadata.get(playerId) !== metadata) { + await mediaImage?.release().catch((): void => {}); + return; + } + await session.setAVMetadata(avMetadata).catch((): void => {}); + await mediaImage?.release().catch((): void => {}); + } + + private async publishPlaybackState(playerId: number): Promise { + const mediaState = this.mediaStates.get(playerId); + if (mediaState === undefined || this.currentPlayerId !== playerId) { + return; + } + const session = await this.ensureAVSession(); + if (session === null || this.currentPlayerId !== playerId) { + return; + } + await session.activate().catch((): void => {}); + await session.setAVPlaybackState({ + state: this.avPlaybackState(mediaState.playbackState), + speed: mediaState.playbackState === PLAYING_STATE ? mediaState.playbackRate : 0, + position: { + elapsedTime: mediaState.positionMicros / 1000.0, + updateTime: Date.now(), + }, + duration: mediaState.durationMicros / 1000.0, + }).catch((): void => {}); + if (mediaState.playbackState === CLOSED_STATE || mediaState.playbackState === ERROR_STATE) { + this.deactivateAVSession(); + } + } + + private avPlaybackState(state: number): avSession.PlaybackState { + if (state === PLAYING_STATE) { + return avSession.PlaybackState.PLAYBACK_STATE_PLAY; + } + if (state === PAUSED_STATE) { + return avSession.PlaybackState.PLAYBACK_STATE_PAUSE; + } + if (state === STOPPED_STATE) { + return avSession.PlaybackState.PLAYBACK_STATE_STOP; + } + if (state === CLOSED_STATE) { + return avSession.PlaybackState.PLAYBACK_STATE_RELEASED; + } + if (state === ERROR_STATE) { + return avSession.PlaybackState.PLAYBACK_STATE_ERROR; + } + return avSession.PlaybackState.PLAYBACK_STATE_PREPARE; + } + + private deactivateAVSession(): void { + this.sessionGeneration += 1; + this.sessionPromise = null; + this.currentSession?.deactivate().catch((): void => {}); + } + + private destroyAVSession(): void { + const session = this.currentSession; + this.sessionGeneration += 1; + this.currentSession = null; + this.sessionPromise = null; + session?.destroy().catch((): void => {}); + } + + private registerSubtitleMemoryFont( + args: Map, + result: MethodResult, + ): void { + const playerId = this.numberArg(args, 'playerId', 0); + if (!this.players.has(playerId)) { + result.error('ERIKA_ERROR', 'Unknown Erika player ' + playerId, null); + return; + } + const data = args.get('data') as Uint8Array; + const response = erikaNative.nativeRegisterSubtitleMemoryFont( + playerId, + data, + ) as Array; + const status = response[0] ?? 1; + if (!this.completeStatus(status, result, 'register subtitle memory font')) { + return; + } + result.success(response[1] ?? 0); + } + + private startFrameLoop(): void { + if (this.frameTimer >= 0) { + return; + } + this.frameTimer = setInterval((): void => { + const frameTimeSeconds = Date.now() / 1000.0; + this.players.forEach((playerId: number): void => { + erikaNative.nativeRenderTick(playerId, frameTimeSeconds); + this.drainEvents(playerId); + }); + }, FRAME_INTERVAL_MS); + } + + private stopFrameLoop(): void { + if (this.frameTimer >= 0) { + clearInterval(this.frameTimer); + this.frameTimer = -1; + } + } + + private drainAllEvents(): void { + this.players.forEach((playerId: number): void => this.drainEvents(playerId)); + } + + private drainEvents(playerId: number): void { + const sink = this.eventSink; + if (!sink) { + return; + } + for (let index = 0; index < 64; index += 1) { + const raw = erikaNative.nativePollEvent(playerId) as string | null; + if (raw === null) { + return; + } + const response = JSON.parse(raw) as NativeResponse; + if (!response.ok || response.value === undefined) { + return; + } + const event: ESObject = response.value; + event.playerId = playerId; + this.observeMediaEvent(playerId, event); + sink.success(event); + } + } + + private observeMediaEvent(playerId: number, event: ESObject): void { + const mediaState = this.mediaStates.get(playerId); + if (mediaState === undefined) { + return; + } + const kind = typeof event.kind === 'number' ? event.kind as number : 0; + if (kind === STATE_CHANGED_EVENT_KIND) { + mediaState.playbackState = typeof event.state === 'number' + ? event.state as number + : mediaState.playbackState; + } + if (kind === POSITION_CHANGED_EVENT_KIND && typeof event.positionMicros === 'number') { + mediaState.positionMicros = Math.max(0, event.positionMicros as number); + } + if ((kind === DURATION_CHANGED_EVENT_KIND || kind === STATE_CHANGED_EVENT_KIND) && + typeof event.durationMicros === 'number') { + mediaState.durationMicros = Math.max(0, event.durationMicros as number); + } + if (this.currentPlayerId !== playerId) { + return; + } + if (kind === DURATION_CHANGED_EVENT_KIND) { + const metadata = this.mediaMetadata.get(playerId); + if (metadata !== undefined) { + this.publishMediaMetadata(playerId, metadata); + } + } + if (kind === STATE_CHANGED_EVENT_KIND || kind === DURATION_CHANGED_EVENT_KIND || + kind === POSITION_CHANGED_EVENT_KIND) { + this.publishPlaybackState(playerId); + } + } + + private completeStatus(status: number, result: MethodResult, operation: string): boolean { + if (status === 0) { + return true; + } + const nativeError = erikaNative.nativeLastError() as string; + result.error( + 'ERIKA_ERROR', + operation + ' failed' + (nativeError.length > 0 ? ': ' + nativeError : ''), + status, + ); + return false; + } + + private numberArg( + args: Map, + name: string, + fallback: number, + ): number { + const value: ESObject = args.get(name); + return typeof value === 'number' ? value as number : fallback; + } + + private mapToObject(args: Map): ESObject { + const object: ESObject = {}; + args.forEach((value: ESObject, key: string): void => { + object[key] = this.jsonValue(value); + }); + return object; + } + + private jsonValue(value: ESObject): ESObject { + if (value instanceof Map) { + return this.mapToObject(value as Map); + } + if (Array.isArray(value)) { + return (value as Array).map( + (child: ESObject): ESObject => this.jsonValue(child), + ); + } + return value; + } + + private isNativePlayerMethod(method: string): boolean { + return [ + 'open', 'play', 'pause', 'stop', 'close', 'seek', + 'setPlaybackRate', 'setVolume', 'setUpscaler', 'setSubtitleScale', + 'selectSubtitleMemoryFonts', 'clearSubtitleMemoryFonts', + 'getSubtitleMemoryFontStatus', + 'getUpscalerStatus', 'getOutputStatus', 'getPresenterStats', + 'getResourceStatus', + 'addExternalSubtitle', 'removeSubtitleTrack', + 'loadDanmakuFile', 'loadDanmakuJson', + 'addDanmakuTrackFile', 'addDanmakuTrackJson', + 'removeDanmakuTrack', 'setDanmakuTrackEnabled', + 'setDanmakuTrackOffset', 'setDanmakuGlobalOffset', + 'danmakuTracks', 'clearDanmaku', 'setDanmakuEnabled', + 'setDanmakuConfig', 'selectAudioTrack', 'selectSubtitleTrack', 'tracks', + ].includes(method); + } +} diff --git a/third_party/erika_flutter/ohos/src/main/module.json5 b/third_party/erika_flutter/ohos/src/main/module.json5 new file mode 100644 index 00000000..809cc598 --- /dev/null +++ b/third_party/erika_flutter/ohos/src/main/module.json5 @@ -0,0 +1,10 @@ +{ + "module": { + "name": "erika_flutter", + "type": "har", + "deviceTypes": [ + "default", + "tablet" + ] + } +} diff --git a/third_party/erika_flutter/pubspec.yaml b/third_party/erika_flutter/pubspec.yaml new file mode 100644 index 00000000..7c1203be --- /dev/null +++ b/third_party/erika_flutter/pubspec.yaml @@ -0,0 +1,45 @@ +name: erika_flutter +description: Cross-platform Flutter media player powered by Erika, with hardware decoding, HDR video, ASS subtitles, danmaku, and system media controls. +version: 0.1.7 + +homepage: https://github.com/AimesSoft/Erika +repository: https://github.com/AimesSoft/Erika +issue_tracker: https://github.com/AimesSoft/Erika/issues +documentation: https://github.com/AimesSoft/Erika/tree/main/docs + +topics: + - video-player + - media-player + - ffmpeg + - rust + - hdr + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + flutter_plugin_android_lifecycle: ^2.0.26 + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: dev.aimesoft.erika_flutter + pluginClass: ErikaFlutterPlugin + ios: + pluginClass: ErikaFlutterPlugin + tvos: + pluginClass: ErikaFlutterPlugin + macos: + pluginClass: ErikaFlutterPlugin + windows: + pluginClass: ErikaFlutterPluginCApi + ohos: + pluginClass: ErikaFlutterPlugin diff --git a/third_party/erika_flutter/tvos/Classes/ErikaFlutterPlugin.swift b/third_party/erika_flutter/tvos/Classes/ErikaFlutterPlugin.swift new file mode 100644 index 00000000..202cfb49 --- /dev/null +++ b/third_party/erika_flutter/tvos/Classes/ErikaFlutterPlugin.swift @@ -0,0 +1,3038 @@ +import Darwin +import AVFoundation +import Flutter +import MediaPlayer +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 { + switch config.outputMode { + case 1: + return String(format: "AppleEdr(headroom=%.2f)", config.edrHeadroom) + case 2: + return String(format: "ExtendedLinear(headroom=%.2f)", config.edrHeadroom) + case 3: + return String(format: "Auto(headroom=%.2f)", config.edrHeadroom) + default: + return "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 || config.outputMode == 2 { + 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)) + } + return + } + + layer.contentsFormat = .RGBA8Uint + if #available(tvOS 18.0, *) { + layer.toneMapMode = .automatic + } + // Auto is initialized in SDR, but the native renderer owns later changes. + // Do not pin preferredDynamicRange/contentsHeadroom to SDR here. + if config.outputMode != 3 { + 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 + var lumaUpscaler: Int32 = 0 + var videoAlphaMode: Int32 = 0 + + static let sdr = ErikaPresenterConfigC() + + static func appleEdr(headroom: Float) -> ErikaPresenterConfigC { + ErikaPresenterConfigC(outputMode: 1, edrHeadroom: max(1.0, headroom)) + } + + static func auto(headroom: Float) -> ErikaPresenterConfigC { + ErikaPresenterConfigC(outputMode: 3, 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 +} + +private struct ErikaSubtitleMemoryFontStatusC { + var registeredCount: UInt = 0 + var registeredBytes: UInt = 0 + var selectedCount: UInt = 0 + var generation: UInt64 = 0 + var selectedIds: UnsafeMutablePointer? +} + +// 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 +} + +// Keep field order and types aligned with `ErikaPresenterResourceStatus` in erika.h. +private struct ErikaPresenterResourceStatusC { + var deviceCurrentAllocatedBytes: UInt64 = 0 + var deviceRecommendedWorkingSetBytes: UInt64 = 0 + var drawableEstimatedBytes: UInt64 = 0 + var videoFrameBytes: UInt64 = 0 + var overlayAtlasBytes: UInt64 = 0 + var danmakuAtlasBytes: UInt64 = 0 + var danmakuVertexBufferBytes: UInt64 = 0 + var upscalerBytes: UInt64 = 0 + var rendererTrackedBytes: UInt64 = 0 + var presenterCpuDanmakuAtlasBytes: UInt64 = 0 + var drawableCount: UInt32 = 0 + var outputModeSwitches: 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 RegisterSubtitleMemoryFontFn = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UInt, UnsafeMutablePointer?) -> Int32 + typealias SelectSubtitleMemoryFontsFn = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UInt) -> Int32 + typealias GetSubtitleMemoryFontStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias FreeSubtitleMemoryFontStatusFn = @convention(c) (UnsafeMutableRawPointer?) -> Void + typealias GetUpscalerStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias GetOutputStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias GetResourceStatusFn = @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 registerSubtitleMemoryFont: RegisterSubtitleMemoryFontFn? + let selectSubtitleMemoryFonts: SelectSubtitleMemoryFontsFn? + let clearSubtitleMemoryFonts: CommandFn? + let getSubtitleMemoryFontStatus: GetSubtitleMemoryFontStatusFn? + let freeSubtitleMemoryFontStatus: FreeSubtitleMemoryFontStatusFn? + let getUpscalerStatus: GetUpscalerStatusFn? + let getOutputStatus: GetOutputStatusFn? + let getResourceStatus: GetResourceStatusFn? + 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) + registerSubtitleMemoryFont = Self.loadOptional("erika_presenter_register_subtitle_memory_font", from: libraryHandle, as: RegisterSubtitleMemoryFontFn.self) + selectSubtitleMemoryFonts = Self.loadOptional("erika_presenter_select_subtitle_memory_fonts", from: libraryHandle, as: SelectSubtitleMemoryFontsFn.self) + clearSubtitleMemoryFonts = Self.loadOptional("erika_presenter_clear_subtitle_memory_fonts", from: libraryHandle, as: CommandFn.self) + getSubtitleMemoryFontStatus = Self.loadOptional("erika_presenter_get_subtitle_memory_font_status", from: libraryHandle, as: GetSubtitleMemoryFontStatusFn.self) + freeSubtitleMemoryFontStatus = Self.loadOptional("erika_subtitle_memory_font_status_free", from: libraryHandle, as: FreeSubtitleMemoryFontStatusFn.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) + getResourceStatus = Self.loadOptional("erika_presenter_get_resource_status", from: libraryHandle, as: GetResourceStatusFn.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 let renderQueue: DispatchQueue + private let nativeCallLock = NSRecursiveLock() + private let renderSubmissionLock = NSLock() + 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 loggedRenderThread = false + private var loggedFirstRenderedVideoFrame = false + private var latestPresenterStats = ErikaPresenterStatsC() + private var renderTickQueued = false + private(set) var nowPlayingTitle = "" + private(set) var nowPlayingArtist: String? + private(set) var nowPlayingAlbum: String? + private(set) var nowPlayingArtwork: MPMediaItemArtwork? + private(set) var durationSeconds: Double? + private(set) var positionSeconds = 0.0 + private(set) var playbackRate = 1.0 + private(set) var isPlaying = false + var onNowPlayingChanged: ((ErikaPlayerHost) -> Void)? + + init(id: Int64, library: ErikaNativeLibrary, config: ErikaPresenterConfigC, hdrDebug: Bool) throws { + self.id = id + self.library = library + self.hdrDebug = hdrDebug + renderQueue = DispatchQueue( + label: "dev.aimesoft.erika.render.tvos.\(id)", + qos: .userInteractive + ) + 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() + withNativeCall { + _ = library.detachSurface(handle) + library.destroy(handle) + } + } + + private func withNativeCall(_ operation: () throws -> T) rethrows -> T { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + return try operation() + } + + func open(uri: String, httpHeaders: [String: String]) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + isPlaying = false + positionSeconds = 0 + durationSeconds = nil + if nowPlayingTitle.isEmpty { + let fallbackTitle = URL(string: uri)?.lastPathComponent.removingPercentEncoding + ?? URL(fileURLWithPath: uri).lastPathComponent + nowPlayingTitle = fallbackTitle.isEmpty ? "Erika" : fallbackTitle + } + try uri.withCString { cString in + guard !httpHeaders.isEmpty else { + try check(library.open(handle, cString), operation: "open") + 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") + } + } + notifyNowPlayingChanged() + } + + func play() throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try configureAudioSessionForPlayback() + try check(library.play(handle), operation: "play") + isPlaying = true + notifyNowPlayingChanged() + } + func pause() throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try check(library.pause(handle), operation: "pause") + isPlaying = false + notifyNowPlayingChanged() + } + func stop() throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try check(library.stop(handle), operation: "stop") + isPlaying = false + positionSeconds = 0 + notifyNowPlayingChanged() + } + func close() throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try check(library.close(handle), operation: "close") + isPlaying = false + positionSeconds = 0 + durationSeconds = nil + notifyNowPlayingChanged() + } + + func seek(positionMicros: UInt64) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try check(library.seek(handle, positionMicros), operation: "seek") + positionSeconds = Double(positionMicros) / 1_000_000 + notifyNowPlayingChanged() + } + + func setPlaybackRate(_ rate: Double) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + guard let setRate = library.setPlaybackRate else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_playback_rate") + } + try check(setRate(handle, rate), operation: "set_playback_rate") + playbackRate = rate + notifyNowPlayingChanged() + } + + func setMediaMetadata(title: String, artist: String?, album: String?, artworkData: Data?) throws { + let artwork: MPMediaItemArtwork? + if let artworkData { + guard let image = UIImage(data: artworkData) else { + throw ErikaPluginError.invalidArguments("metadata.artwork must contain a supported image.") + } + artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + } else { + artwork = nil + } + nowPlayingTitle = title + nowPlayingArtist = artist + nowPlayingAlbum = album + nowPlayingArtwork = artwork + notifyNowPlayingChanged() + } + + func clearMediaMetadata() { + nowPlayingTitle = "" + nowPlayingArtist = nil + nowPlayingAlbum = nil + nowPlayingArtwork = nil + notifyNowPlayingChanged() + } + + func setVolume(_ volume: Double) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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] { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 registerSubtitleMemoryFont(_ data: Data) throws -> UInt64 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + guard let register = library.registerSubtitleMemoryFont else { + throw ErikaPluginError.symbolMissing("erika_presenter_register_subtitle_memory_font") + } + var fontId: UInt64 = 0 + let status = data.withUnsafeBytes { bytes in + register(handle, bytes.bindMemory(to: UInt8.self).baseAddress, UInt(data.count), &fontId) + } + try check(status, operation: "register_subtitle_memory_font") + return fontId + } + + func selectSubtitleMemoryFonts(_ fontIds: [UInt64]) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + guard let select = library.selectSubtitleMemoryFonts else { + throw ErikaPluginError.symbolMissing("erika_presenter_select_subtitle_memory_fonts") + } + try fontIds.withUnsafeBufferPointer { buffer in + try check(select(handle, buffer.baseAddress, UInt(buffer.count)), operation: "select_subtitle_memory_fonts") + } + } + + func clearSubtitleMemoryFonts() throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + guard let clear = library.clearSubtitleMemoryFonts else { + throw ErikaPluginError.symbolMissing("erika_presenter_clear_subtitle_memory_fonts") + } + try check(clear(handle), operation: "clear_subtitle_memory_fonts") + } + + func subtitleMemoryFontStatus() throws -> [String: Any] { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + guard let getStatus = library.getSubtitleMemoryFontStatus else { + throw ErikaPluginError.symbolMissing("erika_presenter_get_subtitle_memory_font_status") + } + var status = ErikaSubtitleMemoryFontStatusC() + defer { + if let freeStatus = library.freeSubtitleMemoryFontStatus { + withUnsafeMutablePointer(to: &status) { freeStatus(UnsafeMutableRawPointer($0)) } + } + } + try withUnsafeMutablePointer(to: &status) { pointer in + try check(getStatus(handle, UnsafeMutableRawPointer(pointer)), operation: "get_subtitle_memory_font_status") + } + return [ + "registeredCount": Int(status.registeredCount), + "registeredBytes": Int(status.registeredBytes), + "selectedCount": Int(status.selectedCount), + "generation": Int64(clamping: status.generation), + "selectedIds": status.selectedIds.map { pointer in + (0.. [String: Any] { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 resourceStatus() throws -> [String: Any] { + guard let getStatus = library.getResourceStatus else { + throw ErikaPluginError.symbolMissing("erika_presenter_get_resource_status") + } + var status = ErikaPresenterResourceStatusC() + let result = withNativeCall { + withUnsafeMutablePointer(to: &status) { pointer in + getStatus(handle, UnsafeMutableRawPointer(pointer)) + } + } + try check(result, operation: "get_resource_status") + return status.toFlutterMap() + } + + func presenterStats() -> [String: Any] { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + return latestPresenterStats.toFlutterMap() + } + + func addExternalSubtitle(uri: String) throws -> Int64 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try check(library.removeSubtitleTrack(handle, trackId), operation: "remove_subtitle_track") + } + + func loadDanmakuFile(uri: String) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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]] { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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() { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try check(library.selectAudioTrack(handle, trackId ?? -1), operation: "select_audio_track") + } + + func selectSubtitleTrack(trackId: Int64?) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + try check(library.selectSubtitleTrack(handle, trackId ?? -1), operation: "select_subtitle_track") + } + + func tracks() throws -> [[String: Any]] { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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] { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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? { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + 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 + withNativeCall { + _ = 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() { + if !loggedRenderThread { + loggedRenderThread = true + erikaHdrLog( + hdrDebug, + "render driver player=\(id) mainThread=\(Thread.isMainThread)" + ) + } + var stats = ErikaPresenterStatsC() + let status = withNativeCall { + let timeSeconds = CACurrentMediaTime() - startTimeSeconds + let status = withUnsafeMutablePointer(to: &stats) { pointer in + library.renderTick(handle, timeSeconds, UnsafeMutableRawPointer(pointer)) + } + if status == 0 { + latestPresenterStats = stats + } + return status + } + if status != 0 { + NSLog("ErikaFlutterPlugin: render_tick failed with status \(status)") + } + if hdrDebug && stats.renderedVideoFrames > 0 { + let statsSnapshot = stats + DispatchQueue.main.async { [weak self] in + self?.logFirstRenderedVideoFrameIfNeeded(statsSnapshot) + } + } + } + + func pollEvents(sendEvent: (([String: Any]) -> Void)?) { + withNativeCall { + while true { + var event = ErikaEventC() + let status = withUnsafeMutablePointer(to: &event) { pointer in + library.pollEvent(handle, UnsafeMutableRawPointer(pointer)) + } + if status == 0 { + if event.durationMicros >= 0 { + durationSeconds = Double(event.durationMicros) / 1_000_000 + } + if event.kind == 3 { + positionSeconds = Double(event.positionMicros) / 1_000_000 + } + if event.kind == 1 { + isPlaying = event.state == 3 + } + if event.kind == 1 || event.kind == 2 || event.kind == 3 { + notifyNowPlayingChanged() + } + if event.kind == 6 { + erikaHdrLog( + hdrDebug, + "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 scheduleRenderTick() { + renderSubmissionLock.lock() + guard !renderTickQueued else { + renderSubmissionLock.unlock() + return + } + renderTickQueued = true + renderSubmissionLock.unlock() + + renderQueue.async { [weak self] in + guard let self else { return } + self.renderTick() + self.renderSubmissionLock.lock() + self.renderTickQueued = false + self.renderSubmissionLock.unlock() + } + } + + private func logFirstRenderedVideoFrameIfNeeded(_ stats: ErikaPresenterStatsC) { + guard hdrDebug, !loggedFirstRenderedVideoFrame else { return } + 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)" + ) + } + + private func attachOrResize(view: ErikaMetalSurfaceView, attach: Bool) throws { + nativeCallLock.lock() + defer { nativeCallLock.unlock() } + if attach || presenterConfig.outputMode != 3 { + 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 } + withNativeCall { + startTimeSeconds = CACurrentMediaTime() + } + let proxy = DisplayLinkProxy { [weak self] in + self?.scheduleRenderTick() + } + let link = CADisplayLink(target: proxy, selector: #selector(DisplayLinkProxy.tick)) + link.preferredFramesPerSecond = resolvedDisplayLinkFps() + link.add(to: .main, forMode: .common) + displayLinkProxy = proxy + displayLink = link + } + + private func notifyNowPlayingChanged() { + onNowPlayingChanged?(self) + } + + private func resolvedDisplayLinkFps() -> Int { + if let override = ProcessInfo.processInfo.environment["ERIKA_FLUTTER_TARGET_FPS"], + let fps = Int(override), fps > 0 { + 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? + private var activePlayerId: Int64? + private var remoteCommandTargets: [(MPRemoteCommand, Any)] = [] + private var systemMediaNavigation: [Int64: (previousEnabled: Bool, nextEnabled: Bool)] = [:] + + deinit { + remoteCommandTargets.forEach { command, target in + command.removeTarget(target) + } + } + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = ErikaFlutterPlugin() + instance.configureSystemPlayback() + let playerChannel = FlutterMethodChannel(name: playerChannelName, binaryMessenger: registrar.messenger()) + let eventsChannel = FlutterEventChannel(name: eventsChannelName, binaryMessenger: registrar.messenger()) + registrar.addMethodCallDelegate(instance, channel: playerChannel) + 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) + systemMediaNavigation.removeValue(forKey: playerId) + if activePlayerId == playerId { + activePlayerId = nil + clearNowPlayingInfo() + refreshRemoteCommands() + } + 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]) ?? [:] + if let metadata = args["metadata"] as? [String: Any] { + try applyMediaMetadata(metadata, to: host) + } else { + host.clearMediaMetadata() + } + try host.open(uri: uri, httpHeaders: headers) + result(nil) + case "play": + let host = try playerHost(from: try dictionaryArgs(call.arguments)) + try host.play() + activePlayerId = host.id + refreshRemoteCommands() + updateNowPlayingInfo(for: host) + result(nil) + case "pause": + try playerHost(from: try dictionaryArgs(call.arguments)).pause() + 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 "setMediaMetadata": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let metadata = args["metadata"] as? [String: Any] else { + throw ErikaPluginError.invalidArguments("metadata is required.") + } + try applyMediaMetadata(metadata, to: host) + result(nil) + case "setSystemMediaNavigation": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + systemMediaNavigation[host.id] = ( + previousEnabled: boolValue(args["previousEnabled"]) ?? false, + nextEnabled: boolValue(args["nextEnabled"]) ?? false + ) + if activePlayerId == host.id { + refreshRemoteCommands() + } + result(nil) + case "setVolume": + let args = try dictionaryArgs(call.arguments) + guard let volume = doubleValue(args["volume"]) else { + 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 "registerSubtitleMemoryFont": + let args = try dictionaryArgs(call.arguments) + guard let data = args["data"] as? FlutterStandardTypedData else { + throw ErikaPluginError.invalidArguments("data is required.") + } + result(Int64(clamping: try playerHost(from: args).registerSubtitleMemoryFont(data.data))) + case "selectSubtitleMemoryFonts": + let args = try dictionaryArgs(call.arguments) + let ids = (args["fontIds"] as? [NSNumber] ?? []).map { $0.uint64Value } + try playerHost(from: args).selectSubtitleMemoryFonts(ids) + result(nil) + case "clearSubtitleMemoryFonts": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).clearSubtitleMemoryFonts() + result(nil) + case "getSubtitleMemoryFontStatus": + let args = try dictionaryArgs(call.arguments) + result(try playerHost(from: args).subtitleMemoryFontStatus()) + 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 "getResourceStatus": + let args = try dictionaryArgs(call.arguments) + result(try playerHost(from: args).resourceStatus()) + 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 + let host = try ErikaPlayerHost(id: id, library: library, config: config, hdrDebug: hdrDebug) + host.onNowPlayingChanged = { [weak self] changedHost in + guard self?.activePlayerId == changedHost.id else { return } + self?.updateNowPlayingInfo(for: changedHost) + } + players[id] = host + systemMediaNavigation[id] = (previousEnabled: false, nextEnabled: false) + startPollTimerIfNeeded() + return id + } + + private func configureSystemPlayback() { + let commands = MPRemoteCommandCenter.shared() + addRemoteTarget(commands.playCommand) { [weak self] _ in + self?.performRemotePlay() ?? .commandFailed + } + addRemoteTarget(commands.pauseCommand) { [weak self] _ in + self?.performRemotePause() ?? .commandFailed + } + addRemoteTarget(commands.togglePlayPauseCommand) { [weak self] _ in + self?.performRemoteToggle() ?? .commandFailed + } + addRemoteTarget(commands.changePlaybackPositionCommand) { [weak self] event in + guard let positionEvent = event as? MPChangePlaybackPositionCommandEvent else { + return .commandFailed + } + return self?.performRemoteSeek(positionEvent.positionTime) ?? .commandFailed + } + addRemoteTarget(commands.previousTrackCommand) { [weak self] _ in + self?.emitSystemMediaNavigation("previous") ?? .commandFailed + } + addRemoteTarget(commands.nextTrackCommand) { [weak self] _ in + self?.emitSystemMediaNavigation("next") ?? .commandFailed + } + refreshRemoteCommands() + } + + private func addRemoteTarget( + _ command: MPRemoteCommand, + handler: @escaping (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus + ) { + let target = command.addTarget(handler: handler) + remoteCommandTargets.append((command, target)) + } + + private func applyMediaMetadata(_ metadata: [String: Any], to host: ErikaPlayerHost) throws { + guard let title = metadata["title"] as? String, !title.isEmpty else { + throw ErikaPluginError.invalidArguments("metadata.title is required.") + } + try host.setMediaMetadata( + title: title, + artist: metadata["artist"] as? String, + album: metadata["album"] as? String, + artworkData: (metadata["artwork"] as? FlutterStandardTypedData)?.data + ) + } + + private func updateNowPlayingInfo(for host: ErikaPlayerHost) { + var info: [String: Any] = [ + MPMediaItemPropertyTitle: host.nowPlayingTitle, + MPNowPlayingInfoPropertyElapsedPlaybackTime: host.positionSeconds, + MPNowPlayingInfoPropertyPlaybackRate: host.isPlaying ? host.playbackRate : 0, + MPNowPlayingInfoPropertyDefaultPlaybackRate: host.playbackRate, + MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.video.rawValue, + ] + if let artist = host.nowPlayingArtist { info[MPMediaItemPropertyArtist] = artist } + if let album = host.nowPlayingAlbum { info[MPMediaItemPropertyAlbumTitle] = album } + if let artwork = host.nowPlayingArtwork { info[MPMediaItemPropertyArtwork] = artwork } + if let duration = host.durationSeconds { info[MPMediaItemPropertyPlaybackDuration] = duration } + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = info + center.playbackState = host.isPlaying ? .playing : .paused + } + + private func clearNowPlayingInfo() { + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = nil + center.playbackState = .stopped + } + + private func performRemotePlay() -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + do { + try host.play() + return .success + } catch { + return .commandFailed + } + } + + private func performRemotePause() -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + do { + try host.pause() + return .success + } catch { + return .commandFailed + } + } + + private func performRemoteToggle() -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + return host.isPlaying ? performRemotePause() : performRemotePlay() + } + + private func performRemoteSeek(_ position: TimeInterval) -> MPRemoteCommandHandlerStatus { + guard let host = activePlayerId.flatMap({ players[$0] }) else { return .noSuchContent } + do { + try host.seek(positionMicros: UInt64(max(0, position) * 1_000_000)) + return .success + } catch { + return .commandFailed + } + } + + private func emitSystemMediaNavigation(_ navigation: String) -> MPRemoteCommandHandlerStatus { + performOnMain { + guard let playerId = self.activePlayerId, + self.players[playerId] != nil, + let capabilities = self.systemMediaNavigation[playerId] else { + return .noSuchContent + } + let enabled = navigation == "previous" + ? capabilities.previousEnabled + : capabilities.nextEnabled + guard enabled else { return .noSuchContent } + Self.sharedEventSink?([ + "playerId": playerId, + "kind": 13, + "navigation": navigation, + ]) + return .success + } + } + + private func performOnMain( + _ work: @escaping () -> MPRemoteCommandHandlerStatus + ) -> MPRemoteCommandHandlerStatus { + if Thread.isMainThread { + return work() + } + return DispatchQueue.main.sync(execute: work) + } + + private func refreshRemoteCommands() { + let commands = MPRemoteCommandCenter.shared() + let enabled = activePlayerId.flatMap { players[$0] } != nil + remoteCommandTargets.forEach { command, _ in + command.isEnabled = enabled + } + let capabilities = activePlayerId.flatMap { systemMediaNavigation[$0] } + commands.previousTrackCommand.isEnabled = enabled && capabilities?.previousEnabled == true + commands.nextTrackCommand.isEnabled = enabled && capabilities?.nextEnabled == true + } + + private func presenterConfigForNewPlayer(arguments: Any?, hdrDebug: Bool) -> ErikaPresenterConfigC { + if let args = arguments as? [String: Any], let explicitMode = int32Value(args["outputMode"]) { + let headroom = floatValue(args["edrHeadroom"]) ?? 4.0 + let config: ErikaPresenterConfigC + switch explicitMode { + case 1: + config = .appleEdr(headroom: headroom) + case 2: + config = ErikaPresenterConfigC(outputMode: 2, edrHeadroom: max(1.0, headroom)) + case 3: + config = .auto(headroom: headroom) + default: + config = .sdr + } + erikaHdrLog( + hdrDebug, + "create explicit outputMode=\(explicitMode) requestedHeadroom=\(String(format: "%.3f", headroom)) selected=\(erikaOutputModeLabel(config))" + ) + return config + } + let headroom = resolvedEdrHeadroom(hdrDebug: hdrDebug) + let config = ErikaPresenterConfigC.auto(headroom: headroom) + 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.05, 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 ErikaPresenterResourceStatusC { + func toFlutterMap() -> [String: Any] { + [ + "deviceCurrentAllocatedBytes": Int64(clamping: deviceCurrentAllocatedBytes), + "deviceRecommendedWorkingSetBytes": Int64(clamping: deviceRecommendedWorkingSetBytes), + "drawableEstimatedBytes": Int64(clamping: drawableEstimatedBytes), + "videoFrameBytes": Int64(clamping: videoFrameBytes), + "overlayAtlasBytes": Int64(clamping: overlayAtlasBytes), + "danmakuAtlasBytes": Int64(clamping: danmakuAtlasBytes), + "danmakuVertexBufferBytes": Int64(clamping: danmakuVertexBufferBytes), + "upscalerBytes": Int64(clamping: upscalerBytes), + "rendererTrackedBytes": Int64(clamping: rendererTrackedBytes), + "presenterCpuDanmakuAtlasBytes": Int64(clamping: presenterCpuDanmakuAtlasBytes), + "drawableCount": Int(drawableCount), + "outputModeSwitches": Int64(clamping: outputModeSwitches), + ] + } +} + +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/third_party/erika_flutter/tvos/erika_flutter.podspec b/third_party/erika_flutter/tvos/erika_flutter.podspec new file mode 100644 index 00000000..3af427d4 --- /dev/null +++ b/third_party/erika_flutter/tvos/erika_flutter.podspec @@ -0,0 +1,193 @@ +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_register_subtitle_memory_font + erika_presenter_render_tick + erika_presenter_resize_surface + erika_presenter_seek + erika_presenter_select_audio_track + erika_presenter_select_subtitle_track + erika_presenter_select_subtitle_memory_fonts + erika_presenter_clear_subtitle_memory_fonts + erika_presenter_get_subtitle_memory_font_status + 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_subtitle_memory_font_status_free + ] + erika_cabi_undefined_flags = erika_cabi_symbols + .map { |symbol| "-Wl,-u,_#{symbol}" } + .join(' ') + + s.name = 'erika_flutter' + s.version = '0.1.7' + 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)" +PACKAGE_ROOT="$(cd "$PLUGIN_TVOS_DIR/.." && pwd -P)" +OUTPUT_LIB="$PODS_TARGET_SRCROOT/native/liberika_capi.a" +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 + +mkdir -p "$PODS_TARGET_SRCROOT/native" +if [ -n "${ERIKA_TVOS_CAPI_STATICLIB:-}" ]; then + cp "$ERIKA_TVOS_CAPI_STATICLIB" "$OUTPUT_LIB" +elif [ "${ERIKA_FORCE_SOURCE_BUILD:-0}" != "1" ]; then + sh "$PACKAGE_ROOT/native/prepare_apple_prebuilt.sh" tvos "${PLATFORM_NAME:-appletvos}" "$ARCH" "$OUTPUT_LIB" +else + if [ -n "${ERIKA_REPO_ROOT:-}" ]; then + SOURCE_ROOT="$ERIKA_REPO_ROOT" + elif [ -n "${ERIKA_ROOT:-}" ]; then + SOURCE_ROOT="$ERIKA_ROOT" + else + SOURCE_ROOT="$(cd "$PACKAGE_ROOT/../.." && pwd -P)" + fi + if [ ! -f "$SOURCE_ROOT/crates/erika_capi/Cargo.toml" ]; then + echo "error: ERIKA_FORCE_SOURCE_BUILD=1 requires an Erika checkout; set ERIKA_REPO_ROOT" >&2 + exit 1 + fi + if ! command -v rustup >/dev/null 2>&1; then + echo "error: rustup is required for an Erika tvOS source build" >&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" + + ERIKA_TARGET_DIST="$SOURCE_ROOT/third_party/dist/$RUST_TARGET/$ERIKA_NATIVE_PROFILE" + ERIKA_FFMPEG_DIR="${ERIKA_FFMPEG_DIR:-$ERIKA_TARGET_DIST/ffmpeg}" + 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="$SOURCE_ROOT/third_party/build/$RUST_TARGET/$ERIKA_NATIVE_PROFILE/dav1d/dav1d-built.txt" + + 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 + (cd "$SOURCE_ROOT" && cargo run -p xtask -- deps build --all --profile "$ERIKA_NATIVE_PROFILE" --target "$RUST_TARGET" --jobs "$HOST_JOBS") + fi + (cd "$SOURCE_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) + cp "$SOURCE_ROOT/target/$RUST_TARGET/$CARGO_PROFILE/liberika_capi.a" "$OUTPUT_LIB" +fi + +if [ ! -f "$OUTPUT_LIB" ]; then + echo "error: Erika C ABI static library not found: $OUTPUT_LIB" >&2 + exit 1 +fi +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 MediaPlayer -framework QuartzCore -framework Metal -framework CoreVideo -framework CoreMedia -framework VideoToolbox -framework CoreText -framework CoreFoundation -framework CoreGraphics -framework Foundation -liconv -lbz2 -lz", + } +end diff --git a/third_party/erika_flutter/windows/CMakeLists.txt b/third_party/erika_flutter/windows/CMakeLists.txt new file mode 100644 index 00000000..fbee9f59 --- /dev/null +++ b/third_party/erika_flutter/windows/CMakeLists.txt @@ -0,0 +1,117 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. Do not increase this version. +cmake_minimum_required(VERSION 3.14) + +set(PROJECT_NAME "erika_flutter") +project(${PROJECT_NAME} LANGUAGES CXX) + +cmake_policy(VERSION 3.14...3.25) + +set(PLUGIN_NAME "erika_flutter_plugin") + +get_filename_component(ERIKA_FLUTTER_PACKAGE_DIR + "${CMAKE_CURRENT_SOURCE_DIR}/.." REALPATH) +set(ERIKA_NATIVE_HEADER_DIR + "${ERIKA_FLUTTER_PACKAGE_DIR}/native/include") +if(NOT EXISTS "${ERIKA_NATIVE_HEADER_DIR}/erika.h") + message(FATAL_ERROR + "Erika C API header is missing from the Flutter package: " + "${ERIKA_NATIVE_HEADER_DIR}/erika.h") +endif() + +list(APPEND PLUGIN_SOURCES + "erika_composition_blend_effect.h" + "erika_flutter_plugin.cpp" + "erika_flutter_plugin.h" + "erika_windows_smtc.cpp" + "erika_windows_smtc.h" +) + +add_library(${PLUGIN_NAME} SHARED + "include/erika_flutter/erika_flutter_plugin_c_api.h" + "erika_flutter_plugin_c_api.cpp" + ${PLUGIN_SOURCES} +) + +apply_standard_settings(${PLUGIN_NAME}) +if(MSVC) + target_compile_options(${PLUGIN_NAME} PRIVATE /utf-8) +endif() + +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE + FLUTTER_PLUGIN_IMPL + _WIN32_WINNT=0x0A00 + WINVER=0x0A00 + _SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS) + +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_include_directories(${PLUGIN_NAME} PRIVATE + "${ERIKA_NATIVE_HEADER_DIR}") +target_link_libraries(${PLUGIN_NAME} PRIVATE + flutter flutter_wrapper_plugin runtimeobject windowsapp CoreMessaging d3d11 dxgi shcore shlwapi) + +set(ERIKA_WINDOWS_ARCH "" CACHE STRING + "Erika Windows architecture: x64, x86_64, arm64, or aarch64") +set(ERIKA_NATIVE_TARGET "" CACHE STRING + "Erika Rust target override for the native runtime") +set(ERIKA_NATIVE_PROFILE "lgpl" CACHE STRING + "Erika native dependency profile") + +if(ERIKA_NATIVE_TARGET STREQUAL "") + if(NOT ERIKA_WINDOWS_ARCH STREQUAL "") + set(_ERIKA_WINDOWS_ARCH "${ERIKA_WINDOWS_ARCH}") + elseif(DEFINED ENV{ERIKA_WINDOWS_ARCH} AND + NOT "$ENV{ERIKA_WINDOWS_ARCH}" STREQUAL "") + set(_ERIKA_WINDOWS_ARCH "$ENV{ERIKA_WINDOWS_ARCH}") + elseif(NOT CMAKE_GENERATOR_PLATFORM STREQUAL "") + set(_ERIKA_WINDOWS_ARCH "${CMAKE_GENERATOR_PLATFORM}") + elseif(DEFINED CMAKE_VS_PLATFORM_NAME AND + NOT CMAKE_VS_PLATFORM_NAME STREQUAL "") + set(_ERIKA_WINDOWS_ARCH "${CMAKE_VS_PLATFORM_NAME}") + else() + set(_ERIKA_WINDOWS_ARCH "${CMAKE_SYSTEM_PROCESSOR}") + endif() + string(TOLOWER "${_ERIKA_WINDOWS_ARCH}" _ERIKA_WINDOWS_ARCH) + if(_ERIKA_WINDOWS_ARCH MATCHES "^(x64|amd64|x86_64)$") + set(ERIKA_NATIVE_TARGET "x86_64-pc-windows-msvc") + elseif(_ERIKA_WINDOWS_ARCH MATCHES "^(arm64|aarch64)$") + set(ERIKA_NATIVE_TARGET "aarch64-pc-windows-msvc") + else() + message(FATAL_ERROR + "Unsupported Erika Windows architecture: ${_ERIKA_WINDOWS_ARCH}. " + "Set ERIKA_WINDOWS_ARCH to x64 or arm64.") + endif() +endif() + +if(NOT ERIKA_NATIVE_TARGET STREQUAL "x86_64-pc-windows-msvc" AND + NOT ERIKA_NATIVE_TARGET STREQUAL "aarch64-pc-windows-msvc") + message(FATAL_ERROR + "Unsupported Erika Windows Rust target: ${ERIKA_NATIVE_TARGET}") +endif() + +set(ERIKA_RUNTIME_DIR + "${CMAKE_CURRENT_BINARY_DIR}/erika_runtime") +set(ERIKA_CAPI_DLL_PATH + "${ERIKA_RUNTIME_DIR}/erika_capi.dll") +add_custom_target(erika_capi_runtime + COMMAND "${CMAKE_COMMAND}" + "-DERIKA_PACKAGE_ROOT=${ERIKA_FLUTTER_PACKAGE_DIR}" + "-DERIKA_RUNTIME_OUT=${ERIKA_CAPI_DLL_PATH}" + "-DERIKA_CACHE_ROOT=${CMAKE_CURRENT_BINARY_DIR}/erika_prebuilt_cache" + "-DERIKA_NATIVE_TARGET=${ERIKA_NATIVE_TARGET}" + "-DERIKA_NATIVE_PROFILE=${ERIKA_NATIVE_PROFILE}" + "-DERIKA_BUILD_CONFIG=$" + -P "${CMAKE_CURRENT_SOURCE_DIR}/build_erika_runtime.cmake" + WORKING_DIRECTORY "${ERIKA_FLUTTER_PACKAGE_DIR}" + COMMENT "Preparing Erika native runtime for $" + VERBATIM +) +add_dependencies(${PLUGIN_NAME} erika_capi_runtime) + +set(erika_flutter_bundled_libraries + "${ERIKA_CAPI_DLL_PATH}" + PARENT_SCOPE +) diff --git a/third_party/erika_flutter/windows/build_erika_runtime.cmake b/third_party/erika_flutter/windows/build_erika_runtime.cmake new file mode 100644 index 00000000..689e6c67 --- /dev/null +++ b/third_party/erika_flutter/windows/build_erika_runtime.cmake @@ -0,0 +1,231 @@ +cmake_minimum_required(VERSION 3.14) + +foreach(required ERIKA_PACKAGE_ROOT ERIKA_RUNTIME_OUT ERIKA_CACHE_ROOT + ERIKA_NATIVE_TARGET) + if(NOT DEFINED ${required} OR "${${required}}" STREQUAL "") + message(FATAL_ERROR "${required} is required") + endif() +endforeach() +if(NOT DEFINED ERIKA_NATIVE_PROFILE OR ERIKA_NATIVE_PROFILE STREQUAL "") + set(ERIKA_NATIVE_PROFILE "lgpl") +endif() +if(NOT DEFINED ERIKA_BUILD_CONFIG OR ERIKA_BUILD_CONFIG STREQUAL "") + set(ERIKA_BUILD_CONFIG "Release") +endif() +if(NOT ERIKA_NATIVE_TARGET STREQUAL "x86_64-pc-windows-msvc" AND + NOT ERIKA_NATIVE_TARGET STREQUAL "aarch64-pc-windows-msvc") + message(FATAL_ERROR "Unsupported Erika Windows target: ${ERIKA_NATIVE_TARGET}") +endif() + +if(NOT "$ENV{ERIKA_FORCE_SOURCE_BUILD}" STREQUAL "1") + if(ERIKA_NATIVE_TARGET STREQUAL "x86_64-pc-windows-msvc") + set(ERIKA_BUNDLED_RUNTIME + "${ERIKA_PACKAGE_ROOT}/native/windows/x64/erika_capi.dll") + set(ERIKA_BUNDLED_SHA256 "c8d916224d8f5bf34937f5c4762efc0c82786171e349029b52f7557b5de01108") + else() + set(ERIKA_BUNDLED_RUNTIME + "${ERIKA_PACKAGE_ROOT}/native/windows/arm64/erika_capi.dll") + set(ERIKA_BUNDLED_SHA256 "") + endif() + if(NOT EXISTS "${ERIKA_BUNDLED_RUNTIME}") + message(FATAL_ERROR + "Bundled Erika runtime is missing: ${ERIKA_BUNDLED_RUNTIME}. " + "SakiEngine desktop builds never download native binaries.") + endif() + file(SHA256 "${ERIKA_BUNDLED_RUNTIME}" ERIKA_BUNDLED_ACTUAL_SHA256) + if(ERIKA_BUNDLED_SHA256 STREQUAL "" OR + NOT ERIKA_BUNDLED_ACTUAL_SHA256 STREQUAL ERIKA_BUNDLED_SHA256) + message(FATAL_ERROR "Bundled Erika runtime checksum mismatch") + endif() + get_filename_component(ERIKA_RUNTIME_DIR "${ERIKA_RUNTIME_OUT}" DIRECTORY) + file(MAKE_DIRECTORY "${ERIKA_RUNTIME_DIR}") + configure_file("${ERIKA_BUNDLED_RUNTIME}" "${ERIKA_RUNTIME_OUT}" COPYONLY) + message(STATUS "Erika: using bundled offline runtime -> ${ERIKA_RUNTIME_OUT}") + return() +endif() + +set(ERIKA_ARTIFACT_MANIFEST + "${ERIKA_PACKAGE_ROOT}/native_artifacts.properties") +if(NOT EXISTS "${ERIKA_ARTIFACT_MANIFEST}") + message(FATAL_ERROR + "Erika native artifact manifest is missing: ${ERIKA_ARTIFACT_MANIFEST}") +endif() + +function(erika_read_artifact_property key output) + file(STRINGS "${ERIKA_ARTIFACT_MANIFEST}" value REGEX "^${key}=") + if(NOT value) + message(FATAL_ERROR "Missing ${key} in ${ERIKA_ARTIFACT_MANIFEST}") + endif() + list(GET value 0 value) + string(REGEX REPLACE "^[^=]+=" "" value "${value}") + set(${output} "${value}" PARENT_SCOPE) +endfunction() + +erika_read_artifact_property(ERIKA_NATIVE_VERSION ERIKA_NATIVE_VERSION) +set(ERIKA_DEFAULT_PREBUILT_TAG "v${ERIKA_NATIVE_VERSION}") +if(ERIKA_NATIVE_TARGET STREQUAL "aarch64-pc-windows-msvc") + set(ERIKA_ASSET_ARCH "arm64") + erika_read_artifact_property( + ERIKA_WINDOWS_ARM64_SHA256 ERIKA_DEFAULT_PREBUILT_SHA256) +else() + set(ERIKA_ASSET_ARCH "x64") + erika_read_artifact_property( + ERIKA_WINDOWS_X64_SHA256 ERIKA_DEFAULT_PREBUILT_SHA256) +endif() + +if(NOT "$ENV{ERIKA_FORCE_SOURCE_BUILD}" STREQUAL "1") + if("$ENV{ERIKA_PREBUILT_TAG}" STREQUAL "") + set(ERIKA_PREBUILT_TAG "${ERIKA_DEFAULT_PREBUILT_TAG}") + else() + set(ERIKA_PREBUILT_TAG "$ENV{ERIKA_PREBUILT_TAG}") + endif() + if("$ENV{ERIKA_PREBUILT_SHA256}" STREQUAL "") + if(NOT ERIKA_PREBUILT_TAG STREQUAL ERIKA_DEFAULT_PREBUILT_TAG) + message(FATAL_ERROR + "ERIKA_PREBUILT_SHA256 is required when ERIKA_PREBUILT_TAG overrides ${ERIKA_DEFAULT_PREBUILT_TAG}") + endif() + set(ERIKA_PREBUILT_SHA256 "${ERIKA_DEFAULT_PREBUILT_SHA256}") + else() + set(ERIKA_PREBUILT_SHA256 "$ENV{ERIKA_PREBUILT_SHA256}") + endif() + + string(REGEX REPLACE "[^A-Za-z0-9._-]" "_" + ERIKA_CACHE_TAG "${ERIKA_PREBUILT_TAG}") + set(ERIKA_WORK + "${ERIKA_CACHE_ROOT}/${ERIKA_CACHE_TAG}-${ERIKA_ASSET_ARCH}") + set(ERIKA_ZIP "${ERIKA_WORK}/bundle.zip") + set(ERIKA_URL + "https://github.com/AimesSoft/Erika/releases/download/${ERIKA_PREBUILT_TAG}/erika-capi-windows-${ERIKA_ASSET_ARCH}.zip") + + if(EXISTS "${ERIKA_ZIP}") + file(SHA256 "${ERIKA_ZIP}" ERIKA_CACHED_SHA256) + if(NOT ERIKA_CACHED_SHA256 STREQUAL ERIKA_PREBUILT_SHA256) + file(REMOVE "${ERIKA_ZIP}" "${ERIKA_RUNTIME_OUT}") + endif() + endif() + if(NOT EXISTS "${ERIKA_ZIP}") + file(MAKE_DIRECTORY "${ERIKA_WORK}") + message(STATUS "Erika: downloading prebuilt ${ERIKA_URL}") + file(DOWNLOAD "${ERIKA_URL}" "${ERIKA_ZIP}" + EXPECTED_HASH "SHA256=${ERIKA_PREBUILT_SHA256}" + STATUS ERIKA_DOWNLOAD_STATUS + SHOW_PROGRESS + TLS_VERIFY ON + TIMEOUT 900) + list(GET ERIKA_DOWNLOAD_STATUS 0 ERIKA_DOWNLOAD_CODE) + if(NOT ERIKA_DOWNLOAD_CODE EQUAL 0) + list(GET ERIKA_DOWNLOAD_STATUS 1 ERIKA_DOWNLOAD_MESSAGE) + message(FATAL_ERROR + "Erika prebuilt download or checksum verification failed: ${ERIKA_DOWNLOAD_MESSAGE}. Set ERIKA_FORCE_SOURCE_BUILD=1 only from an Erika checkout.") + endif() + endif() + + if(NOT EXISTS "${ERIKA_RUNTIME_OUT}") + file(REMOVE_RECURSE "${ERIKA_WORK}/unpacked") + file(MAKE_DIRECTORY "${ERIKA_WORK}/unpacked") + execute_process( + COMMAND "${CMAKE_COMMAND}" -E tar xf "${ERIKA_ZIP}" + WORKING_DIRECTORY "${ERIKA_WORK}/unpacked" + RESULT_VARIABLE ERIKA_EXTRACT_RESULT) + if(NOT ERIKA_EXTRACT_RESULT EQUAL 0) + message(FATAL_ERROR + "Failed to extract Erika prebuilt ${ERIKA_PREBUILT_TAG}") + endif() + file(GLOB_RECURSE ERIKA_FOUND_DLL + "${ERIKA_WORK}/unpacked/*/lib/erika_capi.dll") + if(NOT ERIKA_FOUND_DLL) + message(FATAL_ERROR + "Erika prebuilt ${ERIKA_PREBUILT_TAG} did not contain erika_capi.dll") + endif() + list(GET ERIKA_FOUND_DLL 0 ERIKA_SOURCE_DLL) + get_filename_component(ERIKA_RUNTIME_DIR "${ERIKA_RUNTIME_OUT}" DIRECTORY) + file(MAKE_DIRECTORY "${ERIKA_RUNTIME_DIR}") + file(COPY "${ERIKA_SOURCE_DLL}" DESTINATION "${ERIKA_RUNTIME_DIR}") + endif() + message(STATUS + "Erika: using verified prebuilt ${ERIKA_PREBUILT_TAG} -> ${ERIKA_RUNTIME_OUT}") + return() +endif() + +if(NOT "$ENV{ERIKA_REPO_ROOT}" STREQUAL "") + file(TO_CMAKE_PATH "$ENV{ERIKA_REPO_ROOT}" ERIKA_REPO_ROOT) +else() + get_filename_component(ERIKA_REPO_ROOT + "${ERIKA_PACKAGE_ROOT}/../.." REALPATH) +endif() +if(NOT EXISTS "${ERIKA_REPO_ROOT}/crates/erika_capi/Cargo.toml") + message(FATAL_ERROR + "ERIKA_FORCE_SOURCE_BUILD=1 requires an Erika checkout; set ERIKA_REPO_ROOT") +endif() +find_program(CARGO_EXECUTABLE cargo REQUIRED) + +set(ERIKA_NATIVE_DIST_DIR + "${ERIKA_REPO_ROOT}/third_party/dist/${ERIKA_NATIVE_TARGET}/${ERIKA_NATIVE_PROFILE}") +set(ERIKA_FFMPEG_DIR "${ERIKA_NATIVE_DIST_DIR}/ffmpeg") +set(ERIKA_LIBASS_DIR "${ERIKA_NATIVE_DIST_DIR}/libass") +set(ERIKA_FREETYPE_DIR "${ERIKA_NATIVE_DIST_DIR}/freetype") +set(ERIKA_HARFBUZZ_DIR "${ERIKA_NATIVE_DIST_DIR}/harfbuzz") +set(ERIKA_FRIBIDI_DIR "${ERIKA_NATIVE_DIST_DIR}/fribidi") + +function(erika_native_deps_ready output) + set(ready TRUE) + foreach(path + "${ERIKA_FFMPEG_DIR}/include/libavutil/version.h" + "${ERIKA_LIBASS_DIR}/lib" + "${ERIKA_FREETYPE_DIR}/lib" + "${ERIKA_HARFBUZZ_DIR}/lib" + "${ERIKA_FRIBIDI_DIR}/lib") + if(NOT EXISTS "${path}") + set(ready FALSE) + endif() + endforeach() + set(${output} "${ready}" PARENT_SCOPE) +endfunction() + +erika_native_deps_ready(ERIKA_NATIVE_DEPS_READY) +if(NOT ERIKA_NATIVE_DEPS_READY) + execute_process( + COMMAND "${CARGO_EXECUTABLE}" run -p xtask -- deps build + --profile "${ERIKA_NATIVE_PROFILE}" + --target "${ERIKA_NATIVE_TARGET}" + --all + WORKING_DIRECTORY "${ERIKA_REPO_ROOT}" + RESULT_VARIABLE ERIKA_DEPS_RESULT) + if(NOT ERIKA_DEPS_RESULT EQUAL 0) + message(FATAL_ERROR + "Failed to build Erika native dependencies (exit ${ERIKA_DEPS_RESULT})") + endif() +endif() + +set(ERIKA_CARGO_ARGS build -p erika_capi --target "${ERIKA_NATIVE_TARGET}") +if(NOT ERIKA_BUILD_CONFIG STREQUAL "Debug") + list(APPEND ERIKA_CARGO_ARGS --release) + set(ERIKA_CARGO_PROFILE release) +else() + set(ERIKA_CARGO_PROFILE debug) +endif() +execute_process( + COMMAND "${CMAKE_COMMAND}" -E env + "ERIKA_NATIVE_TARGET=${ERIKA_NATIVE_TARGET}" + "ERIKA_NATIVE_PROFILE=${ERIKA_NATIVE_PROFILE}" + "ERIKA_FFMPEG_DIR=${ERIKA_FFMPEG_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_EXECUTABLE}" ${ERIKA_CARGO_ARGS} + WORKING_DIRECTORY "${ERIKA_REPO_ROOT}" + RESULT_VARIABLE ERIKA_CAPI_RESULT) +if(NOT ERIKA_CAPI_RESULT EQUAL 0) + message(FATAL_ERROR + "Failed to build Erika C API runtime (exit ${ERIKA_CAPI_RESULT})") +endif() + +set(ERIKA_SOURCE_DLL + "${ERIKA_REPO_ROOT}/target/${ERIKA_NATIVE_TARGET}/${ERIKA_CARGO_PROFILE}/erika_capi.dll") +if(NOT EXISTS "${ERIKA_SOURCE_DLL}") + message(FATAL_ERROR "Erika source build did not produce ${ERIKA_SOURCE_DLL}") +endif() +get_filename_component(ERIKA_RUNTIME_DIR "${ERIKA_RUNTIME_OUT}" DIRECTORY) +file(MAKE_DIRECTORY "${ERIKA_RUNTIME_DIR}") +file(COPY "${ERIKA_SOURCE_DLL}" DESTINATION "${ERIKA_RUNTIME_DIR}") diff --git a/third_party/erika_flutter/windows/erika_composition_blend_effect.h b/third_party/erika_flutter/windows/erika_composition_blend_effect.h new file mode 100644 index 00000000..a91d4270 --- /dev/null +++ b/third_party/erika_flutter/windows/erika_composition_blend_effect.h @@ -0,0 +1,155 @@ +// Copyright (c) Aimes Soft and contributors. +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace erika_flutter { + +// Windows.UI.Composition consumes a Win2D-style effect description. The SDK +// does not ship a concrete C++ BlendEffect class, so keep the small descriptor +// here rather than attaching IDCompositionBlendEffect directly to a visual. +// The latter is a filter node whose two inputs must belong to an explicit +// filter graph; treating the HWND target as an implicit input produces an +// invalid D2D graph inside DWM. +class ErikaCompositionBlendEffect final + : public Microsoft::WRL::RuntimeClass< + Microsoft::WRL::RuntimeClassFlags< + Microsoft::WRL::WinRtClassicComMix>, + ABI::Windows::Graphics::Effects::IGraphicsEffect, + ABI::Windows::Graphics::Effects::IGraphicsEffectSource, + ABI::Windows::Graphics::Effects::IGraphicsEffectD2D1Interop> { + InspectableClass(L"Erika.CompositionBlendEffect", BaseTrust); + + public: + HRESULT RuntimeClassInitialize( + ABI::Windows::Graphics::Effects::IGraphicsEffectSource* background, + ABI::Windows::Graphics::Effects::IGraphicsEffectSource* foreground, + D2D1_BLEND_MODE mode) noexcept { + if (background == nullptr || foreground == nullptr) { + return E_INVALIDARG; + } + background_ = background; + foreground_ = foreground; + mode_ = mode; + return S_OK; + } + + IFACEMETHODIMP get_Name(HSTRING* value) override { + if (value == nullptr) { + return E_POINTER; + } + return name_.CopyTo(value); + } + + IFACEMETHODIMP put_Name(HSTRING value) override { + return name_.Set(value); + } + + IFACEMETHODIMP GetEffectId(GUID* value) override { + if (value == nullptr) { + return E_POINTER; + } + // CLSID_D2D1Blend, spelled out to keep this descriptor independent from + // the SDK's external GUID-definition library. + static constexpr GUID kBlendEffectId{ + 0x81C5B77B, + 0x13F8, + 0x4CDD, + {0xAD, 0x20, 0xC8, 0x90, 0x54, 0x7A, 0xC6, 0x5D}}; + *value = kBlendEffectId; + return S_OK; + } + + IFACEMETHODIMP GetNamedPropertyMapping( + LPCWSTR name, + UINT* index, + ABI::Windows::Graphics::Effects::GRAPHICS_EFFECT_PROPERTY_MAPPING* + mapping) override { + if (name == nullptr || index == nullptr || mapping == nullptr) { + return E_POINTER; + } + if (_wcsicmp(name, L"Mode") != 0) { + return E_INVALIDARG; + } + *index = D2D1_BLEND_PROP_MODE; + *mapping = ABI::Windows::Graphics::Effects:: + GRAPHICS_EFFECT_PROPERTY_MAPPING_DIRECT; + return S_OK; + } + + IFACEMETHODIMP GetPropertyCount(UINT* count) override { + if (count == nullptr) { + return E_POINTER; + } + *count = 1; + return S_OK; + } + + IFACEMETHODIMP GetProperty( + UINT index, + ABI::Windows::Foundation::IPropertyValue** value) override { + if (value == nullptr) { + return E_POINTER; + } + *value = nullptr; + if (index != D2D1_BLEND_PROP_MODE) { + return E_INVALIDARG; + } + + Microsoft::WRL::ComPtr + property_value_factory; + Microsoft::WRL::Wrappers::HStringReference class_name( + RuntimeClass_Windows_Foundation_PropertyValue); + HRESULT result = GetActivationFactory(class_name.Get(), + &property_value_factory); + if (FAILED(result)) { + return result; + } + return property_value_factory->CreateUInt32( + static_cast(mode_), + reinterpret_cast(value)); + } + + IFACEMETHODIMP GetSource( + UINT index, + ABI::Windows::Graphics::Effects::IGraphicsEffectSource** value) override { + if (value == nullptr) { + return E_POINTER; + } + if (index == 0) { + return background_.CopyTo(value); + } + if (index == 1) { + return foreground_.CopyTo(value); + } + *value = nullptr; + return E_INVALIDARG; + } + + IFACEMETHODIMP GetSourceCount(UINT* count) override { + if (count == nullptr) { + return E_POINTER; + } + *count = 2; + return S_OK; + } + + private: + Microsoft::WRL::Wrappers::HString name_; + Microsoft::WRL::ComPtr< + ABI::Windows::Graphics::Effects::IGraphicsEffectSource> + background_; + Microsoft::WRL::ComPtr< + ABI::Windows::Graphics::Effects::IGraphicsEffectSource> + foreground_; + D2D1_BLEND_MODE mode_ = D2D1_BLEND_MODE_OVERLAY; +}; + +} // namespace erika_flutter diff --git a/third_party/erika_flutter/windows/erika_flutter_plugin.cpp b/third_party/erika_flutter/windows/erika_flutter_plugin.cpp new file mode 100644 index 00000000..81066fcc --- /dev/null +++ b/third_party/erika_flutter/windows/erika_flutter_plugin.cpp @@ -0,0 +1,3623 @@ +#include "erika_flutter_plugin.h" +#include "erika_composition_blend_effect.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace erika_flutter { +namespace { + +constexpr int64_t kWindowOverlayViewId = -1; +constexpr wchar_t kOverlayWindowClassName[] = L"ErikaFlutterVideoOverlay"; +constexpr wchar_t kFrameMessageWindowClassName[] = L"ErikaFlutterFrameScheduler"; +constexpr wchar_t kFlutterRegularHostWindowClassName[] = + L"FLUTTER_HOST_WINDOW"; +constexpr UINT kFrameTimerMessage = WM_APP + 1; +constexpr UINT kSmtcMessage = WM_APP + 2; +constexpr double kFrameTimerMinFps = 1.0; +constexpr double kFrameTimerMaxFps = 1000.0; +constexpr double kFrameTimerDefaultFps = 60.0; +constexpr double kFrameTimerMinIntervalMs = 1.0; +constexpr DWORD kWaitableTimerHighResolutionFlag = 0x00000002; + +using flutter::EncodableList; +using flutter::EncodableMap; +using flutter::EncodableValue; + +void DebugLog(const std::string& message); + +class PluginError : public std::runtime_error { + public: + explicit PluginError(std::string message) + : std::runtime_error("Erika plugin error"), message_(std::move(message)) {} + + const char* what() const noexcept override { return message_.c_str(); } + + private: + std::string message_; +}; + +void CheckHResult(HRESULT result, const char* operation) { + if (SUCCEEDED(result)) { + return; + } + std::ostringstream message; + message << operation << " failed (HRESULT=0x" << std::hex << std::uppercase + << static_cast(result) << ")"; + const auto detail = message.str(); + DebugLog(detail); + throw PluginError(detail); +} + +std::string LastErrorMessage() { + const DWORD error = GetLastError(); + if (error == 0) { + return {}; + } + + LPWSTR buffer = nullptr; + const DWORD size = FormatMessageW( + FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | + FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, error, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + reinterpret_cast(&buffer), 0, nullptr); + if (size == 0 || buffer == nullptr) { + return "Win32 error " + std::to_string(error); + } + + const int utf8_size = + WideCharToMultiByte(CP_UTF8, 0, buffer, static_cast(size), nullptr, 0, + nullptr, nullptr); + std::string result(static_cast(std::max(0, utf8_size)), '\0'); + if (utf8_size > 0) { + WideCharToMultiByte(CP_UTF8, 0, buffer, static_cast(size), + result.data(), utf8_size, nullptr, nullptr); + } + LocalFree(buffer); + while (!result.empty() && + (result.back() == '\n' || result.back() == '\r' || result.back() == ' ')) { + result.pop_back(); + } + return result.empty() ? "Win32 error " + std::to_string(error) : result; +} + +std::wstring Utf8ToWide(const std::string& value) { + if (value.empty()) { + return {}; + } + const int size = + MultiByteToWideChar(CP_UTF8, 0, value.data(), + static_cast(value.size()), nullptr, 0); + if (size <= 0) { + return {}; + } + std::wstring result(static_cast(size), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast(value.size()), + result.data(), size); + return result; +} + +std::string WideToUtf8(const std::wstring& value) { + if (value.empty()) { + return {}; + } + const int size = + WideCharToMultiByte(CP_UTF8, 0, value.data(), + static_cast(value.size()), nullptr, 0, nullptr, + nullptr); + if (size <= 0) { + return {}; + } + std::string result(static_cast(size), '\0'); + WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast(value.size()), + result.data(), size, nullptr, nullptr); + return result; +} + +std::string PathToUtf8(const std::filesystem::path& path) { + return WideToUtf8(path.wstring()); +} + +std::string SafeUtf8Message(const char* message) { + if (message == nullptr || *message == '\0') { + return "Unknown Erika plugin error."; + } + const int wide_size = MultiByteToWideChar( + CP_UTF8, MB_ERR_INVALID_CHARS, message, -1, nullptr, 0); + if (wide_size > 0) { + return std::string(message); + } + const int fallback_size = + MultiByteToWideChar(CP_ACP, 0, message, -1, nullptr, 0); + if (fallback_size <= 0) { + return "Erika plugin error contained invalid text."; + } + std::wstring wide(static_cast(fallback_size), L'\0'); + MultiByteToWideChar(CP_ACP, 0, message, -1, wide.data(), fallback_size); + if (!wide.empty() && wide.back() == L'\0') { + wide.pop_back(); + } + auto result = WideToUtf8(wide); + return result.empty() ? "Erika plugin error contained invalid text." : result; +} + +std::optional EnvironmentPath(const wchar_t* name) { + const DWORD size = GetEnvironmentVariableW(name, nullptr, 0); + if (size == 0) { + return std::nullopt; + } + std::wstring value(size, L'\0'); + const DWORD written = GetEnvironmentVariableW(name, value.data(), size); + if (written == 0) { + return std::nullopt; + } + value.resize(written); + if (value.empty()) { + return std::nullopt; + } + return std::filesystem::path(value); +} + +std::filesystem::path ExecutableDirectory() { + std::wstring buffer(MAX_PATH, L'\0'); + DWORD size = GetModuleFileNameW(nullptr, buffer.data(), + static_cast(buffer.size())); + while (size == buffer.size()) { + buffer.resize(buffer.size() * 2); + size = GetModuleFileNameW(nullptr, buffer.data(), + static_cast(buffer.size())); + } + if (size == 0) { + return {}; + } + buffer.resize(size); + return std::filesystem::path(buffer).parent_path(); +} + +std::filesystem::path SourceTreeRoot() { +#if defined(ERIKA_REPO_ROOT_PATH) + return std::filesystem::path(ERIKA_REPO_ROOT_PATH); +#else + std::filesystem::path source_file(__FILE__); + return source_file.parent_path() // windows + .parent_path() // erika_flutter + .parent_path() // packages + .parent_path(); // repo root +#endif +} + +std::filesystem::path LogFilePath() { + if (auto value = EnvironmentPath(L"ERIKA_FLUTTER_LOG_FILE")) { + return *value; + } + if (auto value = EnvironmentPath(L"LOCALAPPDATA")) { + return *value / L"Erika" / L"erika_flutter_windows.log"; + } + return std::filesystem::temp_directory_path() / L"erika_flutter_windows.log"; +} + +std::string TimestampForLog() { + const auto now = std::chrono::system_clock::now(); + const auto time = std::chrono::system_clock::to_time_t(now); + std::tm local_time{}; + localtime_s(&local_time, &time); + std::ostringstream stream; + stream << std::put_time(&local_time, "%Y-%m-%d %H:%M:%S"); + return stream.str(); +} + +void DebugLog(const std::string& message) { + const std::string line = TimestampForLog() + " [tid " + + std::to_string(GetCurrentThreadId()) + "] " + + message; + OutputDebugStringW((L"ErikaFlutterPlugin: " + Utf8ToWide(line) + L"\n").c_str()); + static std::mutex log_mutex; + std::lock_guard lock(log_mutex); + try { + const auto path = LogFilePath(); + std::filesystem::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::app | std::ios::binary); + file << line << "\n"; + } catch (...) { + } +} + +double NowSeconds() { + using clock = std::chrono::steady_clock; + const auto now = clock::now().time_since_epoch(); + return std::chrono::duration(now).count(); +} + +double ScaleForWindow(HWND hwnd) { + if (hwnd == nullptr) { + return 1.0; + } + const UINT dpi = GetDpiForWindow(hwnd); + if (dpi == 0) { + return 1.0; + } + return std::max(1.0, static_cast(dpi) / 96.0); +} + +HWND RootHostWindow(HWND flutter_window) { + if (flutter_window == nullptr) { + return nullptr; + } + const HWND root = GetAncestor(flutter_window, GA_ROOT); + return root != nullptr ? root : flutter_window; +} + +struct FlutterRegularHostSearch { + DWORD process_id = 0; + HWND result = nullptr; +}; + +BOOL CALLBACK FindFlutterRegularHostWindow(HWND window, LPARAM parameter) { + auto* search = reinterpret_cast(parameter); + DWORD process_id = 0; + GetWindowThreadProcessId(window, &process_id); + if (process_id != search->process_id) { + return TRUE; + } + + wchar_t class_name[64] = {}; + if (GetClassNameW(window, class_name, 64) == 0 || + std::wcscmp(class_name, kFlutterRegularHostWindowClassName) != 0) { + return TRUE; + } + search->result = window; + return FALSE; +} + +HWND ResolveFlutterRegularHostWindow() { + FlutterRegularHostSearch search{GetCurrentProcessId(), nullptr}; + EnumWindows(FindFlutterRegularHostWindow, + reinterpret_cast(&search)); + return search.result; +} + +int LogicalToPhysical(HWND hwnd, double value) { + return static_cast(std::llround(value * ScaleForWindow(hwnd))); +} + +std::optional RefreshRateForWindow(HWND hwnd) { + HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + if (monitor == nullptr) { + return std::nullopt; + } + + MONITORINFOEXW monitor_info{}; + monitor_info.cbSize = sizeof(monitor_info); + if (!GetMonitorInfoW(monitor, &monitor_info)) { + return std::nullopt; + } + + DEVMODEW mode{}; + mode.dmSize = sizeof(mode); + if (!EnumDisplaySettingsW(monitor_info.szDevice, ENUM_CURRENT_SETTINGS, + &mode)) { + return std::nullopt; + } + if ((mode.dmFields & DM_DISPLAYFREQUENCY) == 0 || + mode.dmDisplayFrequency <= 1) { + return std::nullopt; + } + const double fps = static_cast(mode.dmDisplayFrequency); + if (!std::isfinite(fps) || fps < kFrameTimerMinFps || + fps > kFrameTimerMaxFps) { + return std::nullopt; + } + return fps; +} + +double FrameTimerTargetFps(HWND hwnd) { + const auto env = EnvironmentPath(L"ERIKA_FLUTTER_TARGET_FPS"); + if (env) { + try { + const double fps = std::stod(env->wstring()); + if (std::isfinite(fps) && fps > 0.0) { + return std::clamp(fps, kFrameTimerMinFps, kFrameTimerMaxFps); + } + } catch (...) { + } + } + if (auto refresh_rate = RefreshRateForWindow(hwnd)) { + return std::clamp(*refresh_rate, kFrameTimerMinFps, kFrameTimerMaxFps); + } + return kFrameTimerDefaultFps; +} + +double FrameTimerIntervalMs(double fps) { + if (!std::isfinite(fps) || fps <= 0.0) { + fps = kFrameTimerDefaultFps; + } + return std::max(kFrameTimerMinIntervalMs, 1000.0 / fps); +} + +LARGE_INTEGER RelativeWaitTimeForMilliseconds(double milliseconds) { + LARGE_INTEGER due_time{}; + due_time.QuadPart = + -std::max(1, static_cast( + std::llround(milliseconds * 10000.0))); + return due_time; +} + +bool FrameTraceEnabled() { + const auto value = EnvironmentPath(L"ERIKA_FLUTTER_FRAME_TRACE"); + if (!value) { + return false; + } + const auto text = value->wstring(); + return text != L"0" && text != L"false" && text != L"FALSE"; +} + +std::string StatusName(ErikaStatus status) { + switch (status) { + case ErikaStatus_Ok: + return "Ok"; + case ErikaStatus_NullPointer: + return "NullPointer"; + case ErikaStatus_InvalidUtf8: + return "InvalidUtf8"; + case ErikaStatus_PlayerError: + return "PlayerError"; + case ErikaStatus_Panic: + return "Panic"; + case ErikaStatus_NoEvent: + return "NoEvent"; + } + return "Unknown"; +} + +void Check(ErikaStatus status, + const char* operation, + const std::string& native_error = {}) { + if (status == ErikaStatus_Ok) { + return; + } + std::string message = std::string(operation) + " failed with ErikaStatus_" + + StatusName(status) + " (" + + std::to_string(static_cast(status)) + ")"; + if (!native_error.empty()) { + message += ": " + native_error; + } + DebugLog(message); + throw PluginError(message); +} + +const EncodableMap& DictionaryArgs(const EncodableValue* arguments) { + if (arguments == nullptr) { + throw PluginError("Arguments must be a dictionary."); + } + const auto* map = std::get_if(arguments); + if (map == nullptr) { + throw PluginError("Arguments must be a dictionary."); + } + return *map; +} + +const EncodableValue* FindArg(const EncodableMap& args, const char* name) { + const auto it = args.find(EncodableValue(std::string(name))); + if (it == args.end()) { + return nullptr; + } + return &it->second; +} + +std::optional Int64Value(const EncodableValue* value) { + if (value == nullptr || std::holds_alternative(*value)) { + return std::nullopt; + } + if (const auto* v = std::get_if(value)) { + return static_cast(*v); + } + if (const auto* v = std::get_if(value)) { + return *v; + } + if (const auto* v = std::get_if(value)) { + if (std::isfinite(*v)) { + return static_cast(*v); + } + } + if (const auto* v = std::get_if(value)) { + try { + return std::stoll(*v); + } catch (...) { + return std::nullopt; + } + } + return std::nullopt; +} + +int64_t RequiredInt64(const EncodableMap& args, const char* name) { + const auto value = Int64Value(FindArg(args, name)); + if (!value) { + throw PluginError(std::string(name) + " is required."); + } + return *value; +} + +std::optional DoubleValue(const EncodableValue* value) { + if (value == nullptr || std::holds_alternative(*value)) { + return std::nullopt; + } + if (const auto* v = std::get_if(value)) { + return std::isfinite(*v) ? std::optional(*v) : std::nullopt; + } + if (const auto* v = std::get_if(value)) { + return static_cast(*v); + } + if (const auto* v = std::get_if(value)) { + return static_cast(*v); + } + if (const auto* v = std::get_if(value)) { + try { + const double parsed = std::stod(*v); + return std::isfinite(parsed) ? std::optional(parsed) + : std::nullopt; + } catch (...) { + return std::nullopt; + } + } + return std::nullopt; +} + +std::optional BoolValue(const EncodableValue* value) { + if (value == nullptr || std::holds_alternative(*value)) { + return std::nullopt; + } + if (const auto* v = std::get_if(value)) { + return *v; + } + if (const auto* v = std::get_if(value)) { + return *v != 0; + } + if (const auto* v = std::get_if(value)) { + return *v != 0; + } + if (const auto* v = std::get_if(value)) { + std::string lower = *v; + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lower == "1" || lower == "true" || lower == "yes" || lower == "on") { + return true; + } + if (lower == "0" || lower == "false" || lower == "no" || + lower == "off") { + return false; + } + } + return std::nullopt; +} + +std::optional StringValue(const EncodableValue* value) { + if (value == nullptr || std::holds_alternative(*value)) { + return std::nullopt; + } + if (const auto* v = std::get_if(value)) { + return *v; + } + return std::nullopt; +} + +std::string RequiredString(const EncodableMap& args, const char* name) { + const auto value = StringValue(FindArg(args, name)); + if (!value) { + throw PluginError(std::string(name) + " is required."); + } + return *value; +} + +int64_t OptionalTrackId(const EncodableValue* value) { + const auto track_id = Int64Value(value); + if (!track_id || *track_id < 0) { + return -1; + } + return *track_id; +} + +ErikaDanmakuConfig DefaultDanmakuConfig() { + ErikaDanmakuConfig config{}; + config.enabled = true; + config.font_size = 30.0f; + config.opacity = 1.0f; + config.display_area = 1.0f; + config.scroll_duration_seconds = 10.0f; + config.scroll_speed_factor = 1.0f; + config.track_gap_ratio = 0.15f; + config.outline_width = 1.0f; + config.shadow_offset_x = 1.0f; + config.shadow_offset_y = 1.0f; + config.allow_scroll_overwrite = true; + config.shadow_style = 3; + return config; +} + +EncodableValue NullableString(char* value) { + if (value == nullptr) { + return EncodableValue(); + } + return EncodableValue(std::string(value)); +} + +EncodableValue TrackSelectionToMap(const ErikaTrackSelection& selection) { + return EncodableValue(EncodableMap{ + {EncodableValue("video"), EncodableValue(static_cast(selection.video))}, + {EncodableValue("audio"), EncodableValue(static_cast(selection.audio))}, + {EncodableValue("subtitle"), + EncodableValue(static_cast(selection.subtitle))}, + }); +} + +EncodableValue UpscalerStatusToMap(const ErikaUpscalerStatus& status) { + return EncodableValue(EncodableMap{ + {EncodableValue("requestedMode"), + EncodableValue(static_cast(status.requested_mode))}, + {EncodableValue("activeBackend"), + EncodableValue(static_cast(status.active_backend))}, + {EncodableValue("fallbackCount"), + EncodableValue(static_cast(status.fallback_count))}, + {EncodableValue("upscaledFrames"), + EncodableValue(static_cast(status.upscaled_frames))}, + {EncodableValue("lastEncodeMicros"), + EncodableValue(static_cast(status.last_encode_micros))}, + {EncodableValue("lastGpuMicros"), + EncodableValue(static_cast(status.last_gpu_micros))}, + }); +} + +EncodableValue PresenterStatsToMap(const ErikaPresenterStats& stats) { + return EncodableValue(EncodableMap{ + {EncodableValue("decodedVideoFrames"), + EncodableValue(static_cast(stats.decoded_video_frames))}, + {EncodableValue("renderedVideoFrames"), + EncodableValue(static_cast(stats.rendered_video_frames))}, + {EncodableValue("renderedTestFrames"), + EncodableValue(static_cast(stats.rendered_test_frames))}, + {EncodableValue("pushedAudioFrames"), + EncodableValue(static_cast(stats.pushed_audio_frames))}, + {EncodableValue("overlayFrames"), + EncodableValue(static_cast(stats.overlay_frames))}, + {EncodableValue("danmakuFrames"), + EncodableValue(static_cast(stats.danmaku_frames))}, + {EncodableValue("danmakuItems"), + EncodableValue(static_cast(stats.danmaku_items))}, + {EncodableValue("importFailures"), + EncodableValue(static_cast(stats.import_failures))}, + {EncodableValue("renderFailures"), + EncodableValue(static_cast(stats.render_failures))}, + {EncodableValue("audioFailures"), + EncodableValue(static_cast(stats.audio_failures))}, + {EncodableValue("softwareVideoFrames"), + EncodableValue(static_cast(stats.software_video_frames))}, + {EncodableValue("hardwareVideoFrames"), + EncodableValue(static_cast(stats.hardware_video_frames))}, + {EncodableValue("zeroCopyVideoFrames"), + EncodableValue(static_cast(stats.zero_copy_video_frames))}, + {EncodableValue("cpuVideoFrameFallbacks"), + EncodableValue(static_cast(stats.cpu_video_frame_fallbacks))}, + {EncodableValue("lastRenderMicros"), + EncodableValue(static_cast(stats.last_render_micros))}, + {EncodableValue("lastRenderCurrentMicros"), + EncodableValue(static_cast(stats.last_render_current_micros))}, + {EncodableValue("audioClockReadFrames"), + EncodableValue(static_cast(stats.audio_clock_read_frames))}, + {EncodableValue("audioClockQueuedFrames"), + EncodableValue(static_cast(stats.audio_clock_queued_frames))}, + {EncodableValue("audioClockUnderflowFrames"), + EncodableValue(static_cast(stats.audio_clock_underflow_frames))}, + {EncodableValue("directZeroCopyVideoFrames"), + EncodableValue(static_cast(stats.direct_zero_copy_video_frames))}, + {EncodableValue("sharedHandleVideoFrames"), + EncodableValue(static_cast(stats.shared_handle_video_frames))}, + {EncodableValue("hdrSourceFrames"), + EncodableValue(static_cast(stats.hdr_source_frames))}, + {EncodableValue("hdr10OutputFrames"), + EncodableValue(static_cast(stats.hdr10_output_frames))}, + {EncodableValue("sdrTonemapFrames"), + EncodableValue(static_cast(stats.sdr_tonemap_frames))}, + {EncodableValue("hdr10MetadataUpdates"), + EncodableValue(static_cast(stats.hdr10_metadata_updates))}, + {EncodableValue("hdr10MetadataFailures"), + EncodableValue(static_cast(stats.hdr10_metadata_failures))}, + {EncodableValue("hdr10OutputFailures"), + EncodableValue(static_cast(stats.hdr10_output_failures))}, + {EncodableValue("hdr10OutputActive"), + EncodableValue(stats.hdr10_output_active)}, + {EncodableValue("videoFrameBackpressureDrops"), + EncodableValue( + static_cast(stats.video_frame_backpressure_drops))}, + }); +} + +EncodableValue OutputStatusToMap(const ErikaOutputStatus& status) { + EncodableMap map; + map[EncodableValue("requestedMode")] = + EncodableValue(status.requested_mode); + map[EncodableValue("activeEncoding")] = + EncodableValue(status.active_encoding); + map[EncodableValue("surfaceFormat")] = + EncodableValue(status.surface_format); + map[EncodableValue("nativeDataSpace")] = + EncodableValue(status.native_data_space); + map[EncodableValue("requestedHeadroom")] = + EncodableValue(static_cast(status.requested_headroom)); + map[EncodableValue("activeHeadroom")] = + EncodableValue(static_cast(status.active_headroom)); + map[EncodableValue("activeHeadroomKnown")] = + EncodableValue(status.active_headroom_known); + map[EncodableValue("extendedLinearActive")] = + EncodableValue(status.extended_linear_active); + map[EncodableValue("fallbackReason")] = + EncodableValue(status.fallback_reason); + map[EncodableValue("fallbackCount")] = + EncodableValue(static_cast(status.fallback_count)); + map[EncodableValue("dataSpaceFailures")] = + EncodableValue(static_cast(status.data_space_failures)); + map[EncodableValue("headroomUpdates")] = + EncodableValue(static_cast(status.headroom_updates)); + map[EncodableValue("extendedLinearFrames")] = + EncodableValue(static_cast(status.extended_linear_frames)); + return EncodableValue(std::move(map)); +} + +EncodableValue ResourceStatusToMap(const ErikaPresenterResourceStatus& status) { + EncodableMap map; + map[EncodableValue("deviceCurrentAllocatedBytes")] = + EncodableValue(static_cast(status.device_current_allocated_bytes)); + map[EncodableValue("deviceRecommendedWorkingSetBytes")] = + EncodableValue( + static_cast(status.device_recommended_working_set_bytes)); + map[EncodableValue("drawableEstimatedBytes")] = + EncodableValue(static_cast(status.drawable_estimated_bytes)); + map[EncodableValue("videoFrameBytes")] = + EncodableValue(static_cast(status.video_frame_bytes)); + map[EncodableValue("overlayAtlasBytes")] = + EncodableValue(static_cast(status.overlay_atlas_bytes)); + map[EncodableValue("danmakuAtlasBytes")] = + EncodableValue(static_cast(status.danmaku_atlas_bytes)); + map[EncodableValue("danmakuVertexBufferBytes")] = + EncodableValue(static_cast(status.danmaku_vertex_buffer_bytes)); + map[EncodableValue("upscalerBytes")] = + EncodableValue(static_cast(status.upscaler_bytes)); + map[EncodableValue("rendererTrackedBytes")] = + EncodableValue(static_cast(status.renderer_tracked_bytes)); + map[EncodableValue("presenterCpuDanmakuAtlasBytes")] = + EncodableValue(static_cast(status.presenter_cpu_danmaku_atlas_bytes)); + map[EncodableValue("drawableCount")] = + EncodableValue(static_cast(status.drawable_count)); + map[EncodableValue("outputModeSwitches")] = + EncodableValue(static_cast(status.output_mode_switches)); + return EncodableValue(std::move(map)); +} + +EncodableValue SubtitleMemoryFontStatusToMap( + const ErikaSubtitleMemoryFontStatus& status) { + EncodableList selected_ids; + selected_ids.reserve(status.selected_count); + for (uintptr_t index = 0; index < status.selected_count; ++index) { + selected_ids.emplace_back(static_cast(status.selected_ids[index])); + } + return EncodableValue(EncodableMap{ + {EncodableValue("registeredCount"), + EncodableValue(static_cast(status.registered_count))}, + {EncodableValue("registeredBytes"), + EncodableValue(static_cast(status.registered_bytes))}, + {EncodableValue("selectedCount"), + EncodableValue(static_cast(status.selected_count))}, + {EncodableValue("generation"), + EncodableValue(static_cast(status.generation))}, + {EncodableValue("selectedIds"), EncodableValue(std::move(selected_ids))}, + }); +} + +} // namespace + +struct ErikaFlutterPlugin::ErikaNativeLibrary { + using CreateFn = ErikaPresenterHandle* (*)(); + using CreateWithConfigFn = ErikaPresenterHandle* (*)(ErikaPresenterConfig); + using CreateWithOutputModeFn = ErikaPresenterHandle* (*)(int32_t, float); + using DestroyFn = void (*)(ErikaPresenterHandle*); + using OpenFn = ErikaStatus (*)(ErikaPresenterHandle*, const char*); + using OpenWithHeadersFn = ErikaStatus (*)(ErikaPresenterHandle*, const char*, const ErikaHttpHeader*, uintptr_t); + using CommandFn = ErikaStatus (*)(ErikaPresenterHandle*); + using SeekFn = ErikaStatus (*)(ErikaPresenterHandle*, uint64_t); + using SetPlaybackRateFn = ErikaStatus (*)(ErikaPresenterHandle*, double); + using SetVolumeFn = ErikaStatus (*)(ErikaPresenterHandle*, double); + using SetUpscalerFn = ErikaStatus (*)(ErikaPresenterHandle*, int32_t); + using SetSubtitleScaleFn = ErikaStatus (*)(ErikaPresenterHandle*, double); + using SetSubtitleFontFn = + ErikaStatus (*)(ErikaPresenterHandle*, const char*, const char*); + using SetSubtitleStyleFn = + ErikaStatus (*)(ErikaPresenterHandle*, ErikaSubtitleStyle); + using RegisterSubtitleMemoryFontFn = ErikaStatus (*)( + ErikaPresenterHandle*, const uint8_t*, uintptr_t, uint64_t*); + using SelectSubtitleMemoryFontsFn = ErikaStatus (*)( + ErikaPresenterHandle*, const uint64_t*, uintptr_t); + using GetSubtitleMemoryFontStatusFn = ErikaStatus (*)( + ErikaPresenterHandle*, ErikaSubtitleMemoryFontStatus*); + using GetUpscalerStatusFn = + ErikaStatus (*)(ErikaPresenterHandle*, ErikaUpscalerStatus*); + using GetOutputStatusFn = + ErikaStatus (*)(ErikaPresenterHandle*, ErikaOutputStatus*); + using GetResourceStatusFn = + ErikaStatus (*)(ErikaPresenterHandle*, ErikaPresenterResourceStatus*); + using SelectTrackFn = ErikaStatus (*)(ErikaPresenterHandle*, int64_t); + using AddExternalSubtitleFn = + ErikaStatus (*)(ErikaPresenterHandle*, const char*, int64_t*); + using RemoveSubtitleTrackFn = ErikaStatus (*)(ErikaPresenterHandle*, int64_t); + using LoadDanmakuFn = ErikaStatus (*)(ErikaPresenterHandle*, const char*); + using AddDanmakuTrackFn = + ErikaStatus (*)(ErikaPresenterHandle*, const char*, const char*, int64_t, + uint64_t*); + using RemoveDanmakuTrackFn = ErikaStatus (*)(ErikaPresenterHandle*, uint64_t); + using SetDanmakuTrackEnabledFn = + ErikaStatus (*)(ErikaPresenterHandle*, uint64_t, bool); + using SetDanmakuTrackOffsetFn = + ErikaStatus (*)(ErikaPresenterHandle*, uint64_t, int64_t); + using SetDanmakuGlobalOffsetFn = + ErikaStatus (*)(ErikaPresenterHandle*, int64_t); + using DanmakuTracksFn = + ErikaStatus (*)(ErikaPresenterHandle*, ErikaDanmakuTrackInfo*, uintptr_t, + uintptr_t*); + using ClearDanmakuFn = ErikaStatus (*)(ErikaPresenterHandle*); + using SetDanmakuEnabledFn = ErikaStatus (*)(ErikaPresenterHandle*, bool); + using SetDebugHudEnabledFn = ErikaStatus (*)(ErikaPresenterHandle*, bool); + using SetDanmakuConfigFn = + ErikaStatus (*)(ErikaPresenterHandle*, const ErikaDanmakuConfig*); + using GetDanmakuConfigFn = + ErikaStatus (*)(ErikaPresenterHandle*, ErikaDanmakuConfig*); + using SetDanmakuFontFn = + ErikaStatus (*)(ErikaPresenterHandle*, const char*, const char*); + using TrackSelectionFn = + ErikaStatus (*)(ErikaPresenterHandle*, ErikaTrackSelection*); + using TracksFn = + ErikaStatus (*)(ErikaPresenterHandle*, ErikaTrackInfo*, uintptr_t, + uintptr_t*); + using TrackInfoFreeFn = void (*)(ErikaTrackInfo*); + using DanmakuTrackInfoFreeFn = void (*)(ErikaDanmakuTrackInfo*); + using AttachWindowsHwndFn = + ErikaStatus (*)(ErikaPresenterHandle*, uint64_t, uint64_t, uint32_t, + uint32_t, double); + using AttachFlutterTextureFn = ErikaStatus (*)( + ErikaPresenterHandle*, ErikaFlutterTextureKind, int64_t, uint32_t, + uint32_t, double); + using GetWindowsFlutterTextureFn = + ErikaStatus (*)(ErikaPresenterHandle*, void**); + using AttachWgpuSurfaceWithOutputCapabilitiesFn = ErikaStatus (*)( + ErikaPresenterHandle*, ErikaWgpuSurfaceKind, uint64_t, uint64_t, + uint32_t, uint32_t, double, ErikaSurfaceOutputCapabilities); + using GetWindowsCompositionSwapchainFn = + ErikaStatus (*)(ErikaPresenterHandle*, void**); + using ResizeSurfaceFn = + ErikaStatus (*)(ErikaPresenterHandle*, uint32_t, uint32_t, double); + using RenderTickFn = + ErikaStatus (*)(ErikaPresenterHandle*, double, ErikaPresenterStats*); + using PollEventFn = ErikaStatus (*)(ErikaPresenterHandle*, ErikaEvent*); + using LastErrorMessageFn = char* (*)(); + using StringFreeFn = void (*)(char*); + + static std::shared_ptr Shared() { + static std::mutex mutex; + static std::weak_ptr weak; + std::lock_guard lock(mutex); + if (auto shared = weak.lock()) { + return shared; + } + auto shared = std::shared_ptr(new ErikaNativeLibrary()); + weak = shared; + return shared; + } + + ~ErikaNativeLibrary() { + if (module != nullptr) { + FreeLibrary(module); + module = nullptr; + } + } + + ErikaPresenterHandle* CreatePresenter(ErikaPresenterConfig config) const { + if (create_with_config != nullptr) { + return create_with_config(config); + } + if (config.video_alpha_mode != ErikaVideoAlphaMode_Opaque) { + throw PluginError( + "The loaded erika_capi.dll does not support transparent-video " + "presenter configuration. Update the bundled Erika runtime."); + } + if (create_with_output_mode != nullptr) { + return create_with_output_mode(config.output_mode, config.edr_headroom); + } + return create(); + } + + std::string TakeLastError() const { + if (last_error_message == nullptr) { + return {}; + } + char* raw = last_error_message(); + if (raw == nullptr) { + return {}; + } + std::string message = SafeUtf8Message(raw); + if (string_free != nullptr) { + string_free(raw); + } + return message; + } + + HMODULE module = nullptr; + CreateFn create = nullptr; + CreateWithConfigFn create_with_config = nullptr; + CreateWithOutputModeFn create_with_output_mode = nullptr; + DestroyFn destroy = nullptr; + OpenFn open = nullptr; + OpenWithHeadersFn open_with_headers = nullptr; + CommandFn play = nullptr; + CommandFn pause = nullptr; + CommandFn stop = nullptr; + CommandFn close = nullptr; + SeekFn seek = nullptr; + SetPlaybackRateFn set_playback_rate = nullptr; + SetVolumeFn set_volume = nullptr; + SetUpscalerFn set_upscaler = nullptr; + SetSubtitleScaleFn set_subtitle_scale = nullptr; + SetSubtitleFontFn set_subtitle_font = nullptr; + SetSubtitleStyleFn set_subtitle_style = nullptr; + RegisterSubtitleMemoryFontFn register_subtitle_memory_font = nullptr; + SelectSubtitleMemoryFontsFn select_subtitle_memory_fonts = nullptr; + CommandFn clear_subtitle_memory_fonts = nullptr; + GetSubtitleMemoryFontStatusFn get_subtitle_memory_font_status = nullptr; + void (*free_subtitle_memory_font_status)(ErikaSubtitleMemoryFontStatus*) = nullptr; + GetUpscalerStatusFn get_upscaler_status = nullptr; + GetOutputStatusFn get_output_status = nullptr; + GetResourceStatusFn get_resource_status = nullptr; + SelectTrackFn select_audio_track = nullptr; + SelectTrackFn select_subtitle_track = nullptr; + AddExternalSubtitleFn add_external_subtitle = nullptr; + RemoveSubtitleTrackFn remove_subtitle_track = nullptr; + LoadDanmakuFn load_danmaku_file = nullptr; + LoadDanmakuFn load_danmaku_json = nullptr; + AddDanmakuTrackFn add_danmaku_track_file = nullptr; + AddDanmakuTrackFn add_danmaku_track_json = nullptr; + RemoveDanmakuTrackFn remove_danmaku_track = nullptr; + SetDanmakuTrackEnabledFn set_danmaku_track_enabled = nullptr; + SetDanmakuTrackOffsetFn set_danmaku_track_offset = nullptr; + SetDanmakuGlobalOffsetFn set_danmaku_global_offset = nullptr; + DanmakuTracksFn danmaku_tracks = nullptr; + ClearDanmakuFn clear_danmaku = nullptr; + SetDanmakuEnabledFn set_danmaku_enabled = nullptr; + SetDebugHudEnabledFn set_debug_hud_enabled = nullptr; + SetDanmakuConfigFn set_danmaku_config = nullptr; + GetDanmakuConfigFn get_danmaku_config = nullptr; + SetDanmakuFontFn set_danmaku_font = nullptr; + LoadDanmakuFn set_danmaku_block_words_json = nullptr; + TrackSelectionFn track_selection = nullptr; + TracksFn tracks = nullptr; + TrackInfoFreeFn free_track_info = nullptr; + DanmakuTrackInfoFreeFn free_danmaku_track_info = nullptr; + AttachWindowsHwndFn attach_windows_hwnd = nullptr; + AttachFlutterTextureFn attach_flutter_texture = nullptr; + GetWindowsFlutterTextureFn windows_flutter_texture = nullptr; + AttachWgpuSurfaceWithOutputCapabilitiesFn + attach_wgpu_surface_with_output_capabilities = nullptr; + GetWindowsCompositionSwapchainFn windows_composition_swapchain = nullptr; + ResizeSurfaceFn resize_surface = nullptr; + CommandFn detach_surface = nullptr; + RenderTickFn render_tick = nullptr; + PollEventFn poll_event = nullptr; + LastErrorMessageFn last_error_message = nullptr; + StringFreeFn string_free = nullptr; + + private: + ErikaNativeLibrary() { + const auto loaded = OpenLibrary(); + module = loaded.first; + DebugLog("loaded Erika C API from " + PathToUtf8(loaded.second)); + + create = LoadRequired("erika_presenter_create"); + create_with_config = + LoadOptional("erika_presenter_create_with_config"); + create_with_output_mode = LoadOptional( + "erika_presenter_create_with_output_mode"); + destroy = LoadRequired("erika_presenter_destroy"); + open = LoadRequired("erika_presenter_open"); + open_with_headers = LoadOptional("erika_presenter_open_with_headers"); + play = LoadRequired("erika_presenter_play"); + pause = LoadRequired("erika_presenter_pause"); + stop = LoadRequired("erika_presenter_stop"); + close = LoadRequired("erika_presenter_close"); + seek = LoadRequired("erika_presenter_seek"); + set_playback_rate = + LoadOptional("erika_presenter_set_playback_rate"); + set_volume = LoadOptional("erika_presenter_set_volume"); + set_upscaler = + LoadOptional("erika_presenter_set_upscaler"); + set_subtitle_scale = LoadOptional( + "erika_presenter_set_subtitle_scale"); + set_subtitle_font = + LoadOptional("erika_presenter_set_subtitle_font"); + set_subtitle_style = LoadOptional( + "erika_presenter_set_subtitle_style"); + register_subtitle_memory_font = LoadOptional( + "erika_presenter_register_subtitle_memory_font"); + select_subtitle_memory_fonts = LoadOptional( + "erika_presenter_select_subtitle_memory_fonts"); + clear_subtitle_memory_fonts = LoadOptional( + "erika_presenter_clear_subtitle_memory_fonts"); + get_subtitle_memory_font_status = LoadOptional( + "erika_presenter_get_subtitle_memory_font_status"); + free_subtitle_memory_font_status = LoadOptional( + "erika_subtitle_memory_font_status_free"); + get_upscaler_status = LoadOptional( + "erika_presenter_get_upscaler_status"); + get_output_status = LoadOptional( + "erika_presenter_get_output_status"); + get_resource_status = LoadOptional( + "erika_presenter_get_resource_status"); + select_audio_track = + LoadRequired("erika_presenter_select_audio_track"); + select_subtitle_track = + LoadRequired("erika_presenter_select_subtitle_track"); + add_external_subtitle = + LoadRequired("erika_presenter_add_external_subtitle"); + remove_subtitle_track = + LoadRequired("erika_presenter_remove_subtitle_track"); + load_danmaku_file = + LoadOptional("erika_presenter_load_danmaku_file"); + load_danmaku_json = + LoadOptional("erika_presenter_load_danmaku_json"); + add_danmaku_track_file = LoadOptional( + "erika_presenter_add_danmaku_track_file"); + add_danmaku_track_json = LoadOptional( + "erika_presenter_add_danmaku_track_json"); + remove_danmaku_track = LoadOptional( + "erika_presenter_remove_danmaku_track"); + set_danmaku_track_enabled = LoadOptional( + "erika_presenter_set_danmaku_track_enabled"); + set_danmaku_track_offset = LoadOptional( + "erika_presenter_set_danmaku_track_offset"); + set_danmaku_global_offset = LoadOptional( + "erika_presenter_set_danmaku_global_offset"); + danmaku_tracks = + LoadOptional("erika_presenter_danmaku_tracks"); + clear_danmaku = + LoadOptional("erika_presenter_clear_danmaku"); + set_danmaku_enabled = LoadOptional( + "erika_presenter_set_danmaku_enabled"); + set_debug_hud_enabled = LoadOptional( + "erika_presenter_set_debug_hud_enabled"); + set_danmaku_config = LoadOptional( + "erika_presenter_set_danmaku_config_ptr"); + get_danmaku_config = LoadOptional( + "erika_presenter_get_danmaku_config"); + set_danmaku_font = + LoadOptional("erika_presenter_set_danmaku_font"); + set_danmaku_block_words_json = LoadOptional( + "erika_presenter_set_danmaku_block_words_json"); + track_selection = + LoadRequired("erika_presenter_track_selection"); + tracks = LoadRequired("erika_presenter_tracks"); + free_track_info = LoadRequired("erika_track_info_free"); + free_danmaku_track_info = LoadOptional( + "erika_danmaku_track_info_free"); + attach_windows_hwnd = + LoadRequired("erika_presenter_attach_windows_hwnd"); + attach_flutter_texture = LoadRequired( + "erika_presenter_attach_flutter_texture"); + windows_flutter_texture = LoadRequired( + "erika_presenter_windows_flutter_texture_iunknown"); + attach_wgpu_surface_with_output_capabilities = + LoadRequired( + "erika_presenter_attach_wgpu_surface_with_output_capabilities"); + windows_composition_swapchain = + LoadOptional( + "erika_presenter_windows_composition_swapchain_iunknown"); + resize_surface = + LoadRequired("erika_presenter_resize_surface"); + detach_surface = + LoadRequired("erika_presenter_detach_surface"); + render_tick = LoadRequired("erika_presenter_render_tick"); + poll_event = LoadRequired("erika_presenter_poll_event"); + last_error_message = + LoadOptional("erika_last_error_message"); + string_free = LoadOptional("erika_string_free"); + } + + static std::pair OpenLibrary() { + std::vector candidates; + if (auto value = EnvironmentPath(L"ERIKA_CAPI_DLL")) { + candidates.push_back(*value); + } + if (auto value = EnvironmentPath(L"ERIKA_CAPI_DYLIB")) { + candidates.push_back(*value); + } + const auto exe_dir = ExecutableDirectory(); + if (!exe_dir.empty()) { + candidates.push_back(exe_dir / L"erika_capi.dll"); + } + const auto repo_root = SourceTreeRoot(); + candidates.push_back(repo_root / L"target" / L"debug" / L"erika_capi.dll"); + candidates.push_back(repo_root / L"target" / L"release" / L"erika_capi.dll"); + candidates.push_back(L"erika_capi.dll"); + + std::ostringstream failures; + for (const auto& candidate : candidates) { + SetLastError(0); + if (HMODULE module = LoadLibraryW(candidate.c_str())) { + return {module, candidate}; + } + failures << PathToUtf8(candidate) << " (" << LastErrorMessage() << "); "; + } + throw PluginError("Unable to load erika_capi.dll. Tried: " + + failures.str()); + } + + template + T LoadRequired(const char* symbol) const { + auto* raw = GetProcAddress(module, symbol); + if (raw == nullptr) { + throw PluginError(std::string("Missing Erika C ABI symbol: ") + symbol); + } + return reinterpret_cast(raw); + } + + template + T LoadOptional(const char* symbol) const { + auto* raw = GetProcAddress(module, symbol); + if (raw == nullptr) { + return nullptr; + } + return reinterpret_cast(raw); + } +}; + +struct ErikaFlutterPlugin::ErikaOverlayWindow { + explicit ErikaOverlayWindow(HWND flutter_window) + : flutter(flutter_window), + host(RootHostWindow(flutter_window)), + scale(ScaleForWindow(host)) { + RegisterWindowClass(); + hwnd = CreateWindowExW( + WS_EX_NOACTIVATE | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW, + kOverlayWindowClassName, L"Erika Video Surface", + WS_POPUP | WS_CLIPSIBLINGS | WS_CLIPCHILDREN, 0, 0, 1, 1, + nullptr, nullptr, GetModuleHandleW(nullptr), this); + if (hwnd == nullptr) { + throw PluginError("Unable to create Windows video overlay HWND: " + + LastErrorMessage()); + } + ShowWindow(hwnd, SW_HIDE); + } + + ~ErikaOverlayWindow() { + ShutdownComposition(); + if (hwnd != nullptr) { + DestroyWindow(hwnd); + hwnd = nullptr; + } + } + + void ConfigureComposition(std::string requested_blend_mode, + double requested_opacity) { + if (requested_blend_mode.empty()) { + requested_blend_mode = "srcOver"; + } + if (requested_blend_mode != "srcOver" && + requested_blend_mode != "overlay") { + throw PluginError("Windows DirectComposition supports only srcOver and " + "overlay blend modes."); + } + blend_mode = std::move(requested_blend_mode); + opacity = std::clamp(requested_opacity, 0.0, 1.0); + if (composition_mode && compositor) { + ApplyCompositionState(); + } + } + + void SetCompositionMode(bool enabled) { + if (composition_mode == enabled) { + if (enabled && compositor) { + ApplyCompositionState(); + } + return; + } + composition_mode = enabled; + if (enabled) { + ShowWindow(hwnd, SW_HIDE); + if (compositor) { + ApplyCompositionState(); + } + return; + } + ClearCompositionContent(); + } + + void SetCompositionContent(void* raw_content) { + if (raw_content == nullptr) { + throw PluginError("Erika returned a null DirectComposition swap chain."); + } + Microsoft::WRL::ComPtr next_content; + next_content.Attach(static_cast(raw_content)); + Microsoft::WRL::ComPtr swapchain; + CheckHResult(next_content.As(&swapchain), + "QueryInterface(IDXGISwapChain)"); + EnsureComposition(); + if (composition_content.Get() == next_content.Get()) { + return; + } + + winrt::Windows::UI::Composition::ICompositionSurface next_surface{ + nullptr}; + auto interop = compositor.as< + ABI::Windows::UI::Composition::ICompositorInterop>(); + CheckHResult( + interop->CreateCompositionSurfaceForSwapChain( + swapchain.Get(), + reinterpret_cast< + ABI::Windows::UI::Composition::ICompositionSurface**>( + winrt::put_abi(next_surface))), + "ICompositorInterop::CreateCompositionSurfaceForSwapChain"); + + composition_surface = std::move(next_surface); + surface_brush.Surface(composition_surface); + composition_content = std::move(next_content); + ApplyCompositionState(); + } + + void ClearCompositionContent() { + if (!compositor) { + composition_content.Reset(); + return; + } + composition_surface = nullptr; + surface_brush.Surface(composition_surface); + composition_content.Reset(); + composition_target.Root( + winrt::Windows::UI::Composition::Visual{nullptr}); + } + + void SetFrame(double x, + double y, + double width, + double height, + bool is_visible, + std::optional generation, + const std::optional& debug_label) { + if (!is_visible && generation && active_generation != 0 && + *generation != active_generation) { + return; + } + if (generation) { + active_generation = *generation; + } + logical_x = x; + logical_y = y; + logical_width = width; + logical_height = height; + visible = is_visible && width > 0.0 && height > 0.0; + host = RootHostWindow(flutter); + scale = ScaleForWindow(composition_mode ? flutter : host); + + if (debug_label) { + SetWindowTextW(hwnd, Utf8ToWide(*debug_label).c_str()); + } + + if (composition_mode) { + ShowWindow(hwnd, SW_HIDE); + if (compositor) { + ApplyCompositionState(); + } + return; + } + + if (!visible) { + ShowWindow(hwnd, SW_HIDE); + return; + } + + POINT client_origin{0, 0}; + if (host != nullptr) { + ClientToScreen(host, &client_origin); + } + const int px = client_origin.x + LogicalToPhysical(host, logical_x); + const int py = client_origin.y + LogicalToPhysical(host, logical_y); + const int pw = std::max(1, LogicalToPhysical(host, logical_width)); + const int ph = std::max(1, LogicalToPhysical(host, logical_height)); + const HWND insert_after = host != nullptr ? host : HWND_BOTTOM; + SetWindowPos(hwnd, insert_after, px, py, pw, ph, + SWP_NOACTIVATE | SWP_SHOWWINDOW); + } + + uint32_t PixelWidth() const { + return static_cast( + std::max( + 1, LogicalToPhysical(composition_mode ? flutter : host, + logical_width))); + } + + uint32_t PixelHeight() const { + return static_cast( + std::max( + 1, LogicalToPhysical(composition_mode ? flutter : host, + logical_height))); + } + + void RefreshScaleAndReposition() { + SetFrame(logical_x, logical_y, logical_width, logical_height, visible, + active_generation, std::nullopt); + } + + static void RegisterWindowClass() { + static bool registered = false; + if (registered) { + return; + } + WNDCLASSEXW window_class{}; + window_class.cbSize = sizeof(window_class); + window_class.style = CS_HREDRAW | CS_VREDRAW | CS_OWNDC; + window_class.lpfnWndProc = &ErikaOverlayWindow::WndProc; + window_class.hInstance = GetModuleHandleW(nullptr); + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.hbrBackground = static_cast(GetStockObject(BLACK_BRUSH)); + window_class.lpszClassName = kOverlayWindowClassName; + if (!RegisterClassExW(&window_class) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { + throw PluginError("Unable to register Windows video overlay class: " + + LastErrorMessage()); + } + registered = true; + } + + static LRESULT CALLBACK WndProc(HWND hwnd, + UINT message, + WPARAM wparam, + LPARAM lparam) { + if (message == WM_NCCREATE) { + auto* create = reinterpret_cast(lparam); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, + reinterpret_cast(create->lpCreateParams)); + } + + switch (message) { + case WM_ERASEBKGND: + return 1; + case WM_NCHITTEST: + return HTTRANSPARENT; + default: + return DefWindowProcW(hwnd, message, wparam, lparam); + } + } + + void EnsureDispatcherQueue() { + using winrt::Windows::System::DispatcherQueue; + using winrt::Windows::System::DispatcherQueueController; + + // A DispatcherQueue belongs to the UI thread, not to one overlay target. + // Keep the controller alive if the Flutter HWND changes and the overlay + // object is replaced; shutting down the queue underneath the new + // Compositor can otherwise invalidate its target asynchronously. + static thread_local DispatcherQueueController queue_controller{nullptr}; + if (DispatcherQueue::GetForCurrentThread()) { + return; + } + + DispatcherQueueOptions options{ + sizeof(DispatcherQueueOptions), + DQTYPE_THREAD_CURRENT, + DQTAT_COM_STA, + }; + DispatcherQueueController next_controller{nullptr}; + CheckHResult( + CreateDispatcherQueueController( + options, + reinterpret_cast( + winrt::put_abi(next_controller))), + "CreateDispatcherQueueController"); + queue_controller = std::move(next_controller); + } + + void EnsureComposition() { + if (flutter == nullptr) { + throw PluginError("No Flutter HWND is available for Windows Composition."); + } + if (compositor) { + return; + } + + EnsureDispatcherQueue(); + + using winrt::Windows::Graphics::Effects::IGraphicsEffect; + using winrt::Windows::Graphics::Effects::IGraphicsEffectSource; + using winrt::Windows::UI::Composition::CompositionEffectBrush; + using winrt::Windows::UI::Composition::CompositionEffectSourceParameter; + using winrt::Windows::UI::Composition::CompositionStretch; + using winrt::Windows::UI::Composition::CompositionSurfaceBrush; + using winrt::Windows::UI::Composition::Compositor; + using winrt::Windows::UI::Composition::SpriteVisual; + using winrt::Windows::UI::Composition::Desktop::DesktopWindowTarget; + + Compositor next_compositor; + DesktopWindowTarget next_target{nullptr}; + auto desktop_interop = next_compositor.as< + ABI::Windows::UI::Composition::Desktop::ICompositorDesktopInterop>(); + CheckHResult( + desktop_interop->CreateDesktopWindowTarget( + flutter, TRUE, + reinterpret_cast( + winrt::put_abi(next_target))), + "ICompositorDesktopInterop::CreateDesktopWindowTarget"); + + CompositionSurfaceBrush next_surface_brush = + next_compositor.CreateSurfaceBrush(); + next_surface_brush.Stretch(CompositionStretch::Fill); + + CompositionEffectSourceParameter background_parameter(L"Backdrop"); + CompositionEffectSourceParameter foreground_parameter(L"Video"); + auto background_source = + background_parameter.as(); + auto foreground_source = + foreground_parameter.as(); + + Microsoft::WRL::ComPtr blend_descriptor; + CheckHResult( + Microsoft::WRL::MakeAndInitialize( + blend_descriptor.GetAddressOf(), + reinterpret_cast( + winrt::get_abi(background_source)), + reinterpret_cast( + winrt::get_abi(foreground_source)), + D2D1_BLEND_MODE_OVERLAY), + "Create Windows Composition overlay effect description"); + + Microsoft::WRL::ComPtr< + ABI::Windows::Graphics::Effects::IGraphicsEffect> + abi_blend_effect; + CheckHResult(blend_descriptor.As(&abi_blend_effect), + "QueryInterface(IGraphicsEffect)"); + IGraphicsEffect blend_effect{nullptr}; + winrt::copy_from_abi(blend_effect, abi_blend_effect.Get()); + + CompositionEffectBrush next_overlay_brush = + next_compositor.CreateEffectFactory(blend_effect).CreateBrush(); + next_overlay_brush.SetSourceParameter( + L"Backdrop", next_compositor.CreateBackdropBrush()); + next_overlay_brush.SetSourceParameter(L"Video", next_surface_brush); + + SpriteVisual next_content_visual = next_compositor.CreateSpriteVisual(); + next_content_visual.Brush(next_surface_brush); + + compositor = std::move(next_compositor); + composition_target = std::move(next_target); + surface_brush = std::move(next_surface_brush); + overlay_brush = std::move(next_overlay_brush); + content_visual = std::move(next_content_visual); + } + + void ApplyCompositionState() { + if (!compositor) { + return; + } + if (blend_mode == "overlay") { + content_visual.Brush(overlay_brush); + } else { + content_visual.Brush(surface_brush); + } + content_visual.Opacity(static_cast(opacity)); + content_visual.Offset( + {static_cast(LogicalToPhysical(flutter, logical_x)), + static_cast(LogicalToPhysical(flutter, logical_y)), 0.0f}); + content_visual.Size({static_cast(PixelWidth()), + static_cast(PixelHeight())}); + if (composition_mode && visible && composition_content) { + composition_target.Root(content_visual); + } else { + composition_target.Root( + winrt::Windows::UI::Composition::Visual{nullptr}); + } + } + + void ShutdownComposition() { + if (composition_target) { + composition_target.Root( + winrt::Windows::UI::Composition::Visual{nullptr}); + } + composition_content.Reset(); + composition_surface = nullptr; + content_visual = nullptr; + overlay_brush = nullptr; + surface_brush = nullptr; + composition_target = nullptr; + compositor = nullptr; + } + + HWND flutter = nullptr; + HWND host = nullptr; + HWND hwnd = nullptr; + double scale = 1.0; + double logical_x = 0.0; + double logical_y = 0.0; + double logical_width = 1.0; + double logical_height = 1.0; + bool visible = false; + bool composition_mode = false; + std::string blend_mode = "srcOver"; + double opacity = 1.0; + int64_t active_generation = 0; + int64_t owner_player_id = 0; + winrt::Windows::UI::Composition::Compositor compositor{nullptr}; + winrt::Windows::UI::Composition::Desktop::DesktopWindowTarget + composition_target{nullptr}; + winrt::Windows::UI::Composition::SpriteVisual content_visual{nullptr}; + winrt::Windows::UI::Composition::CompositionSurfaceBrush surface_brush{ + nullptr}; + winrt::Windows::UI::Composition::CompositionEffectBrush overlay_brush{ + nullptr}; + winrt::Windows::UI::Composition::ICompositionSurface composition_surface{ + nullptr}; + Microsoft::WRL::ComPtr composition_content; +}; + +struct ErikaFlutterPlugin::ErikaFlutterTexture { + struct Snapshot { + Snapshot(Microsoft::WRL::ComPtr source, + HANDLE shared_handle, + uint32_t pixel_width, + uint32_t pixel_height) + : texture(std::move(source)) { + descriptor.struct_size = sizeof(FlutterDesktopGpuSurfaceDescriptor); + descriptor.handle = shared_handle; + descriptor.width = pixel_width; + descriptor.height = pixel_height; + descriptor.visible_width = pixel_width; + descriptor.visible_height = pixel_height; + descriptor.format = kFlutterDesktopPixelFormatBGRA8888; + descriptor.release_callback = [](void* context) { + static_cast(context)->opened.store( + true, std::memory_order_release); + }; + descriptor.release_context = this; + } + + Microsoft::WRL::ComPtr texture; + FlutterDesktopGpuSurfaceDescriptor descriptor{}; + std::atomic opened{false}; + uint64_t retire_after_frame = 0; + }; + + ErikaFlutterTexture(flutter::PluginRegistrarWindows* plugin_registrar, + uint32_t pixel_width, + uint32_t pixel_height, + double backing_scale) + : registrar(plugin_registrar), + width(pixel_width), + height(pixel_height), + scale(backing_scale), + texture_variant(std::make_unique( + flutter::GpuSurfaceTexture( + kFlutterDesktopGpuSurfaceTypeDxgiSharedHandle, + [this](size_t requested_width, size_t requested_height) { + const auto* snapshot = + current.load(std::memory_order_acquire); + return snapshot == nullptr ? nullptr : &snapshot->descriptor; + }))) { + texture_id = + registrar->texture_registrar()->RegisterTexture(texture_variant.get()); + if (texture_id < 0) { + throw PluginError("Flutter failed to register the Erika GPU texture."); + } + } + + bool UpdateNativeTexture(void* raw_texture) { + if (raw_texture == nullptr) { + return false; + } + + Microsoft::WRL::ComPtr unknown; + unknown.Attach(static_cast(raw_texture)); + Microsoft::WRL::ComPtr texture; + CheckHResult(unknown.As(&texture), + "QueryInterface(ID3D11Texture2D)"); + + auto* previous = current.load(std::memory_order_acquire); + if (previous != nullptr && previous->texture.Get() == texture.Get()) { + return false; + } + + Microsoft::WRL::ComPtr resource; + CheckHResult(texture.As(&resource), "QueryInterface(IDXGIResource)"); + HANDLE shared_handle = nullptr; + CheckHResult(resource->GetSharedHandle(&shared_handle), + "IDXGIResource::GetSharedHandle"); + if (shared_handle == nullptr) { + throw PluginError("Erika returned a D3D11 texture without a shared handle."); + } + + if (previous != nullptr) { + previous->retire_after_frame = frame_sequence + 4; + } + auto snapshot = + std::make_unique(std::move(texture), shared_handle, width, height); + auto* published = snapshot.get(); + snapshots.push_back(std::move(snapshot)); + current.store(published, std::memory_order_release); + return true; + } + + void Resize(uint32_t pixel_width, + uint32_t pixel_height, + double backing_scale) { + width = pixel_width; + height = pixel_height; + scale = backing_scale; + } + + void MarkFrameAvailable() { + registrar->texture_registrar()->MarkTextureFrameAvailable(texture_id); + ++frame_sequence; + const auto* active = current.load(std::memory_order_acquire); + snapshots.erase( + std::remove_if( + snapshots.begin(), snapshots.end(), + [active, this](const std::unique_ptr& snapshot) { + return snapshot.get() != active && + snapshot->opened.load(std::memory_order_acquire) && + frame_sequence >= snapshot->retire_after_frame; + }), + snapshots.end()); + } + + flutter::PluginRegistrarWindows* registrar = nullptr; + uint32_t width = 1; + uint32_t height = 1; + double scale = 1.0; + int64_t texture_id = -1; + int64_t owner_player_id = 0; + std::unique_ptr texture_variant; + std::vector> snapshots; + std::atomic current{nullptr}; + uint64_t frame_sequence = 0; +}; + +struct ErikaFlutterPlugin::PlayerHost { + PlayerHost(int64_t player_id, + std::shared_ptr native_library, + ErikaPresenterConfig config) + : id(player_id), + library(std::move(native_library)), + video_alpha_mode(config.video_alpha_mode) { + smtc_state.player_id = player_id; + handle = library->CreatePresenter(config); + if (handle == nullptr) { + std::string message = "erika_presenter_create returned null"; + const auto detail = library->TakeLastError(); + if (!detail.empty()) { + message += ": " + detail; + } + DebugLog(message); + throw PluginError(message); + } + RefreshDanmakuConfigSnapshot(); + } + + ~PlayerHost() { + if (handle != nullptr) { + library->detach_surface(handle); + library->destroy(handle); + handle = nullptr; + } + } + + void CheckNative(ErikaStatus status, const char* operation) const { + Check(status, operation, + status == ErikaStatus_Ok ? std::string{} + : library->TakeLastError()); + } + + void Open(const std::string& uri, const EncodableMap& args) { + const auto* raw_headers = FindArg(args, "httpHeaders"); + const EncodableMap* headers = nullptr; + if (raw_headers != nullptr && + !std::holds_alternative(*raw_headers)) { + headers = std::get_if(raw_headers); + if (headers == nullptr) { + throw PluginError("httpHeaders must be a map of string names to string values."); + } + } + if (headers != nullptr && !headers->empty()) { + // Never fall back to the headerless entry point here: silently dropping + // the headers turns an authenticated stream into an opaque 403. + if (library->open_with_headers == nullptr) { + throw PluginError( + "The loaded Erika native library does not export " + "erika_presenter_open_with_headers, so httpHeaders cannot be applied. " + "Update the bundled erika_capi.dll (a prebuilt from 0.1.3 or earlier " + "predates HTTP header support)."); + } + std::vector names; + std::vector values; + std::vector native_headers; + names.reserve(headers->size()); + values.reserve(headers->size()); + native_headers.reserve(headers->size()); + for (const auto& entry : *headers) { + const auto name = StringValue(&entry.first); + const auto value = StringValue(&entry.second); + if (!name || !value) { + throw PluginError("httpHeaders must contain string names and values."); + } + names.push_back(*name); + values.push_back(*value); + } + for (size_t index = 0; index < names.size(); ++index) { + native_headers.push_back({names[index].c_str(), values[index].c_str()}); + } + CheckNative(library->open_with_headers( + handle, uri.c_str(), native_headers.data(), + native_headers.size()), + "open"); + return; + } + CheckNative(library->open(handle, uri.c_str()), "open"); + } + + void SetMediaMetadata(const EncodableMap& metadata) { + const auto title = StringValue(FindArg(metadata, "title")); + if (!title || title->empty()) { + throw PluginError("metadata.title is required."); + } + smtc_state.title = *title; + smtc_state.artist = StringValue(FindArg(metadata, "artist")).value_or(""); + smtc_state.album = StringValue(FindArg(metadata, "album")).value_or(""); + smtc_state.artwork.clear(); + if (const auto* value = FindArg(metadata, "artwork"); value != nullptr) { + if (const auto* bytes = std::get_if>(value)) { + smtc_state.artwork = *bytes; + } else if (!std::holds_alternative(*value)) { + throw PluginError("metadata.artwork must contain image bytes."); + } + } + ++smtc_state.metadata_revision; + } + + void ClearMediaMetadata() { + smtc_state.title.clear(); + smtc_state.artist.clear(); + smtc_state.album.clear(); + smtc_state.artwork.clear(); + ++smtc_state.metadata_revision; + } + + void PrepareForOpen() { + smtc_state.playing = false; + smtc_state.stopped = false; + smtc_state.duration_micros = 0; + smtc_state.position_micros = 0; + } + + void SetSystemMediaNavigation(bool previous_enabled, bool next_enabled) { + smtc_state.previous_enabled = previous_enabled; + smtc_state.next_enabled = next_enabled; + } + + void Play() { + CheckNative(library->play(handle), "play"); + smtc_state.playing = true; + smtc_state.stopped = false; + } + void Pause() { + CheckNative(library->pause(handle), "pause"); + smtc_state.playing = false; + smtc_state.stopped = false; + } + void Stop() { + CheckNative(library->stop(handle), "stop"); + smtc_state.playing = false; + smtc_state.stopped = true; + smtc_state.position_micros = 0; + } + void Close() { + CheckNative(library->close(handle), "close"); + smtc_state.playing = false; + smtc_state.stopped = true; + smtc_state.duration_micros = 0; + smtc_state.position_micros = 0; + } + + void Seek(uint64_t position_micros) { + CheckNative(library->seek(handle, position_micros), "seek"); + smtc_state.position_micros = position_micros; + } + + void SetPlaybackRate(double rate) { + if (library->set_playback_rate == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_playback_rate"); + } + CheckNative(library->set_playback_rate(handle, rate), "set_playback_rate"); + smtc_state.playback_rate = rate; + } + + void SetVolume(double volume) { + if (library->set_volume == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_volume"); + } + const double clamped = std::isfinite(volume) ? std::clamp(volume, 0.0, 1.0) : 1.0; + CheckNative(library->set_volume(handle, clamped), "set_volume"); + } + + void SetUpscaler(int32_t mode) { + if (library->set_upscaler == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_upscaler"); + } + CheckNative(library->set_upscaler(handle, mode), "set_upscaler"); + } + + void SetSubtitleScale(double scale) { + if (library->set_subtitle_scale == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_subtitle_scale"); + } + const double clamped = std::isfinite(scale) ? std::clamp(scale, 0.25, 4.0) : 1.0; + Check(library->set_subtitle_scale(handle, clamped), "set_subtitle_scale"); + } + + void SetSubtitleFont(const std::optional& family, + const std::optional& file_path) { + if (library->set_subtitle_font == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_subtitle_font"); + } + Check(library->set_subtitle_font(handle, family ? family->c_str() : nullptr, + file_path ? file_path->c_str() : nullptr), + "set_subtitle_font"); + } + + void SetSubtitleStyle(const std::optional& font_family, + const std::optional& font_file_path, + uint32_t primary_rgba, uint32_t outline_rgba, + double font_size, double outline_width, bool bold, + bool italic, bool underline, bool strike_out, + double spacing, double scale_x_percent, + double scale_y_percent, int32_t border_style, + double shadow_depth, double blur, int32_t alignment, + int32_t margin_left, int32_t margin_right, + int32_t margin_vertical, uint32_t override_mask) { + if (library->set_subtitle_style == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_subtitle_style"); + } + ErikaSubtitleStyle style{}; + style.font_family = font_family ? font_family->c_str() : nullptr; + style.font_file_path = font_file_path ? font_file_path->c_str() : nullptr; + style.primary_color_rgba = primary_rgba; + style.outline_color_rgba = outline_rgba; + style.font_size = font_size; + style.outline_width = outline_width; + style.bold = bold; + style.italic = italic; + style.underline = underline; + style.strike_out = strike_out; + style.spacing = spacing; + style.scale_x_percent = scale_x_percent; + style.scale_y_percent = scale_y_percent; + style.border_style = border_style; + style.shadow_depth = shadow_depth; + style.blur = blur; + style.alignment = alignment; + style.margin_left = margin_left; + style.margin_right = margin_right; + style.margin_vertical = margin_vertical; + style.override_mask = override_mask; + Check(library->set_subtitle_style(handle, style), + "set_subtitle_style"); + } + + uint64_t RegisterSubtitleMemoryFont(const std::vector& data) { + if (library->register_subtitle_memory_font == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_register_subtitle_memory_font"); + } + uint64_t font_id = 0; + Check(library->register_subtitle_memory_font(handle, data.data(), data.size(), + &font_id), + "register_subtitle_memory_font"); + return font_id; + } + + void SelectSubtitleMemoryFonts(const EncodableList& values) { + if (library->select_subtitle_memory_fonts == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_select_subtitle_memory_fonts"); + } + std::vector ids; + ids.reserve(values.size()); + for (const auto& value : values) { + const auto font_id = Int64Value(&value); + if (!font_id || *font_id <= 0) { + throw PluginError("fontIds must contain positive integers."); + } + ids.push_back(static_cast(*font_id)); + } + Check(library->select_subtitle_memory_fonts(handle, ids.data(), ids.size()), + "select_subtitle_memory_fonts"); + } + + void ClearSubtitleMemoryFonts() { + if (library->clear_subtitle_memory_fonts == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_clear_subtitle_memory_fonts"); + } + Check(library->clear_subtitle_memory_fonts(handle), + "clear_subtitle_memory_fonts"); + } + + EncodableValue GetSubtitleMemoryFontStatus() { + if (library->get_subtitle_memory_font_status == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_get_subtitle_memory_font_status"); + } + ErikaSubtitleMemoryFontStatus status{}; + Check(library->get_subtitle_memory_font_status(handle, &status), + "get_subtitle_memory_font_status"); + auto result = SubtitleMemoryFontStatusToMap(status); + if (library->free_subtitle_memory_font_status != nullptr) { + library->free_subtitle_memory_font_status(&status); + } + return result; + } + + EncodableValue GetUpscalerStatus() { + if (library->get_upscaler_status == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_get_upscaler_status"); + } + ErikaUpscalerStatus status{}; + Check(library->get_upscaler_status(handle, &status), "get_upscaler_status"); + return UpscalerStatusToMap(status); + } + + EncodableValue GetOutputStatus() { + if (library->get_output_status == nullptr) { + throw PluginError( + "Missing Erika C ABI symbol: erika_presenter_get_output_status"); + } + ErikaOutputStatus status{}; + const ErikaStatus result = library->get_output_status(handle, &status); + if (result != ErikaStatus_Ok) { + Check(result, "get_output_status", library->TakeLastError()); + } + return OutputStatusToMap(status); + } + + EncodableValue GetResourceStatus() { + if (library->get_resource_status == nullptr) { + throw PluginError( + "Missing Erika C ABI symbol: erika_presenter_get_resource_status"); + } + ErikaPresenterResourceStatus status{}; + const ErikaStatus result = library->get_resource_status(handle, &status); + if (result != ErikaStatus_Ok) { + Check(result, "get_resource_status", library->TakeLastError()); + } + return ResourceStatusToMap(status); + } + + EncodableValue GetPresenterStats() const { + return PresenterStatsToMap(latest_presenter_stats); + } + + int64_t AddExternalSubtitle(const std::string& uri) { + int64_t track_id = 0; + Check(library->add_external_subtitle(handle, uri.c_str(), &track_id), + "add_external_subtitle"); + return track_id; + } + + void RemoveSubtitleTrack(int64_t track_id) { + Check(library->remove_subtitle_track(handle, track_id), + "remove_subtitle_track"); + } + + void SelectAudioTrack(int64_t track_id) { + Check(library->select_audio_track(handle, track_id), "select_audio_track"); + } + + void SelectSubtitleTrack(int64_t track_id) { + Check(library->select_subtitle_track(handle, track_id), + "select_subtitle_track"); + } + + void LoadDanmakuFile(const std::string& uri) { + if (library->load_danmaku_file == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_load_danmaku_file"); + } + Check(library->load_danmaku_file(handle, uri.c_str()), "load_danmaku_file"); + } + + void LoadDanmakuJson(const std::string& json) { + if (library->load_danmaku_json == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_load_danmaku_json"); + } + Check(library->load_danmaku_json(handle, json.c_str()), "load_danmaku_json"); + } + + uint64_t AddDanmakuTrackFile(const std::string& uri, + const std::optional& name, + int64_t offset_micros) { + if (library->add_danmaku_track_file == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_add_danmaku_track_file"); + } + uint64_t track_id = 0; + Check(library->add_danmaku_track_file( + handle, uri.c_str(), name ? name->c_str() : nullptr, + offset_micros, &track_id), + "add_danmaku_track_file"); + return track_id; + } + + uint64_t AddDanmakuTrackJson(const std::string& json, + const std::optional& name, + int64_t offset_micros) { + if (library->add_danmaku_track_json == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_add_danmaku_track_json"); + } + uint64_t track_id = 0; + Check(library->add_danmaku_track_json( + handle, json.c_str(), name ? name->c_str() : nullptr, + offset_micros, &track_id), + "add_danmaku_track_json"); + return track_id; + } + + void RemoveDanmakuTrack(uint64_t track_id) { + if (library->remove_danmaku_track == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_remove_danmaku_track"); + } + Check(library->remove_danmaku_track(handle, track_id), + "remove_danmaku_track"); + } + + void SetDanmakuTrackEnabled(uint64_t track_id, bool enabled) { + if (library->set_danmaku_track_enabled == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_danmaku_track_enabled"); + } + Check(library->set_danmaku_track_enabled(handle, track_id, enabled), + "set_danmaku_track_enabled"); + } + + void SetDanmakuTrackOffset(uint64_t track_id, int64_t offset_micros) { + if (library->set_danmaku_track_offset == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_danmaku_track_offset"); + } + Check(library->set_danmaku_track_offset(handle, track_id, offset_micros), + "set_danmaku_track_offset"); + } + + void SetDanmakuGlobalOffset(int64_t offset_micros) { + if (library->set_danmaku_global_offset == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_danmaku_global_offset"); + } + Check(library->set_danmaku_global_offset(handle, offset_micros), + "set_danmaku_global_offset"); + } + + EncodableValue DanmakuTracks() { + if (library->danmaku_tracks == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_danmaku_tracks"); + } + uintptr_t len = 0; + Check(library->danmaku_tracks(handle, nullptr, 0, &len), "danmaku_tracks"); + std::vector tracks(len); + if (len > 0) { + Check(library->danmaku_tracks(handle, tracks.data(), len, &len), + "danmaku_tracks"); + } + EncodableList result; + result.reserve(tracks.size()); + for (auto& track : tracks) { + result.push_back(EncodableValue(EncodableMap{ + {EncodableValue("id"), EncodableValue(static_cast(track.id))}, + {EncodableValue("enabled"), EncodableValue(track.enabled)}, + {EncodableValue("offsetMicros"), + EncodableValue(static_cast(track.offset_micros))}, + {EncodableValue("itemCount"), + EncodableValue(static_cast(track.item_count))}, + {EncodableValue("name"), NullableString(track.name)}, + {EncodableValue("source"), NullableString(track.source)}, + })); + if (library->free_danmaku_track_info != nullptr) { + library->free_danmaku_track_info(&track); + } + } + return EncodableValue(result); + } + + void ClearDanmaku() { + if (library->clear_danmaku == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_clear_danmaku"); + } + Check(library->clear_danmaku(handle), "clear_danmaku"); + } + + void SetDanmakuEnabled(bool enabled) { + if (library->set_danmaku_enabled == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_danmaku_enabled"); + } + Check(library->set_danmaku_enabled(handle, enabled), + "set_danmaku_enabled"); + current_danmaku_config.enabled = enabled; + } + + void SetDebugHudEnabled(bool enabled) { + if (library->set_debug_hud_enabled == nullptr) { + throw PluginError( + "Missing Erika C ABI symbol: erika_presenter_set_debug_hud_enabled"); + } + Check(library->set_debug_hud_enabled(handle, enabled), + "set_debug_hud_enabled"); + } + + void SetDanmakuConfig(const ErikaDanmakuConfig& config) { + if (library->set_danmaku_config == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_danmaku_config_ptr"); + } + ErikaDanmakuConfig copy = config; + Check(library->set_danmaku_config(handle, ©), "set_danmaku_config"); + current_danmaku_config = copy; + } + + void SetDanmakuFont(const std::optional& family, + const std::optional& file_path) { + if (library->set_danmaku_font == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_danmaku_font"); + } + Check(library->set_danmaku_font(handle, family ? family->c_str() : nullptr, + file_path ? file_path->c_str() : nullptr), + "set_danmaku_font"); + } + + void SetDanmakuBlockWordsJson(const std::string& json) { + if (library->set_danmaku_block_words_json == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_set_danmaku_block_words_json"); + } + Check(library->set_danmaku_block_words_json(handle, json.c_str()), + "set_danmaku_block_words_json"); + } + + EncodableValue Tracks() { + uintptr_t len = 0; + Check(library->tracks(handle, nullptr, 0, &len), "tracks"); + std::vector tracks(len); + if (len > 0) { + Check(library->tracks(handle, tracks.data(), len, &len), "tracks"); + } + EncodableList result; + result.reserve(tracks.size()); + for (auto& track : tracks) { + result.push_back(EncodableValue(EncodableMap{ + {EncodableValue("id"), EncodableValue(static_cast(track.id))}, + {EncodableValue("kind"), + EncodableValue(static_cast(track.kind))}, + {EncodableValue("source"), + EncodableValue(static_cast(track.source))}, + {EncodableValue("selected"), EncodableValue(track.selected)}, + {EncodableValue("canRemove"), EncodableValue(track.can_remove)}, + {EncodableValue("title"), NullableString(track.title)}, + {EncodableValue("language"), NullableString(track.language)}, + {EncodableValue("codec"), NullableString(track.codec)}, + {EncodableValue("width"), + EncodableValue(static_cast(track.width))}, + {EncodableValue("height"), + EncodableValue(static_cast(track.height))}, + {EncodableValue("sampleRate"), + EncodableValue(static_cast(track.sample_rate))}, + {EncodableValue("channels"), + EncodableValue(static_cast(track.channels))}, + {EncodableValue("pixelFormat"), NullableString(track.pixel_format)}, + {EncodableValue("sampleFormat"), NullableString(track.sample_format)}, + {EncodableValue("profile"), NullableString(track.profile)}, + {EncodableValue("level"), + EncodableValue(static_cast(track.level))}, + {EncodableValue("bitRate"), + EncodableValue(static_cast(track.bit_rate))}, + {EncodableValue("frameRateNumerator"), + EncodableValue(static_cast(track.frame_rate_numerator))}, + {EncodableValue("frameRateDenominator"), + EncodableValue(static_cast(track.frame_rate_denominator))}, + })); + library->free_track_info(&track); + } + return EncodableValue(result); + } + + EncodableValue TrackSelection() { + ErikaTrackSelection selection{}; + Check(library->track_selection(handle, &selection), "track_selection"); + return TrackSelectionToMap(selection); + } + + void AttachOverlay(ErikaOverlayWindow& overlay) { + if (surface_attached) { + Detach(std::nullopt); + } + const uint32_t width = overlay.PixelWidth(); + const uint32_t height = overlay.PixelHeight(); + const double scale = overlay.scale; + const uint64_t hinstance = reinterpret_cast(GetModuleHandleW(nullptr)); + composition_surface = RequiresComposition(overlay); + try { + overlay.SetCompositionMode(composition_surface); + if (composition_surface) { + overlay.ClearCompositionContent(); + ErikaSurfaceOutputCapabilities capabilities{}; + capabilities.direct_composition = true; + capabilities.fallback_reason = ErikaOutputFallbackReason_None; + CheckNative(library->attach_wgpu_surface_with_output_capabilities( + handle, ErikaWgpuSurfaceKind_WindowsHwnd, + reinterpret_cast(overlay.flutter), hinstance, + width, height, scale, capabilities), + "attach_wgpu_surface_with_output_capabilities"); + attached_hwnd = overlay.flutter; + attached_overlay = &overlay; + RefreshCompositionContent(); + } else { + CheckNative(library->attach_windows_hwnd( + handle, reinterpret_cast(overlay.hwnd), + hinstance, width, height, scale), + "attach_windows_hwnd"); + attached_hwnd = overlay.hwnd; + attached_overlay = nullptr; + } + } catch (...) { + if (composition_surface) { + try { + overlay.ClearCompositionContent(); + } catch (const std::exception& error) { + DebugLog(std::string("DirectComposition attach rollback failed: ") + + error.what()); + } + } + library->detach_surface(handle); + attached_hwnd = nullptr; + attached_overlay = nullptr; + attached_view_id = 0; + surface_attached = false; + composition_surface = false; + attached_surface_width = 0; + attached_surface_height = 0; + attached_surface_scale = 0.0; + if (overlay.owner_player_id == id) { + overlay.owner_player_id = 0; + } + throw; + } + attached_view_id = kWindowOverlayViewId; + surface_attached = true; + attached_surface_width = width; + attached_surface_height = height; + attached_surface_scale = scale; + start_time_seconds = NowSeconds(); + } + + void AttachFlutterTexture(ErikaFlutterTexture& texture) { + if (texture.owner_player_id != 0 && texture.owner_player_id != id) { + throw PluginError("Erika Flutter texture " + + std::to_string(texture.texture_id) + + " is already attached to another player."); + } + if (surface_attached) { + Detach(std::nullopt); + } + const auto status = library->attach_flutter_texture( + handle, ErikaFlutterTextureKind_WindowsTextureRegistrar, + texture.texture_id, texture.width, texture.height, texture.scale); + Check(status, "attach_flutter_texture", + status == ErikaStatus_Ok ? std::string{} : library->TakeLastError()); + texture.owner_player_id = id; + attached_texture = &texture; + attached_view_id = texture.texture_id; + surface_attached = true; + composition_surface = false; + attached_surface_width = texture.width; + attached_surface_height = texture.height; + attached_surface_scale = texture.scale; + start_time_seconds = NowSeconds(); + } + + void ResizeFlutterTexture(ErikaFlutterTexture& texture, + uint32_t width, + uint32_t height, + double scale) { + texture.Resize(width, height, scale); + if (!surface_attached || attached_texture != &texture) { + return; + } + if (width == attached_surface_width && height == attached_surface_height && + std::abs(scale - attached_surface_scale) < 0.0001) { + return; + } + const auto status = library->resize_surface(handle, width, height, scale); + Check(status, "resize_surface", + status == ErikaStatus_Ok ? std::string{} : library->TakeLastError()); + attached_surface_width = width; + attached_surface_height = height; + attached_surface_scale = scale; + } + + void RefreshFlutterTexture() { + if (attached_texture == nullptr) { + return; + } + void* raw_texture = nullptr; + const auto status = + library->windows_flutter_texture(handle, &raw_texture); + Check(status, "windows_flutter_texture_iunknown", + status == ErikaStatus_Ok ? std::string{} : library->TakeLastError()); + attached_texture->UpdateNativeTexture(raw_texture); + attached_texture->MarkFrameAvailable(); + } + + void ResizeOverlay(ErikaOverlayWindow& overlay) { + const HWND expected_hwnd = composition_surface ? overlay.flutter : overlay.hwnd; + if (!surface_attached || attached_hwnd != expected_hwnd) { + return; + } + const uint32_t width = overlay.PixelWidth(); + const uint32_t height = overlay.PixelHeight(); + const double scale = overlay.scale; + if (width == attached_surface_width && + height == attached_surface_height && + std::abs(scale - attached_surface_scale) < 0.0001) { + return; + } + CheckNative(library->resize_surface(handle, width, height, scale), + "resize_surface"); + attached_surface_width = width; + attached_surface_height = height; + attached_surface_scale = scale; + if (composition_surface) { + RefreshCompositionContent(); + } + } + + bool RequiresComposition(const ErikaOverlayWindow& overlay) const { + return video_alpha_mode != ErikaVideoAlphaMode_Opaque || + overlay.blend_mode == "overlay"; + } + + void RefreshCompositionContent() { + if (!composition_surface || attached_overlay == nullptr) { + return; + } + if (library->windows_composition_swapchain == nullptr) { + throw PluginError( + "The loaded erika_capi.dll does not expose the DirectComposition " + "swap chain. Update the bundled Erika runtime."); + } + void* swapchain = nullptr; + CheckNative(library->windows_composition_swapchain(handle, &swapchain), + "windows_composition_swapchain_iunknown"); + attached_overlay->SetCompositionContent(swapchain); + } + + void Detach(std::optional view_id) { + if (view_id && attached_view_id != *view_id) { + return; + } + if (composition_surface && attached_overlay != nullptr && + attached_overlay->owner_player_id == id) { + try { + attached_overlay->ClearCompositionContent(); + } catch (const std::exception& error) { + DebugLog(std::string("DirectComposition detach failed: ") + + error.what()); + } + } + attached_hwnd = nullptr; + attached_overlay = nullptr; + if (attached_texture != nullptr && attached_texture->owner_player_id == id) { + attached_texture->owner_player_id = 0; + } + attached_texture = nullptr; + composition_surface = false; + attached_view_id = 0; + surface_attached = false; + attached_surface_width = 0; + attached_surface_height = 0; + attached_surface_scale = 0.0; + library->detach_surface(handle); + } + + void RenderTick(flutter::EventSink* event_sink) { + if (surface_attached) { + ErikaPresenterStats stats{}; + const double time_seconds = NowSeconds() - start_time_seconds; + const auto status = library->render_tick(handle, time_seconds, &stats); + if (status != ErikaStatus_Ok) { + DebugLog("render_tick failed with ErikaStatus_" + StatusName(status) + + " (" + std::to_string(static_cast(status)) + "): " + + library->TakeLastError()); + } else { + latest_presenter_stats = stats; + if (attached_texture != nullptr) { + try { + RefreshFlutterTexture(); + } catch (const std::exception& error) { + DebugLog(std::string("Flutter texture publication failed: ") + + error.what()); + } + } else if (composition_surface) { + try { + RefreshCompositionContent(); + } catch (const std::exception& error) { + DebugLog(std::string("DirectComposition swap chain rebind failed: ") + + error.what()); + } + } + } + } + PollEvents(event_sink); + } + + void PollEvents(flutter::EventSink* event_sink) { + while (true) { + ErikaEvent event{}; + const auto status = library->poll_event(handle, &event); + if (status == ErikaStatus_Ok) { + if (event.kind == ErikaEventKind_StateChanged) { + smtc_state.playing = event.state == ErikaState_Playing; + smtc_state.stopped = event.state == ErikaState_Stopped || + event.state == ErikaState_Closed || + event.state == ErikaState_Idle; + } else if (event.kind == ErikaEventKind_DurationChanged) { + smtc_state.duration_micros = event.duration_micros; + } else if (event.kind == ErikaEventKind_PositionChanged) { + smtc_state.position_micros = event.position_micros; + } + if (event.kind == ErikaEventKind_Error) { + DebugLog("player " + std::to_string(id) + + " event error status=ErikaStatus_" + + StatusName(event.status) + " (" + + std::to_string(static_cast(event.status)) + "): " + + library->TakeLastError()); + } + if (event_sink != nullptr) { + event_sink->Success(EventToMap(event)); + } + continue; + } + if (status != ErikaStatus_NoEvent) { + DebugLog("poll_event failed with ErikaStatus_" + StatusName(status) + + " (" + std::to_string(static_cast(status)) + "): " + + library->TakeLastError()); + } + break; + } + } + + ErikaDanmakuConfig DanmakuConfigFromArgs(const EncodableMap& args) const { + ErikaDanmakuConfig config = current_danmaku_config; + if (auto value = BoolValue(FindArg(args, "enabled"))) { + config.enabled = *value; + } + if (auto value = DoubleValue(FindArg(args, "fontSize"))) { + config.font_size = static_cast(*value); + } + if (auto value = DoubleValue(FindArg(args, "opacity"))) { + config.opacity = static_cast(*value); + } + if (auto value = DoubleValue(FindArg(args, "displayArea"))) { + config.display_area = static_cast(*value); + } + if (auto value = DoubleValue(FindArg(args, "scrollDurationSeconds"))) { + config.scroll_duration_seconds = static_cast(*value); + } + if (auto value = DoubleValue(FindArg(args, "scrollSpeedFactor"))) { + config.scroll_speed_factor = static_cast(*value); + } + if (auto value = DoubleValue(FindArg(args, "trackGapRatio"))) { + config.track_gap_ratio = static_cast(*value); + } + if (auto value = DoubleValue(FindArg(args, "outlineWidth"))) { + config.outline_width = static_cast(*value); + } + if (auto value = DoubleValue(FindArg(args, "shadowOffsetX"))) { + config.shadow_offset_x = static_cast(*value); + } + if (auto value = DoubleValue(FindArg(args, "shadowOffsetY"))) { + config.shadow_offset_y = static_cast(*value); + } + if (auto value = BoolValue(FindArg(args, "mergeDuplicates"))) { + config.merge_duplicates = *value; + } + if (auto value = BoolValue(FindArg(args, "allowStacking"))) { + config.allow_stacking = *value; + } + if (auto value = BoolValue(FindArg(args, "allowScrollOverwrite"))) { + config.allow_scroll_overwrite = *value; + } + if (auto value = Int64Value(FindArg(args, "maxQuantity")); value && *value > 0) { + config.max_quantity = static_cast(*value); + } + if (auto value = Int64Value(FindArg(args, "maxLinesPerMode")); + value && *value > 0) { + config.max_lines_per_mode = static_cast(*value); + } + if (auto value = BoolValue(FindArg(args, "blockTop"))) { + config.block_top = *value; + } + if (auto value = BoolValue(FindArg(args, "blockBottom"))) { + config.block_bottom = *value; + } + if (auto value = BoolValue(FindArg(args, "blockScroll"))) { + config.block_scroll = *value; + } + if (auto value = Int64Value(FindArg(args, "shadowStyle"))) { + config.shadow_style = static_cast(*value); + } + return config; + } + + EncodableValue EventToMap(const ErikaEvent& event) { + EncodableMap map{ + {EncodableValue("playerId"), EncodableValue(id)}, + {EncodableValue("kind"), EncodableValue(static_cast(event.kind))}, + {EncodableValue("status"), + EncodableValue(static_cast(event.status))}, + {EncodableValue("state"), + EncodableValue(static_cast(event.state))}, + {EncodableValue("durationMicros"), + EncodableValue(static_cast(event.duration_micros))}, + {EncodableValue("positionMicros"), + EncodableValue(static_cast(event.position_micros))}, + {EncodableValue("buffering"), EncodableValue(event.buffering)}, + {EncodableValue("video"), + EncodableValue(EncodableMap{ + {EncodableValue("width"), + EncodableValue(static_cast(event.video.width))}, + {EncodableValue("height"), + EncodableValue(static_cast(event.video.height))}, + {EncodableValue("primaries"), + EncodableValue(static_cast(event.video.primaries))}, + {EncodableValue("transfer"), + EncodableValue(static_cast(event.video.transfer))}, + })}, + {EncodableValue("tracks"), + EncodableValue(EncodableMap{ + {EncodableValue("video"), + EncodableValue(static_cast(event.tracks.video))}, + {EncodableValue("audio"), + EncodableValue(static_cast(event.tracks.audio))}, + {EncodableValue("subtitle"), + EncodableValue(static_cast(event.tracks.subtitle))}, + })}, + }; + if (event.kind == ErikaEventKind_TracksChanged || + event.kind == ErikaEventKind_TrackSelectionChanged) { + try { + map[EncodableValue("trackList")] = Tracks(); + map[EncodableValue("trackSelection")] = TrackSelection(); + } catch (const std::exception& error) { + DebugLog(std::string("failed to add track details to event: ") + + error.what()); + map[EncodableValue("trackList")] = EncodableValue(EncodableList{}); + map[EncodableValue("trackSelection")] = + TrackSelectionToMap(ErikaTrackSelection{-1, -1, -1}); + } + } + return EncodableValue(map); + } + + void RefreshDanmakuConfigSnapshot() { + current_danmaku_config = DefaultDanmakuConfig(); + if (library->get_danmaku_config == nullptr) { + return; + } + ErikaDanmakuConfig config{}; + if (library->get_danmaku_config(handle, &config) == ErikaStatus_Ok) { + current_danmaku_config = config; + } + } + + int64_t id = 0; + ErikaSmtcState smtc_state{}; + std::shared_ptr library; + ErikaPresenterHandle* handle = nullptr; + HWND attached_hwnd = nullptr; + ErikaOverlayWindow* attached_overlay = nullptr; + ErikaFlutterTexture* attached_texture = nullptr; + int64_t attached_view_id = 0; + bool surface_attached = false; + bool composition_surface = false; + int32_t video_alpha_mode = ErikaVideoAlphaMode_Opaque; + uint32_t attached_surface_width = 0; + uint32_t attached_surface_height = 0; + double attached_surface_scale = 0.0; + double start_time_seconds = NowSeconds(); + ErikaDanmakuConfig current_danmaku_config = DefaultDanmakuConfig(); + ErikaPresenterStats latest_presenter_stats{}; +}; + +ErikaEventStreamHandler::ErikaEventStreamHandler(ErikaFlutterPlugin* plugin) + : plugin_(plugin) {} + +std::unique_ptr> +ErikaEventStreamHandler::OnListenInternal( + const EncodableValue* arguments, + std::unique_ptr>&& events) { + plugin_->SetEventSink(std::move(events)); + return nullptr; +} + +std::unique_ptr> +ErikaEventStreamHandler::OnCancelInternal(const EncodableValue* arguments) { + plugin_->ClearEventSink(); + return nullptr; +} + +void ErikaFlutterPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + auto channel = + std::make_unique>( + registrar->messenger(), "erika_flutter/player", + &flutter::StandardMethodCodec::GetInstance()); + + auto plugin = std::make_unique(registrar); + + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto& call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + + registrar->AddPlugin(std::move(plugin)); +} + +ErikaFlutterPlugin::ErikaFlutterPlugin( + flutter::PluginRegistrarWindows* registrar) + : registrar_(registrar) { + event_channel_ = std::make_unique>( + registrar_->messenger(), "erika_flutter/events", + &flutter::StandardMethodCodec::GetInstance()); + event_channel_->SetStreamHandler( + std::make_unique(this)); + + window_proc_delegate_id_ = registrar_->RegisterTopLevelWindowProcDelegate( + [this](HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { + return OnTopLevelWindowProc(hwnd, message, wparam, lparam); + }); + StartFrameTimer(); +} + +ErikaFlutterPlugin::~ErikaFlutterPlugin() { + StopFrameTimer(); + smtc_.reset(); + DestroyFrameMessageWindow(); + if (window_proc_delegate_id_ != 0) { + registrar_->UnregisterTopLevelWindowProcDelegate(window_proc_delegate_id_); + window_proc_delegate_id_ = 0; + } + if (event_channel_) { + event_channel_->SetStreamHandler(nullptr); + } + while (!textures_.empty()) { + ReleaseTexture(textures_.begin()->first); + } + players_.clear(); + overlay_window_.reset(); +} + +void ErikaFlutterPlugin::SetEventSink( + std::unique_ptr> sink) { + event_sink_ = std::move(sink); + StartFrameTimer(); + OnFrameTimer(); +} + +void ErikaFlutterPlugin::ClearEventSink() { + event_sink_.reset(); +} + +HWND ErikaFlutterPlugin::FlutterWindow() const { + auto* view = registrar_->GetView(); + if (view == nullptr) { + return nullptr; + } + return view->GetNativeWindow(); +} + +HWND ErikaFlutterPlugin::RequestedOverlayFlutterWindow() const { + if (!overlay_uses_secondary_window_) { + return FlutterWindow(); + } + return ResolveFlutterRegularHostWindow(); +} + +void ErikaFlutterPlugin::UpdateOverlayTarget(const EncodableMap& args) { + const int64_t flutter_view_id = + Int64Value(FindArg(args, "flutterViewId")).value_or(0); + const bool secondary_window = + BoolValue(FindArg(args, "secondaryWindow")).value_or(false); + if (requested_flutter_view_id_ == flutter_view_id && + overlay_uses_secondary_window_ == secondary_window) { + return; + } + requested_flutter_view_id_ = flutter_view_id; + overlay_uses_secondary_window_ = secondary_window; + DebugLog("overlay target changed flutterViewId=" + + std::to_string(flutter_view_id) + + " secondaryWindow=" + + std::string(secondary_window ? "true" : "false")); +} + +double ErikaFlutterPlugin::BackingScale() const { + return ScaleForWindow(FlutterWindow()); +} + +void ErikaFlutterPlugin::StartFrameTimer() { + if (frame_timer_running_.load(std::memory_order_acquire)) { + return; + } + HWND source_hwnd = FlutterWindow(); + const double target_fps = FrameTimerTargetFps(source_hwnd); + const double interval_ms = FrameTimerIntervalMs(target_fps); + HWND hwnd = EnsureFrameMessageWindow(); + if (hwnd == nullptr) { + return; + } + + HANDLE stop_event = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (stop_event == nullptr) { + DebugLog("CreateEventW for frame scheduler failed: " + LastErrorMessage()); + return; + } + + const uint64_t generation = + frame_timer_generation_.fetch_add(1, std::memory_order_acq_rel) + 1; + frame_tick_pending_.store(false, std::memory_order_release); + frame_timer_target_fps_ = target_fps; + frame_timer_interval_ms_ = interval_ms; + frame_timer_stop_event_ = stop_event; + frame_timer_running_.store(true, std::memory_order_release); + try { + frame_timer_thread_ = std::thread( + &ErikaFlutterPlugin::FrameTimerThreadMain, this, hwnd, stop_event, + interval_ms, generation); + } catch (const std::exception& error) { + frame_timer_running_.store(false, std::memory_order_release); + frame_timer_generation_.fetch_add(1, std::memory_order_acq_rel); + frame_timer_stop_event_ = nullptr; + CloseHandle(stop_event); + DebugLog(std::string("frame scheduler thread failed: ") + error.what()); + return; + } + + DebugLog("frame scheduler started interval_ms=" + + std::to_string(interval_ms) + " target_fps=" + + std::to_string(target_fps) + " generation=" + + std::to_string(generation)); +} + +void ErikaFlutterPlugin::StopFrameTimer() { + if (!frame_timer_running_.exchange(false, std::memory_order_acq_rel)) { + return; + } + frame_timer_generation_.fetch_add(1, std::memory_order_acq_rel); + HANDLE stop_event = frame_timer_stop_event_; + if (stop_event != nullptr) { + SetEvent(stop_event); + } + if (frame_timer_thread_.joinable()) { + frame_timer_thread_.join(); + } + if (stop_event != nullptr) { + CloseHandle(stop_event); + frame_timer_stop_event_ = nullptr; + } + frame_tick_pending_.store(false, std::memory_order_release); + DebugLog("frame scheduler stopped"); +} + +void ErikaFlutterPlugin::PostFrameTick(HWND hwnd, uint64_t generation) { + if (!frame_timer_running_.load(std::memory_order_acquire) || + generation != frame_timer_generation_.load(std::memory_order_acquire)) { + return; + } + bool expected = false; + if (!frame_tick_pending_.compare_exchange_strong( + expected, true, std::memory_order_acq_rel)) { + return; + } + if (!PostMessageW(hwnd, kFrameTimerMessage, reinterpret_cast(this), + static_cast(generation))) { + frame_tick_pending_.store(false, std::memory_order_release); + DebugLog("PostMessage frame tick failed: " + LastErrorMessage()); + } +} + +void ErikaFlutterPlugin::FrameTimerThreadMain(HWND hwnd, + HANDLE stop_event, + double interval_ms, + uint64_t generation) { + SetLastError(0); + bool high_resolution = true; + HANDLE timer = + CreateWaitableTimerExW(nullptr, nullptr, + kWaitableTimerHighResolutionFlag, TIMER_ALL_ACCESS); + if (timer == nullptr) { + const auto high_resolution_error = LastErrorMessage(); + high_resolution = false; + timer = CreateWaitableTimerExW(nullptr, nullptr, 0, TIMER_ALL_ACCESS); + if (timer != nullptr) { + DebugLog("high resolution waitable timer unavailable: " + + high_resolution_error + "; using regular waitable timer"); + } + } + + auto run_timeout_loop = [&]() { + const DWORD wait_ms = + static_cast(std::max(1.0, std::ceil(interval_ms))); + while (frame_timer_running_.load(std::memory_order_acquire) && + generation == + frame_timer_generation_.load(std::memory_order_acquire)) { + const DWORD wait = WaitForSingleObject(stop_event, wait_ms); + if (wait != WAIT_TIMEOUT) { + break; + } + PostFrameTick(hwnd, generation); + } + }; + + if (timer == nullptr) { + DebugLog("CreateWaitableTimerExW failed: " + LastErrorMessage() + + "; using wait timeout loop"); + run_timeout_loop(); + return; + } + + DebugLog(std::string("frame scheduler timer armed high_resolution=") + + (high_resolution ? "true" : "false")); + HANDLE wait_handles[] = {stop_event, timer}; + using clock = std::chrono::steady_clock; + const auto period = std::chrono::duration_cast( + std::chrono::duration(interval_ms)); + auto next_tick = clock::now(); + while (frame_timer_running_.load(std::memory_order_acquire) && + generation == frame_timer_generation_.load(std::memory_order_acquire)) { + next_tick += period; + auto now = clock::now(); + const double late_ms = + std::chrono::duration(now - next_tick).count(); + if (late_ms > interval_ms * 4.0) { + next_tick = now + period; + } + const double delay_ms = + std::chrono::duration(next_tick - now).count(); + if (delay_ms <= 0.05) { + PostFrameTick(hwnd, generation); + continue; + } + + auto due_time = RelativeWaitTimeForMilliseconds(delay_ms); + if (!SetWaitableTimer(timer, &due_time, 0, nullptr, nullptr, FALSE)) { + DebugLog("SetWaitableTimer failed: " + LastErrorMessage() + + "; using wait timeout loop"); + CloseHandle(timer); + run_timeout_loop(); + return; + } + + const DWORD wait = WaitForMultipleObjects(2, wait_handles, FALSE, INFINITE); + if (wait == WAIT_OBJECT_0) { + break; + } + if (wait == WAIT_OBJECT_0 + 1) { + PostFrameTick(hwnd, generation); + continue; + } + DebugLog("frame scheduler wait failed: " + LastErrorMessage()); + break; + } + CancelWaitableTimer(timer); + CloseHandle(timer); +} + +HWND ErikaFlutterPlugin::EnsureFrameMessageWindow() { + if (frame_message_window_ != nullptr) { + return frame_message_window_; + } + + WNDCLASSEXW window_class{}; + window_class.cbSize = sizeof(window_class); + window_class.lpfnWndProc = &ErikaFlutterPlugin::FrameMessageWindowProc; + window_class.hInstance = GetModuleHandleW(nullptr); + window_class.lpszClassName = kFrameMessageWindowClassName; + if (!RegisterClassExW(&window_class) && + GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { + DebugLog("Unable to register frame scheduler window class: " + + LastErrorMessage()); + return nullptr; + } + + frame_message_window_ = CreateWindowExW( + 0, kFrameMessageWindowClassName, L"Erika Frame Scheduler", 0, 0, 0, 0, 0, + HWND_MESSAGE, nullptr, GetModuleHandleW(nullptr), this); + if (frame_message_window_ == nullptr) { + DebugLog("Unable to create frame scheduler message HWND: " + + LastErrorMessage()); + } + return frame_message_window_; +} + +void ErikaFlutterPlugin::DestroyFrameMessageWindow() { + if (frame_message_window_ != nullptr) { + DestroyWindow(frame_message_window_); + frame_message_window_ = nullptr; + } +} + +void ErikaFlutterPlugin::RefreshFrameTimerForCurrentDisplay() { + if (!frame_timer_running_.load(std::memory_order_acquire)) { + return; + } + const double target_fps = FrameTimerTargetFps(FlutterWindow()); + const double interval_ms = FrameTimerIntervalMs(target_fps); + if (std::abs(interval_ms - frame_timer_interval_ms_) <= 0.05) { + return; + } + + DebugLog("frame scheduler display refresh changed old_fps=" + + std::to_string(frame_timer_target_fps_) + " new_fps=" + + std::to_string(target_fps) + " old_interval_ms=" + + std::to_string(frame_timer_interval_ms_) + " new_interval_ms=" + + std::to_string(interval_ms)); + StopFrameTimer(); + StartFrameTimer(); +} + +LRESULT CALLBACK ErikaFlutterPlugin::FrameMessageWindowProc(HWND hwnd, + UINT message, + WPARAM wparam, + LPARAM lparam) { + if (message == WM_NCCREATE) { + auto* create = reinterpret_cast(lparam); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, + reinterpret_cast(create->lpCreateParams)); + } + + auto* plugin = reinterpret_cast( + GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + if (message == kFrameTimerMessage && plugin != nullptr) { + const auto generation = static_cast(lparam); + if (plugin == reinterpret_cast(wparam) && + plugin->frame_timer_running_.load(std::memory_order_acquire) && + generation == + plugin->frame_timer_generation_.load(std::memory_order_acquire)) { + plugin->frame_tick_pending_.store(false, std::memory_order_release); + plugin->OnFrameTimer(); + } + return 0; + } + if (message == kSmtcMessage && plugin != nullptr) { + plugin->HandleSmtcCommand(static_cast(wparam), + static_cast(lparam)); + return 0; + } + + if (message == WM_NCDESTROY && plugin != nullptr) { + if (plugin->frame_message_window_ == hwnd) { + plugin->frame_message_window_ = nullptr; + } + SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); + } + return DefWindowProcW(hwnd, message, wparam, lparam); +} + +void ErikaFlutterPlugin::OnFrameTimer() { + if (in_frame_timer_) { + return; + } + in_frame_timer_ = true; + static bool trace_enabled = FrameTraceEnabled(); + static auto last_tick = std::chrono::steady_clock::now(); + static uint64_t tick_count = 0; + const auto tick_started = std::chrono::steady_clock::now(); + for (auto& entry : players_) { + entry.second->RenderTick(event_sink_.get()); + } + RefreshSmtc(); + if (trace_enabled) { + tick_count += 1; + const auto elapsed = std::chrono::duration( + tick_started - last_tick); + const auto work = std::chrono::duration( + std::chrono::steady_clock::now() - tick_started); + if (tick_count % 60 == 0 || elapsed.count() > 24.0 || work.count() > 8.0) { + DebugLog("frame_tick count=" + std::to_string(tick_count) + + " delta_ms=" + std::to_string(elapsed.count()) + + " work_ms=" + std::to_string(work.count()) + + " players=" + std::to_string(players_.size())); + } + last_tick = tick_started; + } + in_frame_timer_ = false; +} + +std::optional ErikaFlutterPlugin::OnTopLevelWindowProc( + HWND hwnd, + UINT message, + WPARAM wparam, + LPARAM lparam) { + if (message == WM_MOVE || message == WM_MOVING || message == WM_SIZE || + message == WM_SIZING || message == WM_EXITSIZEMOVE || + message == WM_SHOWWINDOW || message == WM_DPICHANGED || + message == WM_WINDOWPOSCHANGED) { + if (overlay_window_) { + overlay_window_->RefreshScaleAndReposition(); + ResizeAttachedOverlay(); + } + RefreshFrameTimerForCurrentDisplay(); + } + if (message == WM_DESTROY) { + StopFrameTimer(); + smtc_.reset(); + for (auto& entry : players_) { + entry.second->Detach(std::nullopt); + } + overlay_window_.reset(); + } + return std::nullopt; +} + +ErikaFlutterPlugin::ErikaOverlayWindow& ErikaFlutterPlugin::EnsureOverlayWindow() { + HWND parent = RequestedOverlayFlutterWindow(); + if (parent == nullptr) { + throw PluginError(overlay_uses_secondary_window_ + ? "No detached Flutter HWND is available for Erika overlay." + : "No Flutter HWND is available for Erika overlay."); + } + if (!overlay_window_ || overlay_window_->flutter != parent) { + const std::string blend_mode = + overlay_window_ ? overlay_window_->blend_mode : "srcOver"; + const double opacity = overlay_window_ ? overlay_window_->opacity : 1.0; + overlay_window_ = std::make_unique(parent); + overlay_window_->ConfigureComposition(blend_mode, opacity); + StartFrameTimer(); + for (auto& entry : players_) { + if (entry.second->attached_view_id == kWindowOverlayViewId) { + entry.second->AttachOverlay(*overlay_window_); + } + } + } + return *overlay_window_; +} + +ErikaFlutterPlugin::PlayerHost& ErikaFlutterPlugin::PlayerFromArgs( + const EncodableMap& args) { + const int64_t player_id = RequiredInt64(args, "playerId"); + const auto it = players_.find(player_id); + if (it == players_.end()) { + throw PluginError("Erika player " + std::to_string(player_id) + + " was not found."); + } + return *it->second; +} + +ErikaFlutterPlugin::ErikaFlutterTexture& ErikaFlutterPlugin::TextureFromArgs( + const EncodableMap& args) { + const int64_t texture_id = RequiredInt64(args, "textureId"); + const auto it = textures_.find(texture_id); + if (it == textures_.end()) { + throw PluginError("Erika Flutter texture " + std::to_string(texture_id) + + " was not found."); + } + return *it->second; +} + +int64_t ErikaFlutterPlugin::CreateTexture(const EncodableMap& args) { + const int64_t requested_width = RequiredInt64(args, "width"); + const int64_t requested_height = RequiredInt64(args, "height"); + const double scale = DoubleValue(FindArg(args, "scale")).value_or(1.0); + if (requested_width <= 0 || requested_width > UINT32_MAX || + requested_height <= 0 || requested_height > UINT32_MAX || + !std::isfinite(scale) || scale <= 0.0) { + throw PluginError("Flutter texture metrics must be finite and positive."); + } + auto texture = std::make_shared( + registrar_, static_cast(requested_width), + static_cast(requested_height), scale); + const int64_t texture_id = texture->texture_id; + textures_.emplace(texture_id, std::move(texture)); + DebugLog("registered Flutter GPU texture id=" + + std::to_string(texture_id)); + return texture_id; +} + +void ErikaFlutterPlugin::ReleaseTexture(int64_t texture_id) { + const auto it = textures_.find(texture_id); + if (it == textures_.end()) { + return; + } + auto texture = it->second; + if (texture->owner_player_id != 0) { + const auto player = players_.find(texture->owner_player_id); + if (player != players_.end()) { + player->second->Detach(texture_id); + } + } + textures_.erase(it); + registrar_->texture_registrar()->UnregisterTexture( + texture_id, [texture = std::move(texture)]() mutable { + texture.reset(); + }); + DebugLog("unregistered Flutter GPU texture id=" + + std::to_string(texture_id)); +} + +void ErikaFlutterPlugin::ResizeAttachedOverlay() { + if (!overlay_window_) { + return; + } + for (auto& entry : players_) { + if (entry.second->attached_view_id == kWindowOverlayViewId) { + try { + entry.second->ResizeOverlay(*overlay_window_); + } catch (const std::exception& error) { + DebugLog(std::string("resize_surface failed: ") + error.what()); + } + } + } +} + +int64_t ErikaFlutterPlugin::CreatePlayer(const EncodableValue* arguments) { + ErikaPresenterConfig config{}; + config.output_mode = ErikaPresenterOutputMode_Sdr; + config.edr_headroom = 1.0f; + config.luma_upscaler = ErikaLumaUpscalerMode_Off; + + if (arguments != nullptr && std::holds_alternative(*arguments)) { + const auto& args = std::get(*arguments); + if (auto value = Int64Value(FindArg(args, "outputMode"))) { + config.output_mode = static_cast(*value); + } + if (auto value = DoubleValue(FindArg(args, "edrHeadroom"))) { + config.edr_headroom = static_cast(std::max(1.0, *value)); + } + if (auto value = Int64Value(FindArg(args, "videoAlphaMode"))) { + config.video_alpha_mode = static_cast(*value); + } + } + + const int64_t id = next_player_id_++; + players_[id] = std::make_unique( + id, ErikaNativeLibrary::Shared(), config); + StartFrameTimer(); + OnFrameTimer(); + return id; +} + +void ErikaFlutterPlugin::EnsureSmtc() { + if (smtc_) { + return; + } + HWND window = RootHostWindow(FlutterWindow()); + if (window == nullptr) { + return; + } + HWND message_window = EnsureFrameMessageWindow(); + if (message_window == nullptr) { + return; + } + smtc_ = std::make_unique( + window, [message_window](ErikaSmtcCommand command, + uint64_t position_micros) { + PostMessageW(message_window, kSmtcMessage, + static_cast(command), + static_cast(position_micros)); + }); + if (!smtc_->available()) { + smtc_.reset(); + } +} + +void ErikaFlutterPlugin::SetActivePlayer(int64_t player_id) { + active_player_id_ = player_id; + EnsureSmtc(); + RefreshSmtc(); +} + +void ErikaFlutterPlugin::RefreshSmtc() { + if (!smtc_ || active_player_id_ == 0) { + return; + } + const auto it = players_.find(active_player_id_); + if (it == players_.end()) { + smtc_->Clear(); + active_player_id_ = 0; + return; + } + smtc_->Update(it->second->smtc_state); +} + +void ErikaFlutterPlugin::HandleSmtcCommand(ErikaSmtcCommand command, + uint64_t position_micros) { + const auto it = players_.find(active_player_id_); + if (it == players_.end()) { + return; + } + try { + if (command == ErikaSmtcCommand::play) { + it->second->Play(); + } else if (command == ErikaSmtcCommand::pause) { + it->second->Pause(); + } else if (command == ErikaSmtcCommand::toggle) { + if (it->second->smtc_state.playing) { + it->second->Pause(); + } else { + it->second->Play(); + } + } else if (command == ErikaSmtcCommand::seek) { + it->second->Seek(position_micros); + } else if (command == ErikaSmtcCommand::previous || + command == ErikaSmtcCommand::next) { + const bool enabled = command == ErikaSmtcCommand::previous + ? it->second->smtc_state.previous_enabled + : it->second->smtc_state.next_enabled; + if (enabled) { + SendEvent(EncodableValue(EncodableMap{ + {EncodableValue("playerId"), EncodableValue(active_player_id_)}, + {EncodableValue("kind"), EncodableValue(13)}, + {EncodableValue("navigation"), + EncodableValue(command == ErikaSmtcCommand::previous ? "previous" + : "next")}, + })); + } + } + OnFrameTimer(); + } catch (const std::exception& error) { + DebugLog(std::string("SMTC command failed: ") + error.what()); + } +} + +void ErikaFlutterPlugin::RemovePlayer(int64_t player_id) { + const auto it = players_.find(player_id); + if (it == players_.end()) { + return; + } + // Hide the shared HWND before destroying the presenter. Presenter teardown + // may wait for decoder threads, and leaving the overlay visible during that + // wait exposes the transparent Flutter cutout as an apparently frozen app. + if (overlay_window_ && overlay_window_->owner_player_id == player_id) { + overlay_window_->SetFrame(0.0, 0.0, 0.0, 0.0, false, std::nullopt, + std::nullopt); + overlay_window_->owner_player_id = 0; + } + if (active_player_id_ == player_id) { + active_player_id_ = 0; + if (smtc_) { + smtc_->Clear(); + } + } + players_.erase(it); +} + +void ErikaFlutterPlugin::SendEvent(EncodableValue event) { + if (event_sink_ != nullptr) { + event_sink_->Success(event); + } +} + +void ErikaFlutterPlugin::HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + const auto& method = method_call.method_name(); + try { + if (method == "create") { + const int64_t player_id = CreatePlayer(method_call.arguments()); + OnFrameTimer(); + result->Success(EncodableValue(player_id)); + return; + } + + const EncodableMap& args = DictionaryArgs(method_call.arguments()); + + if (method == "dispose") { + RemovePlayer(RequiredInt64(args, "playerId")); + OnFrameTimer(); + result->Success(); + } else if (method == "createTexture") { + result->Success(EncodableValue(CreateTexture(args))); + } else if (method == "resizeTexture") { + auto& texture = TextureFromArgs(args); + const int64_t width = RequiredInt64(args, "width"); + const int64_t height = RequiredInt64(args, "height"); + const double scale = DoubleValue(FindArg(args, "scale")).value_or(1.0); + if (width <= 0 || width > UINT32_MAX || height <= 0 || + height > UINT32_MAX || !std::isfinite(scale) || scale <= 0.0) { + throw PluginError("Flutter texture metrics must be finite and positive."); + } + const auto player = players_.find(texture.owner_player_id); + if (player != players_.end()) { + player->second->ResizeFlutterTexture( + texture, static_cast(width), + static_cast(height), scale); + } else { + texture.Resize(static_cast(width), + static_cast(height), scale); + } + OnFrameTimer(); + result->Success(); + } else if (method == "releaseTexture") { + ReleaseTexture(RequiredInt64(args, "textureId")); + result->Success(); + } else if (method == "open") { + auto& player = PlayerFromArgs(args); + if (const auto* value = FindArg(args, "metadata"); value != nullptr) { + if (const auto* metadata = std::get_if(value)) { + player.SetMediaMetadata(*metadata); + } else if (std::holds_alternative(*value)) { + player.ClearMediaMetadata(); + } else { + throw PluginError("metadata must be a map."); + } + } else { + player.ClearMediaMetadata(); + } + player.PrepareForOpen(); + player.Open(RequiredString(args, "uri"), args); + OnFrameTimer(); + result->Success(); + } else if (method == "play") { + auto& player = PlayerFromArgs(args); + player.Play(); + SetActivePlayer(player.id); + OnFrameTimer(); + result->Success(); + } else if (method == "pause") { + PlayerFromArgs(args).Pause(); + OnFrameTimer(); + result->Success(); + } else if (method == "stop") { + PlayerFromArgs(args).Stop(); + OnFrameTimer(); + result->Success(); + } else if (method == "close") { + PlayerFromArgs(args).Close(); + OnFrameTimer(); + result->Success(); + } else if (method == "seek") { + PlayerFromArgs(args).Seek( + static_cast(std::max( + 0, RequiredInt64(args, "positionMicros")))); + OnFrameTimer(); + result->Success(); + } else if (method == "setPlaybackRate") { + PlayerFromArgs(args).SetPlaybackRate( + DoubleValue(FindArg(args, "rate")).value_or(1.0)); + result->Success(); + } else if (method == "setMediaMetadata") { + const auto* value = FindArg(args, "metadata"); + const auto* metadata = value == nullptr ? nullptr : std::get_if(value); + if (metadata == nullptr) { + throw PluginError("metadata is required."); + } + PlayerFromArgs(args).SetMediaMetadata(*metadata); + RefreshSmtc(); + result->Success(); + } else if (method == "setSystemMediaNavigation") { + PlayerFromArgs(args).SetSystemMediaNavigation( + BoolValue(FindArg(args, "previousEnabled")).value_or(false), + BoolValue(FindArg(args, "nextEnabled")).value_or(false)); + RefreshSmtc(); + result->Success(); + } else if (method == "setVolume") { + PlayerFromArgs(args).SetVolume( + DoubleValue(FindArg(args, "volume")).value_or(1.0)); + result->Success(); + } else if (method == "setUpscaler") { + PlayerFromArgs(args).SetUpscaler( + static_cast(RequiredInt64(args, "mode"))); + result->Success(); + } else if (method == "setSubtitleScale") { + PlayerFromArgs(args).SetSubtitleScale( + DoubleValue(FindArg(args, "scale")).value_or(1.0)); + result->Success(); + } else if (method == "registerSubtitleMemoryFont") { + const auto* data = std::get_if>(FindArg(args, "data")); + if (data == nullptr || data->empty()) { + throw PluginError("data is required."); + } + result->Success(EncodableValue(static_cast( + PlayerFromArgs(args).RegisterSubtitleMemoryFont(*data)))); + } else if (method == "selectSubtitleMemoryFonts") { + const auto* ids = std::get_if(FindArg(args, "fontIds")); + if (ids == nullptr) { + throw PluginError("fontIds is required."); + } + PlayerFromArgs(args).SelectSubtitleMemoryFonts(*ids); + result->Success(); + } else if (method == "clearSubtitleMemoryFonts") { + PlayerFromArgs(args).ClearSubtitleMemoryFonts(); + result->Success(); + } else if (method == "getSubtitleMemoryFontStatus") { + result->Success(PlayerFromArgs(args).GetSubtitleMemoryFontStatus()); + } else if (method == "setSubtitleStyle") { + auto& host = PlayerFromArgs(args); + const bool has_style = FindArg(args, "fontFamily") != nullptr || + FindArg(args, "fontFilePath") != nullptr || + FindArg(args, "primaryColorRgba") != nullptr || + FindArg(args, "outlineColorRgba") != nullptr || + FindArg(args, "fontSize") != nullptr || + FindArg(args, "outlineWidth") != nullptr || + FindArg(args, "bold") != nullptr || + FindArg(args, "italic") != nullptr || + FindArg(args, "underline") != nullptr || + FindArg(args, "strikeOut") != nullptr || + FindArg(args, "spacing") != nullptr || + FindArg(args, "scaleXPercent") != nullptr || + FindArg(args, "scaleYPercent") != nullptr || + FindArg(args, "borderStyle") != nullptr || + FindArg(args, "shadowDepth") != nullptr || + FindArg(args, "blur") != nullptr || + FindArg(args, "alignment") != nullptr || + FindArg(args, "marginLeft") != nullptr || + FindArg(args, "marginRight") != nullptr || + FindArg(args, "marginVertical") != nullptr || + FindArg(args, "overrideMask") != nullptr; + if (has_style) { + const int64_t primary = + Int64Value(FindArg(args, "primaryColorRgba")).value_or(0xFFFFFFFF); + const int64_t outline = + Int64Value(FindArg(args, "outlineColorRgba")).value_or(0x0000007F); + host.SetSubtitleStyle( + StringValue(FindArg(args, "fontFamily")), + StringValue(FindArg(args, "fontFilePath")), + static_cast(primary), static_cast(outline), + DoubleValue(FindArg(args, "fontSize")).value_or(48.0), + DoubleValue(FindArg(args, "outlineWidth")).value_or(2.0), + BoolValue(FindArg(args, "bold")).value_or(false), + BoolValue(FindArg(args, "italic")).value_or(false), + BoolValue(FindArg(args, "underline")).value_or(false), + BoolValue(FindArg(args, "strikeOut")).value_or(false), + DoubleValue(FindArg(args, "spacing")).value_or(0.0), + DoubleValue(FindArg(args, "scaleXPercent")).value_or(100.0), + DoubleValue(FindArg(args, "scaleYPercent")).value_or(100.0), + static_cast( + Int64Value(FindArg(args, "borderStyle")).value_or(1)), + DoubleValue(FindArg(args, "shadowDepth")).value_or(0.0), + DoubleValue(FindArg(args, "blur")).value_or(0.0), + static_cast( + Int64Value(FindArg(args, "alignment")).value_or(2)), + static_cast( + Int64Value(FindArg(args, "marginLeft")).value_or(48)), + static_cast( + Int64Value(FindArg(args, "marginRight")).value_or(48)), + static_cast( + Int64Value(FindArg(args, "marginVertical")).value_or(54)), + static_cast( + Int64Value(FindArg(args, "overrideMask")).value_or(0))); + } + result->Success(); + } else if (method == "getUpscalerStatus") { + result->Success(PlayerFromArgs(args).GetUpscalerStatus()); + } else if (method == "getOutputStatus") { + result->Success(PlayerFromArgs(args).GetOutputStatus()); + } else if (method == "getPresenterStats") { + result->Success(PlayerFromArgs(args).GetPresenterStats()); + } else if (method == "getResourceStatus") { + result->Success(PlayerFromArgs(args).GetResourceStatus()); + } else if (method == "setDebugHudEnabled") { + PlayerFromArgs(args).SetDebugHudEnabled( + BoolValue(FindArg(args, "enabled")).value_or(false)); + result->Success(); + } else if (method == "addExternalSubtitle") { + const int64_t track_id = + PlayerFromArgs(args).AddExternalSubtitle(RequiredString(args, "uri")); + OnFrameTimer(); + result->Success(EncodableValue(track_id)); + } else if (method == "removeSubtitleTrack") { + PlayerFromArgs(args).RemoveSubtitleTrack( + RequiredInt64(args, "trackId")); + OnFrameTimer(); + result->Success(); + } else if (method == "loadDanmakuFile") { + PlayerFromArgs(args).LoadDanmakuFile(RequiredString(args, "uri")); + result->Success(); + } else if (method == "loadDanmakuJson") { + PlayerFromArgs(args).LoadDanmakuJson(RequiredString(args, "json")); + result->Success(); + } else if (method == "addDanmakuTrackFile") { + result->Success(EncodableValue(static_cast( + PlayerFromArgs(args).AddDanmakuTrackFile( + RequiredString(args, "uri"), StringValue(FindArg(args, "name")), + Int64Value(FindArg(args, "offsetMicros")).value_or(0))))); + } else if (method == "addDanmakuTrackJson") { + result->Success(EncodableValue(static_cast( + PlayerFromArgs(args).AddDanmakuTrackJson( + RequiredString(args, "json"), StringValue(FindArg(args, "name")), + Int64Value(FindArg(args, "offsetMicros")).value_or(0))))); + } else if (method == "removeDanmakuTrack") { + PlayerFromArgs(args).RemoveDanmakuTrack( + static_cast(RequiredInt64(args, "trackId"))); + result->Success(); + } else if (method == "setDanmakuTrackEnabled") { + PlayerFromArgs(args).SetDanmakuTrackEnabled( + static_cast(RequiredInt64(args, "trackId")), + BoolValue(FindArg(args, "enabled")).value_or(true)); + result->Success(); + } else if (method == "setDanmakuTrackOffset") { + PlayerFromArgs(args).SetDanmakuTrackOffset( + static_cast(RequiredInt64(args, "trackId")), + Int64Value(FindArg(args, "offsetMicros")).value_or(0)); + result->Success(); + } else if (method == "setDanmakuGlobalOffset") { + PlayerFromArgs(args).SetDanmakuGlobalOffset( + Int64Value(FindArg(args, "offsetMicros")).value_or(0)); + result->Success(); + } else if (method == "danmakuTracks") { + result->Success(PlayerFromArgs(args).DanmakuTracks()); + } else if (method == "clearDanmaku") { + PlayerFromArgs(args).ClearDanmaku(); + result->Success(); + } else if (method == "setDanmakuEnabled") { + PlayerFromArgs(args).SetDanmakuEnabled( + BoolValue(FindArg(args, "enabled")).value_or(true)); + result->Success(); + } else if (method == "setDanmakuConfig") { + auto& host = PlayerFromArgs(args); + host.SetDanmakuConfig(host.DanmakuConfigFromArgs(args)); + const bool has_font = FindArg(args, "customFontFamily") != nullptr || + FindArg(args, "customFontFilePath") != nullptr; + if (has_font) { + host.SetDanmakuFont(StringValue(FindArg(args, "customFontFamily")), + StringValue(FindArg(args, "customFontFilePath"))); + } + if (auto block_words = StringValue(FindArg(args, "blockWordsJson"))) { + host.SetDanmakuBlockWordsJson(*block_words); + } + result->Success(); + } else if (method == "selectAudioTrack") { + PlayerFromArgs(args).SelectAudioTrack(OptionalTrackId(FindArg(args, "trackId"))); + OnFrameTimer(); + result->Success(); + } else if (method == "selectSubtitleTrack") { + PlayerFromArgs(args).SelectSubtitleTrack( + OptionalTrackId(FindArg(args, "trackId"))); + OnFrameTimer(); + result->Success(); + } else if (method == "tracks") { + result->Success(PlayerFromArgs(args).Tracks()); + } else if (method == "screenshot") { + result->Success(); + } else if (method == "attachView") { + auto& host = PlayerFromArgs(args); + const int64_t view_id = RequiredInt64(args, "viewId"); + if (view_id != kWindowOverlayViewId) { + const auto texture = textures_.find(view_id); + if (texture == textures_.end()) { + throw PluginError("Erika video view " + std::to_string(view_id) + + " was not found."); + } + host.AttachFlutterTexture(*texture->second); + OnFrameTimer(); + result->Success(); + return; + } + auto& overlay = EnsureOverlayWindow(); + overlay.ConfigureComposition( + StringValue(FindArg(args, "blendMode")).value_or("srcOver"), + DoubleValue(FindArg(args, "opacity")).value_or(1.0)); + host.AttachOverlay(overlay); + overlay.owner_player_id = host.id; + OnFrameTimer(); + result->Success(); + } else if (method == "detachView") { + PlayerFromArgs(args).Detach(RequiredInt64(args, "viewId")); + OnFrameTimer(); + result->Success(); + } else if (method == "attachOverlay") { + auto& host = PlayerFromArgs(args); + UpdateOverlayTarget(args); + auto& overlay = EnsureOverlayWindow(); + overlay.ConfigureComposition( + StringValue(FindArg(args, "blendMode")).value_or("srcOver"), + DoubleValue(FindArg(args, "opacity")).value_or(1.0)); + host.AttachOverlay(overlay); + overlay.owner_player_id = host.id; + OnFrameTimer(); + result->Success(EncodableValue(kWindowOverlayViewId)); + } else if (method == "detachOverlay") { + auto& host = PlayerFromArgs(args); + const auto generation = Int64Value(FindArg(args, "generation")); + if (generation && overlay_window_ && + *generation != overlay_window_->active_generation) { + result->Success(); + return; + } + host.Detach(kWindowOverlayViewId); + OnFrameTimer(); + if (overlay_window_) { + overlay_window_->SetFrame(0.0, 0.0, 0.0, 0.0, false, generation, + std::nullopt); + if (overlay_window_->owner_player_id == host.id) { + overlay_window_->owner_player_id = 0; + } + } + result->Success(); + } else if (method == "setOverlayFrame") { + const bool visible = + BoolValue(FindArg(args, "visible")).value_or(true); + const auto generation = Int64Value(FindArg(args, "generation")); + if (!visible && generation && overlay_window_ && + *generation != overlay_window_->active_generation) { + result->Success(); + return; + } + UpdateOverlayTarget(args); + auto& overlay = EnsureOverlayWindow(); + const int64_t player_id = RequiredInt64(args, "playerId"); + auto& host = PlayerFromArgs(args); + if (!visible && overlay.owner_player_id != 0 && + overlay.owner_player_id != player_id) { + result->Success(); + return; + } + if (visible) { + overlay.owner_player_id = player_id; + } + overlay.ConfigureComposition( + StringValue(FindArg(args, "blendMode")).value_or("srcOver"), + DoubleValue(FindArg(args, "opacity")).value_or(1.0)); + if (host.surface_attached && + host.attached_view_id == kWindowOverlayViewId && + host.composition_surface != host.RequiresComposition(overlay)) { + host.AttachOverlay(overlay); + } + overlay.SetFrame(DoubleValue(FindArg(args, "x")).value_or(0.0), + DoubleValue(FindArg(args, "y")).value_or(0.0), + DoubleValue(FindArg(args, "width")).value_or(0.0), + DoubleValue(FindArg(args, "height")).value_or(0.0), + visible, generation, + StringValue(FindArg(args, "debugLabel"))); + if (!visible && overlay.owner_player_id == player_id) { + overlay.owner_player_id = 0; + } + ResizeAttachedOverlay(); + OnFrameTimer(); + result->Success(); + } else { + result->NotImplemented(); + } + } catch (const std::exception& error) { + const auto message = SafeUtf8Message(error.what()); + DebugLog("method " + method + " failed: " + message); + result->Error("ERIKA_ERROR", message); + } +} + +} // namespace erika_flutter diff --git a/third_party/erika_flutter/windows/erika_flutter_plugin.h b/third_party/erika_flutter/windows/erika_flutter_plugin.h new file mode 100644 index 00000000..671c00eb --- /dev/null +++ b/third_party/erika_flutter/windows/erika_flutter_plugin.h @@ -0,0 +1,139 @@ +#ifndef FLUTTER_PLUGIN_ERIKA_FLUTTER_PLUGIN_H_ +#define FLUTTER_PLUGIN_ERIKA_FLUTTER_PLUGIN_H_ + +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "erika.h" +#include "erika_windows_smtc.h" + +namespace erika_flutter { + +class ErikaFlutterPlugin; + +class ErikaEventStreamHandler + : public flutter::StreamHandler { + public: + explicit ErikaEventStreamHandler(ErikaFlutterPlugin* plugin); + + protected: + std::unique_ptr> + OnListenInternal( + const flutter::EncodableValue* arguments, + std::unique_ptr>&& events) + override; + + std::unique_ptr> + OnCancelInternal(const flutter::EncodableValue* arguments) override; + + private: + ErikaFlutterPlugin* plugin_; +}; + +class ErikaFlutterPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); + + explicit ErikaFlutterPlugin(flutter::PluginRegistrarWindows* registrar); + ~ErikaFlutterPlugin() override; + + ErikaFlutterPlugin(const ErikaFlutterPlugin&) = delete; + ErikaFlutterPlugin& operator=(const ErikaFlutterPlugin&) = delete; + + void HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + + void SetEventSink( + std::unique_ptr> sink); + void ClearEventSink(); + + private: + struct ErikaNativeLibrary; + struct ErikaOverlayWindow; + struct ErikaFlutterTexture; + struct PlayerHost; + + friend class ErikaEventStreamHandler; + + HWND FlutterWindow() const; + double BackingScale() const; + void StartFrameTimer(); + void StopFrameTimer(); + void PostFrameTick(HWND hwnd, uint64_t generation); + void FrameTimerThreadMain(HWND hwnd, + HANDLE stop_event, + double interval_ms, + uint64_t generation); + HWND EnsureFrameMessageWindow(); + void DestroyFrameMessageWindow(); + void RefreshFrameTimerForCurrentDisplay(); + static LRESULT CALLBACK FrameMessageWindowProc(HWND hwnd, + UINT message, + WPARAM wparam, + LPARAM lparam); + void OnFrameTimer(); + std::optional OnTopLevelWindowProc(HWND hwnd, + UINT message, + WPARAM wparam, + LPARAM lparam); + + ErikaOverlayWindow& EnsureOverlayWindow(); + HWND RequestedOverlayFlutterWindow() const; + void UpdateOverlayTarget(const flutter::EncodableMap& args); + PlayerHost& PlayerFromArgs(const flutter::EncodableMap& args); + ErikaFlutterTexture& TextureFromArgs(const flutter::EncodableMap& args); + int64_t CreateTexture(const flutter::EncodableMap& args); + void ReleaseTexture(int64_t texture_id); + void ResizeAttachedOverlay(); + int64_t CreatePlayer(const flutter::EncodableValue* arguments); + void RemovePlayer(int64_t player_id); + void SendEvent(flutter::EncodableValue event); + void EnsureSmtc(); + void SetActivePlayer(int64_t player_id); + void RefreshSmtc(); + void HandleSmtcCommand(ErikaSmtcCommand command, uint64_t position_micros); + + flutter::PluginRegistrarWindows* registrar_ = nullptr; + std::unique_ptr> + event_channel_; + std::unique_ptr> event_sink_; + std::unordered_map> players_; + std::unordered_map> textures_; + std::unique_ptr overlay_window_; + std::unique_ptr smtc_; + int64_t active_player_id_ = 0; + int64_t requested_flutter_view_id_ = 0; + bool overlay_uses_secondary_window_ = false; + int64_t next_player_id_ = 1; + int window_proc_delegate_id_ = 0; + HWND frame_message_window_ = nullptr; + HANDLE frame_timer_stop_event_ = nullptr; + std::thread frame_timer_thread_; + std::atomic frame_timer_running_{false}; + std::atomic frame_tick_pending_{false}; + std::atomic frame_timer_generation_{0}; + double frame_timer_target_fps_ = 0.0; + double frame_timer_interval_ms_ = 0.0; + bool in_frame_timer_ = false; +}; + +} // namespace erika_flutter + +#endif // FLUTTER_PLUGIN_ERIKA_FLUTTER_PLUGIN_H_ diff --git a/third_party/erika_flutter/windows/erika_flutter_plugin_c_api.cpp b/third_party/erika_flutter/windows/erika_flutter_plugin_c_api.cpp new file mode 100644 index 00000000..21aa93b2 --- /dev/null +++ b/third_party/erika_flutter/windows/erika_flutter_plugin_c_api.cpp @@ -0,0 +1,12 @@ +#include "include/erika_flutter/erika_flutter_plugin_c_api.h" + +#include + +#include "erika_flutter_plugin.h" + +void ErikaFlutterPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + erika_flutter::ErikaFlutterPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/third_party/erika_flutter/windows/erika_windows_smtc.cpp b/third_party/erika_flutter/windows/erika_windows_smtc.cpp new file mode 100644 index 00000000..38df5e4a --- /dev/null +++ b/third_party/erika_flutter/windows/erika_windows_smtc.cpp @@ -0,0 +1,204 @@ +#include "erika_windows_smtc.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace erika_flutter { +namespace { + +winrt::Windows::Foundation::TimeSpan MicrosToTimeSpan(uint64_t micros) { + using namespace std::chrono; + return duration_cast( + microseconds(micros)); +} + +winrt::Windows::Storage::Streams::IRandomAccessStream ArtworkStream( + const std::vector& artwork) { + if (artwork.size() > std::numeric_limits::max()) { + throw winrt::hresult_invalid_argument(); + } + winrt::com_ptr stream; + stream.attach(SHCreateMemStream(artwork.data(), + static_cast(artwork.size()))); + if (!stream) { + winrt::throw_hresult(E_OUTOFMEMORY); + } + return winrt::capture< + winrt::Windows::Storage::Streams::IRandomAccessStream>( + CreateRandomAccessStreamOverStream, stream.get(), BSOS_DEFAULT); +} + +} + +struct ErikaWindowsSmtc::Impl { + struct CallbackState { + CommandHandler handler; + std::atomic enabled{true}; + }; + + Impl(HWND window, CommandHandler command_handler) + : callback_state(std::make_shared()) { + callback_state->handler = std::move(command_handler); + try { + auto interop = winrt::get_activation_factory< + winrt::Windows::Media::SystemMediaTransportControls, + ISystemMediaTransportControlsInterop>(); + winrt::check_hresult(interop->GetForWindow( + window, winrt::guid_of(), + winrt::put_abi(controls))); + controls.IsEnabled(true); + controls.IsPlayEnabled(true); + controls.IsPauseEnabled(true); + controls.IsStopEnabled(false); + controls.IsNextEnabled(false); + controls.IsPreviousEnabled(false); + button_token = controls.ButtonPressed( + [state = callback_state](const auto&, const auto& args) { + if (!state->enabled.load(std::memory_order_acquire)) { + return; + } + using Button = + winrt::Windows::Media::SystemMediaTransportControlsButton; + if (args.Button() == Button::Play) { + state->handler(ErikaSmtcCommand::play, 0); + } else if (args.Button() == Button::Pause) { + state->handler(ErikaSmtcCommand::pause, 0); + } else if (args.Button() == Button::Previous) { + state->handler(ErikaSmtcCommand::previous, 0); + } else if (args.Button() == Button::Next) { + state->handler(ErikaSmtcCommand::next, 0); + } + }); + seek_token = controls.PlaybackPositionChangeRequested( + [state = callback_state](const auto&, const auto& args) { + if (!state->enabled.load(std::memory_order_acquire)) { + return; + } + const auto ticks = args.RequestedPlaybackPosition().count(); + state->handler( + ErikaSmtcCommand::seek, + ticks <= 0 ? 0 : static_cast(ticks / 10)); + }); + } catch (...) { + controls = nullptr; + } + } + + ~Impl() { + callback_state->enabled.store(false, std::memory_order_release); + if (controls) { + controls.ButtonPressed(button_token); + controls.PlaybackPositionChangeRequested(seek_token); + controls.IsEnabled(false); + } + } + + void Update(const ErikaSmtcState& state) { + if (!controls) { + return; + } + try { + if (!has_state || + state.metadata_revision != last_state.metadata_revision) { + auto display = controls.DisplayUpdater(); + display.Type(winrt::Windows::Media::MediaPlaybackType::Music); + auto properties = display.MusicProperties(); + properties.Title(winrt::to_hstring(state.title)); + properties.Artist(winrt::to_hstring(state.artist)); + properties.AlbumTitle(winrt::to_hstring(state.album)); + if (!state.artwork.empty()) { + display.Thumbnail( + winrt::Windows::Storage::Streams::RandomAccessStreamReference::CreateFromStream( + ArtworkStream(state.artwork))); + } else { + display.Thumbnail(nullptr); + } + display.Update(); + } + + if (!has_state || state.duration_micros != last_state.duration_micros || + state.position_micros != last_state.position_micros) { + winrt::Windows::Media::SystemMediaTransportControlsTimelineProperties timeline; + timeline.StartTime(MicrosToTimeSpan(0)); + timeline.MinSeekTime(MicrosToTimeSpan(0)); + timeline.Position(MicrosToTimeSpan( + std::min(state.position_micros, state.duration_micros))); + timeline.MaxSeekTime(MicrosToTimeSpan(state.duration_micros)); + timeline.EndTime(MicrosToTimeSpan(state.duration_micros)); + controls.UpdateTimelineProperties(timeline); + } + if (!has_state || state.playback_rate != last_state.playback_rate) { + controls.PlaybackRate(state.playback_rate); + } + if (!has_state || + state.previous_enabled != last_state.previous_enabled) { + controls.IsPreviousEnabled(state.previous_enabled); + } + if (!has_state || state.next_enabled != last_state.next_enabled) { + controls.IsNextEnabled(state.next_enabled); + } + if (!has_state || state.playing != last_state.playing || + state.stopped != last_state.stopped) { + controls.PlaybackStatus( + state.playing + ? winrt::Windows::Media::MediaPlaybackStatus::Playing + : state.stopped + ? winrt::Windows::Media::MediaPlaybackStatus::Stopped + : winrt::Windows::Media::MediaPlaybackStatus::Paused); + } + last_state = state; + has_state = true; + } catch (...) { + } + } + + void Clear() { + if (!controls) { + return; + } + try { + controls.DisplayUpdater().ClearAll(); + controls.PlaybackStatus( + winrt::Windows::Media::MediaPlaybackStatus::Closed); + has_state = false; + } catch (...) { + } + } + + std::shared_ptr callback_state; + winrt::Windows::Media::SystemMediaTransportControls controls{nullptr}; + winrt::event_token button_token{}; + winrt::event_token seek_token{}; + ErikaSmtcState last_state{}; + bool has_state = false; +}; + +ErikaWindowsSmtc::ErikaWindowsSmtc(HWND window, CommandHandler handler) + : impl_(std::make_unique(window, std::move(handler))) {} + +ErikaWindowsSmtc::~ErikaWindowsSmtc() = default; + +bool ErikaWindowsSmtc::available() const { + return impl_->controls != nullptr; +} + +void ErikaWindowsSmtc::Update(const ErikaSmtcState& state) { + impl_->Update(state); +} + +void ErikaWindowsSmtc::Clear() { + impl_->Clear(); +} + +} diff --git a/third_party/erika_flutter/windows/erika_windows_smtc.h b/third_party/erika_flutter/windows/erika_windows_smtc.h new file mode 100644 index 00000000..69af9da5 --- /dev/null +++ b/third_party/erika_flutter/windows/erika_windows_smtc.h @@ -0,0 +1,64 @@ +#ifndef FLUTTER_PLUGIN_ERIKA_WINDOWS_SMTC_H_ +#define FLUTTER_PLUGIN_ERIKA_WINDOWS_SMTC_H_ + +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include + +#include +#include +#include +#include +#include + +namespace erika_flutter { + +enum class ErikaSmtcCommand { + play, + pause, + toggle, + seek, + previous, + next, +}; + +struct ErikaSmtcState { + int64_t player_id = 0; + std::string title; + std::string artist; + std::string album; + std::vector artwork; + uint64_t metadata_revision = 0; + uint64_t duration_micros = 0; + uint64_t position_micros = 0; + double playback_rate = 1.0; + bool playing = false; + bool stopped = true; + bool previous_enabled = false; + bool next_enabled = false; +}; + +class ErikaWindowsSmtc { + public: + using CommandHandler = + std::function; + + ErikaWindowsSmtc(HWND window, CommandHandler handler); + ~ErikaWindowsSmtc(); + + ErikaWindowsSmtc(const ErikaWindowsSmtc&) = delete; + ErikaWindowsSmtc& operator=(const ErikaWindowsSmtc&) = delete; + + bool available() const; + void Update(const ErikaSmtcState& state); + void Clear(); + + private: + struct Impl; + std::unique_ptr impl_; +}; + +} + +#endif diff --git a/third_party/erika_flutter/windows/include/erika_flutter/erika_flutter_plugin_c_api.h b/third_party/erika_flutter/windows/include/erika_flutter/erika_flutter_plugin_c_api.h new file mode 100644 index 00000000..50820132 --- /dev/null +++ b/third_party/erika_flutter/windows/include/erika_flutter/erika_flutter_plugin_c_api.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_ERIKA_FLUTTER_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_ERIKA_FLUTTER_PLUGIN_C_API_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void ErikaFlutterPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_ERIKA_FLUTTER_PLUGIN_C_API_H_ diff --git a/toolchains/cross/manifest.json b/toolchains/cross/manifest.json new file mode 100644 index 00000000..a5b0b468 --- /dev/null +++ b/toolchains/cross/manifest.json @@ -0,0 +1,85 @@ +{ + "schemaVersion": 1, + "flutterVersion": "3.44.0", + "engineRevision": "4c525dac5ebe5971c5708ef73558ed8edcf4a362", + "host": "darwin", + "hostArchitectures": [ + "arm64", + "x64" + ], + "flutterSnapshotDirectory": "darwin-x64-release", + "snapshotter": "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/snapshotter/gen_snapshot", + "targets": { + "linux": { + "targetPlatform": "linux-x64", + "flutterSnapshotDirectory": "linux-x64-release", + "template": "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template", + "runnerExecutable": "SakiEngine", + "nativePlugins": [ + "flutter_avif_linux", + "flutter_steamworks", + "hotkey_manager_linux", + "media_kit_libs_linux", + "saki_native", + "screen_retriever_linux", + "window_manager" + ], + "output": "build/linux/x64/release/bundle", + "assetsDestination": "data/flutter_assets", + "aotDestination": "lib/libapp.so" + }, + "windows": { + "targetPlatform": "windows-x64", + "flutterSnapshotDirectory": "windows-x64-release", + "template": "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template", + "runnerExecutable": "SakiEngine.exe", + "nativePlugins": [ + "erika_flutter", + "flutter_avif_windows", + "flutter_steamworks", + "hotkey_manager_windows", + "media_kit_libs_windows_video", + "saki_native", + "screen_retriever_windows", + "window_manager" + ], + "output": "build/windows/x64/runner/Release", + "assetsDestination": "data/flutter_assets", + "aotDestination": "data/app.so" + } + }, + "checksums": { + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/SakiEngine": "1f86099412c920a197a3feb5fd8e519edcd99649e7cd7b387dc9827cddb068b0", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/data/icudtl.dat": "998367809a821d595928089c197b3f7959f0420f81f79d4d0daee53378492ed5", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_avif.so": "6d7f94356eb4256ce7ec7b43aac38027fd9f817bb77361930db5f197e81d7b19", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_avif_linux_plugin.so": "766ccba990d8d37d14eb08588e15758bd7632822aa4ca2669578470f787754db", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_linux_gtk.so": "88a520b9543a2c32048868692dac3f930137d8dd1783b79787c7c4c5c49074cb", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_steamworks_plugin.so": "32f3a4df377c1fbd3153bd70c9500e97a04737976904e560b05a5c36d5c4c708", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libhotkey_manager_linux_plugin.so": "82a92ec116a23296258e4e8bd8b87cca7cebb6e2c884cf4238bcb25bce6e6aa0", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libmedia_kit_libs_linux_plugin.so": "13632538bd06d950ffd7ad3a6e15af466e707b4c6aa810c5f1189ce7048b42be", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libsaki_native.so": "afd3ffe73f2df14199575ada9d186416c0cce74dfc3517d142c638592c6ed2d1", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libscreen_retriever_linux_plugin.so": "eacf5c408efee48372c3feae7fdc16fe341330bc6d26af9fc28d84821ab2b28a", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libwindow_manager_plugin.so": "cd32b19e6f3571d4adbf8eb3b6ef5854a78fdd41c4e5775dfd5da24570366d45", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/snapshotter/gen_snapshot": "baca6f772b577df87b93d4b26a2dec31989ec35a860d74a5805962d56a4814d1", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/SakiEngine.exe": "57725ad4f7dbb22912a1637905fa64fcb41cb6621ea3081f6027f20feaab4579", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/d3dcompiler_47.dll": "5653bc7b0e2701561464ef36602ff6171c96bffe96e4c3597359cd7addcba88a", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/data/icudtl.dat": "998367809a821d595928089c197b3f7959f0420f81f79d4d0daee53378492ed5", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/erika_capi.dll": "6c328865d050212c92c81ce202d48d024b4f667b1bb7da7ac23f7c3b084e6847", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/erika_flutter_plugin.dll": "ca54be385d9e8dc63a8e3d29a7a97848d57f7532e64e5efd420751dd884db094", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_avif.dll": "cde05e5440e7b0f7ede06a02d171fbb1d88284ee4a7d1f746add93ec2dd88ed8", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_avif_windows_plugin.dll": "773ab8141ef377c7246752d43dcce25c7822a7d9b74dcf138433283917499a65", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_steamworks_plugin.dll": "c26fa7047f7e25933425ab71354701e3d9857713a1e170284ec2b024747f0568", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_windows.dll": "3168c546aeb0b0e6a77abc97e0de95b45d13ada150faaad6de75e79acaf3b050", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/hotkey_manager_windows_plugin.dll": "b3d6094513be0f38b451382bc7e36797bb9cd456b43f4fc0ef497ac59f546625", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/libEGL.dll": "b2590bd0692f0381fc45c20bf1c7f7f713c9ea19c7ea6bab62efdd1fadc4eaac", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/libGLESv2.dll": "620bb6e38d7ed6c760a0cf4a8eb6a8f64b259b96ff286551cd32cefc6c35ca39", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/libmpv-2.dll": "d5f0694b08c124e785d858d00082f3e3b158dd9138bfc48c0382bf1eb443a5fc", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/media_kit_libs_windows_video_plugin.dll": "5236ee1c52fc24b72661567b5214ed1d9d4af75a2d8117dd862e884f85f598f2", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/saki_native.dll": "b0c3be3f1d11e9917ee9f8f02b35f73a5db4df0138e282a69b191d6fff64089b", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/screen_retriever_windows_plugin.dll": "0e3bd21a65ca04e613534d6d4f29d777fb0f998cf8f568da93065e41f4472ca8", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/vk_swiftshader.dll": "4f33eea716491972cb1ad123a78acef485f852581130d3f3a98a1981009004f2", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/vulkan-1.dll": "3be9a95dd9019aa1aca47ade26f5c1c7c0047f3cf6f633d586c9ec0d3b459566", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/window_manager_plugin.dll": "d5a31d4e6feb16881a861f9724057743efe50cd5e577120c1c0525c7f0cf26d2", + "toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/zlib.dll": "82d5bf175cf882ac9afc1558b416e674606d055966bc09529076b28a498fc0e4" + } +} diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/metadata.json b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/metadata.json new file mode 100644 index 00000000..96cb0401 --- /dev/null +++ b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/metadata.json @@ -0,0 +1,13 @@ +{ + "targetPlatform": "linux-x64", + "runnerExecutable": "SakiEngine", + "nativePlugins": [ + "flutter_avif_linux", + "flutter_steamworks", + "hotkey_manager_linux", + "media_kit_libs_linux", + "saki_native", + "screen_retriever_linux", + "window_manager" + ] +} diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/SakiEngine b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/SakiEngine new file mode 100755 index 00000000..9a2aaf4b Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/SakiEngine differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/data/icudtl.dat b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/data/icudtl.dat new file mode 100644 index 00000000..8f3adcfc Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/data/icudtl.dat differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_avif.so b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_avif.so new file mode 100644 index 00000000..d655f3a1 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_avif.so differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_avif_linux_plugin.so b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_avif_linux_plugin.so new file mode 100644 index 00000000..15421f4b Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_avif_linux_plugin.so differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_linux_gtk.so b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_linux_gtk.so new file mode 100644 index 00000000..19618742 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_linux_gtk.so differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_steamworks_plugin.so b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_steamworks_plugin.so new file mode 100644 index 00000000..2c0b424d Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libflutter_steamworks_plugin.so differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libhotkey_manager_linux_plugin.so b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libhotkey_manager_linux_plugin.so new file mode 100644 index 00000000..c4d13909 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libhotkey_manager_linux_plugin.so differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libmedia_kit_libs_linux_plugin.so b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libmedia_kit_libs_linux_plugin.so new file mode 100644 index 00000000..8bc7ed75 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libmedia_kit_libs_linux_plugin.so differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libsaki_native.so b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libsaki_native.so new file mode 100644 index 00000000..c786f940 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libsaki_native.so differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libscreen_retriever_linux_plugin.so b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libscreen_retriever_linux_plugin.so new file mode 100644 index 00000000..ebd997b8 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libscreen_retriever_linux_plugin.so differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libwindow_manager_plugin.so b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libwindow_manager_plugin.so new file mode 100644 index 00000000..dddc17af Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/linux-x64/template/lib/libwindow_manager_plugin.so differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/snapshotter/gen_snapshot b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/snapshotter/gen_snapshot new file mode 100755 index 00000000..21a02df3 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/snapshotter/gen_snapshot differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/snapshotter/metadata.json b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/snapshotter/metadata.json new file mode 100644 index 00000000..c9fb6d1c --- /dev/null +++ b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/snapshotter/metadata.json @@ -0,0 +1,5 @@ +{ + "flutterVersion": "3.44.0", + "engineRevision": "4c525dac5ebe5971c5708ef73558ed8edcf4a362", + "flutterSnapshotDirectory": "darwin-x64-release" +} diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/metadata.json b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/metadata.json new file mode 100644 index 00000000..a5006f8d --- /dev/null +++ b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/metadata.json @@ -0,0 +1,14 @@ +{ + "targetPlatform": "windows-x64", + "runnerExecutable": "SakiEngine.exe", + "nativePlugins": [ + "erika_flutter", + "flutter_avif_windows", + "flutter_steamworks", + "hotkey_manager_windows", + "media_kit_libs_windows_video", + "saki_native", + "screen_retriever_windows", + "window_manager" + ] +} diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/SakiEngine.exe b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/SakiEngine.exe new file mode 100644 index 00000000..f03acf29 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/SakiEngine.exe differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/d3dcompiler_47.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/d3dcompiler_47.dll new file mode 100644 index 00000000..a208e7e5 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/d3dcompiler_47.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/data/icudtl.dat b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/data/icudtl.dat new file mode 100644 index 00000000..8f3adcfc Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/data/icudtl.dat differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/erika_capi.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/erika_capi.dll new file mode 100644 index 00000000..23fbf055 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/erika_capi.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/erika_flutter_plugin.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/erika_flutter_plugin.dll new file mode 100644 index 00000000..0f6bc6e9 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/erika_flutter_plugin.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_avif.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_avif.dll new file mode 100644 index 00000000..c1ec323e Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_avif.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_avif_windows_plugin.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_avif_windows_plugin.dll new file mode 100644 index 00000000..86097c4b Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_avif_windows_plugin.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_steamworks_plugin.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_steamworks_plugin.dll new file mode 100644 index 00000000..3ebcd0f8 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_steamworks_plugin.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_windows.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_windows.dll new file mode 100644 index 00000000..2af81d19 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/flutter_windows.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/hotkey_manager_windows_plugin.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/hotkey_manager_windows_plugin.dll new file mode 100644 index 00000000..b82b831f Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/hotkey_manager_windows_plugin.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/libEGL.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/libEGL.dll new file mode 100644 index 00000000..46548739 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/libEGL.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/libGLESv2.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/libGLESv2.dll new file mode 100644 index 00000000..90a22d64 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/libGLESv2.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/libmpv-2.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/libmpv-2.dll new file mode 100644 index 00000000..46ce4c75 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/libmpv-2.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/media_kit_libs_windows_video_plugin.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/media_kit_libs_windows_video_plugin.dll new file mode 100644 index 00000000..4f2666f6 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/media_kit_libs_windows_video_plugin.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/saki_native.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/saki_native.dll new file mode 100644 index 00000000..8fb29593 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/saki_native.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/screen_retriever_windows_plugin.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/screen_retriever_windows_plugin.dll new file mode 100644 index 00000000..19b22938 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/screen_retriever_windows_plugin.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/vk_swiftshader.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/vk_swiftshader.dll new file mode 100644 index 00000000..576cd790 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/vk_swiftshader.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/vulkan-1.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/vulkan-1.dll new file mode 100644 index 00000000..8de5d46f Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/vulkan-1.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/window_manager_plugin.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/window_manager_plugin.dll new file mode 100644 index 00000000..b6ef34e7 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/window_manager_plugin.dll differ diff --git a/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/zlib.dll b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/zlib.dll new file mode 100644 index 00000000..ab45f351 Binary files /dev/null and b/toolchains/cross/packs/3.44.0-4c525dac5ebe5971c5708ef73558ed8edcf4a362/windows-x64/template/zlib.dll differ