Build a music player in FlutterFlow with a full custom skin system: use just_audio for playback in a Custom Widget, load skin data (background, button colors, waveform color) from Firestore, apply skin changes by updating widget state — not by rebuilding the entire widget — to prevent playback interruption. Add a rotating album art animation and a skin selector gallery.
Why Skin Switching Must Not Restart Your AudioPlayer
Building a custom music player skin in FlutterFlow is a satisfying project — but there is one critical mistake that ruins the experience: rebuilding the entire Custom Widget when a user switches skins. In Flutter, rebuilding a Custom Widget that contains an AudioPlayer disposes and re-creates the player, stopping the music mid-track and losing the current position. The correct approach is to keep the AudioPlayer alive and update only the visual properties — colors, backgrounds, button styles — by passing new parameters to the widget without triggering a full rebuild. This tutorial shows you the precise pattern that keeps audio playing while the UI transforms seamlessly.
Prerequisites
- A FlutterFlow project on the Pro plan with code export enabled
- Firebase project with Firestore and Storage configured
- A Firestore collection called player_skins with at least 3 skin documents
- Audio files hosted in Firebase Storage or as public URLs
Step-by-step guide
Seed the player_skins Firestore collection
Seed the player_skins Firestore collection
Create a player_skins Firestore collection. Each document represents one visual theme for the player. Fields: name (String, e.g., 'Neon Dark'), backgroundHex (String, e.g., '#0D0D2B'), primaryHex (String, button and accent color), waveformHex (String), albumArtBorderHex (String), buttonShape (String: 'circle' or 'rounded'), thumbnailUrl (String, preview image for the skin selector gallery), isPremium (Boolean), createdAt (Timestamp). Create at least 3 skins: one dark neon, one warm wood, and one minimal white. Store the thumbnail images in Firebase Storage and put the download URLs in the thumbnailUrl field. This collection-driven approach means you can add new skins by inserting a Firestore document — no app update needed.
Expected result: Your player_skins collection has 3 documents in the Firebase console, each with all required color and style fields plus a thumbnail URL.
Build the MusicPlayerWidget Custom Widget with just_audio
Build the MusicPlayerWidget Custom Widget with just_audio
Create a new Custom Widget called MusicPlayerWidget. Add just_audio: ^0.9.36 and rxdart: ^0.27.7 to your pubspec dependencies. In the widget's Dart code, declare an AudioPlayer as a late final field initialized in initState — this ensures the player is created once and never recreated. Define widget parameters for all skin properties (backgroundHex, primaryHex, waveformHex, buttonShape) plus audio properties (audioUrl, trackTitle, artistName, albumArtUrl). Implement play/pause, seek, and track progress using StreamBuilder on player.positionStream. When parent widget passes new skin color parameters (because the user switched skins), Flutter rebuilds the widget subtree with new parameters but does NOT call initState again — so the AudioPlayer continues playing. This is the key lifecycle insight.
1import 'package:flutter/material.dart';2import 'package:just_audio/just_audio.dart';34class MusicPlayerWidget extends StatefulWidget {5 final String audioUrl;6 final String trackTitle;7 final String artistName;8 final String albumArtUrl;9 // Skin parameters — changing these does NOT rebuild AudioPlayer10 final Color backgroundColor;11 final Color primaryColor;12 final Color waveformColor;13 final bool isCircleButton;1415 const MusicPlayerWidget({16 Key? key,17 required this.audioUrl,18 required this.trackTitle,19 required this.artistName,20 required this.albumArtUrl,21 required this.backgroundColor,22 required this.primaryColor,23 required this.waveformColor,24 required this.isCircleButton,25 }) : super(key: key);2627 @override28 State<MusicPlayerWidget> createState() => _MusicPlayerWidgetState();29}3031class _MusicPlayerWidgetState extends State<MusicPlayerWidget>32 with SingleTickerProviderStateMixin {33 late final AudioPlayer _player;34 late final AnimationController _rotationController;3536 @override37 void initState() {38 super.initState();39 // Created ONCE — never recreated when skin changes40 _player = AudioPlayer();41 _player.setUrl(widget.audioUrl);42 _rotationController = AnimationController(43 vsync: this,44 duration: const Duration(seconds: 10),45 )..repeat();46 }4748 @override49 void dispose() {50 _player.dispose();51 _rotationController.dispose();52 super.dispose();53 }5455 @override56 Widget build(BuildContext context) {57 return AnimatedContainer(58 duration: const Duration(milliseconds: 400),59 color: widget.backgroundColor, // Animates on skin change60 child: Column(61 children: [62 // Rotating album art63 RotationTransition(64 turns: _rotationController,65 child: CircleAvatar(66 radius: 80,67 backgroundImage: NetworkImage(widget.albumArtUrl),68 ),69 ),70 // Playback controls wired to _player71 StreamBuilder<PlayerState>(72 stream: _player.playerStateStream,73 builder: (context, snapshot) {74 final isPlaying = snapshot.data?.playing ?? false;75 return IconButton(76 iconSize: 56,77 icon: Icon(78 isPlaying ? Icons.pause_circle : Icons.play_circle,79 color: widget.primaryColor,80 ),81 onPressed: isPlaying ? _player.pause : _player.play,82 );83 },84 ),85 ],86 ),87 );88 }89}Expected result: The Custom Widget compiles and plays audio when added to a FlutterFlow page. The background color animates smoothly when the parent changes the backgroundColor parameter.
Build the skin selector gallery page
Build the skin selector gallery page
Create a SkinGalleryPage. Add a GridView with 2 columns, populated by a Firestore query on the player_skins collection. Each grid item is a Card widget with: the thumbnailUrl displayed in a network image at the top, the skin name below, and a selected border (highlighted) when that skin's document ID matches a Page State variable selectedSkinId. Tapping a card updates selectedSkinId, fetches the full skin document, and updates an App State variable currentSkin (type: JSON) with the skin data. Close the sheet and navigate back to the player. On the player page, read currentSkin from App State and pass its fields as parameters to MusicPlayerWidget. Since you are only changing the parameters (not recreating the widget), the AudioPlayer keeps playing.
Expected result: The skin gallery shows all available skins as a grid. Tapping a skin closes the gallery and the player's background and colors animate to the new theme within 400ms — without pausing music.
Add the waveform visualization Custom Widget
Add the waveform visualization Custom Widget
Create a separate Custom Widget called WaveformWidget. Add audio_waveforms: ^1.0.5 to your pubspec dependencies. In the widget, show a PlayerWaveStyle with the waveformColor parameter passed from the parent skin configuration. The WaveformWidget is purely visual — it does not own the AudioPlayer, it only receives the current playback position as a double parameter (0.0 to 1.0) and renders the visual waveform accordingly. Pass the current position down from MusicPlayerWidget's StreamBuilder. This separation means the waveform color updates when the skin changes without affecting audio. For pre-computed waveform data, you can store waveform JSON in each track's Firestore document or generate it server-side.
Expected result: The waveform visualization displays below the album art. Its color matches the active skin's waveformHex value and changes instantly when the user switches skins.
Persist the user's skin preference to Firestore
Persist the user's skin preference to Firestore
When the user selects a skin in the gallery, save their preference to Firestore. Add a preferredSkinId field to the users collection document for the current user. On MusicPlayerPage load, run an On Page Load action that queries the users collection for the current user's preferredSkinId, then fetches the corresponding player_skins document and sets the currentSkin App State variable. This ensures the player always opens with the last skin the user chose. Add a 'Reset to Default' button on the gallery page that clears the preferredSkinId and applies the first skin in the collection. The skin loading on page init should show a short Shimmer animation while Firestore returns the data.
Expected result: Closing and reopening the app preserves the last selected skin. The player loads with the correct skin colors within 1-2 seconds of page open.
Complete working example
1import 'package:cloud_firestore/cloud_firestore.dart';2import 'package:firebase_auth/firebase_auth.dart';3import 'package:flutter/material.dart';45// Data class for a player skin6class PlayerSkin {7 final String id;8 final String name;9 final Color backgroundColor;10 final Color primaryColor;11 final Color waveformColor;12 final Color albumBorderColor;13 final bool isCircleButton;14 final String thumbnailUrl;15 final bool isPremium;1617 PlayerSkin({18 required this.id,19 required this.name,20 required this.backgroundColor,21 required this.primaryColor,22 required this.waveformColor,23 required this.albumBorderColor,24 required this.isCircleButton,25 required this.thumbnailUrl,26 required this.isPremium,27 });2829 factory PlayerSkin.fromFirestore(DocumentSnapshot doc) {30 final d = doc.data() as Map<String, dynamic>;31 return PlayerSkin(32 id: doc.id,33 name: d['name'] ?? 'Default',34 backgroundColor: _hexToColor(d['backgroundHex'] ?? '#000000'),35 primaryColor: _hexToColor(d['primaryHex'] ?? '#FFFFFF'),36 waveformColor: _hexToColor(d['waveformHex'] ?? '#00FF88'),37 albumBorderColor: _hexToColor(d['albumArtBorderHex'] ?? '#FFFFFF'),38 isCircleButton: d['buttonShape'] == 'circle',39 thumbnailUrl: d['thumbnailUrl'] ?? '',40 isPremium: d['isPremium'] ?? false,41 );42 }4344 static Color _hexToColor(String hex) {45 final clean = hex.replaceAll('#', '');46 return Color(int.parse('FF$clean', radix: 16));47 }48}4950// Load available skins from Firestore51Future<List<PlayerSkin>> loadAvailableSkins() async {52 final snap = await FirebaseFirestore.instance53 .collection('player_skins')54 .orderBy('createdAt')55 .get();56 return snap.docs.map(PlayerSkin.fromFirestore).toList();57}5859// Save user's preferred skin60Future<void> savePreferredSkin(String skinId) async {61 final uid = FirebaseAuth.instance.currentUser?.uid;62 if (uid == null) return;63 await FirebaseFirestore.instance.collection('users').doc(uid).update({64 'preferredSkinId': skinId,65 'skinUpdatedAt': FieldValue.serverTimestamp(),66 });67}6869// Load user's preferred skin70Future<PlayerSkin?> loadPreferredSkin() async {71 final uid = FirebaseAuth.instance.currentUser?.uid;72 if (uid == null) return null;7374 final userDoc = await FirebaseFirestore.instance75 .collection('users')76 .doc(uid)77 .get();78 final skinId = userDoc.data()?['preferredSkinId'] as String?;79 if (skinId == null) return null;8081 final skinDoc = await FirebaseFirestore.instance82 .collection('player_skins')83 .doc(skinId)84 .get();85 if (!skinDoc.exists) return null;86 return PlayerSkin.fromFirestore(skinDoc);87}Common mistakes when implementing a Custom Skin System for a Music Player in FlutterFlow
Why it's a problem: Rebuilding the entire Custom Widget when switching skins, destroying the AudioPlayer instance
How to avoid: Keep the MusicPlayerWidget in the widget tree continuously. Pass skin colors as parameters that can be updated without rebuilding the widget. Flutter's reconciliation engine updates the parameters and repaints the widget while the stateful AudioPlayer in _MusicPlayerWidgetState remains untouched.
Why it's a problem: Storing skin colors as hardcoded constants in the Custom Widget code
How to avoid: Store all skin definitions in Firestore. The app fetches and caches them at runtime. You can add, update, or remove skins instantly from the Firebase console.
Why it's a problem: Creating a new AudioPlayer instance every time the playback page is rebuilt
How to avoid: Initialize the AudioPlayer in initState using late final. It is created exactly once per widget lifecycle. Dispose it in dispose(). Use didUpdateWidget to react to parameter changes (like audioUrl changing for a new track) without recreating the player.
Best practices
- Never place AudioPlayer initialization inside the build() method — always use initState()
- Pass skin properties as widget parameters so the parent can update them without rebuilding the stateful widget
- Use AnimatedContainer for skin transitions — a 400ms color animation feels polished without being slow
- Pause the album art rotation animation when audio is paused to reinforce the paused state visually
- Cache the skin list in App State after the first Firestore fetch — skins rarely change during a session
- Add a loading shimmer on the skin gallery while Firestore fetches — avoid a blank grid flash
- Test AudioPlayer lifecycle on both iOS and Android — background audio behavior differs between platforms
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I am building a music player Custom Widget in Flutter using just_audio. Explain the correct lifecycle pattern for initializing an AudioPlayer in initState so it is never recreated when the parent widget rebuilds with new skin color parameters. Show how to update the visual theme (background color, button color) via widget parameters without stopping playback.
In my FlutterFlow Custom Widget for a music player using just_audio, write the Dart code for a StatefulWidget that accepts backgroundColor, primaryColor, and waveformColor as parameters. The AudioPlayer must be initialized once in initState. When the parent passes new color parameters, the widget repaints with new colors using AnimatedContainer without restarting audio.
Frequently asked questions
Why does my music stop when I switch skins?
This happens when the Custom Widget containing the AudioPlayer is rebuilt from scratch. Check that you are updating skin parameters on the existing widget rather than replacing the widget in the tree. Common causes: using a conditional widget (if/else) that swaps widget types, or changing the widget's key property. Keep the MusicPlayerWidget mounted continuously and pass new skin colors as parameter updates.
Can I use just_audio in FlutterFlow without Pro plan?
No. just_audio is a Dart package that must be added to pubspec.yaml and used in Custom Code. Both of these features require a FlutterFlow Pro plan with code export enabled. On the Free plan, you are limited to FlutterFlow's built-in Audio Player widget, which does not support custom skins.
How do I support background audio playback (audio continues when the app is in the background)?
Background audio requires additional platform configuration. For Android, add the FOREGROUND_SERVICE and WAKE_LOCK permissions to AndroidManifest.xml and configure an AudioService. For iOS, add the audio background mode to Info.plist. The just_audio package documentation covers both configurations. These changes require editing the exported project files in Android Studio or Xcode.
Can I sell premium skins as in-app purchases?
Yes. Set isPremium: true on premium skin documents in Firestore. In the skin gallery, check the user's entitlements (stored in their Firestore profile after a successful purchase) before unlocking premium skins. Use the in_app_purchase Flutter package for the purchase flow, triggered when a user taps a locked premium skin thumbnail.
How many skins can I have before Firestore performance degrades?
Fetching 50-100 skin documents in a single query is fast and inexpensive. The thumbnail images are the performance concern — use appropriately sized images (200x200px maximum for gallery thumbnails) and lazy-load them in the GridView using cached_network_image to prevent memory issues.
How do I generate waveform data for the WaveformWidget?
Pre-computing waveform data from audio files requires server-side processing. A Firebase Cloud Function can use the ffmpeg-wasm or a Node.js audio library to analyze an audio file and produce an array of amplitude values. Store this array in the track's Firestore document. For tracks where waveform data is not yet computed, show an animated placeholder visualization instead.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation