flutter_media_session 3.0.8 copy "flutter_media_session: ^3.0.8" to clipboard
flutter_media_session: ^3.0.8 copied to clipboard

Sync media metadata and playback state with system controls on Android, iOS, macOS, Windows, and Web.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_media_session/flutter_media_session.dart';
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:audioplayers/audioplayers.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:window_manager/window_manager.dart';

import 'models/track.dart';
import 'widgets/player_button.dart';
import 'widgets/artwork_widget.dart';
import 'widgets/settings_panel.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
    await windowManager.ensureInitialized();
    WindowOptions windowOptions = const WindowOptions(
      minimumSize: Size(400, 600),
    );
    windowManager.waitUntilReadyToShow(windowOptions, () async {
      await windowManager.show();
      await windowManager.focus();
    });
  }
  if (!kIsWeb && Platform.isWindows) {
    await FlutterMediaSession().setWindowsAppUserModelId(
      'dev.wyrin.flutter_media_session_example',
      displayName: 'Flutter Media Session Example',
    );
  }
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: Colors.deepPurple,
        brightness: Brightness.dark,
      ),
      home: const PlayerHome(),
    );
  }
}

class PlayerHome extends StatefulWidget {
  const PlayerHome({super.key});

  @override
  State<PlayerHome> createState() => _PlayerHomeState();
}

class _PlayerHomeState extends State<PlayerHome> {
  final _plugin = FlutterMediaSession();
  final List<AudioPlayer> _players = [AudioPlayer(), AudioPlayer()];
  int _currentPlayerIndex = 0;
  AudioPlayer get _audioPlayer => _players[_currentPlayerIndex];

  bool _active = false;
  PlaybackStatus _status = PlaybackStatus.idle;
  bool _isBuffering = false;
  bool _handlesInterruptions = false;
  bool _backgroundKeepAlive = false;
  bool _hasError = false;
  bool _isSwitchingTrack = false;
  int _currentIndex = 0;
  int _trackVersion = 0;
  Duration _position = Duration.zero;
  Duration _currentDuration = Duration.zero;
  bool _isShuffle = false;
  int _repeatMode = 0; // 0 = off, 2 = one (Track), 1 = all (List)
  bool _playPressed = false;
  bool _isDragging = false;
  bool _shouldResumeAfterDrag = false;
  final _random = Random();
  final List<int> _history = [];

  // Note: Custom icons (ic_shuffle_on, etc.) must be added to Android's
  // res/drawable folder to appear in the notification. On other platforms,
  // these will automatically fall back to standard shuffle/repeat buttons.
  MediaAction get _shuffleAction => MediaAction.custom(
        name: 'shuffle',
        customLabel: 'Shuffle',
        customIconResource: _isShuffle ? 'ic_shuffle_on' : 'ic_shuffle_off',
      );

  MediaAction get _repeatAction {
    String icon = 'ic_repeat_off';
    if (_repeatMode == 2) {
      icon = 'ic_repeat_one';
    } else if (_repeatMode == 1) {
      icon = 'ic_repeat_on';
    }
    return MediaAction.custom(
      name: 'repeat',
      customLabel: 'Repeat',
      customIconResource: icon,
    );
  }

  Set<MediaAction>? _availableActions;

  String? _loadedUrl;
  Timer? _seekDebounce;
  DateTime _lastSeekTime = DateTime.fromMillisecondsSinceEpoch(0);
  final List<StreamSubscription> _subscriptions = [];
  final List<StreamSubscription> _audioSubscriptions = [];
  Timer? _positionSyncTimer;

  AudioSourceType _audioSource = AudioSourceType.soundHelix;

  List<Track> get _playlist => List.generate(17, (index) {
        final id = index + 1;
        return Track(
          title: 'SoundHelix Song $id',
          artist: 'SoundHelix',
          artwork: 'https://picsum.photos/400/400?seed=$id',
          url: '${_audioSource.baseUrl}/SoundHelix-Song-$id.mp3',
        );
      });

