diff --git a/apps/chanora_flutter/lib/services/ui_preferences_service.dart b/apps/chanora_flutter/lib/services/ui_preferences_service.dart index c0b99a9..cbba117 100644 --- a/apps/chanora_flutter/lib/services/ui_preferences_service.dart +++ b/apps/chanora_flutter/lib/services/ui_preferences_service.dart @@ -18,11 +18,19 @@ class UiSettings { this.host = '', this.nickname = '', this.themeMode = UiThemeMode.system, + this.transmitModeIndex, + this.releaseTailMs, + this.inputDeviceId, + this.outputDeviceId, }); final String host; final String nickname; final UiThemeMode themeMode; + final int? transmitModeIndex; + final int? releaseTailMs; + final String? inputDeviceId; + final String? outputDeviceId; } class UiPreferencesService { @@ -30,6 +38,10 @@ class UiPreferencesService { static const _nicknameKey = 'ui.nickname'; static const _themeModeKey = 'ui.theme_mode'; static const _permissionsExplainedKey = 'perms_explained'; + static const _transmitModeIndexKey = 'voice.transmit_mode_index'; + static const _releaseTailMsKey = 'voice.release_tail_ms'; + static const _inputDeviceIdKey = 'audio.input_device_id'; + static const _outputDeviceIdKey = 'audio.output_device_id'; const UiPreferencesService(); @@ -39,6 +51,10 @@ class UiPreferencesService { host: prefs.getString(_hostKey) ?? '', nickname: prefs.getString(_nicknameKey) ?? '', themeMode: UiThemeMode.fromStorage(prefs.getString(_themeModeKey)), + transmitModeIndex: prefs.getInt(_transmitModeIndexKey), + releaseTailMs: prefs.getInt(_releaseTailMsKey), + inputDeviceId: prefs.getString(_inputDeviceIdKey), + outputDeviceId: prefs.getString(_outputDeviceIdKey), ); } @@ -53,6 +69,34 @@ class UiPreferencesService { await prefs.setString(_themeModeKey, themeMode.name); } + Future saveTransmitModeIndex(int index) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_transmitModeIndexKey, index); + } + + Future saveReleaseTailMs(int ms) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_releaseTailMsKey, ms); + } + + Future saveInputDeviceId(String? id) async { + final prefs = await SharedPreferences.getInstance(); + if (id == null) { + await prefs.remove(_inputDeviceIdKey); + } else { + await prefs.setString(_inputDeviceIdKey, id); + } + } + + Future saveOutputDeviceId(String? id) async { + final prefs = await SharedPreferences.getInstance(); + if (id == null) { + await prefs.remove(_outputDeviceIdKey); + } else { + await prefs.setString(_outputDeviceIdKey, id); + } + } + Future hasExplainedPermissions() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool(_permissionsExplainedKey) ?? false; diff --git a/apps/chanora_flutter/lib/widgets/audio_device_list_tile.dart b/apps/chanora_flutter/lib/widgets/audio_device_list_tile.dart index 70497a5..97f3819 100644 --- a/apps/chanora_flutter/lib/widgets/audio_device_list_tile.dart +++ b/apps/chanora_flutter/lib/widgets/audio_device_list_tile.dart @@ -27,6 +27,7 @@ class AudioDeviceListTile extends StatefulWidget { AudioDeviceListLoader? loadDevices, AudioDeviceSetter? setInputDevice, AudioDeviceSetter? setOutputDevice, + this.onDeviceChanged, }) : loadDevices = loadDevices ?? rust.listAudioDevices, setInputDevice = setInputDevice ?? rust.setInputDevice, setOutputDevice = setOutputDevice ?? rust.setOutputDevice; @@ -46,6 +47,10 @@ class AudioDeviceListTile extends StatefulWidget { /// Selects an output device. final AudioDeviceSetter setOutputDevice; + /// Called after a device selection succeeds. Receives the device id + /// (null for system default). + final ValueChanged? onDeviceChanged; + @override State createState() => _AudioDeviceListTileState(); } @@ -88,6 +93,7 @@ class _AudioDeviceListTileState extends State { setState(() { _selectedDeviceId = deviceId; }); + widget.onDeviceChanged?.call(deviceId); final selectedName = _selectedDevice?.name ?? 'System default'; ScaffoldMessenger.of(context).showSnackBar( diff --git a/apps/chanora_flutter/lib/widgets/snapshot_view.dart b/apps/chanora_flutter/lib/widgets/snapshot_view.dart index 149d22e..f53b432 100644 --- a/apps/chanora_flutter/lib/widgets/snapshot_view.dart +++ b/apps/chanora_flutter/lib/widgets/snapshot_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../l10n/generated/app_localizations.dart'; import '../services/channel_spacer.dart'; @@ -571,11 +572,40 @@ class _ClientVolumePreference { } class _ClientVolumePreferences extends ChangeNotifier { - _ClientVolumePreferences._(); + _ClientVolumePreferences._() { + _load(); + } static final instance = _ClientVolumePreferences._(); + static const _prefsKey = 'client_volume_prefs'; + final Map _byClientId = {}; + bool _loaded = false; + + Future _load() async { + if (_loaded) return; + _loaded = true; + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getStringList(_prefsKey) ?? const []; + for (final entry in raw) { + final parts = entry.split(':'); + if (parts.length == 3) { + final id = BigInt.tryParse(parts[0]); + final volume = double.tryParse(parts[1]); + final muted = parts[2] == '1'; + if (id != null && volume != null) { + _byClientId[id] = _ClientVolumePreference( + volume: volume, + muted: muted, + ); + } + } + } + notifyListeners(); + } catch (_) {} + } _ClientVolumePreference preferenceFor(BigInt clientId) { return _byClientId[clientId] ?? const _ClientVolumePreference(); @@ -588,6 +618,17 @@ class _ClientVolumePreferences extends ChangeNotifier { _byClientId.remove(clientId); } notifyListeners(); + _save(); + } + + Future _save() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = _byClientId.entries + .map((e) => '${e.key}:${e.value.volume}:${e.value.muted ? 1 : 0}') + .toList(); + await prefs.setStringList(_prefsKey, raw); + } catch (_) {} } } diff --git a/apps/chanora_flutter/lib/widgets/voice_settings.dart b/apps/chanora_flutter/lib/widgets/voice_settings.dart index 7a62981..f0c80dd 100644 --- a/apps/chanora_flutter/lib/widgets/voice_settings.dart +++ b/apps/chanora_flutter/lib/widgets/voice_settings.dart @@ -47,6 +47,8 @@ class VoiceSettingsDialog extends StatefulWidget { this.talkPower, this.neededTalkPower, this.talkPowerGranted, + this.onInputDeviceChanged, + this.onOutputDeviceChanged, }); final rust.BridgeTransmitMode initialMode; @@ -58,6 +60,8 @@ class VoiceSettingsDialog extends StatefulWidget { final int? talkPower; final int? neededTalkPower; final bool? talkPowerGranted; + final ValueChanged? onInputDeviceChanged; + final ValueChanged? onOutputDeviceChanged; @override State createState() => _VoiceSettingsDialogState(); @@ -207,13 +211,15 @@ class _VoiceSettingsDialogState extends State { if (!isAndroidHost && !isIosHost) ...[ const Divider(height: 24), const VoiceSectionHeader('Audio devices'), - const AudioDeviceListTile( + AudioDeviceListTile( label: 'Input', kind: AudioDeviceKind.input, + onDeviceChanged: widget.onInputDeviceChanged, ), - const AudioDeviceListTile( + AudioDeviceListTile( label: 'Output', kind: AudioDeviceKind.output, + onDeviceChanged: widget.onOutputDeviceChanged, ), const SizedBox(height: 8), ], diff --git a/apps/chanora_flutter/test/services/ui_preferences_service_test.dart b/apps/chanora_flutter/test/services/ui_preferences_service_test.dart index 93613c0..560dd97 100644 --- a/apps/chanora_flutter/test/services/ui_preferences_service_test.dart +++ b/apps/chanora_flutter/test/services/ui_preferences_service_test.dart @@ -67,4 +67,44 @@ void main() { expect(settings.themeMode, UiThemeMode.system); }); + + test('loads null voice settings when unset', () async { + final settings = await service.loadSettings(); + + expect(settings.transmitModeIndex, isNull); + expect(settings.releaseTailMs, isNull); + expect(settings.inputDeviceId, isNull); + expect(settings.outputDeviceId, isNull); + }); + + test('saves and loads transmit mode index', () async { + await service.saveTransmitModeIndex(1); + final settings = await service.loadSettings(); + + expect(settings.transmitModeIndex, 1); + }); + + test('saves and loads release tail ms', () async { + await service.saveReleaseTailMs(300); + final settings = await service.loadSettings(); + + expect(settings.releaseTailMs, 300); + }); + + test('saves and loads audio device ids', () async { + await service.saveInputDeviceId('input-123'); + await service.saveOutputDeviceId('output-456'); + final settings = await service.loadSettings(); + + expect(settings.inputDeviceId, 'input-123'); + expect(settings.outputDeviceId, 'output-456'); + }); + + test('removes audio device id when set to null', () async { + await service.saveInputDeviceId('input-123'); + await service.saveInputDeviceId(null); + final settings = await service.loadSettings(); + + expect(settings.inputDeviceId, isNull); + }); }