device_volume 2.0.0
device_volume: ^2.0.0 copied to clipboard
Control and observe the device volume from Flutter through native platform channels on Android, iOS, macOS, Windows, and Linux.
import 'dart:async';
import 'package:device_volume/device_volume.dart';
import 'package:flutter/material.dart';
/// Starts the example application used for manual and integration validation.
void main() => runApp(const DeviceVolumeExampleApp());
/// Material application demonstrating every public 2.0 API.
class DeviceVolumeExampleApp extends StatelessWidget {
const DeviceVolumeExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'device_volume 2.0',
theme: ThemeData(colorSchemeSeed: Colors.deepPurple, useMaterial3: true),
home: const DeviceVolumePage(),
);
}
}
/// Interactive page that adapts its controls to native capabilities.
class DeviceVolumePage extends StatefulWidget {
const DeviceVolumePage({super.key});
@override
State<DeviceVolumePage> createState() => _DeviceVolumePageState();
}
class _DeviceVolumePageState extends State<DeviceVolumePage> {
VolumeCapabilities? _capabilities;
VolumeChannel _channel = VolumeChannel.media;
VolumeState? _state;
StreamSubscription<VolumeState>? _subscription;
String? _error;
bool _busy = true;
@override
void initState() {
super.initState();
unawaited(_load());
}
@override
void dispose() {
unawaited(_subscription?.cancel());
super.dispose();
}
/// Loads capabilities first so unsupported controls are never presented as
/// guaranteed features, then reads and observes the selected channel.
Future<void> _load() async {
setState(() {
_busy = true;
_error = null;
});
try {
final capabilities = await DeviceVolume.getCapabilities();
final channel = capabilities.channels.contains(_channel)
? _channel
: capabilities.channels.first;
final state = await DeviceVolume.getVolume(channel: channel);
await _replaceSubscription(channel);
if (!mounted) return;
setState(() {
_capabilities = capabilities;
_channel = channel;
_state = state;
_busy = false;
});
} on Object catch (error) {
_showError(error);
}
}
/// Replaces the native stream whenever the selected logical channel changes.
Future<void> _replaceSubscription(VolumeChannel channel) async {
await _subscription?.cancel();
_subscription = DeviceVolume.streamVolume(channel: channel).listen((state) {
if (!mounted) return;
setState(() {
_state = state;
_error = null;
});
}, onError: _showError);
}
/// Reads a newly selected channel and restarts event filtering.
Future<void> _selectChannel(VolumeChannel channel) async {
setState(() {
_channel = channel;
_busy = true;
});
try {
final state = await DeviceVolume.getVolume(channel: channel);
await _replaceSubscription(channel);
if (!mounted) return;
setState(() {
_state = state;
_busy = false;
_error = null;
});
} on Object catch (error) {
_showError(error);
}
}
/// Applies a normalized slider value and displays the actual native result.
Future<void> _setVolume(int value) async {
await _runAction(
() =>
DeviceVolume.setVolume(value, channel: _channel, showSystemUi: true),
);
}
/// Runs one volume operation with consistent loading and error handling.
Future<void> _runAction(Future<VolumeState> Function() action) async {
setState(() => _busy = true);
try {
final state = await action();
if (!mounted) return;
setState(() {
_state = state;
_busy = false;
_error = null;
});
} on Object catch (error) {
_showError(error);
}
}
/// Converts native/plugin failures into a visible diagnostics card.
void _showError(Object error) {
if (!mounted) return;
setState(() {
_error = error.toString();
_busy = false;
});
}
@override
Widget build(BuildContext context) {
final capabilities = _capabilities;
final state = _state;
final canWrite =
capabilities?.writeSupport != VolumeWriteSupport.unsupported;
return Scaffold(
appBar: AppBar(
title: const Text('device_volume 2.0'),
actions: [
IconButton(
key: const ValueKey('refresh'),
tooltip: 'Refresh capabilities',
onPressed: _busy ? null : _load,
icon: const Icon(Icons.refresh),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (_error case final error?)
Card(
key: const ValueKey('error_card'),
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(error),
),
),
if (_busy) const LinearProgressIndicator(key: ValueKey('loading')),
if (capabilities case final value?) ...[
_SectionCard(
title: 'Platform capabilities',
children: [
_InfoRow(
'Channels',
value.channels.map((e) => e.name).join(', '),
),
_InfoRow('Read', '${value.canRead}'),
_InfoRow('Observe', '${value.canObserve}'),
_InfoRow('Write', value.writeSupport.name),
_InfoRow('System UI', '${value.canShowSystemUi}'),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<VolumeChannel>(
key: const ValueKey('channel_selector'),
initialValue: _channel,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Volume channel',
),
items: [
for (final channel in value.channels)
DropdownMenuItem(value: channel, child: Text(channel.name)),
],
onChanged: _busy
? null
: (channel) {
if (channel != null) unawaited(_selectChannel(channel));
},
),
],
if (state case final value?) ...[
const SizedBox(height: 16),
_SectionCard(
title: 'Current state',
children: [
Text(
'${value.value}%',
key: const ValueKey('volume_value'),
style: Theme.of(context).textTheme.displayMedium,
),
_InfoRow('Channel', value.channel.name),
_InfoRow('Muted', '${value.isMuted}'),
Slider(
key: const ValueKey('volume_slider'),
value: value.value.toDouble(),
max: 100,
divisions: 100,
label: '${value.value}',
onChanged: canWrite && !_busy
? (next) => unawaited(_setVolume(next.round()))
: null,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FilledButton.tonalIcon(
key: const ValueKey('decrement'),
onPressed: canWrite && !_busy
? () => _runAction(
() => DeviceVolume.decrementVolume(
channel: _channel,
showSystemUi: true,
),
)
: null,
icon: const Icon(Icons.remove),
label: const Text('Down'),
),
FilledButton.tonalIcon(
key: const ValueKey('increment'),
onPressed: canWrite && !_busy
? () => _runAction(
() => DeviceVolume.incrementVolume(
channel: _channel,
showSystemUi: true,
),
)
: null,
icon: const Icon(Icons.add),
label: const Text('Up'),
),
],
),
],
),
if (capabilities?.writeSupport == VolumeWriteSupport.bestEffort)
const Padding(
padding: EdgeInsets.only(top: 12),
child: Text(
'This platform treats system-volume writes as best effort. '
'Always use the returned state as the actual result.',
key: ValueKey('best_effort_notice'),
),
),
],
],
),
);
}
}
/// Shared visual container used to keep the example compact and readable.
class _SectionCard extends StatelessWidget {
const _SectionCard({required this.title, required this.children});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
...children,
],
),
),
);
}
}
/// Label/value row used by state and capability cards.
class _InfoRow extends StatelessWidget {
const _InfoRow(this.label, this.value);
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Expanded(child: Text(label)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
);
}
}