From c0c5d0c2bfef4ee77329e377e874ceb13a4878ae Mon Sep 17 00:00:00 2001 From: Ryan Heise Date: Thu, 9 Jul 2020 13:27:53 +1000 Subject: [PATCH] Playists, looping, shuffling for Android and web --- README.md | 10 +- .../com/ryanheise/just_audio/AudioPlayer.java | 376 ++++++-- example/lib/main.dart | 145 ++- example/pubspec.lock | 71 +- lib/just_audio.dart | 697 +++++++++++--- lib/just_audio_web.dart | 847 +++++++++++++++--- pubspec.lock | 25 +- pubspec.yaml | 3 +- 8 files changed, 1795 insertions(+), 379 deletions(-) diff --git a/README.md b/README.md index 2871161..c894525 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # just_audio -A Flutter plugin to play audio from URLs, files, assets and DASH/HLS streams. This plugin can be used with [audio_service](https://pub.dev/packages/audio_service) to play audio in the background and control playback from the lock screen, Android notifications, the iOS Control Center, and headset buttons. +A Flutter plugin to play audio from URLs, files, assets, DASH/HLS streams and playlists. This plugin can be used with [audio_service](https://pub.dev/packages/audio_service) to play audio in the background and control playback from the lock screen, Android notifications, the iOS Control Center, and headset buttons. ## Features @@ -18,6 +18,10 @@ A Flutter plugin to play audio from URLs, files, assets and DASH/HLS streams. Th | clip audio | ✅ | | | ✅ | | dispose | ✅ | ✅ | ✅ | ✅ | | report player errors | ✅ | ✅ | ✅ | ✅ | +| playlists | ✅ | | | ✅ | +| looping | ✅ | | | ✅ | +| shuffle | ✅ | | | ✅ | +| gapless playback | ✅ | | | | This plugin has been tested on Android and Web, and is being made available for testing on iOS. Please consider reporting any bugs you encounter [here](https://github.com/ryanheise/just_audio/issues) or submitting pull requests [here](https://github.com/ryanheise/just_audio/pulls). @@ -123,7 +127,3 @@ If you wish to connect to non-HTTPS URLS, add the following to your `Info.plist` ``` - -## Todo - -* Gapless playback diff --git a/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java b/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java index 2a2c67c..3ffa789 100644 --- a/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java +++ b/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java @@ -8,13 +8,18 @@ import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataOutput; import com.google.android.exoplayer2.metadata.icy.IcyHeaders; import com.google.android.exoplayer2.metadata.icy.IcyInfo; import com.google.android.exoplayer2.source.ClippingMediaSource; +import com.google.android.exoplayer2.source.ConcatenatingMediaSource; +import com.google.android.exoplayer2.source.LoopingMediaSource; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; +import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.dash.DashMediaSource; @@ -36,12 +41,18 @@ import io.flutter.plugin.common.MethodChannel.Result; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.stream.Collectors; public class AudioPlayer implements MethodCallHandler, Player.EventListener, MetadataOutput { static final String TAG = "AudioPlayer"; + private static Random random = new Random(); + private final Context context; private final MethodChannel methodChannel; private final EventChannel eventChannel; @@ -62,11 +73,15 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met private boolean seekProcessed; private boolean buffering; private boolean justConnected; - private MediaSource mediaSource; + private Map mediaSources = new HashMap(); private IcyInfo icyInfo; private IcyHeaders icyHeaders; private SimpleExoPlayer player; + private MediaSource mediaSource; + private Integer currentIndex; + private Map loopingChildren = new HashMap<>(); + private Map loopingCounts = new HashMap<>(); private final Handler handler = new Handler(); private final Runnable bufferWatcher = new Runnable() { @Override @@ -152,6 +167,31 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met } } + @Override + public void onPositionDiscontinuity(int reason) { + switch (reason) { + case Player.DISCONTINUITY_REASON_PERIOD_TRANSITION: + case Player.DISCONTINUITY_REASON_SEEK: + onItemMayHaveChanged(); + break; + } + } + + @Override + public void onTimelineChanged(Timeline timeline, int reason) { + if (reason == Player.TIMELINE_CHANGE_REASON_DYNAMIC) { + onItemMayHaveChanged(); + } + } + + private void onItemMayHaveChanged() { + Integer newIndex = player.getCurrentWindowIndex(); + if (newIndex != currentIndex) { + currentIndex = newIndex; + } + broadcastPlaybackEvent(); + } + @Override public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { switch (playbackState) { @@ -169,6 +209,7 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met break; case Player.STATE_ENDED: if (state != PlaybackState.completed) { + player.setPlayWhenReady(false); transition(PlaybackState.completed); } break; @@ -230,19 +271,8 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met final List args = (List) call.arguments; try { switch (call.method) { - case "setUrl": - setUrl((String) args.get(0), result); - break; - case "setClip": - Object start = args.get(0); - if (start != null && start instanceof Integer) { - start = new Long((Integer) start); - } - Object end = args.get(1); - if (end != null && end instanceof Integer) { - end = new Long((Integer) end); - } - setClip((Long) start, (Long) end, result); + case "load": + load(getAudioSource(args.get(0)), result); break; case "play": play(); @@ -263,17 +293,57 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met setSpeed((float) ((double) ((Double) args.get(0)))); result.success(null); break; + case "setLoopMode": + setLoopMode((Integer) args.get(0)); + result.success(null); + break; + case "setShuffleModeEnabled": + setShuffleModeEnabled((Boolean) args.get(0)); + result.success(null); + break; case "setAutomaticallyWaitsToMinimizeStalling": result.success(null); break; case "seek": Long position = getLong(args.get(0)); - seek(position == null ? C.TIME_UNSET : position, result); + Integer index = (Integer)args.get(1); + seek(position == null ? C.TIME_UNSET : position, result, index); break; case "dispose": dispose(); result.success(null); break; + case "concatenating.add": + concatenating(args.get(0)) + .addMediaSource(getAudioSource(args.get(1)), null, () -> result.success(null)); + break; + case "concatenating.insert": + concatenating(args.get(0)) + .addMediaSource((Integer)args.get(1), getAudioSource(args.get(2)), null, () -> result.success(null)); + break; + case "concatenating.addAll": + concatenating(args.get(0)) + .addMediaSources(getAudioSources(args.get(1)), null, () -> result.success(null)); + break; + case "concatenating.insertAll": + concatenating(args.get(0)) + .addMediaSources((Integer)args.get(1), getAudioSources(args.get(2)), null, () -> result.success(null)); + break; + case "concatenating.removeAt": + concatenating(args.get(0)) + .removeMediaSource((Integer)args.get(1), null, () -> result.success(null)); + break; + case "concatenating.removeRange": + concatenating(args.get(0)) + .removeMediaSourceRange((Integer)args.get(1), (Integer)args.get(2), null, () -> result.success(null)); + break; + case "concatenating.move": + concatenating(args.get(0)) + .moveMediaSource((Integer)args.get(1), (Integer)args.get(2), null, () -> result.success(null)); + break; + case "concatenating.clear": + concatenating(args.get(0)).clear(null, () -> result.success(null)); + break; default: result.notImplemented(); break; @@ -287,6 +357,169 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met } } + // Set the shuffle order for mediaSource, with currentIndex at + // the first position. Traverse the tree incrementing index at each + // node. + private int setShuffleOrder(MediaSource mediaSource, int index) { + if (mediaSource instanceof ConcatenatingMediaSource) { + final ConcatenatingMediaSource source = (ConcatenatingMediaSource)mediaSource; + // Find which child is current + Integer currentChildIndex = null; + for (int i = 0; i < source.getSize(); i++) { + final int indexBefore = index; + final MediaSource child = source.getMediaSource(i); + index = setShuffleOrder(child, index); + // If currentIndex falls within this child, make this child come first. + if (currentIndex >= indexBefore && currentIndex < index) { + currentChildIndex = i; + } + } + // Shuffle so that the current child is first in the shuffle order + source.setShuffleOrder(createShuffleOrder(source.getSize(), currentChildIndex)); + } else if (mediaSource instanceof LoopingMediaSource) { + final LoopingMediaSource source = (LoopingMediaSource)mediaSource; + // The ExoPlayer API doesn't provide accessors for these so we have + // to index them ourselves. + MediaSource child = loopingChildren.get(source); + int count = loopingCounts.get(source); + for (int i = 0; i < count; i++) { + index = setShuffleOrder(child, index); + } + } else { + // An actual media item takes up one spot in the playlist. + index++; + } + return index; + } + + private static int[] shuffle(int length, Integer firstIndex) { + final int[] shuffleOrder = new int[length]; + for (int i = 0; i < length; i++) { + final int j = random.nextInt(i + 1); + shuffleOrder[i] = shuffleOrder[j]; + shuffleOrder[j] = i; + } + if (firstIndex != null) { + for (int i = 1; i < length; i++) { + if (shuffleOrder[i] == firstIndex) { + final int v = shuffleOrder[0]; + shuffleOrder[0] = shuffleOrder[i]; + shuffleOrder[i] = v; + break; + } + } + } + return shuffleOrder; + } + + // Create a shuffle order optionally fixing the first index. + private ShuffleOrder createShuffleOrder(int length, Integer firstIndex) { + int[] shuffleIndices = shuffle(length, firstIndex); + return new DefaultShuffleOrder(shuffleIndices, random.nextLong()); + } + + private ConcatenatingMediaSource concatenating(final Object index) { + return (ConcatenatingMediaSource)mediaSources.get((Integer)index); + } + + private MediaSource getAudioSource(final Object json) { + Map map = (Map)json; + String id = (String)map.get("id"); + MediaSource mediaSource = mediaSources.get(id); + if (mediaSource == null) { + mediaSource = decodeAudioSource(map); + mediaSources.put(id, mediaSource); + } + return mediaSource; + } + + private MediaSource decodeAudioSource(final Object json) { + Map map = (Map)json; + String id = (String)map.get("id"); + switch ((String)map.get("type")) { + case "progressive": + return new ProgressiveMediaSource.Factory(buildDataSourceFactory()) + .setTag(id) + .createMediaSource(Uri.parse((String)map.get("uri"))); + case "dash": + return new DashMediaSource.Factory(buildDataSourceFactory()) + .setTag(id) + .createMediaSource(Uri.parse((String)map.get("uri"))); + case "hls": + return new HlsMediaSource.Factory(buildDataSourceFactory()) + .setTag(id) + .createMediaSource(Uri.parse((String)map.get("uri"))); + case "concatenating": + List audioSources = (List)map.get("audioSources"); + return new ConcatenatingMediaSource( + false, // isAtomic + (Boolean)map.get("useLazyPreparation"), + new DefaultShuffleOrder(audioSources.size()), + audioSources + .stream() + .map(s -> getAudioSource(s)) + .toArray(MediaSource[]::new)); + case "clipping": + Long start = getLong(map.get("start")); + Long end = getLong(map.get("end")); + return new ClippingMediaSource(getAudioSource(map.get("audioSource")), + (start != null ? start : 0) * 1000L, + (end != null ? end : C.TIME_END_OF_SOURCE) * 1000L); + case "looping": + Integer count = (Integer)map.get("count"); + MediaSource looperChild = getAudioSource(map.get("audioSource")); + LoopingMediaSource looper = new LoopingMediaSource(looperChild, count); + // TODO: store both in a single map + loopingChildren.put(looper, looperChild); + loopingCounts.put(looper, count); + return looper; + default: + throw new IllegalArgumentException("Unknown AudioSource type: " + map.get("type")); + } + } + + private List getAudioSources(final Object json) { + return ((List)json) + .stream() + .map(s -> getAudioSource(s)) + .collect(Collectors.toList()); + } + + private DataSource.Factory buildDataSourceFactory() { + String userAgent = Util.getUserAgent(context, "just_audio"); + DataSource.Factory httpDataSourceFactory = new DefaultHttpDataSourceFactory( + userAgent, + DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, + DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, + true + ); + return new DefaultDataSourceFactory(context, httpDataSourceFactory); + } + + private void load(final MediaSource mediaSource, final Result result) { + justConnected = false; + switch (state) { + case none: + break; + case connecting: + abortExistingConnection(); + player.stop(); + player.setPlayWhenReady(false); + break; + default: + player.stop(); + player.setPlayWhenReady(false); + break; + } + prepareResult = result; + transition(PlaybackState.connecting); + if (player.getShuffleModeEnabled()) { + setShuffleOrder(mediaSource, 0); + } + this.mediaSource = mediaSource; + player.prepare(mediaSource); + } + private void ensurePlayerInitialized() { if (player == null) { player = new SimpleExoPlayer.Builder(context).build(); @@ -296,44 +529,39 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met } private void broadcastPlaybackEvent() { - final ArrayList event = new ArrayList(); - event.add(state.ordinal()); - event.add(buffering); - event.add(updatePosition = getCurrentPosition()); - event.add(updateTime = System.currentTimeMillis()); - event.add(Math.max(updatePosition, bufferedPosition)); - event.add(collectIcyMetadata()); - event.add(duration = getDuration()); + final Map event = new HashMap(); + event.put("state", state.ordinal()); + event.put("buffering", buffering); + event.put("updatePosition", updatePosition = getCurrentPosition()); + event.put("updateTime", updateTime = System.currentTimeMillis()); + event.put("bufferedPosition", Math.max(updatePosition, bufferedPosition)); + event.put("icyMetadata", collectIcyMetadata()); + event.put("duration", duration = getDuration()); + event.put("currentIndex", currentIndex); if (eventSink != null) { eventSink.success(event); } } - private ArrayList collectIcyMetadata() { - final ArrayList icyData = new ArrayList<>(); - final ArrayList info; - final ArrayList headers; + private Map collectIcyMetadata() { + final Map icyData = new HashMap<>(); if (icyInfo != null) { - info = new ArrayList<>(); - info.add(icyInfo.title); - info.add(icyInfo.url); - } else { - info = new ArrayList<>(Collections.nCopies(2, null)); + final Map info = new HashMap<>(); + info.put("title", icyInfo.title); + info.put("url", icyInfo.url); + icyData.put("info", info); } if (icyHeaders != null) { - headers = new ArrayList<>(); - headers.add(icyHeaders.bitrate); - headers.add(icyHeaders.genre); - headers.add(icyHeaders.name); - headers.add(icyHeaders.metadataInterval); - headers.add(icyHeaders.url); - headers.add(icyHeaders.isPublic); - } else { - headers = new ArrayList<>(Collections.nCopies(6, null)); + final Map headers = new HashMap<>(); + headers.put("bitrate", icyHeaders.bitrate); + headers.put("genre", icyHeaders.genre); + headers.put("name", icyHeaders.name); + headers.put("metadataInterval", icyHeaders.metadataInterval); + headers.put("url", icyHeaders.url); + headers.put("isPublic", icyHeaders.isPublic); + icyData.put("headers", headers); } - icyData.add(info); - icyData.add(headers); return icyData; } @@ -372,32 +600,6 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met broadcastPlaybackEvent(); } - public void setUrl(final String url, final Result result) throws IOException { - justConnected = false; - abortExistingConnection(); - prepareResult = result; - transition(PlaybackState.connecting); - String userAgent = Util.getUserAgent(context, "just_audio"); - DataSource.Factory httpDataSourceFactory = new DefaultHttpDataSourceFactory( - userAgent, - DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, - DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, - true - ); - DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(context, - httpDataSourceFactory); - Uri uri = Uri.parse(url); - String extension = getLowerCaseExtension(uri); - if (extension.equals("mpd")) { - mediaSource = new DashMediaSource.Factory(dataSourceFactory).createMediaSource(uri); - } else if (extension.equals("m3u8")) { - mediaSource = new HlsMediaSource.Factory(dataSourceFactory).createMediaSource(uri); - } else { - mediaSource = new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(uri); - } - player.prepare(mediaSource); - } - private String getLowerCaseExtension(Uri uri) { // Until ExoPlayer provides automatic detection of media source types, we // rely on the file extension. When this is absent, as a temporary @@ -408,23 +610,6 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met return filename.replaceAll("^.*\\.", "").toLowerCase(); } - public void setClip(final Long start, final Long end, final Result result) { - if (state == PlaybackState.none) { - throw new IllegalStateException("Cannot call setClip from none state"); - } - abortExistingConnection(); - this.start = start; - this.end = end; - prepareResult = result; - if (start != null || end != null) { - player.prepare(new ClippingMediaSource(mediaSource, - (start != null ? start : 0) * 1000L, - (end != null ? end : C.TIME_END_OF_SOURCE) * 1000L)); - } else { - player.prepare(mediaSource); - } - } - public void play() { switch (state) { case playing: @@ -493,7 +678,18 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met broadcastPlaybackEvent(); } - public void seek(final long position, final Result result) { + public void setLoopMode(final int mode) { + player.setRepeatMode(mode); + } + + public void setShuffleModeEnabled(final boolean enabled) { + if (enabled) { + setShuffleOrder(mediaSource, 0); + } + player.setShuffleModeEnabled(enabled); + } + + public void seek(final long position, final Result result, final Integer index) { if (state == PlaybackState.none || state == PlaybackState.connecting) { throw new IllegalStateException("Cannot call seek from none none/connecting states"); } @@ -501,10 +697,14 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met seekPos = position; seekResult = result; seekProcessed = false; - player.seekTo(position); + int windowIndex = index != null ? index : player.getCurrentWindowIndex(); + player.seekTo(windowIndex, position); } public void dispose() { + mediaSources.clear(); + mediaSource = null; + loopingChildren.clear(); if (player != null) { player.release(); player = null; diff --git a/example/lib/main.dart b/example/lib/main.dart index 7c234d3..b75febe 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -14,19 +14,57 @@ class _MyAppState extends State { final _volumeSubject = BehaviorSubject.seeded(1.0); final _speedSubject = BehaviorSubject.seeded(1.0); AudioPlayer _player; + ConcatenatingAudioSource _playlist = ConcatenatingAudioSource(audioSources: [ + LoopingAudioSource( + count: 2, + audioSource: ClippingAudioSource( + start: Duration(seconds: 60), + end: Duration(seconds: 65), + audioSource: AudioSource.uri(Uri.parse( + "https://s3.amazonaws.com/scifri-episodes/scifri20181123-episode.mp3")), + tag: AudioMetadata( + album: "Science Friday", + title: "A Salute To Head-Scratching Science (5 seconds)", + ), + ), + ), + AudioSource.uri( + Uri.parse( + "https://s3.amazonaws.com/scifri-episodes/scifri20181123-episode.mp3"), + tag: AudioMetadata( + album: "Science Friday", + title: "A Salute To Head-Scratching Science (full)", + ), + ), + AudioSource.uri( + Uri.parse("https://s3.amazonaws.com/scifri-segments/scifri201711241.mp3"), + tag: AudioMetadata( + album: "Science Friday", + title: "From Cat Rheology To Operatic Incompetence", + ), + ), + ]); + + List get _sequence => _playlist.sequence; + + List get _metadataSequence => + _sequence.map((s) => s.tag as AudioMetadata).toList(); @override void initState() { super.initState(); AudioPlayer.setIosCategory(IosCategory.playback); _player = AudioPlayer(); - _player - .setUrl( - "https://s3.amazonaws.com/scifri-episodes/scifri20181123-episode.mp3") - .catchError((error) { + _loadAudio(); + } + + _loadAudio() async { + try { + await _player.load(_playlist); + } catch (e) { // catch audio error ex: 404 url, wrong url ... - print(error); - }); + print("$e"); + } } @override @@ -47,8 +85,21 @@ class _MyAppState extends State { crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text("Science Friday"), - Text("Science Friday and WNYC Studios"), + StreamBuilder( + stream: _player.currentIndexStream, + builder: (context, snapshot) { + final index = snapshot.data ?? 0; + final metadata = _metadataSequence[index]; + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(metadata.album ?? '', + style: Theme.of(context).textTheme.headline6), + Text(metadata.title ?? ''), + ], + ); + }, + ), StreamBuilder( stream: _player.fullPlaybackStateStream, builder: (context, snapshot) { @@ -141,6 +192,77 @@ class _MyAppState extends State { }, ), ), + Row( + children: [ + StreamBuilder( + stream: _player.loopModeStream, + builder: (context, snapshot) { + final loopMode = snapshot.data ?? LoopMode.off; + const icons = [ + Icon(Icons.repeat, color: Colors.grey), + Icon(Icons.repeat, color: Colors.orange), + Icon(Icons.repeat_one, color: Colors.orange), + ]; + const cycleModes = [ + LoopMode.off, + LoopMode.all, + LoopMode.one, + ]; + final index = cycleModes.indexOf(loopMode); + return IconButton( + icon: icons[index], + onPressed: () { + _player.setLoopMode(cycleModes[ + (cycleModes.indexOf(loopMode) + 1) % + cycleModes.length]); + }, + ); + }, + ), + Expanded( + child: Text( + "Playlist", + style: Theme.of(context).textTheme.headline6, + textAlign: TextAlign.center, + ), + ), + StreamBuilder( + stream: _player.shuffleModeEnabledStream, + builder: (context, snapshot) { + final shuffleModeEnabled = snapshot.data ?? false; + return IconButton( + icon: shuffleModeEnabled + ? Icon(Icons.shuffle, color: Colors.orange) + : Icon(Icons.shuffle, color: Colors.grey), + onPressed: () { + _player.setShuffleModeEnabled(!shuffleModeEnabled); + }, + ); + }, + ), + ], + ), + Expanded( + child: StreamBuilder( + stream: _player.currentIndexStream, + builder: (context, snapshot) { + final currentIndex = snapshot.data ?? 0; + return ListView.builder( + itemCount: _metadataSequence.length, + itemBuilder: (context, index) => Material( + color: + index == currentIndex ? Colors.grey.shade300 : null, + child: ListTile( + title: Text(_metadataSequence[index].title), + onTap: () { + _player.seek(Duration.zero, index: index); + }, + ), + ), + ); + }, + ), + ), ], ), ), @@ -192,3 +314,10 @@ class _SeekBarState extends State { ); } } + +class AudioMetadata { + final String album; + final String title; + + AudioMetadata({this.album, this.title}); +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 0b52b86..d35044c 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -1,20 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - archive: - dependency: transitive - description: - name: archive - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.13" - args: - dependency: transitive - description: - name: args - url: "https://pub.dartlang.org" - source: hosted - version: "1.6.0" async: dependency: transitive description: @@ -36,6 +22,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.3" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" collection: dependency: transitive description: @@ -64,6 +57,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.3" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" file: dependency: transitive description: @@ -86,13 +86,6 @@ packages: description: flutter source: sdk version: "0.0.0" - image: - dependency: transitive - description: - name: image - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.12" intl: dependency: transitive description: @@ -106,7 +99,7 @@ packages: path: ".." relative: true source: path - version: "0.2.1" + version: "0.2.2" matcher: dependency: transitive description: @@ -127,7 +120,7 @@ packages: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.6.4" + version: "1.7.0" path_provider: dependency: transitive description: @@ -156,13 +149,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.2" - petitparser: - dependency: transitive - description: - name: petitparser - url: "https://pub.dartlang.org" - source: hosted - version: "2.4.0" platform: dependency: transitive description: @@ -184,13 +170,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.13" - quiver: - dependency: transitive - description: - name: quiver - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.3" rxdart: dependency: "direct main" description: @@ -244,7 +223,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.15" + version: "0.2.16" typed_data: dependency: transitive description: @@ -252,6 +231,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.6" + uuid: + dependency: transitive + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" vector_math: dependency: transitive description: @@ -266,13 +252,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.0" - xml: - dependency: transitive - description: - name: xml - url: "https://pub.dartlang.org" - source: hosted - version: "3.6.1" sdks: - dart: ">=2.6.0 <3.0.0" + dart: ">=2.7.0 <3.0.0" flutter: ">=1.12.13+hotfix.5 <2.0.0" diff --git a/lib/just_audio.dart b/lib/just_audio.dart index 5fd63df..d9cd24d 100644 --- a/lib/just_audio.dart +++ b/lib/just_audio.dart @@ -7,6 +7,9 @@ import 'package:flutter/widgets.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:rxdart/rxdart.dart'; +import 'package:uuid/uuid.dart'; + +final _uuid = Uuid(); /// An object to manage playing audio from a URL, a locale file or an asset. /// @@ -32,12 +35,12 @@ import 'package:rxdart/rxdart.dart'; /// The [AudioPlayer] instance transitions through different states as follows: /// /// * [AudioPlaybackState.none]: immediately after instantiation and [dispose]. -/// * [AudioPlaybackState.stopped]: eventually after [setUrl], [setFilePath], -/// [setAsset] or [setClip] completes, and immediately after [stop]. +/// * [AudioPlaybackState.stopped]: eventually after [load] completes, and +/// immediately after [stop]. /// * [AudioPlaybackState.paused]: after [pause]. /// * [AudioPlaybackState.playing]: after [play]. -/// * [AudioPlaybackState.connecting]: immediately after [setUrl], -/// [setFilePath] and [setAsset] while waiting for the media to load. +/// * [AudioPlaybackState.connecting]: immediately after [load] while waiting +/// for the media to load. /// * [AudioPlaybackState.completed]: immediately after playback reaches the /// end of the media or the end of the clip. /// @@ -46,8 +49,8 @@ import 'package:rxdart/rxdart.dart'; class AudioPlayer { static final _mainChannel = MethodChannel('com.ryanheise.just_audio.methods'); - static Future _init(int id) async { - await _mainChannel.invokeMethod('init', ['$id']); + static Future _init(String id) async { + await _mainChannel.invokeMethod('init', [id]); return MethodChannel('com.ryanheise.just_audio.methods.$id'); } @@ -75,7 +78,7 @@ class AudioPlayer { _ProxyHttpServer _proxy; - final int _id; + final String _id; Future _durationFuture; @@ -90,15 +93,8 @@ class AudioPlayer { bufferedPosition: Duration.zero, speed: 1.0, duration: null, - icyMetadata: IcyMetadata( - info: IcyInfo(title: null, url: null), - headers: IcyHeaders( - bitrate: null, - genre: null, - name: null, - metadataInterval: null, - url: null, - isPublic: null)), + icyMetadata: null, + currentIndex: null, ); Stream _eventChannelStream; @@ -117,47 +113,48 @@ class AudioPlayer { final _fullPlaybackStateSubject = BehaviorSubject(); + final _currentIndexSubject = BehaviorSubject(); + + final _loopModeSubject = BehaviorSubject(); + + final _shuffleModeEnabledSubject = BehaviorSubject(); + double _volume = 1.0; double _speed = 1.0; bool _automaticallyWaitsToMinimizeStalling = true; - File _cacheFile; + AudioSource _audioSource; + + Map _audioSources = {}; /// Creates an [AudioPlayer]. - factory AudioPlayer() => - AudioPlayer._internal(DateTime.now().microsecondsSinceEpoch); + factory AudioPlayer() => AudioPlayer._internal(_uuid.v4()); AudioPlayer._internal(this._id) : _channel = _init(_id) { _eventChannelStream = EventChannel('com.ryanheise.just_audio.events.$_id') .receiveBroadcastStream() .map((data) { - final duration = data.length < 7 || data[6] < 0 + final duration = (data['duration'] ?? -1) < 0 ? null - : Duration(milliseconds: data[6]); + : Duration(milliseconds: data['duration']); _durationFuture = Future.value(duration); _durationSubject.add(duration); - return _audioPlaybackEvent = AudioPlaybackEvent( - state: AudioPlaybackState.values[data[0]], - buffering: data[1], - updatePosition: Duration(milliseconds: data[2]), - updateTime: Duration(milliseconds: data[3]), - bufferedPosition: Duration(milliseconds: data[4]), + _audioPlaybackEvent = AudioPlaybackEvent( + state: AudioPlaybackState.values[data['state']], + buffering: data['buffering'], + updatePosition: Duration(milliseconds: data['updatePosition']), + updateTime: Duration(milliseconds: data['updateTime']), + bufferedPosition: Duration(milliseconds: data['bufferedPosition']), speed: _speed, duration: duration, - icyMetadata: data.length < 6 || data[5] == null + icyMetadata: data['icyMetadata'] == null ? null - : IcyMetadata( - info: IcyInfo(title: data[5][0][0], url: data[5][0][1]), - headers: IcyHeaders( - bitrate: data[5][1][0], - genre: data[5][1][1], - name: data[5][1][2], - metadataInterval: data[5][1][3], - url: data[5][1][4], - isPublic: data[5][1][5])), + : IcyMetadata.fromJson(data['icyMetadata']), + currentIndex: data['currentIndex'], ); + return _audioPlaybackEvent; }); _eventChannelStreamSubscription = _eventChannelStream.listen( _playbackEventSubject.add, @@ -178,20 +175,21 @@ class AudioPlayer { .map((state) => state.icyMetadata) .distinct() .handleError((err, stack) {/* noop */})); - _fullPlaybackStateSubject.addStream(Rx.combineLatest3( - playbackStateStream, - bufferingStream, - icyMetadataStream, - (state, buffering, icyMetadata) => - FullAudioPlaybackState(state, buffering, icyMetadata))); + _currentIndexSubject.addStream(playbackEventStream + .map((state) => state.currentIndex) + .distinct() + .handleError((err, stack) {/* noop */})); + _fullPlaybackStateSubject.addStream(playbackEventStream + .map((event) => FullAudioPlaybackState( + event.state, event.buffering, event.icyMetadata)) + .distinct() + .handleError((err, stack) {/* noop */})); } - /// The duration of any media set via [setUrl], [setFilePath] or [setAsset], - /// or null otherwise. + /// The duration of any media loaded via [load], or null if unknown. Future get durationFuture => _durationFuture; - /// The duration of any media set via [setUrl], [setFilePath] or [setAsset]. + /// The duration of any media loaded via [load]. Stream get durationStream => _durationSubject.stream; /// The latest [AudioPlaybackEvent]. @@ -208,6 +206,9 @@ class AudioPlayer { Stream get playbackStateStream => _playbackStateSubject.stream; + /// A stream broadcasting the current item. + Stream get currentIndexStream => _currentIndexSubject.stream; + /// Whether the player is buffering. bool get buffering => _audioPlaybackEvent.buffering; @@ -238,6 +239,13 @@ class AudioPlayer { Stream.periodic(period), (state, _) => state.position).distinct(); + /// A stream of [LoopMode]s. + Stream get loopModeStream => _loopModeSubject.stream; + + /// A stream of the shuffle mode status. + Stream get shuffleModeEnabledStream => + _shuffleModeEnabledSubject.stream; + /// The current volume of the player. double get volume => _volume; @@ -249,79 +257,94 @@ class AudioPlayer { bool get automaticallyWaitsToMinimizeStalling => _automaticallyWaitsToMinimizeStalling; - /// Loads audio media from a URL and completes with the duration of that - /// audio, or a [PlatformException] if this call was interrupted by another - /// call to [setUrl], [setFilePath], [setAsset] or [stop]. + /// Convenience method to load audio from a URL with optional headers, + /// equivalent to: + /// + /// ``` + /// load(ProgressiveAudioSource(Uri.parse(url), headers: headers)); + /// ``` + /// + /// + Future setUrl(String url, {Map headers}) => + load(AudioSource.uri(Uri.parse(url), headers: headers)); + + /// Convenience method to load audio from a file, equivalent to: + /// + /// ``` + /// load(ProgressiveAudioSource(Uri.file(filePath))); + /// ``` + Future setFilePath(String filePath) => + load(ProgressiveAudioSource(Uri.file(filePath))); + + /// Convenience method to load audio from an asset, equivalent to: + /// + /// ``` + /// load(ProgressiveAudioSource(Uri.parse('asset://$filePath'))); + /// ``` + Future setAsset(String assetPath) => + load(ProgressiveAudioSource(Uri.parse('asset://$assetPath'))); + + /// Loads audio from an [AudioSource] and completes with the duration of that + /// audio, or an exception if this call was interrupted by another + /// call to [load], or if for any reason the audio source was unable to be + /// loaded. /// /// If the duration is unknown, null will be returned. /// - /// On platforms except for the web, the supplied [headers] will be passed - /// with the request. Currently headers are not recursively applied to items - /// within playlist files such as m3u8. - /// /// On Android, DASH and HLS streams are detected only when the URL's path /// has an "mpd" or "m3u8" extension. If the URL does not have such an /// extension and you have no control over the server, and you also know the /// type of the stream in advance, you may as a workaround supply the /// extension as a URL fragment. e.g. /// https://somewhere.com/somestream?x=etc#.m3u8 - Future setUrl(String url, {Map headers}) async { + Future load(AudioSource source) async { try { - if (!kIsWeb && headers != null) { + _audioSource = source; + return await _load(source); + } catch (e) { + _audioSource = null; + _audioSources.clear(); + rethrow; + } + } + + _registerAudioSource(AudioSource source) { + _audioSources[source._id] = source; + } + + Future _load(AudioSource source) async { + try { + if (!kIsWeb && source._requiresHeaders) { if (_proxy == null) { _proxy = _ProxyHttpServer(); await _proxy.start(); } - url = _proxy.addUrl(url, headers); } - _durationFuture = _invokeMethod('setUrl', [url]).then( + await source._setup(this); + _durationFuture = _invokeMethod('load', [source.toJson()]).then( (ms) => (ms == null || ms < 0) ? null : Duration(milliseconds: ms)); final duration = await _durationFuture; _durationSubject.add(duration); return duration; } on PlatformException catch (e) { - return Future.error(e.message); + // TODO: Create own exception type. + throw Exception(e.message); } } - /// Loads audio media from a file and completes with the duration of that - /// audio, or null if this call was interrupted by another call so [setUrl], - /// [setFilePath] or [setAsset]. - Future setFilePath(final String filePath) => setUrl( - Platform.isAndroid ? File(filePath).uri.toString() : 'file://$filePath'); - - /// Loads audio media from an asset and completes with the duration of that - /// audio, or null if this call was interrupted by another call so [setUrl], - /// [setFilePath] or [setAsset]. - Future setAsset(final String assetPath) async { - final file = await _getCacheFile(assetPath); - this._cacheFile = file; - if (!file.existsSync()) { - await file.create(recursive: true); - } - await file - .writeAsBytes((await rootBundle.load(assetPath)).buffer.asUint8List()); - return await setFilePath(file.path); - } - - /// Get file for caching asset media with proper extension - Future _getCacheFile(final String assetPath) async => File(p.join( - (await getTemporaryDirectory()).path, - 'just_audio_asset_cache', - '$_id${p.extension(assetPath)}')); - - /// Clip the audio to the given [start] and [end] timestamps. This method - /// cannot be called from the [AudioPlaybackState.none] state. - Future setClip({Duration start, Duration end}) async { - _durationFuture = - _invokeMethod('setClip', [start?.inMilliseconds, end?.inMilliseconds]) - .then((ms) => (ms == null || ms < 0) - ? const Duration(milliseconds: -1) - : Duration(milliseconds: ms)); - final duration = await _durationFuture; - _durationSubject.add(duration); - return duration; - } + /// Clips the current [AudioSource] to the given [start] and [end] + /// timestamps. If [start] is null, it will be reset to the start of the + /// original [AudioSource]. If [end] is null, it will be reset to the end of + /// the original [AudioSource]. This method cannot be called from the + /// [AudioPlaybackState.none] state. + Future setClip({Duration start, Duration end}) => + _load(start == null && end == null + ? _audioSource + : ClippingAudioSource( + audioSource: _audioSource, + start: start, + end: end, + )); /// Plays the currently loaded media from the current position. The [Future] /// returned by this method completes when playback completes or is paused or @@ -426,6 +449,18 @@ class AudioPlayer { await _invokeMethod('setSpeed', [speed]); } + /// Sets the [LoopMode]. + Future setLoopMode(LoopMode mode) async { + _loopModeSubject.add(mode); + await _invokeMethod('setLoopMode', [mode.index]); + } + + /// Sets whether shuffle mode is enabled. + Future setShuffleModeEnabled(bool enabled) async { + _shuffleModeEnabledSubject.add(enabled); + await _invokeMethod('setShuffleModeEnabled', [enabled]); + } + /// Sets automaticallyWaitsToMinimizeStalling for AVPlayer in iOS 10.0 or later, defaults to true. /// Has no effect on Android clients Future setAutomaticallyWaitsToMinimizeStalling( @@ -436,12 +471,13 @@ class AudioPlayer { [automaticallyWaitsToMinimizeStalling]); } - /// Seeks to a particular position. Specify [null] to seek to the end of live streams. - /// It is legal to invoke this method from - /// any state except for [AudioPlaybackState.none] and + /// Seeks to a particular [position]. If a composition of multiple + /// [AudioSource]s has been loaded, you may also specify [index] to seek to a + /// particular item within that sequence. It is legal to invoke this method + /// from any state except for [AudioPlaybackState.none] and /// [AudioPlaybackState.connecting]. - Future seek(final Duration position) async { - await _invokeMethod('seek', [position?.inMilliseconds]); + Future seek(final Duration position, {int index}) async { + await _invokeMethod('seek', [position?.inMilliseconds, index]); } /// Release all resources associated with this player. You must invoke this @@ -452,13 +488,15 @@ class AudioPlayer { /// * [AudioPlaybackState.connecting] Future dispose() async { await _invokeMethod('dispose'); - if (_cacheFile?.existsSync() == true) { - _cacheFile?.deleteSync(); - } + _audioSource = null; + _audioSources.values.forEach((s) => s._dispose()); + _audioSources.clear(); _proxy?.stop(); await _durationSubject.close(); await _eventChannelStreamSubscription.cancel(); await _playbackEventSubject.close(); + await _loopModeSubject.close(); + await _shuffleModeEnabledSubject.close(); } Future _invokeMethod(String method, [dynamic args]) async => @@ -491,6 +529,9 @@ class AudioPlaybackEvent { final IcyMetadata icyMetadata; + /// The index of the currently playing item. + final int currentIndex; + AudioPlaybackEvent({ @required this.state, @required this.buffering, @@ -500,6 +541,7 @@ class AudioPlaybackEvent { @required this.speed, @required this.duration, @required this.icyMetadata, + @required this.currentIndex, }); AudioPlaybackEvent copyWith({ @@ -511,6 +553,7 @@ class AudioPlaybackEvent { double speed, Duration duration, IcyMetadata icyMetadata, + UriAudioSource currentIndex, }) => AudioPlaybackEvent( state: state ?? this.state, @@ -521,6 +564,7 @@ class AudioPlaybackEvent { speed: speed ?? this.speed, duration: duration ?? this.duration, icyMetadata: icyMetadata ?? this.icyMetadata, + currentIndex: currentIndex ?? this.currentIndex, ); /// The current position of the player. @@ -565,6 +609,17 @@ class FullAudioPlaybackState { final IcyMetadata icyMetadata; FullAudioPlaybackState(this.state, this.buffering, this.icyMetadata); + + @override + int get hashCode => + icyMetadata.hashCode * (state.index + 1) * (buffering ? 2 : 1); + + @override + bool operator ==(dynamic other) => + other is FullAudioPlaybackState && + other?.state == state && + other?.buffering == buffering && + other?.icyMetadata == icyMetadata; } class IcyInfo { @@ -572,6 +627,18 @@ class IcyInfo { final String url; IcyInfo({@required this.title, @required this.url}); + + IcyInfo.fromJson(Map json) : this(title: json['title'], url: json['url']); + + @override + String toString() => 'title=$title,url=$url'; + + @override + int get hashCode => toString().hashCode; + + @override + bool operator ==(dynamic other) => + other is IcyInfo && other?.toString() == toString(); } class IcyHeaders { @@ -582,13 +649,35 @@ class IcyHeaders { final String url; final bool isPublic; - IcyHeaders( - {@required this.bitrate, - @required this.genre, - @required this.name, - @required this.metadataInterval, - @required this.url, - @required this.isPublic}); + IcyHeaders({ + @required this.bitrate, + @required this.genre, + @required this.name, + @required this.metadataInterval, + @required this.url, + @required this.isPublic, + }); + + IcyHeaders.fromJson(Map json) + : this( + bitrate: json['bitrate'], + genre: json['genre'], + name: json['name'], + metadataInterval: json['metadataInterval'], + url: json['url'], + isPublic: json['isPublic'], + ); + + @override + String toString() => + 'bitrate=$bitrate,genre=$genre,name=$name,metadataInterval=$metadataInterval,url=$url,isPublic=$isPublic'; + + @override + int get hashCode => toString().hashCode; + + @override + bool operator ==(dynamic other) => + other is IcyHeaders && other?.toString() == toString(); } class IcyMetadata { @@ -596,6 +685,16 @@ class IcyMetadata { final IcyHeaders headers; IcyMetadata({@required this.info, @required this.headers}); + + IcyMetadata.fromJson(Map json) + : this(info: json['info'], headers: json['headers']); + + @override + int get hashCode => info.hashCode ^ headers.hashCode; + + @override + bool operator ==(dynamic other) => + other is IcyMetadata && other?.info == info && other?.headers == headers; } /// The audio session categories on iOS, to be used with @@ -624,17 +723,14 @@ class _ProxyHttpServer { /// Associate headers with a URL. This may be called only after [start] has /// completed. - String addUrl(String url, Map headers) { - final uri = Uri.parse(url); - final path = _requestKey(uri); - _uriMap[path] = _ProxyRequest(uri, headers); - return uri - .replace( - scheme: 'http', - host: InternetAddress.loopbackIPv4.address, - port: port, - ) - .toString(); + Uri addUrl(Uri url, Map headers) { + final path = _requestKey(url); + _uriMap[path] = _ProxyRequest(url, headers); + return url.replace( + scheme: 'http', + host: InternetAddress.loopbackIPv4.address, + port: port, + ); } /// A unique key for each request that can be processed by this proxy, @@ -730,3 +826,358 @@ class _ProxyRequest { _ProxyRequest(this.uri, this.headers); } + +/// Specifies a source of audio to be played. Audio sources are composable +/// using the subclasses of this class. The same [AudioSource] instance should +/// not be used simultaneously by more than one [AudioPlayer]. +abstract class AudioSource { + final String _id; + AudioPlayer _player; + + /// Creates an [AudioSource] from a [Uri] with optional headers by + /// attempting to guess the type of stream. On iOS, this uses Apple's SDK to + /// automatically detect the stream type. On Android, the type of stream will + /// be guessed from the extension. + static AudioSource uri(Uri uri, {Map headers, Object tag}) { + bool hasExtension(Uri uri, String extension) => + uri.path.toLowerCase().endsWith('.$extension') || + uri.fragment.toLowerCase().endsWith('.$extension'); + if (hasExtension(uri, 'mdp')) { + return DashAudioSource(uri, headers: headers, tag: tag); + } else if (hasExtension(uri, 'm3u8')) { + return HlsAudioSource(uri, headers: headers, tag: tag); + } else { + return ProgressiveAudioSource(uri, headers: headers, tag: tag); + } + } + + static AudioSource fromJson(Map json) { + switch (json['type']) { + case 'progressive': + return ProgressiveAudioSource(Uri.parse(json['uri']), + headers: json['headers']); + case "dash": + return DashAudioSource(Uri.parse(json['uri']), + headers: json['headers']); + case "hls": + return HlsAudioSource(Uri.parse(json['uri']), headers: json['headers']); + case "concatenating": + return ConcatenatingAudioSource( + audioSources: (json['audioSources'] as List) + .map((s) => AudioSource.fromJson(s)) + .toList()); + case "clipping": + return ClippingAudioSource( + audioSource: AudioSource.fromJson(json['audioSource']), + start: Duration(milliseconds: json['start']), + end: Duration(milliseconds: json['end'])); + default: + throw Exception("Unknown AudioSource type: " + json['type']); + } + } + + AudioSource() : _id = _uuid.v4(); + + @mustCallSuper + Future _setup(AudioPlayer player) async { + _player = player; + player._registerAudioSource(this); + } + + @mustCallSuper + void _dispose() { + _player = null; + } + + bool get _requiresHeaders; + + List get sequence; + + Map toJson(); + + @override + int get hashCode => _id.hashCode; + + @override + bool operator ==(dynamic other) => other is AudioSource && other._id == _id; +} + +/// An [AudioSource] that can appear in a sequence. +abstract class IndexedAudioSource extends AudioSource { + final Object tag; + + IndexedAudioSource(this.tag); + + @override + List get sequence => [this]; +} + +abstract class UriAudioSource extends IndexedAudioSource { + final Uri uri; + final Map headers; + final String _type; + Uri _overrideUri; + File _cacheFile; + + UriAudioSource(this.uri, {this.headers, Object tag, @required String type}) + : _type = type, + super(tag); + + @override + Future _setup(AudioPlayer player) async { + await super._setup(player); + if (uri.scheme == 'asset') { + _overrideUri = Uri.file((await _loadAsset(uri.path)).path); + } else if (headers != null) { + _overrideUri = player._proxy.addUrl(uri, headers); + } + } + + @override + void _dispose() { + if (_cacheFile?.existsSync() == true) { + _cacheFile?.deleteSync(); + } + super._dispose(); + } + + Future _loadAsset(String assetPath) async { + final file = await _getCacheFile(assetPath); + this._cacheFile = file; + if (!file.existsSync()) { + await file.create(recursive: true); + await file.writeAsBytes( + (await rootBundle.load(assetPath)).buffer.asUint8List()); + } + return file; + } + + /// Get file for caching asset media with proper extension + Future _getCacheFile(final String assetPath) async => File(p.join( + (await getTemporaryDirectory()).path, + 'just_audio_asset_cache', + '${_player._id}_$_id${p.extension(assetPath)}')); + + @override + bool get _requiresHeaders => headers != null; + + @override + Map toJson() => { + 'id': _id, + 'type': _type, + 'uri': (_overrideUri ?? uri).toString(), + 'headers': headers, + }; +} + +/// An [AudioSource] representing a regular media file such asn an MP3 or M4A +/// file. The following URI schemes are supported: +/// +/// * file: loads from a local file (provided you give your app permission to +/// access that file). +/// * asset: loads from a Flutter asset (not supported on Web). +/// * http(s): loads from an HTTP(S) resource. +/// +/// On platforms except for the web, the supplied [headers] will be passed with +/// the HTTP(S) request. +class ProgressiveAudioSource extends UriAudioSource { + ProgressiveAudioSource(Uri uri, {Map headers, Object tag}) + : super(uri, headers: headers, tag: tag, type: 'progressive'); +} + +/// An [AudioSource] representing a DASH stream. +/// +/// On platforms except for the web, the supplied [headers] will be passed with +/// the HTTP(S) request. Currently headers are not recursively applied to items +/// the HTTP(S) request. Currently headers are not applied recursively. +class DashAudioSource extends UriAudioSource { + DashAudioSource(Uri uri, {Map headers, Object tag}) + : super(uri, headers: headers, tag: tag, type: 'dash'); +} + +/// An [AudioSource] representing an HLS stream. +/// +/// On platforms except for the web, the supplied [headers] will be passed with +/// the HTTP(S) request. Currently headers are not applied recursively. +class HlsAudioSource extends UriAudioSource { + HlsAudioSource(Uri uri, {Map headers, Object tag}) + : super(uri, headers: headers, tag: tag, type: 'hls'); +} + +/// An [AudioSource] representing a concatenation of multiple audio sources to +/// be played in succession. This can be used to create playlists. Audio sources +/// can be dynamically added, removed and reordered while the audio is playing. +class ConcatenatingAudioSource extends AudioSource { + final List audioSources; + final bool useLazyPreparation; + + ConcatenatingAudioSource({ + @required this.audioSources, + this.useLazyPreparation = false, + }); + + @override + Future _setup(AudioPlayer player) async { + await super._setup(player); + for (var source in audioSources) { + await source._setup(player); + } + } + + /// Appends an [AudioSource]. + Future add(AudioSource audioSource) async { + audioSources.add(audioSource); + if (_player != null) { + await _player + ._invokeMethod('concatenating.add', [_id, audioSource.toJson()]); + } + } + + /// Inserts an [AudioSource] at [index]. + Future insert(int index, AudioSource audioSource) async { + audioSources.insert(index, audioSource); + if (_player != null) { + await _player._invokeMethod( + 'concatenating.insert', [_id, index, audioSource.toJson()]); + } + } + + /// Appends multiple [AudioSource]s. + Future addAll(List audioSources) async { + this.audioSources.addAll(audioSources); + if (_player != null) { + await _player._invokeMethod('concatenating.addAll', + [_id, audioSources.map((s) => s.toJson()).toList()]); + } + } + + /// Insert multiple [AudioSource]s at [index]. + Future insertAll(int index, List audioSources) async { + audioSources.insertAll(index, audioSources); + if (_player != null) { + await _player._invokeMethod('concatenating.insertAll', + [_id, index, audioSources.map((s) => s.toJson()).toList()]); + } + } + + /// Dynmaically remove an [AudioSource] at [index] after this + /// [ConcatenatingAudioSource] has already been loaded. + Future removeAt(int index) async { + audioSources.removeAt(index); + if (_player != null) { + await _player._invokeMethod('concatenating.removeAt', [_id, index]); + } + } + + /// Removes a range of [AudioSource]s from index [start] inclusive to [end] + /// exclusive. + Future removeRange(int start, int end) async { + audioSources.removeRange(start, end); + if (_player != null) { + await _player + ._invokeMethod('concatenating.removeRange', [_id, start, end]); + } + } + + /// Moves an [AudioSource] from [currentIndex] to [newIndex]. + Future move(int currentIndex, int newIndex) async { + audioSources.insert(newIndex, audioSources.removeAt(currentIndex)); + if (_player != null) { + await _player + ._invokeMethod('concatenating.move', [_id, currentIndex, newIndex]); + } + } + + /// Removes all [AudioSources]. + Future clear() async { + audioSources.clear(); + if (_player != null) { + await _player._invokeMethod('concatenating.clear', [_id]); + } + } + + /// The number of [AudioSource]s. + int get length => audioSources.length; + + operator [](int index) => audioSources[index]; + + @override + List get sequence => + audioSources.expand((s) => s.sequence).toList(); + + @override + bool get _requiresHeaders => + audioSources.any((source) => source._requiresHeaders); + + @override + Map toJson() => { + 'id': _id, + 'type': 'concatenating', + 'audioSources': audioSources.map((source) => source.toJson()).toList(), + 'useLazyPreparation': useLazyPreparation, + }; +} + +/// An [AudioSource] that clips the audio of a [UriAudioSource] between a +/// certain start and end time. +class ClippingAudioSource extends IndexedAudioSource { + final UriAudioSource audioSource; + final Duration start; + final Duration end; + + ClippingAudioSource({ + @required this.audioSource, + this.start, + this.end, + Object tag, + }) : super(tag); + + @override + Future _setup(AudioPlayer player) async { + await super._setup(player); + await audioSource._setup(player); + } + + @override + bool get _requiresHeaders => audioSource._requiresHeaders; + + @override + Map toJson() => { + 'id': _id, + 'type': 'clipping', + 'audioSource': audioSource.toJson(), + 'start': start?.inMilliseconds, + 'end': end?.inMilliseconds, + }; +} + +// An [AudioSource] that loops a nested [AudioSource] a +// specified number of times. +class LoopingAudioSource extends AudioSource { + AudioSource audioSource; + final int count; + + LoopingAudioSource({ + @required this.audioSource, + this.count, + }) : super(); + + @override + List get sequence => + List.generate(count, (i) => audioSource) + .expand((s) => s.sequence) + .toList(); + + @override + bool get _requiresHeaders => audioSource._requiresHeaders; + + @override + Map toJson() => { + 'id': _id, + 'type': 'looping', + 'audioSource': audioSource.toJson(), + 'count': count, + }; +} + +enum LoopMode { off, one, all } diff --git a/lib/just_audio_web.dart b/lib/just_audio_web.dart index 752f694..d515f5d 100644 --- a/lib/just_audio_web.dart +++ b/lib/just_audio_web.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:html'; +import 'dart:math'; import 'package:async/async.dart'; import 'package:flutter/services.dart'; @@ -7,6 +8,8 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:just_audio/just_audio.dart'; +final Random _random = Random(); + class JustAudioPlugin { static void registerWith(Registrar registrar) { final MethodChannel channel = MethodChannel( @@ -43,6 +46,7 @@ abstract class JustAudioPlayer { final StreamController eventController = StreamController(); AudioPlaybackState _state = AudioPlaybackState.none; bool _buffering = false; + int _index; JustAudioPlayer({@required this.id, @required this.registrar}) : methodChannel = MethodChannel('com.ryanheise.just_audio.methods.$id', @@ -54,34 +58,55 @@ abstract class JustAudioPlayer { } Future _methodHandler(MethodCall call) async { - final args = call.arguments; - switch (call.method) { - case 'setUrl': - return await setUrl(args[0]); - case 'setClip': - return await setClip(args[0], args[1]); - case 'play': - return await play(); - case 'pause': - return await pause(); - case 'stop': - return await stop(); - case 'setVolume': - return await setVolume(args[0]); - case 'setSpeed': - return await setSpeed(args[0]); - case 'seek': - return await seek(args[0]); - case 'dispose': - return dispose(); - default: - throw PlatformException(code: 'Unimplemented'); + try { + final args = call.arguments; + switch (call.method) { + case 'load': + return await load(args[0]); + case 'play': + return await play(); + case 'pause': + return await pause(); + case 'stop': + return await stop(); + case 'setVolume': + return await setVolume(args[0]); + case 'setSpeed': + return await setSpeed(args[0]); + case 'setLoopMode': + return await setLoopMode(args[0]); + case 'setShuffleModeEnabled': + return await setShuffleModeEnabled(args[0]); + case 'seek': + return await seek(args[0], args[1]); + case 'dispose': + return dispose(); + case 'concatenating.add': + return await concatenatingAdd(args[0], args[1]); + case "concatenating.insert": + return await concatenatingInsert(args[0], args[1], args[2]); + case "concatenating.addAll": + return await concatenatingAddAll(args[0], args[1]); + case "concatenating.insertAll": + return await concatenatingInsertAll(args[0], args[1], args[2]); + case "concatenating.removeAt": + return await concatenatingRemoveAt(args[0], args[1]); + case "concatenating.removeRange": + return await concatenatingRemoveRange(args[0], args[1], args[2]); + case "concatenating.move": + return await concatenatingMove(args[0], args[1], args[2]); + case "concatenating.clear": + return await concatenatingClear(args[0]); + default: + throw PlatformException(code: 'Unimplemented'); + } + } catch (e, stacktrace) { + print("$stacktrace"); + rethrow; } } - Future setUrl(final String url); - - Future setClip(int start, int end); + Future load(Map source); Future play(); @@ -93,37 +118,51 @@ abstract class JustAudioPlayer { Future setSpeed(double speed); - Future seek(int position); + Future setLoopMode(int mode); + + Future setShuffleModeEnabled(bool enabled); + + Future seek(int position, int index); @mustCallSuper void dispose() { eventController.close(); } - double getCurrentPosition(); + Duration getCurrentPosition(); - int getCurrentPositionMs() => (getCurrentPosition() * 1000).toInt(); + Duration getDuration(); - double getDuration(); + concatenatingAdd(String playerId, Map source); - int getDurationMs() { - final duration = getDuration(); - return duration.isFinite ? (duration * 1000).toInt() : -1; - } + concatenatingInsert(String playerId, int index, Map source); + + concatenatingAddAll(String playerId, List sources); + + concatenatingInsertAll(String playerId, int index, List sources); + + concatenatingRemoveAt(String playerId, int index); + + concatenatingRemoveRange(String playerId, int start, int end); + + concatenatingMove(String playerId, int currentIndex, int newIndex); + + concatenatingClear(String playerId); broadcastPlaybackEvent() { var updateTime = DateTime.now().millisecondsSinceEpoch; - eventController.add([ - _state.index, - _buffering, - getCurrentPositionMs(), - updateTime, + eventController.add({ + 'state': _state.index, + 'buffering': _buffering, + 'updatePosition': getCurrentPosition()?.inMilliseconds, + 'updateTime': updateTime, // TODO: buffered position - getCurrentPositionMs(), + 'bufferedPosition': getCurrentPosition()?.inMilliseconds, // TODO: Icy Metadata - null, - getDurationMs(), - ]); + 'icyMetadata': null, + 'duration': getDuration()?.inMilliseconds, + 'currentIndex': _index, + }); } transition(AudioPlaybackState state) { @@ -134,22 +173,23 @@ abstract class JustAudioPlayer { class Html5AudioPlayer extends JustAudioPlayer { AudioElement _audioElement = AudioElement(); - Completer _durationCompleter; - double _startPos = 0.0; - double _start = 0.0; - double _end; - CancelableOperation _playOperation; + Completer _durationCompleter; + AudioSourcePlayer _audioSourcePlayer; + LoopMode _loopMode = LoopMode.off; + bool _shuffleModeEnabled = false; + bool _playing = false; + final Map _audioSourcePlayers = {}; Html5AudioPlayer({@required String id, @required Registrar registrar}) : super(id: id, registrar: registrar) { _audioElement.addEventListener('durationchange', (event) { - _durationCompleter?.complete(getDuration()); + _durationCompleter?.complete(); }); _audioElement.addEventListener('error', (event) { _durationCompleter?.completeError(_audioElement.error); }); - _audioElement.addEventListener('ended', (event) { - transition(AudioPlaybackState.completed); + _audioElement.addEventListener('ended', (event) async { + onEnded(); }); _audioElement.addEventListener('seek', (event) { _buffering = true; @@ -161,72 +201,124 @@ class Html5AudioPlayer extends JustAudioPlayer { }); } - @override - Future setUrl(final String url) async { - _interruptPlay(); - transition(AudioPlaybackState.connecting); - _durationCompleter = Completer(); - _audioElement.src = url; - _audioElement.preload = 'auto'; - _audioElement.load(); - try { - await _durationCompleter.future; - } on MediaError catch (e) { - throw PlatformException(code: "${e.code}", message: "Failed to load URL"); - } finally { - _durationCompleter = null; + List get order { + final sequence = _audioSourcePlayer.sequence; + List order = List(sequence.length); + if (_shuffleModeEnabled) { + order = _audioSourcePlayer.shuffleOrder; + } else { + for (var i = 0; i < order.length; i++) { + order[i] = i; + } } - transition(AudioPlaybackState.stopped); - return getDurationMs(); + return order; } + List getInv(List order) { + List orderInv = List(order.length); + for (var i = 0; i < order.length; i++) { + orderInv[order[i]] = i; + } + return orderInv; + } + + onEnded() async { + if (_loopMode == LoopMode.one) { + await seek(0, null); + play(); + } else { + final order = this.order; + final orderInv = getInv(order); + if (orderInv[_index] + 1 < order.length) { + // move to next item + _index = order[orderInv[_index] + 1]; + await _currentAudioSourcePlayer.load(); + // Should always be true... + if (_playing) { + play(); + } + } else { + // reached end of playlist + if (_loopMode == LoopMode.all) { + // Loop back to the beginning + if (order.length == 1) { + await seek(0, null); + await play(); + } else { + _index = order[0]; + await _currentAudioSourcePlayer.load(); + // Should always be true... + if (_playing) { + await play(); + } + } + } else { + _playing = false; + transition(AudioPlaybackState.completed); + } + } + } + } + + // TODO: Improve efficiency. + IndexedAudioSourcePlayer get _currentAudioSourcePlayer => + _audioSourcePlayer != null && _index < _audioSourcePlayer.sequence.length + ? _audioSourcePlayer.sequence[_index] + : null; + @override - Future setClip(int start, int end) async { - _interruptPlay(); - _start = start / 1000.0; - _end = end / 1000.0; - _startPos = _start; + Future load(Map source) async { + _currentAudioSourcePlayer?.pause(); + _audioSourcePlayer = getAudioSource(source); + _index = 0; + if (_shuffleModeEnabled) { + _audioSourcePlayer?.shuffle(0, _index); + } + return (await _currentAudioSourcePlayer.load()).inMilliseconds; + } + + Future loadUri(final Uri uri) async { + transition(AudioPlaybackState.connecting); + final src = uri.toString(); + if (src != _audioElement.src) { + _durationCompleter = Completer(); + _audioElement.src = src; + _audioElement.preload = 'auto'; + _audioElement.load(); + try { + await _durationCompleter.future; + } on MediaError catch (e) { + throw PlatformException( + code: "${e.code}", message: "Failed to load URL"); + } finally { + _durationCompleter = null; + } + } + transition(AudioPlaybackState.stopped); + final seconds = _audioElement.duration; + return seconds.isFinite + ? Duration(milliseconds: (seconds * 1000).toInt()) + : null; } @override Future play() async { - _interruptPlay(); - final duration = _end == null ? null : _end - _startPos; - - _audioElement.currentTime = _startPos; - _audioElement.play(); - if (duration != null) { - _playOperation = CancelableOperation.fromFuture(Future.delayed(Duration( - milliseconds: duration * 1000 ~/ _audioElement.playbackRate))) - .then((_) { - pause(); - _playOperation = null; - }); - } + _playing = true; + _currentAudioSourcePlayer.play(); transition(AudioPlaybackState.playing); } - _interruptPlay() { - if (_playOperation != null) { - _playOperation.cancel(); - _playOperation = null; - } - } - @override Future pause() async { - _interruptPlay(); - _startPos = _audioElement.currentTime; - _audioElement.pause(); + _playing = false; + _currentAudioSourcePlayer.pause(); transition(AudioPlaybackState.paused); } @override Future stop() async { - _interruptPlay(); - _startPos = _start; - _audioElement.pause(); - _audioElement.currentTime = _start; + _playing = false; + _currentAudioSourcePlayer.stop(); transition(AudioPlaybackState.stopped); } @@ -241,25 +333,568 @@ class Html5AudioPlayer extends JustAudioPlayer { } @override - Future seek(int position) async { - _interruptPlay(); - _startPos = _start + position / 1000.0; - _audioElement.currentTime = _startPos; + Future setLoopMode(int mode) async { + _loopMode = LoopMode.values[mode]; } @override - double getCurrentPosition() => _audioElement.currentTime; + Future setShuffleModeEnabled(bool enabled) async { + _shuffleModeEnabled = enabled; + if (enabled) { + _audioSourcePlayer?.shuffle(0, _index); + } + } @override - double getDuration() => _audioElement.duration; + Future seek(int position, int newIndex) async { + int index = newIndex ?? _index; + if (index != _index) { + _currentAudioSourcePlayer.pause(); + _index = index; + await _currentAudioSourcePlayer.load(); + await _currentAudioSourcePlayer.seek(position); + if (_playing) { + await play(); + } + } else { + await _currentAudioSourcePlayer.seek(position); + } + } + + ConcatenatingAudioSourcePlayer _concatenating(String playerId) => + _audioSourcePlayers[playerId] as ConcatenatingAudioSourcePlayer; + + concatenatingAdd(String playerId, Map source) { + final playlist = _concatenating(playerId); + playlist.add(getAudioSource(source)); + } + + concatenatingInsert(String playerId, int index, Map source) { + _concatenating(playerId).insert(index, getAudioSource(source)); + if (index <= _index) { + _index++; + } + } + + concatenatingAddAll(String playerId, List sources) { + _concatenating(playerId).addAll(getAudioSources(sources)); + } + + concatenatingInsertAll(String playerId, int index, List sources) { + _concatenating(playerId).insertAll(index, getAudioSources(sources)); + if (index <= _index) { + _index += sources.length; + } + } + + concatenatingRemoveAt(String playerId, int index) async { + // Pause if removing current item + if (_index == index && _playing) { + _currentAudioSourcePlayer.pause(); + } + _concatenating(playerId).removeAt(index); + if (_index == index) { + // Skip backward if there's nothing after this + if (index == _audioSourcePlayer.sequence.length) { + _index--; + } + // Resume playback at the new item (if it exists) + if (_playing && _currentAudioSourcePlayer != null) { + await _currentAudioSourcePlayer.load(); + _currentAudioSourcePlayer.play(); + } + } else if (index < _index) { + // Reflect that the current item has shifted its position + _index--; + } + } + + concatenatingRemoveRange(String playerId, int start, int end) async { + if (_index >= start && _index < end && _playing) { + // Pause if removing current item + _currentAudioSourcePlayer.pause(); + } + _concatenating(playerId).removeRange(start, end); + if (_index >= start && _index < end) { + // Skip backward if there's nothing after this + if (start >= _audioSourcePlayer.sequence.length) { + _index = start - 1; + } else { + _index = start; + } + // Resume playback at the new item (if it exists) + if (_playing && _currentAudioSourcePlayer != null) { + await _currentAudioSourcePlayer.load(); + _currentAudioSourcePlayer.play(); + } + } else if (end <= _index) { + // Reflect that the current item has shifted its position + _index -= (end - start); + } + } + + concatenatingMove(String playerId, int currentIndex, int newIndex) { + _concatenating(playerId).move(currentIndex, newIndex); + if (currentIndex == _index) { + _index = newIndex; + } else if (currentIndex < _index && newIndex >= _index) { + _index--; + } else if (currentIndex > _index && newIndex <= _index) { + _index++; + } + } + + concatenatingClear(String playerId) { + _currentAudioSourcePlayer.stop(); + _concatenating(playerId).clear(); + } + + @override + Duration getCurrentPosition() => _currentAudioSourcePlayer?.position; + + @override + Duration getDuration() => _currentAudioSourcePlayer?.duration; @override void dispose() { - _interruptPlay(); - _audioElement.pause(); + _currentAudioSourcePlayer?.pause(); _audioElement.removeAttribute('src'); _audioElement.load(); transition(AudioPlaybackState.none); super.dispose(); } + + List getAudioSources(List json) => + json.map((s) => getAudioSource(s)).toList(); + + AudioSourcePlayer getAudioSource(Map json) { + final String id = json['id']; + var audioSourcePlayer = _audioSourcePlayers[id]; + if (audioSourcePlayer == null) { + audioSourcePlayer = decodeAudioSource(json); + _audioSourcePlayers[id] = audioSourcePlayer; + } + return audioSourcePlayer; + } + + AudioSourcePlayer decodeAudioSource(Map json) { + try { + switch (json['type']) { + case 'progressive': + return ProgressiveAudioSourcePlayer( + this, json['id'], Uri.parse(json['uri']), json['headers']); + case "dash": + return DashAudioSourcePlayer( + this, json['id'], Uri.parse(json['uri']), json['headers']); + case "hls": + return HlsAudioSourcePlayer( + this, json['id'], Uri.parse(json['uri']), json['headers']); + case "concatenating": + return ConcatenatingAudioSourcePlayer( + this, + json['id'], + getAudioSources(json['audioSources']), + json['useLazyPreparation']); + case "clipping": + return ClippingAudioSourcePlayer( + this, + json['id'], + getAudioSource(json['audioSource']), + Duration(milliseconds: json['start']), + Duration(milliseconds: json['end'])); + case "looping": + return LoopingAudioSourcePlayer(this, json['id'], + getAudioSource(json['audioSource']), json['count']); + default: + throw Exception("Unknown AudioSource type: " + json['type']); + } + } catch (e, stacktrace) { + print("$stacktrace"); + rethrow; + } + } +} + +abstract class AudioSourcePlayer { + Html5AudioPlayer html5AudioPlayer; + final String id; + + AudioSourcePlayer(this.html5AudioPlayer, this.id); + + List get sequence; + + List get shuffleOrder; + + int shuffle(int treeIndex, int currentIndex); +} + +abstract class IndexedAudioSourcePlayer extends AudioSourcePlayer { + IndexedAudioSourcePlayer(Html5AudioPlayer html5AudioPlayer, String id) + : super(html5AudioPlayer, id); + + Future load(); + + Future play(); + + Future pause(); + + Future stop(); + + Future seek(int position); + + Duration get duration; + + Duration get position; + + AudioElement get _audioElement => html5AudioPlayer._audioElement; + + @override + int shuffle(int treeIndex, int currentIndex) => treeIndex + 1; + + @override + String toString() => "${this.runtimeType}"; +} + +abstract class UriAudioSourcePlayer extends IndexedAudioSourcePlayer { + final Uri uri; + final Map headers; + double _resumePos; + Duration _duration; + + UriAudioSourcePlayer( + Html5AudioPlayer html5AudioPlayer, String id, this.uri, this.headers) + : super(html5AudioPlayer, id); + + @override + List get sequence => [this]; + + @override + List get shuffleOrder => [0]; + + @override + Future load() async { + _resumePos = 0.0; + return _duration = await html5AudioPlayer.loadUri(uri); + } + + @override + Future play() async { + _audioElement.currentTime = _resumePos; + _audioElement.play(); + } + + @override + Future pause() async { + _resumePos = _audioElement.currentTime; + _audioElement.pause(); + } + + @override + Future seek(int position) async { + _audioElement.currentTime = _resumePos = position / 1000.0; + } + + @override + Future stop() async { + _resumePos = 0.0; + _audioElement.pause(); + _audioElement.currentTime = 0.0; + } + + @override + Duration get duration { + return _duration; + //final seconds = _audioElement.duration; + //return seconds.isFinite + // ? Duration(milliseconds: (seconds * 1000).toInt()) + // : null; + } + + @override + Duration get position { + double seconds = _audioElement.currentTime; + return Duration(milliseconds: (seconds * 1000).toInt()); + } +} + +class ProgressiveAudioSourcePlayer extends UriAudioSourcePlayer { + ProgressiveAudioSourcePlayer( + Html5AudioPlayer html5AudioPlayer, String id, Uri uri, Map headers) + : super(html5AudioPlayer, id, uri, headers); +} + +class DashAudioSourcePlayer extends UriAudioSourcePlayer { + DashAudioSourcePlayer( + Html5AudioPlayer html5AudioPlayer, String id, Uri uri, Map headers) + : super(html5AudioPlayer, id, uri, headers); +} + +class HlsAudioSourcePlayer extends UriAudioSourcePlayer { + HlsAudioSourcePlayer( + Html5AudioPlayer html5AudioPlayer, String id, Uri uri, Map headers) + : super(html5AudioPlayer, id, uri, headers); +} + +class ConcatenatingAudioSourcePlayer extends AudioSourcePlayer { + static List generateShuffleOrder(int length, [int firstIndex]) { + final shuffleOrder = List(length); + for (var i = 0; i < length; i++) { + final j = _random.nextInt(i + 1); + shuffleOrder[i] = shuffleOrder[j]; + shuffleOrder[j] = i; + } + if (firstIndex != null) { + for (var i = 1; i < length; i++) { + if (shuffleOrder[i] == firstIndex) { + final v = shuffleOrder[0]; + shuffleOrder[0] = shuffleOrder[i]; + shuffleOrder[i] = v; + break; + } + } + } + return shuffleOrder; + } + + final List audioSourcePlayers; + final bool useLazyPreparation; + List _shuffleOrder; + + ConcatenatingAudioSourcePlayer(Html5AudioPlayer html5AudioPlayer, String id, + this.audioSourcePlayers, this.useLazyPreparation) + : _shuffleOrder = generateShuffleOrder(audioSourcePlayers.length), + super(html5AudioPlayer, id); + + @override + List get sequence => + audioSourcePlayers.expand((p) => p.sequence).toList(); + + @override + List get shuffleOrder { + final order = []; + var offset = order.length; + final childOrders = >[]; + for (var audioSourcePlayer in audioSourcePlayers) { + final childShuffleOrder = audioSourcePlayer.shuffleOrder; + childOrders.add(childShuffleOrder.map((i) => i + offset).toList()); + offset += childShuffleOrder.length; + } + for (var i = 0; i < childOrders.length; i++) { + order.addAll(childOrders[_shuffleOrder[i]]); + } + return order; + } + + @override + int shuffle(int treeIndex, int currentIndex) { + int currentChildIndex; + for (var i = 0; i < audioSourcePlayers.length; i++) { + final indexBefore = treeIndex; + final child = audioSourcePlayers[i]; + treeIndex = child.shuffle(treeIndex, currentIndex); + if (currentIndex >= indexBefore && currentIndex < treeIndex) { + currentChildIndex = i; + } else {} + } + // Shuffle so that the current child is first in the shuffle order + _shuffleOrder = + generateShuffleOrder(audioSourcePlayers.length, currentChildIndex); + return treeIndex; + } + + add(AudioSourcePlayer player) { + audioSourcePlayers.add(player); + _shuffleOrder.add(audioSourcePlayers.length - 1); + } + + insert(int index, AudioSourcePlayer player) { + audioSourcePlayers.insert(index, player); + for (var i = 0; i < audioSourcePlayers.length; i++) { + if (_shuffleOrder[i] >= index) { + _shuffleOrder[i]++; + } + } + _shuffleOrder.add(index); + } + + addAll(List players) { + audioSourcePlayers.addAll(players); + _shuffleOrder.addAll( + List.generate(players.length, (i) => audioSourcePlayers.length + i) + .toList() + ..shuffle()); + } + + insertAll(int index, List players) { + audioSourcePlayers.insertAll(index, players); + for (var i = 0; i < audioSourcePlayers.length; i++) { + if (_shuffleOrder[i] >= index) { + _shuffleOrder[i] += players.length; + } + } + _shuffleOrder.addAll( + List.generate(players.length, (i) => index + i).toList()..shuffle()); + } + + removeAt(int index) { + audioSourcePlayers.removeAt(index); + // 0 1 2 3 + // 3 2 0 1 + for (var i = 0; i < audioSourcePlayers.length; i++) { + if (_shuffleOrder[i] > index) { + _shuffleOrder[i]--; + } + } + _shuffleOrder.removeWhere((i) => i == index); + } + + removeRange(int start, int end) { + audioSourcePlayers.removeRange(start, end); + for (var i = 0; i < audioSourcePlayers.length; i++) { + if (_shuffleOrder[i] >= end) { + _shuffleOrder[i] -= (end - start); + } + } + _shuffleOrder.removeWhere((i) => i >= start && i < end); + } + + move(int currentIndex, int newIndex) { + audioSourcePlayers.insert( + newIndex, audioSourcePlayers.removeAt(currentIndex)); + } + + clear() { + audioSourcePlayers.clear(); + _shuffleOrder.clear(); + } +} + +class ClippingAudioSourcePlayer extends IndexedAudioSourcePlayer { + final UriAudioSourcePlayer audioSourcePlayer; + final Duration start; + final Duration end; + CancelableOperation _playOperation; + double _resumePos; + Duration _duration; + + ClippingAudioSourcePlayer(Html5AudioPlayer html5AudioPlayer, String id, + this.audioSourcePlayer, this.start, this.end) + : super(html5AudioPlayer, id); + + @override + List get sequence => [this]; + + @override + List get shuffleOrder => [0]; + + @override + Future load() async { + _resumePos = start.inMilliseconds / 1000.0; + Duration fullDuration = + await html5AudioPlayer.loadUri(audioSourcePlayer.uri); + _audioElement.currentTime = _resumePos; + _duration = Duration( + milliseconds: min(end.inMilliseconds, fullDuration.inMilliseconds) - + start.inMilliseconds); + return _duration; + } + + @override + Future play() async { + _interruptPlay(); + //_playing = true; + final duration = + end == null ? null : end.inMilliseconds / 1000 - _resumePos; + + _audioElement.currentTime = _resumePos; + _audioElement.play(); + if (duration != null) { + _playOperation = CancelableOperation.fromFuture(Future.delayed(Duration( + milliseconds: duration * 1000 ~/ _audioElement.playbackRate))) + .then((_) { + _playOperation = null; + pause(); + html5AudioPlayer.onEnded(); + }); + } + } + + @override + Future pause() async { + _interruptPlay(); + _resumePos = _audioElement.currentTime; + _audioElement.pause(); + } + + @override + Future seek(int position) async { + _interruptPlay(); + _audioElement.currentTime = + _resumePos = start.inMilliseconds / 1000.0 + position / 1000.0; + } + + @override + Future stop() async { + _resumePos = 0.0; + _audioElement.pause(); + _audioElement.currentTime = start.inMilliseconds / 1000.0; + } + + @override + Duration get duration { + return _duration; + } + + @override + Duration get position { + double seconds = _audioElement.currentTime; + var position = Duration(milliseconds: (seconds * 1000).toInt()); + if (start != null) { + position -= start; + } + if (position < Duration.zero) { + position = Duration.zero; + } + return position; + } + + _interruptPlay() { + _playOperation?.cancel(); + _playOperation = null; + } +} + +class LoopingAudioSourcePlayer extends AudioSourcePlayer { + final AudioSourcePlayer audioSourcePlayer; + final int count; + + LoopingAudioSourcePlayer(Html5AudioPlayer html5AudioPlayer, String id, + this.audioSourcePlayer, this.count) + : super(html5AudioPlayer, id); + + @override + List get sequence => + List.generate(count, (i) => audioSourcePlayer) + .expand((p) => p.sequence) + .toList(); + + @override + List get shuffleOrder { + final order = []; + var offset = order.length; + for (var i = 0; i < count; i++) { + final childShuffleOrder = audioSourcePlayer.shuffleOrder; + order.addAll(childShuffleOrder.map((i) => i + offset).toList()); + offset += childShuffleOrder.length; + } + return order; + } + + @override + int shuffle(int treeIndex, int currentIndex) { + for (var i = 0; i < count; i++) { + treeIndex = audioSourcePlayer.shuffle(treeIndex, currentIndex); + } + return treeIndex; + } } diff --git a/pubspec.lock b/pubspec.lock index 9bda4b9..bf47cdc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -36,6 +36,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.14.12" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.5" fake_async: dependency: transitive description: @@ -195,7 +209,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.15" + version: "0.2.16" typed_data: dependency: transitive description: @@ -203,6 +217,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.6" + uuid: + dependency: "direct main" + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" vector_math: dependency: transitive description: @@ -218,5 +239,5 @@ packages: source: hosted version: "0.1.0" sdks: - dart: ">=2.6.0 <3.0.0" + dart: ">=2.7.0 <3.0.0" flutter: ">=1.12.13+hotfix.5 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index f706b83..cdb6dcc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: just_audio -description: Flutter plugin to play audio from streams, files, assets and DASH/HLS streams. Works with audio_service to play audio in the background. +description: Flutter plugin to play audio from streams, files, assets, DASH/HLS streams and playlists. Works with audio_service to play audio in the background. version: 0.2.2 homepage: https://github.com/ryanheise/just_audio @@ -12,6 +12,7 @@ dependencies: path: ^1.6.4 path_provider: ^1.6.10 async: ^2.4.1 + uuid: ^2.2.0 flutter: sdk: flutter flutter_web_plugins: