Michele Volpato

Michele Volpato

Flutter full app 2. Add a playlist to a simple music player in Flutter

Tutorials 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:

A playlist at the top of the screen

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.

Get a weekly email about Flutter

Subscribe to get a weekly curated list of articles and videos about Flutter and Dart.

    We respect your privacy. Unsubscribe at any time.

    Leave a comment