fix(music): convert SAF content:// downloads for gapless arming

setNext() appended raw content:// URIs that mpv cannot open, stalling
playback at the SD-card-download track boundary. Convert to fdclose://
like open(), track the armed fd, and reclaim it via a new closeContentFd
method when the entry is dropped unplayed (close only when provably
unconsumed — playlist-pos 0 before and after the remove; leak on doubt).
The playlist-pos pre-check also keeps the clear path from removing the
playing entry when mpv rolls into the armed track mid-clear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
edde746
2026-07-06 15:26:59 +02:00
co-authored by Claude Fable 5
parent 4de38f4b17
commit 73be8ab1c8
5 changed files with 531 additions and 90 deletions
@@ -5,6 +5,7 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityAware
@@ -156,6 +157,7 @@ open class MpvPlayerPlugin(
"requestAudioFocus" -> handleRequestAudioFocus(result) "requestAudioFocus" -> handleRequestAudioFocus(result)
"abandonAudioFocus" -> handleAbandonAudioFocus(result) "abandonAudioFocus" -> handleAbandonAudioFocus(result)
"openContentFd" -> handleOpenContentFd(call, result) "openContentFd" -> handleOpenContentFd(call, result)
"closeContentFd" -> handleCloseContentFd(call, result)
"isInitialized" -> result.success(playerCore?.isInitialized ?: false) "isInitialized" -> result.success(playerCore?.isInitialized ?: false)
"setLogLevel" -> result.success(null) "setLogLevel" -> result.success(null)
else -> result.notImplemented() else -> result.notImplemented()
@@ -454,6 +456,26 @@ open class MpvPlayerPlugin(
}.start() }.start()
} }
// Reclaims a detached fd from handleOpenContentFd that mpv will never
// consume (a gapless-armed entry dropped before mpv opened it). The Dart
// side guarantees single-close and only calls this when the entry provably
// never played.
private fun handleCloseContentFd(call: MethodCall, result: MethodChannel.Result) {
val fd = call.argument<Int>("fd")
if (fd == null || fd < 0) {
result.error("INVALID_ARGS", "Missing 'fd'", null)
return
}
try {
ParcelFileDescriptor.adoptFd(fd).close()
Log.d(tag, "Closed content FD $fd")
result.success(null)
} catch (e: Exception) {
Log.e(tag, "Failed to close content FD $fd: ${e.message}", e)
result.error("CLOSE_FAILED", e.message, null)
}
}
// PlayerDelegate // PlayerDelegate
override fun onPropertyChange(name: String, value: Any?) { override fun onPropertyChange(name: String, value: Any?) {
+145 -32
View File
@@ -2,6 +2,7 @@ import 'dart:async' show unawaited;
import 'dart:convert'; import 'dart:convert';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../media/media_display_criteria.dart'; import '../../media/media_display_criteria.dart';
@@ -32,8 +33,17 @@ class PlayerNative extends PlayerBase {
// Gapless-audio arming state (audioOnly). The native playlist is always // Gapless-audio arming state (audioOnly). The native playlist is always
// [current, next?]; these track whether entry 1 exists and what it plays. // [current, next?]; these track whether entry 1 exists and what it plays.
// _armedNextUri keeps the ORIGINAL media URI (the music service matches
// trackTransition events against it); _armedNextFd is the content-fd claim
// when the armed URI needed fdclose:// conversion (see _toPlayableUri).
bool _hasArmedNext = false; bool _hasArmedNext = false;
String? _armedNextUri; String? _armedNextUri;
int? _armedNextFd;
/// Host tests aren't Android, so the content:// → fdclose:// path would be
/// unreachable; forces the conversion regardless of platform.
@visibleForTesting
static bool debugForceContentFdConversion = false;
// Set by open() and consumed by that load's file-loaded event, so it is // Set by open() and consumed by that load's file-loaded event, so it is
// not mistaken for a gapless advance (see _handleAudioFileLoaded). // not mistaken for a gapless advance (see _handleAudioFileLoaded).
@@ -222,6 +232,34 @@ class PlayerNative extends PlayerBase {
} }
} }
/// Closes a detached content fd that mpv will never consume. Fire-and-forget
/// safe: a failure only leaks one fd.
Future<void> _closeContentFd(int fd) async {
try {
await invoke('closeContentFd', {'fd': fd});
} catch (e) {
appLogger.d('$logPrefix: closeContentFd($fd) failed', error: e);
}
}
/// Converts Android SAF content:// URIs to `fdclose://<fd>` — mpv owns the fd
/// and closes it when it opens the entry. Returns the loadfile URI and the
/// opened fd (null when no conversion applied). [strict] throws instead of
/// falling back to the raw URI when the fd cannot be opened: mpv cannot
/// open content:// itself, so arming one would stall playback at the track
/// boundary — setNext must fail loudly so the music service falls back to
/// an explicit open.
Future<(String, int?)> _toPlayableUri(String uri, {bool strict = false}) async {
final convert = (Platform.isAndroid || debugForceContentFdConversion) && uri.startsWith('content://');
if (!convert) return (uri, null);
final fd = await _openContentFd(uri);
if (fd == null) {
if (strict) throw StateError('openContentFd failed for ${_uriTail(uri)}');
return (uri, null);
}
return ('fdclose://$fd', fd);
}
@override @override
Future<void> open( Future<void> open(
Media media, { Media media, {
@@ -234,9 +272,9 @@ class PlayerNative extends PlayerBase {
if (disposed) return; if (disposed) return;
await _ensureInitialized(); await _ensureInitialized();
// `loadfile replace` (below) clears the native playlist, dropping any // `loadfile replace` (below) clears the native playlist, dropping any
// gapless entry armed via setNext. // gapless entry armed via setNext — settle its content-fd claim first.
_hasArmedNext = false; // No transition is surfaced: the caller is replacing playback anyway.
_armedNextUri = null; await _clearArmedNext(adoptIfRolledIn: false);
final startPosition = media.start ?? Duration.zero; final startPosition = media.start ?? Duration.zero;
configureTimeline(offset: timelineOffset, duration: timelineDuration); configureTimeline(offset: timelineOffset, duration: timelineDuration);
clearTracks(); clearTracks();
@@ -276,14 +314,10 @@ class PlayerNative extends PlayerBase {
await setProperty('sid', 'no'); await setProperty('sid', 'no');
await setProperty('secondary-sid', 'no'); await setProperty('secondary-sid', 'no');
// Convert content:// URIs to fdclose:// for MPV on Android (SAF SD card downloads) // Convert content:// URIs to fdclose:// for MPV on Android (SAF SD card
var uri = media.uri; // downloads). The immediate `loadfile replace` consumes the fd, so no
if (Platform.isAndroid && uri.startsWith('content://')) { // claim tracking is needed here (unlike setNext).
final fd = await _openContentFd(uri); final (uri, _) = await _toPlayableUri(media.uri);
if (fd != null) {
uri = 'fdclose://$fd';
}
}
final loadfileArgs = ['loadfile', uri, 'replace']; final loadfileArgs = ['loadfile', uri, 'replace'];
final loadfileOption = _externalSubtitlesLoadfileOption(externalSubtitles); final loadfileOption = _externalSubtitlesLoadfileOption(externalSubtitles);
@@ -314,8 +348,9 @@ class PlayerNative extends PlayerBase {
@override @override
Future<void> stop() async { Future<void> stop() async {
_hasArmedNext = false; // `stop` tears down the playlist without mpv opening the armed entry —
_armedNextUri = null; // settle its content-fd claim first. No transition: playback is ending.
await _clearArmedNext(adoptIfRolledIn: false);
await command(['stop']); await command(['stop']);
setSeekable(false); setSeekable(false);
if (!audioOnly) await invoke('setVisible', {'visible': false}); if (!audioOnly) await invoke('setVisible', {'visible': false});
@@ -331,34 +366,102 @@ class PlayerNative extends PlayerBase {
Future<void> setNext(Media? media) async { Future<void> setNext(Media? media) async {
if (!audioOnly || disposed || !initialized) return; if (!audioOnly || disposed || !initialized) return;
if (_hasArmedNext) { await _clearArmedNext();
_hasArmedNext = false;
_armedNextUri = null;
appLogger.d('MPV-audio: clearing armed entry (playlist-remove 1)');
try {
await command(['playlist-remove', '1']);
} on PlatformException {
// Entry 1 can vanish in the arm/advance race (mpv already rolled into
// it); the append below still lands after the current entry.
}
}
if (media == null) return; if (media == null) return;
final (loadUri, fd) = await _toPlayableUri(media.uri, strict: true);
// Per-entry options are the 4th loadfile argument on mpv >= 0.38 // Per-entry options are the 4th loadfile argument on mpv >= 0.38
// (`loadfile <url> append -1 opt=val`), exactly like open() passes // (`loadfile <url> append -1 opt=val`), exactly like open() passes
// sub-files. `gapless-audio=weak` splices the armed entry into the // sub-files. `gapless-audio=weak` splices the armed entry into the
// running audio stream when formats match. // running audio stream when formats match.
final args = ['loadfile', media.uri, 'append']; final args = ['loadfile', loadUri, 'append'];
final headerOption = _httpHeaderFieldsLoadfileOption(media.headers); final headerOption = _httpHeaderFieldsLoadfileOption(media.headers);
if (headerOption != null) { if (headerOption != null) {
args.addAll(['-1', headerOption]); args.addAll(['-1', headerOption]);
} }
await command(args); try {
await command(args);
} catch (e) {
// The entry never joined the playlist, so the fd has no consumer.
if (fd != null) unawaited(_closeContentFd(fd));
rethrow;
}
_hasArmedNext = true; _hasArmedNext = true;
_armedNextUri = media.uri; _armedNextUri = media.uri;
_armedNextFd = fd;
appLogger.d('MPV-audio: armed next ${_uriTail(media.uri)}'); appLogger.d('MPV-audio: armed next ${_uriTail(media.uri)}');
} }
/// Clears the armed entry (if any), resolving the arm/advance race and the
/// content-fd claim. mpv may have already rolled into the armed entry
/// before this runs; blindly removing index 1 then would remove the
/// PLAYING entry, so playlist-pos is checked first. fd ownership: mpv owns
/// the fd from the moment it opens the entry (fdclose closes it at stream
/// close); Dart may close only when the entry provably never opened —
/// playlist-pos 0 both before and after a successful remove. Anything
/// ambiguous leaks the fd (one fd, ms-wide window) rather than risk
/// closing an fd mpv holds. The remove's success cannot be the ownership
/// signal: Android's command bridge never surfaces mpv command failures.
///
/// [adoptIfRolledIn]: when mpv already advanced into the armed entry,
/// surface the transition here (the pending file-loaded event becomes a
/// no-op once the flags are cleared) — without this a queue edit landing
/// exactly at the gapless boundary desyncs the music service from the
/// audio for the whole next track. Callers that replace or stop playback
/// pass false: no one is listening for that entry anymore.
Future<void> _clearArmedNext({bool adoptIfRolledIn = true}) async {
if (!_hasArmedNext) return;
final uri = _armedNextUri;
final fd = _armedNextFd;
_hasArmedNext = false;
_armedNextUri = null;
_armedNextFd = null;
String? pos;
try {
pos = await getProperty('playlist-pos');
} catch (_) {
// Unknown state — fall through to the remove, never close the fd.
}
if (pos == '1') {
appLogger.d('MPV-audio: clear requested but armed entry already playing');
if (adoptIfRolledIn) _completeArmedAdvance(uri);
return;
}
appLogger.d('MPV-audio: clearing armed entry (playlist-remove 1)');
try {
await command(['playlist-remove', '1']);
} on PlatformException {
// Entry 1 vanished in the arm/advance race — mpv rolled into it and
// the file-loaded handler already rebased. The fd (if any) is mpv's.
return;
}
if (fd == null) return;
String? postPos;
try {
postPos = await getProperty('playlist-pos');
} catch (_) {}
if (pos == '0' && postPos == '0') {
unawaited(_closeContentFd(fd));
}
// Any other combination is ambiguous (mpv advanced mid-clear, idle
// playlist, property error): leak on doubt.
}
/// The armed entry became the playing one (mpv rolled into it): clear the
/// arm — the fd (if any) was consumed by mpv — remove the spent entry so
/// the playing entry rebases to index 0, and surface the transition.
void _completeArmedAdvance(String? uri) {
_hasArmedNext = false;
_armedNextUri = null;
_armedNextFd = null;
appLogger.d('MPV-audio: armed entry advanced → playlist-remove 0, ${_uriTail(uri ?? '')}');
unawaited(command(['playlist-remove', '0']));
if (uri != null) trackTransitionController.add(uri);
}
@override @override
void handlePropertyChange(String name, dynamic value) { void handlePropertyChange(String name, dynamic value) {
if (audioOnly && name == 'playlist-pos') { if (audioOnly && name == 'playlist-pos') {
@@ -402,13 +505,23 @@ class PlayerNative extends PlayerBase {
appLogger.d('MPV-audio: file-loaded (nothing armed, ignored)'); appLogger.d('MPV-audio: file-loaded (nothing armed, ignored)');
return; return;
} }
_completeArmedAdvance(_armedNextUri);
}
final uri = _armedNextUri; @override
_hasArmedNext = false; Future<void> dispose({bool preserveDisplayMode = false}) async {
_armedNextUri = null; if (disposed) return;
appLogger.d('MPV-audio: transition (file-loaded) → playlist-remove 0, ${_uriTail(uri ?? '')}'); // Settle an armed-but-unconsumed content fd before the base teardown
unawaited(command(['playlist-remove', '0'])); // disables invoke() — the playlist is torn down without mpv ever opening
if (uri != null) trackTransitionController.add(uri); // the entry.
if (_hasArmedNext) {
try {
await _clearArmedNext(adoptIfRolledIn: false);
} catch (_) {
// Leak on doubt.
}
}
await super.dispose(preserveDisplayMode: preserveDisplayMode);
} }
@override @override
+308
View File
@@ -0,0 +1,308 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/mpv/player/player_native.dart';
import 'package:plezy/services/settings_service.dart';
import '../test_helpers/mock_player_channels.dart';
import '../test_helpers/prefs.dart';
/// Gapless arming (setNext) on the audio core: content:// → fdclose://
/// conversion and the content-fd ownership rules — Dart closes an armed fd
/// only when the entry provably never played (playlist-pos 0 before and
/// after the remove); every ambiguous outcome leaks rather than risking a
/// close of an fd mpv holds.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
PlayerNative.debugForceContentFdConversion = true;
});
tearDown(() {
PlayerNative.debugForceContentFdConversion = false;
});
Future<void> run(_AudioCoreMock core, Future<void> Function(PlayerNative player, List<String> transitions) body) {
return withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_audio_player',
eventChannelName: 'com.plezy/mpv_audio_player/events',
methodHandler: core.handle,
testBody: () async {
final player = PlayerNative.audio();
final transitions = <String>[];
final sub = player.streams.trackTransition.listen(transitions.add);
try {
await body(player, transitions);
} finally {
await sub.cancel();
await player.dispose();
}
},
);
}
/// Opens a first track and consumes its own file-loaded so later
/// file-loaded events read as gapless roll-ins.
Future<void> openFirst(PlayerNative player, [String uri = 'https://example.test/t1.flac']) async {
await player.open(Media(uri));
player.handlePlayerEvent('file-loaded', null);
}
group('setNext content:// conversion', () {
test('arms fdclose:// and surfaces the ORIGINAL uri on transition', () async {
final core = _AudioCoreMock();
await run(core, (player, transitions) async {
await openFirst(player);
await player.setNext(Media('content://downloads/t2'));
expect(core.openedContentUris, ['content://downloads/t2']);
expect(core.commands('loadfile').last, ['loadfile', 'fdclose://7', 'append']);
player.handlePlayerEvent('file-loaded', null);
await Future<void>.delayed(Duration.zero);
// The service matches transitions against the resolver's URL, so the
// original content:// uri must surface — never the fdclose:// form.
expect(transitions, ['content://downloads/t2']);
expect(core.commands('playlist-remove').last, ['playlist-remove', '0']);
expect(core.closedFds, isEmpty, reason: 'mpv consumed the fd');
});
});
test('open() still converts content:// (regression)', () async {
final core = _AudioCoreMock();
await run(core, (player, transitions) async {
await player.open(Media('content://downloads/t1'));
expect(core.commands('loadfile').single, ['loadfile', 'fdclose://7', 'replace']);
expect(core.closedFds, isEmpty);
});
});
test('non-content arm opens no fd and re-arm still clears entry 1', () async {
final core = _AudioCoreMock();
await run(core, (player, transitions) async {
await openFirst(player);
await player.setNext(Media('https://example.test/t2.flac'));
await player.setNext(Media('https://example.test/t3.flac'));
expect(core.openedContentUris, isEmpty);
expect(core.closedFds, isEmpty);
expect(core.commands('playlist-remove'), [
['playlist-remove', '1'],
]);
expect(core.commands('loadfile').last, ['loadfile', 'https://example.test/t3.flac', 'append']);
});
});
test('openContentFd failure throws instead of arming a raw content:// uri', () async {
final core = _AudioCoreMock()..failOpenContentFd = true;
await run(core, (player, transitions) async {
await openFirst(player);
await expectLater(player.setNext(Media('content://downloads/t2')), throwsStateError);
expect(core.commands('loadfile'), hasLength(1), reason: 'only the open() load');
});
});
});
group('armed fd ownership', () {
test('clearing an unconsumed arm closes the fd (pos 0 before and after)', () async {
final core = _AudioCoreMock();
await run(core, (player, transitions) async {
await openFirst(player);
await player.setNext(Media('content://downloads/t2'));
core.playlistPosResponses.addAll(['0', '0']);
await player.setNext(null);
expect(core.commands('playlist-remove'), [
['playlist-remove', '1'],
]);
expect(core.closedFds, [7]);
expect(transitions, isEmpty);
});
});
test('clear while mpv already rolled in adopts the transition, keeps the fd', () async {
final core = _AudioCoreMock();
await run(core, (player, transitions) async {
await openFirst(player);
await player.setNext(Media('content://downloads/t2'));
core.playlistPosResponses.add('1');
await player.setNext(null);
await Future<void>.delayed(Duration.zero);
expect(transitions, ['content://downloads/t2'], reason: 'the pending file-loaded is a no-op now');
expect(core.commands('playlist-remove'), [
['playlist-remove', '0'],
], reason: 'rebase only — removing index 1 would kill the playing entry');
expect(core.closedFds, isEmpty, reason: 'mpv opened the entry and owns the fd');
// The real file-loaded event arrives late: nothing armed, ignored.
player.handlePlayerEvent('file-loaded', null);
await Future<void>.delayed(Duration.zero);
expect(transitions, hasLength(1));
});
});
test('ambiguous post-remove position leaks on doubt', () async {
final core = _AudioCoreMock();
await run(core, (player, transitions) async {
await openFirst(player);
await player.setNext(Media('content://downloads/t2'));
core.playlistPosResponses.addAll(['0', '-1']);
await player.setNext(null);
expect(core.commands('playlist-remove'), [
['playlist-remove', '1'],
]);
expect(core.closedFds, isEmpty);
});
});
test('playlist-remove failure leaks on doubt', () async {
final core = _AudioCoreMock()..failPlaylistRemove1 = true;
await run(core, (player, transitions) async {
await openFirst(player);
await player.setNext(Media('content://downloads/t2'));
core.playlistPosResponses.add('0');
await player.setNext(null);
expect(core.closedFds, isEmpty);
expect(transitions, isEmpty);
});
});
test('stop() settles an unconsumed armed fd without a transition', () async {
final core = _AudioCoreMock();
await run(core, (player, transitions) async {
await openFirst(player);
await player.setNext(Media('content://downloads/t2'));
core.playlistPosResponses.addAll(['0', '0']);
await player.stop();
expect(core.closedFds, [7]);
expect(transitions, isEmpty);
});
});
test('stop() while rolled in emits no transition and keeps the fd', () async {
final core = _AudioCoreMock();
await run(core, (player, transitions) async {
await openFirst(player);
await player.setNext(Media('content://downloads/t2'));
core.playlistPosResponses.add('1');
await player.stop();
await Future<void>.delayed(Duration.zero);
expect(transitions, isEmpty, reason: 'playback is ending — nobody listens for that entry');
expect(core.closedFds, isEmpty);
});
});
test('open() replace settles an unconsumed armed fd', () async {
final core = _AudioCoreMock();
await run(core, (player, transitions) async {
await openFirst(player);
await player.setNext(Media('content://downloads/t2'));
core.playlistPosResponses.addAll(['0', '0']);
await player.open(Media('https://example.test/t3.flac'));
expect(core.closedFds, [7]);
expect(transitions, isEmpty);
expect(core.commands('loadfile').last, ['loadfile', 'https://example.test/t3.flac', 'replace']);
});
});
test('dispose() settles an unconsumed armed fd', () async {
final core = _AudioCoreMock();
await run(core, (player, transitions) async {
await openFirst(player);
await player.setNext(Media('content://downloads/t2'));
core.playlistPosResponses.addAll(['0', '0']);
await player.dispose();
expect(core.closedFds, [7]);
});
});
test('replacing an unconsumed content arm closes the old fd only', () async {
final core = _AudioCoreMock();
await run(core, (player, transitions) async {
await openFirst(player);
await player.setNext(Media('content://downloads/t2'));
core.playlistPosResponses.addAll(['0', '0']);
await player.setNext(Media('content://downloads/t3'));
expect(core.openedContentUris, ['content://downloads/t2', 'content://downloads/t3']);
expect(core.closedFds, [7]);
expect(core.commands('loadfile').last, ['loadfile', 'fdclose://8', 'append']);
player.handlePlayerEvent('file-loaded', null);
await Future<void>.delayed(Duration.zero);
expect(transitions, ['content://downloads/t3']);
expect(core.closedFds, [7], reason: 'the consumed fd stays with mpv');
});
});
});
}
/// Scriptable mock of the native audio core: records calls, hands out
/// incrementing content fds, and answers playlist-pos reads from a queue
/// (defaulting to '0').
class _AudioCoreMock {
final calls = <MethodCall>[];
final openedContentUris = <String>[];
final closedFds = <int>[];
final playlistPosResponses = <String>[];
int _nextFd = 7;
bool failOpenContentFd = false;
bool failPlaylistRemove1 = false;
Future<Object?> handle(MethodCall call) async {
calls.add(call);
switch (call.method) {
case 'initialize':
return true;
case 'openContentFd':
if (failOpenContentFd) throw PlatformException(code: 'OPEN_FAILED');
openedContentUris.add(_args(call)['uri'] as String);
return _nextFd++;
case 'closeContentFd':
closedFds.add(_args(call)['fd'] as int);
return null;
case 'getProperty':
if (_args(call)['name'] == 'playlist-pos') {
return playlistPosResponses.isEmpty ? '0' : playlistPosResponses.removeAt(0);
}
return null;
case 'command':
final args = (_args(call)['args'] as List).cast<Object?>();
if (failPlaylistRemove1 && args.length >= 2 && args[0] == 'playlist-remove' && args[1] == '1') {
throw PlatformException(code: 'error', message: 'playlist-remove failed');
}
return null;
default:
return null;
}
}
List<List<Object?>> commands(String first) => calls
.where((c) => c.method == 'command')
.map((c) => (_args(c)['args'] as List).cast<Object?>())
.where((args) => args.isNotEmpty && args.first == first)
.toList();
static Map<Object?, Object?> _args(MethodCall call) => Map<Object?, Object?>.from(call.arguments as Map);
}
+19 -58
View File
@@ -9,6 +9,7 @@ import 'package:plezy/mpv/player/platform/player_android.dart';
import 'package:plezy/mpv/player/player_native.dart'; import 'package:plezy/mpv/player/player_native.dart';
import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/settings_service.dart';
import '../test_helpers/mock_player_channels.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
void main() { void main() {
@@ -22,7 +23,7 @@ void main() {
group('player open', () { group('player open', () {
test('ExoPlayer clears stale Dart track state before opening new media', () async { test('ExoPlayer clears stale Dart track state before opening new media', () async {
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player', methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events', eventChannelName: 'com.plezy/exo_player/events',
testBody: () async { testBody: () async {
@@ -48,7 +49,7 @@ void main() {
test('ExoPlayer forwards external subtitle metadata at open', () async { test('ExoPlayer forwards external subtitle metadata at open', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player', methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events', eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) { methodHandler: (call) {
@@ -98,7 +99,7 @@ void main() {
}); });
test('ExoPlayer backend switch clears stale tracks before fallback tracks arrive', () async { test('ExoPlayer backend switch clears stale tracks before fallback tracks arrive', () async {
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player', methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events', eventChannelName: 'com.plezy/exo_player/events',
testBody: () async { testBody: () async {
@@ -137,7 +138,7 @@ void main() {
final initialize = Completer<bool>(); final initialize = Completer<bool>();
final calls = <MethodCall>[]; final calls = <MethodCall>[];
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player', methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events', eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) { methodHandler: (call) {
@@ -183,7 +184,7 @@ void main() {
test('ExoPlayer maps copyts transcode streams as absolute timeline positions', () async { test('ExoPlayer maps copyts transcode streams as absolute timeline positions', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player', methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events', eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) { methodHandler: (call) {
@@ -235,7 +236,7 @@ void main() {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
late PlayerAndroid player; late PlayerAndroid player;
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player', methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events', eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) { methodHandler: (call) {
@@ -280,7 +281,7 @@ void main() {
test('ExoPlayer marks explicit non-zero media starts for native fallback', () async { test('ExoPlayer marks explicit non-zero media starts for native fallback', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player', methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events', eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) { methodHandler: (call) {
@@ -309,7 +310,7 @@ void main() {
}); });
test('MPV clears stale Dart track state before opening new media', () async { test('MPV clears stale Dart track state before opening new media', () async {
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player', methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events', eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async { testBody: () async {
@@ -335,7 +336,7 @@ void main() {
test('MPV disables subtitles before loading media', () async { test('MPV disables subtitles before loading media', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player', methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events', eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) { methodHandler: (call) {
@@ -373,7 +374,7 @@ void main() {
test('MPV passes external subtitles through loadfile options', () async { test('MPV passes external subtitles through loadfile options', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player', methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events', eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) { methodHandler: (call) {
@@ -416,7 +417,7 @@ void main() {
}); });
test('MPV preserves external subtitle metadata for loadfile sidecars', () async { test('MPV preserves external subtitle metadata for loadfile sidecars', () async {
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player', methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events', eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async { testBody: () async {
@@ -460,7 +461,7 @@ void main() {
test('MPV open(play: true) unpauses after loadfile even when previously paused', () async { test('MPV open(play: true) unpauses after loadfile even when previously paused', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player', methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events', eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) { methodHandler: (call) {
@@ -494,7 +495,7 @@ void main() {
test('MPV open(play: false) opens paused and never unpauses', () async { test('MPV open(play: false) opens paused and never unpauses', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player', methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events', eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) { methodHandler: (call) {
@@ -526,7 +527,7 @@ void main() {
}); });
test('MPV exposes file-loaded events through PlayerStreams', () async { test('MPV exposes file-loaded events through PlayerStreams', () async {
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player', methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events', eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async { testBody: () async {
@@ -547,7 +548,7 @@ void main() {
test('MPV maps server-offset streams to absolute timeline positions', () async { test('MPV maps server-offset streams to absolute timeline positions', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player', methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events', eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) { methodHandler: (call) {
@@ -590,7 +591,7 @@ void main() {
test('MPV refresh seek preserves timeline offset position', () async { test('MPV refresh seek preserves timeline offset position', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player', methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events', eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) { methodHandler: (call) {
@@ -630,7 +631,7 @@ void main() {
test('MPV forwards preserve display mode flag on dispose', () async { test('MPV forwards preserve display mode flag on dispose', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player', methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events', eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) { methodHandler: (call) {
@@ -652,7 +653,7 @@ void main() {
test('dispose continues when native event stream cancellation is already detached', () async { test('dispose continues when native event stream cancellation is already detached', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
await _withMockChannels( await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player', methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events', eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) { methodHandler: (call) {
@@ -679,46 +680,6 @@ void main() {
}); });
} }
Future<void> _withMockChannels({
required String methodChannelName,
required String eventChannelName,
Future<Object?> Function(MethodCall call)? methodHandler,
Future<Object?> Function(MethodCall call)? eventHandler,
required Future<void> Function() testBody,
}) async {
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
final methodChannel = MethodChannel(methodChannelName);
final eventChannel = MethodChannel(eventChannelName);
messenger.setMockMethodCallHandler(
methodChannel,
methodHandler ??
(call) async {
switch (call.method) {
case 'initialize':
return true;
case 'observeProperty':
case 'setVisible':
case 'setProperty':
case 'command':
case 'open':
case 'dispose':
return null;
default:
return null;
}
},
);
messenger.setMockMethodCallHandler(eventChannel, eventHandler ?? (call) async => null);
try {
await testBody();
} finally {
messenger.setMockMethodCallHandler(methodChannel, null);
messenger.setMockMethodCallHandler(eventChannel, null);
}
}
void _seedTracks(dynamic player) { void _seedTracks(dynamic player) {
player.handlePropertyChange('track-list', const [ player.handlePropertyChange('track-list', const [
{'type': 'audio', 'id': '2_0', 'title': 'English', 'lang': 'eng', 'selected': true}, {'type': 'audio', 'id': '2_0', 'title': 'English', 'lang': 'eng', 'selected': true},
@@ -0,0 +1,37 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// Installs mock method/event channel handlers for a native player for the
/// duration of [testBody], then removes them.
Future<void> withMockPlayerChannels({
required String methodChannelName,
required String eventChannelName,
Future<Object?> Function(MethodCall call)? methodHandler,
Future<Object?> Function(MethodCall call)? eventHandler,
required Future<void> Function() testBody,
}) async {
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
final methodChannel = MethodChannel(methodChannelName);
final eventChannel = MethodChannel(eventChannelName);
messenger.setMockMethodCallHandler(
methodChannel,
methodHandler ??
(call) async {
switch (call.method) {
case 'initialize':
return true;
default:
return null;
}
},
);
messenger.setMockMethodCallHandler(eventChannel, eventHandler ?? (call) async => null);
try {
await testBody();
} finally {
messenger.setMockMethodCallHandler(methodChannel, null);
messenger.setMockMethodCallHandler(eventChannel, null);
}
}