  Track get current => _playlist[_currentIndex];

  String _appVersion = '';
  late final _ExamplePlayerAdapter _adapter;

  @override
  void initState() {
    super.initState();
    _loadAppVersion();
    _loadSavedAudioSource();
    _adapter = _ExamplePlayerAdapter(this);
    _availableActions = {
      MediaAction.play,
      MediaAction.pause,
      MediaAction.skipToNext,
      MediaAction.skipToPrevious,
      MediaAction.seekTo,
      MediaAction.stop,
      MediaAction.rewind,
      MediaAction.fastForward,
      _shuffleAction,
      _repeatAction,
    };
    _listenMediaSessionActions();
    _listenAudioPlayerEvents();
    _activate();
  }

  Future<void> _loadAppVersion() async {
    try {
      final info = await PackageInfo.fromPlatform();
      if (mounted && info.version.isNotEmpty) {
        setState(() {
          _appVersion = 'v${info.version}';
        });
      }
    } catch (_) {
      // Fallback or ignore in test / unsupported environments
    }
  }

  Future<void> _loadSavedAudioSource() async {
    try {
      final prefs = await SharedPreferences.getInstance();
      final savedSource = prefs.getString('preferred_audio_source');
      if (savedSource != null && mounted) {
        final matched =
            AudioSourceType.values.where((e) => e.name == savedSource);
        if (matched.isNotEmpty && matched.first != _audioSource) {
          setState(() {
            _audioSource = matched.first;
          });
        }
      }
    } catch (_) {}
  }

  void _listenMediaSessionActions() {
    _plugin.bind(_adapter);
  }

  void _listenAudioPlayerEvents() {
    for (final s in _audioSubscriptions) {
      s.cancel();
    }
    _audioSubscriptions.clear();

    _audioSubscriptions.add(_audioPlayer.onDurationChanged.listen((Duration d) {
      if (mounted) {
        setState(() {
          _currentDuration = d;
          if (d > Duration.zero) _isBuffering = false;
        });
        _updateAll();
      }
    }));

    _audioSubscriptions.add(_audioPlayer.onPositionChanged.listen((p) {
      if (mounted) {
        if (_isDragging) return;
        if (DateTime.now().difference(_lastSeekTime).inMilliseconds < 500) {
          return;
        }

        final wasBuffering = _isBuffering;
        setState(() {
          _position = p;
          if (!_isSwitchingTrack) _isBuffering = false;
        });
        if (wasBuffering && !_isBuffering) _updatePlayback();
      }
    }));

    _audioSubscriptions.add(_audioPlayer.onPlayerComplete.listen((event) {
      if (_repeatMode == 2) {
        _replayTrack();
      } else {
        _next();
      }
    }));

    _audioSubscriptions.add(_audioPlayer.onPlayerStateChanged.listen((state) {
      if (mounted) {
        setState(() {
          if (state == PlayerState.playing) {
            _status = PlaybackStatus.playing;
            _isBuffering = _currentDuration == Duration.zero;
            _isSwitchingTrack = false;
          } else if (state == PlayerState.paused) {
            _status = PlaybackStatus.paused;
            // Only stop buffering if we actually have metadata/duration
            if (_currentDuration > Duration.zero) _isBuffering = false;
          } else if (state == PlayerState.completed ||
              state == PlayerState.stopped) {
            _status = PlaybackStatus.idle;
            _isBuffering = false;
          }
        });
        _updatePlayback();
      }
    }));
  }

  Future<void> _activate() async {
    _listenMediaSessionActions();
    await _plugin.activate();
    await _plugin.setSkipIntervals(forwardSeconds: 10, backwardSeconds: 10);
    if (!mounted) return;
    setState(() {
      _active = true;
      _status = PlaybackStatus.paused;
    });
    await Future.wait([
      _updateAvailableActions(),
      _updateAll(),
    ]);

    _positionSyncTimer?.cancel();
    _positionSyncTimer = Timer.periodic(const Duration(seconds: 10), (timer) {
      if (_status == PlaybackStatus.playing && !_isBuffering) {
        _updatePlayback();
      }
    });
  }

  Future<void> _updateAvailableActions() async {
    if (!_active) return;

    // Ensure actions maintain a fixed order to prevent Android custom buttons from jumping
    if (_availableActions != null) {
      final fixedOrder = [
        MediaAction.play,
        MediaAction.pause,
        MediaAction.skipToNext,
        MediaAction.skipToPrevious,
        MediaAction.seekTo,
        MediaAction.stop,
        MediaAction.rewind,
        MediaAction.fastForward,
        _repeatAction, // Fixed order: e.g. repeat before shuffle
        _shuffleAction,
      ];
      _availableActions = fixedOrder
          .where((ref) => _availableActions!.any((a) => a.name == ref.name))
          .toSet();
    }

    _adapter.syncAvailableActions(_availableActions);
  }

  Future<void> _deactivate() async {
    _seekDebounce?.cancel();
    _positionSyncTimer?.cancel();
    await _plugin.setBackgroundKeepAlive(false);
    _plugin.unbind();
    await _plugin.deactivate();
    await _audioPlayer.stop();
    setState(() {
      _active = false;
      _status = PlaybackStatus.idle;
      _isBuffering = false;
      _position = Duration.zero;
      _isSwitchingTrack = false;
      _handlesInterruptions = false;
      _backgroundKeepAlive = false;
      _loadedUrl = null;
    });
  }

  void handleSeekAction(Duration newPosition) {
    final bool wasPlaying = _status == PlaybackStatus.playing;
    if (mounted) {
      setState(() {
        _position = newPosition;
        _lastSeekTime = DateTime.now();
      });
    }
    _updatePlayback();
    _seekDebounce?.cancel();
    _seekDebounce = Timer(const Duration(milliseconds: 200), () async {
      if (mounted) {
        try {
          await _audioPlayer.seek(newPosition);
          if (wasPlaying && _audioPlayer.state != PlayerState.playing) {
            await _audioPlayer.resume();
          }
        } catch (_) {}
      }
    });
  }

  Future<void> _attemptPlay() async {
    for (int i = 0; i < 2; i++) {
      try {
        await _audioPlayer.play(UrlSource(current.url));
        return;
      } catch (e) {
        debugPrint("Play attempt ${i + 1} failed: $e");
        if (e.toString().contains('AbortError') ||
            e.toString().contains('interrupted')) {
          rethrow;
        }
        if (i == 1) rethrow;

        // On Windows, if attempt 1 fails (e.g., Failed to set source), the native
        // player instance is often hopelessly corrupted and won't emit further events.
        // We must switch to our alternate clean player before attempt 2.
        _currentPlayerIndex = (_currentPlayerIndex + 1) % 2;
        await _audioPlayer.stop().catchError((_) {});
        _listenAudioPlayerEvents();

        await Future.delayed(const Duration(milliseconds: 500));
      }
    }
  }

  Future<void> _play() async {
    final bool wasError = _hasError;
    setState(() {
      _hasError = false;
      if (_status != PlaybackStatus.playing) _isBuffering = true;
    });
    _updatePlayback(); // Ensure buffering state is sent immediately
    try {
      if (wasError ||
          _loadedUrl != current.url ||
          _audioPlayer.source == null) {
        _loadedUrl = current.url;
        await _attemptPlay();
      } else {
        await _audioPlayer.resume();
      }
    } catch (e) {
      debugPrint("Play error: $e");
      if (e.toString().contains('AbortError') ||
          e.toString().contains('interrupted')) {
        if (mounted) {
          setState(() {
            _isBuffering = false;
            if (_status == PlaybackStatus.playing) {
              _status = PlaybackStatus.paused;
            }
          });
          _updatePlayback();
        }
        return;
      }
      _handleError();
    }
  }

