voice_anim_kit 0.3.0
voice_anim_kit: ^0.3.0 copied to clipboard
A collection of beautiful, customizable recording & voice animation widgets for Flutter. 9 premium styles — Wave, Bar, Circle, Blob, Line, Particle, Ripple, AI Gaze, and Glow Bar — all driven by a sim [...]
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart';
import 'package:voice_anim_kit/voice_anim_kit.dart';
void main() {
runApp(const AnimationGalleryApp());
}
// ─── Theme ─────────────────────────────────────────────────────────────────
class AnimationGalleryApp extends StatelessWidget {
const AnimationGalleryApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Voice Anim Kit Gallery',
debugShowCheckedModeBanner: false,
theme: _buildTheme(),
home: const GalleryPage(),
);
}
ThemeData _buildTheme() {
const seedColor = Color(0xFF8CA8CD); // Titanium Blue
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
surface: const Color(0xFF080D14),
primary: seedColor,
),
scaffoldBackgroundColor: const Color(0xFF080D14),
cardColor: const Color(0xFF101620),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF080D14),
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
sliderTheme: const SliderThemeData(trackHeight: 3),
chipTheme: ChipThemeData(
backgroundColor: const Color(0xFF181F2C),
side: const BorderSide(color: Color(0xFF263145)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
labelStyle: const TextStyle(fontSize: 12),
),
switchTheme: SwitchThemeData(
trackColor: WidgetStateProperty.resolveWith(
(s) => s.contains(WidgetState.selected)
? seedColor.withValues(alpha: 0.5)
: const Color(0xFF181F2C),
),
thumbColor: WidgetStateProperty.resolveWith(
(s) => s.contains(WidgetState.selected) ? seedColor : Colors.white38,
),
),
);
}
}
// ─── Color palette ──────────────────────────────────────────────────────────
const List<_ColorOption> _colorOptions = [
_ColorOption('White', Colors.white),
_ColorOption('Titanium', Color(0xFF8CA8CD)),
_ColorOption('Gold', Color(0xFFFFCB47)),
_ColorOption('Cyan', Color(0xFF00E5FF)),
_ColorOption('Green', Color(0xFF4DFF91)),
_ColorOption('Red', Color(0xFFFF3D3D)),
_ColorOption('Pink', Color(0xFFFF4D9E)),
_ColorOption('Sky', Color(0xFF40C4FF)),
];
class _ColorOption {
final String label;
final Color color;
const _ColorOption(this.label, this.color);
}
// ─── Gallery page ────────────────────────────────────────────────────────────
class GalleryPage extends StatefulWidget {
const GalleryPage({super.key});
@override
State<GalleryPage> createState() => _GalleryPageState();
}
class _GalleryPageState extends State<GalleryPage> {
// ── visualizer state ─────────────────────────────────────────────────────
bool _isRecording = false;
double _amplitude = 0.0;
int _selectedIndex = 0;
Color _selectedColor = Colors.white;
// ── mic state ────────────────────────────────────────────────────────────
final AudioRecorder _recorder = AudioRecorder();
Timer? _ampTimer;
bool _micActive = false;
bool _micPermissionDenied = false;
// ── manual mode ──────────────────────────────────────────────────────────
bool _manualMode = false; // false = mic mode, true = manual slider
double _manualAmplitude = 0.5;
final List<String> _names = [
'Wave',
'Bar',
'Circle',
'Blob',
'Line',
'Particle',
'Ripple',
'AI Gaze',
'Glow Bar',
];
// ─── lifecycle ─────────────────────────────────────────────────────────
@override
void dispose() {
_stopMic();
_recorder.dispose();
super.dispose();
}
// ─── mic logic ──────────────────────────────────────────────────────────
Future<void> _toggleMic() async {
if (_micActive) {
await _stopMic();
} else {
await _startMic();
}
}
Future<void> _startMic() async {
// Request permission
final status = await Permission.microphone.request();
if (!status.isGranted) {
if (mounted) setState(() => _micPermissionDenied = true);
return;
}
if (mounted) setState(() => _micPermissionDenied = false);
// record 包需要一个合法的可写路径才能初始化 MediaMuxer,
// 即使我们只读取振幅也必须提供真实路径。
final tmpDir = await getTemporaryDirectory();
final tmpPath = '${tmpDir.path}/voice_anim_tmp.aac';
await _recorder.start(
const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 44100),
path: tmpPath,
);
// 每 50 ms 轮询一次振幅 → 约 20 fps 更新
_ampTimer = Timer.periodic(const Duration(milliseconds: 50), (_) async {
final amp = await _recorder.getAmplitude();
if (!mounted) return;
// amp.current 单位为 dB(−160 … 0),转换为 0–1
final normalized = _dbToNormalized(amp.current);
setState(() {
_amplitude = normalized;
_isRecording = true;
});
});
if (mounted) setState(() => _micActive = true);
}
Future<void> _stopMic() async {
_ampTimer?.cancel();
_ampTimer = null;
await _recorder.stop();
if (mounted) {
setState(() {
_micActive = false;
_isRecording = false;
_amplitude = 0.0;
});
}
}
/// Maps dB value (−160 … 0) to a 0–1 amplitude for the visualizers.
/// Silence floor at −50 dB; anything louder is boosted to fill the range.
double _dbToNormalized(double db) {
const double floor = -50.0;
if (db <= floor) return 0.0;
final ratio = (db - floor) / floor.abs(); // 0 … 1
return math.pow(ratio, 0.6).toDouble().clamp(0.0, 1.0);
}
// ─── build ──────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final effectiveAmplitude = _manualMode ? _manualAmplitude : _amplitude;
final effectiveRecording = _manualMode ? _isRecording : _micActive;
return Scaffold(
appBar: AppBar(
title: const Text('Voice Anim Kit'),
actions: [
// Manual / Mic toggle
Padding(
padding: const EdgeInsets.only(right: 12),
child: _ModeToggle(
manualMode: _manualMode,
onToggle: (v) {
setState(() {
_manualMode = v;
if (v && _micActive) _stopMic();
if (!v) {
_isRecording = false;
_amplitude = 0.0;
}
});
},
),
),
],
),
body: Column(
children: [
// ── preview ──────────────────────────────────────────────────────
Expanded(
child: _PreviewFrame(
child: _buildVisualizer(effectiveAmplitude, effectiveRecording),
),
),
// ── controls ────────────────────────────────────────────────────
_ControlsPanel(
// shared
selectedIndex: _selectedIndex,
names: _names,
selectedColor: _selectedColor,
colorOptions: _colorOptions,
onStyleSelected: (i) => setState(() => _selectedIndex = i),
onColorSelected: (c) => setState(() => _selectedColor = c),
// manual mode
manualMode: _manualMode,
isRecording: _isRecording,
manualAmplitude: _manualAmplitude,
onRecordingToggled: (v) => setState(() => _isRecording = v),
onManualAmplitudeChanged: (v) =>
setState(() => _manualAmplitude = v),
// mic mode
micActive: _micActive,
micPermissionDenied: _micPermissionDenied,
currentAmplitude: _amplitude,
onMicToggle: _toggleMic,
),
],
),
);
}
Widget _buildVisualizer(double amplitude, bool recording) {
switch (_selectedIndex) {
case 0:
return WaveVisualizer(
isRecording: recording,
amplitude: amplitude,
color: _selectedColor,
);
case 1:
return BarVisualizer(
isRecording: recording,
amplitude: amplitude,
color: _selectedColor,
);
case 2:
return CircleVisualizer(
isRecording: recording,
amplitude: amplitude,
color: _selectedColor,
);
case 3:
return BlobVisualizer(
isRecording: recording,
amplitude: amplitude,
color: _selectedColor,
);
case 4:
return LineVisualizer(
isRecording: recording,
amplitude: amplitude,
color: _selectedColor,
);
case 5:
return ParticleVisualizer(
isRecording: recording,
amplitude: amplitude,
color: _selectedColor,
);
case 6:
return RippleVisualizer(
isRecording: recording,
amplitude: amplitude,
color: _selectedColor,
);
case 7:
return AIGazeVisualizer(
isRecording: recording,
amplitude: amplitude,
color: _selectedColor,
);
case 8:
return GlowBarVisualizer(
isRecording: recording,
amplitude: amplitude,
color: _selectedColor,
);
default:
return const SizedBox();
}
}
}
// ─── Sub-widgets ─────────────────────────────────────────────────────────────
/// Dark frame for the visualizer preview
class _PreviewFrame extends StatelessWidget {
final Widget child;
const _PreviewFrame({required this.child});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
decoration: const BoxDecoration(color: Color(0xFF04060A)),
child: Center(child: child),
);
}
}
/// AppBar mode toggle chip
class _ModeToggle extends StatelessWidget {
final bool manualMode;
final ValueChanged<bool> onToggle;
const _ModeToggle({required this.manualMode, required this.onToggle});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onToggle(!manualMode),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: manualMode
? const Color(0xFF181F2C)
: const Color(0xFF8CA8CD).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: manualMode
? const Color(0xFF263145)
: const Color(0xFF8CA8CD).withValues(alpha: 0.7),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
manualMode ? Icons.tune : Icons.mic,
size: 14,
color: manualMode ? Colors.white54 : const Color(0xFF8CA8CD),
),
const SizedBox(width: 6),
Text(
manualMode ? 'Manual' : 'Mic',
style: TextStyle(
fontSize: 12,
color: manualMode ? Colors.white54 : const Color(0xFF8CA8CD),
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
/// Full controls panel (scrollable bottom sheet style)
class _ControlsPanel extends StatelessWidget {
// shared
final int selectedIndex;
final List<String> names;
final Color selectedColor;
final List<_ColorOption> colorOptions;
final ValueChanged<int> onStyleSelected;
final ValueChanged<Color> onColorSelected;
// manual
final bool manualMode;
final bool isRecording;
final double manualAmplitude;
final ValueChanged<bool> onRecordingToggled;
final ValueChanged<double> onManualAmplitudeChanged;
// mic
final bool micActive;
final bool micPermissionDenied;
final double currentAmplitude;
final VoidCallback onMicToggle;
const _ControlsPanel({
required this.selectedIndex,
required this.names,
required this.selectedColor,
required this.colorOptions,
required this.onStyleSelected,
required this.onColorSelected,
required this.manualMode,
required this.isRecording,
required this.manualAmplitude,
required this.onRecordingToggled,
required this.onManualAmplitudeChanged,
required this.micActive,
required this.micPermissionDenied,
required this.currentAmplitude,
required this.onMicToggle,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 32),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
blurRadius: 24,
offset: const Offset(0, -6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// drag handle
Center(
child: Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white12,
borderRadius: BorderRadius.circular(2),
),
),
),
if (manualMode) ..._buildManualControls(context),
if (!manualMode) ..._buildMicControls(context),
const SizedBox(height: 16),
_buildColorRow(),
const SizedBox(height: 16),
_buildStyleChips(),
],
),
);
}
// ── manual controls ────────────────────────────────────────────────────
List<Widget> _buildManualControls(BuildContext context) {
return [
Row(
children: [
const Text(
'Recording',
style: TextStyle(fontSize: 13, color: Colors.white70),
),
const Spacer(),
Switch(value: isRecording, onChanged: onRecordingToggled),
],
),
const SizedBox(height: 8),
Row(
children: [
const Text(
'Amplitude',
style: TextStyle(fontSize: 13, color: Colors.white70),
),
const SizedBox(width: 8),
Text(
manualAmplitude.toStringAsFixed(2),
style: TextStyle(
fontSize: 13,
color: selectedColor,
fontWeight: FontWeight.w600,
),
),
],
),
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: selectedColor,
thumbColor: selectedColor,
overlayColor: selectedColor.withValues(alpha: 0.15),
inactiveTrackColor: Colors.white10,
),
child: Slider(
value: manualAmplitude,
onChanged: onManualAmplitudeChanged,
),
),
];
}
// ── mic controls ───────────────────────────────────────────────────────
List<Widget> _buildMicControls(BuildContext context) {
return [
Row(
children: [
// Big mic button
_MicButton(active: micActive, onTap: onMicToggle),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
micActive ? 'Listening…' : 'Tap to start',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: micActive ? const Color(0xFF8CA8CD) : Colors.white54,
),
),
const SizedBox(height: 4),
if (micPermissionDenied)
const Text(
'Microphone permission denied',
style: TextStyle(fontSize: 12, color: Color(0xFFFF4D4D)),
)
else
Text(
micActive
? 'Amplitude: ${(currentAmplitude * 100).toInt()}%'
: 'Real-time voice visualization',
style: const TextStyle(fontSize: 12, color: Colors.white38),
),
],
),
),
// Amplitude bar indicator
if (micActive)
_AmplitudeBar(amplitude: currentAmplitude, color: selectedColor),
],
),
];
}
// ── color row ──────────────────────────────────────────────────────────
Widget _buildColorRow() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Color',
style: TextStyle(
fontSize: 12,
color: Colors.white38,
letterSpacing: 0.8,
),
),
const SizedBox(height: 10),
Row(
children: colorOptions.map((opt) {
final isSelected = opt.color.toARGB32() == selectedColor.toARGB32();
return Padding(
padding: const EdgeInsets.only(right: 10),
child: GestureDetector(
onTap: () => onColorSelected(opt.color),
child: Tooltip(
message: opt.label,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 28,
height: 28,
decoration: BoxDecoration(
color: opt.color,
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? Colors.white : Colors.white24,
width: isSelected ? 2.5 : 1.0,
),
boxShadow: isSelected
? [
BoxShadow(
color: opt.color.withValues(alpha: 0.7),
blurRadius: 8,
spreadRadius: 1,
),
]
: [],
),
child: isSelected
? const Icon(
Icons.check,
size: 14,
color: Colors.black87,
)
: null,
),
),
),
);
}).toList(),
),
],
);
}
// ── style chips ────────────────────────────────────────────────────────
Widget _buildStyleChips() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Style',
style: TextStyle(
fontSize: 12,
color: Colors.white38,
letterSpacing: 0.8,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: List.generate(names.length, (index) {
final selected = selectedIndex == index;
return GestureDetector(
onTap: () => onStyleSelected(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
color: selected
? selectedColor.withValues(alpha: 0.18)
: const Color(0xFF181F2C),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: selected
? selectedColor.withValues(alpha: 0.8)
: const Color(0xFF263145),
width: selected ? 1.5 : 1.0,
),
),
child: Text(
names[index],
style: TextStyle(
fontSize: 12,
color: selected ? selectedColor : Colors.white38,
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
),
),
),
);
}),
),
],
);
}
}
/// Pulsing microphone button
class _MicButton extends StatelessWidget {
final bool active;
final VoidCallback onTap;
const _MicButton({required this.active, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: active ? const Color(0xFF8CA8CD) : const Color(0xFF181F2C),
boxShadow: active
? [
BoxShadow(
color: const Color(0xFF8CA8CD).withValues(alpha: 0.45),
blurRadius: 20,
spreadRadius: 2,
),
]
: [],
),
child: Icon(
active ? Icons.stop_rounded : Icons.mic,
color: active ? Colors.white : Colors.white38,
size: 26,
),
),
);
}
}
/// Vertical amplitude level bar (mic mode indicator)
class _AmplitudeBar extends StatelessWidget {
final double amplitude;
final Color color;
const _AmplitudeBar({required this.amplitude, required this.color});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 6,
height: 48,
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: Stack(
alignment: Alignment.bottomCenter,
children: [
Container(color: Colors.white10),
FractionallySizedBox(
heightFactor: amplitude.clamp(0.0, 1.0),
child: AnimatedContainer(
duration: const Duration(milliseconds: 80),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.6),
blurRadius: 4,
),
],
),
),
),
],
),
),
);
}
}