Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/CesarArellano/Music-Player-App/llms.txt

Use this file to discover all available pages before exploring further.

Musynth separates the contract for audio playback from its concrete implementation through an abstract Dart interface. PlaybackService declares every operation the rest of the app can perform on the audio engine; JustAudioPlaybackService fulfils that contract using the just_audio package and keeps multiple Cubits in sync via five stream subscriptions that are active for the entire lifetime of the app. This page covers the full interface, the implementation’s construction and subscription setup, every method’s behaviour, and the _ReorderableShuffleOrder helper used to make drag-reordering work correctly in shuffle mode.

PlaybackService Interface

abstract interface class PlaybackService {
  /// The effective (shuffled or sequential) index order exposed by the player.
  List<int>? get effectiveIndices;

  Future<void> loadPlaylist({
    required List<SongModel> songs,
    required int initialIndex,
    required String appDirectory,
    Duration? initialPosition,
  });

  Future<void> play();
  Future<void> pause();
  Future<void> stop();
  Future<void> seek(Duration position, {int? index});
  Future<void> setShuffleModeEnabled(bool enabled);

  Future<void> removeFromQueue(SongModel song);
  Future<void> moveInQueue(int oldIndex, int newIndex);

  Future<void> clearCurrentSong();
  Future<void> dispose();
}
effectiveIndices
List<int>?
The player’s current effective index sequence. In shuffle mode this is the shuffled order; in normal mode it mirrors the source list order. Used by the queue screen to display songs in the order they will play.
loadPlaylist
Future<void>
Loads songs into the player, sets the audio source list, and initialises playback state in all Cubits. Optionally seeks to initialPosition before playback begins (used when restoring state on app launch).
removeFromQueue
Future<void>
Removes a single song from the live queue. If the removed song is currently playing, just_audio advances seamlessly to the next track. Removing the last song stops playback.
moveInQueue
Future<void>
Moves the song at oldIndex to newIndex in the live queue without reloading the playlist or interrupting playback.
clearCurrentSong
Future<void>
Stops the player and wipes both queue and current-song state from all Cubits, including the persisted lastSongId. The media notification is dismissed. Used by the notification’s Close action and the queue screen’s Clear queue menu.

JustAudioPlaybackService

JustAudioPlaybackService is registered as the concrete singleton for PlaybackService in di.dart. It is eagerly created in setupServiceLayer (not lazily), so the five stream subscriptions begin as soon as the service layer is set up — before the first frame renders.

Construction

JustAudioPlaybackService({
  required AudioPlayer audioPlayer,
  required PlaybackStateCubit playbackStateCubit,
  required AudioControlCubit audioControlCubit,
  required PreferencesRepository preferences,
  required AudioPlayerHandler handler,
  required FavoritesCubit favoritesCubit,
})
The constructor immediately starts all five subscriptions by calling the private _subscribe* methods.

Stream Subscriptions

_positionSub = _player.positionStream.listen((duration) {
  _audioCubit.updateCurrentDuration(duration);
});
Updates AudioControlCubit with the playback position on every tick. lastSongDuration is not written here — it is persisted only on pause() and stop() to avoid constant SharedPreferences writes.
_indexSub = _player.currentIndexStream.listen((currentIndex) {
  if (_playbackCubit.state.currentPlaylist.isEmpty) return;
  final index = currentIndex.nonNullValue();
  _audioCubit.updateCurrentIndex(index);
  final song = _playbackCubit.state.currentPlaylist[index];
  _playbackCubit.updateSongPlayed(song);
  _prefs.lastSongId = song.id;
  final tag = _player.sequence[index].tag as MediaItem?;
  if (tag != null) _handler.setCurrentItem(tag);
  _handler.updateFavoriteState(
    _favoritesCubit.state.isFavoriteSong(song.id),
  );
});
The most important subscription. When just_audio advances to a new track (auto-advance or skip), this listener updates the cubit song, persists lastSongId, pushes the new MediaItem to the handler, and refreshes the favourite icon in the notification.
_playingSub = _player.playingStream.listen((isPlaying) {
  _playbackCubit.updateIsPlaying(isPlaying);
});
Mirrors just_audio’s playing state into PlaybackStateCubit so the UI play/pause button stays in sync.
_shuffleSub = _player.shuffleModeEnabledStream.listen((enabled) {
  _playbackCubit.updateIsShuffling(enabled);
});
Keeps PlaybackStateCubit.isShuffling in sync for the shuffle toggle button.
_favoritesSub = _favoritesCubit.stream.listen((favoritesState) {
  final song = _playbackCubit.state.songPlayed;
  if (song.id == 0) return;
  _handler.updateFavoriteState(favoritesState.isFavoriteSong(song.id));
});
Whenever the user toggles a favourite from anywhere in the app, this subscription pushes the updated heart-icon state to the notification without requiring the current-index subscription to fire.

Method Implementations

loadPlaylist

