This is a basic implementation for a replacement of the stock audio player widget in FlutterFlow using the amazing Just Audio. In time I hope to have the time to extend the implementation of the other features. [Image] This widget works ONLY with Null Safety turned off • I needed to set the icon color white within the code. If you remove the relevant code they go back to the original black color
Parameters:
[Screenshot 2022-07-25 at 5.51.03 PM.png]
Dependencies:[Screenshot 2022-07-25 at 5.52.24 PM.png]
import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart';
import 'package:rxdart/rxdart.dart';
class FFAudioPlayer extends StatefulWidget {
const FFAudioPlayer({
Key key,
this.width,
this.height,
this.audioURL,
this.backgroundColor,
this.autoplay,
}) : super(key: key);
final double width;
final double height;
final String audioURL;
final Color backgroundColor;
final bool autoplay;
@override
_FFAudioPlayerState createState() => _FFAudioPlayerState();
}
class _FFAudioPlayerState extends State {
final AudioPlayer _player = AudioPlayer();
@override
void initState() {
super.initState();
_init();
}
Future _init() async {
// Inform the operating system of our app's audio attributes etc.
// We pick a reasonable default for an app that plays speech.
final session = await AudioSession.instance;
await session.configure(AudioSessionConfiguration.speech());
// Listen to errors during playback.
_player.playbackEventStream.listen((event) {},
onError: (Object e, StackTrace stackTrace) {
print('A stream error occurred: $e');
});
// Try to load audio from a source and catch any errors.
try {
await _player.setAudioSource(
AudioSource.uri(
Uri.parse(widget.audioURL),
//tag: MediaItem(
// Specify a unique ID for each media item:
// id: '1',
// Metadata to display in the notification:
// album: widget.albumName,
// title: widget.titleName,
// artUri: Uri.parse(widget.imageURL),
// ),
),
);
if (widget.autoplay) {
_player.play();
}
} catch (e) {
print("Error loading audio source: $e");
}
}
@override
void dispose() {
// Release decoders and buffers back to the operating system making them
// available for other apps to use.
_player.dispose();
super.dispose();
}
/// Collects the data useful for displaying in a seek bar, using a handy
/// feature of rx_dart to combine the 3 streams of interest into one.
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 MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
backgroundColor: widget.backgroundColor,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Display play/pause button and volume/speed sliders.
ControlButtons(_player),
// Display seek bar. Using StreamBuilder, this widget rebuilds
// each time the position, buffered position or duration changes.
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: _player.seek,
);
},
),
],
),
),
),
);
}
}
/// Displays the play/pause button and volume/speed sliders.
class ControlButtons extends StatelessWidget {
final AudioPlayer player;
ControlButtons(this.player);
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Opens volume slider dialog
IconButton(
icon: Icon(Icons.volume_up, color: Colors.white),
onPressed: () {
showSliderDialog(
context: context,
title: "Adjust volume",
divisions: 10,
min: 0.0,
max: 1.0,
stream: player.volumeStream,
onChanged: player.setVolume,
);
},
),
/// This StreamBuilder rebuilds whenever the player state changes, which
/// includes the playing/paused state and also the
/// loading/buffering/ready state. Depending on the state we show the
/// appropriate button or loading indicator.
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: EdgeInsets.all(8.0),
width: 64.0,
height: 64.0,
child: CircularProgressIndicator(),
);
} else if (playing != true) {
return IconButton(
icon: Icon(Icons.play_arrow, color: Colors.white),
iconSize: 64.0,
onPressed: player.play,
);
} else if (processingState != ProcessingState.completed) {
return IconButton(
icon: Icon(Icons.pause, color: Colors.white),
iconSize: 64.0,
onPressed: player.pause,
);
} else {
return IconButton(
icon: Icon(Icons.replay, color: Colors.white),
iconSize: 64.0,
onPressed: () => player.seek(Duration.zero),
);
}
},
),
// Opens speed slider dialog
StreamBuilder(
stream: player.speedStream,
builder: (context, snapshot) => IconButton(
icon: Text("${snapshot.data?.toStringAsFixed(1)}x",
style: TextStyle(
fontWeight: FontWeight.bold, color: Colors.white)),
onPressed: () {
showSliderDialog(
context: context,
title: "Adjust speed",
divisions: 10,
min: 0.5,
max: 1.5,
stream: player.speedStream,
onChanged: player.setSpeed,
);
},
),
),
],
);
}
}
class SeekBar extends StatefulWidget {
final Duration duration;
final Duration position;
final Duration bufferedPosition;
final ValueChanged onChanged;
final ValueChanged onChangeEnd;
SeekBar({
this.duration,
this.position,
this.bufferedPosition,
this.onChanged,
this.onChangeEnd,
});
@override
_SeekBarState createState() => _SeekBarState();
}
class _SeekBarState extends State {
double _dragValue;
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(
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.caption),
),
],
);
}
Duration get _remaining => widget.duration - widget.position;
}
class PositionData {
final Duration position;
final Duration bufferedPosition;
final Duration duration;
PositionData(this.position, this.bufferedPosition, this.duration);
}
void showSliderDialog({
BuildContext context,
String title,
int divisions,
double min,
double max,
String valueSuffix = '',
Stream stream,
ValueChanged onChanged,
}) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title, textAlign: TextAlign.center),
content: StreamBuilder(
stream: stream,
builder: (context, snapshot) => Container(
height: 100.0,
child: Column(
children: [
Text('${snapshot.data?.toStringAsFixed(1)}$valueSuffix',
style: TextStyle(
fontFamily: 'Fixed',
fontWeight: FontWeight.bold,
fontSize: 24.0)),
Slider(
divisions: divisions,
min: min,
max: max,
value: snapshot.data ?? 1.0,
onChanged: onChanged,
),
],
),
),
),
),
);
}
Please note: currently, since it is not possible to edit the info.plist file using FlutterFlow, if you use the automated deployment system in FF you will receive this error from Apple ITMS-90683: Missing Purpose String in Info.plist - Your app‘s code references one or more APIs that access sensitive user data. The app‘s Info.plist file should contain a NSMicrophoneUsageDescription key with a user-facing purpose string explaining clearly and completely why your app needs the data. If you're using external libraries or SDKs, they may reference APIs that require a purpose string. While your app might not use these APIs, a purpose string is still required. For details, visit: https://developer.apple.com/documentation/uikit/protecting_the_user_s_privacy/requesting_access_to_protected_resources therefore you are required to download the source code - or deploying to Github in order to modify the info.plist file before deployment. With the latest version of Flutterflow you can edit the info.plist directly from FF control panel.