  Future<void> _pause() async {
    try {
      await _audioPlayer.pause();
    } catch (_) {}
  }

  Future<void> _stop() async {
    _seekDebounce?.cancel();
    await _pause();
    if (mounted) {
      setState(() {
        _position = Duration.zero;
        _status = PlaybackStatus.paused;
      });
    }
    await _audioPlayer.seek(Duration.zero);
    _updatePlayback();
  }

  Future<void> _next() async {
    int nextIndex;
    if (_isShuffle && _playlist.length > 1) {
      do {
        nextIndex = _random.nextInt(_playlist.length);
      } while (nextIndex == _currentIndex);
    } else {
      nextIndex = (_currentIndex + 1) % _playlist.length;
    }
    _playIndex(nextIndex, pushHistory: true);
  }

  Future<void> _prev() async {
    if (_position.inSeconds >= 5 || (_history.isEmpty && _isShuffle)) {
      await _audioPlayer.seek(Duration.zero);
      return;
    }

    int prevIndex;
    if (_history.isNotEmpty) {
      prevIndex = _history.removeLast();
    } else {
      prevIndex = (_currentIndex - 1 + _playlist.length) % _playlist.length;
    }
    _playIndex(prevIndex, pushHistory: false);
  }

  void _toggleShuffle() {
    setState(() {
      _isShuffle = !_isShuffle;
    });
    _updateAvailableActions();
  }

  void _toggleRepeat() {
    setState(() {
      if (_repeatMode == 0) {
        _repeatMode = 2; // Repeat One
      } else if (_repeatMode == 2) {
        _repeatMode = 1; // Repeat All
      } else {
        _repeatMode = 0; // Off
      }
    });
    _updateAvailableActions();
    _updatePlayback();
  }

  void _changeAudioSource(AudioSourceType newSource) async {
    if (_audioSource == newSource) return;
    setState(() {
      _audioSource = newSource;
    });

    SharedPreferences.getInstance().then((prefs) {
      prefs.setString('preferred_audio_source', newSource.name);
    }).catchError((_) {});

    // If currently playing or loaded, reload track from new source preserving position if possible
    if (_status == PlaybackStatus.playing) {
      final currentPos = _position;
      _loadedUrl = null;
      setState(() {
        _isBuffering = true;
      });
      _updatePlayback();
      try {
        await _audioPlayer.stop();
        _loadedUrl = current.url;
        await _audioPlayer.play(UrlSource(current.url), position: currentPos);
      } catch (e) {
        _handleError();
      }
    } else {
      _loadedUrl = null;
    }
  }

  void _playIndex(int newIndex, {bool pushHistory = true}) async {
    if (pushHistory) {
      _history.add(_currentIndex);
      if (_history.length > 10) {
        _history.removeAt(0);
      }
    }

    _seekDebounce?.cancel();
    // Use ping-pong players to avoid Windows Media Foundation bugs where the old source
    // gets accidentally resumed for a split second during rapid track switches.
    final oldPlayer = _audioPlayer;
    oldPlayer.pause().catchError((_) {});

    _currentPlayerIndex = (_currentPlayerIndex + 1) % 2;
    _listenAudioPlayerEvents();

    final versionAtStart = ++_trackVersion;

    setState(() {
      _isSwitchingTrack = true;
      _currentIndex = newIndex;
      _position = Duration.zero;
      _currentDuration = Duration.zero;
      _isBuffering = true;
      _hasError = false;
      _status = PlaybackStatus.idle;
      _loadedUrl = current.url;
    });
    _updateAll();

    Future.delayed(const Duration(milliseconds: 300), () async {
      if (_trackVersion != versionAtStart) return;

      try {
        await _attemptPlay();
      } catch (e) {
        debugPrint("Change track error: $e");
        if (e.toString().contains('AbortError') ||
            e.toString().contains('interrupted')) {
          if (mounted && _trackVersion == versionAtStart) {
            setState(() {
              _isBuffering = false;
            });
            _updatePlayback();
          }
          return;
        }
        _handleError();
      }
    });
  }

  Future<void> _replayTrack() async {
    setState(() {
      _position = Duration.zero;
      _isBuffering = true;
      _hasError = false;
      _status = PlaybackStatus.idle;
    });
    _updatePlayback();
    try {
      await _attemptPlay();
    } catch (_) {
      _handleError();
    }
  }

  void _handleError() {
    if (!mounted) return;
    setState(() {
      _hasError = true;
      _isBuffering = false;
      _isSwitchingTrack = false;
      _status = PlaybackStatus.error;
    });
    ScaffoldMessenger.of(
      context,
    ).showSnackBar(SnackBar(content: Text('Failed to load ${current.title}')));
  }

  Future<void> _updateAll() async {
    if (!_active) return;
    _adapter.sync();
  }

  Future<void> _updatePlayback() async {
    if (!_active) return;
    _adapter.sync();
  }

  @override
  void dispose() {
    for (final subscription in _subscriptions) {
      subscription.cancel();
    }
    _subscriptions.clear();
    for (final s in _audioSubscriptions) {
      s.cancel();
    }
    _audioSubscriptions.clear();
    _seekDebounce?.cancel();
    _positionSyncTimer?.cancel();
    for (final p in _players) {
      p.dispose();
    }
    super.dispose();
  }

  String _format(Duration d) {
    if (_isBuffering || _isSwitchingTrack) return "--:--";
    String two(int n) => n.toString().padLeft(2, '0');
    return "${two(d.inMinutes)}:${two(d.inSeconds % 60)}";
  }

