Skip to content

[video_player_videohole] Migrating from Platform Channels to Dart FFI - #1073

Open
gin7773 wants to merge 34 commits into
flutter-tizen:masterfrom
gin7773:videohole/ffi_migration
Open

[video_player_videohole] Migrating from Platform Channels to Dart FFI#1073
gin7773 wants to merge 34 commits into
flutter-tizen:masterfrom
gin7773:videohole/ffi_migration

Conversation

@gin7773

@gin7773 gin7773 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Main changes:

  • Migrated from Platform Channels to Dart FFI.
  • Replaced EventChannel with FFI port for event callbacks.
  • Added JSON serialization for complex parameters.

Threading note

After migrating from Platform Channels to Dart FFI, some native calls are invoked synchronously from the Flutter UI thread rather than through the previous platform-channel handler path. The ecore_wl2 display path is marked with a TODO because those APIs are not thread-safe and will be revisited during the planned ecore-to-GLib migration.

@gin7773
gin7773 marked this pull request as draft July 28, 2026 10:38
gin7773 added 6 commits July 29, 2026 18:01
- Add nlohmann/json single-header library to tizen/third_party/
- Replace handwritten JSON parser with nlohmann/json in ParseJsonMap()
- Simplify ParseCreateMessage() to use json library directly
- Add EncodableValueFromJson() helper for JSON to EncodableValue conversion

This change improves JSON parsing reliability by supporting:
- Escape characters in strings
- Nested objects and arrays
- Unicode characters
- Proper error handling with parse_error exceptions

Co-Authored-By: Cline SR
- Add UnregisterAllPlayerEventPorts() function in video_player.cc
- Add ffi_unregister_all_player_event_ports() FFI wrapper
- Add Dart bindings in ffi_messages.g.dart
- Call unregisterAllPlayerEventPorts() in init() to clean up ports on hot restart

This fix prevents Dart port leaks when the Dart VM is restarted
(e.g., hot restart during development) while the native process continues.

Co-Authored-By: Cline SR
- Fix FFI event port symbol name mismatch (ffi_register_dart_port)
- Call Prepare() after RestorePlayer for two-phase initialization
- Return true from Play() when already playing (idempotent)
- Remove duplicate play() call in restored event handler

Co-Authored-By: Cline SR
@xiaowei-guan
xiaowei-guan self-requested a review July 30, 2026 09:24
@gin7773
gin7773 marked this pull request as ready for review August 5, 2026 08:19
@gin7773
gin7773 requested a review from JSUYA August 5, 2026 08:19
@gin7773
gin7773 requested a review from seungsoo47 August 5, 2026 08:20
Comment thread packages/video_player_videohole/tizen/src/video_player_tizen_plugin.cc Outdated
Comment thread packages/video_player_videohole/tizen/src/video_player.cc Outdated
Comment thread packages/video_player_videohole/tizen/src/media_player.cc
gin7773 added 3 commits August 5, 2026 18:45
- Move all FFI function implementations from video_player_tizen_plugin.cc to ffi_messages.cc
- Add ffi_prepare() function that was missing
- Use dependency injection pattern (ffi_set_plugin_registrar) for clean boundaries
- Keep video_player_tizen_plugin.cc minimal (plugin registration only)

Co-Authored-By: Cline SR
>
> 1. Fix potential deadlock in PostEventToDart
>    - Copy port number under lock, then release lock BEFORE calling Dart_PostCObject_DL
>    - Use explicit scope block to ensure lock is released immediately
>    - Prevents deadlock if Dart_PostCObject_DL blocks or callbacks into native code
>
> 2. Remove dead code (player_index variable)
>    - player_index in video_player.cc is no longer used
>    - ID generation moved to media_player.cc (player_id_counter)
>
> Co-Authored-By: Cline SR
Comment on lines +122 to +125
if (controller != null && !controller.isClosed) {
final VideoEvent videoEvent = _parseVideoEventFromMap(eventMap);
controller.add(videoEvent);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The C++ side posts {"event":"error","code":...,"message":...} through the port, but _parseVideoEventFromMap has no 'error' case, so errors are delivered as VideoEventType.unknown via controller.add().
You can add an error event handling part to the parser.
Or, Fix: branch on the error event before parsing:

if (controller != null && !controller.isClosed) {
  if (eventMap['event'] == 'error') {
    controller.addError(PlatformException(
      code: eventMap['code'] as String? ?? 'unknown',
      message: eventMap['message'] as String?,
    ));
    return;
  }
  final VideoEvent videoEvent = _parseVideoEventFromMap(eventMap);
  controller.add(videoEvent);
}

This restores the old EventChannel event_sink_->Error() semantics

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}

