Perché just_audio

Quando un'app deve riprodurre musica, podcast o semplici effetti sonori, la scelta del pacchetto giusto fa la differenza tra qualche riga di codice e settimane di lavoro sui dettagli nativi. just_audio è oggi la libreria di riferimento per l'audio in Flutter: supporta Android, iOS, macOS, Web, Windows e Linux, gestisce lo streaming HTTP, HLS e DASH, espone tutto lo stato del player tramite Stream e si integra con just_audio_background per i controlli nella notifica di sistema e nella lock screen.

In questa guida costruiamo un player completo partendo dalle basi, evidenziando gli errori più comuni: memory leak, seek bar che scatta, audio che continua a suonare quando arriva una telefonata.

Installazione e configurazione delle piattaforme

dependencies:
  just_audio: ^0.10.0
  just_audio_background: ^0.0.1-beta.16
  audio_session: ^0.2.0
  rxdart: ^0.28.0

Android

Serve il permesso di rete e, se usi just_audio_background, la dichiarazione del servizio. In android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>

<application ...>
  <activity android:name=".MainActivity" ... />

  <service
      android:name="com.ryanheise.audioservice.AudioService"
      android:foregroundServiceType="mediaPlayback"
      android:exported="true">
    <intent-filter>
      <action android:name="android.media.browse.MediaBrowserService"/>
    </intent-filter>
  </service>

  <receiver
      android:name="com.ryanheise.audioservice.MediaButtonReceiver"
      android:exported="true">
    <intent-filter>
      <action android:name="android.intent.action.MEDIA_BUTTON"/>
    </intent-filter>
  </receiver>
</application>

Nota: se usi just_audio_background, la tua MainActivity deve estendere AudioServiceActivity (Kotlin: class MainActivity: AudioServiceActivity()).

iOS

In ios/Runner/Info.plist abilita la modalità background audio:

<key>UIBackgroundModes</key>
<array>
  <string>audio</string>
</array>

Se carichi file da URL non HTTPS, ricordati di configurare NSAppTransportSecurity.

Il player minimo

L'API di base è estremamente compatta:

import 'package:just_audio/just_audio.dart';

final player = AudioPlayer();

Future<void> start() async {
  await player.setUrl('https://example.com/track.mp3');
  await player.play(); // ritorna quando la riproduzione termina
}

Due punti fondamentali che generano confusione:

  • play() non è un semplice "avvia": restituisce un Future che si completa quando la traccia finisce (o quando viene messa in pausa/fermata). Non fare await player.play() dentro un onPressed se poi devi aggiornare la UI subito dopo.
  • AudioPlayer va sempre rilasciato con dispose(), altrimenti resta un player nativo attivo che consuma memoria e batteria.
class PlayerPage extends StatefulWidget {
  const PlayerPage({super.key});
  @override
  State<PlayerPage> createState() => _PlayerPageState();
}

class _PlayerPageState extends State<PlayerPage> {
  late final AudioPlayer _player;

  @override
  void initState() {
    super.initState();
    _player = AudioPlayer();
    _init();
  }

  Future<void> _init() async {
    try {
      await _player.setAudioSource(
        AudioSource.uri(Uri.parse('https://example.com/track.mp3')),
      );
    } on PlayerException catch (e) {
      debugPrint('Errore di caricamento: ${e.message}');
    } on PlayerInterruptedException catch (_) {
      // Il caricamento è stato annullato da una nuova richiesta
    }
  }

