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.

Dart is single-threaded by default, and Flutter’s rendering engine needs the main isolate free to hit 60 or 120 fps. When CPU-bound work — filtering, sorting, encoding — lands on the main isolate, frames are skipped and the UI janks. This page documents every place Musynth uses Isolate.run() to move that work off-thread, explains the algorithm improvements made alongside each isolate migration, and lists operations that were deliberately left on the main isolate.
Rule of thumb: isolate only CPU work (parsing, filtering, sorting, encoding). Never platform channels — SharedPreferences.setString, on_audio_query, artwork generation — these must stay on the main isolate because Flutter’s platform channel mechanism is bound to the main thread.

Background: Isolate.run()

Isolate.run() (Dart 3 / Flutter 3.7+) spawns a short-lived background isolate, executes a closure there, marshals the return value back to the calling isolate, and disposes itself — no ports, no ReceivePort boilerplate required.
// Basic usage pattern
final result = await Isolate.run(() {
  // CPU work here — fully off the main thread
  return expensiveComputation(data);
});
Because the closure runs in a fresh isolate, it captures only the data passed to it by value. Shared mutable state is not possible (and not needed for these use-cases).

Where Isolates Are Applied

2
File: lib/cubits/library/library_state.dart
3
Problem: searchByQuery() ran three synchronous .where() + .toLowerCase().contains() scans over the full songList, albumList, and artistList on every keystroke. At 5 000 songs + 200 albums + 300 artists, this is measurably slow and directly causes dropped frames while the user types.
4
Fix: A new static Future<MultipleSearchModel> searchAsync(...) method offloads all three filter passes to Isolate.run(). The call site (music_search_screen.dart) debounces keystrokes at 150 ms and cancels stale results with a query != _query guard before emitting to state.
5
Complexity: unchanged O(n) per collection — but now entirely off the main thread.
6
// lib/cubits/library/library_state.dart
static Future<MultipleSearchModel> searchAsync({
  required String query,
  required List<SongModel> songList,
  required List<AlbumModel> albumList,
  required List<ArtistModel> artistList,
}) {
  final q = query.toLowerCase();
  return Isolate.run(() => MultipleSearchModel(
    songs: songList
        .where((s) => s.title.nonNullValue().toLowerCase().contains(q))
        .toList(),
    albums: albumList
        .where((a) => a.album.toLowerCase().contains(q))
        .toList(),
    artists: artistList
        .where((a) => a.artist.toLowerCase().contains(q))
        .toList(),
  ));
}
7
The 150 ms debounce means at most one isolate is alive per burst of keystrokes. The stale-result guard (query != _query) discards the result if the user has already moved on by the time the isolate returns.
8
9
FavoritesCubit.initFavorites() — Startup Hydration
10
File: lib/cubits/favorites/favorites_cubit.dart
11
Problem (algorithm + thread): SharedPreferences stores favorite song IDs as a List<String>. The original hydration called indexWhere on the full song list for each ID — O(n × m): 100 favorites × 5 000 songs = 500 000 comparisons, synchronously, before the first frame renders.
12
Fix: Converted to Future<void>, moved work into Isolate.run(), and replaced the linear scan with a HashMap lookup. The isolate builds {for (final s in allSongs) s.id: s} in a single O(m) pass, then resolves each of the n IDs in O(1).
13
Algorithm improvement: O(n × m) → O(n + m).
14
// lib/cubits/favorites/favorites_cubit.dart
Future<void> initFavorites(List<SongModel> allSongs) async {
  final ids = _prefs.favoriteSongList;
  final favorites = await Isolate.run(() {
    final byId = {for (final s in allSongs) s.id: s};
    return ids
        .map((id) => byId[int.tryParse(id)])
        .whereType<SongModel>()
        .toList();
  });
  emit(FavoritesState(
    favoriteList: favorites,
    favoriteSongList: ids,
  ));
}
15
LibraryCubit.onSongsLoaded callback type was updated from void Function(...) to Future<void> Function(...) so the cubit can await hydration before proceeding. Because initFavorites emits asynchronously, the first frame renders immediately — favorites populate shortly after via context.watch.
16
17
LibraryCubit.searchByArtistId() — Artist Content Building
18
File: lib/cubits/library/library_cubit.dart
19
Problem (algorithm + thread): Building the artist detail page required two compounding scans:
20
  • A linear O(n) pass over songList to find the artist’s songs
  • For each found song, albumList.firstWhere() to look up its album — O(k × m) where k = artist song count, m = total albums
  • 21
    Fix: Converted to Future<void>, moved all filtering, sorting, and album resolution into Isolate.run(). Album lookup was switched to a HashMap built once in the isolate: {for (final a in allAlbums) a.id: a} — O(m) to build, O(1) per lookup.
    22
    Algorithm improvement: O(k × m) → O(k + m) for album resolution.
    23
    // lib/cubits/library/library_cubit.dart
    Future<void> searchByArtistId(int artistId, {bool force = false}) async {
      if (state.artistCollection.containsKey(artistId) && !force) return;
    
      final allSongs = state.songList;
      final allAlbums = state.albumList;
    
      final result = await Isolate.run(() {
        final artistSongs =
            allSongs.where((s) => s.artistId == artistId).toList();
        int totalMs = 0;
        final albumIds = <int>{};
        for (final song in artistSongs) {
          totalMs += song.duration ?? 0;
          if ((song.albumId ?? 0) != 0) albumIds.add(song.albumId!);
        }
        // HashMap: O(m) build, O(1) per lookup
        final albumById = {for (final a in allAlbums) a.id: a};
        final albums = albumIds
            .map((id) => albumById[id])
            .whereType<AlbumModel>()
            .toList();
        artistSongs.sort((a, b) => a.id.compareTo(b.id));
        return (songs: artistSongs, albums: albums, totalMs: totalMs);
      });
    
      final updated =
          Map<int, ArtistContentModel>.from(state.artistCollection)
            ..[artistId] = ArtistContentModel(
              songs: result.songs,
              albums: result.albums,
              totalDuration:
                  Duration(milliseconds: result.totalMs).timeString,
            );
      emit(state.copyWith(artistCollection: updated));
    }
    
    24
    Results land in state.artistCollection (a keyed map), so subsequent visits to the same artist are O(1) cache hits — the isolate is never re-spawned unless force: true is passed.
    25
    26
    SharedPreferencesRepository.dominantColorCollection Setter — JSON Encode
    27
    File: lib/data/repositories/shared_preferences_repository.dart
    28
    Problem (I/O frequency, not CPU): The dominantColorCollection getter called json.decode() on the full album-color JSON blob every time UICubit reacted to a song change. Frequent track skips decoded the same string repeatedly, allocating new maps each call. The setter json.encode() on write also happened synchronously.
    29
    Fix: Added Map<String, String>? _colorCache in SharedPreferencesRepository. The getter uses ??= — decode once, serve from memory. No isolate needed for reading. The setter updates _colorCache immediately (so the next getter call is instant) and then offloads json.encode() to Isolate.run(), calling _prefs.setRawDominantColor(encoded) on the main isolate once serialization completes.
    30
    // lib/data/repositories/shared_preferences_repository.dart
    Map<String, String>? _colorCache;
    
    @override
    Map<String, String> get dominantColorCollection =>
        _colorCache ??= _prefs.dominantColorCollection; // decode once
    
    @override
    set dominantColorCollection(Map<String, String> value) {
      _colorCache = value; // immediate in-memory update
      Isolate.run(() => json.encode(value))
          .then((encoded) => _prefs.setRawDominantColor(encoded));
    }
    
    31
    The setter is called only when a new dominant color is discovered (rare). The getter is called on every song change (frequent). Caching the getter is therefore the highest-value fix; isolating the setter is a secondary improvement.

    What Was NOT Isolated (and Why)

    The following operations were deliberately left on the main isolate:
    LocationReason skipped
    getAllSongs()Pure platform-channel I/O — channels must be called from the main isolate; already async and I/O-bound
    searchByAlbumId()O(n) scan over typically ≤ 200 albums; isolate spawn overhead exceeds computation time
    buildArtworkCache()queryArtwork() is a platform channel — cannot be moved to an isolate
    totalDurationString()O(n) fold on a sub-list; trivially fast at any real library size
    Spawning an isolate carries measurable overhead (a fresh Dart heap, message serialization). For operations that complete in under ~1 ms — such as filtering a 200-item list — the spawn cost outweighs the benefit. Profile before isolating.

    Verification Checklist

    Use this checklist after making changes that touch any of the isolated paths:
    flutter analyze --no-pub
    
    Expected: zero issues. Isolate closures must only capture plain Dart objects (no platform objects, no BuildContext).
    Open the search screen and type rapidly. Results should appear approximately 150 ms after the last keystroke with no UI jank — the frame rate counter in Flutter DevTools should stay green throughout.
    Cold-launch with 100+ saved favorites. The first frame must render before favorites finish hydrating — they arrive asynchronously via emit. The favorites tab should populate within a second of launch without blocking the splash or home screen.
    Navigate to an artist with many songs and albums for the first time. The screen transition should be smooth; songs and albums populate shortly after arrival via context.watch. No frame drops during the route push.
    Skip songs rapidly using next/previous. The dominant color on the Now Playing screen should update instantly (in-memory cache hit on the getter). The background JSON write is fire-and-forget — it must not delay the color transition.

    Build docs developers (and LLMs) love