Skip to main content
RapidDev - Software Development Agency
flutterflow-tutorials

How to Implement a Custom Skin System for a Music Player in FlutterFlow

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.

What you'll learn

  • Integrate the just_audio package in a Custom Widget for full playback control
  • Build a Firestore-backed skin system with background color, button styles, and waveform colors
  • Switch skins without rebuilding the AudioPlayer instance to avoid interrupting playback
  • Animate album art rotation with AnimationController and a skin selector gallery
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner10 min read50-65 minFlutterFlow Pro+ (code export required for just_audio Custom Widget)March 2026RapidDev Engineering Team
TL;DR

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

1

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.

2

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.

MusicPlayerWidget.dart
1import 'package:flutter/material.dart';
2import 'package:just_audio/just_audio.dart';
3
4class 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 AudioPlayer
10 final Color backgroundColor;
11 final Color primaryColor;
12 final Color waveformColor;
13 final bool isCircleButton;
14
15 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);
26
27 @override
28 State<MusicPlayerWidget> createState() => _MusicPlayerWidgetState();
29}
30
31class _MusicPlayerWidgetState extends State<MusicPlayerWidget>
32 with SingleTickerProviderStateMixin {
33 late final AudioPlayer _player;
34 late final AnimationController _rotationController;
35
36 @override
37 void initState() {
38 super.initState();
39 // Created ONCE — never recreated when skin changes
40 _player = AudioPlayer();
41 _player.setUrl(widget.audioUrl);
42 _rotationController = AnimationController(
43 vsync: this,
44 duration: const Duration(seconds: 10),
45 )..repeat();
46 }
47
48 @override
49 void dispose() {
50 _player.dispose();
51 _rotationController.dispose();
52 super.dispose();
53 }
54
55 @override
56 Widget build(BuildContext context) {
57 return AnimatedContainer(
58 duration: const Duration(milliseconds: 400),
59 color: widget.backgroundColor, // Animates on skin change
60 child: Column(
61 children: [
62 // Rotating album art
63 RotationTransition(
64 turns: _rotationController,
65 child: CircleAvatar(
66 radius: 80,
67 backgroundImage: NetworkImage(widget.albumArtUrl),
68 ),
69 ),
70 // Playback controls wired to _player
71 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.

3

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.

4

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.

5

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

SkinManager.dart
1import 'package:cloud_firestore/cloud_firestore.dart';
2import 'package:firebase_auth/firebase_auth.dart';
3import 'package:flutter/material.dart';
4
5// Data class for a player skin
6class 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;
16
17 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 });
28
29 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 }
43
44 static Color _hexToColor(String hex) {
45 final clean = hex.replaceAll('#', '');
46 return Color(int.parse('FF$clean', radix: 16));
47 }
48}
49
50// Load available skins from Firestore
51Future<List<PlayerSkin>> loadAvailableSkins() async {
52 final snap = await FirebaseFirestore.instance
53 .collection('player_skins')
54 .orderBy('createdAt')
55 .get();
56 return snap.docs.map(PlayerSkin.fromFirestore).toList();
57}
58
59// Save user's preferred skin
60Future<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}
68
69// Load user's preferred skin
70Future<PlayerSkin?> loadPreferredSkin() async {
71 final uid = FirebaseAuth.instance.currentUser?.uid;
72 if (uid == null) return null;
73
74 final userDoc = await FirebaseFirestore.instance
75 .collection('users')
76 .doc(uid)
77 .get();
78 final skinId = userDoc.data()?['preferredSkinId'] as String?;
79 if (skinId == null) return null;
80
81 final skinDoc = await FirebaseFirestore.instance
82 .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.

ChatGPT Prompt

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.

FlutterFlow Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.