  @override
  void dispose() {
    _player.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => const SizedBox.shrink();
}

Le sorgenti audio

AudioSource astrae la provenienza del contenuto:

// File remoto (mp3, m4a, HLS, DASH...)
AudioSource.uri(Uri.parse('https://example.com/stream.m3u8'));

// Asset incluso nel bundle
AudioSource.asset('assets/audio/jingle.mp3');

// File locale
AudioSource.file('/storage/emulated/0/Music/song.mp3');

// Streaming con cache su disco: scarica mentre riproduce e riusa il file
LockCachingAudioSource(Uri.parse('https://example.com/podcast.mp3'));

// Solo una porzione della traccia
ClippingAudioSource(
  child: AudioSource.uri(Uri.parse('https://example.com/track.mp3')),
  start: const Duration(seconds: 30),
  end: const Duration(seconds: 60),
);

LockCachingAudioSource è particolarmente utile per i podcast: la seconda riproduzione parte istantaneamente e non consuma dati.

Stato del player: gli stream da conoscere

Tutto lo stato è esposto come Stream, quindi si integra bene con StreamBuilder o con qualsiasi state manager.

Stream Contenuto
playerStateStream playing (bool) + processingState
positionStream posizione corrente, aggiornata frequentemente
bufferedPositionStream quanto è stato bufferizzato
durationStream durata della traccia corrente (nullable)
currentIndexStream indice nella playlist
sequenceStateStream stato completo: sequenza, indice, shuffle
volumeStream, speedStream volume e velocità

ProcessingState vale idle, loading, buffering, ready o completed: è la base per capire se mostrare uno spinner o il pulsante play.

StreamBuilder<PlayerState>(
  stream: _player.playerStateStream,
  builder: (context, snapshot) {
    final state = snapshot.data;
    final processing = state?.processingState;
    final playing = state?.playing ?? false;

    if (processing == ProcessingState.loading ||
        processing == ProcessingState.buffering) {
      return const SizedBox(
        width: 48,
        height: 48,
        child: CircularProgressIndicator(),
      );
    }

    if (!playing) {
      return IconButton(
        iconSize: 48,
        icon: const Icon(Icons.play_arrow),
        onPressed: _player.play, // niente await
      );
    }

    if (processing != ProcessingState.completed) {
      return IconButton(
        iconSize: 48,
        icon: const Icon(Icons.pause),
        onPressed: _player.pause,
      );
    }

    return IconButton(
      iconSize: 48,
      icon: const Icon(Icons.replay),
      onPressed: () => _player.seek(Duration.zero),
    );
  },
)

Una seek bar che non scatta

L'errore classico è costruire la barra di avanzamento su positionStream e aggiornare lo slider mentre l'utente lo sta trascinando: il risultato è un cursore che "rimbalza". La soluzione è combinare i tre stream rilevanti in un unico modello e mantenere un valore di drag locale.

import 'package:rxdart/rxdart.dart';

class PositionData {
  const PositionData(this.position, this.buffered, this.duration);
  final Duration position;
  final Duration buffered;
  final Duration duration;
}

Stream<PositionData> positionDataStream(AudioPlayer player) =>
    Rx.combineLatest3<Duration, Duration, Duration?, PositionData>(
      player.positionStream,
      player.bufferedPositionStream,
      player.durationStream,
      (position, buffered, duration) =>
          PositionData(position, buffered, duration ?? Duration.zero),
    );

E il widget:

class SeekBar extends StatefulWidget {
  const SeekBar({
    super.key,
    required this.data,
    required this.onChangeEnd,
  });

  final PositionData data;
  final ValueChanged<Duration> onChangeEnd;

  @override
  State<SeekBar> createState() => _SeekBarState();
}

class _SeekBarState extends State<SeekBar> {
  double? _dragValue;

  @override
  Widget build(BuildContext context) {
    final max = widget.data.duration.inMilliseconds.toDouble();
    final current = _dragValue ??
        widget.data.position.inMilliseconds.toDouble().clamp(0, max);

    return Slider(
      min: 0,
      max: max == 0 ? 1 : max,
      value: current.toDouble(),
      onChanged: (value) => setState(() => _dragValue = value),
      onChangeEnd: (value) {
        widget.onChangeEnd(Duration(milliseconds: value.round()));
        setState(() => _dragValue = null);
      },
    );
  }
}

Mentre l'utente trascina, _dragValue ha la precedenza; al rilascio si esegue il seek e si torna a seguire lo stream.

Playlist, shuffle e loop

Dalla versione 0.10 le playlist si gestiscono direttamente sul player con setAudioSources, addAudioSource, removeAudioSourceAt e moveAudioSource (la vecchia ConcatenatingAudioSource è deprecata ma ancora funzionante nei progetti esistenti).

await _player.setAudioSources(
  [
    AudioSource.uri(Uri.parse('https://example.com/1.mp3')),
    AudioSource.uri(Uri.parse('https://example.com/2.mp3')),
    AudioSource.asset('assets/audio/3.mp3'),
  ],
  initialIndex: 0,
  initialPosition: Duration.zero,
);

await _player.setLoopMode(LoopMode.all);   // off | one | all
await _player.setShuffleModeEnabled(true);

// Navigazione
await _player.seekToNext();
await _player.seekToPrevious();
await _player.seek(Duration.zero, index: 2);

Per sapere quale elemento è in riproduzione, sequenceStateStream restituisce tutto il contesto:

StreamBuilder<SequenceState>(
  stream: _player.sequenceStateStream,
  builder: (context, snapshot) {
    final state = snapshot.data;
    final source = state?.currentSource;
    final metadata = source?.tag as MediaItem?;
    return Text(metadata?.title ?? 'Nessuna traccia');
  },
)

Controlli in background e notifica di sistema

Per far continuare la riproduzione con l'app in background e mostrare i controlli nella notifica, nella lock screen e sui dispositivi Bluetooth, il modo più rapido è just_audio_background.

import 'package:just_audio_background/just_audio_background.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await JustAudioBackground.init(
    androidNotificationChannelId: 'it.example.audio.channel',
    androidNotificationChannelName: 'Riproduzione audio',
    androidNotificationOngoing: true,
    androidStopForegroundOnPause: true,
  );

  runApp(const MyApp());
}

Da quel momento ogni AudioSource deve portare un tag di tipo MediaItem, che alimenta i metadati mostrati dal sistema:

AudioSource.uri(
  Uri.parse('https://example.com/podcast-ep1.mp3'),
  tag: MediaItem(
    id: 'ep1',
    title: 'Episodio 1 — Flutter e l\'audio',
    album: 'Il podcast di esempio',
    artist: 'Redazione',
    duration: const Duration(minutes: 42),
    artUri: Uri.parse('https://example.com/cover.jpg'),
  ),
);

