native_haptics_and_audio 2.0.0
native_haptics_and_audio: ^2.0.0 copied to clipboard
Ultra-low latency native haptic and audio feedback designed specifically for high-speed performance on iOS and Android.
import 'package:flutter/material.dart';
import 'package:native_haptics_and_audio/native_haptics_and_audio.dart';
void main() {
runApp(const ExampleApp());
}
/// An audio asset owned by this app rather than the plugin.
const CustomSound kCustomDemo = CustomSound('assets/custom_demo.m4a');
/// The sound pinned at startup, standing in for a latency-critical hot path.
const NativeSound kPinnedSound = NativeSound.scannerBeep;
/// Kept deliberately small so LRU eviction is visible while tapping around.
const int kMaxCachedSounds = 5;
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'native_haptics_and_audio',
theme: ThemeData(
colorSchemeSeed: Colors.indigo,
brightness: Brightness.light,
),
darkTheme: ThemeData(
colorSchemeSeed: Colors.indigo,
brightness: Brightness.dark,
),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final NativeHapticsAndAudioRepository _repo = NativeHapticsAndAudioRepository.instance;
bool _ready = false;
double _volume = 1.0;
double _rate = 1.0;
String? _lastPlayed;
@override
void initState() {
super.initState();
_boot();
}
Future<void> _boot() async {
await _repo.initialize(maxCachedSounds: kMaxCachedSounds, respectSilentSwitch: false);
// Pin the hot sound so it is never evicted, no matter what else plays.
await _repo.preload(kPinnedSound);
if (!mounted) return;
setState(() => _ready = true);
}
@override
void dispose() {
_repo.release();
super.dispose();
}
Future<void> _play(Sound sound, String label) async {
await _repo.play(sound, volume: _volume, rate: _rate);
if (!mounted) return;
setState(() => _lastPlayed = label);
}
Future<void> _haptic(HapticPattern pattern) => _repo.playHaptic(pattern);
Future<void> _unloadAll() async {
await _repo.unloadAll();
if (!mounted) return;
setState(() => _lastPlayed = null);
}
Future<void> _repin() async {
await _repo.preload(kPinnedSound);
if (!mounted) return;
setState(() {});
}
@override
Widget build(BuildContext context) {
if (!_ready) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(
title: const Text('native_haptics_and_audio'),
actions: [
IconButton(
icon: const Icon(Icons.delete_sweep_outlined),
tooltip: 'Unload all sounds',
onPressed: _unloadAll,
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_MemoryPanel(
pinned: _repo.pinnedAssets,
cached: _repo.cachedAssets,
maxCachedSounds: _repo.maxCachedSounds,
lastPlayed: _lastPlayed,
onRepin: _repin,
),
const SizedBox(height: 24),
const _SectionHeader(
title: 'Playback',
subtitle: 'Applies to every sound below',
),
_SliderTile(
label: 'Volume',
value: _volume,
min: 0,
max: 1,
onChanged: (double v) => setState(() => _volume = v),
),
_SliderTile(
label: 'Rate',
value: _rate,
min: 0.5,
max: 2,
onChanged: (double v) => setState(() => _rate = v),
),
const SizedBox(height: 24),
_SectionHeader(
title: 'Built-in sounds',
subtitle:
'${NativeSound.values.length} bundled clips. '
'Tapping an unloaded sound loads it on demand.',
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: NativeSound.values.map((NativeSound sound) {
final bool pinned = sound == kPinnedSound;
final bool loaded = _repo.isLoaded(sound);
return FilledButton.tonalIcon(
onPressed: () => _play(sound, sound.name),
icon: Icon(
pinned
? Icons.push_pin
: loaded
? Icons.memory
: Icons.download_outlined,
size: 18,
),
label: Text(sound.name),
);
}).toList(),
),
const SizedBox(height: 24),
const _SectionHeader(
title: 'Custom sound',
subtitle: 'An asset owned by this app, loaded through the same path.',
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: () => _play(kCustomDemo, 'custom_demo'),
icon: const Icon(Icons.audio_file_outlined),
label: const Text('assets/custom_demo.m4a'),
),
const SizedBox(height: 24),
const _SectionHeader(title: 'Haptics'),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: HapticPattern.values.map((HapticPattern pattern) {
return OutlinedButton.icon(
onPressed: () => _haptic(pattern),
icon: const Icon(Icons.vibration, size: 18),
label: Text(pattern.name),
);
}).toList(),
),
const SizedBox(height: 32),
],
),
);
}
}
/// Live view of what the plugin is holding in native RAM.
class _MemoryPanel extends StatelessWidget {
const _MemoryPanel({
required this.pinned,
required this.cached,
required this.maxCachedSounds,
required this.lastPlayed,
required this.onRepin,
});
final Set<String> pinned;
final List<String> cached;
final int maxCachedSounds;
final String? lastPlayed;
final VoidCallback onRepin;
/// Trims the asset key down to the bare filename for display.
static String _short(String assetKey) => assetKey.split('/').last.replaceAll('.m4a', '');
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text('Native memory', style: theme.textTheme.titleMedium),
),
if (pinned.isEmpty) TextButton(onPressed: onRepin, child: const Text('Re-pin')),
],
),
const SizedBox(height: 12),
_Tier(
icon: Icons.push_pin,
label: 'Pinned',
detail: 'never evicted',
items: pinned.map(_short).toList(),
emptyText: 'nothing pinned',
),
const SizedBox(height: 12),
_Tier(
icon: Icons.memory,
label: 'Cached (${cached.length}/$maxCachedSounds)',
detail: 'least recently used first',
items: cached.map(_short).toList(),
emptyText: 'cache empty',
),
if (lastPlayed != null) ...<Widget>[
const Divider(height: 24),
Text(
'Last played: $lastPlayed',
style: theme.textTheme.bodySmall,
),
],
],
),
),
);
}
}
class _Tier extends StatelessWidget {
const _Tier({
required this.icon,
required this.label,
required this.detail,
required this.items,
required this.emptyText,
});
final IconData icon;
final String label;
final String detail;
final List<String> items;
final String emptyText;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 16, color: theme.colorScheme.primary),
const SizedBox(width: 6),
Text(label, style: theme.textTheme.labelLarge),
const SizedBox(width: 6),
Text(
detail,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 6),
if (items.isEmpty)
Text(
emptyText,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: items
.map(
(String name) => Chip(
label: Text(name),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
)
.toList(),
),
],
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title, this.subtitle});
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
if (subtitle != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
);
}
}
class _SliderTile extends StatelessWidget {
const _SliderTile({
required this.label,
required this.value,
required this.min,
required this.max,
required this.onChanged,
});
final String label;
final double value;
final double min;
final double max;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(width: 56, child: Text(label)),
Expanded(
child: Slider(
value: value,
min: min,
max: max,
onChanged: onChanged,
),
),
SizedBox(
width: 44,
child: Text(value.toStringAsFixed(2), textAlign: TextAlign.end),
),
],
);
}
}