diff --git a/lib/mpv/player/mpv_node_decoder.dart b/lib/mpv/player/mpv_node_decoder.dart new file mode 100644 index 00000000..17fc600a --- /dev/null +++ b/lib/mpv/player/mpv_node_decoder.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; + +/// Decodes an mpv node delivered either as a platform-channel value or JSON. +abstract final class MpvNodeDecoder { + static List? decodeList(Object? value) { + final decoded = _decode(value); + return decoded is List ? decoded : null; + } + + static Map? decodeMap(Object? value) { + final decoded = _decode(value); + return decoded is Map ? decoded : null; + } + + static Object? _decode(Object? value) { + if (value is List || value is Map) return value; + if (value is! String || value.isEmpty) return null; + + try { + return jsonDecode(value); + } on FormatException { + return null; + } + } +} diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 865e6761..8822b3cd 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math' as math; import 'package:collection/collection.dart'; @@ -11,6 +10,7 @@ import '../../utils/app_logger.dart'; import '../../utils/track_label_builder.dart'; import '../font_loader.dart'; import '../models.dart'; +import 'mpv_node_decoder.dart'; import 'player.dart'; import 'player_state.dart'; import 'player_stream_controllers.dart'; @@ -258,17 +258,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { break; case 'track-list': - List? trackList; - if (value is List) { - trackList = value; - } else if (value is String && value.isNotEmpty) { - try { - final parsed = jsonDecode(value); - if (parsed is List) trackList = parsed; - } catch (e) { - appLogger.d('Player: track-list parse failed', error: e); - } - } + final trackList = MpvNodeDecoder.decodeList(value); if (trackList != null) { final result = parseTrackList(trackList); _state = _state.copyWith(tracks: result.tracks); @@ -298,17 +288,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { break; case 'audio-device-list': - List? deviceList; - if (value is List) { - deviceList = value; - } else if (value is String && value.isNotEmpty) { - try { - final parsed = jsonDecode(value); - if (parsed is List) deviceList = parsed; - } catch (e) { - appLogger.d('Player: device-list parse failed', error: e); - } - } + final deviceList = MpvNodeDecoder.decodeList(value); if (deviceList != null) { final devices = deviceList .whereType() @@ -331,19 +311,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { /// Parse demuxer-cache-state property to extract seekable ranges and buffer end. void _handleDemuxerCacheState(dynamic value) { - Map? cacheState; - if (value is Map) { - cacheState = value; - } else if (value is String && value.isNotEmpty) { + if (value is String && value.isNotEmpty) { // Throttle JSON parsing to avoid ANR on low-end devices final nowMs = _throttleSw.elapsedMilliseconds; if (nowMs - _lastCacheStateMs < 250) return; _lastCacheStateMs = nowMs; - try { - final parsed = jsonDecode(value); - if (parsed is Map) cacheState = parsed; - } catch (_) {} } + final cacheState = MpvNodeDecoder.decodeMap(value); if (cacheState == null) return; // Extract cache-end for the single buffer duration (replaces demuxer-cache-time) diff --git a/test/mpv/mpv_node_decoder_test.dart b/test/mpv/mpv_node_decoder_test.dart new file mode 100644 index 00000000..37161d53 --- /dev/null +++ b/test/mpv/mpv_node_decoder_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/mpv/player/mpv_node_decoder.dart'; + +void main() { + const nestedList = [ + { + 'type': 'audio', + 'metadata': { + 'values': [true, 2, 3.5, null], + }, + }, + ]; + const nestedMap = { + 'cache-end': 12.5, + 'seekable-ranges': [ + {'start': 1, 'end': 9.25}, + ], + }; + + final cases = <({String name, Object? input, Object? list, Object? map})>[ + (name: 'nested structured list', input: nestedList, list: nestedList, map: null), + (name: 'nested structured map', input: nestedMap, list: null, map: nestedMap), + ( + name: 'valid list JSON', + input: '[{"type":"audio","metadata":{"values":[true,2,3.5,null]}}]', + list: nestedList, + map: null, + ), + ( + name: 'valid map JSON', + input: '{"cache-end":12.5,"seekable-ranges":[{"start":1,"end":9.25}]}', + list: null, + map: nestedMap, + ), + (name: 'malformed JSON', input: '{not-json', list: null, map: null), + (name: 'JSON scalar', input: '42', list: null, map: null), + (name: 'structured scalar', input: 42, list: null, map: null), + (name: 'empty string', input: '', list: null, map: null), + (name: 'null', input: null, list: null, map: null), + (name: 'JSON null', input: 'null', list: null, map: null), + (name: 'empty structured list', input: const [], list: const [], map: null), + (name: 'empty structured map', input: const {}, list: null, map: const {}), + (name: 'empty JSON list', input: '[]', list: const [], map: null), + (name: 'empty JSON map', input: '{}', list: null, map: const {}), + ]; + + for (final testCase in cases) { + test(testCase.name, () { + expect(MpvNodeDecoder.decodeList(testCase.input), testCase.list); + expect(MpvNodeDecoder.decodeMap(testCase.input), testCase.map); + }); + } +} diff --git a/test/mpv/player_native_bridge_test.dart b/test/mpv/player_native_bridge_test.dart index 8b206fdb..b36630d8 100644 --- a/test/mpv/player_native_bridge_test.dart +++ b/test/mpv/player_native_bridge_test.dart @@ -101,17 +101,29 @@ void main() { {'start': 1, 'end': 9.25}, ], }); + await sendObservation('audio-device-list', const [ + {'name': 'speakers', 'description': 'Main speakers'}, + ]); 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)); + expect(player.state.audioDevices.single.name, 'speakers'); // 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'); + for (final invalid in [null, '{not-json', 42]) { + await sendObservation('track-list', invalid); + await sendObservation('demuxer-cache-state', invalid); + await sendObservation('audio-device-list', invalid); + + expect(player.state.tracks.audio.single.id, '7'); + expect(player.state.buffer, const Duration(milliseconds: 12500)); + expect(player.state.bufferRanges.single.end, const Duration(milliseconds: 9250)); + expect(player.state.audioDevices.single.name, 'speakers'); + } } finally { await player.dispose(); }