Flutter full app 5. Add a local playlist
11 min read

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 Providers 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 ListTiles.

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:

Release Add a local playlist, fix usability bug · mvolpato/the-player
Audio player app in Flutter. Created as a tutorial for learning Flutter. - mvolpato/the-player

Wanna stay up-to-date with Flutter and Dart?

Subscribe to get a weekly email with the best articles about Flutter and Dart.

    We won't send you spam. Unsubscribe at any time.