diff --git a/packages/audioplayers/CHANGELOG.md b/packages/audioplayers/CHANGELOG.md index 93447220c..8f0d74940 100644 --- a/packages/audioplayers/CHANGELOG.md +++ b/packages/audioplayers/CHANGELOG.md @@ -1,3 +1,11 @@ +## 3.1.5 + +* Reset the play position to 0 after `stop` (best-effort) to match other platforms. +* Handle `setAudioContext` gracefully instead of throwing on the global channel. +* Return `null` for current position and duration after `release`, matching other platforms. +* Fix a crash when stopping a network source. +* Emit player error and interrupt events on the platform thread. + ## 3.1.4 * Remove Ecore API. diff --git a/packages/audioplayers/README.md b/packages/audioplayers/README.md index ce79ca6b1..0463ee346 100644 --- a/packages/audioplayers/README.md +++ b/packages/audioplayers/README.md @@ -11,7 +11,7 @@ This package is not an _endorsed_ implementation of `audioplayers`. Therefore, y ```yaml dependencies: audioplayers: ^6.6.0 - audioplayers_tizen: ^3.1.4 + audioplayers_tizen: ^3.1.5 ``` diff --git a/packages/audioplayers/example/assets/coins_no_extension b/packages/audioplayers/example/assets/coins_no_extension new file mode 100644 index 000000000..c0dc31c28 Binary files /dev/null and b/packages/audioplayers/example/assets/coins_no_extension differ diff --git "a/packages/audioplayers/example/assets/coins_non_ascii_\320\270.wav" "b/packages/audioplayers/example/assets/coins_non_ascii_\320\270.wav" new file mode 100644 index 000000000..c0dc31c28 Binary files /dev/null and "b/packages/audioplayers/example/assets/coins_non_ascii_\320\270.wav" differ diff --git a/packages/audioplayers/example/integration_test/audioplayers_test.dart b/packages/audioplayers/example/integration_test/audioplayers_test.dart index 55220cbb0..b50e8e31c 100644 --- a/packages/audioplayers/example/integration_test/audioplayers_test.dart +++ b/packages/audioplayers/example/integration_test/audioplayers_test.dart @@ -1,188 +1,601 @@ +@Timeout(Duration(minutes: 5)) +library; + import 'dart:async'; import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; +import 'package:audioplayers_tizen_example/tabs/sources.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -const String _kAssetAudio = 'nasa_on_a_mission.mp3'; -const Duration _kPlayDuration = Duration(seconds: 1); +import 'lib/lib_source_test_data.dart'; +import 'lib/lib_test_utils.dart'; +import 'platform_features.dart'; +import 'test_utils.dart'; + +const _defaultTimeout = Duration(seconds: 30); void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final features = PlatformFeatures.instance(); + final isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + // Only local asset sources are used; remote URL/stream/bytes playback does + // not emit reliable events on Tizen. This list is built synchronously (no + // await) so all tests are declared before the test runner starts. Awaiting + // getAudioTestDataList() here races the runner on the TV emulator and fails + // with "Can't call test() once tests have begun running". + final assetTestDataList = [wavAsset2TestData]; + + testWidgets('test asset source with special char', + (WidgetTester tester) async { + final player = AudioPlayer(); + + await player.play(specialCharAssetTestData.source); + await expectLater(player.onPlayerComplete.first, completes); + await player.dispose(); + }); - group('asset audio', () { - testWidgets('can be initialized', (WidgetTester tester) async { + testWidgets( + 'test device file source with special char', + (WidgetTester tester) async { final player = AudioPlayer(); - final initialized = Completer(); - player.onDurationChanged.listen( - (Duration duration) => initialized.complete(), - ); - await player.setSourceAsset(_kAssetAudio); - await initialized.future; - expect(player.state, PlayerState.stopped); - - final duration = await player.getDuration(); - expect(duration, isNotNull); - expect(duration!.inMilliseconds, greaterThan(0)); + final path = await player.audioCache.loadPath(specialCharAsset); + expect(path, isNot(contains('%'))); // Ensure path is not URL encoded + await player.play(DeviceFileSource(path)); + await expectLater(player.onPlayerComplete.first, completes); + await player.dispose(); + }, + skip: kIsWeb, + ); - final position = await player.getCurrentPosition(); - expect(duration, isNotNull); - expect(position!.inMilliseconds, 0); + testWidgets( + 'test url source with no extension', + (WidgetTester tester) async { + final player = AudioPlayer(); + await player.play(noExtensionAssetTestData.source); + await expectLater(player.onPlayerComplete.first, completes); await player.dispose(); + }, + ); + + testWidgets('data URI source', (WidgetTester tester) async { + final player = AudioPlayer(); + + await player.play(mp3DataUriTestData.source); + await expectLater(player.onPlayerComplete.first, completes); + await player.dispose(); + }); + + group('AP events', () { + late AudioPlayer player; + + setUp(() async { + player = AudioPlayer( + playerId: 'somePlayerId', + ); }); - testWidgets('can be played', (WidgetTester tester) async { - final player = AudioPlayer(); - final started = Completer(); - player.onPositionChanged.listen((position) { - if (!started.isCompleted) { - started.complete(); + void testPositionUpdater( + LibSourceTestData td, { + bool useTimerPositionUpdater = false, + }) { + final positionUpdaterName = useTimerPositionUpdater + ? 'TimerPositionUpdater' + : 'FramePositionUpdater'; + testWidgets( + '#positionEvent with $positionUpdaterName: ${td.source}', + (tester) async { + if (useTimerPositionUpdater) { + player.positionUpdater = TimerPositionUpdater( + getPosition: player.getCurrentPosition, + interval: const Duration(milliseconds: 100), + ); + } + final futurePositions = player.onPositionChanged.toList(); + + 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); + } + 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); + } + }, + skip: + // FIXME(gustl22): [FLAKY] macos 13 fails on live streams. + (isMacOS && td.isLiveStream) || + // FIXME(gustl22): Android provides no position for samples + // shorter than 0.5 seconds. + (isAndroid && + !td.isLiveStream && + td.duration! < const Duration(seconds: 1)), + ); + } + + /// Test at least one source with [TimerPositionUpdater]. + testPositionUpdater(wavAsset2TestData, useTimerPositionUpdater: true); + + for (final td in assetTestDataList) { + testPositionUpdater(td); + } + }); + + group('play multiple sources', () { + testWidgets( + 'simultaneously', + (WidgetTester tester) async { + final players = + List.generate(assetTestDataList.length, (_) => AudioPlayer()); + + // Start all players simultaneously + final iterator = List.generate(assetTestDataList.length, (i) => i); + await Future.wait( + iterator.map( + (i) async => players[i].play(assetTestDataList[i].source), + ), + ); + final playerStates = List.generate( + assetTestDataList.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)); + }, + ); + await Future.wait(iterator.map((i) => players[i].stop())); + await Future.wait(players.map((p) => p.dispose())); + }, + // FIXME: Causes media error on Android (see #1333, #1353) + // Unexpected platform error: MediaPlayer error with + // what:MEDIA_ERROR_UNKNOWN {what:1} extra:MEDIA_ERROR_SYSTEM + // FIXME: Cannot play multiple players simultaneously at exactly the same + // time on Android Exo Player + skip: isAndroid, + ); + + testWidgets( + 'consecutively', + (WidgetTester tester) async { + final player = AudioPlayer(); + + for (final td in assetTestDataList) { + 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.stop(); } - }); + await player.dispose(); + }, + ); + }); - await player.play(AssetSource(_kAssetAudio)); - await started.future; - expect(player.state, PlayerState.playing); + group('Audio Context', () { + testWidgets( + 'Set global AudioContextConfig on unsupported platforms', + (WidgetTester tester) async { + final audioContext = AudioContextConfig().build(); + final globalLogFuture = AudioPlayer.global.onLog.first; + await AudioPlayer.global.setAudioContext(audioContext); + + expect( + await globalLogFuture, + contains('Setting AudioContext is not supported'), + ); + + final player = AudioPlayer(); + final logFuture = player.onLog.first; + await player.setAudioContext(audioContext); + expect( + await logFuture, + contains('Setting AudioContext is not supported'), + ); + + await player.dispose(); + }, + skip: features.hasRespectSilence, + ); + }); - final position = await player.getCurrentPosition(); - await Future.delayed(_kPlayDuration); - final currentPosition = await player.getCurrentPosition(); - expect(position, isNotNull); - expect(currentPosition, isNotNull); - expect(position! < currentPosition!, true); + testWidgets('Race condition on play and pause (#1687) with asset source', + (WidgetTester tester) async { + final player = AudioPlayer(); - await player.dispose(); - }); + final futurePlay = player.play(wavAsset2TestData.source); - testWidgets('can seek', (WidgetTester tester) async { - final player = AudioPlayer(); - final seek = Completer(); - player.onSeekComplete.listen((event) => seek.complete()); + // Player is still in `stopped` state as it isn't playing yet. + expect(player.state, PlayerState.stopped); + expect(player.desiredState, PlayerState.playing); - await player.setSourceAsset(_kAssetAudio); - const seekToPosition = Duration(seconds: 1); - await player.seek(seekToPosition); - await seek.future; - expect(player.state, PlayerState.stopped); + // Execute `pause` before `play` has finished. + final futurePause = player.pause(); + expect(player.desiredState, PlayerState.paused); - final position = await player.getCurrentPosition(); - expect(position, isNotNull); - expect(position!.inMilliseconds, seekToPosition.inMilliseconds); + await futurePlay; + await futurePause; - await player.dispose(); - }); + expect(player.state, PlayerState.paused); - testWidgets('can seek with different playrate', ( - WidgetTester tester, - ) async { - final player = AudioPlayer(); - final started = Completer(); - player.onPositionChanged.listen((position) { - if (!started.isCompleted) { - started.complete(); - } - }); + await player.dispose(); + }); - final seek = Completer(); - player.onSeekComplete.listen((event) => seek.complete()); + // Ported from upstream platform_test.dart: low-level platform-interface + // (channel contract) regression tests. Source-driven cases use the + // asset-only list to avoid network playback, which is unreliable on Tizen. + group('Platform method channel', () { + late AudioplayersPlatformInterface platform; + late String playerId; + + setUp(() async { + platform = AudioplayersPlatformInterface.instance; + playerId = 'somePlayerId'; + await platform.create(playerId); + }); - await player.play(AssetSource(_kAssetAudio)); - await player.setPlaybackRate(2.0); - await started.future; + tearDown(() async { + await platform.dispose(playerId); + }); - const seekToPosition = Duration(seconds: 10); - await player.seek(seekToPosition); - await seek.future; - await player.pause(); + testWidgets('#create and #dispose', (tester) async { + await platform.dispose(playerId); - final position = await player.getCurrentPosition(); - expect(position, isNotNull); - expect(position, greaterThanOrEqualTo(seekToPosition)); + try { + await platform.stop(playerId); + fail('PlatformException not thrown'); + } on PlatformException catch (e) { + // Tizen reports a plugin-specific message, unlike other platforms. + expect(e.message, 'No AudioPlayer$playerId is exist.'); + } - await player.dispose(); + // Create player again, so it can be disposed in tearDown + await platform.create(playerId); }); - testWidgets('can be paused', (WidgetTester tester) async { - final player = AudioPlayer(); - final started = Completer(); - player.onPositionChanged.listen((position) { - if (!started.isCompleted) { - started.complete(); - } + if (features.hasVolume) { + for (final td in assetTestDataList) { + testWidgets('#volume ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + for (final volume in [0.0, 0.5, 1.0]) { + await platform.setVolume(playerId, volume); + await platform.resume(playerId); + await tester.pump(const Duration(seconds: 1)); + await platform.stop(playerId); + } + }); + } + } + + for (final td in assetTestDataList) { + if (features.hasPlaybackRate && !td.isLiveStream) { + testWidgets('#playbackRate ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + for (final playbackRate in [0.5, 1.0, 2.0]) { + await platform.setPlaybackRate(playerId, playbackRate); + await platform.resume(playerId); + await tester.pump(const Duration(seconds: 1)); + await platform.stop(playerId); + } + }); + } + } + + for (final td in assetTestDataList) { + if (features.hasSeek && !td.isLiveStream) { + testWidgets('#seek with millisecond precision ${td.source}', + (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + + final eventStream = platform.getEventStream(playerId); + final seekCompleter = Completer(); + final onSeekSub = eventStream + .where((event) => event.eventType == AudioEventType.seekComplete) + .listen( + (_) => seekCompleter.complete(), + onError: seekCompleter.completeError, + ); + await platform.seek(playerId, const Duration(milliseconds: 22)); + await seekCompleter.future.timeout(_defaultTimeout); + await onSeekSub.cancel(); + final positionMs = await platform.getCurrentPosition(playerId); + expect( + positionMs != null ? Duration(milliseconds: positionMs) : null, + (Duration? actual) => durationRangeMatcher( + actual, + const Duration(milliseconds: 22), + deviation: const Duration(milliseconds: 1), + ), + ); + }); + } + } + + for (final td in assetTestDataList) { + if (features.hasReleaseModeLoop && + !td.isLiveStream && + td.duration! < const Duration(seconds: 2)) { + testWidgets('#ReleaseMode.loop ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + await platform.setReleaseMode(playerId, ReleaseMode.loop); + await platform.resume(playerId); + await tester.pump(const Duration(seconds: 3)); + await platform.stop(playerId); + }); + } + } + + for (final td in assetTestDataList) { + if (features.hasReleaseModeRelease && !td.isLiveStream) { + testWidgets('#ReleaseMode.release ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + await platform.setReleaseMode(playerId, ReleaseMode.release); + await platform.resume(playerId); + if (td.duration! < const Duration(seconds: 2)) { + await tester.pumpAndSettle(const Duration(seconds: 3)); + } else { + await tester.pumpAndSettle(const Duration(seconds: 1)); + await platform.stop(playerId); + } + expect(await platform.getDuration(playerId), null); + expect(await platform.getCurrentPosition(playerId), null); + }); + } + } + + for (final td in assetTestDataList) { + testWidgets('#release ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + await tester.pump(const Duration(seconds: 1)); + await platform.release(playerId); + expect(await platform.getDuration(playerId), null); + expect(await platform.getCurrentPosition(playerId), null); }); + } + }); - await player.play(AssetSource(_kAssetAudio)); - await started.future; - expect(player.state, PlayerState.playing); - - await Future.delayed(_kPlayDuration); - await player.pause(); - final pausedPosition = await player.getCurrentPosition(); - await Future.delayed(_kPlayDuration); - final currentPosition = await player.getCurrentPosition(); + group('Platform event channel', () { + late AudioplayersPlatformInterface platform; + late String playerId; - expect(player.state, PlayerState.paused); - expect(currentPosition, pausedPosition); + setUp(() async { + platform = AudioplayersPlatformInterface.instance; + playerId = 'somePlayerId'; + await platform.create(playerId); + }); - await player.dispose(); + tearDown(() async { + await platform.dispose(playerId); }); - testWidgets('do not exceed duration after audio completed', ( - WidgetTester tester, - ) async { - final player = AudioPlayer(); - final initialized = Completer(); - final seek = Completer(); - player.onDurationChanged.listen((duration) { - if (!initialized.isCompleted) { - initialized.complete(); - } - }); - player.onSeekComplete.listen((event) { - if (!seek.isCompleted) { - seek.complete(); - } - }); + for (final td in assetTestDataList) { + if (!td.isLiveStream && td.duration! < const Duration(seconds: 2)) { + testWidgets('#completeEvent ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + + expect( + platform.getEventStream(playerId).map((event) => event.eventType), + emitsThrough(AudioEventType.complete), + ); + + await platform.resume(playerId); + await tester.pumpAndSettle(const Duration(seconds: 3)); + }); + } + } + + testWidgets('Listen and cancel twice', (tester) async { + final eventStream = platform.getEventStream(playerId); + for (var i = 0; i < 2; i++) { + final eventSub = eventStream.listen(null); + await eventSub.cancel(); + } + }); - await player.setSourceAsset(_kAssetAudio); - await initialized.future; - final duration = await player.getDuration(); - expect(duration, isNotNull); - await player.seek(duration! - const Duration(milliseconds: 500)); - await seek.future; + testWidgets('Emit platform log', (tester) async { + final eventStream = platform.getEventStream(playerId); + expect( + eventStream, + emitsThrough( + const AudioEvent( + eventType: AudioEventType.log, + logMessage: 'SomeLog', + ), + ), + ); + await platform.emitLog(playerId, 'SomeLog'); + }); - var isComplete = false; - player.onPlayerComplete.listen((event) { - isComplete = true; - }); - await player.resume(); - await Future.delayed(_kPlayDuration); - expect(isComplete, true); + testWidgets('Emit global platform log', (tester) async { + final global = GlobalAudioplayersPlatformInterface.instance; + + final globalEventStream = global.getGlobalEventStream(); + expect( + globalEventStream, + emitsThrough( + const GlobalAudioEvent( + eventType: GlobalAudioEventType.log, + logMessage: 'SomeGlobalLog', + ), + ), + ); - await player.dispose(); + await global.emitGlobalLog('SomeGlobalLog'); }); - testWidgets('receives position updates regularly', ( - WidgetTester tester, - ) async { - final player = AudioPlayer(); - final started = Completer(); - var count = 0; - player.onPositionChanged.listen((position) { - if (!started.isCompleted) { - started.complete(); - } - count += 1; - }); + testWidgets('Emit platform error', (tester) async { + final eventStream = platform.getEventStream(playerId); + expect( + eventStream, + emitsThrough( + emitsError( + isA() + .having( + (PlatformException e) => e.code, + 'code', + 'SomeErrorCode', + ) + .having( + (PlatformException e) => e.message, + 'message', + 'SomeErrorMessage', + ), + ), + ), + ); - await player.play(AssetSource(_kAssetAudio)); - await started.future; - await Future.delayed(_kPlayDuration); - expect(count, greaterThanOrEqualTo(2)); + await platform.emitError( + playerId, + 'SomeErrorCode', + 'SomeErrorMessage', + ); + }); - await player.dispose(); + testWidgets('Emit global platform error', (tester) async { + final global = GlobalAudioplayersPlatformInterface.instance; + final globalEventStream = global.getGlobalEventStream(); + expect( + globalEventStream, + emitsThrough( + emitsError( + isA() + .having( + (PlatformException e) => e.code, + 'code', + 'SomeGlobalErrorCode', + ) + .having( + (PlatformException e) => e.message, + 'message', + 'SomeGlobalErrorMessage', + ), + ), + ), + ); + + await global.emitGlobalError( + 'SomeGlobalErrorCode', + 'SomeGlobalErrorMessage', + ); }); }); } + +extension on WidgetTester { + Future prepareSource({ + required String playerId, + required AudioplayersPlatformInterface platform, + required LibSourceTestData testData, + }) async { + final eventStream = platform.getEventStream(playerId); + final preparedFuture = eventStream + .firstWhere( + (event) => + event.eventType == AudioEventType.prepared && + (event.isPrepared ?? false), + ) + .timeout(_defaultTimeout); + + Future setSource(Source source) async { + if (source is UrlSource) { + return platform.setSourceUrl(playerId, source.url); + } else if (source is AssetSource) { + final cachePath = await AudioCache.instance.loadPath(source.path); + return platform.setSourceUrl(playerId, cachePath, isLocal: true); + } else if (source is BytesSource) { + return platform.setSourceBytes(playerId, source.bytes); + } else { + throw 'Unknown source type: ${source.runtimeType}'; + } + } + + final setSourceFuture = setSource(testData.source); + + await Future.wait([setSourceFuture, preparedFuture]); + } +} diff --git a/packages/audioplayers/example/integration_test/lib/lib_source_test_data.dart b/packages/audioplayers/example/integration_test/lib/lib_source_test_data.dart new file mode 100644 index 000000000..e902403dd --- /dev/null +++ b/packages/audioplayers/example/integration_test/lib/lib_source_test_data.dart @@ -0,0 +1,141 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_tizen_example/tabs/sources.dart'; +import 'package:http/http.dart'; + +import '../platform_features.dart'; +import '../source_test_data.dart'; + +/// Data of a library test source. +class LibSourceTestData extends SourceTestData { + Source source; + + LibSourceTestData({ + required this.source, + required super.duration, + super.isVBR, + }); + + @override + String toString() { + return 'LibSourceTestData(' + 'source: $source, ' + 'duration: $duration, ' + 'isVBR: $isVBR' + ')'; + } +} + +final _features = PlatformFeatures.instance(); + +final wavUrl1TestData = LibSourceTestData( + source: UrlSource(wavUrl1), + duration: const Duration(milliseconds: 451), +); + +final specialCharUrlTestData = LibSourceTestData( + source: UrlSource(wavUrl3), + duration: const Duration(milliseconds: 451), +); + +final mp3Url1TestData = LibSourceTestData( + source: UrlSource(mp3Url1), + duration: const Duration(minutes: 3, seconds: 30, milliseconds: 77), + isVBR: true, +); + +final m3u8UrlTestData = LibSourceTestData( + source: UrlSource(m3u8StreamUrl), + duration: null, +); + +final mpgaUrlTestData = LibSourceTestData( + source: UrlSource(mpgaStreamUrl), + duration: null, +); + +final wavAsset1TestData = LibSourceTestData( + source: AssetSource(wavAsset1), + duration: const Duration(milliseconds: 451), +); + +final wavAsset2TestData = LibSourceTestData( + source: AssetSource(wavAsset2), + duration: const Duration(seconds: 1, milliseconds: 068), +); + +final invalidAssetTestData = LibSourceTestData( + source: AssetSource(invalidAsset), + duration: null, +); + +final specialCharAssetTestData = LibSourceTestData( + source: AssetSource(specialCharAsset), + duration: const Duration(milliseconds: 451), +); + +final noExtensionAssetTestData = LibSourceTestData( + source: AssetSource(noExtensionAsset, mimeType: 'audio/wav'), + duration: const Duration(milliseconds: 451), +); + +final nonExistentUrlTestData = LibSourceTestData( + source: UrlSource('non_existent.txt'), + duration: null, +); + +final wavDataUriTestData = LibSourceTestData( + source: UrlSource(wavDataUri), + duration: const Duration(milliseconds: 451), +); + +final mp3DataUriTestData = LibSourceTestData( + source: UrlSource(mp3DataUri), + duration: const Duration(milliseconds: 444), +); + +Future mp3BytesTestData() async => LibSourceTestData( + source: BytesSource( + await readBytes(Uri.parse(mp3Url1)), + mimeType: 'audio/mpeg', + ), + duration: const Duration(minutes: 3, seconds: 30, milliseconds: 76), + ); + +// Some sources are commented which are considered redundant +Future> getAudioTestDataList() async { + return [ + if (_features.hasUrlSource) wavUrl1TestData, + /*if (_features.hasUrlSource) + LibSourceTestData( + source: UrlSource(wavUrl2), + duration: const Duration(seconds: 1, milliseconds: 068), + ),*/ + if (_features.hasUrlSource) mp3Url1TestData, + /*if (_features.hasUrlSource) + LibSourceTestData( + source: UrlSource(mp3Url2), + duration: const Duration(minutes: 1, seconds: 34, milliseconds: 119), + ),*/ + if (_features.hasUrlSource && _features.hasPlaylistSourceType) + m3u8UrlTestData, + if (_features.hasUrlSource) mpgaUrlTestData, + if (_features.hasDataUriSource) wavDataUriTestData, + // if (_features.hasDataUriSource) mp3DataUriTestData, + if (_features.hasAssetSource) wavAsset2TestData, + /*if (_features.hasAssetSource) + LibSourceTestData( + source: AssetSource(mp3Asset), + duration: const Duration(minutes: 1, seconds: 34, milliseconds: 119), + ),*/ + if (_features.hasBytesSource) await mp3BytesTestData(), + /*if (_features.hasBytesSource) + // Cache not working for web + LibSourceTestData( + source: BytesSource( + await AudioCache.instance.loadAsBytes(wavAsset2), + mimeType: 'audio/wav', + ), + duration: const Duration(seconds: 1, milliseconds: 068), + ),*/ + ]; +} diff --git a/packages/audioplayers/example/integration_test/lib/lib_test_utils.dart b/packages/audioplayers/example/integration_test/lib/lib_test_utils.dart new file mode 100644 index 000000000..552f080e7 --- /dev/null +++ b/packages/audioplayers/example/integration_test/lib/lib_test_utils.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +extension LibWidgetTester on WidgetTester { + Future pumpPlatform([ + Duration? duration, + EnginePhase phase = EnginePhase.sendSemanticsUpdate, + ]) async { + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.linux) { + // FIXME(1556): Pump on Linux doesn't work with GStreamer bus callback + await Future.delayed(duration ?? Duration.zero); + } else { + await pump(duration, phase); + } + } + + /// See [pumpFrames]. + Future pumpGlobalFrames( + Duration maxDuration, [ + Duration interval = const Duration(milliseconds: 16, microseconds: 683), + ]) { + var elapsed = Duration.zero; + return TestAsyncUtils.guard(() async { + binding.scheduleFrame(); + while (elapsed < maxDuration) { + await binding.pump(interval); + elapsed += interval; + } + }); + } +} diff --git a/packages/audioplayers/example/integration_test/platform_features.dart b/packages/audioplayers/example/integration_test/platform_features.dart new file mode 100644 index 000000000..f80b24657 --- /dev/null +++ b/packages/audioplayers/example/integration_test/platform_features.dart @@ -0,0 +1,56 @@ +/// Specify supported features for a platform. +class PlatformFeatures { + final bool hasUrlSource; + final bool hasDataUriSource; + final bool hasAssetSource; + final bool hasBytesSource; + + final bool hasPlaylistSourceType; + + final bool hasLowLatency; + final bool hasReleaseModeRelease; + final bool hasReleaseModeLoop; + final bool hasVolume; + final bool hasBalance; + final bool hasSeek; + final bool hasMp3Duration; + + final bool hasPlaybackRate; + final bool hasForceSpeaker; + final bool hasDuckAudio; + final bool hasRespectSilence; + final bool hasStayAwake; + final bool hasRecordingActive; + final bool hasPlayingRoute; + + final bool hasDurationEvent; + final bool hasPlayerStateEvent; + final bool hasErrorEvent; + + const PlatformFeatures({ + this.hasUrlSource = true, + this.hasDataUriSource = true, + this.hasAssetSource = true, + this.hasBytesSource = true, + this.hasPlaylistSourceType = true, + this.hasLowLatency = true, + this.hasReleaseModeRelease = true, + this.hasReleaseModeLoop = true, + this.hasMp3Duration = true, + this.hasVolume = true, + this.hasBalance = true, + this.hasSeek = true, + this.hasPlaybackRate = true, + this.hasForceSpeaker = true, + this.hasDuckAudio = true, + this.hasRespectSilence = true, + this.hasStayAwake = true, + this.hasRecordingActive = true, + this.hasPlayingRoute = true, + this.hasDurationEvent = true, + this.hasPlayerStateEvent = true, + this.hasErrorEvent = true, + }); + + factory PlatformFeatures.instance() => const PlatformFeatures(); +} diff --git a/packages/audioplayers/example/integration_test/source_test_data.dart b/packages/audioplayers/example/integration_test/source_test_data.dart new file mode 100644 index 000000000..eb49a8606 --- /dev/null +++ b/packages/audioplayers/example/integration_test/source_test_data.dart @@ -0,0 +1,22 @@ +/// Data of a ui test source. +abstract class SourceTestData { + Duration? duration; + + bool get isLiveStream => duration == null; + + /// Whether this source has variable bitrate + bool isVBR; + + SourceTestData({ + required this.duration, + this.isVBR = false, + }); + + @override + String toString() { + return 'SourceTestData(' + 'duration: $duration, ' + 'isVBR: $isVBR' + ')'; + } +} diff --git a/packages/audioplayers/example/integration_test/test_utils.dart b/packages/audioplayers/example/integration_test/test_utils.dart new file mode 100644 index 000000000..90f9ef0e7 --- /dev/null +++ b/packages/audioplayers/example/integration_test/test_utils.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +void printWithTimeOnFailure(String message) { + printOnFailure('${DateTime.now()}: $message'); +} + +bool durationRangeMatcher( + Duration? actual, + Duration? expected, { + Duration deviation = const Duration(seconds: 1), +}) { + if (actual == null && expected == null) { + return true; + } + if (actual == null || expected == null) { + return false; + } + return actual >= (expected - deviation) && actual <= (expected + deviation); +} + +extension ExtendedWidgetTester on WidgetTester { + // Add [stackTrace] to work around https://github.com/flutter/flutter/issues/89138 + Future waitFor( + Future Function() testExpectation, { + Duration? timeout = const Duration(seconds: 15), + Duration? pollInterval = const Duration(milliseconds: 500), + String? stackTrace, + }) async => + _waitUntil( + (setFailureMessage) async { + try { + await pump(); + await testExpectation(); + return true; + } on TestFailure catch (e) { + setFailureMessage(e.message ?? ''); + return false; + } + }, + timeout: timeout, + pollInterval: pollInterval, + stackTrace: stackTrace, + ); + + /// Waits until the [condition] returns true + /// Will raise a complete with a [TimeoutException] if the + /// condition does not return true with the timeout period. + /// Copied from: https://github.com/jonsamwell/flutter_gherkin/blob/02a4af91d7a2512e0a4540b9b1ab13e36d5c6f37/lib/src/flutter/utils/driver_utils.dart#L86 + Future _waitUntil( + Future Function(void Function(String message) setFailureMessage) + condition, { + Duration? timeout = const Duration(seconds: 15), + Duration? pollInterval = const Duration(milliseconds: 500), + String? stackTrace, + }) async { + var firstFailureMsg = ''; + var lastFailureMsg = 'same as first failure'; + void setFailureMessage(String message) { + if (firstFailureMsg.isEmpty) { + firstFailureMsg = '${DateTime.now()}:\n $message'; + } else { + lastFailureMsg = '${DateTime.now()}:\n $message'; + } + } + + try { + await Future.microtask( + () async { + final completer = Completer(); + final maxAttempts = + (timeout!.inMilliseconds / pollInterval!.inMilliseconds).round(); + var attempts = 0; + + while (attempts < maxAttempts) { + final result = await condition(setFailureMessage); + if (result) { + completer.complete(); + break; + } else { + await Future.delayed(pollInterval); + } + attempts++; + } + }, + ).timeout( + timeout!, + ); + } on TimeoutException catch (e) { + throw Exception( + '''$e + +Stacktrace: +$stackTrace +First Failure: +$firstFailureMsg +Last Failure: +$lastFailureMsg''', + ); + } + } +} diff --git a/packages/audioplayers/example/lib/tabs/sources.dart b/packages/audioplayers/example/lib/tabs/sources.dart index 069e16ca4..041d40f20 100644 --- a/packages/audioplayers/example/lib/tabs/sources.dart +++ b/packages/audioplayers/example/lib/tabs/sources.dart @@ -23,7 +23,7 @@ final mp3Url1 = '$host/files/audio/ambient_c_motion.mp3'; final mp3Url2 = '$host/files/audio/nasa_on_a_mission.mp3'; final m3u8StreamUrl = useLocalServer ? '$host/files/live_streams/nasa_power_of_the_rovers.m3u8' - : 'https://ll-hls-test.cdn-apple.com/llhls4/ll-hls-test-04/multi.m3u8'; + : 'https://test-streams.mux.dev/x36xhzz/x3lis7z94ey1eglf.m3u8'; final mpgaStreamUrl = useLocalServer ? '$host/stream/mpeg' : 'https://timesradio.wireless.radio/stream'; diff --git a/packages/audioplayers/example/pubspec.yaml b/packages/audioplayers/example/pubspec.yaml index 0df215b80..ba5b63cc4 100644 --- a/packages/audioplayers/example/pubspec.yaml +++ b/packages/audioplayers/example/pubspec.yaml @@ -8,6 +8,7 @@ environment: dependencies: audioplayers: ^6.6.0 + audioplayers_platform_interface: ^7.1.1 audioplayers_tizen: path: ../ collection: ^1.16.0 diff --git a/packages/audioplayers/pubspec.yaml b/packages/audioplayers/pubspec.yaml index 19982e7b6..11645686b 100644 --- a/packages/audioplayers/pubspec.yaml +++ b/packages/audioplayers/pubspec.yaml @@ -2,7 +2,7 @@ name: audioplayers_tizen description: Tizen implementation of the audioplayers plugin. homepage: https://github.com/flutter-tizen/plugins repository: https://github.com/flutter-tizen/plugins/tree/master/packages/audioplayers -version: 3.1.4 +version: 3.1.5 environment: sdk: ^3.6.0 diff --git a/packages/audioplayers/tizen/src/audio_player.cc b/packages/audioplayers/tizen/src/audio_player.cc index e9121dce5..e95be6ca6 100644 --- a/packages/audioplayers/tizen/src/audio_player.cc +++ b/packages/audioplayers/tizen/src/audio_player.cc @@ -4,6 +4,9 @@ #include "audio_player.h" +#include +#include + #include "audio_player_error.h" #include "log.h" @@ -111,6 +114,15 @@ void AudioPlayer::Stop() { if (ret != PLAYER_ERROR_NONE) { throw AudioPlayerError("player_stop failed", get_error_message(ret)); } + // Reset the play position to 0 to match other platforms. This is + // best-effort: on some devices (e.g. TV with network sources) + // player_set_play_position right after stop can fail with an invalid + // state, which must not crash the app. + try { + Seek(0); + } catch (const AudioPlayerError &error) { + OnLog("Failed to reset position on stop: " + error.message()); + } } should_play_ = false; @@ -259,6 +271,12 @@ int AudioPlayer::GetCurrentPosition() { return position; } +bool AudioPlayer::IsSourcePrepared() { + player_state_e state = GetPlayerState(); + return state == PLAYER_STATE_READY || state == PLAYER_STATE_PLAYING || + state == PLAYER_STATE_PAUSED; +} + bool AudioPlayer::IsPlaying() { return (GetPlayerState() == PLAYER_STATE_PLAYING); } @@ -453,17 +471,47 @@ void AudioPlayer::OnPlayCompleted(void *data) { } void AudioPlayer::OnInterrupted(player_interrupted_code_e code, void *data) { - // On TV devices, callbacks are not executed on the main loop. - // However, race condition will not occur as player_id_ is read-only. - const auto *player = reinterpret_cast(data); - player->log_listener_(player->player_id_, "Player interrupted."); + auto *self = reinterpret_cast(data); + // On TV devices, callbacks are not executed on the main loop. Transfer to + // the main loop so the log event is sent on the platform thread. + g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, + [](gpointer data) -> gboolean { + auto *idle = static_cast(data); + if (!*idle->is_alive) { + return G_SOURCE_REMOVE; + } + idle->player->log_listener_(idle->player->player_id_, + "Player interrupted."); + return G_SOURCE_REMOVE; + }, + new IdleData{self, self->is_alive_}, + [](gpointer data) { delete static_cast(data); }); } void AudioPlayer::OnError(int code, void *data) { - // On TV devices, callbacks are not executed on the main loop. - // However, race condition will not occur as player_id_ is read-only. - const auto *player = reinterpret_cast(data); - player->log_listener_(player->player_id_, get_error_message(code)); + auto *self = reinterpret_cast(data); + // On TV devices, callbacks are not executed on the main loop. Transfer to + // the main loop so the log event is sent on the platform thread. The error + // message is resolved here and carried via a heap-allocated context. + struct ErrorData { + AudioPlayer *player; + std::shared_ptr is_alive; + std::string message; + }; + g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, + [](gpointer data) -> gboolean { + auto *error_data = static_cast(data); + if (!*error_data->is_alive) { + return G_SOURCE_REMOVE; + } + error_data->player->log_listener_(error_data->player->player_id_, + error_data->message); + return G_SOURCE_REMOVE; + }, + new ErrorData{self, self->is_alive_, get_error_message(code)}, + [](gpointer data) { delete static_cast(data); }); } void AudioPlayer::StartPositionUpdates() { diff --git a/packages/audioplayers/tizen/src/audio_player.h b/packages/audioplayers/tizen/src/audio_player.h index 2905077b8..9d634bede 100644 --- a/packages/audioplayers/tizen/src/audio_player.h +++ b/packages/audioplayers/tizen/src/audio_player.h @@ -55,6 +55,7 @@ class AudioPlayer { int32_t GetCurrentPosition(); std::string GetPlayerId() const { return player_id_; } bool IsPlaying(); + bool IsSourcePrepared(); private: // The player state should be none before calling this function. diff --git a/packages/audioplayers/tizen/src/audioplayers_tizen_plugin.cc b/packages/audioplayers/tizen/src/audioplayers_tizen_plugin.cc index 90fb5c826..f9723bf29 100644 --- a/packages/audioplayers/tizen/src/audioplayers_tizen_plugin.cc +++ b/packages/audioplayers/tizen/src/audioplayers_tizen_plugin.cc @@ -238,7 +238,13 @@ class AudioplayersTizenPlugin : public flutter::Plugin { } else if (method_name == "getDuration") { // TODO(seungsoo47): If an exception occurs, null is sent. try { - result->Success(flutter::EncodableValue(player->GetDuration())); + // Without a prepared source (e.g. after release), duration is null, + // matching the behavior of other platforms. + if (player->IsSourcePrepared()) { + result->Success(flutter::EncodableValue(player->GetDuration())); + } else { + result->Success(flutter::EncodableValue(std::monostate())); + } } catch (const AudioPlayerError &error) { player->OnLog(error.code() + error.message()); result->Success(flutter::EncodableValue(std::monostate())); @@ -246,8 +252,14 @@ class AudioplayersTizenPlugin : public flutter::Plugin { } else if (method_name == "getCurrentPosition") { // TODO(seungsoo47): If an exception occurs, null is sent. try { - result->Success( - flutter::EncodableValue(player->GetCurrentPosition())); + // Without a prepared source (e.g. after release), position is null, + // matching the behavior of other platforms. + if (player->IsSourcePrepared()) { + result->Success( + flutter::EncodableValue(player->GetCurrentPosition())); + } else { + result->Success(flutter::EncodableValue(std::monostate())); + } } catch (const AudioPlayerError &error) { player->OnLog(error.code() + error.message()); result->Success(flutter::EncodableValue(std::monostate())); @@ -260,7 +272,7 @@ class AudioplayersTizenPlugin : public flutter::Plugin { result->Success(); } else if (method_name == "setAudioContext") { player->OnLog("Setting AudioContext is not supported on Tizen"); - result->NotImplemented(); + result->Success(); } else if (method_name == "emitLog") { auto message = GetRequiredArg(arguments, "message"); player->OnLog(message); @@ -294,7 +306,7 @@ class AudioplayersTizenPlugin : public flutter::Plugin { audio_players_.clear(); } else if (method_name == "setAudioContext") { OnGlobalLog("Setting AudioContext is not supported on Tizen"); - result->NotImplemented(); + result->Success(); return; } else if (method_name == "emitLog") { if (arguments) {