int x = 0, y = 0, width = 0, height = 0;
ecore_wl2_window_proxy_->ecore_wl2_window_geometry_get(native_window, &x, &y,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the FFI migration this path now runs on the Flutter UI thread instead of the platform main thread.

ecore_wl2 APIs are not thread-safe, so in principle this is a crash/UB risk point. Since an ecore → glib migration is already planned, I'm not asking for thread marshaling in this PR — instead please: (1) leave a marker in the code (like TODO) so the risk is visible until the migration lands, (2) state the threading-model change (platform thread → UI thread) in the PR description, and (3) share repeated create/destroy verification results on a real device.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on Tizen9.0 TV(=TIZEN-TRUNK2025-OscarP-RELEASE_20260402.1) with an automated create/dispose stress test.

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('stress test: repeated attach/detach with display (TEMPORARY)', () {
    testWidgets('stress test: 100 cycles of create/display/dispose',
        (WidgetTester tester) async {
      const int cycleCount = 100;
      const String testAsset = 'assets/Butterfly-209.mp4';

      debugPrint(
          '=== STARTING STRESS TEST: $cycleCount cycles of attach/detach ===');

      for (int i = 0; i < cycleCount; i++) {
        final controller = VideoPlayerController.asset(testAsset);

        try {
          await controller.initialize();

          // Verify initialization
          expect(controller.value.isInitialized, true);

          // Attach: Add VideoPlayer widget to the screen
          await tester.pumpWidget(
            Material(
              child: Directionality(
                textDirection: TextDirection.ltr,
                child: Center(
                  child: AspectRatio(
                    aspectRatio: controller.value.aspectRatio,
                    child: VideoPlayer(controller),
                  ),
                ),
              ),
            ),
          );
          await tester.pumpAndSettle();

          debugPrint('=== Cycle $i/$cycleCount: Widget ATTACHED ===');

          await tester.pump();

          await tester.pumpWidget(const SizedBox.shrink());
          await tester.pumpAndSettle();

          debugPrint('=== Cycle $i/$cycleCount: Widget DETACHED ===');

          await controller.dispose();

          debugPrint('=== Cycle $i/$cycleCount: Controller DISPOSED ===');

          await Future<void>.delayed(const Duration(milliseconds: 200));

          if (i % 10 == 0) {
            debugPrint(
                '=== STRESS TEST PROGRESS: $i/$cycleCount cycles completed ===');
          }
        } catch (e, stackTrace) {
          debugPrint('=== Cycle $i/$cycleCount: ERROR - $e ===');
          debugPrint('Stack trace: $stackTrace');
          rethrow;
        }
      }

      debugPrint('=== STRESS TEST COMPLETED: $cycleCount cycles ===');
      debugPrint(
          'Check native logs with: sdb logcat | grep -E "\\[VideoPlayer\\]|\\[FFI\\]|\\[MediaPlayer\\]|\\[EcoreWl2\\]"');
    }, skip: kIsWeb); // Skip on web as it doesn't support local assets
  });
}

Result:

00:00 +0: stress test: repeated attach/detach with display (TEMPORARY) stress test: 80 cycles of create/display/dispose
... ...
00:00 +0: stress test: repeated attach/detach with display (TEMPORARY) stress test: 100 cycles of create/display/dispose
=== STARTING STRESS TEST: 100 cycles of attach/detach ===
FFI bindings loaded successfully
Dart API DL initialized successfully
FFI event port bindings loaded successfully
Event port registered: 7174231465897579
... ...
=== STRESS TEST COMPLETED: 100 cycles ===
Check native logs with: sdb logcat | grep -E "\[VideoPlayer\]|\[FFI\]|\[MediaPlayer\]|\[EcoreWl2\]"
01:45 +1: (tearDownAll)
01:48 +1: All tests passed!

