import 'dart:math'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; // import 'package:just_audio_background/just_audio_background.dart'; class AudioPlayerPage extends StatefulWidget { const AudioPlayerPage({super.key}); @override State createState() => _AudioPlayerPageState(); } class _AudioPlayerPageState extends State { static int _nextMediaId = 0; late AudioPlayer _player; final _playlist = ConcatenatingAudioSource(children: [ ClippingAudioSource( start: const Duration(seconds: 0), end: const Duration(seconds: 90), child: AudioSource.uri(Uri.parse( "https://s3.amazonaws.com/scifri-episodes/scifri20181123-episode.mp3")), tag: MediaItem( id: '${_nextMediaId++}', album: "Science Friday", title: "A Salute To Head-Scratching Science (30 seconds)", artUri: Uri.parse( "https://media.wnyc.org/i/1400/1400/l/80/1/ScienceFriday_WNYCStudios_1400.jpg"), ), ), // AudioSource.uri( // Uri.parse( // "https://upos-sz-mirror08c.bilivideo.com/upgcxcode/05/52/1205825205/1205825205-1-16.mp4?e=ig8euxZM2rNcNbRVhwdVhwdlhWdVhwdVhoNvNC8BqJIzNbfq9rVEuxTEnE8L5F6VnEsSTx0vkX8fqJeYTj_lta53NCM=&uipk=5&nbs=1&deadline=1693821903&gen=playurlv2&os=08cbv&oi=1865700872&trid=bfc9c19f85c545dd8f4794ff97f4f57fh&mid=17340771&platform=html5&upsig=9bf98515091bb8a80e1950a03a2a0d68&uparams=e,uipk,nbs,deadline,gen,os,oi,trid,mid,platform&bvc=vod&nettype=0&f=h_0_0&bw=49663&logo=80000000"), // headers: { // 'user-agent': // 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15', // 'referer': 'https://www.bilibili.com' // }, // tag: MediaItem( // id: '${_nextMediaId++}', // album: "Science Friday", // title: "A Salute To Head-Scratching Science", // artUri: Uri.parse( // "https://media.wnyc.org/i/1400/1400/l/80/1/ScienceFriday_WNYCStudios_1400.jpg"), // ), // ), AudioSource.uri( Uri.parse("https://s3.amazonaws.com/scifri-segments/scifri201711241.mp3"), tag: MediaItem( id: '${_nextMediaId++}', album: "Science Friday", title: "From Cat Rheology To Operatic Incompetence", artUri: Uri.parse( "https://media.wnyc.org/i/1400/1400/l/80/1/ScienceFriday_WNYCStudios_1400.jpg"), ), ), AudioSource.uri( Uri.parse("asset:///audio/nature.mp3"), tag: MediaItem( id: '${_nextMediaId++}', album: "Public Domain", title: "Nature Sounds", artUri: Uri.parse( "https://media.wnyc.org/i/1400/1400/l/80/1/ScienceFriday_WNYCStudios_1400.jpg"), ), ), ]); @override void initState() { super.initState(); _player = AudioPlayer(); _init(); } @override void dispose() { _player.dispose(); super.dispose(); } Future _init() async { final session = await AudioSession.instance; await session.configure(const AudioSessionConfiguration.speech()); // Listen to errors during playback. _player.playbackEventStream.listen((event) {}, onError: (Object e, StackTrace stackTrace) { print('A stream error occurred: $e'); }); try { await _player.setAudioSource(_playlist); } catch (e, stackTrace) { // Catch load errors: 404, invalid url ... print("Error loading playlist: $e"); print(stackTrace); } } // Stream get _positionDataStream => // Rx.combineLatest3( // _player.positionStream, // _player.bufferedPositionStream, // _player.durationStream, // (position, bufferedPosition, duration) => PositionData( // position, bufferedPosition, duration ?? Duration.zero)); @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: StreamBuilder( stream: _player.sequenceStateStream, builder: (context, snapshot) { final state = snapshot.data; if (state?.sequence.isEmpty ?? true) { return const SizedBox(); } final metadata = state!.currentSource!.tag as MediaItem; return Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Padding( padding: const EdgeInsets.all(8.0), child: Center( child: Image.network(metadata.artUri.toString())), ), ), Text(metadata.album!, style: Theme.of(context).textTheme.titleLarge), Text(metadata.title), ], ); }, ), ), ControlButtons(_player), // StreamBuilder( // stream: _positionDataStream, // builder: (context, snapshot) { // final positionData = snapshot.data; // return SeekBar( // duration: positionData?.duration ?? Duration.zero, // position: positionData?.position ?? Duration.zero, // bufferedPosition: // positionData?.bufferedPosition ?? Duration.zero, // onChangeEnd: (newPosition) { // _player.seek(newPosition); // }, // ); // }, // ), const SizedBox(height: 8.0), Row( children: [ StreamBuilder( stream: _player.loopModeStream, builder: (context, snapshot) { final loopMode = snapshot.data ?? LoopMode.off; const icons = [ Icon(Icons.repeat, color: Colors.grey), Icon(Icons.repeat, color: Colors.orange), Icon(Icons.repeat_one, color: Colors.orange), ]; const cycleModes = [ LoopMode.off, LoopMode.all, LoopMode.one, ]; final index = cycleModes.indexOf(loopMode); return IconButton( icon: icons[index], onPressed: () { _player.setLoopMode(cycleModes[ (cycleModes.indexOf(loopMode) + 1) % cycleModes.length]); }, ); }, ), Expanded( child: Text( "Playlist", style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.center, ), ), StreamBuilder( stream: _player.shuffleModeEnabledStream, builder: (context, snapshot) { final shuffleModeEnabled = snapshot.data ?? false; return IconButton( icon: shuffleModeEnabled ? const Icon(Icons.shuffle, color: Colors.orange) : const Icon(Icons.shuffle, color: Colors.grey), onPressed: () async { final enable = !shuffleModeEnabled; if (enable) { await _player.shuffle(); } await _player.setShuffleModeEnabled(enable); }, ); }, ), ], ), SizedBox( height: 240.0, child: StreamBuilder( stream: _player.sequenceStateStream, builder: (context, snapshot) { final state = snapshot.data; final sequence = state?.sequence ?? []; return ReorderableListView( onReorder: (int oldIndex, int newIndex) { if (oldIndex < newIndex) newIndex--; _playlist.move(oldIndex, newIndex); }, children: [ for (var i = 0; i < sequence.length; i++) Dismissible( key: ValueKey(sequence[i]), background: Container( color: Colors.redAccent, alignment: Alignment.centerRight, child: const Padding( padding: EdgeInsets.only(right: 8.0), child: Icon(Icons.delete, color: Colors.white), ), ), onDismissed: (dismissDirection) { _playlist.removeAt(i); }, child: Material( color: i == state!.currentIndex ? Colors.grey.shade300 : null, child: ListTile( title: Text(sequence[i].tag.title as String), onTap: () { _player.seek(Duration.zero, index: i); }, ), ), ), ], ); }, ), ), ], ), ), ); } } class ControlButtons extends StatelessWidget { final AudioPlayer player; const ControlButtons(this.player, {Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.volume_up), onPressed: () { showSliderDialog( context: context, title: "Adjust volume", divisions: 10, min: 0.0, max: 1.0, stream: player.volumeStream, onChanged: player.setVolume, ); }, ), StreamBuilder( stream: player.sequenceStateStream, builder: (context, snapshot) => IconButton( icon: const Icon(Icons.skip_previous), onPressed: player.hasPrevious ? player.seekToPrevious : null, ), ), StreamBuilder( stream: player.playerStateStream, builder: (context, snapshot) { final playerState = snapshot.data; final processingState = playerState?.processingState; final playing = playerState?.playing; if (processingState == ProcessingState.loading || processingState == ProcessingState.buffering) { return Container( margin: const EdgeInsets.all(8.0), width: 64.0, height: 64.0, child: const CircularProgressIndicator(), ); } else if (playing != true) { return IconButton( icon: const Icon(Icons.play_arrow), iconSize: 64.0, onPressed: player.play, ); } else if (processingState != ProcessingState.completed) { return IconButton( icon: const Icon(Icons.pause), iconSize: 64.0, onPressed: player.pause, ); } else { return IconButton( icon: const Icon(Icons.replay), iconSize: 64.0, onPressed: () => player.seek(Duration.zero, index: player.effectiveIndices!.first), ); } }, ), StreamBuilder( stream: player.sequenceStateStream, builder: (context, snapshot) => IconButton( icon: const Icon(Icons.skip_next), onPressed: player.hasNext ? player.seekToNext : null, ), ), StreamBuilder( stream: player.speedStream, builder: (context, snapshot) => IconButton( icon: Text("${snapshot.data?.toStringAsFixed(1)}x", style: const TextStyle(fontWeight: FontWeight.bold)), onPressed: () { showSliderDialog( context: context, title: "Adjust speed", divisions: 10, min: 0.5, max: 1.5, stream: player.speedStream, onChanged: player.setSpeed, ); }, ), ), ], ); } } void showSliderDialog({ required BuildContext context, required String title, required int divisions, required double min, required double max, String valueSuffix = '', required Stream stream, required ValueChanged onChanged, }) { showDialog( context: context, builder: (context) => AlertDialog( title: Text(title, textAlign: TextAlign.center), content: StreamBuilder( stream: stream, builder: (context, snapshot) => SizedBox( height: 100.0, child: Column( children: [ Text('${snapshot.data?.toStringAsFixed(1)}$valueSuffix', style: const TextStyle( fontFamily: 'Fixed', fontWeight: FontWeight.bold, fontSize: 24.0)), Slider( divisions: divisions, min: min, max: max, value: snapshot.data ?? 1.0, onChanged: onChanged, ), ], ), ), ), ), ); } class PositionData { final Duration position; final Duration bufferedPosition; final Duration duration; PositionData(this.position, this.bufferedPosition, this.duration); } class SeekBar extends StatefulWidget { final Duration duration; final Duration position; final Duration bufferedPosition; final ValueChanged? onChanged; final ValueChanged? onChangeEnd; const SeekBar({ Key? key, required this.duration, required this.position, required this.bufferedPosition, this.onChanged, this.onChangeEnd, }) : super(key: key); @override SeekBarState createState() => SeekBarState(); } class SeekBarState extends State { double? _dragValue; late SliderThemeData _sliderThemeData; @override void didChangeDependencies() { super.didChangeDependencies(); _sliderThemeData = SliderTheme.of(context).copyWith( trackHeight: 2.0, ); } @override Widget build(BuildContext context) { return Stack( children: [ SliderTheme( data: _sliderThemeData.copyWith( thumbShape: HiddenThumbComponentShape(), activeTrackColor: Colors.blue.shade100, inactiveTrackColor: Colors.grey.shade300, ), child: ExcludeSemantics( child: Slider( min: 0.0, max: widget.duration.inMilliseconds.toDouble(), value: min(widget.bufferedPosition.inMilliseconds.toDouble(), widget.duration.inMilliseconds.toDouble()), onChanged: (value) { setState(() { _dragValue = value; }); if (widget.onChanged != null) { widget.onChanged!(Duration(milliseconds: value.round())); } }, onChangeEnd: (value) { if (widget.onChangeEnd != null) { widget.onChangeEnd!(Duration(milliseconds: value.round())); } _dragValue = null; }, ), ), ), SliderTheme( data: _sliderThemeData.copyWith( inactiveTrackColor: Colors.transparent, ), child: Slider( min: 0.0, max: widget.duration.inMilliseconds.toDouble(), value: min(_dragValue ?? widget.position.inMilliseconds.toDouble(), widget.duration.inMilliseconds.toDouble()), onChanged: (value) { setState(() { _dragValue = value; }); if (widget.onChanged != null) { widget.onChanged!(Duration(milliseconds: value.round())); } }, onChangeEnd: (value) { if (widget.onChangeEnd != null) { widget.onChangeEnd!(Duration(milliseconds: value.round())); } _dragValue = null; }, ), ), Positioned( right: 16.0, bottom: 0.0, child: Text( RegExp(r'((^0*[1-9]\d*:)?\d{2}:\d{2})\.\d+$') .firstMatch("$_remaining") ?.group(1) ?? '$_remaining', style: Theme.of(context).textTheme.bodySmall), ), ], ); } Duration get _remaining => widget.duration - widget.position; } class HiddenThumbComponentShape extends SliderComponentShape { @override Size getPreferredSize(bool isEnabled, bool isDiscrete) => Size.zero; @override void paint( PaintingContext context, Offset center, { required Animation activationAnimation, required Animation enableAnimation, required bool isDiscrete, required TextPainter labelPainter, required RenderBox parentBox, required SliderThemeData sliderTheme, required TextDirection textDirection, required double value, required double textScaleFactor, required Size sizeWithOverflow, }) {} }