|
@@ -34,13 +34,28 @@ class SystemController extends ChangeNotifier {
|
|
|
bool _disposed = false;
|
|
bool _disposed = false;
|
|
|
int _lastNonZeroVolume = 0;
|
|
int _lastNonZeroVolume = 0;
|
|
|
|
|
|
|
|
|
|
+ /// How long a fresh write may take before a poll decides the read-back is
|
|
|
|
|
+ /// not tracking it. Car units often answer a write a few hundred ms later.
|
|
|
|
|
+ static const Duration _settleWindow = Duration(milliseconds: 1600);
|
|
|
|
|
+
|
|
|
|
|
+ int? _pendingIndex;
|
|
|
|
|
+ int? _pendingBaseline;
|
|
|
|
|
+ DateTime? _pendingSince;
|
|
|
|
|
+ int _pendingMisses = 0;
|
|
|
|
|
+ bool _readBackStale = false;
|
|
|
|
|
+ int? _staleIndex;
|
|
|
|
|
+
|
|
|
SystemStatus get status => _status;
|
|
SystemStatus get status => _status;
|
|
|
VolumeInfo get volume => _volume;
|
|
VolumeInfo get volume => _volume;
|
|
|
int get volumePercent => _volume.percent;
|
|
int get volumePercent => _volume.percent;
|
|
|
bool get isMuted => _volume.index <= 0;
|
|
bool get isMuted => _volume.index <= 0;
|
|
|
- bool get isVolumeFixed => _volume.fixed;
|
|
|
|
|
|
|
+ bool get isVolumeFixed => !_volume.isControllable;
|
|
|
bool get isMusicPlaying => _musicPlaying;
|
|
bool get isMusicPlaying => _musicPlaying;
|
|
|
|
|
|
|
|
|
|
+ /// True when the car reports the same level no matter what was written.
|
|
|
|
|
+ /// The card still shows the requested level, marked with a question mark.
|
|
|
|
|
+ bool get volumeUnconfirmed => _readBackStale;
|
|
|
|
|
+
|
|
|
String? get volumeDiagnostics {
|
|
String? get volumeDiagnostics {
|
|
|
if (!_showVolumeDiagnostics) return null;
|
|
if (!_showVolumeDiagnostics) return null;
|
|
|
final write = _lastVolumeTarget == null
|
|
final write = _lastVolumeTarget == null
|
|
@@ -49,7 +64,15 @@ class SystemController extends ChangeNotifier {
|
|
|
final streams = _volume.streams.entries
|
|
final streams = _volume.streams.entries
|
|
|
.map((entry) => '${entry.key} ${entry.value}')
|
|
.map((entry) => '${entry.key} ${entry.value}')
|
|
|
.join(' ');
|
|
.join(' ');
|
|
|
- return 'raw ${_volume.index}/${_volume.max} fixed ${_volume.fixed}\n'
|
|
|
|
|
|
|
+ final state = [
|
|
|
|
|
+ 'raw ${_volume.index}/${_volume.max}',
|
|
|
|
|
+ 'min ${_volume.min}',
|
|
|
|
|
+ 'fixed ${_volume.fixed}',
|
|
|
|
|
+ 'source ${_volume.source}',
|
|
|
|
|
+ if (_volume.vendor.isNotEmpty) 'vendor ${_volume.vendor}',
|
|
|
|
|
+ if (_readBackStale) 'unconfirmed',
|
|
|
|
|
+ ].join(' ');
|
|
|
|
|
+ return '$state\n'
|
|
|
'write $write\n'
|
|
'write $write\n'
|
|
|
'${streams.isEmpty ? 'streams unavailable' : streams}';
|
|
'${streams.isEmpty ? 'streams unavailable' : streams}';
|
|
|
}
|
|
}
|
|
@@ -94,20 +117,114 @@ class SystemController extends ChangeNotifier {
|
|
|
_volumeBusy = true;
|
|
_volumeBusy = true;
|
|
|
try {
|
|
try {
|
|
|
final next = await _platform.getVolume();
|
|
final next = await _platform.getVolume();
|
|
|
|
|
+ if (!_active) return;
|
|
|
if (_lastVolumeTarget != null) _lastVolumeRead = next;
|
|
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();
|
|
|
|
|
|
|
+
|
|
|
|
|
+ final pending = _pendingIndex;
|
|
|
|
|
+ if (pending != null) {
|
|
|
|
|
+ if (next.index == pending) {
|
|
|
|
|
+ _confirmVolume(next);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ final baseline = _pendingBaseline;
|
|
|
|
|
+ final since = _pendingSince;
|
|
|
|
|
+ final elapsed = since == null
|
|
|
|
|
+ ? _settleWindow
|
|
|
|
|
+ : DateTime.now().difference(since);
|
|
|
|
|
+ if (baseline != null && next.index == baseline) {
|
|
|
|
|
+ if (elapsed >= _settleWindow) {
|
|
|
|
|
+ _pendingMisses++;
|
|
|
|
|
+ if (_pendingMisses >= 2) {
|
|
|
|
|
+ _readBackStale = true;
|
|
|
|
|
+ _staleIndex = baseline;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ _mergeMetadata(next);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // The car may still be applying an earlier step of the same gesture.
|
|
|
|
|
+ if (elapsed < _settleWindow) {
|
|
|
|
|
+ _mergeMetadata(next);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // Something else moved the volume (hardware keys, another app).
|
|
|
|
|
+ _adoptVolume(next);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (_readBackStale) {
|
|
|
|
|
+ if (next.index == _staleIndex) {
|
|
|
|
|
+ _mergeMetadata(next);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ _readBackStale = false;
|
|
|
|
|
+ _staleIndex = null;
|
|
|
}
|
|
}
|
|
|
|
|
+ _adoptVolume(next);
|
|
|
} finally {
|
|
} finally {
|
|
|
_volumeBusy = false;
|
|
_volumeBusy = false;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ bool _sameVolume(VolumeInfo a, VolumeInfo b) {
|
|
|
|
|
+ return a.index == b.index &&
|
|
|
|
|
+ a.max == b.max &&
|
|
|
|
|
+ a.min == b.min &&
|
|
|
|
|
+ a.fixed == b.fixed &&
|
|
|
|
|
+ a.source == b.source &&
|
|
|
|
|
+ a.vendor == b.vendor &&
|
|
|
|
|
+ mapEquals(a.streams, b.streams);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ void _clearPending() {
|
|
|
|
|
+ _pendingIndex = null;
|
|
|
|
|
+ _pendingBaseline = null;
|
|
|
|
|
+ _pendingSince = null;
|
|
|
|
|
+ _pendingMisses = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ void _confirmVolume(VolumeInfo next) {
|
|
|
|
|
+ _clearPending();
|
|
|
|
|
+ _readBackStale = false;
|
|
|
|
|
+ _staleIndex = null;
|
|
|
|
|
+ _applyVolume(next);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ void _adoptVolume(VolumeInfo next) {
|
|
|
|
|
+ _clearPending();
|
|
|
|
|
+ _readBackStale = false;
|
|
|
|
|
+ _staleIndex = null;
|
|
|
|
|
+ _applyVolume(next);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ void _applyVolume(VolumeInfo next) {
|
|
|
|
|
+ if (next.index > 0) _lastNonZeroVolume = next.index;
|
|
|
|
|
+ if (!_sameVolume(next, _volume)) {
|
|
|
|
|
+ _volume = next;
|
|
|
|
|
+ _notify();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Keeps the level the user chose while refreshing the metadata around it.
|
|
|
|
|
+ void _mergeMetadata(VolumeInfo next) {
|
|
|
|
|
+ final max = next.max <= 0 ? _volume.max : next.max;
|
|
|
|
|
+ final min = next.min.clamp(0, max);
|
|
|
|
|
+ final index = _volume.index.clamp(min, max);
|
|
|
|
|
+ final merged = VolumeInfo(
|
|
|
|
|
+ index: index,
|
|
|
|
|
+ max: max,
|
|
|
|
|
+ min: min,
|
|
|
|
|
+ fixed: next.fixed,
|
|
|
|
|
+ streams: next.streams,
|
|
|
|
|
+ source: next.source,
|
|
|
|
|
+ vendor: next.vendor,
|
|
|
|
|
+ );
|
|
|
|
|
+ if (!_sameVolume(merged, _volume)) {
|
|
|
|
|
+ _volume = merged;
|
|
|
|
|
+ _notify();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
Future<void> _pollMusic() async {
|
|
Future<void> _pollMusic() async {
|
|
|
if (_musicBusy || !_active) return;
|
|
if (_musicBusy || !_active) return;
|
|
|
_musicBusy = true;
|
|
_musicBusy = true;
|
|
@@ -144,16 +261,25 @@ class SystemController extends ChangeNotifier {
|
|
|
|
|
|
|
|
Future<void> setVolume(int index) async {
|
|
Future<void> setVolume(int index) async {
|
|
|
final max = _volume.max <= 0 ? 15 : _volume.max;
|
|
final max = _volume.max <= 0 ? 15 : _volume.max;
|
|
|
- final clamped = index.clamp(0, max);
|
|
|
|
|
- if (_volume.fixed) {
|
|
|
|
|
|
|
+ final min = _volume.min.clamp(0, max);
|
|
|
|
|
+ final clamped = index.clamp(min, max);
|
|
|
|
|
+ if (isVolumeFixed) {
|
|
|
_lastVolumeTarget = clamped;
|
|
_lastVolumeTarget = clamped;
|
|
|
_lastVolumeRead = _volume;
|
|
_lastVolumeRead = _volume;
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
+ final before = _volume.index;
|
|
|
|
|
+ if (clamped == before && _pendingIndex == null) return;
|
|
|
final writeId = ++_volumeWriteId;
|
|
final writeId = ++_volumeWriteId;
|
|
|
_lastVolumeTarget = clamped;
|
|
_lastVolumeTarget = clamped;
|
|
|
|
|
+ if (_pendingIndex == null) {
|
|
|
|
|
+ _pendingBaseline = _lastVolumeRead?.index ?? before;
|
|
|
|
|
+ }
|
|
|
|
|
+ _pendingIndex = clamped;
|
|
|
|
|
+ _pendingSince = DateTime.now();
|
|
|
|
|
+ _pendingMisses = 0;
|
|
|
_volumeSetting = true;
|
|
_volumeSetting = true;
|
|
|
- _volume = VolumeInfo(index: clamped, max: max, streams: _volume.streams);
|
|
|
|
|
|
|
+ _volume = _volume.copyWith(index: clamped);
|
|
|
if (clamped > 0) _lastNonZeroVolume = clamped;
|
|
if (clamped > 0) _lastNonZeroVolume = clamped;
|
|
|
_notify();
|
|
_notify();
|
|
|
try {
|
|
try {
|
|
@@ -162,12 +288,14 @@ class SystemController extends ChangeNotifier {
|
|
|
final actual = await _platform.getVolume();
|
|
final actual = await _platform.getVolume();
|
|
|
if (writeId != _volumeWriteId) return;
|
|
if (writeId != _volumeWriteId) return;
|
|
|
_lastVolumeRead = actual;
|
|
_lastVolumeRead = actual;
|
|
|
- _volume = actual;
|
|
|
|
|
- if (actual.index > 0) _lastNonZeroVolume = actual.index;
|
|
|
|
|
- if (!actual.fixed && actual.index != clamped) {
|
|
|
|
|
- _showVolumeDiagnostics = true;
|
|
|
|
|
|
|
+ if (actual.index == clamped) {
|
|
|
|
|
+ _confirmVolume(actual);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // The car has not reported the new level yet. Keep what the user set;
|
|
|
|
|
+ // the next polls either confirm it or mark it unconfirmed instead of
|
|
|
|
|
+ // snapping the slider back to the old value.
|
|
|
|
|
+ _mergeMetadata(actual);
|
|
|
}
|
|
}
|
|
|
- _notify();
|
|
|
|
|
} finally {
|
|
} finally {
|
|
|
if (writeId == _volumeWriteId) _volumeSetting = false;
|
|
if (writeId == _volumeWriteId) _volumeSetting = false;
|
|
|
}
|
|
}
|
|
@@ -215,7 +343,7 @@ class SystemController extends ChangeNotifier {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
Future<void> toggleMute() async {
|
|
Future<void> toggleMute() async {
|
|
|
- if (_volume.fixed) return;
|
|
|
|
|
|
|
+ if (isVolumeFixed) return;
|
|
|
if (_volume.index > 0) {
|
|
if (_volume.index > 0) {
|
|
|
_lastNonZeroVolume = _volume.index;
|
|
_lastNonZeroVolume = _volume.index;
|
|
|
await setVolume(0);
|
|
await setVolume(0);
|