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
This commit is contained in:
2026-08-20 18:35:06 +02:00
co-authored by Claude Opus 5
parent 7aa98864aa
commit 1325badf90
13 changed files with 1020 additions and 1 deletions
+198
View File
@@ -0,0 +1,198 @@
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;
}
}
}
+29
View File
@@ -1,10 +1,12 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart';
import '../library/database.dart';
import '../services/cache_manager.dart';
import '../services/navidrome_service.dart';
import 'headphone_autoplay.dart';
import 'sleep_timer.dart';
/// Entscheidet, ob eine gespeicherte Wiedergabeposition beim Laden
@@ -37,6 +39,10 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
final NavidromeService _nav = NavidromeService();
int? _lastCountedIndex;
/// Wiedergabe automatisch starten, sobald Kopfhörer verbunden werden.
/// Wird von den App-Einstellungen gesetzt.
bool autoPlayOnHeadphones = false;
MeloAudioHandler({required this.db}) {
_cache = CacheManager();
_cache.init();
@@ -71,6 +77,8 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
}
});
_watchHeadphones();
// Ermittelte Dauer nachtragen
_player.durationStream.listen((duration) {
final item = mediaItem.value;
@@ -80,6 +88,27 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
});
}
/// Startet die Wiedergabe, wenn Kopfhörer verbunden werden und der Nutzer
/// das in den Einstellungen erlaubt hat.
Future<void> _watchHeadphones() async {
final session = await AudioSession.instance;
session.devicesChangedEventStream.listen((event) {
final added = event.devicesAdded.map((d) => d.type).toSet();
if (shouldAutoPlay(
added: added,
enabled: autoPlayOnHeadphones,
hasQueue: queue.value.isNotEmpty,
playing: _player.playing,
)) {
play();
}
});
}
/// Audio-Session des Players — die Klangeffekte hängen daran.
Stream<int?> get androidAudioSessionIdStream =>
_player.androidAudioSessionIdStream;
/// Ersetzt die Warteschlange und startet ab [startIndex].
/// Auto-Caching: Streame von Server + speichere lokal gleichzeitig.
Future<void> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
+22
View File
@@ -0,0 +1,22 @@
import 'package:audio_session/audio_session.dart';
/// Gerätetypen, die als "Kopfhörer" gelten — Klinke wie Bluetooth.
const _headphoneTypes = {
AudioDeviceType.wiredHeadset,
AudioDeviceType.wiredHeadphones,
AudioDeviceType.bluetoothA2dp,
AudioDeviceType.bluetoothSco,
};
/// Entscheidet, ob nach dem Verbinden von [added] automatisch abgespielt wird.
/// Nur wenn die Einstellung aktiv ist, etwas in der Warteschlange steht und
/// gerade nichts läuft — eine laufende Wiedergabe wird nie unterbrochen.
bool shouldAutoPlay({
required Set<AudioDeviceType> added,
required bool enabled,
required bool hasQueue,
required bool playing,
}) {
if (!enabled || !hasQueue || playing) return false;
return added.any(_headphoneTypes.contains);
}