  @override
  Widget build(BuildContext context) {
    final track = current;
    final colorScheme = Theme.of(context).colorScheme;
    final textTheme = Theme.of(context).textTheme;

    return Scaffold(
      appBar: AppBar(
        title: Text(_appVersion.isEmpty
            ? "Example Player"
            : "Example Player $_appVersion"),
        centerTitle: true,
        backgroundColor: Colors.transparent,
        elevation: 0,
      ),
      body: LayoutBuilder(
        builder: (context, constraints) {
          final isWide = constraints.maxWidth > 800;

          if (isWide) {
            return Row(
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                Expanded(
                  flex: 1,
                  child: Center(
                    child: ConstrainedBox(
                      constraints: const BoxConstraints(maxWidth: 500),
                      child: SingleChildScrollView(
                        padding: const EdgeInsets.all(24),
                        child: Column(
                          mainAxisAlignment: MainAxisAlignment.center,
                          crossAxisAlignment: CrossAxisAlignment.stretch,
                          children: [
                            ArtworkWidget(
                              track: track,
                              trackVersion: _trackVersion,
                              colorScheme: colorScheme,
                            ),
                            const SizedBox(height: 32),
                            ..._buildPlayerControls(
                                track, colorScheme, textTheme),
                          ],
                        ),
                      ),
                    ),
                  ),
                ),
                Expanded(
                  flex: 1,
                  child: Center(
                    child: ConstrainedBox(
                      constraints: const BoxConstraints(maxWidth: 500),
                      child: SingleChildScrollView(
                        padding: const EdgeInsets.all(24),
                        child: Column(
                          mainAxisAlignment: MainAxisAlignment.center,
                          crossAxisAlignment: CrossAxisAlignment.stretch,
                          children: [
                            Text(
                              "Media Session Settings",
                              style: textTheme.headlineSmall?.copyWith(
                                fontWeight: FontWeight.bold,
                                color: colorScheme.onSurface,
                              ),
                              textAlign: TextAlign.center,
                            ),
                            const SizedBox(height: 32),
                            SettingsPanel(
                              active: _active,
                              onActivate: _activate,
                              onDeactivate: _deactivate,
                              availableActions: _availableActions,
                              onActionsChanged: (actions) {
                                setState(() => _availableActions = actions);
                                _updateAvailableActions();
                              },
                              shuffleAction: _shuffleAction,
                              repeatAction: _repeatAction,
                              handlesInterruptions: _handlesInterruptions,
                              onHandleInterruptionsChanged: (val) {
                                setState(() => _handlesInterruptions = val);
                                _plugin.setAutoHandleInterruptions(val);
                              },
                              backgroundKeepAlive: _backgroundKeepAlive,
                              onBackgroundKeepAliveChanged: (val) {
                                setState(() => _backgroundKeepAlive = val);
                                _plugin.setBackgroundKeepAlive(val);
                              },
                              audioSource: _audioSource,
                              onAudioSourceChanged: _changeAudioSource,
                            ),
                          ],
                        ),
                      ),
                    ),
                  ),
                ),
              ],
            );
          }

          return Center(
            child: ConstrainedBox(
              constraints: const BoxConstraints(maxWidth: 600),
              child: SingleChildScrollView(
                padding:
                    const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    ArtworkWidget(
                      track: track,
                      trackVersion: _trackVersion,
                      colorScheme: colorScheme,
                    ),
                    const SizedBox(height: 32),
                    ..._buildPlayerControls(track, colorScheme, textTheme),
                    const SizedBox(height: 32),
                    const Divider(),
                    const SizedBox(height: 32),
                    Text(
                      "Media Session Settings",
                      style: textTheme.headlineSmall?.copyWith(
                        fontWeight: FontWeight.bold,
                        color: colorScheme.onSurface,
                      ),
                      textAlign: TextAlign.center,
                    ),
                    const SizedBox(height: 24),
                    SettingsPanel(
                      active: _active,
                      onActivate: _activate,
                      onDeactivate: _deactivate,
                      availableActions: _availableActions,
                      onActionsChanged: (actions) {
                        setState(() => _availableActions = actions);
                        _updateAvailableActions();
                      },
                      shuffleAction: _shuffleAction,
                      repeatAction: _repeatAction,
                      handlesInterruptions: _handlesInterruptions,
                      onHandleInterruptionsChanged: (val) {
                        setState(() => _handlesInterruptions = val);
                        _plugin.setAutoHandleInterruptions(val);
                      },
                      backgroundKeepAlive: _backgroundKeepAlive,
                      onBackgroundKeepAliveChanged: (val) {
                        setState(() => _backgroundKeepAlive = val);
                        _plugin.setBackgroundKeepAlive(val);
                      },
                      audioSource: _audioSource,
                      onAudioSourceChanged: _changeAudioSource,
                    ),
                  ],
                ),
              ),
            ),
          );
        },
      ),
    );
  }

  List<Widget> _buildPlayerControls(
      Track track, ColorScheme colorScheme, TextTheme textTheme) {
    return [
      Text(
        track.title,
        style: textTheme.headlineMedium?.copyWith(
          color: colorScheme.onSurface,
          fontWeight: FontWeight.bold,
        ),
        textAlign: TextAlign.center,
        maxLines: 2,
      ),
      const SizedBox(height: 8),
      Text(
        track.artist,
        style: textTheme.titleMedium?.copyWith(
          color: colorScheme.onSurfaceVariant.withValues(alpha: 0.8),
        ),
        textAlign: TextAlign.center,
      ),
      const SizedBox(height: 32),
      // Playback Progress
      SizedBox(
        height: 20,
        child: (_isBuffering ||
                _isSwitchingTrack ||
                (_status == PlaybackStatus.playing &&
                    _currentDuration <= Duration.zero))
            ? Center(
                child: LinearProgressIndicator(
                  minHeight: 12.0,
                  borderRadius: BorderRadius.circular(6.0),
                ),
              )
            : LayoutBuilder(
                builder: (context, constraints) {
                  if (constraints.maxWidth <= 0) return const SizedBox.shrink();
                  return SliderTheme(
                    data: SliderTheme.of(context).copyWith(
                      trackHeight: 12,
                      padding: EdgeInsets.zero,
                      overlayShape: SliderComponentShape.noOverlay,
                      thumbShape: const RoundSliderThumbShape(
                        enabledThumbRadius: 8.0,
                      ),
                    ),
                    child: (() {
                      final double durationMs =
                          _currentDuration.inMilliseconds.toDouble();
                      final double positionMs =
                          _position.inMilliseconds.toDouble();
                      // Ensure max is always at least 1.0 and greater than min (0.0)
                      final double safeMax = durationMs > 0 ? durationMs : 1.0;
                      // Ensure value is strictly within [0.0, safeMax]
                      final double safeValue = positionMs.clamp(0.0, safeMax);

                      return Slider(
                        value: safeValue,
                        min: 0.0,
                        max: safeMax,
                        onChangeStart: (_hasError ||
                                _currentDuration <= Duration.zero ||
                                (_availableActions != null &&
                                    !_availableActions!
                                        .contains(MediaAction.seekTo)))
                            ? null
                            : (v) {
                                setState(() {
                                  _isDragging = true;
                                  if (_status == PlaybackStatus.playing ||
                                      _status == PlaybackStatus.buffering) {
                                    _shouldResumeAfterDrag = true;
                                    _audioPlayer.pause();
                                  } else {
                                    _shouldResumeAfterDrag = false;
                                  }
                                });
                              },
                        onChanged: (_hasError ||
                                _currentDuration <= Duration.zero ||
                                (_availableActions != null &&
                                    !_availableActions!
                                        .contains(MediaAction.seekTo)))
                            ? null
                            : (v) {
                                setState(() {
                                  _position = Duration(milliseconds: v.toInt());
                                });
                              },
                        onChangeEnd: (_hasError ||
                                _currentDuration <= Duration.zero ||
                                (_availableActions != null &&
                                    !_availableActions!
                                        .contains(MediaAction.seekTo)))
                            ? null
                            : (v) async {
                                final newPosition =
                                    Duration(milliseconds: v.toInt());
                                await _audioPlayer.seek(newPosition);
                                if (_shouldResumeAfterDrag) {
                                  await _audioPlayer.resume();
                                }
                                setState(() {
                                  _isDragging = false;
                                  _position = newPosition;
                                  _lastSeekTime = DateTime.now();
                                });
                                _updatePlayback();
                              },
                      );
                    })(),
                  );
                },
              ),
      ),
      Padding(
        padding: const EdgeInsets.symmetric(
          horizontal: 12,
          vertical: 8,
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(_format(_position), style: textTheme.labelMedium),
            Text(
              _format(_currentDuration),
              style: textTheme.labelMedium,
            ),
          ],
        ),
      ),
      const SizedBox(height: 16),
      // Unified control row
      SizedBox(
        height: 64,
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            PlayerToggleButton(
              isOn: _repeatMode != 0,
              enabled: _active,
              onTap: _toggleRepeat,
              icon: _repeatMode == 2
                  ? Icons.repeat_one_rounded
                  : Icons.repeat_rounded,
              normalWidth: 40,
              pressedWidth: 50,
              colorScheme: colorScheme,
            ),
            const SizedBox(width: 6),
            PlayerActionButton(
              onTap: _prev,
              icon: Icons.skip_previous_rounded,
              isFilled: false,
              normalWidth: 64,
              pressedWidth: 80,
              squeezed: _playPressed,
              colorScheme: colorScheme,
            ),
            const SizedBox(width: 6),
            Expanded(
              child: PlayerActionButton(
                onTap: (_isBuffering || _isSwitchingTrack)
                    ? null
                    : () {
                        if (_hasError || _status != PlaybackStatus.playing) {
                          _play();
                        } else {
                          _pause();
                        }
                      },
                icon: _status == PlaybackStatus.playing
                    ? Icons.pause_rounded
                    : Icons.play_arrow_rounded,
                isFilled: true,
                normalWidth: null,
                pressedWidth: null,
                onPressChanged: (v) => setState(() => _playPressed = v),
                colorScheme: colorScheme,
              ),
            ),
            const SizedBox(width: 6),
            PlayerActionButton(
              onTap: _next,
              icon: Icons.skip_next_rounded,
              isFilled: false,
              normalWidth: 64,
              pressedWidth: 80,
              squeezed: _playPressed,
              colorScheme: colorScheme,
            ),
            const SizedBox(width: 6),
            PlayerToggleButton(
              isOn: _isShuffle,
              enabled: _active,
              onTap: _toggleShuffle,
              icon: Icons.shuffle_rounded,
              normalWidth: 40,
              pressedWidth: 50,
              colorScheme: colorScheme,
            ),
          ],
        ),
      ),
    ];
  }
}

