flutter_waveform_player 0.1.1 copy "flutter_waveform_player: ^0.1.1" to clipboard
flutter_waveform_player: ^0.1.1 copied to clipboard

A Flutter waveform audio player widget with built-in downloading, FFmpeg waveform extraction, caching, playback, seek gestures, and customisable UI.

example/lib/main.dart

// ============================================================================
// 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';
}
4
likes
160
points
120
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter waveform audio player widget with built-in downloading, FFmpeg waveform extraction, caching, playback, seek gestures, and customisable UI.

Repository (GitHub)
View/report issues

Topics

#audio #waveform #player #audio-player #music

License

MIT (license)

Dependencies

dio, ffmpeg_kit_flutter_new, flutter, just_audio, path_provider

More

Packages that depend on flutter_waveform_player