Flutter full app 5. Add a local playlist
In the latest article, we started working on the architecture of the app, by adding Provider
and moving hardcoded playlists in a temporary special service file.
In this article, we add a local playlist, with music by Brent Simmons available at inessential.com. We discover bugs (yes, we already have bugs in the code), and we fix them. Finally, we change the behavior of the app when tapping one of the playlists, which will fix the usability issue explained in the previous article.
Add a local playlist
We start by adding a playlist to HardcodedPlaylistsService
. First, we download some mp3 files: Slow House, Vampire’s Run, and Tie & Suit. We place them in a new folder: assets/audio/
. The Flutter engine needs to know that we have some assets in that folder, so we add it to our pubspec.yaml
:
...
flutter:
assets:
- assets/audio/
...
Now we can add a new playlist to HardcodedPlaylistsService
:
final _inessential = [
PlaylistItem(Author("Brent Simmons", null), "Slow House", null,
Uri.parse("asset:///assets/audio/SlowHouse.mp3")),
PlaylistItem(Author("Brent Simmons", null), "Vampire’s Run", null,
Uri.parse("asset:///assets/audio/VampiresRun.mp3")),
PlaylistItem(Author("Brent Simmons", null), "Tie & Suit", null,
Uri.parse("asset:///assets/audio/TieSuit2021.mp3")),
];
@override
Map<String, List<PlaylistItem>> get playlists {
return {'Games': _gameSongs, "Inessential": _inessential};
}
We also add a new field to PlaylistsService
: playlists
. You can see the implementation in the snippet above.
To get the playlist available in the app, we need to add the ListTile
to CategorySelector
:
ListTile(
title: Text("Inessential"),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Player(_audioPlayer,
value.playlists['Inessential']!),
),
);
},
),
ListTile(
title: Text("Games"),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Player(
_audioPlayer, value.playlists['Games']!),
),
);
},
),
Now if you run the app you can listen to the new playlist, but, in the console, you get an error:
The following ArgumentError was thrown resolving an image codec:
Invalid argument(s): No host specified in URI file:///null
This is because of a bug in Player
: item.artworkUri.toString()
returns the string null
if artworkUri
is null. We want to return a placeholder, so we temporary change the code to:
tag: AudioMetadata(
title: item.title,
artwork: item.artworkUri?.toString() ??
'https://via.placeholder.com/150'),
Now the app works without errors. But, while playing one playlist, to change to another playlist we need first to pause the current, playing one.
A more usable player
It is now time to refactor part of the app. The current player screen shows on top of the playlist that is currently playing. Thus if we are playing the games music and go back to select the inessential music, without pausing first, nothing will happen.
We need to change the screen shown after tapping a playlist on the main page to show the correct items, which are independent of the playlist that is currently playing.
A small refactor
First thing first, notice that AudioMetadata
is not really needed. We can pass a PlaylistItem
to the audio player. So we delete the audio_metadata.dart
file and refactor PlaylistItem
, Playlist
, and Player
:
// playlist_item.dart
/// An audio item
class PlaylistItem {
/// The [Author] of this audio item.
final Author author;
/// The title of this audio item.
final String title;
/// The Uri to an image representing this audio item.
final String artworkLocation;
/// An Uri at which the audio can be found.
final Uri itemLocation;
PlaylistItem({
required this.author,
required this.title,
this.artworkLocation = "https://via.placeholder.com/150",
required this.itemLocation,
});
}
/// playlist.dart
ListTile(
selected: i == state.currentIndex,
leading: Image.network(sequence[i].tag.artworkLocation), // changed
title: Text(sequence[i].tag.title),
onTap: () {
_audioPlayer.seek(Duration.zero, index: i);
},
),
/// player.dart
void _loadAudioSources(List<PlaylistItem> playlist) {
_audioPlayer
.setAudioSource(
ConcatenatingAudioSource(
children: playlist
.map(
(item) => AudioSource.uri(
item.itemLocation,
tag: item, // changed
),
)
.toList(),
),
)
.catchError((error) {
// catch load errors: 404, invalid url ...
print("An error occured $error");
});
}
Image.network
uses String
instead of Uri
, so we refactored the artwork uri to a simple string.
We will also need to modify HardcodedPlaylistsService
:
class HardcodedPlaylistsService implements PlaylistsService {
final _gameSongs = [
PlaylistItem(
author: Author("Blizzard North", null),
title: "Tristram",
artworkLocation:
"https://upload.wikimedia.org/wikipedia/en/3/3a/Diablo_Coverart.png",
itemLocation: Uri.parse(
"https://archive.org/download/IGM-V7/IGM%20-%20Vol.%207/25%20Diablo%20-%20Tristram%20%28Blizzard%29.mp3")),
PlaylistItem(
author: Author("Game Freak", null),
title: "Cerulean City",
artworkLocation:
"https://upload.wikimedia.org/wikipedia/en/f/f1/Bulbasaur_pokemon_red.png",
itemLocation: Uri.parse(
"https://archive.org/download/igm-v8_202101/IGM%20-%20Vol.%208/15%20Pokemon%20Red%20-%20Cerulean%20City%20%28Game%20Freak%29.mp3")),
PlaylistItem(
author: Author("Lucasfilm Games", null),
title: "The secret of Monkey Island - Introduction",
artworkLocation:
"https://upload.wikimedia.org/wikipedia/en/a/a8/The_Secret_of_Monkey_Island_artwork.jpg",
itemLocation: Uri.parse(
"https://scummbar.com/mi2/MI1-CD/01%20-%20Opening%20Themes%20-%20Introduction.mp3")),
];
final _inessential = [
PlaylistItem(
author: Author("Brent Simmons", null),
title: "Slow House",
itemLocation: Uri.parse("asset:///assets/audio/SlowHouse.mp3")),
PlaylistItem(
author: Author("Brent Simmons", null),
title: "Vampire’s Run",
itemLocation: Uri.parse("asset:///assets/audio/VampiresRun.mp3")),
PlaylistItem(
author: Author("Brent Simmons", null),
title: "Tie & Suit",
itemLocation: Uri.parse("asset:///assets/audio/TieSuit2021.mp3")),
];
...
A much bigger refactor
We are using the audio player from just_audio
in many places. We could use Provider
to get the player available wherever we need it. If in the future we want to add tests (we will), or if we want to swap just_audio
out for a different package (we won’t), it is a good idea now to hide the audio player behind an interface. When we are ready to add tests, we can create a mock that implements such an interface.
/// audio_player_service.dart
/// Enumerates the different processing states of a player.
enum AudioProcessingState {
/// The player has not loaded an audio source.
idle,
/// The player is loading an audio source.
loading,
/// The player is buffering audio and unable to play.
buffering,
/// The player has enough audio buffered and is able to play.
ready,
/// The player is ready and playing.
playing,
/// The player has reached the end of the audio.
completed,
/// The status is unknown.
unknown,
}
/// An enumeration of modes representing the loop status.
enum PlaylistLoopMode {
/// No audio is looping.
off,
/// Looping the current audio.
one,
/// Looping the current playlist.
all,
}
abstract class AudioPlayerService {
/// Whether the player is playing any audio.
Stream<bool> get isPlaying;
/// Whether shuffle mode is currently enabled.
Stream<bool> get shuffleModeEnabled;
/// The current [AudioProcessingState] of the player.
Stream<AudioProcessingState> get audioProcessingState;
/// Which loop mode is currently active in the player.
Stream<PlaylistLoopMode> get loopMode;
/// Whether there is a previous audio in the playlist.
///
/// Note: this account for shuffle and repeat modes.
bool get hasPrevious;
/// Whether there is a next audio in the playlist.
///
/// Note: this account for shuffle and repeat modes.
bool get hasNext;
/// The current playlist of item.
///
/// Note: this does not change with shuffle and repeat mode.
Stream<List<PlaylistItem>?> get currentPlaylist;
/// Skip to the previous audio in the playlist, if any.
Future<void> seekToPrevious();
/// Skip to the next audio in the playlist, if any.
Future<void> seekToNext();
/// Set a specific loop mode.
Future<void> setLoopMode(PlaylistLoopMode mode);
/// Set whether the shuffle mode is enabled.
Future<void> setShuffleModeEnabled(bool enabled);
/// Pause the player.
Future<void> pause();
/// Start playing from the item previously seeked to,
/// or the first item if no seek was previously done.
Future<void> play();
/// Move to the start of the playlist.
Future<void> seekToStart();
/// Move to the `index` item in the playlist.
Future<void> seekToIndex(int index);
/// Load a playlist.
///
/// Note: this is needed before playing any item.
Future<Duration?> loadPlaylist(List<PlaylistItem> playlist);
}
AudioPlayerService
is inspired by AudioPlayer
in just_audio
, but it is easy to use any other audio player package and adapt it to this interface.
The just_audio
implementation is:
/// just_audio_player.dart
class JustAudioPlayer implements AudioPlayerService {
final AudioPlayer _audioPlayer = AudioPlayer();
// State
@override
Stream<AudioProcessingState> get audioProcessingState =>
_audioPlayer.playerStateStream.map(
(_playerStateMap),
);
@override
Stream<List<PlaylistItem>?> get currentPlaylist =>
_audioPlayer.sequenceStateStream.map(
(sequenceState) {
return sequenceState?.sequence
.map(
(source) => source.tag,
)
.whereType<PlaylistItem>()
.toList();
},
);
@override
bool get hasNext => _audioPlayer.hasNext;
@override
bool get hasPrevious => _audioPlayer.hasPrevious;
@override
Stream<bool> get isPlaying => _audioPlayer.playingStream;
@override
Stream<PlaylistLoopMode> get loopMode =>
_audioPlayer.loopModeStream.map((_loopModeMap));
@override
Stream<bool> get shuffleModeEnabled => _audioPlayer.shuffleModeEnabledStream;
// Actions
@override
Future<void> pause() {
return _audioPlayer.pause();
}
@override
Future<void> play() {
return _audioPlayer.play();
}
@override
Future<void> seekToNext() {
return _audioPlayer.seekToNext();
}
@override
Future<void> seekToPrevious() {
return _audioPlayer.seekToPrevious();
}
@override
Future<void> setLoopMode(PlaylistLoopMode mode) {
switch (mode) {
case PlaylistLoopMode.off:
return _audioPlayer.setLoopMode(LoopMode.off);
case PlaylistLoopMode.one:
return _audioPlayer.setLoopMode(LoopMode.one);
case PlaylistLoopMode.all:
return _audioPlayer.setLoopMode(LoopMode.all);
}
}
@override
Future<void> setShuffleModeEnabled(bool enabled) async {
if (enabled) {
await _audioPlayer.shuffle();
}
return _audioPlayer.setShuffleModeEnabled(enabled);
}
@override
Future<void> seekToStart() {
return _audioPlayer.seek(Duration.zero,
index: _audioPlayer.effectiveIndices?.first);
}
@override
Future<void> seekToIndex(int index) {
return _audioPlayer.seek(Duration.zero, index: index);
}
@override
Future<Duration?> loadPlaylist(List<PlaylistItem> playlist) {
// TODO do not load a playlist if it is already loaded.
return _audioPlayer
.setAudioSource(
ConcatenatingAudioSource(
children: playlist
.map(
(item) => AudioSource.uri(
item.itemLocation,
tag: item,
),
)
.toList(),
),
)
.catchError((error) {
// catch load errors: 404, invalid url ...
print("An error occured $error");
});
}
Future<void> dispose() {
return _audioPlayer.dispose();
}
// Helpers
static AudioProcessingState _playerStateMap(PlayerState? state) {
final processingState = state?.processingState;
if (processingState == null) return AudioProcessingState.unknown;
switch (processingState) {
case ProcessingState.idle:
return AudioProcessingState.idle;
case ProcessingState.loading:
return AudioProcessingState.loading;
case ProcessingState.buffering:
return AudioProcessingState.buffering;
case ProcessingState.ready:
if (state?.playing ?? false)
return AudioProcessingState.playing;
else
return AudioProcessingState.ready;
case ProcessingState.completed:
return AudioProcessingState.completed;
}
}
static PlaylistLoopMode _loopModeMap(LoopMode mode) {
switch (mode) {
case LoopMode.off:
return PlaylistLoopMode.off;
case LoopMode.one:
return PlaylistLoopMode.one;
case LoopMode.all:
return PlaylistLoopMode.all;
}
}
}
Now we can provide a JustAudioPlayer
in main.dart
. We also move the Provider
s around the MaterialApp
because we want such objects to be available on all routes.
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<PlaylistsService>(
create: (_) => HardcodedPlaylistsService(),
),
Provider<AudioPlayerService>(
create: (_) => JustAudioPlayer(),
dispose: (_, value) {
(value as JustAudioPlayer).dispose();
},
),
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
home: CategorySelector(),
),
);
}
}
Note how we need to cast value
to a JustAudioPlayer
because we are actually providing a AudioPlayerService
.
Next, we create a new widget that places its child at the top of the page and the audio controlling buttons at the bottom of the page. We can use this widget every time we want the buttons to be visible if some audio items are loaded.
/// player_buttons_container.dart
/// Widget that place the content of a screen on top of the buttons that
/// control the audio. `child` is wrapped in an [Expanded] widget.
class PlayerButtonsContainer extends StatelessWidget {
final Widget child;
PlayerButtonsContainer({Key? key, required this.child}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(child: child),
Consumer<AudioPlayerService>(
builder: (context, player, _) {
return StreamBuilder<bool>(
stream: player.audioProcessingState
.map((state) => state != AudioProcessingState.idle),
builder: (context, snapshot) {
// If no audio is loaded, do not show the controllers.
if (snapshot.data ?? false)
return PlayerButtons();
else
return Container();
},
);
},
),
],
);
}
}
We are using state != AudioProcessingState.idle
to control when to show the buttons. idle
means that no audio has been loaded yet, which is a perfect example of when to hide the buttons.
Next is, finally, a screen that shows the content of a playlist. We will use the widget above to place the buttons at the bottom of the screen.
/// playlist_screen.dart
/// A screen with a playlist.
class PlaylistScreen extends StatelessWidget {
final List<PlaylistItem> _playlist;
PlaylistScreen(this._playlist, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: SafeArea(
child: PlayerButtonsContainer(
child: Playlist(_playlist),
),
),
),
);
}
}
Now we can have the CategorySelector
screen navigating to the PlaylistScreen
.
/// category_selector.dart
/// A selector screen for categories of audio.
///
/// Current categories are:
/// - all items;
class CategorySelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: SafeArea(
child: PlayerButtonsContainer(
child: Consumer<PlaylistsService>(
builder: (__, value, _) {
return Column(
children: [
ListView(
shrinkWrap: true,
children: [
ListTile(
title: Text("All items"),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
PlaylistScreen(value.allItems),
),
);
},
),
]..addAll(
value.playlists.keys.map((playlistName) {
return ListTile(
title: Text(playlistName),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => PlaylistScreen(
value.playlists[playlistName]!),
),
);
},
);
}),
),
),
],
);
},
),
),
),
),
);
}
}
We wrap the content of this page in a PlayerButtonsContainer
, and we remove the hardcoded playlist names, in favor of dynamically generating the ListTile
s.
We are almost done. We need to modify Playlist
to represent any playlist, not only the currently loaded one. Change the name to PlaylistView
seems also appropriate.
/// playlist_view.dart
/// A list of tiles showing all the items of a playlist.
///
/// Items are displayed with a `ListTile` with a leading image (the
/// artwork), and the title of the item.
class PlaylistView extends StatelessWidget {
final List<PlaylistItem> _playlist;
PlaylistView(this._playlist, {Key? key}) : super(key: key);
Widget build(BuildContext context) {
return ListView(
children: [
for (var i = 0; i < _playlist.length; i++)
ListTile(
// selected: i == state.currentIndex, // TODO only if this is the loaded playlist
leading: Image.network(_playlist[i].artworkLocation),
title: Text(_playlist[i].title),
onTap: () {
final player =
Provider.of<AudioPlayerService>(context, listen: false);
player
.loadPlaylist(_playlist)
.then((_) => player.seekToIndex(i))
.then((_) => player.play());
},
),
],
);
}
}
We do not have a way to know if one of the titles is being played, so we comment out the related line of code, for now.
Another class that should be refactored is PlayerButtons
. It should use AudioPlayerService
and not just_audio
directly.
/// player_buttons.dart
/// A `Row` of buttons that interact with audio.
///
/// The order is: shuffle, previous, play/pause/restart, next, repeat.
class PlayerButtons extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<AudioPlayerService>(builder: (_, player, __) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Shuffle
StreamBuilder<bool>(
stream: player.shuffleModeEnabled,
builder: (context, snapshot) {
return _shuffleButton(context, snapshot.data ?? false, player);
},
),
// Previous
StreamBuilder<List<PlaylistItem>?>(
stream: player.currentPlaylist,
builder: (_, __) {
return _previousButton(player);
},
),
// Play/pause/restart
StreamBuilder<AudioProcessingState>(
stream: player.audioProcessingState,
builder: (_, snapshot) {
final playerState = snapshot.data ?? AudioProcessingState.unknown;
return _playPauseButton(playerState, player);
},
),
// Next
StreamBuilder<List<PlaylistItem>?>(
stream: player.currentPlaylist,
builder: (_, __) {
return _nextButton(player);
},
),
// Repeat
StreamBuilder<PlaylistLoopMode>(
stream: player.loopMode,
builder: (context, snapshot) {
return _repeatButton(
context, snapshot.data ?? PlaylistLoopMode.off, player);
},
),
],
);
});
}
/// A button that plays or pauses the audio.
///
/// If the audio is playing, a pause button is shown.
/// If the audio has finished playing, a restart button is shown.
/// If the audio is paused, or not started yet, a play button is shown.
/// If the audio is loading, a progress indicator is shown.
Widget _playPauseButton(
AudioProcessingState processingState, AudioPlayerService player) {
if (processingState == AudioProcessingState.loading ||
processingState == AudioProcessingState.buffering) {
return Container(
margin: EdgeInsets.all(8.0),
width: 64.0,
height: 64.0,
child: CircularProgressIndicator(),
);
} else if (processingState == AudioProcessingState.ready) {
return IconButton(
icon: Icon(Icons.play_arrow),
iconSize: 64.0,
onPressed: player.play,
);
} else if (processingState != AudioProcessingState.completed) {
return IconButton(
icon: Icon(Icons.pause),
iconSize: 64.0,
onPressed: player.pause,
);
} else {
return IconButton(
icon: Icon(Icons.replay),
iconSize: 64.0,
onPressed: () => player.seekToStart(),
);
}
}
/// A shuffle button. Tapping it will either enabled or disable shuffle mode.
Widget _shuffleButton(
BuildContext context, bool isEnabled, AudioPlayerService player) {
return IconButton(
icon: isEnabled
? Icon(Icons.shuffle, color: Theme.of(context).accentColor)
: Icon(Icons.shuffle),
onPressed: () async {
final enable = !isEnabled;
await player.setShuffleModeEnabled(enable);
},
);
}
/// A previous button. Tapping it will seek to the previous audio in the list.
Widget _previousButton(AudioPlayerService player) {
return IconButton(
icon: Icon(Icons.skip_previous),
onPressed: player.hasPrevious ? player.seekToPrevious : null,
);
}
/// A next button. Tapping it will seek to the next audio in the list.
Widget _nextButton(AudioPlayerService player) {
return IconButton(
icon: Icon(Icons.skip_next),
onPressed: player.hasNext ? player.seekToNext : null,
);
}
/// A repeat button. Tapping it will cycle through not repeating, repeating
/// the entire list, or repeat the current audio.
Widget _repeatButton(BuildContext context, PlaylistLoopMode loopMode,
AudioPlayerService player) {
final icons = [
Icon(Icons.repeat),
Icon(Icons.repeat, color: Theme.of(context).accentColor),
Icon(Icons.repeat_one, color: Theme.of(context).accentColor),
];
const cycleModes = [
PlaylistLoopMode.off,
PlaylistLoopMode.all,
PlaylistLoopMode.one,
];
final index = cycleModes.indexOf(loopMode);
return IconButton(
icon: icons[index],
onPressed: () {
player.setLoopMode(
cycleModes[(cycleModes.indexOf(loopMode) + 1) % cycleModes.length]);
},
);
}
}
Now, finally, we can start playing a playlist, then open another playlist and play it without the need to first stop the previous one.
Quite a big change for a small bug, but we also refactored a lot of code to make it easier to maintain.
The code is, as usual, available on GitHub.
Leave a comment