Future<void> loadPlaylist({
  required List<SongModel> songs,
  required int initialIndex,
  required String appDirectory,
  Duration? initialPosition,
}) {
  final validSongs = songs.where((s) => s.data != null).toList();
  final mediaItems = validSongs.map((song) => MediaItem(
    id: song.id.nonNullValue().toString(),
    title: song.title.nonNullValue(),
    album: song.album,
    artist: song.artist,
    duration: Duration(milliseconds: song.duration.nonNullValue()),
    genre: song.genre,
    artUri: Uri.file('$appDirectory/${song.albumId}.jpg'),
  )).toList();

  _player.setAudioSources(
    validSongs.asMap().entries.map((e) => AudioSource.file(
      e.value.data!,
      tag: mediaItems[e.key],
    )).toList(),
    initialIndex: initialIndex,
    initialPosition: initialPosition,
    shuffleOrder: _shuffleOrder,
  );

  _handler.setQueueAndItem(mediaItems, initialIndex);
  _audioCubit.updateCurrentIndex(initialIndex);
  _playbackCubit.updateSongPlayed(songs[initialIndex]);
  _prefs.lastSongId = songs[initialIndex].id;

  return Future.value();
}
Key points:
  • Songs with a null data path are silently filtered out — they cannot be played.
  • Artwork URIs point to pre-cached files: file://<appDirectory>/<albumId>.jpg (written by ArtworkCacheService).
  • The custom _shuffleOrder instance is passed to setAudioSources so drag-reorder in shuffle mode works correctly (see below).

pause and stop

Both methods persist the current position to PreferencesRepository.lastSongDuration before pausing or stopping the player, so the position survives process death:
Future<void> pause() {
  _prefs.lastSongDuration = _player.position.inMilliseconds;
  return _player.pause();
}

Future<void> stop() {
  _prefs.lastSongDuration = _player.position.inMilliseconds;
  return _player.stop();
}

removeFromQueue

Future<void> removeFromQueue(SongModel song) async {
  final updated = List<SongModel>.from(_playbackCubit.state.currentPlaylist)
    ..removeWhere((s) => s.id == song.id);
  _playbackCubit.updateCurrentPlaylist(updated);

  final targetId = song.id.nonNullValue().toString();
  final index = _player.sequence.indexWhere(
    (source) => (source.tag as MediaItem?)?.id == targetId,
  );
  if (index < 0) return;

  if (updated.isEmpty) await _player.stop();
  await _player.removeAudioSourceAt(index);
}
The Cubit’s list is updated first so the UI responds immediately. The player sequence is matched by MediaItem tag ID (not by cubit index) because songs without a file path are filtered out of the player sequence, making the two lists potentially out of alignment.

moveInQueue

Queue reordering follows different paths depending on shuffle mode:
Future<void> moveInQueue(int oldIndex, int newIndex) async {
  if (_player.shuffleModeEnabled) {
    final order = List<int>.of(_player.shuffleIndices);
    order.insert(newIndex, order.removeAt(oldIndex));
    _shuffleOrder.applyOnNextShuffle(order);
    await _player.shuffle();
    _playbackCubit.updateCurrentPlaylist(
      _playbackCubit.state.currentPlaylist,
    );
    return;
  }
  final list = List<SongModel>.from(_playbackCubit.state.currentPlaylist);
  final song = list.removeAt(oldIndex);
  list.insert(newIndex, song);
  _playbackCubit.updateCurrentPlaylist(list);
  await _player.moveAudioSource(oldIndex, newIndex);
}
ModeStrategy
Normal_player.moveAudioSource(oldIndex, newIndex) moves the underlying audio source; the Cubit list is updated to match.
ShuffleThe drag indices address positions in the shuffled order. _player.moveAudioSource would move the wrong underlying sources and re-randomize the moved item’s position. Instead, the shuffle order itself is edited and _player.shuffle() is called with _shuffleOrder holding the desired result.

clearCurrentSong

Future<void> clearCurrentSong() async {
  _playbackCubit.updateCurrentPlaylist([]);
  _playbackCubit.clearSongPlayed();
  _audioCubit.updateCurrentIndex(-1);
  _prefs.lastSongId = 0;
  _prefs.lastSongDuration = 0;
  await _handler.stop(); // dismisses the notification
}
clearCurrentSong resets lastSongId to 0, so the song will not be restored on the next launch. This is intentional — the user explicitly closed playback.

dispose

Future<void> dispose() async {
  await _positionSub?.cancel();
  await _indexSub?.cancel();
  await _playingSub?.cancel();
  await _shuffleSub?.cancel();
  await _favoritesSub?.cancel();
  await _player.dispose();
}
Cancels all five StreamSubscription objects before disposing the AudioPlayer, preventing stale callbacks after the service is torn down.

_ReorderableShuffleOrder

class _ReorderableShuffleOrder extends DefaultShuffleOrder {
  List<int>? _nextOrder;

  void applyOnNextShuffle(List<int> order) => _nextOrder = order;

  @override
  void shuffle({int? initialIndex}) {
    final next = _nextOrder;
    _nextOrder = null;
    if (next != null && next.length == indices.length) {
      indices.setRange(0, indices.length, next);
      return;
    }
    super.shuffle(initialIndex: initialIndex);
  }
}
DefaultShuffleOrder.shuffle randomises the order. When the user drags a row in the queue screen during shuffle mode, the app already knows the exact desired order. applyOnNextShuffle caches that order so the very next _player.shuffle() call sets indices to the supplied list instead of randomising. The cached order is consumed and cleared on the first use, so subsequent unrelated shuffle calls behave normally.

Build docs developers (and LLMs) love