Se l'app ha bisogno di logica più sofisticata (code dinamiche, integrazione con Android Auto, download offline), la strada corretta è il pacchetto audio_service con un BaseAudioHandler personalizzato, di cui just_audio_background è una versione semplificata.

Gestire interruzioni, cuffie e ducking

Un player serio deve reagire a telefonate, notifiche di altre app e alla rimozione delle cuffie. Se ne occupa audio_session:

import 'package:audio_session/audio_session.dart';

Future<void> configureSession(AudioPlayer player) async {
  final session = await AudioSession.instance;
  await session.configure(const AudioSessionConfiguration.music());

  // Cuffie staccate: metti in pausa (comportamento atteso dagli utenti)
  session.becomingNoisyEventStream.listen((_) => player.pause());

  session.interruptionEventStream.listen((event) {
    if (event.begin) {
      switch (event.type) {
        case AudioInterruptionType.duck:
          player.setVolume(0.3);
        case AudioInterruptionType.pause:
        case AudioInterruptionType.unknown:
          player.pause();
      }
    } else {
      switch (event.type) {
        case AudioInterruptionType.duck:
          player.setVolume(1.0);
        case AudioInterruptionType.pause:
          player.play();
        case AudioInterruptionType.unknown:
          break;
      }
    }
  });
}

just_audio_background configura già una sessione di default: in quel caso limitati ad ascoltare gli eventi senza riconfigurare, per non sovrascrivere le impostazioni.

Incapsulare il player in un servizio

Esporre AudioPlayer direttamente ai widget rende difficile testare e cambiare implementazione. Meglio un servizio con un'API di dominio, registrabile in get_it o esposto con un provider:

class AudioRepository {
  AudioRepository(this._player);

  final AudioPlayer _player;

  Stream<bool> get isPlaying => _player.playingStream;
  Stream<PositionData> get position => positionDataStream(_player);
  Stream<int?> get currentIndex => _player.currentIndexStream;

  Future<void> loadQueue(List<Track> tracks, {int startAt = 0}) async {
    await _player.setAudioSources(
      tracks.map(_toSource).toList(),
      initialIndex: startAt,
    );
  }

  AudioSource _toSource(Track track) => AudioSource.uri(
        Uri.parse(track.url),
        tag: MediaItem(
          id: track.id,
          title: track.title,
          artist: track.artist,
          artUri: Uri.tryParse(track.coverUrl),
        ),
      );

  Future<void> play() => _player.play();
  Future<void> pause() => _player.pause();
  Future<void> seek(Duration position) => _player.seek(position);
  Future<void> setSpeed(double speed) => _player.setSpeed(speed);

  Future<void> dispose() => _player.dispose();
}

Con un'interfaccia del genere i widget non sanno nulla di just_audio, e in test puoi sostituire il repository con un fake.

Funzionalità extra utili

  • Velocità di riproduzione: player.setSpeed(1.5) — indispensabile per i podcast; su Android e iOS il pitch resta corretto.
  • Volume e bilanciamento: setVolume(0..1), e su alcune piattaforme setPitch.
  • Preload della traccia successiva: gestito automaticamente nelle playlist, con gap-less playback sui formati supportati.
  • Effetti audio: su Android e iOS puoi collegare AudioPipeline con equalizzatore e loudness enhancer (AndroidEqualizer, AndroidLoudnessEnhancer).
  • Timer di spegnimento: basta un Timer che chiami pause() combinato con un fade sul volume.
final equalizer = AndroidEqualizer();
final player = AudioPlayer(
  audioPipeline: AudioPipeline(androidAudioEffects: [equalizer]),
);
await equalizer.setEnabled(true);

Errori frequenti da evitare

  1. Creare un AudioPlayer per ogni widget: usa una singola istanza condivisa a livello di app, o al massimo una per contesto (musica di sottofondo vs effetti).
  2. Dimenticare dispose(): su Android il player nativo continua a girare e la notifica resta appesa.
  3. Chiamare setUrl a ogni rebuild: il caricamento va fatto in initState o nel servizio, mai in build.
  4. Ignorare le eccezioni: PlayerException (errore della piattaforma, con codice) e PlayerInterruptedException (caricamento annullato) vanno gestite separatamente, altrimenti l'app mostra spinner infiniti.
  5. Usare setState sul positionStream: un aggiornamento ogni ~200 ms che ricostruisce l'intera pagina è uno spreco; isola la seek bar in uno StreamBuilder dedicato.
  6. Testare solo in foreground: verifica sempre il comportamento a schermo bloccato, con telefonata in arrivo e con le cuffie Bluetooth.

Conclusione

just_audio copre praticamente ogni esigenza di riproduzione audio in Flutter con un'API dichiarativa e reattiva. Il pattern vincente è sempre lo stesso: un'unica istanza del player incapsulata in un servizio, la UI costruita su stream ben separati, just_audio_background per i controlli di sistema e audio_session per convivere educatamente con le altre app. Con questi quattro elementi si passa da un semplice "play" a un'esperienza di ascolto che gli utenti percepiscono come nativa.