Files
Melo/lib/player/audio_effects.dart
dustinandClaude Opus 5 1325badf90 Equalizer (nativ), Dolby-Atmos-Zugang und Auto-Wiedergabe bei Kopfhoerern
- Native Bruecke AudioEffectsBridge.kt: Equalizer + BassBoost + Virtualizer
  auf der Audio-Session des Players, inkl. System-Klangeffekt-Panel
- Dart-Seite AudioEffects: Presets, Baender, Bass/Immersiv, persistiert
- EqualizerScreen nach Vorlage inkl. Drehregler (CustomPainter)
- Auto-Wiedergabe bei verbundenen Kopfhoerern (Klinke + Bluetooth)
- MODIFY_AUDIO_SETTINGS im Manifest
- Einstellungen: neuer Bereich "Ton"

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011snPXPafPC5i87o68H14W7
2026-08-20 18:35:06 +02:00

199 lines
6.6 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Ein Equalizer-Band, wie es das Gerät meldet.
class EqBand {
const EqBand({required this.index, required this.centerHz, required this.level});
final int index;
/// Mittenfrequenz in Hz (Android liefert Milli-Hertz).
final int centerHz;
/// Pegel in Millibel.
final int level;
EqBand withLevel(int newLevel) =>
EqBand(index: index, centerHz: centerHz, level: newLevel);
/// "60Hz" bzw. "14.0kHz" — wie in der Vorlage.
String get label => centerHz >= 1000
? '${(centerHz / 1000).toStringAsFixed(1)}kHz'
: '${centerHz}Hz';
}
/// Steuert die nativen Android-Klangeffekte (Equalizer, Bass, Immersives
/// Audio) und merkt sich die Einstellungen über Neustarts hinweg.
///
/// Die Effekte hängen an der Audio-Session des Players. Die meldet just_audio
/// erst beim Abspielen und bei jedem Wechsel neu — deshalb [attach].
class AudioEffects extends ChangeNotifier {
AudioEffects({MethodChannel? channel})
: _channel = channel ?? const MethodChannel('de.baka.melo/audio_effects');
final MethodChannel _channel;
static const _keyEnabled = 'eq_aktiv';
static const _keyLevels = 'eq_pegel';
static const _keyBass = 'eq_bass';
static const _keyVirtualizer = 'eq_immersiv';
bool _enabled = false;
bool _available = false;
List<EqBand> _bands = const [];
List<String> _presets = const [];
int _minLevel = -1500;
int _maxLevel = 1500;
int _bass = 0;
int _virtualizer = 0;
bool _bassSupported = false;
bool _virtualizerSupported = false;
String? _error;
bool get enabled => _enabled;
/// Ob das Gerät die Effekte überhaupt bereitgestellt hat.
bool get available => _available;
List<EqBand> get bands => _bands;
List<String> get presets => _presets;
int get minLevel => _minLevel;
int get maxLevel => _maxLevel;
/// Bass-Stärke 01000 (Android-Skala).
int get bass => _bass;
/// Virtualizer-Stärke 01000 ("Immersives Audio").
int get virtualizer => _virtualizer;
bool get bassSupported => _bassSupported;
bool get virtualizerSupported => _virtualizerSupported;
String? get error => _error;
/// Lädt die gespeicherten Werte. Angewendet werden sie, sobald über
/// [attach] eine Audio-Session bekannt ist.
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
_enabled = prefs.getBool(_keyEnabled) ?? false;
_bass = prefs.getInt(_keyBass) ?? 0;
_virtualizer = prefs.getInt(_keyVirtualizer) ?? 0;
notifyListeners();
}
/// Verbindet die Effekte mit der Audio-Session [sessionId] und stellt die
/// gespeicherten Werte wieder her.
Future<void> attach(int sessionId) async {
try {
final config = await _channel
.invokeMapMethod<String, dynamic>('attach', {'sessionId': sessionId});
if (config == null) return;
_bands = [
for (final raw in (config['bands'] as List).cast<Map>())
EqBand(
index: raw['index'] as int,
// Android liefert Milli-Hertz.
centerHz: (raw['centerFrequencyMilliHz'] as int) ~/ 1000,
level: raw['level'] as int,
),
];
_presets = (config['presets'] as List).cast<String>();
_minLevel = config['minLevel'] as int;
_maxLevel = config['maxLevel'] as int;
_bassSupported = config['bassSupported'] as bool? ?? false;
_virtualizerSupported = config['virtualizerSupported'] as bool? ?? false;
_available = true;
_error = null;
await _restoreSavedLevels();
await setEnabled(_enabled);
await setBass(_bass);
await setVirtualizer(_virtualizer);
} on PlatformException catch (e) {
_available = false;
_error = e.message;
}
notifyListeners();
}
Future<void> setEnabled(bool value) async {
_enabled = value;
notifyListeners();
await _invoke('setEnabled', {'enabled': value});
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyEnabled, value);
}
Future<void> setBandLevel(int index, int level) async {
final clamped = level.clamp(_minLevel, _maxLevel);
_bands = [
for (final band in _bands)
band.index == index ? band.withLevel(clamped) : band,
];
notifyListeners();
await _invoke('setBandLevel', {'band': index, 'level': clamped});
await _saveLevels();
}
Future<void> usePreset(int preset) async {
final levels = await _invoke<List<Object?>>('usePreset', {'preset': preset});
if (levels != null && levels.length == _bands.length) {
_bands = [
for (var i = 0; i < _bands.length; i++)
_bands[i].withLevel(levels[i] as int),
];
notifyListeners();
await _saveLevels();
}
}
Future<void> setBass(int strength) async {
_bass = strength.clamp(0, 1000);
notifyListeners();
await _invoke('setBassStrength', {'strength': _bass});
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyBass, _bass);
}
Future<void> setVirtualizer(int strength) async {
_virtualizer = strength.clamp(0, 1000);
notifyListeners();
await _invoke('setVirtualizerStrength', {'strength': _virtualizer});
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyVirtualizer, _virtualizer);
}
/// Öffnet die Klangeffekte des Systems (auf POCO/Xiaomi Dolby Atmos).
/// Gibt false zurück, wenn das Gerät keine solche Oberfläche anbietet.
Future<bool> openSystemPanel() async {
return await _invoke<bool>('openSystemPanel', null) ?? false;
}
Future<void> _restoreSavedLevels() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getStringList(_keyLevels);
if (saved == null || saved.length != _bands.length) return;
for (var i = 0; i < _bands.length; i++) {
final level = int.tryParse(saved[i]);
if (level == null) continue;
_bands[i] = _bands[i].withLevel(level);
await _invoke('setBandLevel', {'band': _bands[i].index, 'level': level});
}
}
Future<void> _saveLevels() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_keyLevels, [for (final b in _bands) b.level.toString()]);
}
Future<T?> _invoke<T>(String method, Map<String, dynamic>? args) async {
try {
return await _channel.invokeMethod<T>(method, args);
} on PlatformException catch (e) {
_error = e.message;
notifyListeners();
return null;
} on MissingPluginException {
// Kein Android (Tests, Desktop): Effekte gibt es dort nicht.
return null;
}
}
}