Dispose(), StopAndDestroy() and ~VideoPlayer() were called for each iteration, and no crash or abnormal behavior was observed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a TODO in media_player.cc - SetDisplay() and stated the threading-model change in the PR description.

@JSUYA

JSUYA commented Aug 11, 2026

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 97da2d750e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +375 to +376
default:
return VideoEvent(eventType: VideoEventType.unknown);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Forward native error events as stream errors

When the native player reports an asynchronous failure, VideoPlayer::SendError now posts an event whose type is error, but this switch converts it to an ordinary VideoEventType.unknown. Consequently the stream's onError handler is never called; failures during preparation can leave initialize() waiting forever, and playback errors are silently hidden. Handle error by adding a PlatformException to the controller as an error rather than emitting an unknown event.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +311 to +316
// Static RawReceivePort for FFI event notifications
static RawReceivePort? _eventPort;

// Map of playerId to StreamController for broadcasting events
final Map<int, StreamController<VideoEvent>> _eventControllers =
<int, StreamController<VideoEvent>>{};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebind the event port when the platform instance changes

When VideoPlayerPlatform.instance is replaced with another VideoPlayerTizen, this static port remains non-null, so the new instance skips registration while the port handler still closes over the old instance's _eventControllers. Events for players created by the replacement are therefore routed to the old map, and the new controller can wait indefinitely for its initialized event. The port and its handler need to be instance-scoped or explicitly rebound during initialization.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍Done

Comment on lines +239 to +240
player->SeekTo(position_ms, []() -> void {});
return 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return a failure when native seek is rejected

When MediaPlayer::SeekTo rejects a request—for example because player_set_play_position fails in the current player state—its false return value is discarded and this FFI function still returns success. Dart then completes seekTo() and updates the controller's position even though the native player did not move. Propagate the boolean result as a nonzero FFI error.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍Done

Comment on lines +23 to +24
// Inject registrar reference into FFI layer
ffi_set_plugin_registrar(registrar_ref, plugin_registrar);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore a plugin owner for native teardown

When the Flutter engine or registrar is destroyed without terminating the host process, this registration leaves no flutter::Plugin object whose destructor can dispose g_players; the global players, registrar pointers, and event port therefore outlive the engine and can continue handling callbacks through a dead messenger or view. The previous implementation added an owned plugin and disposed every player in its destructor, so retain an equivalent lifecycle owner while exposing the FFI API.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍Done

Comment on lines +662 to +664
.asFunction<int Function(ffi.Pointer<ffi.Void>)>()(
ffi.NativeApi.initializeApiDLData);
_apiDlInitialized = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check whether Dart API DL initialization succeeded

When the native initializer returns -1, such as for an incompatible Dart API DL version, this code discards that result and permanently marks initialization successful. The event port is then registered even though Dart_PostCObject_DL is unavailable, so no initialized event can reach Dart and controller initialization can hang without any retry or surfaced error. Only set _apiDlInitialized after a zero result and propagate failures to the caller.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍Done

Fix the following issues from PR flutter-tizen#1073 reviews:

1. Error event handling missing
   - Add 'error' event branch in _ensureEventPortRegistered()
   - Call controller.addError(PlatformException(...)) for error events

2. Seek failure return value not propagated
   - ffi_seek_to() now returns player->SeekTo(...) ? 0 : -1

3. Plugin lifecycle management missing
   - Add VideoPlayerTizenPlugin class inheriting flutter::Plugin
   - Call ffi_dispose_all_players() in destructor
   - Add ffi_dispose_all_players() function to clean up players and unregister port

4. Dart API DL initialization check missing
   - Only set _apiDlInitialized if native initialization returns 0
   - Throw exception on failure

5. Event port rebinding issue
   - Change _eventPort from static to instance variable
   - Each VideoPlayerTizen instance has its own port

Co-Authored-By: Cline SR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants