Flutter full app 2. Add a playlist to a simple music player in Flutter
Flutter | Dart | just_audio |
---|---|---|
1.22.6 | 2.10.5 | 0.6.13 |
In the previous article, we created a new project in Flutter and we added some buttons to interact with music playing in the app.
In this article we will start improving the repository where the code for the app is versioned, and we will add a playlist to the app.
Repository management
Keeping your code clean is not only about code itself but also about comments and documentation. It is common to have a README
file where a new developer, or a future version of yourself, can get basic information about the project, how to install tools needed to work on it, how to tests it, and other information that is important to start programming on the right step.
We add a README.md
file to the repository, for now we leave it empty, but we will add some information when needed.
Another common file is the CHANGELOG
, which will track versions of the app paired with what was changed in each of them. A well-known way to maintain a changelog is Keep a Changelog, we follow it for this project.
// CHANGELOG.md
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.0.1] - add basic buttons
### Added
- a row of buttons to play, skip, shuffle, and loop.
[0.0.1]: https://github.com/mvolpato/the-player/releases/tag/0.0.1
Create a playlist widget
In part 1 we created some controls to interact with the music playing in the app. We can skip to the next or the previous audio source, but there is no way to know what will play, or even what is now playing.
The playlist is an important part of a music player app. We will add one now.
To be able to display information about the audio sources, we need to add such information somewhere. The constructor AudioSource.uri
that we used in player.dart
has a parameter called tag
that can be used in this case.
It is of type dynamic
, because we can pass the information however we prefer. In our case, we create a new data class AudioMetadata
that holds title and artwork of the audio source.
class AudioMetadata {
final String title;
final String artwork;
AudioMetadata({this.title, this.artwork});
}
And we update player.dart
with the title and the artwork.
_audioPlayer
.setAudioSource(
ConcatenatingAudioSource(
children: [
AudioSource.uri(
Uri.parse(
"https://archive.org/download/IGM-V7/IGM%20-%20Vol.%207/25%20Diablo%20-%20Tristram%20%28Blizzard%29.mp3"),
tag: AudioMetadata(
title: "Tristram",
artwork:
"https://upload.wikimedia.org/wikipedia/en/3/3a/Diablo_Coverart.png",
),
),
AudioSource.uri(
Uri.parse(
"https://archive.org/download/igm-v8_202101/IGM%20-%20Vol.%208/15%20Pokemon%20Red%20-%20Cerulean%20City%20%28Game%20Freak%29.mp3"),
tag: AudioMetadata(
title: "Cerulean City",
artwork:
"https://upload.wikimedia.org/wikipedia/en/f/f1/Bulbasaur_pokemon_red.png",
),
),
AudioSource.uri(
Uri.parse(
"https://scummbar.com/mi2/MI1-CD/01%20-%20Opening%20Themes%20-%20Introduction.mp3"),
tag: AudioMetadata(
title: "The secret of Monkey Island - Introduction",
artwork:
"https://upload.wikimedia.org/wikipedia/en/a/a8/The_Secret_of_Monkey_Island_artwork.jpg",
),
),
],
),
)
.catchError((error) {
// catch load errors: 404, invalid url ...
print("An error occured $error");
});
The playlist will show a list of audio sources, with the artwork as leading widgetand the title as main information. As with PlayerButtons
, we create a new file in screens/commons
because we might re-use the playlist in other parts of the app.
// playlist.dart
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
class Playlist extends StatelessWidget {
const Playlist(this._audioPlayer, {Key key}) : super(key: key);
final AudioPlayer _audioPlayer;
Widget build(BuildContext context) {
return StreamBuilder<SequenceState>(
stream: _audioPlayer.sequenceStateStream,
builder: (context, snapshot) {
final state = snapshot.data;
final sequence = state?.sequence ?? [];
return ListView(
children: [
for (var i = 0; i < sequence.length; i++)
ListTile(
selected: i == state.currentIndex,
leading: Image.network(sequence[i].tag.artwork),
title: Text(sequence[i].tag.title),
onTap: () {
// TODO: play this audio when tapped.
},
),
],
);
},
);
}
}
As for the player buttons, we use a Stream
exposed by the audio player: sequenceStateStream
which encapsulates the current sequence of audio sources, in .sequence
and the index representing the audio that is currently being played, in .currentIndex
. We use a ListView
to show the sequence of audio sources.
We mark the audio that is currently being played by selecting the related tile.
When one of the list tiles is tapped, we want the head of the player to move to the start of the related audio. If the player is already playing something, then it will start immediately to play the audio source of the tapped tile. We can achieve it with a method of AudioPlayer
that we already used in the previous article: seek
.
_audioPlayer.seek(Duration.zero, index: i);
Add the playlist widget to the app
With the new playlist widget, we can now add the list of audio sources to the app. We add a Column
widget to the build
method of Player
, and the Playlist
as the first child, while PlayerButtons
as the second child of Column
. We also wrap the Playlist
in an Expanded
widget, because we want it to take all the available spaceafter PlayerButtons
is placed.
return Scaffold(
body: Center(
child: Column(
children: [
Expanded(child: Playlist(_audioPlayer)),
PlayerButtons(_audioPlayer),
],
),
),
);
This works, but in some devices, like the iPhone 12, the UI falls outside of the safe area. We can fix it by wrapping the Column
in a SafeArea
widget.
return Scaffold(
body: Center(
child: SafeArea( // <-- added this
child: Column(
children: [
Expanded(child: Playlist(_audioPlayer)),
PlayerButtons(_audioPlayer),
],
),
),
),
);
And this is the result:
Keep the changelog updated
Now that we have a new working version of the app, we update the changelog.
## [0.0.2] - add playlist
### Added
- this changelog
- a playlist to the player
and we also update the version of the app in pubspec.yaml
.
version: 0.0.2+1
Be a better citizen
We could be done for today. We added the playlist and the app works. But the code is not in a good shape, we have almost no comments.
When we come back to the code in a month, we will have forgotten most decisions we took. Comments are essential to help to remember them, or to help teammates getting started with the code we wrote.
Let’s go back to all classes and add some documentation, after which this is the full code for this article.
// player.dart
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'package:music_player/domain/audio_metadata.dart';
import 'package:music_player/screens/commons/player_buttons.dart';
import 'package:music_player/screens/commons/playlist.dart';
/// An audio player.
///
/// At the bottom of the page there is [PlayerButtons], while the rest of the
/// page is filled with a [PLaylist] widget.
class Player extends StatefulWidget {
@override
_PlayerState createState() => _PlayerState();
}
class _PlayerState extends State<Player> {
AudioPlayer _audioPlayer;
@override
void initState() {
super.initState();
_audioPlayer = AudioPlayer();
// Hardcoded audio sources
// TODO: Get sources with a network call, or at least move to a separated file.
_audioPlayer
.setAudioSource(
ConcatenatingAudioSource(
children: [
AudioSource.uri(
Uri.parse(
"https://archive.org/download/IGM-V7/IGM%20-%20Vol.%207/25%20Diablo%20-%20Tristram%20%28Blizzard%29.mp3"),
tag: AudioMetadata(
title: "Tristram",
artwork:
"https://upload.wikimedia.org/wikipedia/en/3/3a/Diablo_Coverart.png",
),
),
AudioSource.uri(
Uri.parse(
"https://archive.org/download/igm-v8_202101/IGM%20-%20Vol.%208/15%20Pokemon%20Red%20-%20Cerulean%20City%20%28Game%20Freak%29.mp3"),
tag: AudioMetadata(
title: "Cerulean City",
artwork:
"https://upload.wikimedia.org/wikipedia/en/f/f1/Bulbasaur_pokemon_red.png",
),
),
AudioSource.uri(
Uri.parse(
"https://scummbar.com/mi2/MI1-CD/01%20-%20Opening%20Themes%20-%20Introduction.mp3"),
tag: AudioMetadata(
title: "The secret of Monkey Island - Introduction",
artwork:
"https://upload.wikimedia.org/wikipedia/en/a/a8/The_Secret_of_Monkey_Island_artwork.jpg",
),
),
],
),
)
.catchError((error) {
// catch load errors: 404, invalid url ...
print("An error occured $error");
});
}
@override
void dispose() {
_audioPlayer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SafeArea(
child: Column(
children: [
Expanded(child: Playlist(_audioPlayer)),
PlayerButtons(_audioPlayer),
],
),
),
),
);
}
}
// player_buttons.dart
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
/// A `Row` of buttons that interact with audio.
///
/// The order is: shuffle, previous, play/pause/restart, next, repeat.
class PlayerButtons extends StatelessWidget {
const PlayerButtons(this._audioPlayer, {Key key}) : super(key: key);
final AudioPlayer _audioPlayer;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Shuffle
StreamBuilder<bool>(
stream: _audioPlayer.shuffleModeEnabledStream,
builder: (context, snapshot) {
return _shuffleButton(context, snapshot.data ?? false);
},
),
// Previous
StreamBuilder<SequenceState>(
stream: _audioPlayer.sequenceStateStream,
builder: (_, __) {
return _previousButton();
},
),
// Play/pause/restart
StreamBuilder<PlayerState>(
stream: _audioPlayer.playerStateStream,
builder: (_, snapshot) {
final playerState = snapshot.data;
return _playPauseButton(playerState);
},
),
// Next
StreamBuilder<SequenceState>(
stream: _audioPlayer.sequenceStateStream,
builder: (_, __) {
return _nextButton();
},
),
// Repeat
StreamBuilder<LoopMode>(
stream: _audioPlayer.loopModeStream,
builder: (context, snapshot) {
return _repeatButton(context, snapshot.data ?? LoopMode.off);
},
),
],
);
}
/// 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(PlayerState playerState) {
final processingState = playerState?.processingState;
if (processingState == ProcessingState.loading ||
processingState == ProcessingState.buffering) {
return Container(
margin: EdgeInsets.all(8.0),
width: 64.0,
height: 64.0,
child: CircularProgressIndicator(),
);
} else if (_audioPlayer.playing != true) {
return IconButton(
icon: Icon(Icons.play_arrow),
iconSize: 64.0,
onPressed: _audioPlayer.play,
);
} else if (processingState != ProcessingState.completed) {
return IconButton(
icon: Icon(Icons.pause),
iconSize: 64.0,
onPressed: _audioPlayer.pause,
);
} else {
return IconButton(
icon: Icon(Icons.replay),
iconSize: 64.0,
onPressed: () => _audioPlayer.seek(Duration.zero,
index: _audioPlayer.effectiveIndices.first),
);
}
}
/// A shuffle button. Tapping it will either enabled or disable shuffle mode.
Widget _shuffleButton(BuildContext context, bool isEnabled) {
return IconButton(
icon: isEnabled
? Icon(Icons.shuffle, color: Theme.of(context).accentColor)
: Icon(Icons.shuffle),
onPressed: () async {
final enable = !isEnabled;
if (enable) {
await _audioPlayer.shuffle();
}
await _audioPlayer.setShuffleModeEnabled(enable);
},
);
}
/// A previous button. Tapping it will seek to the previous audio in the list.
Widget _previousButton() {
return IconButton(
icon: Icon(Icons.skip_previous),
onPressed: _audioPlayer.hasPrevious ? _audioPlayer.seekToPrevious : null,
);
}
/// A next button. Tapping it will seek to the next audio in the list.
Widget _nextButton() {
return IconButton(
icon: Icon(Icons.skip_next),
onPressed: _audioPlayer.hasNext ? _audioPlayer.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, LoopMode loopMode) {
final icons = [
Icon(Icons.repeat),
Icon(Icons.repeat, color: Theme.of(context).accentColor),
Icon(Icons.repeat_one, color: Theme.of(context).accentColor),
];
const cycleModes = [
LoopMode.off,
LoopMode.all,
LoopMode.one,
];
final index = cycleModes.indexOf(loopMode);
return IconButton(
icon: icons[index],
onPressed: () {
_audioPlayer.setLoopMode(
cycleModes[(cycleModes.indexOf(loopMode) + 1) % cycleModes.length]);
},
);
}
}
// playlist.dart
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
/// A list of tiles showing all the audio sources added to the audio player.
///
/// Audio sources are displayed with a `ListTile` with a leading image (the
/// artwork), and the title of the audio source.
class Playlist extends StatelessWidget {
const Playlist(this._audioPlayer, {Key key}) : super(key: key);
final AudioPlayer _audioPlayer;
Widget build(BuildContext context) {
return StreamBuilder<SequenceState>(
stream: _audioPlayer.sequenceStateStream,
builder: (context, snapshot) {
final state = snapshot.data;
final sequence = state?.sequence ?? [];
return ListView(
children: [
for (var i = 0; i < sequence.length; i++)
ListTile(
selected: i == state.currentIndex,
leading: Image.network(sequence[i].tag.artwork),
title: Text(sequence[i].tag.title),
onTap: () {
_audioPlayer.seek(Duration.zero, index: i);
},
),
],
);
},
);
}
}
// audio_metadata.dart
/// Represents information about an audio source.
class AudioMetadata {
/// The name of the song/show/recording.
final String title;
/// URL to an image representing this audio source.
final String artwork;
AudioMetadata({this.title, this.artwork});
}
The app code is also available on GitHub.
In the next article of the series, we will migrate the project to sound null safety.
Leave a comment