volume_button_listener 0.1.0 copy "volume_button_listener: ^0.1.0" to clipboard
volume_button_listener: ^0.1.0 copied to clipboard

Listen to hardware volume button events and control system volume

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:volume_button_listener/volume_button_listener.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _OtherLogEntry {
  final String message;
  final DateTime at;

  _OtherLogEntry(this.message, this.at);
}

class _MyAppState extends State<MyApp> {
  static const int _maxLogEntries = 80;
  final List<_LogEntry> _volumeLog = [];
  final List<_OtherLogEntry> _otherLog = [];
  bool isListening = false;
  _LogEntry? _lastVolumeEvent;
  double? _currentVolume;
  bool _isFetchingVolume = false;

  @override
  void initState() {
    super.initState();
    unawaited(_refreshListeningState());
  }

  @override
  void dispose() {
    unawaited(_removeListeners());
    super.dispose();
  }

  Future<void> _addListeners() async {
    await VolumeButtonListener.instance.addButtonPressedListener(
      _buttonPressedCallback,
    );
    await VolumeButtonListener.instance.addButtonReleasedListener(
      _buttonReleasedCallback,
    );
    await _refreshListeningState();
  }

  Future<void> _removeListeners() async {
    await VolumeButtonListener.instance.removeButtonPressedListener(
      _buttonPressedCallback,
    );
    await VolumeButtonListener.instance.removeButtonReleasedListener(
      _buttonReleasedCallback,
    );
    await _refreshListeningState();
  }

  Future<void> _refreshListeningState() async {
    final active = await VolumeButtonListener.instance.isListening;
    if (!mounted) return;
    setState(() => isListening = active);
  }

  void _buttonPressedCallback(VolumeButtonDirection direction) {
    final entry = _LogEntry.buttonPressed(direction);
    setState(() {
      _lastVolumeEvent = entry;
      _volumeLog.insert(0, entry);
    });
  }

  void _buttonReleasedCallback(VolumeButtonDirection direction) {
    final entry = _LogEntry.buttonReleased(direction);
    setState(() {
      _lastVolumeEvent = entry;
      _volumeLog.insert(0, entry);
      if (_volumeLog.length > _maxLogEntries) _volumeLog.removeLast();
    });
  }

  void addOtherLog(String message) {
    setState(() {
      _otherLog.insert(0, _OtherLogEntry(message, DateTime.now()));
      if (_otherLog.length > _maxLogEntries) _otherLog.removeLast();
    });
  }

  List<Object> getMergedLogs() {
    final merged = <Object>[..._volumeLog, ..._otherLog];
    merged.sort((a, b) {
      final at = a is _LogEntry ? a.at : (a as _OtherLogEntry).at;
      final bt = b is _LogEntry ? b.at : (b as _OtherLogEntry).at;
      return bt.compareTo(at);
    });
    return merged;
  }

