refactor(mpv): unify property decoding

This commit is contained in:
edde746
2026-07-12 08:42:26 +02:00
parent 359a6bf02a
commit b7078448ea
4 changed files with 97 additions and 33 deletions
+25
View File
@@ -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<Object?>? decodeList(Object? value) {
final decoded = _decode(value);
return decoded is List<Object?> ? decoded : null;
}
static Map<Object?, Object?>? decodeMap(Object? value) {
final decoded = _decode(value);
return decoded is Map<Object?, Object?> ? 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;
}
}
}
+5 -31
View File
@@ -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<Map>()
@@ -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)
+53
View File
@@ -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);
});
}
}
+14 -2
View File
@@ -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();
}