/// A custom application-level adapter mapping the example app's players
/// state to the [FlutterMediaSession] plugin.
class _ExamplePlayerAdapter implements MediaSessionAdapter {
  final _PlayerHomeState state;
  FlutterMediaSession? _session;
  StreamSubscription? _actionSubscription;

  _ExamplePlayerAdapter(this.state);

  @override
  void bind(FlutterMediaSession session) {
    _session = session;
    _actionSubscription =
        FlutterMediaSessionPlatform.instance.onMediaAction.listen((action) {
      switch (action.name) {
        case 'play':
          state._play();
          break;
        case 'pause':
          state._pause();
          break;
        case 'stop':
          state._stop();
          break;
        case 'skipToNext':
          state._next();
          break;
        case 'skipToPrevious':
          state._prev();
          break;
        case 'rewind':
          final newPos = state._position - const Duration(seconds: 10);
          state.handleSeekAction(
              newPos < Duration.zero ? Duration.zero : newPos);
          break;
        case 'fastForward':
          final newPos = state._position + const Duration(seconds: 10);
          state.handleSeekAction(newPos > state._currentDuration
              ? state._currentDuration
              : newPos);
          break;
        case 'seekTo':
          if (action.seekPosition != null) {
            state.handleSeekAction(action.seekPosition!);
          }
          break;
        default:
          if (action.name == 'shuffle') {
            if (state.mounted) state._toggleShuffle();
          } else if (action.name == 'repeat') {
            if (state.mounted) state._toggleRepeat();
          }
          break;
      }
    });
  }