  void clearLogs() {
    setState(() {
      _volumeLog.clear();
      _otherLog.clear();
      _lastVolumeEvent = null;
      _currentVolume = null;
    });
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;

    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Volume Button Listener'),
          centerTitle: true,
          elevation: 0,
          scrolledUnderElevation: 2,
          actions: [
            IconButton(
              onPressed: _volumeLog.isEmpty && _otherLog.isEmpty
                  ? null
                  : clearLogs,
              icon: const Icon(Icons.clear_all),
              tooltip: 'Clear logs',
            ),
          ],
        ),
        body: Column(
          children: [
            // Minimal state label
            Container(
              width: double.infinity,
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
              color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
              child: Row(
                children: [
                  Container(
                    width: 8,
                    height: 8,
                    decoration: BoxDecoration(
                      shape: BoxShape.circle,
                      color: isListening
                          ? colorScheme.primary
                          : colorScheme.outline.withValues(alpha: 0.6),
                    ),
                  ),
                  if (_lastVolumeEvent != null) ...[
                    const SizedBox(width: 16),
                    Text(
                      'Last: ${_lastVolumeEvent!.label}',
                      style: theme.textTheme.bodySmall?.copyWith(
                        color: colorScheme.onSurfaceVariant,
                      ),
                    ),
                  ],
                  if (_currentVolume != null) ...[
                    const SizedBox(width: 16),
                    Text(
                      'Volume: ${_currentVolume!.toStringAsFixed(2)}',
                      style: theme.textTheme.bodySmall?.copyWith(
                        color: colorScheme.primary,
                        fontWeight: FontWeight.w600,
                      ),
                    ),
                  ],
                ],
              ),
            ),

            const SizedBox(height: 16),

            // Controls
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: Row(
                children: [
                  Expanded(
                    child: FilledButton.icon(
                      onPressed: () async {
                        final active =
                            await VolumeButtonListener.instance.isListening;
                        if (active) {
                          await _removeListeners();
                        } else {
                          await _addListeners();
                        }
                      },
                      icon: Icon(
                        isListening
                            ? Icons.stop_rounded
                            : Icons.play_arrow_rounded,
                      ),
                      label: Text(isListening ? 'Stop' : 'Start'),
                    ),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    child: OutlinedButton.icon(
                      onPressed: _isFetchingVolume
                          ? null
                          : () async {
                              setState(() => _isFetchingVolume = true);
                              try {
                                final volume = await VolumeButtonListener
                                    .instance
                                    .getVolume();
                                if (mounted) {
                                  setState(() {
                                    _currentVolume = volume;
                                    _isFetchingVolume = false;
                                  });
                                  addOtherLog(
                                    'Volume: ${volume.toStringAsFixed(2)}',
                                  );
                                }
                              } catch (e) {
                                if (mounted) {
                                  setState(() => _isFetchingVolume = false);
                                  addOtherLog('Get volume error: $e');
                                }
                              }
                            },
                      icon: _isFetchingVolume
                          ? SizedBox(
                              width: 20,
                              height: 20,
                              child: CircularProgressIndicator(
                                strokeWidth: 2,
                                color: colorScheme.primary,
                              ),
                            )
                          : const Icon(Icons.volume_up_outlined, size: 20),
                      label: Text(_isFetchingVolume ? '…' : 'vol'),
                    ),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    child: OutlinedButton.icon(
                      onPressed: () async {
                        await VolumeButtonListener.instance.setVolume(0.5);
                        if (mounted) addOtherLog('Set to 0.5');
                      },
                      icon: const Icon(Icons.tune_rounded, size: 20),
                      label: const Text('0.5'),
                    ),
                  ),
                ],
              ),
            ),
            // Show/Hide Volume UI
            const SizedBox(height: 8),
            const Divider(height: 1),
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
              child: SingleChildScrollView(
                scrollDirection: Axis.horizontal,
                child: Row(
                  children: [
                    OutlinedButton.icon(
                      onPressed: () =>
                          VolumeButtonListener.instance.showVolumeUI = true,
                      icon: const Icon(Icons.visibility_rounded),
                      label: const Text('Show Volume UI'),
                    ),
                    const SizedBox(width: 12),
                    OutlinedButton.icon(
                      onPressed: () =>
                          VolumeButtonListener.instance.showVolumeUI = false,
                      icon: const Icon(Icons.visibility_off_rounded),
                      label: const Text('Hide Volume UI'),
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 8),
            const Divider(height: 1),
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
              child: SingleChildScrollView(
                scrollDirection: Axis.horizontal,
                child: Row(
                  children: [
                    OutlinedButton.icon(
                      onPressed: () =>
                          VolumeButtonListener
                                  .instance
                                  .suppressRepeatedPressEvents =
                              true,
                      icon: const Icon(Icons.repeat_one_rounded),
                      label: const Text('Suppress Repeated Press Events'),
                    ),
                    const SizedBox(width: 12),
                    OutlinedButton.icon(
                      onPressed: () =>
                          VolumeButtonListener
                                  .instance
                                  .suppressRepeatedPressEvents =
                              false,
                      icon: const Icon(Icons.repeat_one_rounded),
                      label: const Text('Allow Repeated Press Events'),
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 8),
            const Divider(height: 1),
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
              child: Row(
                children: [
                  Text(
                    'Events',
                    style: theme.textTheme.titleSmall?.copyWith(
                      color: colorScheme.onSurfaceVariant,
                      fontWeight: FontWeight.w600,
                    ),
                  ),
                ],
              ),
            ),

            // Log list (merged by time, newest first)
            Expanded(
              child: Builder(
                builder: (context) {
                  final merged = getMergedLogs();
                  return ListView.builder(
                    padding: const EdgeInsets.fromLTRB(12, 0, 12, 16),
                    itemCount: merged.length,
                    itemBuilder: (context, index) {
                      final entry = merged[index];
                      if (entry is _LogEntry) {
                        return _VolumeLogTile(entry: entry);
                      }
                      return _OtherLogTile(
                        message: (entry as _OtherLogEntry).message,
                      );
                    },
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _VolumeLogTile extends StatelessWidget {
  const _VolumeLogTile({required this.entry});

  final _LogEntry entry;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;
    final isUp = entry.direction == VolumeButtonDirection.up;
    final isPressed = entry.isPressed;
    final color = isPressed
        ? (isUp ? colorScheme.primary : colorScheme.tertiary)
        : colorScheme.outline;
    final icon = isUp ? Icons.add_circle : Icons.remove_circle;
    final time = _formatTime(entry.at);

    return Padding(
      padding: const EdgeInsets.only(bottom: 4),
      child: Material(
        color: color.withValues(alpha: isPressed ? 0.18 : 0.12),
        borderRadius: BorderRadius.circular(8),
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
          child: Row(
            children: [
              Icon(icon, color: color, size: 20),
              const SizedBox(width: 8),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisSize: MainAxisSize.min,
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Text(
                      entry.label,
                      style: theme.textTheme.bodySmall?.copyWith(
                        fontWeight: FontWeight.w600,
                        color: colorScheme.onSurface,
                      ),
                    ),
                    Text(
                      time,
                      style: theme.textTheme.labelSmall?.copyWith(
                        color: colorScheme.onSurfaceVariant,
                      ),
                    ),
                  ],
                ),
              ),
              Container(
                padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
                decoration: BoxDecoration(
                  color: color.withValues(alpha: 0.2),
                  borderRadius: BorderRadius.circular(6),
                ),
                child: Text(
                  isUp ? 'UP' : 'DOWN',
                  style: theme.textTheme.labelSmall?.copyWith(
                    color: color,
                    fontWeight: FontWeight.bold,
                    letterSpacing: 0.5,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _OtherLogTile extends StatelessWidget {
  const _OtherLogTile({required this.message});

  final String message;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;

    return Padding(
      padding: const EdgeInsets.only(bottom: 4),
      child: Material(
        color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
        borderRadius: BorderRadius.circular(8),
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
          child: Row(
            children: [
              Icon(
                Icons.info_outline_rounded,
                size: 18,
                color: colorScheme.onSurfaceVariant,
              ),
              const SizedBox(width: 8),
              Expanded(
                child: Text(
                  message,
                  style: theme.textTheme.bodySmall?.copyWith(
                    color: colorScheme.onSurfaceVariant,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

String _formatTime(DateTime dt) {
  final now = DateTime.now();
  final today = DateTime(now.year, now.month, now.day);
  final d = DateTime(dt.year, dt.month, dt.day);
  final time =
      '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}.${(dt.millisecond ~/ 100).toString().padLeft(1, '0')}';
  if (d == today) return time;
  if (d == today.subtract(const Duration(days: 1))) return 'Yesterday $time';
  return '${dt.month}/${dt.day} $time';
}

class _LogEntry {
  final bool isPressed;
  final VolumeButtonDirection direction;
  final DateTime at;

  _LogEntry(this.isPressed, this.direction, this.at);

  factory _LogEntry.buttonPressed(VolumeButtonDirection direction) =>
      _LogEntry(true, direction, DateTime.now());

  factory _LogEntry.buttonReleased(VolumeButtonDirection direction) =>
      _LogEntry(false, direction, DateTime.now());

  String get label => '$_labelPrefix . ${isPressed ? 'pressed' : 'released'}';

  String get _labelPrefix => switch (direction) {
    VolumeButtonDirection.up => 'Volume up',
    VolumeButtonDirection.down => 'Volume down',
  };
}