flutter_media_session 2.0.0-pre.2
flutter_media_session: ^2.0.0-pre.2 copied to clipboard
Sync media metadata and playback state with system controls on Android, Windows, and Web.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_media_session/flutter_media_session.dart';
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
void main() {
runApp(const MyApp());
}
class Track {
final String title;
final String artist;
final String artwork;
final String url;
const Track({
required this.title,
required this.artist,
required this.artwork,
required this.url,
});
}
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 AudioPlayer _audioPlayer = AudioPlayer();
bool _active = false;
PlaybackStatus _status = PlaybackStatus.idle;
bool _isBuffering = false;
bool _hasError = false;
bool _isSwitchingTrack = false;
int _currentIndex = 0;
Duration _position = Duration.zero;
Duration _currentDuration = Duration.zero;
Set<MediaAction>? _availableActions;
String? _loadedUrl;
Timer? _seekDebounce;
DateTime _lastSeekTime = DateTime.fromMillisecondsSinceEpoch(0);
final List<StreamSubscription> _subscriptions = [];
final List<Track> _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: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-$id.mp3',
);
});
Track get current => _playlist[_currentIndex];
@override
void initState() {
super.initState();
_listenMediaSessionActions();
_listenAudioPlayerEvents();
}
void _listenMediaSessionActions() {
_subscriptions.add(_plugin.onMediaAction.listen((action) {
switch (action) {
case MediaAction.play:
_play();
break;
case MediaAction.pause:
_pause();
break;
case MediaAction.skipToNext:
_next();
break;
case MediaAction.skipToPrevious:
_prev();
break;
case MediaAction.seekTo:
if (action.seekPosition != null) {
final newPosition = action.seekPosition!;
if (mounted) {
setState(() {
_position = newPosition;
_lastSeekTime = DateTime.now();
});
}
_updatePlayback();
// Debouncing external seek commands to avoid flooding the audio player.
// 200ms provides a good balance between responsiveness and stability.
_seekDebounce?.cancel();
_seekDebounce = Timer(const Duration(milliseconds: 200), () {
if (mounted) {
_audioPlayer.seek(newPosition).catchError((_) {});
}
});
}
break;
default:
break;
}
}));
}
void _listenAudioPlayerEvents() {
_subscriptions.add(_audioPlayer.onDurationChanged.listen((Duration d) {
if (mounted) {
setState(() {
_currentDuration = d;
if (d > Duration.zero) _isBuffering = false;
});
_updateAll();
}
}));
_subscriptions.add(_audioPlayer.onPositionChanged.listen((p) {
if (mounted) {
// Ignore stale position updates for 500ms after a seek to prevent UI flickering
if (DateTime.now().difference(_lastSeekTime).inMilliseconds < 500) return;
final wasBuffering = _isBuffering;
setState(() {
_position = p;
if (!_isSwitchingTrack) _isBuffering = false;
});
if (wasBuffering && !_isBuffering) _updatePlayback();
// System Media Sessions (Android/Windows) extrapolate position automatically.
}
}));
_subscriptions
.add(_audioPlayer.onPlayerComplete.listen((event) => _next()));
_subscriptions.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;
_isBuffering = false;
} else if (state == PlayerState.completed ||
state == PlayerState.stopped) {
_status = PlaybackStatus.idle;
_isBuffering = false;
}
});
_updatePlayback();
}
}));
}
Future<void> _activate() async {
final granted = await _plugin.requestNotificationPermission();
if (!granted && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Notification permission is required for media controls')),
);
}
await _plugin.activate();
setState(() => _active = true);
await _updateAvailableActions();
await _updateAll();
}
Future<void> _updateAvailableActions() async {
if (!_active) return;
await _plugin.updateAvailableActions(_availableActions);
}
Future<void> _deactivate() async {
_seekDebounce?.cancel();
await _plugin.deactivate();
await _audioPlayer.stop();
setState(() {
_active = false;
_status = PlaybackStatus.idle;
_isBuffering = false;
_position = Duration.zero;
_isSwitchingTrack = false;
});
}
Future<void> _play() async {
if (_availableActions != null && !_availableActions!.contains(MediaAction.play)) return;
setState(() {
_hasError = false;
if (_status != PlaybackStatus.playing) _isBuffering = true;
});
try {
if (_loadedUrl != current.url || _audioPlayer.source == null) {
_loadedUrl = current.url;
await _audioPlayer.play(UrlSource(current.url));
} else {
await _audioPlayer.resume();
}
} catch (e) {
if (e.toString().contains('AbortError') || e.toString().contains('interrupted')) {
// A known race condition in browsers when pause is called right after play
return;
}
debugPrint("Play error: $e");
_handleError();
}
}
Future<void> _pause() async {
if (_availableActions != null && !_availableActions!.contains(MediaAction.pause)) return;
try {
await _audioPlayer.pause();
} catch (_) {}
}
Future<void> _next() async {
if (_availableActions != null && !_availableActions!.contains(MediaAction.skipToNext)) return;
_changeTrack(1);
}
Future<void> _prev() async {
if (_availableActions != null && !_availableActions!.contains(MediaAction.skipToPrevious)) return;
_changeTrack(-1);
}
void _changeTrack(int step) async {
_seekDebounce?.cancel();
await _audioPlayer.stop().catchError((_) {});
setState(() {
_isSwitchingTrack = true;
_currentIndex =
(_currentIndex + step + _playlist.length) % _playlist.length;
_position = Duration.zero;
_currentDuration = Duration.zero;
_isBuffering = true;
_hasError = false;
_status = PlaybackStatus.idle;
_loadedUrl = current.url;
});
_updateAll();
Future.delayed(const Duration(milliseconds: 50), () async {
try {
await _audioPlayer.play(UrlSource(current.url));
} catch (e) {
if (e.toString().contains('AbortError') || e.toString().contains('interrupted')) return;
debugPrint("Change track error: $e");
_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;
await _plugin.updateMetadata(
MediaMetadata(
title: current.title,
artist: current.artist,
album: 'SoundHelix Demo',
artworkUri: current.artwork,
duration: _currentDuration,
),
);
await _updatePlayback();
}
Future<void> _updatePlayback() async {
if (!_active) return;
PlaybackStatus status = _status;
if (_isSwitchingTrack) {
status = PlaybackStatus.idle;
} else if (_isBuffering) {
status = PlaybackStatus.buffering;
}
await _plugin.updatePlaybackState(
PlaybackState(
status: status,
position: _position,
),
);
}
@override
void dispose() {
for (final subscription in _subscriptions) {
subscription.cancel();
}
_subscriptions.clear();
_seekDebounce?.cancel();
_audioPlayer.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: const Text("MD3 Player"),
centerTitle: true,
backgroundColor: Colors.transparent,
elevation: 0,
),
body: 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: [
Center(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: Card(
key: ValueKey(track.artwork),
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
clipBehavior: Clip.antiAlias,
child: Image.network(
track.artwork,
width: 280,
height: 280,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 280,
height: 280,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_off,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
const SizedBox(height: 32),
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),
),
)
: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 12,
padding: EdgeInsets.zero,
overlayShape: SliderComponentShape.noOverlay,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 8.0,
),
),
child: Slider(
value: _position.inMilliseconds.toDouble().clamp(
0.0,
_currentDuration.inMilliseconds.toDouble(),
),
min: 0.0,
max: _currentDuration.inMilliseconds.toDouble() > 0
? _currentDuration.inMilliseconds.toDouble()
: 1.0,
onChanged:
(_hasError || _currentDuration <= Duration.zero || (_availableActions != null && !_availableActions!.contains(MediaAction.seekTo)))
? null
: (v) {
final newPosition = Duration(
milliseconds: v.toInt(),
);
setState(() => _position = newPosition);
},
onChangeEnd:
(_hasError || _currentDuration <= Duration.zero || (_availableActions != null && !_availableActions!.contains(MediaAction.seekTo)))
? null
: (v) {
final newPosition = Duration(
milliseconds: v.toInt(),
);
setState(() {
_position = newPosition;
_lastSeekTime = DateTime.now();
});
_audioPlayer
.seek(newPosition)
.catchError((_) {});
_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: 24),
// Control Buttons
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton.filledTonal(
iconSize: 32,
onPressed: (_availableActions != null && !_availableActions!.contains(MediaAction.skipToPrevious)) ? null : _prev,
icon: const Icon(Icons.skip_previous),
),
IconButton.filled(
iconSize: 56,
onPressed: (_availableActions != null && !_availableActions!.contains(_status == PlaybackStatus.playing ? MediaAction.pause : MediaAction.play))
? null
: (_hasError
? _play
: (_status == PlaybackStatus.playing
? _pause
: _play)),
icon: Icon(
_status == PlaybackStatus.playing
? Icons.pause
: Icons.play_arrow,
),
),
IconButton.filledTonal(
iconSize: 32,
onPressed: (_availableActions != null && !_availableActions!.contains(MediaAction.skipToNext)) ? null : _next,
icon: const Icon(Icons.skip_next),
),
],
),
const SizedBox(height: 32),
Center(
child: SegmentedButton<bool>(
segments: const [
ButtonSegment(
value: true,
label: Text('Active'),
icon: Icon(Icons.sensors),
),
ButtonSegment(
value: false,
label: Text('Inactive'),
icon: Icon(Icons.sensors_off),
),
],
selected: {_active},
onSelectionChanged: (set) {
final val = set.first;
if (val != _active) val ? _activate() : _deactivate();
},
),
),
const SizedBox(height: 32),
if (_active) ...[
Text(
"System Control Actions",
style: textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
FilterChip(
label: const Text("All"),
selected: _availableActions == null,
onSelected: (selected) {
if (selected) {
setState(() => _availableActions = null);
_updateAvailableActions();
}
},
),
for (final action in [
MediaAction.play,
MediaAction.pause,
MediaAction.skipToNext,
MediaAction.skipToPrevious,
MediaAction.seekTo,
MediaAction.stop,
MediaAction.rewind,
MediaAction.fastForward,
])
_singleActionChip(action),
],
),
const SizedBox(height: 16),
Text(
"Selected: ${_availableActions == null ? 'All' : _availableActions!.map((a) => a.name).join(', ')}",
style: textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
],
),
),
),
),
);
}
Widget _singleActionChip(MediaAction action) {
final isSelected =
_availableActions == null || _availableActions!.contains(action);
return FilterChip(
label: Text(action.name),
selected: isSelected,
onSelected: (selected) {
setState(() {
if (_availableActions == null) {
if (!selected) {
_availableActions = {
MediaAction.play,
MediaAction.pause,
MediaAction.skipToNext,
MediaAction.skipToPrevious,
MediaAction.seekTo,
MediaAction.stop,
MediaAction.rewind,
MediaAction.fastForward,
}..remove(action);
}
} else {
if (selected) {
_availableActions!.add(action);
if (_availableActions!.length == 8) {
// If all 8 standard actions are selected, revert to null (All)
_availableActions = null;
}
} else {
_availableActions!.remove(action);
}
}
});
_updateAvailableActions();
},
);
}
}