A Robust Audio Player for Flutter Web with just_audio
Ship music and sound effects in a Flutter web app with just_audio: autoplay rules, position streams, web limitations like gapless and HLS, Media Session keys, and cleanup.
Published on • November 11, 2026
AI Assistant

Audio is the feature where Flutter web and native diverge fastest: playback works, but the browser’s autoplay policy, format support, and storage rules reshape the API around it. just_audio still gives you one codebase — here is what its web implementation really does, where it stops, and how to build a player that survives Chrome and Safari.
just_audio on web is an HTML audio element
just_audio (0.10.6) supports web through the endorsed just_audio_web (0.4.16), which needs no direct dependency — adding just_audio is enough:
dependencies:
flutter:
sdk: flutter
just_audio: ^0.10.6
Reading its source tells you everything: the plugin creates an HTMLAudioElement, sets preload = 'auto', and maps the element’s timeupdate, waiting, canplaythrough, and error events onto just_audio’s streams, surfacing MediaError codes as PlayerException. audioplayers remains the main alternative, but the API surface and error model above are what this post assumes.
Autoplay starts at a button tap
Browsers block play() without a recent user gesture — MDN calls out the classic mistake of playing an element on page load — so load the source during startup if you like, but only start playback from a tap:
final player = AudioPlayer();
Widget playButton() => FilledButton.icon(
icon: const Icon(Icons.play_arrow),
label: const Text('Play'),
onPressed: () async {
try {
await player.setAudioSource(AudioSource.uri(
Uri.parse('https://cdn.example.com/night-drive.mp3')));
await player.play(); // must stay inside the tap
} on PlayerException catch (e) {
print('audio failed: ${e.code} ${e.message}'); // Web: MediaError.code
}
},
);
Chains matter: await between the gesture and play() is fine, spawning the work from initState() is not. If playback must resume after a page reload, show the button again rather than firing play() on launch.
Loading, position, and control
Set the source, then use load() when you need duration and metadata up front — seeking and the progress bar both depend on it:
await player.setAudioSource(source); // ready to play
final duration = await player.load(); // force metadata/preload
player.positionStream.listen((pos) => slider.value = pos);
player.playerStateStream.listen((state) {
final busy = state.processingState != ProcessingState.ready;
icon.value = state.playing ? Icons.pause : Icons.play_arrow;
spinner.value = busy;
});
await player.setSpeed(1.25);
await player.setVolume(0.8);
await player.setLoopMode(LoopMode.all);
Watch the two-state model: playing flips immediately, while processingState walks loading → buffering → ready. Sound is audible only at ready, so drive spinners from the processing state and the button icon from playing, or you will show a pause button over silence.
What the browser does not give you
The platform matrix is blunt about web: no gapless playback (native has it), no HTTP Live Streaming, and no custom request headers — the web implementation documents headers as unsupported. For authenticated audio, cookies are the path, or set the element’s CORS mode explicitly:
// Cookie-based auth: let the <audio> element send credentials
await player.setWebCrossOrigin(WebCrossOrigin.useCredentials);
// Cross-origin CDN without credentials: anonymous CORS request
// await player.setWebCrossOrigin(WebCrossOrigin.anonymous);
Server-side, the file still needs correct Content-Type, a Content-Length, and Range request support — without ranges, duration detection and seeking degrade — and cross-origin files need Access-Control-Allow-Origin (plus Allow-Credentials when you use useCredentials).
OS media keys, SFX, and visualizers
For lock-screen and keyboard media controls, the Media Session API provides metadata and action handlers; just_audio gives you the Dart side, the browser side looks like this:
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: 'Night Drive',
artist: 'Neon Coast',
artwork: [{ src: '/cover.png', sizes: '512x512', type: 'image/png' }],
});
navigator.mediaSession.setActionHandler('play', () => { /* resume */ });
navigator.mediaSession.setActionHandler('pause', () => { /* pause */ });
navigator.mediaSession.setActionHandler('previoustrack', () => { /* previous */ });
navigator.mediaSession.setActionHandler('nexttrack', () => { /* next */ });
}
Finally, treat music and sound effects differently. Music wants one long-lived player, position streams, and a Media Session; SFX want many short, preloaded instances — a single AudioPlayer per effect, created at startup, because a fetch-then-play round trip sounds like latency on web. And for a waveform or visualizer, just_audio will not help: cross the boundary into the Web Audio API with package:web, create an AudioContext, route the element through createMediaElementSource, and read frames from an AnalyserNode — note the gesture rule applies to the context too, so create or resume it inside the same tap that starts playback.
Takeaway: Build Flutter web audio on just_audio, start every play() from a tap, drive spinners from processingState, serve audio with ranges and CORS (cookies or setWebCrossOrigin for auth), preloaded players for SFX, Media Session for OS keys, and dispose() when the screen goes away.