diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index b1540768..a03896a8 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -73,9 +73,9 @@ class PlayerNative extends PlayerBase { @override bool get attachesExternalSubtitlesAtOpen => true; - /// Node properties are returned as structured maps on macOS/iOS/Linux, - /// but as JSON strings on Android/Windows. - static final String _nodeFormat = (Platform.isAndroid || Platform.isWindows) ? 'string' : 'node'; + /// Node properties are returned as structured maps on desktop and Apple + /// platforms, but as JSON strings on Android. + static final String _nodeFormat = Platform.isAndroid ? 'string' : 'node'; static String _normalizeDvConversionMode(String value) { return switch (value.toLowerCase()) { diff --git a/test/mpv/player_native_bridge_test.dart b/test/mpv/player_native_bridge_test.dart index d37bcbaf..8b206fdb 100644 --- a/test/mpv/player_native_bridge_test.dart +++ b/test/mpv/player_native_bridge_test.dart @@ -53,6 +53,72 @@ void main() { ); }); + test('MPV accepts nested node observations and null unsupported values', () async { + final observations = {}; + await withMockPlayerChannels( + methodChannelName: 'com.plezy/mpv_player', + eventChannelName: 'com.plezy/mpv_player/events', + methodHandler: (call) async { + if (call.method == 'initialize') return true; + if (call.method == 'observeProperty') { + final arguments = call.arguments as Map; + observations[arguments['name'] as String] = arguments['id'] as int; + } + return null; + }, + testBody: () async { + final player = PlayerNative(); + try { + await player.setLogLevel('warn'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + const codec = StandardMethodCodec(); + + Future sendObservation(String name, Object? value) async { + final done = Completer(); + await messenger.handlePlatformMessage( + 'com.plezy/mpv_player/events', + codec.encodeSuccessEnvelope([observations[name], value]), + (_) => done.complete(), + ); + await done.future; + await Future.delayed(Duration.zero); + } + + await sendObservation('track-list', const [ + { + 'type': 'audio', + 'id': 7, + 'title': 'Main', + 'selected': true, + 'metadata': { + 'nested': [true, 2, 3.5, null], + }, + }, + ]); + await sendObservation('demuxer-cache-state', const { + 'cache-end': 12.5, + 'seekable-ranges': [ + {'start': 1, 'end': 9.25}, + ], + }); + + expect(player.state.tracks.audio.single.id, '7'); + expect(player.state.tracks.audio.single.title, 'Main'); + expect(player.state.buffer, const Duration(milliseconds: 12500)); + expect(player.state.bufferRanges.single.start, const Duration(seconds: 1)); + expect(player.state.bufferRanges.single.end, const Duration(milliseconds: 9250)); + + // Unsupported mpv_node formats cross the native bridge as null and + // must not erase the last valid structured observation. + await sendObservation('track-list', null); + expect(player.state.tracks.audio.single.id, '7'); + } finally { + await player.dispose(); + } + }, + ); + }); + test('Android command failure reaches seek recovery', () async { await withMockPlayerChannels( methodChannelName: 'com.plezy/mpv_player', diff --git a/test/mpv/property_observation_test.dart b/test/mpv/property_observation_test.dart index 5275b3cf..c01281fe 100644 --- a/test/mpv/property_observation_test.dart +++ b/test/mpv/property_observation_test.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/mpv/player/platform/player_android.dart'; @@ -94,5 +96,12 @@ void main() { final registered = names(observations); expect(registered, containsAll(coreNames)); expect(registered, containsAll({'secondary-sid', 'demuxer-cache-state', 'audio-device-list', 'audio-device'})); + final structuredFormat = Platform.isAndroid ? 'string' : 'node'; + for (final call in observations.where((call) { + final name = (call.arguments as Map)['name']; + return name == 'track-list' || name == 'demuxer-cache-state' || name == 'audio-device-list'; + })) { + expect((call.arguments as Map)['format'], structuredFormat); + } }); } diff --git a/windows/runner/mpv/mpv_player.cpp b/windows/runner/mpv/mpv_player.cpp index 6174d5e5..eadcc540 100644 --- a/windows/runner/mpv/mpv_player.cpp +++ b/windows/runner/mpv/mpv_player.cpp @@ -25,6 +25,46 @@ constexpr std::chrono::milliseconds kNullFirstDelay{500}; constexpr std::chrono::milliseconds kNullBackoffCap{8000}; constexpr std::chrono::milliseconds kDeviceListDebounce{250}; +flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) { + if (!node) return flutter::EncodableValue(); + + switch (node->format) { + case MPV_FORMAT_STRING: + return flutter::EncodableValue(SanitizeUtf8(node->u.string)); + case MPV_FORMAT_FLAG: + return flutter::EncodableValue(node->u.flag != 0); + case MPV_FORMAT_INT64: + return flutter::EncodableValue(node->u.int64); + case MPV_FORMAT_DOUBLE: + return flutter::EncodableValue(node->u.double_); + case MPV_FORMAT_NODE_ARRAY: { + const mpv_node_list* node_list = node->u.list; + if (!node_list || node_list->num < 0 || (node_list->num > 0 && !node_list->values)) { + return flutter::EncodableValue(); + } + flutter::EncodableList list; + list.reserve(static_cast(node_list->num)); + for (int i = 0; i < node_list->num; ++i) { + list.push_back(NodeToEncodableValue(&node_list->values[i])); + } + return flutter::EncodableValue(list); + } + case MPV_FORMAT_NODE_MAP: { + const mpv_node_list* node_list = node->u.list; + if (!node_list || node_list->num < 0 || (node_list->num > 0 && (!node_list->values || !node_list->keys))) { + return flutter::EncodableValue(); + } + flutter::EncodableMap map; + for (int i = 0; i < node_list->num; ++i) { + map[flutter::EncodableValue(SanitizeUtf8(node_list->keys[i]))] = NodeToEncodableValue(&node_list->values[i]); + } + return flutter::EncodableValue(map); + } + default: + return flutter::EncodableValue(); + } +} + // DComp-mode input forwarding. mpv's inner window lives on mpv's own thread // and consumes the mouse input over the video (WS_EX_TRANSPARENT hit-test // skipping is same-thread-only, and disabling the subtree makes the system @@ -528,7 +568,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { } case MPV_EVENT_PROPERTY_CHANGE: { auto* prop = static_cast(event->data); - mpv_node node; + mpv_node node{}; node.format = prop->format; switch (prop->format) { @@ -636,30 +676,11 @@ void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) { auto it = name_to_id_.find(name); if (it == name_to_id_.end()) return; - flutter::EncodableValue value; - if (data) { - switch (data->format) { - case MPV_FORMAT_STRING: - value = flutter::EncodableValue(SanitizeUtf8(data->u.string)); - break; - case MPV_FORMAT_FLAG: - value = flutter::EncodableValue(data->u.flag != 0); - break; - case MPV_FORMAT_INT64: - value = flutter::EncodableValue(data->u.int64); - break; - case MPV_FORMAT_DOUBLE: - value = flutter::EncodableValue(data->u.double_); - break; - default: - value = flutter::EncodableValue(); - break; - } - } - + // mpv owns event node storage; copy the full tree before the callback can + // queue it beyond the current mpv_wait_event result's lifetime. flutter::EncodableList list; list.push_back(flutter::EncodableValue(it->second)); - list.push_back(value); + list.push_back(NodeToEncodableValue(data)); std::lock_guard lock(callback_mutex_); if (event_callback_) {