system_controller.dart 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import 'dart:async';
  2. import 'package:flutter/foundation.dart';
  3. import '../../data/models/media_playback.dart';
  4. import '../../data/models/system_status.dart';
  5. import '../../data/services/native_bridge.dart';
  6. /// Polls the system for battery, Wi-Fi, media volume and media activity.
  7. ///
  8. /// Polling keeps the app free of long-lived broadcast receivers and works
  9. /// identically on Android 10 and 15. Timers are stopped while the launcher is
  10. /// in the background.
  11. class SystemController extends ChangeNotifier {
  12. SystemController(this._platform);
  13. final LauncherPlatform _platform;
  14. SystemStatus _status = const SystemStatus();
  15. VolumeInfo _volume = const VolumeInfo();
  16. Timer? _volumeTimer;
  17. Timer? _statusTimer;
  18. bool _active = false;
  19. bool _volumeBusy = false;
  20. bool _statusBusy = false;
  21. bool _musicBusy = false;
  22. bool _musicPlaying = false;
  23. bool _volumeSetting = false;
  24. bool _showVolumeDiagnostics = false;
  25. int _volumeWriteId = 0;
  26. int? _lastVolumeTarget;
  27. VolumeInfo? _lastVolumeRead;
  28. MediaPlayback _media = const MediaPlayback();
  29. bool _disposed = false;
  30. int _lastNonZeroVolume = 0;
  31. SystemStatus get status => _status;
  32. VolumeInfo get volume => _volume;
  33. int get volumePercent => _volume.percent;
  34. bool get isMuted => _volume.index <= 0;
  35. bool get isVolumeFixed => _volume.fixed;
  36. bool get isMusicPlaying => _musicPlaying;
  37. String? get volumeDiagnostics {
  38. if (!_showVolumeDiagnostics) return null;
  39. final write = _lastVolumeTarget == null
  40. ? 'none'
  41. : '${_lastVolumeTarget!} -> ${_lastVolumeRead?.index ?? '?'}';
  42. final streams = _volume.streams.entries
  43. .map((entry) => '${entry.key} ${entry.value}')
  44. .join(' ');
  45. return 'raw ${_volume.index}/${_volume.max} fixed ${_volume.fixed}\n'
  46. 'write $write\n'
  47. '${streams.isEmpty ? 'streams unavailable' : streams}';
  48. }
  49. void toggleVolumeDiagnostics() {
  50. _showVolumeDiagnostics = !_showVolumeDiagnostics;
  51. _notify();
  52. }
  53. MediaPlayback get media => _media;
  54. bool get hasMediaAccess => _media.hasAccess;
  55. Future<void> start() async {
  56. if (_active) return;
  57. _active = true;
  58. await refreshAll();
  59. _volumeTimer = Timer.periodic(const Duration(milliseconds: 900), (_) {
  60. _pollVolume();
  61. _pollMusic();
  62. });
  63. _statusTimer = Timer.periodic(
  64. const Duration(seconds: 20),
  65. (_) => _pollStatus(),
  66. );
  67. }
  68. void stop() {
  69. _active = false;
  70. _volumeTimer?.cancel();
  71. _volumeTimer = null;
  72. _statusTimer?.cancel();
  73. _statusTimer = null;
  74. }
  75. Future<void> refreshAll() async {
  76. await Future.wait([_pollVolume(), _pollStatus(), _pollMusic()]);
  77. }
  78. Future<void> _pollVolume() async {
  79. if (_volumeBusy || _volumeSetting || !_active) return;
  80. _volumeBusy = true;
  81. try {
  82. final next = await _platform.getVolume();
  83. if (_lastVolumeTarget != null) _lastVolumeRead = next;
  84. if (next.index != _volume.index ||
  85. next.max != _volume.max ||
  86. next.fixed != _volume.fixed ||
  87. !mapEquals(next.streams, _volume.streams)) {
  88. _volume = next;
  89. if (next.index > 0) _lastNonZeroVolume = next.index;
  90. _notify();
  91. }
  92. } finally {
  93. _volumeBusy = false;
  94. }
  95. }
  96. Future<void> _pollMusic() async {
  97. if (_musicBusy || !_active) return;
  98. _musicBusy = true;
  99. try {
  100. final media = await _platform.getMediaState();
  101. final fallback = await _platform.isMusicPlaying();
  102. final playing = media.hasAccess && media.hasSession
  103. ? media.playing
  104. : fallback;
  105. final changed = media != _media || playing != _musicPlaying;
  106. _media = media;
  107. _musicPlaying = playing;
  108. if (changed) _notify();
  109. } finally {
  110. _musicBusy = false;
  111. }
  112. }
  113. Future<void> _pollStatus() async {
  114. if (_statusBusy) return;
  115. _statusBusy = true;
  116. try {
  117. final next = await _platform.getStatus();
  118. if (next.batteryLevel != _status.batteryLevel ||
  119. next.charging != _status.charging ||
  120. next.wifi != _status.wifi) {
  121. _status = next;
  122. _notify();
  123. }
  124. } finally {
  125. _statusBusy = false;
  126. }
  127. }
  128. Future<void> setVolume(int index) async {
  129. final max = _volume.max <= 0 ? 15 : _volume.max;
  130. final clamped = index.clamp(0, max);
  131. if (_volume.fixed) {
  132. _lastVolumeTarget = clamped;
  133. _lastVolumeRead = _volume;
  134. return;
  135. }
  136. final writeId = ++_volumeWriteId;
  137. _lastVolumeTarget = clamped;
  138. _volumeSetting = true;
  139. _volume = VolumeInfo(index: clamped, max: max, streams: _volume.streams);
  140. if (clamped > 0) _lastNonZeroVolume = clamped;
  141. _notify();
  142. try {
  143. await _platform.setVolume(clamped);
  144. if (writeId != _volumeWriteId) return;
  145. final actual = await _platform.getVolume();
  146. if (writeId != _volumeWriteId) return;
  147. _lastVolumeRead = actual;
  148. _volume = actual;
  149. if (actual.index > 0) _lastNonZeroVolume = actual.index;
  150. if (!actual.fixed && actual.index != clamped) {
  151. _showVolumeDiagnostics = true;
  152. }
  153. _notify();
  154. } finally {
  155. if (writeId == _volumeWriteId) _volumeSetting = false;
  156. }
  157. }
  158. Future<void> adjustVolume(int delta) => setVolume(_volume.index + delta);
  159. Future<bool> openMediaAccessSettings() {
  160. return _platform.openNotificationAccessSettings();
  161. }
  162. /// Sends a pause key immediately. The next poll reconciles the real state.
  163. Future<void> pauseMusic() async {
  164. try {
  165. await _platform.sendMediaKey('pause');
  166. } catch (_) {
  167. // Media keys are best-effort; the next poll still reflects reality.
  168. }
  169. if (_musicPlaying) {
  170. _musicPlaying = false;
  171. _notify();
  172. }
  173. }
  174. /// Tries to resume a paused session. Returns false when nothing started, so
  175. /// the caller can open the configured music app instead.
  176. Future<bool> playMusic() async {
  177. try {
  178. final sent = await _platform.sendMediaKey('play');
  179. if (!sent) return false;
  180. await Future<void>.delayed(const Duration(milliseconds: 450));
  181. final media = await _platform.getMediaState();
  182. final fallback = await _platform.isMusicPlaying();
  183. final playing = media.hasAccess && media.hasSession
  184. ? media.playing
  185. : fallback;
  186. final changed = media != _media || playing != _musicPlaying;
  187. _media = media;
  188. _musicPlaying = playing;
  189. if (changed) _notify();
  190. return playing;
  191. } catch (_) {
  192. return false;
  193. }
  194. }
  195. Future<void> toggleMute() async {
  196. if (_volume.fixed) return;
  197. if (_volume.index > 0) {
  198. _lastNonZeroVolume = _volume.index;
  199. await setVolume(0);
  200. } else {
  201. final restore = _lastNonZeroVolume > 0
  202. ? _lastNonZeroVolume
  203. : (_volume.max <= 0
  204. ? 1
  205. : (_volume.max / 2).round().clamp(1, _volume.max));
  206. await setVolume(restore);
  207. }
  208. }
  209. void _notify() {
  210. if (!_disposed) notifyListeners();
  211. }
  212. @override
  213. void dispose() {
  214. _disposed = true;
  215. stop();
  216. super.dispose();
  217. }
  218. }