  @override
  void unbind() {
    _actionSubscription?.cancel();
    _actionSubscription = null;
    _session = null;
  }

  /// Synchronizes all metadata and playback states to the system controls.
  void sync() {
    if (_session == null) return;

    FlutterMediaSessionPlatform.instance.updateMetadata(
      MediaMetadata(
        title: state.current.title,
        artist: state.current.artist,
        album: 'SoundHelix Demo',
        artworkUri: state.current.artwork,
        duration: state._currentDuration,
      ),
    );

    PlaybackStatus status = state._status;
    if (state._isBuffering) {
      status = PlaybackStatus.buffering;
    } else if (state._isSwitchingTrack) {
      status = PlaybackStatus.idle;
    }

    FlutterMediaSessionPlatform.instance.updatePlaybackState(
      PlaybackState(
        status: status,
        position: state._position,
        repeatMode: MediaRepeatMode.values[state._repeatMode],
        shuffleModeEnabled: state._isShuffle,
      ),
    );
  }

  /// Synchronizes available system actions.
  void syncAvailableActions(Set<MediaAction>? actions) {
    if (_session == null) return;
    FlutterMediaSessionPlatform.instance.updateAvailableActions(actions);
  }
}
4
likes
160
points
1.63k
downloads

Documentation

API reference

Publisher

verified publisherwyrin.dev

Weekly Downloads

Sync media metadata and playback state with system controls on Android, iOS, macOS, Windows, and Web.

Repository (GitHub)
View/report issues

Topics

#media #audio #playback #media-session #lockscreen

License

MIT (license)

Dependencies

flutter, flutter_web_plugins, plugin_platform_interface, web

More

Packages that depend on flutter_media_session

Packages that implement flutter_media_session