[video_player_videohole] Migrating from Platform Channels to Dart FFI - #1073
[video_player_videohole] Migrating from Platform Channels to Dart FFI#1073gin7773 wants to merge 34 commits into
Conversation
…tore the return type of restore.
- 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
- 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
| if (controller != null && !controller.isClosed) { | ||
| final VideoEvent videoEvent = _parseVideoEventFromMap(eventMap); | ||
| controller.add(videoEvent); | ||
| } |
There was a problem hiding this comment.
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
| } | ||
|
|
||
| int x = 0, y = 0, width = 0, height = 0; | ||
| ecore_wl2_window_proxy_->ecore_wl2_window_geometry_get(native_window, &x, &y, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I added a TODO in media_player.cc - SetDisplay() and stated the threading-model change in the PR description.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| default: | ||
| return VideoEvent(eventType: VideoEventType.unknown); |
There was a problem hiding this comment.
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 👍 / 👎.
| // 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>>{}; |
There was a problem hiding this comment.
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 👍 / 👎.
| player->SeekTo(position_ms, []() -> void {}); | ||
| return 0; |
There was a problem hiding this comment.
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 👍 / 👎.
| // Inject registrar reference into FFI layer | ||
| ffi_set_plugin_registrar(registrar_ref, plugin_registrar); |
There was a problem hiding this comment.
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 👍 / 👎.
| .asFunction<int Function(ffi.Pointer<ffi.Void>)>()( | ||
| ffi.NativeApi.initializeApiDLData); | ||
| _apiDlInitialized = true; |
There was a problem hiding this comment.
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 👍 / 👎.
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
Main changes:
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.