import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../data/models/media_playback.dart'; import '../../data/models/system_status.dart'; import '../../data/services/native_bridge.dart'; /// Polls the system for battery, Wi-Fi, media volume and media activity. /// /// Polling keeps the app free of long-lived broadcast receivers and works /// identically on Android 10 and 15. Timers are stopped while the launcher is /// in the background. class SystemController extends ChangeNotifier { SystemController(this._platform); final LauncherPlatform _platform; SystemStatus _status = const SystemStatus(); VolumeInfo _volume = const VolumeInfo(); Timer? _volumeTimer; Timer? _statusTimer; bool _active = false; bool _volumeBusy = false; bool _statusBusy = false; bool _musicBusy = false; bool _musicPlaying = false; bool _volumeSetting = false; bool _showVolumeDiagnostics = false; int _volumeWriteId = 0; int? _lastVolumeTarget; VolumeInfo? _lastVolumeRead; MediaPlayback _media = const MediaPlayback(); bool _disposed = false; int _lastNonZeroVolume = 0; SystemStatus get status => _status; VolumeInfo get volume => _volume; int get volumePercent => _volume.percent; bool get isMuted => _volume.index <= 0; bool get isVolumeFixed => _volume.fixed; bool get isMusicPlaying => _musicPlaying; String? get volumeDiagnostics { if (!_showVolumeDiagnostics) return null; final write = _lastVolumeTarget == null ? 'none' : '${_lastVolumeTarget!} -> ${_lastVolumeRead?.index ?? '?'}'; final streams = _volume.streams.entries .map((entry) => '${entry.key} ${entry.value}') .join(' '); return 'raw ${_volume.index}/${_volume.max} fixed ${_volume.fixed}\n' 'write $write\n' '${streams.isEmpty ? 'streams unavailable' : streams}'; } void toggleVolumeDiagnostics() { _showVolumeDiagnostics = !_showVolumeDiagnostics; _notify(); } MediaPlayback get media => _media; bool get hasMediaAccess => _media.hasAccess; Future start() async { if (_active) return; _active = true; await refreshAll(); _volumeTimer = Timer.periodic(const Duration(milliseconds: 900), (_) { _pollVolume(); _pollMusic(); }); _statusTimer = Timer.periodic( const Duration(seconds: 20), (_) => _pollStatus(), ); } void stop() { _active = false; _volumeTimer?.cancel(); _volumeTimer = null; _statusTimer?.cancel(); _statusTimer = null; } Future refreshAll() async { await Future.wait([_pollVolume(), _pollStatus(), _pollMusic()]); } Future _pollVolume() async { if (_volumeBusy || _volumeSetting || !_active) return; _volumeBusy = true; try { final next = await _platform.getVolume(); if (_lastVolumeTarget != null) _lastVolumeRead = next; if (next.index != _volume.index || next.max != _volume.max || next.fixed != _volume.fixed || !mapEquals(next.streams, _volume.streams)) { _volume = next; if (next.index > 0) _lastNonZeroVolume = next.index; _notify(); } } finally { _volumeBusy = false; } } Future _pollMusic() async { if (_musicBusy || !_active) return; _musicBusy = true; try { final media = await _platform.getMediaState(); final fallback = await _platform.isMusicPlaying(); final playing = media.hasAccess && media.hasSession ? media.playing : fallback; final changed = media != _media || playing != _musicPlaying; _media = media; _musicPlaying = playing; if (changed) _notify(); } finally { _musicBusy = false; } } Future _pollStatus() async { if (_statusBusy) return; _statusBusy = true; try { final next = await _platform.getStatus(); if (next.batteryLevel != _status.batteryLevel || next.charging != _status.charging || next.wifi != _status.wifi) { _status = next; _notify(); } } finally { _statusBusy = false; } } Future setVolume(int index) async { final max = _volume.max <= 0 ? 15 : _volume.max; final clamped = index.clamp(0, max); if (_volume.fixed) { _lastVolumeTarget = clamped; _lastVolumeRead = _volume; return; } final writeId = ++_volumeWriteId; _lastVolumeTarget = clamped; _volumeSetting = true; _volume = VolumeInfo(index: clamped, max: max, streams: _volume.streams); if (clamped > 0) _lastNonZeroVolume = clamped; _notify(); try { await _platform.setVolume(clamped); if (writeId != _volumeWriteId) return; final actual = await _platform.getVolume(); if (writeId != _volumeWriteId) return; _lastVolumeRead = actual; _volume = actual; if (actual.index > 0) _lastNonZeroVolume = actual.index; if (!actual.fixed && actual.index != clamped) { _showVolumeDiagnostics = true; } _notify(); } finally { if (writeId == _volumeWriteId) _volumeSetting = false; } } Future adjustVolume(int delta) => setVolume(_volume.index + delta); Future openMediaAccessSettings() { return _platform.openNotificationAccessSettings(); } /// Sends a pause key immediately. The next poll reconciles the real state. Future pauseMusic() async { try { await _platform.sendMediaKey('pause'); } catch (_) { // Media keys are best-effort; the next poll still reflects reality. } if (_musicPlaying) { _musicPlaying = false; _notify(); } } /// Tries to resume a paused session. Returns false when nothing started, so /// the caller can open the configured music app instead. Future playMusic() async { try { final sent = await _platform.sendMediaKey('play'); if (!sent) return false; await Future.delayed(const Duration(milliseconds: 450)); final media = await _platform.getMediaState(); final fallback = await _platform.isMusicPlaying(); final playing = media.hasAccess && media.hasSession ? media.playing : fallback; final changed = media != _media || playing != _musicPlaying; _media = media; _musicPlaying = playing; if (changed) _notify(); return playing; } catch (_) { return false; } } Future toggleMute() async { if (_volume.fixed) return; if (_volume.index > 0) { _lastNonZeroVolume = _volume.index; await setVolume(0); } else { final restore = _lastNonZeroVolume > 0 ? _lastNonZeroVolume : (_volume.max <= 0 ? 1 : (_volume.max / 2).round().clamp(1, _volume.max)); await setVolume(restore); } } void _notify() { if (!_disposed) notifyListeners(); } @override void dispose() { _disposed = true; stop(); super.dispose(); } }