flutter_waveform_player 0.1.0
flutter_waveform_player: ^0.1.0 copied to clipboard
A self-contained Flutter waveform audio player widget. Provide a local or network audio URL — it handles downloading, real waveform extraction via FFmpeg, caching, playback, seek gestures, and a fully [...]
// ============================================================================
// Flutter Waveform Player — Example App
//
// Demonstrates all features of the flutter_waveform_player package:
// • Loading audio from a URL
// • Default and custom-styled waveform players
// • Playback status indicators
// • Lifecycle-aware audio management
// ============================================================================
import 'package:flutter/material.dart';
import 'package:flutter_waveform_player/flutter_waveform_player.dart';
/// Entry point for the example application.
void main() => runApp(const MyApp());
// ──────────────────────────────────────────────────────────────────────────────
// Root App
// ──────────────────────────────────────────────────────────────────────────────
/// Root widget that configures the Material 3 theme and launches the demo page.
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Waveform Player Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: const Color(0xFF6750A4),
brightness: Brightness.light,
useMaterial3: true,
),
darkTheme: ThemeData(
colorSchemeSeed: const Color(0xFF6750A4),
brightness: Brightness.dark,
useMaterial3: true,
),
home: const WaveformDemoPage(),
);
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Demo Page
// ──────────────────────────────────────────────────────────────────────────────
/// The main demo page showcasing different waveform player configurations.
class WaveformDemoPage extends StatefulWidget {
const WaveformDemoPage({super.key});
@override
State<WaveformDemoPage> createState() => _WaveformDemoPageState();
}
class _WaveformDemoPageState extends State<WaveformDemoPage>
with WidgetsBindingObserver {
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
/// Controller that manages audio playback and waveform data.
///
/// The [bars] parameter determines how many vertical bars the waveform
/// visualization will contain. The audio file is divided into that many
/// equal segments, and the peak amplitude of each segment becomes one bar.
///
/// **Examples:**
/// - `bars: 30` → 30 bars — coarse, chunky look (good for small widths).
/// - `bars: 50` → 50 bars — balanced detail and performance.
/// - `bars: 80` → 80 bars — fine detail (good for wider players).
/// - `bars: 120` → 120 bars — very detailed, thin bars.
///
/// **How it works internally:**
/// 1. The audio is converted to raw PCM samples via FFmpeg.
/// 2. The total samples are split into [bars] equal chunks.
/// 3. The peak amplitude (0.0–1.0) of each chunk is computed.
/// 4. These values drive the height of each bar in the waveform.
///
/// Higher values produce more visual detail but take slightly longer to
/// extract. Typical values range from 40 to 120 depending on the
/// available widget width.
final _controller = WaveformController(bars: 50);
/// Text controller for the audio URL input field.
final _urlController = TextEditingController(
text: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
);
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
@override
void initState() {
super.initState();
// Register lifecycle observer to stop audio when the app is backgrounded.
WidgetsBinding.instance.addObserver(this);
// Pre-load the waveform and prepare the audio without auto-playing.
// This ensures the waveform is visible and ready when the user taps play.
final url = _urlController.text.trim();
if (url.isNotEmpty) {
_controller.load(url, autoPlay: false);
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Stop playback when the app goes to background or is terminated.
// This prevents audio from continuing to play when the user leaves the app.
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
_controller.stop();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
_urlController.dispose();
super.dispose();
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
/// Loads the audio URL from the text field and starts playback.
void _loadAndPlayAudio() {
final url = _urlController.text.trim();
if (url.isNotEmpty) {
_controller.load(url);
}
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
// ── App Bar ──
appBar: AppBar(
title: const Text('Waveform Player'),
centerTitle: true,
elevation: 0,
scrolledUnderElevation: 2,
),
body: ListenableBuilder(
listenable: _controller,
builder: (context, _) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── URL Input Section ──
_SectionHeader(
icon: Icons.link_rounded,
title: 'Audio Source',
color: colorScheme.primary,
),
const SizedBox(height: 8),
_buildUrlInputCard(theme, colorScheme),
const SizedBox(height: 24),
// ── Status Indicator ──
_buildStatusChip(colorScheme),
const SizedBox(height: 24),
// ── Default Style Player ──
_SectionHeader(
icon: Icons.graphic_eq_rounded,
title: 'Default Style',
color: colorScheme.primary,
),
const SizedBox(height: 8),
_buildDefaultPlayerCard(colorScheme),
const SizedBox(height: 24),
// ── Custom Style Player ──
_SectionHeader(
icon: Icons.palette_rounded,
title: 'Custom Style',
color: colorScheme.tertiary,
),
const SizedBox(height: 8),
_buildCustomPlayerCard(theme),
const SizedBox(height: 24),
// ── Minimal Style Player ──
_SectionHeader(
icon: Icons.minimize_rounded,
title: 'Minimal Style',
color: colorScheme.secondary,
),
const SizedBox(height: 8),
_buildMinimalPlayerCard(colorScheme),
const SizedBox(height: 24),
// ── External Controls Demo ──
// Demonstrates how to use showPlayButton: false and control
// playback from a completely separate button placed elsewhere.
_SectionHeader(
icon: Icons.tune_rounded,
title: 'External Controls',
color: colorScheme.error,
),
const SizedBox(height: 8),
_buildExternalControlsCard(colorScheme),
const SizedBox(height: 24),
// ── Error Display ──
if (_controller.error != null) ...[
_buildErrorCard(colorScheme),
const SizedBox(height: 24),
],
],
),
);
},
),
);
}
// ---------------------------------------------------------------------------
// UI Components
// ---------------------------------------------------------------------------
/// Builds the URL input card with a text field and load button.
Widget _buildUrlInputCard(ThemeData theme, ColorScheme colorScheme) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _urlController,
decoration: InputDecoration(
labelText: 'Audio URL',
hintText: 'https://example.com/audio.mp3',
prefixIcon: const Icon(Icons.music_note_rounded),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: colorScheme.surfaceContainerLow,
),
style: theme.textTheme.bodyMedium,
onSubmitted: (_) => _loadAndPlayAudio(),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _loadAndPlayAudio,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Load & Play'),
),
),
],
),
),
);
}
/// Builds a status chip showing the current playback state.
Widget _buildStatusChip(ColorScheme colorScheme) {
final (label, icon, color) = switch (_controller.playbackStatus) {
AudioPlaybackStatus.idle => (
'Ready',
Icons.check_circle_outline,
colorScheme.outline,
),
AudioPlaybackStatus.loading => (
'Loading...',
Icons.hourglass_top_rounded,
colorScheme.tertiary,
),
AudioPlaybackStatus.playing => (
'Playing',
Icons.play_circle_rounded,
colorScheme.primary,
),
AudioPlaybackStatus.paused => (
'Paused',
Icons.pause_circle_rounded,
colorScheme.secondary,
),
};
return Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
if (_controller.duration > Duration.zero) ...[
const Spacer(),
Text(
'${_formatDuration(_controller.position)} / ${_formatDuration(_controller.duration)}',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
fontFamily: 'monospace',
),
),
],
],
);
}
/// Builds the default-styled waveform player inside a card.
Widget _buildDefaultPlayerCard(ColorScheme colorScheme) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: WaveformPlayer(
controller: _controller,
height: 48,
style: WaveformStyle(
highlightColor: colorScheme.primary,
seekIndicatorColor: colorScheme.primary,
baseColor: colorScheme.surfaceContainerHighest,
),
),
),
);
}
/// Builds a custom-styled waveform player with a dark purple theme
/// and square bars, demonstrating [playButtonBuilder] customisation.
Widget _buildCustomPlayerCard(ThemeData theme) {
return Card(
elevation: 0,
color: const Color(0xFF1A1128),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
child: WaveformPlayer(
controller: _controller,
height: 64,
showPlayButton: true,
style: const WaveformStyle(
baseColor: Color(0xFF3D2E5C),
highlightColor: Color(0xFFBB86FC),
seekIndicatorColor: Color(0xFFBB86FC),
barWidth: 3.0,
barCap: BarCap.square,
barSpacing: 2.0,
seekIndicatorRadius: 6.0,
),
timeLabelStyle: const TextStyle(
color: Color(0xFFBB86FC),
fontSize: 10,
fontFamily: 'monospace',
),
// Custom play/pause button using a rounded rectangle container.
playButtonBuilder: (context, isPlaying) {
return Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFFBB86FC),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: const Color(0xFF1A1128),
size: 22,
),
);
},
),
),
);
}
/// Builds a minimal waveform player — no play button, no time labels.
/// Users can tap or drag the waveform to seek.
Widget _buildMinimalPlayerCard(ColorScheme colorScheme) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tap the waveform to seek',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
WaveformPlayer(
controller: _controller,
height: 32,
showPlayButton: false,
showTimeLabels: false,
style: WaveformStyle(
baseColor: colorScheme.secondaryContainer,
highlightColor: colorScheme.secondary,
seekIndicatorColor: colorScheme.secondary,
barWidth: 2.0,
barSpacing: 1.0,
),
),
],
),
),
);
}
/// Builds a demo showing external play/pause controls.
///
/// The waveform has `showPlayButton: false` — playback is controlled
/// entirely by a separate button placed outside the [WaveformPlayer] widget.
/// This is how you'd integrate the waveform into a custom player UI.
///
/// **Key concept:** Call `_controller.togglePlayPause()` from any widget
/// to play/pause. The waveform will update automatically since it listens
/// to the same [WaveformController].
Widget _buildExternalControlsCard(ColorScheme colorScheme) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Info text ──
Text(
'Play/pause button is outside the waveform widget',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
// ── Waveform with NO built-in play button ──
WaveformPlayer(
controller: _controller,
height: 48,
showPlayButton: false, // ← Disabled
showTimeLabels: false,
style: WaveformStyle(
baseColor: colorScheme.errorContainer,
highlightColor: colorScheme.error,
seekIndicatorColor: colorScheme.error,
barWidth: 2.5,
barSpacing: 1.5,
),
),
const SizedBox(height: 16),
// ── External controls row ──
// These buttons are completely separate from the WaveformPlayer
// widget but control the same WaveformController.
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Rewind 10 seconds
IconButton(
onPressed: () {
final newPos =
_controller.position - const Duration(seconds: 10);
_controller.seekTo(
newPos < Duration.zero ? Duration.zero : newPos,
);
},
icon: const Icon(Icons.replay_10_rounded),
tooltip: 'Rewind 10s',
),
const SizedBox(width: 8),
// Play / Pause — the main external control
FilledButton.tonalIcon(
onPressed: _controller.togglePlayPause,
icon: Icon(
_controller.isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
label: Text(
_controller.isPlaying ? 'Pause' : 'Play',
),
),
const SizedBox(width: 8),
// Forward 10 seconds
IconButton(
onPressed: () {
final newPos =
_controller.position + const Duration(seconds: 10);
_controller.seekTo(
newPos > _controller.duration
? _controller.duration
: newPos,
);
},
icon: const Icon(Icons.forward_10_rounded),
tooltip: 'Forward 10s',
),
],
),
const SizedBox(height: 8),
// ── Time display ──
Center(
child: Text(
'${_formatDuration(_controller.position)} / ${_formatDuration(_controller.duration)}',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
fontFamily: 'monospace',
),
),
),
],
),
),
);
}
/// Builds an error card displayed when the controller reports an error.
Widget _buildErrorCard(ColorScheme colorScheme) {
return Card(
elevation: 0,
color: colorScheme.errorContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline_rounded, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(
_controller.error!,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 13,
),
),
),
],
),
),
);
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Reusable Widgets
// ──────────────────────────────────────────────────────────────────────────────
/// A section header with an icon and title, used to label demo sections.
class _SectionHeader extends StatelessWidget {
/// The icon displayed before the title.
final IconData icon;
/// The section title text.
final String title;
/// The color applied to both the icon and title.
final Color color;
const _SectionHeader({
required this.icon,
required this.title,
required this.color,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
],
);
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────────
/// Formats a [Duration] into a human-readable "M:SS" string.
///
/// Example: `Duration(minutes: 3, seconds: 7)` → `"3:07"`.
String _formatDuration(Duration d) {
final m = d.inMinutes.remainder(60);
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}