diff --git a/CHANGELOG.md b/CHANGELOG.md
index 367ed6e..a9bd6f2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,38 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased]
+### 🎚️ Equalizer, Dolby Atmos und automatische Wiedergabe (2026-08-20)
+- âś… **Was:** Die Einstellungen haben einen neuen Bereich **Ton** mit
+ Equalizer, Dolby Atmos und der automatischen Wiedergabe bei Kopfhörern.
+- 🎛️ **Equalizer (echt, nicht nachgebaut):** Neuer Bildschirm mit den
+ Klangprofilen deines Geräts (Normal, Rock, Pop …), den Frequenzbändern als
+ senkrechte Regler mit Beschriftung (60Hz … 14.0kHz) und den beiden
+ Drehreglern **Bass** und **Immersives Audio** — wie auf deinem Bild. Zum
+ Regeln senkrecht ĂĽber den Drehregler wischen.
+ Alle Werte werden gespeichert und nach dem Neustart wiederhergestellt.
+- 🔊 **Wie das technisch geht:** Neue native Android-Brücke
+ (`AudioEffectsBridge.kt`) auf `android.media.audiofx` — Equalizer,
+ BassBoost und Virtualizer hängen an der Audio-Session des Players. Da
+ just_audio die Session-ID erst beim Abspielen meldet, verbindet sich die
+ App bei jedem Wechsel automatisch neu. Bietet ein Gerät einen Effekt nicht
+ an, wird er ausgegraut statt die App abstĂĽrzen zu lassen.
+- 🎬 **Dolby Atmos:** Öffnet die **Klangeffekte des Systems** — auf deinem
+ POCO ist das die Dolby-Atmos-Seite. Eine App kann Dolby Atmos nicht selbst
+ steuern; der System-Weg ist der einzige, der wirklich funktioniert. Bietet
+ ein Gerät die Seite nicht an, sagt Melo das.
+- 🎧 **Automatische Wiedergabe:** Neuer Schalter — sobald Kopfhörer verbunden
+ werden (Klinke **oder Bluetooth**), spielt Melo weiter. Eine bereits
+ laufende Wiedergabe wird nie unterbrochen, und ohne Warteschlange passiert
+ nichts.
+- đź”§ **Details:**
+ - Neu: `player/audio_effects.dart` (6 Tests, u. a. Grenzwerte und
+ Wiederherstellen), `player/headphone_autoplay.dart` (6 Tests),
+ `settings/equalizer_screen.dart` inkl. selbst gezeichnetem Drehregler.
+ - `MODIFY_AUDIO_SETTINGS` im Manifest ergänzt (nötig für die Effekte).
+ - `MainActivity.kt` registriert die BrĂĽcke und gibt die Effekte beim
+ Beenden wieder frei.
+- âś… **Getestet:** 141 Tests grĂĽn, `flutter analyze` ohne Befund.
+
### 🏷️ Kategorien, Metadaten-Ansicht und Expertenmodus (2026-08-20)
- ✅ **Was:** Jeder Song kann jetzt **mehreren Kategorien** angehören. Sie
stehen in der Songzeile hinter dem Künstler ("Neoni | Trap cool · Chill") —
diff --git a/analysis_options.yaml b/analysis_options.yaml
index bf8d421..528a7f8 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -8,6 +8,11 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
analyzer:
+ errors:
+ # audio_session markiert AudioDeviceType als experimentell. Es ist der
+ # einzige Weg, verbundene Kopfhoerer zu erkennen ("Automatische
+ # Wiedergabe"), und wird bewusst verwendet.
+ experimental_member_use: ignore
exclude:
- build/**
- android/**
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 6b55cb6..d940a27 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -7,6 +7,8 @@
+
+
result.success(attach(call.argument("sessionId") ?: 0))
+ "setEnabled" -> {
+ enabled = call.argument("enabled") ?: false
+ equalizer?.enabled = enabled
+ bassBoost?.enabled = enabled
+ virtualizer?.enabled = enabled
+ result.success(null)
+ }
+ "setBandLevel" -> {
+ val band = (call.argument("band") ?: 0).toShort()
+ val level = (call.argument("level") ?: 0).toShort()
+ equalizer?.setBandLevel(band, level)
+ result.success(null)
+ }
+ "usePreset" -> {
+ val preset = (call.argument("preset") ?: 0).toShort()
+ equalizer?.usePreset(preset)
+ result.success(currentLevels())
+ }
+ "setBassStrength" -> {
+ val strength = (call.argument("strength") ?: 0).toShort()
+ bassBoost?.let { if (it.strengthSupported) it.setStrength(strength) }
+ result.success(null)
+ }
+ "setVirtualizerStrength" -> {
+ val strength = (call.argument("strength") ?: 0).toShort()
+ virtualizer?.let { if (it.strengthSupported) it.setStrength(strength) }
+ result.success(null)
+ }
+ "openSystemPanel" -> result.success(openSystemPanel())
+ "release" -> {
+ release()
+ result.success(null)
+ }
+ else -> result.notImplemented()
+ }
+ } catch (e: Exception) {
+ // Nicht jedes Geraet stellt jeden Effekt bereit; die App soll
+ // deswegen nicht abstuerzen, sondern den Fehler anzeigen koennen.
+ result.error("audio_effects", e.message, null)
+ }
+ }
+
+ private fun attach(newSessionId: Int): Map {
+ release()
+ sessionId = newSessionId
+
+ val eq = Equalizer(PRIORITY, sessionId)
+ eq.enabled = enabled
+ equalizer = eq
+
+ bassBoost = runCatching { BassBoost(PRIORITY, sessionId).also { it.enabled = enabled } }
+ .getOrNull()
+ virtualizer = runCatching { Virtualizer(PRIORITY, sessionId).also { it.enabled = enabled } }
+ .getOrNull()
+
+ val bands = (0 until eq.numberOfBands).map { index ->
+ mapOf(
+ "index" to index,
+ "centerFrequencyMilliHz" to eq.getCenterFreq(index.toShort()),
+ "level" to eq.getBandLevel(index.toShort()).toInt()
+ )
+ }
+ val presets = (0 until eq.numberOfPresets).map { eq.getPresetName(it.toShort()) }
+
+ return mapOf(
+ "bands" to bands,
+ "presets" to presets,
+ "minLevel" to eq.bandLevelRange[0].toInt(),
+ "maxLevel" to eq.bandLevelRange[1].toInt(),
+ "bassSupported" to (bassBoost?.strengthSupported ?: false),
+ "virtualizerSupported" to (virtualizer?.strengthSupported ?: false)
+ )
+ }
+
+ private fun currentLevels(): List {
+ val eq = equalizer ?: return emptyList()
+ return (0 until eq.numberOfBands).map { eq.getBandLevel(it.toShort()).toInt() }
+ }
+
+ /**
+ * Oeffnet die Klangeffekt-Oberflaeche des Systems — auf POCO/Xiaomi ist
+ * das die Dolby-Atmos-Seite. Gibt false zurueck, wenn kein System die
+ * Anzeige unterstuetzt.
+ */
+ private fun openSystemPanel(): Boolean {
+ val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
+ .putExtra(AudioEffect.EXTRA_AUDIO_SESSION, sessionId)
+ .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, activity.packageName)
+ .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
+ if (intent.resolveActivity(activity.packageManager) == null) return false
+ activity.startActivity(intent)
+ return true
+ }
+
+ fun release() {
+ equalizer?.release()
+ bassBoost?.release()
+ virtualizer?.release()
+ equalizer = null
+ bassBoost = null
+ virtualizer = null
+ }
+}
diff --git a/android/app/src/main/kotlin/de/baka/melo/MainActivity.kt b/android/app/src/main/kotlin/de/baka/melo/MainActivity.kt
index 04cb75b..c316390 100644
--- a/android/app/src/main/kotlin/de/baka/melo/MainActivity.kt
+++ b/android/app/src/main/kotlin/de/baka/melo/MainActivity.kt
@@ -1,5 +1,25 @@
package de.baka.melo
import com.ryanheise.audioservice.AudioServiceActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.MethodChannel
-class MainActivity : AudioServiceActivity()
+class MainActivity : AudioServiceActivity() {
+ private var audioEffects: AudioEffectsBridge? = null
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+ val bridge = AudioEffectsBridge(this)
+ audioEffects = bridge
+ MethodChannel(
+ flutterEngine.dartExecutor.binaryMessenger,
+ AudioEffectsBridge.CHANNEL
+ ).setMethodCallHandler(bridge::handle)
+ }
+
+ override fun onDestroy() {
+ audioEffects?.release()
+ audioEffects = null
+ super.onDestroy()
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
index 71460c9..6e77b0f 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -10,6 +10,7 @@ import 'library/library_service.dart';
import 'library/my_music_screen.dart';
import 'library/playlist_service.dart';
import 'library/search_screen.dart';
+import 'player/audio_effects.dart';
import 'player/audio_handler.dart';
import 'player/mini_player.dart';
import 'services/logger_service.dart';
@@ -24,6 +25,7 @@ late final PlaylistService _playlists;
late final OfflineMode _offlineMode;
late final CategoryService _categories;
late final AppSettings _settings;
+late final AudioEffects _effects;
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -45,6 +47,18 @@ Future main() async {
androidShowNotificationBadge: true,
),
);
+ _effects = AudioEffects();
+ await _effects.init();
+ // Die Audio-Session steht erst beim Abspielen fest und kann wechseln —
+ // die Klangeffekte müssen dann neu daran gehängt werden.
+ _handler.androidAudioSessionIdStream.listen((id) {
+ if (id != null) _effects.attach(id);
+ });
+ _handler.autoPlayOnHeadphones = _settings.autoPlayOnHeadphones;
+ _settings.addListener(
+ () => _handler.autoPlayOnHeadphones = _settings.autoPlayOnHeadphones,
+ );
+
runApp(const MeloApp());
}
@@ -62,6 +76,7 @@ class MeloApp extends StatelessWidget {
ChangeNotifierProvider.value(value: _offlineMode),
ChangeNotifierProvider.value(value: _categories),
ChangeNotifierProvider.value(value: _settings),
+ ChangeNotifierProvider.value(value: _effects),
],
child: MaterialApp(
title: 'Melo',
diff --git a/lib/player/audio_effects.dart b/lib/player/audio_effects.dart
new file mode 100644
index 0000000..6e322e4
--- /dev/null
+++ b/lib/player/audio_effects.dart
@@ -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 _bands = const [];
+ List _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 get bands => _bands;
+ List get presets => _presets;
+ int get minLevel => _minLevel;
+ int get maxLevel => _maxLevel;
+
+ /// Bass-Stärke 0–1000 (Android-Skala).
+ int get bass => _bass;
+
+ /// Virtualizer-Stärke 0–1000 ("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 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 attach(int sessionId) async {
+ try {
+ final config = await _channel
+ .invokeMapMethod('attach', {'sessionId': sessionId});
+ if (config == null) return;
+ _bands = [
+ for (final raw in (config['bands'] as List).cast