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 usesDocumentation 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.
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.
Where Isolates Are Applied
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.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.// 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(),
));
}
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.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.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).// 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,
));
}
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.songList to find the artist’s songsalbumList.firstWhere() to look up its album — O(k × m) where k = artist song count, m = total albumsFix: 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.// 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));
}
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.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.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.// 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));
}
What Was NOT Isolated (and Why)
The following operations were deliberately left on the main isolate:| Location | Reason 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 |
Verification Checklist
Use this checklist after making changes that touch any of the isolated paths:Run static analysis
Run static analysis
BuildContext).Search screen responsiveness
Search screen responsiveness
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 favorites
Cold-launch with favorites
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.Prolific artist navigation
Prolific artist navigation
Rapid song skipping
Rapid song skipping
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.