miniaudio_player 1.0.1
miniaudio_player: ^1.0.1 copied to clipboard
Ultra-low-resource cross-platform audio player using miniaudio. Equalizer, pitch and rate are supported.
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:miniaudio_player/miniaudio_player.dart';
import 'package:path_provider/path_provider.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
MiniaudioPlayer.config(
defaultBufferSize: 0,
logLevel: MiniaudioLogLevel.verbose,
defaultAudioDevice: AudioDevice.auto,
);
runApp(const MiniaudioPlayerExampleApp());
}
class AudioTrack {
final String id;
final String title;
final String? assetPath;
final String? filePath;
final bool isGenerated;
const AudioTrack({
required this.id,
required this.title,
this.assetPath,
this.filePath,
this.isGenerated = false,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AudioTrack && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}
class MiniaudioPlayerExampleApp extends StatelessWidget {
const MiniaudioPlayerExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Miniaudio Player Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const PlayerScreen(),
);
}
}
class PlayerScreen extends StatefulWidget {
const PlayerScreen({super.key});
@override
State<PlayerScreen> createState() => _PlayerScreenState();
}
class _PlayerScreenState extends State<PlayerScreen> {
final MiniaudioPlayer _player = MiniaudioPlayer();
List<AudioTrack> _availableTracks = [];
AudioTrack? _selectedTrack;
String? _samplePath;
bool _isReady = false;
bool _isLoadingTrack = false;
Equalizer _currentEqualizer = Equalizer.flat;
double _volume = 1.0;
double _rate = 1.0;
double _pitch = 1.0;
double? _dragPositionMs;
List<AudioDevice> _audioDevices = [AudioDevice.auto];
StreamSubscription<AudioDevice>? _deviceSub;
@override
void initState() {
super.initState();
_prepareAudioTracks();
_refreshAudioDevices();
_deviceSub = _player.stream.audioDevice.listen((_) {
_refreshAudioDevices();
});
}
Future<void> _refreshAudioDevices() async {
try {
final devs = await MiniaudioPlayer.getAudioDevices();
if (mounted) {
setState(() {
_audioDevices = devs;
});
}
} catch (_) {}
}
bool _isAudioFile(String path) {
final lower = path.toLowerCase();
return miniAudioPlayerSupportedExtensions.any(
(ext) => lower.endsWith('.$ext'),
);
}
Future<void> _prepareAudioTracks() async {
final List<AudioTrack> tracks = [];
final Set<String> seenFilenames = {};
void addTrack({
required String id,
required String title,
String? assetPath,
String? filePath,
bool isGenerated = false,
}) {
final filename = (assetPath ?? filePath ?? id)
.split(RegExp(r'[/\\]'))
.last;
if (seenFilenames.contains(filename)) return;
seenFilenames.add(filename);
tracks.add(
AudioTrack(
id: id,
title: title,
assetPath: assetPath,
filePath: filePath,
isGenerated: isGenerated,
),
);
}
// 1. Scan Flutter AssetManifest strictly under assets/music/
try {
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final assets = manifest.listAssets();
for (final asset in assets) {
if (asset.startsWith('assets/music/') && _isAudioFile(asset)) {
final filename = asset.substring('assets/music/'.length);
addTrack(id: asset, title: filename, assetPath: asset);
}
}
} catch (e) {
debugPrint('AssetManifest query note: $e');
}
// 2. Scan strictly assets/music on the local filesystem
final searchDirs = [
Directory('assets/music'),
Directory('example/assets/music'),
];
for (final dir in searchDirs) {
if (dir.existsSync()) {
for (final entity in dir.listSync()) {
if (entity is File && _isAudioFile(entity.path)) {
final filename = entity.path.split(RegExp(r'[/\\]')).last;
addTrack(
id: entity.path,
title: filename,
filePath: entity.path,
assetPath: 'assets/music/$filename',
);
}
}
}
}
// Fallback: If no assets found, use the autogenerated sine wave as fallback
if (tracks.isEmpty) {
tracks.add(
const AudioTrack(
id: 'fallback_generated',
title: 'Fallback Autogenerado (440Hz Sine)',
isGenerated: true,
),
);
} else {
// Also provide the option to switch to the generated test tone
tracks.add(
const AudioTrack(
id: 'test_tone_generated',
title: 'Tono autogenerado (440Hz Sine)',
isGenerated: true,
),
);
}
_availableTracks = tracks;
final initialTrack = tracks.first;
_selectedTrack = initialTrack;
try {
final path = await _resolveTrackPath(initialTrack);
_samplePath = path;
if (path.isNotEmpty) {
await _player.action.open(path);
}
} catch (e, st) {
debugPrint('Initial track open note: $e\n$st');
} finally {
if (mounted) {
setState(() {
_isReady = true;
});
}
}
}
Future<Directory> _getTempDirectory() async {
try {
return await getTemporaryDirectory();
} catch (_) {
return Directory.systemTemp;
}
}
Future<String> _resolveTrackPath(AudioTrack track) async {
if (track.isGenerated) {
return _generateFallbackAudio();
}
// 1. If explicit file path is provided and exists on disk
if (track.filePath != null && File(track.filePath!).existsSync()) {
return track.filePath!;
}
// 2. If asset path is provided, try extracting from Flutter rootBundle
if (track.assetPath != null) {
try {
final byteData = await rootBundle.load(track.assetPath!);
final tempDir = await _getTempDirectory();
final fileName = track.assetPath!.split(RegExp(r'[/\\]')).last;
final tempFile = File('${tempDir.path}/$fileName');
if (!tempFile.existsSync() ||
tempFile.lengthSync() != byteData.lengthInBytes) {
await tempFile.writeAsBytes(
byteData.buffer.asUint8List(
byteData.offsetInBytes,
byteData.lengthInBytes,
),
flush: true,
);
}
return tempFile.path;
} catch (e) {
debugPrint('Asset extraction error (${track.assetPath}): $e');
// Fall through to filesystem checks
}
}
// 3. Check filesystem locations
final filename = (track.assetPath ?? track.id).split(RegExp(r'[/\\]')).last;
final possiblePaths = [
'assets/music/$filename',
'example/assets/music/$filename',
];
for (final path in possiblePaths) {
final file = File(path);
if (file.existsSync()) {
return file.path;
}
}
// 4. Ultimate fallback: generate audio
return _generateFallbackAudio();
}
Future<String> _generateFallbackAudio() async {
try {
final tempDir = await _getTempDirectory();
final file = File('${tempDir.path}/miniaudio_demo_440hz_generated.wav');
if (!file.existsSync()) {
const sampleRate = 44100;
const durationSeconds = 10;
const numSamples = sampleRate * durationSeconds;
const dataSize = numSamples * 2;
final byteData = ByteData(44 + dataSize);
byteData.setUint8(0, 0x52); // R
byteData.setUint8(1, 0x49); // I
byteData.setUint8(2, 0x46); // F
byteData.setUint8(3, 0x46); // F
byteData.setUint32(4, 36 + dataSize, Endian.little);
byteData.setUint8(8, 0x57); // W
byteData.setUint8(9, 0x41); // A
byteData.setUint8(10, 0x56); // V
byteData.setUint8(11, 0x45); // E
byteData.setUint8(12, 0x66); // f
byteData.setUint8(13, 0x6D); // m
byteData.setUint8(14, 0x74); // t
byteData.setUint8(15, 0x20); // ' '
byteData.setUint32(16, 16, Endian.little);
byteData.setUint16(20, 1, Endian.little);
byteData.setUint16(22, 1, Endian.little);
byteData.setUint32(24, sampleRate, Endian.little);
byteData.setUint32(28, sampleRate * 2, Endian.little);
byteData.setUint16(32, 2, Endian.little);
byteData.setUint16(34, 16, Endian.little);
byteData.setUint8(36, 0x64); // d
byteData.setUint8(37, 0x61); // a
byteData.setUint8(38, 0x74); // t
byteData.setUint8(39, 0x61); // a
byteData.setUint32(40, dataSize, Endian.little);
for (int i = 0; i < numSamples; i++) {
final t = i / sampleRate;
final sampleVal = (sin(2 * pi * 440.0 * t) * 32767 * 0.7).toInt();
byteData.setInt16(
44 + i * 2,
sampleVal.clamp(-32768, 32767),
Endian.little,
);
}
await file.writeAsBytes(byteData.buffer.asUint8List(), flush: true);
}
return file.path;
} catch (e) {
debugPrint('Error generating fallback audio: $e');
return '';
}
}
Future<void> _selectTrack(AudioTrack track) async {
setState(() {
_isLoadingTrack = true;
_selectedTrack = track;
});
try {
final wasPlaying = _player.state.playing;
await _player.action.stop();
final path = await _resolveTrackPath(track);
_samplePath = path;
await _player.action.open(path);
if (wasPlaying) {
await _player.action.play();
}
} catch (e) {
debugPrint('Error switching track: $e');
} finally {
if (mounted) {
setState(() {
_isLoadingTrack = false;
});
}
}
}
@override
void dispose() {
_deviceSub?.cancel();
_player.dispose();
super.dispose();
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
@override
Widget build(BuildContext context) {
if (!_isReady) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(title: const Text('miniaudio_player Demo')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPlaybackCard(),
const SizedBox(height: 16),
_buildDspControlsCard(),
const SizedBox(height: 16),
_buildEqualizerCard(),
],
),
),
);
}
Widget _buildPlaybackCard() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Pista de audio (Assets)',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
prefixIcon: Icon(Icons.library_music),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<AudioTrack>(
value: _selectedTrack,
isExpanded: true,
items: _availableTracks.map((track) {
final isFallback = track.isGenerated;
return DropdownMenuItem<AudioTrack>(
value: track,
child: Row(
children: [
Icon(
isFallback
? Icons.graphic_eq
: Icons.audiotrack,
size: 18,
color: isFallback
? Colors.amberAccent
: Colors.tealAccent,
),
const SizedBox(width: 8),
Expanded(
child: Text(
track.title,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}).toList(),
onChanged: _isLoadingTrack
? null
: (track) {
if (track != null && track != _selectedTrack) {
_selectTrack(track);
}
},
),
),
),
),
if (_isLoadingTrack) ...[
const SizedBox(width: 12),
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Archivo: ${_samplePath?.split(RegExp(r"[/\\]")).last ?? ''}',
style: Theme.of(context).textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
StreamBuilder<int>(
stream: _player.stream.audioBitrate,
initialData: _player.state.audioBitrate,
builder: (context, snapshot) {
final bitrate = snapshot.data ?? 0;
if (bitrate <= 0) return const SizedBox.shrink();
final kbps = (bitrate / 1000).round();
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Chip(
labelPadding: const EdgeInsets.symmetric(horizontal: 4),
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.speed, size: 14),
label: Text(
'$kbps kbps',
style: const TextStyle(fontSize: 10),
),
),
);
},
),
StreamBuilder<bool>(
stream: _player.stream.playing,
initialData: _player.state.playing,
builder: (context, snapshot) {
final isPlaying = snapshot.data ?? false;
return Chip(
labelPadding: const EdgeInsets.symmetric(horizontal: 4),
visualDensity: VisualDensity.compact,
label: Text(
isPlaying ? 'PLAYING' : 'STOPPED / PAUSED',
style: const TextStyle(fontSize: 10),
),
);
},
),
],
),
const SizedBox(height: 8),
StreamBuilder<Duration>(
stream: _player.stream.position,
initialData: _player.state.position,
builder: (context, posSnapshot) {
final pos = posSnapshot.data ?? Duration.zero;
final dur = _player.state.duration;
final maxMs = dur.inMilliseconds > 0
? dur.inMilliseconds.toDouble()
: 1.0;
final currentMs = pos.inMilliseconds.toDouble().clamp(
0.0,
maxMs,
);
return Column(
children: [
Slider(
value: (_dragPositionMs ?? currentMs).clamp(0.0, maxMs),
max: maxMs,
onChangeStart: (val) {
setState(() => _dragPositionMs = val);
},
onChanged: (val) {
setState(() => _dragPositionMs = val);
},
onChangeEnd: (val) {
_player.action.seek(
Duration(milliseconds: val.toInt()),
);
setState(() => _dragPositionMs = null);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(
_dragPositionMs != null
? Duration(
milliseconds: _dragPositionMs!.toInt(),
)
: pos,
),
),
Text(_formatDuration(dur)),
],
),
],
);
},
),
const SizedBox(height: 12),
StreamBuilder<bool>(
stream: _player.stream.playing,
initialData: _player.state.playing,
builder: (context, snapshot) {
final isPlaying = snapshot.data ?? false;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
iconSize: 40,
icon: const Icon(Icons.stop),
onPressed: () => _player.action.stop(),
),
const SizedBox(width: 16),
IconButton(
iconSize: 48,
icon: Icon(
isPlaying ? Icons.pause_circle : Icons.play_circle,
),
onPressed: () {
if (isPlaying) {
_player.action.pause();
} else {
_player.action.play();
}
},
),
],
);
},
),
],
),
),
);
}
Widget _buildDspControlsCard() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'DSP Controls',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Row(
children: [
const SizedBox(width: 70, child: Text('Volume')),
Expanded(
child: Slider(
value: _volume,
min: 0.0,
max: 2.0,
divisions: 20,
label: '${(_volume * 100).toInt()}%',
onChanged: (v) {
setState(() => _volume = v);
_player.action.setVolume(v);
},
),
),
SizedBox(width: 45, child: Text('${(_volume * 100).toInt()}%')),
],
),
Row(
children: [
const SizedBox(width: 70, child: Text('Rate')),
Expanded(
child: Slider(
value: _rate,
min: 0.5,
max: 2.0,
divisions: 15,
label: '${_rate.toStringAsFixed(1)}x',
onChanged: (v) {
setState(() => _rate = v);
_player.action.setRate(v);
},
),
),
SizedBox(
width: 45,
child: Text('${_rate.toStringAsFixed(1)}x'),
),
],
),
Row(
children: [
const SizedBox(width: 70, child: Text('Pitch')),
Expanded(
child: Slider(
value: _pitch,
min: 0.5,
max: 2.0,
divisions: 15,
label: '${_pitch.toStringAsFixed(1)}x',
onChanged: (v) {
setState(() => _pitch = v);
_player.action.setPitch(v);
},
),
),
SizedBox(
width: 45,
child: Text('${_pitch.toStringAsFixed(1)}x'),
),
],
),
const Divider(height: 24),
Row(
children: [
const SizedBox(width: 70, child: Text('Buffer Size')),
Expanded(
child: StreamBuilder<int>(
stream: _player.stream.bufferSize,
initialData: _player.state.bufferSize,
builder: (context, snapshot) {
final currentBuffer = snapshot.data ?? 0;
final standardValues = [0, 256, 512, 1024, 2048, 4096];
final value = standardValues.contains(currentBuffer)
? currentBuffer
: 0;
return DropdownButton<int>(
value: value,
isExpanded: true,
items: const [
DropdownMenuItem(
value: 0,
child: Text('0 (Native Default)'),
),
DropdownMenuItem(
value: 256,
child: Text('256 frames (~5.8ms @ 44.1kHz)'),
),
DropdownMenuItem(
value: 512,
child: Text('512 frames (~11.6ms @ 44.1kHz)'),
),
DropdownMenuItem(
value: 1024,
child: Text('1024 frames (~23.2ms @ 44.1kHz)'),
),
DropdownMenuItem(
value: 2048,
child: Text('2048 frames (~46.4ms @ 44.1kHz)'),
),
DropdownMenuItem(
value: 4096,
child: Text('4096 frames (~92.8ms @ 44.1kHz)'),
),
],
onChanged: (newBuf) {
if (newBuf != null) {
_player.action.setBufferSize(newBuf);
}
},
);
},
),
),
],
),
const Divider(height: 24),
Row(
children: [
const SizedBox(width: 70, child: Text('Output')),
Expanded(
child: StreamBuilder<AudioDevice>(
stream: _player.stream.audioDevice,
initialData: _player.state.audioDevice,
builder: (context, snapshot) {
final currentDev =
snapshot.data ?? _player.state.audioDevice;
final items = <AudioDevice>{
..._audioDevices,
currentDev,
}.toList();
AudioDevice? selectedValue;
for (final item in items) {
if (item == currentDev) {
selectedValue = item;
break;
}
}
selectedValue ??= items.isNotEmpty ? items.first : null;
return DropdownButton<AudioDevice>(
value: selectedValue,
isExpanded: true,
items: items.map((dev) {
return DropdownMenuItem<AudioDevice>(
value: dev,
child: Text(
"${dev.isAuto ? '[AUTO] ' : ''}${dev.name}${dev.isDefault ? ' [Default]' : ''}",
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (newDev) async {
if (newDev != null) {
try {
final success = await _player.action.setDevice(
newDev,
);
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Device "${newDev.name}" is unavailable or disconnected. Falling back to default.',
),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 3),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to set device: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
_refreshAudioDevices();
}
},
);
},
),
),
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Refresh Audio Devices',
onPressed: _refreshAudioDevices,
),
],
),
],
),
),
);
}
Widget _buildEqualizerCard() {
final bands = [
(
'60Hz',
_currentEqualizer.hz60,
(double v) => _currentEqualizer.copyWith(hz60: v),
),
(
'170Hz',
_currentEqualizer.hz170,
(double v) => _currentEqualizer.copyWith(hz170: v),
),
(
'310Hz',
_currentEqualizer.hz310,
(double v) => _currentEqualizer.copyWith(hz310: v),
),
(
'600Hz',
_currentEqualizer.hz600,
(double v) => _currentEqualizer.copyWith(hz600: v),
),
(
'1kHz',
_currentEqualizer.hz1k,
(double v) => _currentEqualizer.copyWith(hz1k: v),
),
(
'3kHz',
_currentEqualizer.hz3k,
(double v) => _currentEqualizer.copyWith(hz3k: v),
),
(
'6kHz',
_currentEqualizer.hz6k,
(double v) => _currentEqualizer.copyWith(hz6k: v),
),
(
'12kHz',
_currentEqualizer.hz12k,
(double v) => _currentEqualizer.copyWith(hz12k: v),
),
(
'14kHz',
_currentEqualizer.hz14k,
(double v) => _currentEqualizer.copyWith(hz14k: v),
),
(
'16kHz',
_currentEqualizer.hz16k,
(double v) => _currentEqualizer.copyWith(hz16k: v),
),
];
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'10-Band Equalizer',
style: Theme.of(context).textTheme.titleMedium,
),
DropdownButton<String>(
value: null,
hint: const Text('Presets'),
items: const [
DropdownMenuItem(value: 'flat', child: Text('Flat')),
DropdownMenuItem(value: 'rock', child: Text('Rock')),
DropdownMenuItem(value: 'pop', child: Text('Pop')),
DropdownMenuItem(value: 'jazz', child: Text('Jazz')),
DropdownMenuItem(
value: 'classical',
child: Text('Classical'),
),
DropdownMenuItem(
value: 'bassBoost',
child: Text('Bass Boost'),
),
],
onChanged: (preset) {
if (preset == null) return;
Equalizer target;
switch (preset) {
case 'rock':
target = Equalizer.rock;
case 'pop':
target = Equalizer.pop;
case 'jazz':
target = Equalizer.jazz;
case 'classical':
target = Equalizer.classical;
case 'bassBoost':
target = Equalizer.bassBoost;
default:
target = Equalizer.flat;
}
setState(() => _currentEqualizer = target);
_player.action.setEqualizer(target);
},
),
],
),
const SizedBox(height: 12),
SizedBox(
height: 180,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
children: bands.map((band) {
return SizedBox(
width: 48,
child: Column(
children: [
Text(
'${band.$2.toInt() > 0 ? "+" : ""}${band.$2.toInt()}',
style: const TextStyle(fontSize: 10),
),
Expanded(
child: RotatedBox(
quarterTurns: 3,
child: Slider(
value: band.$2.clamp(-24.0, 24.0),
min: -24.0,
max: 24.0,
onChanged: (newVal) {
final updated = band.$3(newVal);
setState(() => _currentEqualizer = updated);
_player.action.setEqualizer(updated);
},
),
),
),
Text(band.$1, style: const TextStyle(fontSize: 10)),
],
),
);
}).toList(),
),
),
),
],
),
),
);
}
}