Flutter 录音播放
项目地址[(gitee.com)](杉木笙/recorder_flutter (gitee.com))
这个案例利用permission_handler
进行权限管理,用provider
来进行状态管理,path_provider
获取存储路径,flutter_sound
来录音并保存和播放,audioplayers
负责获取播放时长.
关于介绍,采用一点点添加功能去描述,有利于慢慢了解每个插件的使用
实现效果
实现效果为点击Start Recording
后开始录音.再次点击结束录音,并添加到列表中,显示名称和时长.
点击Start Playback
后开始播放.再次点击结束播放,或播放结束自动停止.
准备工作
项目开始前先根据插件进行准备工作
- permission_handler的配置
- flutter_sound的配置
在
下添加
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
添加后
现在正式开始项目实现
第一版实现录音和播放功能
创建RecordingProvider
首先创建RecordingProvider
类,以使用provider
利于全局的状态管理。
class RecordingProvider with ChangeNotifier {}
录音和播放采用的是flutter_sound
插件提供的FlutterSoundRecorder
和FlutterSoundPlayer
,使用前需要初始化,初始化时要保证具有权限,权限则通过permission_handler
来获取
class RecordingProvider with ChangeNotifier {
FlutterSoundRecorder? _recorder; //录音控制器
FlutterSoundPlayer? _player; //播放控制器
bool _isRecording = false; //开始录音
bool _isPlaying = false; //开始播放
bool get isRecording => _isRecording;
bool get isPlaying => _isPlaying;
RecordingProvider() {
_recorder = FlutterSoundRecorder();
_player = FlutterSoundPlayer();
_initializeRecorder();
}
}
Future<void> _initializeRecorder() async {
try {
var status = await Permission.microphone.request();
if (status != PermissionStatus.granted) {
throw RecordingPermissionException('Microphone permission not granted');
}
await _recorder!.openRecorder();
await _player!.openPlayer();
} catch (e) {
print('Failed to initialize recorder/player: $e');
}
}
这里有一个权限问题,将在片尾解决
初始化后则需要考虑怎么去使用_recorder
和_player
,继续在RecordingProvider
中添加新方法,
开始录音
_recorder
录音需要一个存储位置,而这个位置,我们则通过path_provider
来获取获取临时目录getTemporaryDirectory()
Future<String> _getFilePath() async {
Directory tempDir = await getTemporaryDirectory(); //获取获取临时目录
String tempPath = '${tempDir.path}/audio_example.aac';
return tempPath;
}
继续在RecordingProvider
中声明变量String? _filePath
用来存储录音文件的存放位置
String? _filePath; //文件路径
Future<void> _startRecording() async {
try {
var status = await Permission.microphone.request();//获取权限
if (status != PermissionStatus.granted) {//如果没有权限则不进行录音
throw RecordingPermissionException('Microphone permission not granted');
}
_filePath = await _getFilePath();//获取路径
await _recorder!.startRecorder(//开始录音
toFile: _filePath,
codec: Codec.aacADTS,
);
_isRecording = true;//设置标识-正在录音
notifyListeners();
} catch (e) {
print('Failed to start recording: $e');
}
}
停止录音
Future<void> stopRecording() async {
try {
await _recorder!.stopRecorder();
_isRecording = false;
notifyListeners();
print('Recording saved to: $_filePath');
} catch (e) {
print('Failed to stop recording: $e');
}
}
开始播放
Future<void> startPlayback() async {
try {
await _player!.startPlayer(
fromURI: _filePath,
codec: Codec.aacADTS,
);
_isPlaying = true;
notifyListeners();
} catch (e) {
print('Failed to start playback: $e');
}
}
停止播放
Future<void> stopPlayback() async {
try {
await _player!.stopPlayer();
_isPlaying = false;
notifyListeners();
} catch (e) {
print('Failed to stop playback: $e');
}
}
清除音频会话
void dispose() {
_closeAudioSessions();
super.dispose();
}
Future<void> _closeAudioSessions() async {
try {
if (_isPlaying) {
await _player!.stopPlayer();
}
await _player!.closePlayer();
} catch (e) {
print('Failed to close player: $e');
}
try {
await _recorder!.closeRecorder();
} catch (e) {
print('Failed to close recorder: $e');
}
await clearTemporaryDirectory();
}
怎么去使用这个RecordingProvider
在main方法中的runApp里用ChangeNotifierProvider
嵌套MyApp
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => RecordingProvider(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: RecordingScreenProvider(),
);
}
}
搭建主界面UI RecordingScreenProvider
在主界面中使用RecordingProvider
,通过在build函数里新建RecordingProvider对象
final recordingProvider = Provider.of<RecordingProvider>(context);
就可以通过
recordingProvider
来调用方法,如:await recordingProvider.stopRecording();
以下是目前的全部UI
class RecordingScreenProvider extends StatelessWidget {
@override
Widget build(BuildContext context) {
final recordingProvider = Provider.of<RecordingProvider>(context);
return SafeArea(
child: Scaffold(
body: SingleChildScrollView(
child: Column(
children: [
ElevatedButton(
onPressed: () async {
if (recordingProvider.isRecording) {
await recordingProvider.stopRecording();
} else {
await recordingProvider.startRecording();
}
},
child: Text(recordingProvider.isRecording
? 'Stop Recording'
: 'Start Recording'),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () async {
if (recordingProvider.isPlaying) {
await recordingProvider.stopPlayback();
} else {
// 这里播放最近录音的例子
if (recordingProvider.recordings.isNotEmpty) {
await recordingProvider.startPlayback(
);
}
}
},
child: Text(recordingProvider.isPlaying
? 'Stop Playback'
: 'Start Playback'),
),
],
),
),
),
);
}
}
至此完成了第一版的录音和播放功能,但是距离一个完善的录音播放组件,还差了很多,于是有了第二,三,四...版
第二版获取录音的时长和获取文件数量
因为flutter_sound
的获取时长在9.7.2
暂时用不了,所以这里改为使用audioplayers
插件去获取录音时长.
在RecordingProvider
中声明变量Duration? _recordingDuration
用来存储录音文件的时长
Duration? get recordingDuration => _recordingDuration;
并对外提供
在RecordingProvider
中声明变量int _fileCount
用来存储录音文件的数量
int get fileCount => _fileCount;
并对外提供
在RecordingProvider
中声明变量final AudioPlayer _audioPlayer = AudioPlayer()
用来获取时长
class RecordingProvider with ChangeNotifier {
...省略...
final AudioPlayer _audioPlayer = AudioPlayer(); //用来获取时长的控制器
Duration? _recordingDuration; //播放时长
Duration? get recordingDuration => _recordingDuration;
int _fileCount = 0; //文件数量
int get fileCount => _fileCount;
...省略...
}
新建方法_updateFileCount()
去获取文件数量
Future<void> _updateFileCount() async {
try {
// 获取临时目录的路径
Directory tempdir = await getTemporaryDirectory();
// 检查临时目录是否存在
if (tempdir.existsSync()) {
// 列出临时目录中的所有文件系统实体(文件和子目录)
List<FileSystemEntity> files = tempdir.listSync();
// 过滤出所有文件并计算其数量
_fileCount = files.where((file) => file is File).length;
// 通知所有监听器,数据已经更新
notifyListeners();
}
} catch (e) {
// 捕获并打印可能出现的异常
print('Failed to get file count in temporary directory: $e');
}
}
新建方法getRecordingDuration()
去获取时长
Future<void> getRecordingDuration() async {
if (_filePath != null) {
try {
// 设置音频播放器的源为录音文件路径
await _audioPlayer.setSourceUrl(_filePath!);
print(_filePath);
// 获取录音文件的时长
_recordingDuration = await _audioPlayer.getDuration();
// 通知所有监听器,数据已经更新
notifyListeners();
print('Recording duration: $_recordingDuration');
} catch (e) {
// 捕获并打印可能出现的异常
print('Failed to get recording duration: $e');
}
}
}
getRecordingDuration()
和_updateFileCount()
的调用位置为stopRecording()
中
Future<void> stopRecording() async {
try {
await _recorder!.stopRecorder();
_isRecording = false;
await getRecordingDuration();//在这里
notifyListeners();
print('Recording saved to: $_filePath');
await _updateFileCount();
} catch (e) {
print('Failed to stop recording: $e');
}
}
_closeAudioSessions()
改动,加上对_audioPlayer
的销毁
Future<void> _closeAudioSessions() async {
try {
if (_isPlaying) {
await _player!.stopPlayer();
}
await _player!.closePlayer();
} catch (e) {
print('Failed to close player: $e');
}
try {
await _recorder!.closeRecorder();
} catch (e) {
print('Failed to close recorder: $e');
}
await _audioPlayer.dispose(); //新加
await clearTemporaryDirectory();
}
这样在RecordingScreenProvider
中通过就可以获取到当前录音文件的时长和数量
if (recordingProvider.recordingDuration != null)
Text(
'Recording Duration: ${recordingProvider.recordingDuration!.inSeconds} seconds'),
Text('File length :${recordingProvider.fileCount}'),
至此,可以获取到录音文件的时长和数量
第三版存储多个录音文件并且存储每一个录音文件的信息以及播放每个录音文件
这一版则是解决只能同时存储一个录音文件的问题,并且为了更好管理录音文件信息而存在. 解决的最好办法是创建一个类来存储录音信息
///存储录音的信息
class Recording {
final String filePath;
final Duration duration;
Recording({required this.duration, required this.filePath});
}
然后在RecordingProvider
中
class RecordingProvider with ChangeNotifier {
...省略...
List<Recording> _recordings = []; //录音列表
List<Recording> get recordings => _recordings;
...省略...
}
关于在哪里去给录音列表添加数据,这里最好的地方为停止录音stopRecording
;
Future<void> stopRecording() async {
try {
await _recorder!.stopRecorder();
_isRecording = false;
notifyListeners();
await getRecordingDuration();
if (_filePath != null && _recordingDuration != null) {
_recordings.add(
Recording(duration: _recordingDuration!, filePath: _filePath!));
}//在这里
await _updateFileCount();
print('Recording saved to: $_filePath');
} catch (e) {
print('Failed to stop recording: $e');
}
}
但目前_filePath
的名称全部都是默认,所以对getFilePath()
进行改动
Future<String> getFilePath() async {
Directory tempDir = await getTemporaryDirectory();
String tempPath =
'${tempDir.path}/audio_example_${DateTime.now().millisecondsSinceEpoch}.aac';
return tempPath;
}
播放每个录音文件的解决方法也应知而来,根据不同路径去播放即可
///开始播放
Future<void> startPlayback(String filePath) async {
try {
await _player!.startPlayer(
fromURI: filePath,
codec: Codec.aacADTS,
);
_isPlaying = true;
notifyListeners();
} catch (e) {
print('Failed to start playback: $e');
}
}
这样在RecordingScreenProvider
中通过ListView.builder就可以获取所有音频文件的播放
ListView.builder(
physics: NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: recordingProvider.recordings.length,
itemBuilder: (BuildContext context, int index) {
Recording recording = recordingProvider.recordings[index];
return ListTile(
title: Text('Recording${index + 1}'),
subtitle:
Text('Duration:${recording.duration.inSeconds}seconds'),
onTap: () async {
if (recordingProvider.isPlaying) {
await recordingProvider.stopPlayback();
}
await recordingProvider.startPlayback(recording.filePath);
},
);
},
),
第四版播放自动结束和清除所有文件
关于自动结束,则需要对播放器初始化时添加一个监听,当进度和时长相同时自动结束播放
Future<void> _initializeRecorder() async {
.....省略
await _player!.openPlayer();
_player!.setSubscriptionDuration(Duration(milliseconds: 10));
_player!.onProgress!.listen((e) {
if (e != null && e.position == e.duration) {
_stopPlaybackAutomatically();
}
});
.....省略
}
void _stopPlaybackAutomatically() {
_isPlaying = false;
print('object');
notifyListeners();
}
清除所有文件
Future<void> clearTemporaryDirectory() async {
try {
Directory tempDir = await getTemporaryDirectory();
if (tempDir.existsSync()) {
tempDir.deleteSync(recursive: true);
}
_recordings.clear();
// await getFileCountInTempDir();
await _updateFileCount();
notifyListeners();
print('Temporary directory cleared');
} catch (e) {
print('Failed to clear temporary directory: $e');
}
}
对权限更好地管理
以上功能方面解决,现在还差一个权限问题,当权限请求失败或者拒绝时,因为只在init里调用,所以失败后不会调用第二次,但在其他地方调用,又会导致重复初始化,导致出错. 解决方法为,给初始化加上标识符,在每次录音时调用,如果标识符为false,则请求权限,同时也进一步优化权限的请求提示,改为弹窗
RecordingProvider新加声明变量
bool _recorderInitialized = false; //是否初始化
bool _playerInitialized = false; //是否初始化
void _showPermissionDeniedDialog(BuildContext context) {
// 显示一个对话框,告知用户权限被拒绝
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('权限被拒绝'),
content: Text('录音功能需要麦克风权限,请在设置中授予权限。'),
actions: <Widget>[
TextButton(
child: Text('取消'),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: Text('去设置'),
onPressed: () {
openAppSettings();
Navigator.of(context).pop();
},
),
],
),
);
}
void _showPermissionPermanentlyDeniedDialog(BuildContext context) {
// 显示一个对话框,告知用户权限被永久拒绝
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('权限被永久拒绝'),
content: Text('录音功能需要麦克风权限,请在设置中手动授予权限。'),
actions: <Widget>[
TextButton(
child: Text('取消'),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: Text('去设置'),
onPressed: () {
openAppSettings();
Navigator.of(context).pop();
},
),
],
),
);
}
一下是更新后的权限请求
///请求权限
Future<bool> requestMicrophonePermission(BuildContext context) async {
var status = await Permission.microphone.request();
if (status != PermissionStatus.granted) {
if (status == PermissionStatus.denied) {
_showPermissionDeniedDialog(context);
} else if (status == PermissionStatus.permanentlyDenied) {
_showPermissionPermanentlyDeniedDialog(context);
}
return false;
}
return true;
}
在录音时,获取权限
///开始录音
Future<void> startRecording(BuildContext context) async {
if (await requestMicrophonePermission(context)) {
try {
await _initializeRecorder();
_filePath = await getFilePath();
await _recorder!.startRecorder(
toFile: _filePath,
codec: Codec.aacADTS,
);
notifyListeners();
_isRecording = true;
} catch (e) {
print('Failed to start recording: $e');
}
}
}
init方法也添加标识符
Future<void> _initializeRecorder() async {
if (_recorderInitialized && _playerInitialized) {
return;
}
try {
var status = await Permission.microphone.request();
if (status != PermissionStatus.granted) {
throw RecordingPermissionException('Microphone permission not granted');
}
if (!_recorderInitialized) { //标识符
await _recorder!.openRecorder();
_recorderInitialized = true;
}
if (!_playerInitialized) { //标识符
await _player!.openPlayer();
_player!.setSubscriptionDuration(Duration(milliseconds: 10));
_player!.onProgress!.listen((e) {
if (e != null && e.position == e.duration) {
_stopPlaybackAutomatically();
}
});
_playerInitialized = true;
}
} catch (e) {
print('Failed to initialize recorder/player: $e');
}
}
转载自:https://juejin.cn/post/7393548766878384167