diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt index f1f49e76..91bd881b 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -5,6 +5,7 @@ import android.content.Context import android.net.Uri import android.os.Handler import android.os.Looper +import android.os.ParcelFileDescriptor import android.util.Log import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware @@ -156,6 +157,7 @@ open class MpvPlayerPlugin( "requestAudioFocus" -> handleRequestAudioFocus(result) "abandonAudioFocus" -> handleAbandonAudioFocus(result) "openContentFd" -> handleOpenContentFd(call, result) + "closeContentFd" -> handleCloseContentFd(call, result) "isInitialized" -> result.success(playerCore?.isInitialized ?: false) "setLogLevel" -> result.success(null) else -> result.notImplemented() @@ -454,6 +456,26 @@ open class MpvPlayerPlugin( }.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("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 override fun onPropertyChange(name: String, value: Any?) { diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 092ee93e..b1540768 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -2,6 +2,7 @@ import 'dart:async' show unawaited; import 'dart:convert'; import 'dart:io' show Platform; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/services.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 // [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; 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 // 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 _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://` — 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 Future open( Media media, { @@ -234,9 +272,9 @@ class PlayerNative extends PlayerBase { if (disposed) return; await _ensureInitialized(); // `loadfile replace` (below) clears the native playlist, dropping any - // gapless entry armed via setNext. - _hasArmedNext = false; - _armedNextUri = null; + // gapless entry armed via setNext — settle its content-fd claim first. + // No transition is surfaced: the caller is replacing playback anyway. + await _clearArmedNext(adoptIfRolledIn: false); final startPosition = media.start ?? Duration.zero; configureTimeline(offset: timelineOffset, duration: timelineDuration); clearTracks(); @@ -276,14 +314,10 @@ class PlayerNative extends PlayerBase { await setProperty('sid', 'no'); await setProperty('secondary-sid', 'no'); - // Convert content:// URIs to fdclose:// for MPV on Android (SAF SD card downloads) - var uri = media.uri; - if (Platform.isAndroid && uri.startsWith('content://')) { - final fd = await _openContentFd(uri); - if (fd != null) { - uri = 'fdclose://$fd'; - } - } + // Convert content:// URIs to fdclose:// for MPV on Android (SAF SD card + // downloads). The immediate `loadfile replace` consumes the fd, so no + // claim tracking is needed here (unlike setNext). + final (uri, _) = await _toPlayableUri(media.uri); final loadfileArgs = ['loadfile', uri, 'replace']; final loadfileOption = _externalSubtitlesLoadfileOption(externalSubtitles); @@ -314,8 +348,9 @@ class PlayerNative extends PlayerBase { @override Future stop() async { - _hasArmedNext = false; - _armedNextUri = null; + // `stop` tears down the playlist without mpv opening the armed entry — + // settle its content-fd claim first. No transition: playback is ending. + await _clearArmedNext(adoptIfRolledIn: false); await command(['stop']); setSeekable(false); if (!audioOnly) await invoke('setVisible', {'visible': false}); @@ -331,34 +366,102 @@ class PlayerNative extends PlayerBase { Future setNext(Media? media) async { if (!audioOnly || disposed || !initialized) return; - if (_hasArmedNext) { - _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. - } - } + await _clearArmedNext(); 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 // (`loadfile append -1 opt=val`), exactly like open() passes // sub-files. `gapless-audio=weak` splices the armed entry into the // running audio stream when formats match. - final args = ['loadfile', media.uri, 'append']; + final args = ['loadfile', loadUri, 'append']; final headerOption = _httpHeaderFieldsLoadfileOption(media.headers); if (headerOption != null) { 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; _armedNextUri = media.uri; + _armedNextFd = fd; 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 _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 void handlePropertyChange(String name, dynamic value) { if (audioOnly && name == 'playlist-pos') { @@ -402,13 +505,23 @@ class PlayerNative extends PlayerBase { appLogger.d('MPV-audio: file-loaded (nothing armed, ignored)'); return; } + _completeArmedAdvance(_armedNextUri); + } - final uri = _armedNextUri; - _hasArmedNext = false; - _armedNextUri = null; - appLogger.d('MPV-audio: transition (file-loaded) → playlist-remove 0, ${_uriTail(uri ?? '')}'); - unawaited(command(['playlist-remove', '0'])); - if (uri != null) trackTransitionController.add(uri); + @override + Future dispose({bool preserveDisplayMode = false}) async { + if (disposed) return; + // Settle an armed-but-unconsumed content fd before the base teardown + // disables invoke() — the playlist is torn down without mpv ever opening + // the entry. + if (_hasArmedNext) { + try { + await _clearArmedNext(adoptIfRolledIn: false); + } catch (_) { + // Leak on doubt. + } + } + await super.dispose(preserveDisplayMode: preserveDisplayMode); } @override diff --git a/test/mpv/player_gapless_test.dart b/test/mpv/player_gapless_test.dart new file mode 100644 index 00000000..45c19be4 --- /dev/null +++ b/test/mpv/player_gapless_test.dart @@ -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 run(_AudioCoreMock core, Future Function(PlayerNative player, List 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 = []; + 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 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.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.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.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.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.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 = []; + final openedContentUris = []; + final closedFds = []; + final playlistPosResponses = []; + int _nextFd = 7; + bool failOpenContentFd = false; + bool failPlaylistRemove1 = false; + + Future 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(); + 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> commands(String first) => calls + .where((c) => c.method == 'command') + .map((c) => (_args(c)['args'] as List).cast()) + .where((args) => args.isNotEmpty && args.first == first) + .toList(); + + static Map _args(MethodCall call) => Map.from(call.arguments as Map); +} diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index 8206d752..44c11d0d 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -9,6 +9,7 @@ import 'package:plezy/mpv/player/platform/player_android.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'; void main() { @@ -22,7 +23,7 @@ void main() { group('player open', () { test('ExoPlayer clears stale Dart track state before opening new media', () async { - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/exo_player', eventChannelName: 'com.plezy/exo_player/events', testBody: () async { @@ -48,7 +49,7 @@ void main() { test('ExoPlayer forwards external subtitle metadata at open', () async { final calls = []; - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/exo_player', eventChannelName: 'com.plezy/exo_player/events', methodHandler: (call) { @@ -98,7 +99,7 @@ void main() { }); test('ExoPlayer backend switch clears stale tracks before fallback tracks arrive', () async { - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/exo_player', eventChannelName: 'com.plezy/exo_player/events', testBody: () async { @@ -137,7 +138,7 @@ void main() { final initialize = Completer(); final calls = []; - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/exo_player', eventChannelName: 'com.plezy/exo_player/events', methodHandler: (call) { @@ -183,7 +184,7 @@ void main() { test('ExoPlayer maps copyts transcode streams as absolute timeline positions', () async { final calls = []; - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/exo_player', eventChannelName: 'com.plezy/exo_player/events', methodHandler: (call) { @@ -235,7 +236,7 @@ void main() { final calls = []; late PlayerAndroid player; - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/exo_player', eventChannelName: 'com.plezy/exo_player/events', methodHandler: (call) { @@ -280,7 +281,7 @@ void main() { test('ExoPlayer marks explicit non-zero media starts for native fallback', () async { final calls = []; - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/exo_player', eventChannelName: 'com.plezy/exo_player/events', methodHandler: (call) { @@ -309,7 +310,7 @@ void main() { }); test('MPV clears stale Dart track state before opening new media', () async { - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/mpv_player', eventChannelName: 'com.plezy/mpv_player/events', testBody: () async { @@ -335,7 +336,7 @@ void main() { test('MPV disables subtitles before loading media', () async { final calls = []; - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/mpv_player', eventChannelName: 'com.plezy/mpv_player/events', methodHandler: (call) { @@ -373,7 +374,7 @@ void main() { test('MPV passes external subtitles through loadfile options', () async { final calls = []; - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/mpv_player', eventChannelName: 'com.plezy/mpv_player/events', methodHandler: (call) { @@ -416,7 +417,7 @@ void main() { }); test('MPV preserves external subtitle metadata for loadfile sidecars', () async { - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/mpv_player', eventChannelName: 'com.plezy/mpv_player/events', testBody: () async { @@ -460,7 +461,7 @@ void main() { test('MPV open(play: true) unpauses after loadfile even when previously paused', () async { final calls = []; - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/mpv_player', eventChannelName: 'com.plezy/mpv_player/events', methodHandler: (call) { @@ -494,7 +495,7 @@ void main() { test('MPV open(play: false) opens paused and never unpauses', () async { final calls = []; - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/mpv_player', eventChannelName: 'com.plezy/mpv_player/events', methodHandler: (call) { @@ -526,7 +527,7 @@ void main() { }); test('MPV exposes file-loaded events through PlayerStreams', () async { - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/mpv_player', eventChannelName: 'com.plezy/mpv_player/events', testBody: () async { @@ -547,7 +548,7 @@ void main() { test('MPV maps server-offset streams to absolute timeline positions', () async { final calls = []; - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/mpv_player', eventChannelName: 'com.plezy/mpv_player/events', methodHandler: (call) { @@ -590,7 +591,7 @@ void main() { test('MPV refresh seek preserves timeline offset position', () async { final calls = []; - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/mpv_player', eventChannelName: 'com.plezy/mpv_player/events', methodHandler: (call) { @@ -630,7 +631,7 @@ void main() { test('MPV forwards preserve display mode flag on dispose', () async { final calls = []; - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/mpv_player', eventChannelName: 'com.plezy/mpv_player/events', methodHandler: (call) { @@ -652,7 +653,7 @@ void main() { test('dispose continues when native event stream cancellation is already detached', () async { final calls = []; - await _withMockChannels( + await withMockPlayerChannels( methodChannelName: 'com.plezy/mpv_player', eventChannelName: 'com.plezy/mpv_player/events', methodHandler: (call) { @@ -679,46 +680,6 @@ void main() { }); } -Future _withMockChannels({ - required String methodChannelName, - required String eventChannelName, - Future Function(MethodCall call)? methodHandler, - Future Function(MethodCall call)? eventHandler, - required Future 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) { player.handlePropertyChange('track-list', const [ {'type': 'audio', 'id': '2_0', 'title': 'English', 'lang': 'eng', 'selected': true}, diff --git a/test/test_helpers/mock_player_channels.dart b/test/test_helpers/mock_player_channels.dart new file mode 100644 index 00000000..67f07bbc --- /dev/null +++ b/test/test_helpers/mock_player_channels.dart @@ -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 withMockPlayerChannels({ + required String methodChannelName, + required String eventChannelName, + Future Function(MethodCall call)? methodHandler, + Future Function(MethodCall call)? eventHandler, + required Future 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); + } +}