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 tuaMainActivitydeve estendereAudioServiceActivity(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 unFutureche si completa quando la traccia finisce (o quando viene messa in pausa/fermata). Non fareawait player.play()dentro unonPressedse poi devi aggiornare la UI subito dopo.AudioPlayerva sempre rilasciato condispose(), 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 piattaformesetPitch. - Preload della traccia successiva: gestito automaticamente nelle playlist, con gap-less playback sui formati supportati.
- Effetti audio: su Android e iOS puoi collegare
AudioPipelinecon equalizzatore e loudness enhancer (AndroidEqualizer,AndroidLoudnessEnhancer). - Timer di spegnimento: basta un
Timerche chiamipause()combinato con un fade sul volume.
final equalizer = AndroidEqualizer();
final player = AudioPlayer(
audioPipeline: AudioPipeline(androidAudioEffects: [equalizer]),
);
await equalizer.setEnabled(true);
Errori frequenti da evitare
- Creare un
AudioPlayerper ogni widget: usa una singola istanza condivisa a livello di app, o al massimo una per contesto (musica di sottofondo vs effetti). - Dimenticare
dispose(): su Android il player nativo continua a girare e la notifica resta appesa. - Chiamare
setUrla ogni rebuild: il caricamento va fatto ininitStateo nel servizio, mai inbuild. - Ignorare le eccezioni:
PlayerException(errore della piattaforma, con codice) ePlayerInterruptedException(caricamento annullato) vanno gestite separatamente, altrimenti l'app mostra spinner infiniti. - Usare
setStatesulpositionStream: un aggiornamento ogni ~200 ms che ricostruisce l'intera pagina è uno spreco; isola la seek bar in unoStreamBuilderdedicato. - 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.