diff --git a/packages/audioplayers/example/integration_test/lib_test.dart b/packages/audioplayers/example/integration_test/lib_test.dart index 7f1b5606f..2026274c7 100644 --- a/packages/audioplayers/example/integration_test/lib_test.dart +++ b/packages/audioplayers/example/integration_test/lib_test.dart @@ -1,9 +1,5 @@ -@Timeout(Duration(minutes: 5)) -library; - import 'package:audioplayers/audioplayers.dart'; import 'package:audioplayers_example/tabs/sources.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -11,7 +7,6 @@ import 'package:integration_test/integration_test.dart'; import 'lib/lib_source_test_data.dart'; import 'lib/lib_test_utils.dart'; import 'platform_features.dart'; -import 'test_utils.dart'; void main() async { final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -135,36 +130,43 @@ void main() async { interval: const Duration(milliseconds: 100), ); } - final futurePositions = player.onPositionChanged.toList(); + final positionsStream = player.onPositionChanged; + + expectLater(positionsStream, neverEmits(null)); + Future futureExpectations; + if (td.isLiveStream) { + // TODO(gustl22): Live streams may have zero or null as initial + // position. This should be consistent across all platforms. + futureExpectations = expectLater( + positionsStream, + emitsThrough(greaterThan(Duration.zero)), + ); + } else { + futureExpectations = expectLater( + positionsStream, + emitsInOrder([ + Duration.zero, + emitsThrough(greaterThan(Duration.zero)), + emitsThrough(Duration.zero), + ]), + ); + } await player.setReleaseMode(ReleaseMode.stop); await player.setSource(td.source); await player.resume(); await tester.pumpGlobalFrames(const Duration(seconds: 5)); - if (!td.isLiveStream && td.duration! < const Duration(seconds: 2)) { - expect(player.state, PlayerState.completed); - } else { - if (td.isLiveStream || td.duration! > const Duration(seconds: 10)) { - expect(player.state, PlayerState.playing); - } else { - // Don't know for sure, if has yet completed or is still playing - } - await player.stop(); - expect(player.state, PlayerState.stopped); - } + expect( + player.state, + anyOf([PlayerState.completed, PlayerState.playing]), + ); + await player.stop(); + expect(player.state, PlayerState.stopped); + + await futureExpectations; + await player.dispose(); - final positions = await futurePositions; - printOnFailure('Positions: $positions'); - expect(positions, isNot(contains(null))); - expect(positions, contains(greaterThan(Duration.zero))); - if (td.isLiveStream) { - // TODO(gustl22): Live streams may have zero or null as initial - // position. This should be consistent across all platforms. - } else { - expect(positions.first, Duration.zero); - expect(positions.last, Duration.zero); - } }, timeout: const Timeout(Duration(seconds: 30)), skip: @@ -200,32 +202,14 @@ void main() async { (i) async => players[i].play(audioTestDataList[i].source), ), ); - final playerStates = List.generate( - audioTestDataList.length, - (index) => null, - ); - await tester.waitFor( - () async { - // TODO(gustl22): Improve detection of started players via player - // state. - final unplayed = playerStates - .mapIndexed( - (index, element) => element != null ? null : index, - ) - .nonNulls; - for (final i in unplayed) { - final player = players[i]; - if (player.state == PlayerState.completed || - player.state == PlayerState.disposed) { - playerStates[i] = player.state; - } else if (((await player.getCurrentPosition()) ?? - Duration.zero) > - Duration.zero) { - playerStates[i] = PlayerState.playing; - } - } - expect(playerStates, everyElement(isNotNull)); - }, + final playerStates = players.map((player) => player.state); + expect( + playerStates, + everyElement( + (playerState) => + playerState != PlayerState.stopped && + playerState != PlayerState.paused, + ), ); await Future.wait(iterator.map((i) => players[i].stop())); await Future.wait(players.map((p) => p.dispose())); @@ -244,22 +228,12 @@ void main() async { final player = AudioPlayer(); for (final td in audioTestDataList) { - player.play(td.source); - // TODO(gustl22): Improve detection of started players via player - // state. - PlayerState? playerState; - await tester.waitFor( - () async { - if (player.state == PlayerState.completed || - player.state == PlayerState.disposed) { - playerState = player.state; - } else if (((await player.getCurrentPosition()) ?? - Duration.zero) > - Duration.zero) { - playerState = PlayerState.playing; - } - expect(playerState, isNotNull); - }, + await player.play(td.source); + expect( + player.state, + (playerState) => + playerState != PlayerState.stopped && + playerState != PlayerState.paused, ); await player.stop(); } @@ -415,6 +389,71 @@ void main() async { await player.dispose(); }); + group('Call twice:', () { + testWidgets('SetSource', (WidgetTester tester) async { + final player = AudioPlayer(); + await player.setSource(mp3Url1TestData.source); + expect(player.state, PlayerState.stopped); + await player.setSource(mp3Url1TestData.source); + expect(player.state, PlayerState.stopped); + await player.dispose(); + expect(player.state, PlayerState.disposed); + }); + testWidgets('Play', (WidgetTester tester) async { + final player = AudioPlayer(); + await player.play(mp3Url1TestData.source); + expect(player.state, PlayerState.playing); + await player.play(mp3Url1TestData.source); + expect(player.state, PlayerState.playing); + await player.dispose(); + expect(player.state, PlayerState.disposed); + }); + testWidgets('Pause', (WidgetTester tester) async { + final player = AudioPlayer(); + await player.play(mp3Url1TestData.source); + expect(player.state, PlayerState.playing); + await player.pause(); + expect(player.state, PlayerState.paused); + await player.pause(); + expect(player.state, PlayerState.paused); + await player.dispose(); + expect(player.state, PlayerState.disposed); + }); + testWidgets('Resume', (WidgetTester tester) async { + final player = AudioPlayer(); + await player.setSource(mp3Url1TestData.source); + expect(player.state, PlayerState.stopped); + await player.resume(); + expect(player.state, PlayerState.playing); + await player.resume(); + expect(player.state, PlayerState.playing); + await player.dispose(); + expect(player.state, PlayerState.disposed); + }); + testWidgets('Stop', (WidgetTester tester) async { + final player = AudioPlayer(); + await player.play(mp3Url1TestData.source); + expect(player.state, PlayerState.playing); + await player.stop(); + expect(player.state, PlayerState.stopped); + await player.stop(); + expect(player.state, PlayerState.stopped); + await player.dispose(); + expect(player.state, PlayerState.disposed); + }); + testWidgets('Release', (WidgetTester tester) async { + final player = AudioPlayer(); + await player.play(mp3Url1TestData.source); + expect(player.state, PlayerState.playing); + await player.release(); + expect(player.state, PlayerState.stopped); + await player.release(); + expect(player.state, PlayerState.stopped); + await player.dispose(); + expect(player.state, PlayerState.disposed); + }); + }); + group( 'Android only:', () { diff --git a/packages/audioplayers/example/integration_test/platform_test.dart b/packages/audioplayers/example/integration_test/platform_test.dart index 35704b087..69b3ac68c 100644 --- a/packages/audioplayers/example/integration_test/platform_test.dart +++ b/packages/audioplayers/example/integration_test/platform_test.dart @@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:pub_semver/pub_semver.dart'; import 'lib/lib_source_test_data.dart'; import 'platform_features.dart'; @@ -20,15 +19,10 @@ final isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android; bool canDetermineDuration(SourceTestData td) { // TODO(gustl22): cannot determine duration for VBR on Linux - // FIXME(gustl22): duration event is not emitted for short duration - // WAV on Linux (only platform tests, may be a race condition). if (td.duration == null) { return true; } - if (isLinux) { - return !(td.isVBR || td.duration! < const Duration(seconds: 5)); - } - return true; + return !isLinux || !td.isVBR; } void main() async { @@ -69,16 +63,6 @@ void main() async { testData: invalidAssetTestData, ), ); - - // TODO(gustl22): remove when min supported Flutter version is 3.41.0 - if (isLinux && - FlutterVersion.version != null && - Version.parse(FlutterVersion.version!) < Version.parse('3.41.0')) { - // Flutter throws a second failure event for invalid files on Linux. - // If not caught, it would be randomly thrown in the following tests. - final nextEvent = platform.getEventStream(playerId).first; - await tester.expectSettingSourceFailure(future: nextEvent); - } }, ); @@ -147,9 +131,7 @@ void main() async { ), ); }, - // FIXME(gustl22): determines wrong initial position for m3u8 on Linux - skip: !canDetermineDuration(td) || - isLinux && td.source == m3u8UrlTestData.source, + skip: !canDetermineDuration(td), ); } diff --git a/packages/audioplayers/example/pubspec.yaml b/packages/audioplayers/example/pubspec.yaml index 180e09e45..61edb063e 100644 --- a/packages/audioplayers/example/pubspec.yaml +++ b/packages/audioplayers/example/pubspec.yaml @@ -22,7 +22,6 @@ dev_dependencies: sdk: flutter integration_test: sdk: flutter - pub_semver: ^2.2.0 pubspec_parse: ^1.5.0 flutter: diff --git a/packages/audioplayers/lib/src/audioplayer.dart b/packages/audioplayers/lib/src/audioplayer.dart index 0d00e199d..1a86677b4 100644 --- a/packages/audioplayers/lib/src/audioplayer.dart +++ b/packages/audioplayers/lib/src/audioplayer.dart @@ -20,6 +20,7 @@ const _uuid = Uuid(); class AudioPlayer { static final global = GlobalAudioScope(); static Duration preparationTimeout = const Duration(seconds: 30); + static Duration playingStateUpdateTimeout = const Duration(seconds: 30); static Duration seekingTimeout = const Duration(seconds: 30); final _platform = AudioplayersPlatformInterface.instance; @@ -69,16 +70,23 @@ class AudioPlayer { PlayerState get state => _playerState; - /// The current playback state. + /// The current playback and desired state. /// It is only set, when the corresponding action succeeds. set state(PlayerState state) { + _setPlayerState(state); + desiredState = state; + } + + /// Set the player state only. + /// This method purposely does not change the desired player state. + void _setPlayerState(PlayerState state) { if (_playerState == PlayerState.disposed) { throw Exception('AudioPlayer has been disposed'); } if (!_playerStateController.isClosed) { _playerStateController.add(state); } - _playerState = desiredState = state; + _playerState = state; } PositionUpdater? _positionUpdater; @@ -89,6 +97,7 @@ class AudioPlayer { final creatingCompleter = Completer(); late final StreamSubscription _onPlayerCompleteStreamSubscription; + late final StreamSubscription _onPlayingStateUpdateSubscription; late final StreamSubscription _onLogStreamSubscription; @@ -131,6 +140,12 @@ class AudioPlayer { Stream get onPlayerComplete => eventStream.where((event) => event.eventType == AudioEventType.complete); + /// Stream of updates on the playing state. + @visibleForTesting + Stream get onPlayingStateUpdate => eventStream + .where((event) => event.eventType == AudioEventType.playingStateUpdate) + .map((event) => event.isPlaying!); + /// Stream of seek completions. /// /// An event is going to be sent as soon as the audio seek is finished. @@ -181,6 +196,71 @@ class AudioPlayer { /* Errors are already handled via log stream */ }, ); + // Playing state could change from external sources (e.g. system controls). + _onPlayingStateUpdateSubscription = onPlayingStateUpdate.listen( + (isPlaying) async { + if (state == PlayerState.disposed) { + return; + } + final updatedState = isPlaying + ? PlayerState.playing + : desiredState == PlayerState.playing + // Fall back to paused, if desired state was playing, + // but received a non-playing (pause, stop, dispose) event. + ? PlayerState.paused + : desiredState; + if (state != updatedState) { + // When the user triggers a state change (e.g. starts playing), + // the desiredState is set accordingly. + // Therefore the action is triggered by AP, if the desiredState + // differs from the current `state`. + // Then only the player state is set. + // + // On the other hand the action can be triggered by the system + // (e.g. system controls, an interrupting call, etc.). + // Then the desiredState also needs to be updated to the system + // value. + final isTriggeredBySystem = desiredState == state; + _setPlayerState(updatedState); + if (isTriggeredBySystem) { + // If AP didn't request any change, then can also set it as + // desired state. + desiredState = updatedState; + } else { + // Do not override the current desiredState, if it already + // differs, + // as the expected state might come with the next state update. + if (updatedState != desiredState) { + AudioLogger.log( + 'Updated Playing State ($updatedState) did not match desired' + ' state ($desiredState).', + ); + } + } + if (isPlaying) { + _positionUpdater?.start(); + } else { + await _positionUpdater?.stopAndUpdate(); + } + + if (!isTriggeredBySystem && (updatedState == desiredState)) { + // Complete _playingStateUpdateCompleter AFTER: + // - setting the new state + // - updating the position + // to ensure everything is up to date after + // _completePlayingStateUpdate. + _playingStateUpdateCompleter?.complete(); + _playingStateUpdateCompleter = null; + } + } + }, + onError: (Object? e, StackTrace? st) { + if (e != null) { + _playingStateUpdateCompleter?.completeError(e, st); + _playingStateUpdateCompleter = null; + } + }, + ); _create(); positionUpdater = FramePositionUpdater( getPosition: getCurrentPosition, @@ -255,11 +335,11 @@ class AudioPlayer { Future pause() async { desiredState = PlayerState.paused; await creatingCompleter.future; - if (desiredState == PlayerState.paused) { - await _platform.pause(playerId); - state = PlayerState.paused; - await _positionUpdater?.stopAndUpdate(); - } + await _completePlayingStateUpdate( + () => _platform.pause(playerId), + playerState: PlayerState.paused, + ); + // Playing state is set via _onPlayingStateUpdateSubscription } /// Stops the audio that is currently playing. @@ -268,16 +348,26 @@ class AudioPlayer { /// from the last point. Future stop() async { desiredState = PlayerState.stopped; - await creatingCompleter.future; - if (desiredState == PlayerState.stopped) { - await _platform.stop(playerId); - state = PlayerState.stopped; - await _positionUpdater?.stopAndUpdate(); - } + await _stopWithoutDesire(); if (releaseMode == ReleaseMode.release) { await _platform.release(playerId); _source = null; } + // Update position, once the async platform stop has finished. + // The position update in 'onPlayingStateUpdateSubscription' sometimes is + // not sufficient, as seeking to 0 still takes place asynchronously after + // the player has emitted the pause/stop event. + await _positionUpdater?.update(); + } + + /// Stop without changing the desired state. + Future _stopWithoutDesire() async { + await creatingCompleter.future; + await _completePlayingStateUpdate( + () => _platform.stop(playerId), + playerState: PlayerState.stopped, + ); + // Playing state is set via _onPlayingStateUpdateSubscription } /// Resumes the audio that has been paused or stopped. @@ -289,11 +379,11 @@ class AudioPlayer { /// Resume without setting the desired state. Future _resume() async { await creatingCompleter.future; - if (desiredState == PlayerState.playing) { - await _platform.resume(playerId); - state = PlayerState.playing; - _positionUpdater?.start(); - } + await _completePlayingStateUpdate( + () => _platform.resume(playerId), + playerState: PlayerState.playing, + ); + // Playing state is set via _onPlayingStateUpdateSubscription } /// Releases the resources associated with this media player. @@ -377,6 +467,15 @@ class AudioPlayer { /// This can happen immediately after [setSource] has finished or it needs to /// wait for the [AudioEvent] [AudioEventType.prepared] to arrive. Future _completePrepared(Future Function() setSource) async { + // Reset playing state on new source + _playingStateUpdateCompleter?.complete(); + _playingStateUpdateCompleter = null; + if (state == PlayerState.playing) { + // Stop the player, so a new source can be set. + // The desire might still be to play the source, after it has been set. + await _stopWithoutDesire(); + } + await creatingCompleter.future; final preparedFuture = _onPrepared @@ -396,6 +495,53 @@ class AudioPlayer { await _positionUpdater?.update(); } + /// Track playing state update. + Completer? _playingStateUpdateCompleter; + + Future _completePlayingStateUpdate( + Future Function() applyPlayingState, { + required PlayerState playerState, + }) async { + if (desiredState != playerState) { + // The desired state is not the state requested from the method. + // It is therefore obsolete and handled by another method. + return; + } + + // Complete previous state update, no matter which event was emitted, + // as waiting for a new event now. + _playingStateUpdateCompleter?.complete(); + + if (state == playerState) { + // The desired state is already the state requested from the method. + _playingStateUpdateCompleter = null; + return; + } + + if (playerState != PlayerState.playing && state != PlayerState.playing) { + // If both, the desiredState and the actual state are not "playing" + // states, the isPlaying event would never be emitted, as its already + // in the "not playing" state. + // Then just await setting the method, but not the event completer. + _playingStateUpdateCompleter = null; + await applyPlayingState(); + // Check if the desired state is still the methods playerState after + // awaiting the applyPlayingState: + if (playerState == desiredState) { + state = playerState; + } + return; + } + + // The completer is completed by _onPlayingStateUpdateSubscription + final completer = _playingStateUpdateCompleter = Completer(); + + // Wait simultaneously to ensure all errors are propagated through the same + // future. + await Future.wait([applyPlayingState(), completer.future]) + .timeout(AudioPlayer.playingStateUpdateTimeout); + } + /// Sets the URL to a remote link. /// /// The resources will start being fetched or buffered as soon as you call @@ -526,6 +672,7 @@ class AudioPlayer { if (_positionUpdater != null) _positionUpdater!.dispose(), if (!_playerStateController.isClosed) _playerStateController.close(), _onPlayerCompleteStreamSubscription.cancel(), + _onPlayingStateUpdateSubscription.cancel(), _onLogStreamSubscription.cancel(), _eventStreamSubscription.cancel(), _eventStreamController.close(), diff --git a/packages/audioplayers/test/audio_pool_test.dart b/packages/audioplayers/test/audio_pool_test.dart index aa68f1d82..2730467ad 100644 --- a/packages/audioplayers/test/audio_pool_test.dart +++ b/packages/audioplayers/test/audio_pool_test.dart @@ -28,7 +28,7 @@ void main() { expect((pool.source as AssetSource).path, 'audio.mp3'); expect(pool.audioCache.loadedFiles.keys.first, 'audio.mp3'); - stop(); + await stop(); expect((pool.source as AssetSource).path, 'audio.mp3'); }); diff --git a/packages/audioplayers/test/audioplayers_test.dart b/packages/audioplayers/test/audioplayers_test.dart index 35c084beb..3da6de8e3 100644 --- a/packages/audioplayers/test/audioplayers_test.dart +++ b/packages/audioplayers/test/audioplayers_test.dart @@ -46,7 +46,7 @@ void main() { expect(urlSource?.url, 'internet.com/file.mp3'); await player.dispose(); - expect(platform.popCall().method, 'stop'); + // "stop" isn't called, if already in stopped state. expect(platform.popCall().method, 'release'); expect(platform.popLastCall().method, 'dispose'); expect(player.source, null); @@ -139,20 +139,25 @@ void main() { eventType: AudioEventType.log, logMessage: 'someLogMessage', ), - const AudioEvent( - eventType: AudioEventType.complete, - ), - const AudioEvent( - eventType: AudioEventType.seekComplete, - ), + const AudioEvent(eventType: AudioEventType.complete), + const AudioEvent(eventType: AudioEventType.seekComplete), ]; - expect( + final expected = expectLater( player.eventStream, - emitsInOrder(audioEvents), + emitsInOrder([ + ...audioEvents, + // Playing state is emitted via stop after complete. + const AudioEvent( + eventType: AudioEventType.playingStateUpdate, + isPlaying: false, + ), + ]), ); audioEvents.forEach(platform.eventStreamControllers['p1']!.add); + await expected; + await platform.eventStreamControllers['p1']!.close(); }); }); diff --git a/packages/audioplayers/test/fake_audioplayers_platform.dart b/packages/audioplayers/test/fake_audioplayers_platform.dart index 40c49a44f..9b8e86544 100644 --- a/packages/audioplayers/test/fake_audioplayers_platform.dart +++ b/packages/audioplayers/test/fake_audioplayers_platform.dart @@ -42,6 +42,12 @@ class FakeAudioplayersPlatform extends AudioplayersPlatformInterface { @override Future dispose(String playerId) async { calls.add(FakeCall(id: playerId, method: 'dispose')); + eventStreamControllers[playerId]?.add( + const AudioEvent( + eventType: AudioEventType.playingStateUpdate, + isPlaying: false, + ), + ); eventStreamControllers[playerId]?.close(); } @@ -70,16 +76,34 @@ class FakeAudioplayersPlatform extends AudioplayersPlatformInterface { @override Future pause(String playerId) async { calls.add(FakeCall(id: playerId, method: 'pause')); + eventStreamControllers[playerId]?.add( + const AudioEvent( + eventType: AudioEventType.playingStateUpdate, + isPlaying: false, + ), + ); } @override Future release(String playerId) async { calls.add(FakeCall(id: playerId, method: 'release')); + eventStreamControllers[playerId]?.add( + const AudioEvent( + eventType: AudioEventType.playingStateUpdate, + isPlaying: false, + ), + ); } @override Future resume(String playerId) async { calls.add(FakeCall(id: playerId, method: 'resume')); + eventStreamControllers[playerId]?.add( + const AudioEvent( + eventType: AudioEventType.playingStateUpdate, + isPlaying: true, + ), + ); } @override @@ -156,6 +180,12 @@ class FakeAudioplayersPlatform extends AudioplayersPlatformInterface { @override Future stop(String playerId) async { calls.add(FakeCall(id: playerId, method: 'stop')); + eventStreamControllers[playerId]?.add( + const AudioEvent( + eventType: AudioEventType.playingStateUpdate, + isPlaying: false, + ), + ); } @override diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt index 7bf894952..ad17d43a5 100644 --- a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt @@ -228,6 +228,10 @@ class AudioplayersPlugin : FlutterPlugin { player.eventHandler.success("audio.onComplete") } + fun handlePlayingStateUpdate(player: WrappedPlayer, isPlaying: Boolean) { + player.eventHandler.success("audio.onPlayingStateUpdate", hashMapOf("value" to isPlaying)) + } + fun handlePrepared(player: WrappedPlayer, isPrepared: Boolean) { player.eventHandler.success("audio.onPrepared", hashMapOf("value" to isPrepared)) } diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/MediaPlayerWrapper.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/MediaPlayerWrapper.kt index 1e2b3b003..af6243a24 100644 --- a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/MediaPlayerWrapper.kt +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/MediaPlayerWrapper.kt @@ -44,6 +44,10 @@ class MediaPlayerWrapper( } else { error("Changing the playback rate is only available for Android M/23+ or using LOW_LATENCY mode.") } + if (rate != 0.0f) { + // Manually invoke playing state update, as there does not exist a suitable event in MediaPlayer. + wrappedPlayer.onPlayingStateUpdate(true) + } } override fun setSource(source: Source) { @@ -62,10 +66,13 @@ class MediaPlayerWrapper( override fun pause() { mediaPlayer.pause() + // Manually invoke playing state update, as there does not exist a suitable event in MediaPlayer. + wrappedPlayer.onPlayingStateUpdate(false) } override fun stop() { mediaPlayer.stop() + wrappedPlayer.onPlayingStateUpdate(false) } override fun release() { @@ -90,6 +97,7 @@ class MediaPlayerWrapper( override fun reset() { mediaPlayer.reset() + wrappedPlayer.onPlayingStateUpdate(false) } override fun isLiveStream(): Boolean { diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/SoundPoolPlayer.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/SoundPoolPlayer.kt index ff4403546..c59bbcbc2 100644 --- a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/SoundPoolPlayer.kt +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/SoundPoolPlayer.kt @@ -58,6 +58,7 @@ class SoundPoolPlayer( streamId?.let { soundPool.stop(it) streamId = null + wrappedPlayer.onPlayingStateUpdate(false) } } @@ -83,7 +84,10 @@ class SoundPoolPlayer( } override fun pause() { - streamId?.let { soundPool.pause(it) } + streamId?.let { + soundPool.pause(it) + wrappedPlayer.onPlayingStateUpdate(false) + } } override fun updateContext(context: AudioContextAndroid) { @@ -185,6 +189,7 @@ class SoundPoolPlayer( wrappedPlayer.rate, ) } + wrappedPlayer.onPlayingStateUpdate(true) } override fun prepare() { diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt index bb4b6c9d7..9f4065319 100644 --- a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt @@ -295,6 +295,10 @@ class WrappedPlayer internal constructor( ref.handleComplete(this) } + fun onPlayingStateUpdate(isPlaying: Boolean) { + ref.handlePlayingStateUpdate(this, isPlaying) + } + @Suppress("UNUSED_PARAMETER") fun onBuffering(percent: Int) { // TODO(luan): expose this as a stream diff --git a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt index a34fdaa04..41b82fc68 100644 --- a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt +++ b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt @@ -230,6 +230,10 @@ class AudioplayersPlugin : FlutterPlugin { player.eventHandler.success("audio.onComplete") } + fun handlePlayingStateUpdate(player: WrappedPlayer, isPlaying: Boolean) { + player.eventHandler.success("audio.onPlayingStateUpdate", hashMapOf("value" to isPlaying)) + } + fun handlePrepared(player: WrappedPlayer, isPrepared: Boolean) { player.eventHandler.success("audio.onPrepared", hashMapOf("value" to isPrepared)) } diff --git a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/ExoPlayerWrapper.kt b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/ExoPlayerWrapper.kt index a7bf8dce4..a865d07db 100644 --- a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/ExoPlayerWrapper.kt +++ b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/ExoPlayerWrapper.kt @@ -62,6 +62,10 @@ class ExoPlayerWrapper( Player.STATE_ENDED -> wrappedPlayer.onCompletion() } } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + wrappedPlayer.onPlayingStateUpdate(isPlaying) + } } private var player: ExoPlayer diff --git a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt index 86eb8e9c6..0b817220c 100644 --- a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt +++ b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt @@ -244,6 +244,10 @@ class WrappedPlayer internal constructor( ref.handleComplete(this) } + fun onPlayingStateUpdate(isPlaying: Boolean) { + ref.handlePlayingStateUpdate(this, isPlaying) + } + @Suppress("UNUSED_PARAMETER") fun onBuffering(percent: Int) { // TODO(luan): expose this as a stream diff --git a/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/AudioplayersDarwinPlugin.swift b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/AudioplayersDarwinPlugin.swift index 1696f5c98..7853ea4cd 100644 --- a/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/AudioplayersDarwinPlugin.swift +++ b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/AudioplayersDarwinPlugin.swift @@ -436,6 +436,10 @@ class AudioPlayersStreamHandler: NSObject, FlutterStreamHandler { sendEvent(["event": "audio.onSeekComplete"]) } + func onPlayingStateUpdate(isPlaying: Bool) { + sendEvent(["event": "audio.onPlayingStateUpdate", "value": isPlaying] as [String: Any]) + } + func onComplete() { sendEvent(["event": "audio.onComplete"]) } diff --git a/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/WrappedMediaPlayer.swift b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/WrappedMediaPlayer.swift index cecd007d4..e7a08bb97 100644 --- a/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/WrappedMediaPlayer.swift +++ b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/WrappedMediaPlayer.swift @@ -1,4 +1,5 @@ import AVKit +import Combine private let defaultPlaybackRate: Double = 1.0 @@ -29,7 +30,6 @@ enum ReleaseMode: String { private var completionObserver: TimeObserver? private var playerItemStatusObservation: NSKeyValueObservation? - init( reference: AudioplayersDarwinPlugin, eventHandler: AudioPlayersStreamHandler, @@ -50,6 +50,7 @@ enum ReleaseMode: String { self.volume = volume self.releaseMode = releaseMode self.url = url + setUpPlayerObservation(player) } func setSourceUrl( @@ -147,6 +148,7 @@ enum ReleaseMode: String { func dispose() async { await release() + removePlayerObservation(player) self.eventHandler.dispose() } @@ -190,6 +192,29 @@ enum ReleaseMode: String { return playerItem } + private var cancellables = Set() + + private func setUpPlayerObservation(_ player: AVPlayer) { + player.publisher(for: \.timeControlStatus) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { status in + switch status { + case .paused: + self.eventHandler.onPlayingStateUpdate(isPlaying: false) + case .playing: + self.eventHandler.onPlayingStateUpdate(isPlaying: true) + @unknown default: + break + } + } + .store(in: &cancellables) + } + + private func removePlayerObservation(_ player: AVPlayer) { + cancellables.removeAll() + } + private func setUpPlayerItemStatusObservation( _ playerItem: AVPlayerItem ) async throws { diff --git a/packages/audioplayers_linux/linux/audio_player.cc b/packages/audioplayers_linux/linux/audio_player.cc index 0ceb0c0f0..226674445 100644 --- a/packages/audioplayers_linux/linux/audio_player.cc +++ b/packages/audioplayers_linux/linux/audio_player.cc @@ -123,6 +123,13 @@ gboolean AudioPlayer::OnBusMessage(GstBus* bus, if (!data->_isSeekCompleted) { data->OnSeekCompleted(); data->_isSeekCompleted = true; + break; + } + + if (!data->_isInitialized) { + data->_isInitialized = true; + data->OnDurationUpdate(); + data->OnPrepared(true); } break; default: @@ -181,34 +188,30 @@ void AudioPlayer::OnMediaStateChange(GstObject* src, } if (src == GST_OBJECT(playbin)) { - if (*new_state == GST_STATE_READY) { - // Need to set to pause state, in order to make player functional - GstStateChangeReturn ret = - gst_element_set_state(playbin, GST_STATE_PAUSED); - if (ret == GST_STATE_CHANGE_FAILURE) { - // Only use [OnLog] as error is handled via [OnMediaError]. - gchar const* errorDescription = - "OnMediaStateChange -> GST_STATE_CHANGE_FAILURE:" - "Unable to set the pipeline from GST_STATE_READY to " - "GST_STATE_PAUSED."; - this->OnLog(errorDescription); + if (*new_state < GST_STATE_PAUSED) { + if (*new_state == GST_STATE_READY) { + // Need to set to pause state, in order to make player functional + GstStateChangeReturn ret = + gst_element_set_state(playbin, GST_STATE_PAUSED); + if (ret == GST_STATE_CHANGE_FAILURE) { + // Only use [OnLog] as error is handled via [OnMediaError]. + gchar const* errorDescription = + "OnMediaStateChange -> GST_STATE_CHANGE_FAILURE:" + "Unable to set the pipeline from GST_STATE_READY to " + "GST_STATE_PAUSED."; + this->OnLog(errorDescription); + } } if (this->_isInitialized) { this->_isInitialized = false; } - } else if (*old_state == GST_STATE_PAUSED && - *new_state == GST_STATE_PLAYING) { - OnDurationUpdate(); - } else if (*new_state >= GST_STATE_PAUSED) { - if (!this->_isInitialized) { - this->_isInitialized = true; - this->OnPrepared(true); - if (this->_isPlaying) { - Resume(); - } + } else { + if (*old_state == GST_STATE_PAUSED && *new_state == GST_STATE_PLAYING) { + OnPlayingStateUpdate(true); + } else if (*old_state == GST_STATE_PLAYING && + *new_state == GST_STATE_PAUSED) { + OnPlayingStateUpdate(false); } - } else if (this->_isInitialized) { - this->_isInitialized = false; } } } @@ -242,6 +245,16 @@ void AudioPlayer::OnSeekCompleted() { } } +void AudioPlayer::OnPlayingStateUpdate(bool isPlaying) { + if (this->_eventChannel) { + g_autoptr(FlValue) map = fl_value_new_map(); + fl_value_set_string(map, "event", + fl_value_new_string("audio.onPlayingStateUpdate")); + fl_value_set_string(map, "value", fl_value_new_bool(isPlaying)); + fl_event_channel_send(this->_eventChannel, map, nullptr, nullptr); + } +} + void AudioPlayer::OnPlaybackEnded() { if (this->_eventChannel) { g_autoptr(FlValue) map = fl_value_new_map(); diff --git a/packages/audioplayers_linux/linux/audio_player.h b/packages/audioplayers_linux/linux/audio_player.h index d657fe97b..67d774d31 100644 --- a/packages/audioplayers_linux/linux/audio_player.h +++ b/packages/audioplayers_linux/linux/audio_player.h @@ -112,7 +112,9 @@ class AudioPlayer { void OnSeekCompleted(); + void OnPlayingStateUpdate(bool isPlaying); + void OnPlaybackEnded(); void OnPrepared(bool isPrepared); -}; \ No newline at end of file +}; diff --git a/packages/audioplayers_platform_interface/lib/src/api/audio_event.dart b/packages/audioplayers_platform_interface/lib/src/api/audio_event.dart index fabecce50..4457fb771 100644 --- a/packages/audioplayers_platform_interface/lib/src/api/audio_event.dart +++ b/packages/audioplayers_platform_interface/lib/src/api/audio_event.dart @@ -6,6 +6,7 @@ enum AudioEventType { seekComplete, complete, prepared, + playingStateUpdate, } /// Event emitted from the platform implementation. @@ -19,6 +20,7 @@ class AudioEvent { this.duration, this.logMessage, this.isPrepared, + this.isPlaying, }); /// The type of the event. @@ -33,6 +35,9 @@ class AudioEvent { /// Whether the source is prepared to be played. final bool? isPrepared; + /// Whether the audio has started or stopped playing. + final bool? isPlaying; + @override bool operator ==(Object other) { return identical(this, other) || @@ -41,6 +46,7 @@ class AudioEvent { eventType == other.eventType && duration == other.duration && logMessage == other.logMessage && + isPlaying == other.isPlaying && isPrepared == other.isPrepared; } @@ -49,6 +55,7 @@ class AudioEvent { eventType, duration, logMessage, + isPlaying, isPrepared, ); @@ -58,6 +65,7 @@ class AudioEvent { 'eventType: $eventType, ' 'duration: $duration, ' 'logMessage: $logMessage, ' + 'isPlaying: $isPlaying, ' 'isPrepared: $isPrepared' ')'; } diff --git a/packages/audioplayers_platform_interface/lib/src/audioplayers_platform.dart b/packages/audioplayers_platform_interface/lib/src/audioplayers_platform.dart index 4fab6ca48..8706cedc3 100644 --- a/packages/audioplayers_platform_interface/lib/src/audioplayers_platform.dart +++ b/packages/audioplayers_platform_interface/lib/src/audioplayers_platform.dart @@ -253,6 +253,12 @@ mixin EventChannelAudioplayersPlatform ? Duration(milliseconds: millis) : Duration.zero, ); + case 'audio.onPlayingStateUpdate': + final isPlaying = map.getBool('value'); + return AudioEvent( + eventType: AudioEventType.playingStateUpdate, + isPlaying: isPlaying, + ); case 'audio.onComplete': return const AudioEvent(eventType: AudioEventType.complete); case 'audio.onSeekComplete': diff --git a/packages/audioplayers_web/lib/wrapped_player.dart b/packages/audioplayers_web/lib/wrapped_player.dart index 0274ce342..15c4f4567 100644 --- a/packages/audioplayers_web/lib/wrapped_player.dart +++ b/packages/audioplayers_web/lib/wrapped_player.dart @@ -25,6 +25,7 @@ class WrappedPlayer { StreamSubscription? _playerEndedSubscription; StreamSubscription? _playerLoadedDataSubscription; StreamSubscription? _playerPlaySubscription; + StreamSubscription? _playerPauseSubscription; StreamSubscription? _playerSeekedSubscription; StreamSubscription? _playerErrorSubscription; @@ -123,9 +124,28 @@ class WrappedPlayer { duration: p.duration.fromSecondsToDuration(), ), ); + eventStreamController.add( + const AudioEvent( + eventType: AudioEventType.playingStateUpdate, + isPlaying: true, + ), + ); + }, + onError: eventStreamController.addError, + ); + + _playerPauseSubscription = p.onPause.listen( + (_) { + eventStreamController.add( + const AudioEvent( + eventType: AudioEventType.playingStateUpdate, + isPlaying: false, + ), + ); }, onError: eventStreamController.addError, ); + _playerSeekedSubscription = p.onSeeked.listen( (_) { eventStreamController.add( @@ -195,6 +215,8 @@ class WrappedPlayer { _playerSeekedSubscription = null; await _playerPlaySubscription?.cancel(); _playerPlaySubscription = null; + await _playerPauseSubscription?.cancel(); + _playerPauseSubscription = null; await _playerErrorSubscription?.cancel(); _playerErrorSubscription = null; } diff --git a/packages/audioplayers_windows/windows/MediaEngineWrapper.cpp b/packages/audioplayers_windows/windows/MediaEngineWrapper.cpp index 8dacb2623..a6002f7b2 100644 --- a/packages/audioplayers_windows/windows/MediaEngineWrapper.cpp +++ b/packages/audioplayers_windows/windows/MediaEngineWrapper.cpp @@ -34,17 +34,20 @@ class MediaEngineCallbackHelper std::function onLoadedCB, MediaEngineWrapper::ErrorCB errorCB, MediaEngineWrapper::BufferingStateChangeCB bufferingStateChangeCB, + MediaEngineWrapper::PlayingStateChangeCB playingStateUpdateCB, std::function playbackEndedCB, std::function seekCompletedCB) : m_onLoadedCB(onLoadedCB), m_errorCB(errorCB), m_bufferingStateChangeCB(bufferingStateChangeCB), + m_playingStateUpdateCB(playingStateUpdateCB), m_playbackEndedCB(playbackEndedCB), m_seekCompletedCB(seekCompletedCB) { // Ensure that callbacks are valid THROW_HR_IF(E_INVALIDARG, !m_onLoadedCB); THROW_HR_IF(E_INVALIDARG, !m_errorCB); THROW_HR_IF(E_INVALIDARG, !m_bufferingStateChangeCB); + THROW_HR_IF(E_INVALIDARG, !m_playingStateUpdateCB); THROW_HR_IF(E_INVALIDARG, !m_playbackEndedCB); THROW_HR_IF(E_INVALIDARG, !m_seekCompletedCB); } @@ -56,6 +59,7 @@ class MediaEngineCallbackHelper m_onLoadedCB = nullptr; m_errorCB = nullptr; m_bufferingStateChangeCB = nullptr; + m_playingStateUpdateCB = nullptr; m_playbackEndedCB = nullptr; m_seekCompletedCB = nullptr; } @@ -82,6 +86,12 @@ class MediaEngineCallbackHelper m_bufferingStateChangeCB( MediaEngineWrapper::BufferingState::HAVE_NOTHING); break; + case MF_MEDIA_ENGINE_EVENT_PLAYING: + m_playingStateUpdateCB(true); + break; + case MF_MEDIA_ENGINE_EVENT_PAUSE: + m_playingStateUpdateCB(false); + break; case MF_MEDIA_ENGINE_EVENT_ENDED: m_playbackEndedCB(); break; @@ -101,6 +111,7 @@ class MediaEngineCallbackHelper std::function m_onLoadedCB; MediaEngineWrapper::ErrorCB m_errorCB; MediaEngineWrapper::BufferingStateChangeCB m_bufferingStateChangeCB; + MediaEngineWrapper::PlayingStateChangeCB m_playingStateUpdateCB; std::function m_playbackEndedCB; std::function m_seekCompletedCB; bool m_detached = false; @@ -284,6 +295,7 @@ void MediaEngineWrapper::CreateMediaEngine() { [&]() { this->OnLoaded(); }, [&](MF_MEDIA_ENGINE_ERR error, HRESULT hr) { this->OnError(error, hr); }, [&](BufferingState state) { this->OnBufferingStateChange(state); }, + [&](bool isPlaying) { this->OnPlayingStateUpdate(isPlaying); }, [&]() { this->OnPlaybackEnded(); }, [&]() { this->OnSeekCompleted(); }); THROW_IF_FAILED(creationAttributes->SetUnknown(MF_MEDIA_ENGINE_CALLBACK, m_callbackHelper.get())); @@ -345,6 +357,12 @@ void MediaEngineWrapper::OnBufferingStateChange(BufferingState state) { } } +void MediaEngineWrapper::OnPlayingStateUpdate(bool isPlaying) { + if (m_playingStateUpdateCB) { + m_playingStateUpdateCB(isPlaying); + } +} + void MediaEngineWrapper::OnPlaybackEnded() { if (m_playbackEndedCB) { m_playbackEndedCB(); diff --git a/packages/audioplayers_windows/windows/MediaEngineWrapper.h b/packages/audioplayers_windows/windows/MediaEngineWrapper.h index c0c92efe3..cbcb78b55 100644 --- a/packages/audioplayers_windows/windows/MediaEngineWrapper.h +++ b/packages/audioplayers_windows/windows/MediaEngineWrapper.h @@ -18,15 +18,18 @@ class MediaEngineWrapper enum class BufferingState { HAVE_NOTHING = 0, HAVE_ENOUGH = 1 }; using BufferingStateChangeCB = std::function; + using PlayingStateChangeCB = std::function; MediaEngineWrapper(std::function initializedCB, ErrorCB errorCB, BufferingStateChangeCB bufferingStateChangeCB, + PlayingStateChangeCB playingStateUpdateCB, std::function playbackEndedCB, std::function seekCompletedCB) : m_initializedCB(initializedCB), m_errorCB(errorCB), m_bufferingStateChangeCB(bufferingStateChangeCB), + m_playingStateUpdateCB(playingStateUpdateCB), m_playbackEndedCB(playbackEndedCB), m_seekCompletedCB(seekCompletedCB) {} ~MediaEngineWrapper() {} @@ -66,6 +69,7 @@ class MediaEngineWrapper std::function m_initializedCB; ErrorCB m_errorCB; BufferingStateChangeCB m_bufferingStateChangeCB; + PlayingStateChangeCB m_playingStateUpdateCB; std::function m_playbackEndedCB; std::function m_seekCompletedCB; MFPlatformRef m_platformRef; @@ -76,6 +80,7 @@ class MediaEngineWrapper void OnLoaded(); void OnError(MF_MEDIA_ENGINE_ERR error, HRESULT hr); void OnBufferingStateChange(BufferingState state); + void OnPlayingStateUpdate(bool isPlaying); void OnPlaybackEnded(); void OnSeekCompleted(); }; diff --git a/packages/audioplayers_windows/windows/audio_player.cpp b/packages/audioplayers_windows/windows/audio_player.cpp index 4040a4fbf..c4bbac4cd 100644 --- a/packages/audioplayers_windows/windows/audio_player.cpp +++ b/packages/audioplayers_windows/windows/audio_player.cpp @@ -32,14 +32,16 @@ AudioPlayer::AudioPlayer( std::placeholders::_1, std::placeholders::_2); auto onBufferingStateChanged = std::bind(&AudioPlayer::OnMediaStateChange, this, std::placeholders::_1); + auto onPlayingStateUpdateCB = std::bind(&AudioPlayer::OnPlayingStateUpdate, + this, std::placeholders::_1); auto onPlaybackEndedCB = std::bind(&AudioPlayer::OnPlaybackEnded, this); auto onSeekCompletedCB = std::bind(&AudioPlayer::OnSeekCompleted, this); auto onLoadedCB = std::bind(&AudioPlayer::SendInitialized, this); // Create and initialize the MediaEngineWrapper which manages media playback m_mediaEngineWrapper = winrt::make_self( - onLoadedCB, onError, onBufferingStateChanged, onPlaybackEndedCB, - onSeekCompletedCB); + onLoadedCB, onError, onBufferingStateChanged, onPlayingStateUpdateCB, + onPlaybackEndedCB, onSeekCompletedCB); m_mediaEngineWrapper->Initialize(); } @@ -171,6 +173,17 @@ void AudioPlayer::OnPrepared(bool isPrepared) { } } +void AudioPlayer::OnPlayingStateUpdate(bool isPlaying) { + if (this->_eventHandler) { + this->_eventHandler->Success( + std::make_unique(flutter::EncodableMap( + {{flutter::EncodableValue("event"), + flutter::EncodableValue("audio.onPlayingStateUpdate")}, + {flutter::EncodableValue("value"), + flutter::EncodableValue(isPlaying)}}))); + } +} + void AudioPlayer::OnPlaybackEnded() { if (this->_eventHandler) { this->_eventHandler->Success(std::make_unique( diff --git a/packages/audioplayers_windows/windows/audio_player.h b/packages/audioplayers_windows/windows/audio_player.h index 45ddf515d..22b49f633 100644 --- a/packages/audioplayers_windows/windows/audio_player.h +++ b/packages/audioplayers_windows/windows/audio_player.h @@ -116,6 +116,8 @@ class AudioPlayer { void OnMediaStateChange( media::MediaEngineWrapper::BufferingState bufferingState); + void OnPlayingStateUpdate(bool isPlaying); + void OnPlaybackEnded(); void OnDurationUpdate();