/* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD SPDX-License-Identifier: MIT */ import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart'; import 'package:ffmpeg_kit_flutter_new/return_code.dart'; import 'package:flutter/cupertino.dart'; import 'package:just_audio/just_audio.dart'; import 'package:music_feature_analyzer/music_feature_analyzer.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; class MusicInfo { int duration; //translated comment String filePath; String? title; String? artist; String? album; String? artwork; String? lyrics; MusicInfo( this.duration, this.filePath, { this.title, this.artist, this.album, this.artwork, this.lyrics, }); ///loadmusicfileBytedata Future loadData() async { try { final file = File(filePath); if (!await file.exists()) { throw FileSystemException("音乐文件不存在", file.path); } return await file.readAsBytes(); } catch (e) { throw Exception("加载音乐文件失败: $e"); } } String get mimeType { final ext = path.extension(filePath).toLowerCase(); switch (ext) { case '.wav': return 'audio/wav'; case '.m4a': return 'audio/mp4'; case '.flac': return 'audio/flac'; case '.mp3': default: return 'audio/mpeg'; } } Future> getProgressData({int targetSampleCount = 100}) async { if (targetSampleCount <= 0) { throw ArgumentError("目标采样点数必须大于0: $targetSampleCount"); } final audioFile = File(filePath); if (!await audioFile.exists()) { throw FileSystemException("音频文件不存在", filePath); } final tempDir = await getTemporaryDirectory(); final pcmFileName = "audio_waveform_${DateTime.now().microsecondsSinceEpoch}.pcm"; final pcmFilePath = "${tempDir.path}/$pcmFileName"; try { final command = '-loglevel error -hide_banner -i "$filePath" -f s16le -ac 1 -ar 16000 -vn "$pcmFilePath"'; final session = await FFmpegKit.execute(command); final returnCode = await session.getReturnCode(); if (!ReturnCode.isSuccess(returnCode)) { final error = await session.getAllLogsAsString(); throw Exception("FFmpeg转换失败: 码=$returnCode, 错误=$error"); } //Process PCM file, calculate chunk volume (RMS) final volumeData = await _processPcmFileForVolume( pcmFilePath, targetSampleCount, ); return volumeData; } catch (e) { throw Exception("获取音频波动数据失败: $e"); } finally { try { final pcmFile = File(pcmFilePath); if (await pcmFile.exists()) { await pcmFile.delete(); } } catch (e) { //onlyPrintdeletefaillog,NotinterruptMainStreamProcess / Thread } } } ///Process PCM file, calculate chunk volume (RMS/decibel) Future> _processPcmFileForVolume( String pcmPath, int targetSampleCount, ) async { final file = File(pcmPath); final bytes = await file.readAsBytes(); const sampleSize = 2; //16-bit PCM = 2 bytes/sample final totalSamples = bytes.length ~/ sampleSize; //Boundary: return all zeros when no samples if (totalSamples == 0) { return List.filled(targetSampleCount, 0.0); } final byteData = ByteData.view(bytes.buffer); final volumeValues = []; //calculateeachBlockShouldContainssampleCount / Number final samplesPerBlock = (totalSamples / targetSampleCount).ceil(); //Calculate volume in chunks (RMS) for (int blockIndex = 0; blockIndex < targetSampleCount; blockIndex++) { //calculatecurrentBlocksamplerange final startSample = blockIndex * samplesPerBlock; final endSample = ((blockIndex + 1) * samplesPerBlock).clamp( 0, totalSamples, ); final blockSampleCount = endSample - startSample; //Boundary: chunk with no samples, volume is 0 if (blockSampleCount <= 0) { volumeValues.add(0.0); continue; } //Calculate RMS of current chunk: reflects average volume in this period double sumOfSquares = 0.0; for (int i = startSample; i < endSample; i++) { //Read 16-bit little-endian PCM sample (range: -32768 ~ 32767) final int16Value = byteData.getInt16(i * sampleSize, Endian.little); //calculateSquareand sumOfSquares += (int16Value * int16Value).toDouble(); } //RMS = sqrt(sum of squares / sample count) final rms = sqrt(sumOfSquares / blockSampleCount); //Normalize to 0~1 range (32767 is max value for 16-bit signed integer) final normalizedRms = (rms / 32767.0).clamp(0.0, 1.0); //Optional: Convert to decibels (dB) (closer to human perception, range: 0~1) //Decibel formula: 20 * log10(RMS / 32767), but handle 0 to avoid log(0) // final db = normalizedRms > 0 ? 20 * log10(normalizedRms) : -100; //final normalizedDb = (db + 100) / 100; // Map to 0~1 // volumeValues.add(normalizedDb.clamp(0.0, 1.0)); //Use normalized RMS directly (simpler, linear volume representation) volumeValues.add(normalizedRms); } return volumeValues; } } ///Custom byte stream audio source (adapt just_audio) class BytesAudioSource extends StreamAudioSource { final Uint8List bytes; final String contentType; final String? id; BytesAudioSource(this.bytes, {this.contentType = 'audio/mpeg', this.id}); @override Future request([int? start, int? end]) async { start ??= 0; end ??= bytes.length; return StreamAudioResponse( sourceLength: bytes.length, contentLength: end - start, offset: start, stream: Stream.value(bytes.sublist(start, end)), contentType: contentType, ); } } class MusicUtil { //Singletonmode MusicUtil._internal() { _initAnalyzer(); _setupPlayerListener(); //beforeinitlistener,avoid } static final MusicUtil shared = MusicUtil._internal(); //Core player instance (just_audio) final AudioPlayer _audioPlayer = AudioPlayer(); //playcompletecallback void Function()? _playbackCompletion; //musicduration(Second(s)) double _musicDuration = 0.0; //currentplayprogress(Second(s)) double _currentPosition = 0.0; //currentplaymusicinfo MusicInfo? _currentMusicInfo; ///initmusicAnalyzer Future _initAnalyzer() async { try { await MusicFeatureAnalyzer.initialize(); } catch (e) { } } ///configplayerlistener(System1Managerstate) void _setupPlayerListener() { _audioPlayer.setVolume(1.0); //playerstatelisten(Containsplaystateandhandlestate) _audioPlayer.playerStateStream.listen((PlayerState state) { //Playback completion check (handle completed status) if (state.processingState == ProcessingState.completed) { _currentPosition = 0.0; //resetprogress //SingleloopThenreplay,elseexecutecompletecallback if (_audioPlayer.loopMode == LoopMode.one && _currentMusicInfo != null) { _audioPlayer.seek(Duration.zero); _audioPlayer.play(); } else { _playbackCompletion?.call(); _playbackCompletion = null; } } //stopstateresetprogress if (state.processingState == ProcessingState.idle) { _currentPosition = 0.0; } }); //durationChangelisten _audioPlayer.durationStream.listen((Duration? duration) { if (duration != null) { _musicDuration = duration.inMilliseconds / 1000.0; } }); //playprogresslisten _audioPlayer.positionStream.listen((Duration position) { _currentPosition = position.inMilliseconds / 1000.0; //preventprogressExceeds totalduration if (_currentPosition > _musicDuration && _musicDuration > 0) { _currentPosition = _musicDuration; } }); //errorlisten _audioPlayer.errorStream.listen((PlayerException? e) { if (e != null) { } }); } ///playBytedataFormat / Formmusic Future playMusicData( Uint8List data, { String contentType = 'audio/mpeg', }) async { try { await stopMusic(); _musicDuration = 0.0; _currentPosition = 0.0; //usecustomByteStreamaudioSourceload final audioSource = BytesAudioSource(data, contentType: contentType); await _audioPlayer.setAudioSource(audioSource); await _audioPlayer.play(); } on PlayerException catch (e) { throw Exception("播放失败: ${e.message}"); } catch (e) { throw Exception("播放失败: $e"); } } ///playSinglemusic(playcompleteafterexecutecallback) Future playMusicOnce(MusicInfo musicInfo, Function() completion) async { _playbackCompletion = completion; await playMusic(musicInfo, isLoop: false); //playcloseloop } ///PlayOnlineMusic1Time(s),RepeatCallThenStopFrontFrom beginningPlay Future playUrlMusicOnce(String? url, {Function()? completion}) async { if (url == null) { return; } try { // First / PreviouslyStopFrontPlay await stopMusic(); // SetComplete / DoneCallback _playbackCompletion = completion; // SetIsNotLoop _audioPlayer.setLoopMode(LoopMode.off); // DirectlyUse setUrl Load URL await _audioPlayer.setUrl(url); await _audioPlayer.play(); } on PlayerException catch (e) { throw Exception("播放失败: ${e.message}"); } catch (e) { throw Exception("播放失败: $e"); } } ///coreplaymethod(supportloop) Future playMusic(MusicInfo? musicInfo, {bool isLoop = false}) async { if (musicInfo == null) { return; } //Recordcurrentplaymusicinfo _currentMusicInfo = musicInfo; //Set loop mode (just_audio LoopMode) _audioPlayer.setLoopMode(isLoop ? LoopMode.one : LoopMode.off); try { final data = await musicInfo.loadData(); final contentType = musicInfo.mimeType; await playMusicData(data, contentType: contentType); } on PlayerException catch (e) { throw Exception("播放失败: ${e.message}"); } catch (e) { throw Exception("播放失败: $e"); } } ///stopplay Future stopMusic() async { await _audioPlayer.stop(); await _audioPlayer.seek(Duration.zero); //resetprogressto _currentPosition = 0.0; _playbackCompletion = null; _currentMusicInfo = null; } ///pauseplay Future pauseMusic() async { if (_audioPlayer.playing) { await _audioPlayer.pause(); } } ///resumeplay Future resumeMusic() async { if (!_audioPlayer.playing && _currentMusicInfo != null) { await _audioPlayer.play(); } } ///setloopplaystate void setMusicLoop(bool isLoop) { final loopMode = isLoop ? LoopMode.one : LoopMode.off; _audioPlayer.setLoopMode(loopMode); } ///jumpplayprogress Future seekTo(double seconds) async { if (seconds < 0 || seconds > _musicDuration) { return; } await _audioPlayer.seek(Duration(seconds: seconds.toInt())); _currentPosition = seconds; } ///Set volume (0.0 ~ 1.0) Future setVolume(double volume) async { if (volume < 0.0 || volume > 1.0) { return; } await _audioPlayer.setVolume(volume); } ///setplayspeed Future setPlaybackSpeed(double speed) async { if (speed <= 0) { return; } await _audioPlayer.setSpeed(speed); } ///Getcurrentplayprogress(Second(s)) double getCurrentPosition() => _currentPosition; ///GetmusicTotalduration(Second(s)) double getMusicDuration() => _musicDuration; ///Getcurrentloopstate bool getIsLoop() => _audioPlayer.loopMode == LoopMode.one; ///GetcurrentplayerwhetherCurrentlyinplay bool isPlaying() => _audioPlayer.playing; ///releaseplayerAsset / ResourceSource(pagedisposewhenCall) Future dispose() async { await stopMusic(); await _audioPlayer.dispose(); _currentMusicInfo = null; _playbackCompletion = null; } ///improveaftermusicinfoparse(With / CarryVerboselog+cacheverify) Future getMusicInfoAsync(String urlString) async { const tag = "MusicUtil/getMusicInfoAsync"; try { //1. Parse URL final uri = Uri.parse(urlString); if (!uri.isAbsolute) { return null; } //2. Generate cache file info final extension = path.extension(uri.path); if (extension.isEmpty || ![ '.mp3', '.wav', '.m4a', '.flac', ].contains(extension.toLowerCase())) { return null; } final fileName = '${uri.hashCode.toRadixString(16)}$extension'; //useDocumentDirectoryAnd / WhileNotisWhenwhenDirectory,avoidSystemautocleancachefile final cacheDir = await getApplicationDocumentsDirectory(); final musicCacheDir = Directory(path.join(cacheDir.path, 'music_cache')); if (!await musicCacheDir.exists()) { await musicCacheDir.create(recursive: true); } final filePath = path.join(musicCacheDir.path, fileName); //3. Check cache file final file = File(filePath); if (await file.exists()) { final stat = await file.stat(); final fileSizeKB = stat.size / 1024; if (fileSizeKB < 10) { await file.delete(); } else { return await _extractMetadataFromFile(filePath, uri); } } //4. Download file await _downloadFile(uri, file); final stat = await file.stat(); final fileSizeKB = stat.size / 1024; if (fileSizeKB < 10) { return null; } //5. Extract metadata return await _extractMetadataFromFile(filePath, uri); } catch (e, stackTrace) { return null; } } ///DownLoadfiletoLocalcache Future _downloadFile(Uri uri, File file) async { const tag = "MusicUtil/_downloadFile"; final httpClient = HttpClient(); try { final request = await httpClient.getUrl(uri); final response = await request.close(); if (response.statusCode != HttpStatus.ok) { throw Exception("下载失败:状态码 ${response.statusCode},URL=$uri"); } await response.pipe(file.openWrite()); } catch (e) { rethrow; } finally { httpClient.close(); } } ///fromfileExtractmusicMetadata / Metadata Future _extractMetadataFromFile(String filePath, Uri uri) async { const tag = "MusicUtil/_extractMetadataFromFile"; try { final song = await MusicFeatureAnalyzer.metadata(filePath); if (song == null) { return null; } final durationSec = song.duration ~/ 1000; //convertas return MusicInfo( durationSec, filePath, title: song.title, artist: song.artist, album: song.album, artwork: song.albumArt, ); } catch (e, stackTrace) { return null; } } ///cleanExpiredmusiccache(optional:Periodicallyclean) Future clearExpiredCache({ Duration maxAge = const Duration(days: 7), }) async { const tag = "MusicUtil/clearExpiredCache"; try { final cacheDir = await getTemporaryDirectory(); final files = await cacheDir.list().toList(); final now = DateTime.now(); int deletedCount = 0; for (final file in files) { if (file is File) { final extension = path.extension(file.path).toLowerCase(); if (['.mp3', '.wav', '.m4a', '.flac'].contains(extension)) { final stat = await file.stat(); final fileAge = now.difference(stat.modified); if (fileAge > maxAge) { await file.delete(); deletedCount++; } } } } } catch (e) { } } }