Build a fully functional audio player in FlutterFlow using a Custom Widget with the just_audio package. You will create play/pause/seek controls, a live progress bar powered by StreamBuilder on positionStream, duration display, and playlist support with ConcatenatingAudioSource. The key challenge is managing three player states (loading, playing, paused) and disposing the player to prevent background audio leaks.
Building an audio player Custom Widget with just_audio
Audio playback is a common requirement for podcast apps, music players, guided meditation, and language learning apps. FlutterFlow has no built-in audio player widget, so you need a Custom Widget using the just_audio package. This tutorial builds a complete player with play/pause, seek bar, time display, and proper lifecycle handling.
Prerequisites
- FlutterFlow Pro plan or higher (Custom Code required)
- A direct URL to an audio file (MP3, AAC, or WAV) or a Firebase Storage audio URL
- Basic understanding of FlutterFlow Custom Widgets
- A FlutterFlow project open in the builder
Step-by-step guide
Add the just_audio dependency
Add the just_audio dependency
Go to Custom Code → Pubspec Dependencies. Add just_audio version ^0.9.36. This package provides AudioPlayer with streaming support, background audio, and platform-specific audio session handling for both iOS and Android. It is the most widely used audio package in the Flutter ecosystem with active maintenance.
Expected result: just_audio appears in dependencies without version conflicts.
Create the Custom Widget with AudioPlayer controller
Create the Custom Widget with AudioPlayer controller
Create Custom Widget named CustomAudioPlayer. Add a required parameter audioUrl (String). In initState(), create an AudioPlayer instance and call player.setUrl(widget.audioUrl). Do NOT call player.play() in initState — let the user tap play. In dispose(), call player.dispose() to stop playback and release resources. This prevents the common bug where audio continues playing after the user navigates away from the page.
1import 'package:just_audio/just_audio.dart';23class CustomAudioPlayer extends StatefulWidget {4 const CustomAudioPlayer({super.key, this.width, this.height, required this.audioUrl});5 final double? width;6 final double? height;7 final String audioUrl;8 @override9 State<CustomAudioPlayer> createState() => _CustomAudioPlayerState();10}1112class _CustomAudioPlayerState extends State<CustomAudioPlayer> {13 final AudioPlayer _player = AudioPlayer();1415 @override16 void initState() {17 super.initState();18 _player.setUrl(widget.audioUrl);19 }2021 @override22 void dispose() {23 _player.dispose();24 super.dispose();25 }Expected result: Widget compiles. Player loads the audio URL on initialization.
Build the player UI with StreamBuilder for live updates
Build the player UI with StreamBuilder for live updates
In build(), use three StreamBuilders for reactive UI: (1) StreamBuilder<PlayerState> on player.playerStateStream — shows play icon when paused/completed, pause icon when playing, spinner when loading/buffering. (2) StreamBuilder<Duration?> on player.durationStream — displays total track duration. (3) StreamBuilder<Duration> on player.positionStream — updates the Slider position and current time text. The Slider's onChanged calls player.seek(Duration(milliseconds: value)).
1StreamBuilder<PlayerState>(2 stream: _player.playerStateStream,3 builder: (context, snapshot) {4 final state = snapshot.data;5 final playing = state?.playing ?? false;6 final processingState = state?.processingState;7 if (processingState == ProcessingState.loading ||8 processingState == ProcessingState.buffering) {9 return const SizedBox(width: 48, height: 48, child: CircularProgressIndicator());10 }11 return IconButton(12 iconSize: 48,13 icon: Icon(playing ? Icons.pause_circle_filled : Icons.play_circle_filled),14 onPressed: playing ? _player.pause : _player.play,15 );16 },17),Expected result: Player shows play/pause/loading states reactively. Slider tracks audio position in real time.
Add skip forward/backward and duration display
Add skip forward/backward and duration display
Add two IconButtons for skip: one calls player.seek(player.position + Duration(seconds: 15)) for skip-forward, another subtracts 15 seconds for skip-backward. For time display, format Duration to mm:ss using a helper: '${d.inMinutes.remainder(60).toString().padLeft(2,'0')}:${d.inSeconds.remainder(60).toString().padLeft(2,'0')}'. Show current position on the left and total duration on the right of the Slider.
Expected result: Users can skip ±15 seconds and see formatted time labels (e.g., 02:34 / 05:12).
Pass the audio URL as a Component Parameter for reuse
Pass the audio URL as a Component Parameter for reuse
The audioUrl String parameter lets you reuse this widget across your app — podcast detail page, meditation player, language lesson, etc. When placing the widget on a page, bind audioUrl to a Page State variable, Route Parameter, or Backend Query result field (e.g., lesson.audioFileUrl from Firestore). For playlists, extend the widget to accept a list of URLs and use ConcatenatingAudioSource.
Expected result: Widget displays and plays different audio files depending on the URL passed from the parent page.
Complete working example
1import 'package:just_audio/just_audio.dart';23class CustomAudioPlayer extends StatefulWidget {4 const CustomAudioPlayer({super.key, this.width, this.height, required this.audioUrl});5 final double? width;6 final double? height;7 final String audioUrl;8 @override9 State<CustomAudioPlayer> createState() => _CustomAudioPlayerState();10}1112class _CustomAudioPlayerState extends State<CustomAudioPlayer> {13 final AudioPlayer _player = AudioPlayer();1415 @override16 void initState() {17 super.initState();18 _player.setUrl(widget.audioUrl);19 }2021 @override22 void dispose() {23 _player.dispose();24 super.dispose();25 }2627 String _fmt(Duration d) =>28 '${d.inMinutes.remainder(60).toString().padLeft(2, '0')}'29 ':${d.inSeconds.remainder(60).toString().padLeft(2, '0')}';3031 @override32 Widget build(BuildContext context) {33 return SizedBox(34 width: widget.width ?? double.infinity,35 child: Column(mainAxisSize: MainAxisSize.min, children: [36 // Play/Pause + Skip buttons37 Row(mainAxisAlignment: MainAxisAlignment.center, children: [38 IconButton(39 icon: const Icon(Icons.replay_10),40 onPressed: () => _player.seek(41 _player.position - const Duration(seconds: 10)),42 ),43 StreamBuilder<PlayerState>(44 stream: _player.playerStateStream,45 builder: (ctx, snap) {46 final state = snap.data;47 final processing = state?.processingState;48 if (processing == ProcessingState.loading ||49 processing == ProcessingState.buffering) {50 return const SizedBox(51 width: 48, height: 48,52 child: CircularProgressIndicator());53 }54 final playing = state?.playing ?? false;55 return IconButton(56 iconSize: 48,57 icon: Icon(playing58 ? Icons.pause_circle_filled59 : Icons.play_circle_filled),60 onPressed: playing ? _player.pause : _player.play,61 );62 },63 ),64 IconButton(65 icon: const Icon(Icons.forward_30),66 onPressed: () => _player.seek(67 _player.position + const Duration(seconds: 30)),68 ),69 ]),70 // Seek bar + time labels71 StreamBuilder<Duration?>(stream: _player.durationStream,72 builder: (ctx, durSnap) {73 final duration = durSnap.data ?? Duration.zero;74 return StreamBuilder<Duration>(75 stream: _player.positionStream,76 builder: (ctx, posSnap) {77 final position = posSnap.data ?? Duration.zero;78 return Column(children: [79 Slider(80 min: 0,81 max: duration.inMilliseconds.toDouble().clamp(1, double.infinity),82 value: position.inMilliseconds.toDouble().clamp(0, duration.inMilliseconds.toDouble()),83 onChanged: (v) => _player.seek(Duration(milliseconds: v.toInt())),84 ),85 Padding(86 padding: const EdgeInsets.symmetric(horizontal: 16),87 child: Row(88 mainAxisAlignment: MainAxisAlignment.spaceBetween,89 children: [Text(_fmt(position)), Text(_fmt(duration))],90 ),91 ),92 ]);93 },94 );95 },96 ),97 ]),98 );99 }100}Common mistakes when creating a custom audio player for your FlutterFlow app
Why it's a problem: Not calling player.dispose() in the widget's dispose method
How to avoid: Always override dispose() and call _player.dispose() before super.dispose(). This stops playback and releases all resources.
Why it's a problem: Calling player.play() in initState
How to avoid: Only load the URL in initState with setUrl(). Let the user explicitly tap the play button to start playback.
Why it's a problem: Not handling buffering state — showing play button during load
How to avoid: Use StreamBuilder on playerStateStream to show a CircularProgressIndicator during ProcessingState.loading and ProcessingState.buffering states.
Best practices
- Always dispose the AudioPlayer in the widget's dispose() method
- Show loading/buffering state explicitly with a spinner — never leave the UI ambiguous
- Format duration as mm:ss using padLeft(2, '0') for consistent display
- Pass audio URL as Component Parameter for reusability across pages
- Use ConcatenatingAudioSource for playlists instead of manually managing multiple players
- Clamp Slider value to prevent RangeError when position exceeds duration during seek
- Test with slow network — audio buffering is the most common real-world UX issue
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Write a Flutter Custom Widget for FlutterFlow that plays audio from a URL using the just_audio package. Include play/pause button, seek Slider, skip ±15s buttons, duration labels in mm:ss format, loading state handling, and proper dispose. Use StreamBuilder for reactive UI.
Create a page for a podcast episode detail. Add the episode title, description text from Firestore, and a container below for the audio player Custom Widget. Pass the episode's audioUrl field to the widget.
Frequently asked questions
Can I play audio in the background when the app is minimized?
Yes, but it requires additional setup. Add the audio_service package alongside just_audio and configure an AudioHandler. This is an advanced topic — the basic Custom Widget stops when the app goes to background.
What audio formats does just_audio support?
MP3, AAC, WAV, OGG, FLAC, and HLS streaming. MP3 and AAC are the most widely compatible across iOS and Android. HLS is useful for streaming long audio without downloading the full file.
How do I show album art or a waveform?
For album art: add an Image widget above the player controls, bound to the episode/track's imageUrl field. For waveform visualization: use the audio_waveforms package in a separate Custom Widget that reads the audio file's amplitude data.
Can I adjust playback speed?
Yes. Call player.setSpeed(1.5) for 1.5x speed. Add a speed selector button that cycles through 0.5x, 1.0x, 1.5x, 2.0x and display the current speed label.
Why does audio restart when I switch tabs in my app?
The widget is being disposed and recreated when you navigate. To keep audio playing across pages, lift the AudioPlayer instance to App State or use a global singleton. This requires Custom Code beyond the widget level.
Can RapidDev help with advanced audio features?
Yes. Background audio, offline caching, waveform visualization, and playlist management are complex implementations. RapidDev can build production-ready audio experiences for podcast, music, or education apps in FlutterFlow.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation