本次更新涵盖多个功能模块的优化与新增: 1. 新增Wi-Fi直连配对方式与协作画布模块 2. 完成设备管理重命名API与前端适配 3. 优化日签卡片空数据保护与UI细节 4. 新增剪贴板工具与每日运势会话 5. 修复应用锁恢复、语音消息录制等已知问题 6. 完善文件传输统计与配对逻辑 7. 更新安卓权限配置与iOS隐私描述 8. 新增自动化测试脚本与文档 9. 清理旧版审计报告与测试文件
334 lines
9.6 KiB
Dart
334 lines
9.6 KiB
Dart
// ============================================================
|
|
// 闲言APP — 语音消息服务
|
|
// 创建时间: 2026-05-12
|
|
// 更新时间: 2026-05-12
|
|
// 作用: 语音消息录音/播放/波形可视化 — record+audioplayers
|
|
// 上次更新: v11.5.0 初始版本
|
|
// ============================================================
|
|
|
|
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:audioplayers/audioplayers.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:record/record.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import 'package:xianyan/core/utils/logger.dart';
|
|
import '../models/voice_message_data.dart';
|
|
|
|
enum VoiceRecorderState {
|
|
idle,
|
|
recording,
|
|
paused,
|
|
stopped,
|
|
}
|
|
|
|
enum VoicePlayerState {
|
|
idle,
|
|
playing,
|
|
paused,
|
|
stopped,
|
|
}
|
|
|
|
class VoiceMessageService {
|
|
VoiceMessageService._();
|
|
static final VoiceMessageService _instance = VoiceMessageService._();
|
|
static VoiceMessageService get instance => _instance;
|
|
|
|
final AudioRecorder _recorder = AudioRecorder();
|
|
final AudioPlayer _player = AudioPlayer();
|
|
final _uuid = const Uuid();
|
|
|
|
VoiceRecorderState _recorderState = VoiceRecorderState.idle;
|
|
VoicePlayerState _playerState = VoicePlayerState.idle;
|
|
|
|
String? _currentRecordingPath;
|
|
DateTime? _recordingStartTime;
|
|
List<double> _currentWaveform = [];
|
|
StreamSubscription<RecordState>? _recordStateSub;
|
|
StreamSubscription<Amplitude>? _amplitudeSub;
|
|
|
|
final _recorderStateController =
|
|
StreamController<VoiceRecorderState>.broadcast();
|
|
final _playerStateController =
|
|
StreamController<VoicePlayerState>.broadcast();
|
|
final _waveformController =
|
|
StreamController<List<double>>.broadcast();
|
|
final _playerPositionController =
|
|
StreamController<Duration>.broadcast();
|
|
final _recordingDurationController =
|
|
StreamController<Duration>.broadcast();
|
|
|
|
Stream<VoiceRecorderState> get recorderState =>
|
|
_recorderStateController.stream;
|
|
Stream<VoicePlayerState> get playerState =>
|
|
_playerStateController.stream;
|
|
Stream<List<double>> get waveform => _waveformController.stream;
|
|
Stream<Duration> get playerPosition =>
|
|
_playerPositionController.stream;
|
|
Stream<Duration> get recordingDuration =>
|
|
_recordingDurationController.stream;
|
|
|
|
VoiceRecorderState get currentRecorderState => _recorderState;
|
|
VoicePlayerState get currentPlayerState => _playerState;
|
|
|
|
// ============================================================
|
|
// 录音
|
|
// ============================================================
|
|
|
|
Future<bool> hasPermission() async {
|
|
try {
|
|
return await _recorder.hasPermission();
|
|
} catch (e) {
|
|
Log.e('VoiceMessageService: Permission check failed: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> startRecording() async {
|
|
if (_recorderState == VoiceRecorderState.recording) return false;
|
|
|
|
try {
|
|
final hasPermission = await _recorder.hasPermission();
|
|
if (!hasPermission) {
|
|
Log.w('VoiceMessageService: Microphone permission denied');
|
|
return false;
|
|
}
|
|
|
|
final directory = await getTemporaryDirectory();
|
|
final voiceDir = Directory('${directory.path}/voice_messages');
|
|
if (!voiceDir.existsSync()) {
|
|
voiceDir.createSync(recursive: true);
|
|
}
|
|
|
|
_currentRecordingPath =
|
|
'${voiceDir.path}/voice_${_uuid.v4()}.m4a';
|
|
_currentWaveform = [];
|
|
_recordingStartTime = DateTime.now();
|
|
|
|
await _recorder.start(
|
|
const RecordConfig(
|
|
numChannels: 1,
|
|
),
|
|
path: _currentRecordingPath!,
|
|
);
|
|
|
|
_setRecorderState(VoiceRecorderState.recording);
|
|
|
|
_amplitudeSub = _recorder
|
|
.onAmplitudeChanged(const Duration(milliseconds: 100))
|
|
.listen((amplitude) {
|
|
final normalized =
|
|
(1.0 - (amplitude.current / -160).clamp(0.0, 1.0))
|
|
.clamp(0.0, 1.0);
|
|
_currentWaveform.add(normalized);
|
|
_waveformController.add(List.unmodifiable(_currentWaveform));
|
|
|
|
if (_recordingStartTime != null) {
|
|
final elapsed = DateTime.now().difference(_recordingStartTime!);
|
|
_recordingDurationController.add(elapsed);
|
|
|
|
if (elapsed.inMilliseconds >= VoiceMessageData.maxDurationMs) {
|
|
stopRecording();
|
|
}
|
|
}
|
|
});
|
|
|
|
Log.i('VoiceMessageService: Recording started');
|
|
return true;
|
|
} catch (e) {
|
|
Log.e('VoiceMessageService: Start recording failed: $e');
|
|
_setRecorderState(VoiceRecorderState.idle);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<VoiceMessageData?> stopRecording() async {
|
|
if (_recorderState != VoiceRecorderState.recording) return null;
|
|
|
|
try {
|
|
final path = await _recorder.stop();
|
|
_amplitudeSub?.cancel();
|
|
_amplitudeSub = null;
|
|
|
|
_setRecorderState(VoiceRecorderState.idle);
|
|
|
|
if (path == null || _recordingStartTime == null) {
|
|
Log.w('VoiceMessageService: No recording data');
|
|
return null;
|
|
}
|
|
|
|
final duration =
|
|
DateTime.now().difference(_recordingStartTime!).inMilliseconds;
|
|
|
|
if (duration < 500) {
|
|
Log.w('VoiceMessageService: Recording too short (${duration}ms)');
|
|
_deleteFile(path);
|
|
return null;
|
|
}
|
|
|
|
final file = File(path);
|
|
if (!await file.exists()) {
|
|
Log.w('VoiceMessageService: Recording file not found');
|
|
return null;
|
|
}
|
|
|
|
final fileSize = await file.length();
|
|
if (fileSize > VoiceMessageData.maxFileSize) {
|
|
Log.w('VoiceMessageService: Recording too large ($fileSize bytes)');
|
|
_deleteFile(path);
|
|
return null;
|
|
}
|
|
|
|
final sampledWaveform = _sampleWaveform(_currentWaveform, 50);
|
|
|
|
Log.i(
|
|
'VoiceMessageService: Recording complete '
|
|
'(${duration}ms, ${(fileSize / 1024).toStringAsFixed(1)}KB)',
|
|
);
|
|
|
|
return VoiceMessageData(
|
|
id: _uuid.v4(),
|
|
messageId: '',
|
|
sessionId: '',
|
|
filePath: path,
|
|
duration: duration,
|
|
waveform: sampledWaveform,
|
|
);
|
|
} catch (e) {
|
|
Log.e('VoiceMessageService: Stop recording failed: $e');
|
|
_setRecorderState(VoiceRecorderState.idle);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<void> cancelRecording() async {
|
|
if (_recorderState != VoiceRecorderState.recording) return;
|
|
|
|
try {
|
|
final path = await _recorder.stop();
|
|
_amplitudeSub?.cancel();
|
|
_amplitudeSub = null;
|
|
_setRecorderState(VoiceRecorderState.idle);
|
|
|
|
if (path != null) {
|
|
_deleteFile(path);
|
|
}
|
|
Log.i('VoiceMessageService: Recording cancelled');
|
|
} catch (e) {
|
|
Log.e('VoiceMessageService: Cancel recording failed: $e');
|
|
_setRecorderState(VoiceRecorderState.idle);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 播放
|
|
// ============================================================
|
|
|
|
Future<void> playVoice(VoiceMessageData voice) async {
|
|
if (_playerState == VoicePlayerState.playing) {
|
|
await stopPlayback();
|
|
}
|
|
|
|
try {
|
|
final file = File(voice.filePath);
|
|
if (!await file.exists()) {
|
|
Log.w('VoiceMessageService: Voice file not found: ${voice.filePath}');
|
|
return;
|
|
}
|
|
|
|
_setPlayerState(VoicePlayerState.playing);
|
|
|
|
_player.onPositionChanged.listen((position) {
|
|
_playerPositionController.add(position);
|
|
});
|
|
|
|
_player.onPlayerComplete.listen((_) {
|
|
_setPlayerState(VoicePlayerState.idle);
|
|
});
|
|
|
|
_player.onLog.listen((msg) {
|
|
Log.d('VoiceMessageService: Player log: $msg');
|
|
});
|
|
|
|
await _player.play(DeviceFileSource(voice.filePath));
|
|
Log.i('VoiceMessageService: Playing voice ${voice.durationText}');
|
|
} catch (e) {
|
|
Log.e('VoiceMessageService: Play voice failed: $e');
|
|
_setPlayerState(VoicePlayerState.idle);
|
|
}
|
|
}
|
|
|
|
Future<void> pausePlayback() async {
|
|
if (_playerState != VoicePlayerState.playing) return;
|
|
await _player.pause();
|
|
_setPlayerState(VoicePlayerState.paused);
|
|
}
|
|
|
|
Future<void> resumePlayback() async {
|
|
if (_playerState != VoicePlayerState.paused) return;
|
|
await _player.resume();
|
|
_setPlayerState(VoicePlayerState.playing);
|
|
}
|
|
|
|
Future<void> stopPlayback() async {
|
|
await _player.stop();
|
|
_setPlayerState(VoicePlayerState.idle);
|
|
}
|
|
|
|
Future<void> seekTo(Duration position) async {
|
|
await _player.seek(position);
|
|
}
|
|
|
|
// ============================================================
|
|
// 工具方法
|
|
// ============================================================
|
|
|
|
List<double> _sampleWaveform(List<double> waveform, int targetCount) {
|
|
if (waveform.length <= targetCount) return List.from(waveform);
|
|
|
|
final step = waveform.length / targetCount;
|
|
final result = <double>[];
|
|
for (int i = 0; i < targetCount; i++) {
|
|
final start = (i * step).floor();
|
|
final end = ((i + 1) * step).floor().clamp(0, waveform.length);
|
|
double sum = 0;
|
|
for (int j = start; j < end; j++) {
|
|
sum += waveform[j];
|
|
}
|
|
result.add(end > start ? sum / (end - start) : 0.0);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void _setRecorderState(VoiceRecorderState state) {
|
|
_recorderState = state;
|
|
_recorderStateController.add(state);
|
|
}
|
|
|
|
void _setPlayerState(VoicePlayerState state) {
|
|
_playerState = state;
|
|
_playerStateController.add(state);
|
|
}
|
|
|
|
void _deleteFile(String path) {
|
|
try {
|
|
final file = File(path);
|
|
if (file.existsSync()) file.deleteSync();
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> dispose() async {
|
|
_amplitudeSub?.cancel();
|
|
_recordStateSub?.cancel();
|
|
await _recorder.dispose();
|
|
await _player.dispose();
|
|
await _recorderStateController.close();
|
|
await _playerStateController.close();
|
|
await _waveformController.close();
|
|
await _playerPositionController.close();
|
|
await _recordingDurationController.close();
|
|
}
|
|
}
|