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
+32
View File
@@ -5,6 +5,38 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [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) ### 🏷️ Kategorien, Metadaten-Ansicht und Expertenmodus (2026-08-20)
-**Was:** Jeder Song kann jetzt **mehreren Kategorien** angehören. Sie -**Was:** Jeder Song kann jetzt **mehreren Kategorien** angehören. Sie
stehen in der Songzeile hinter dem Künstler ("Neoni | Trap cool · Chill") — stehen in der Songzeile hinter dem Künstler ("Neoni | Trap cool · Chill") —
+5
View File
@@ -8,6 +8,11 @@
# The following line activates a set of recommended lints for Flutter apps, # The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
analyzer: 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: exclude:
- build/** - build/**
- android/** - android/**
+2
View File
@@ -7,6 +7,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- Equalizer/Bass/Virtualizer (android.media.audiofx) -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<!-- Musik lesen: Android 13+ (API 33) nutzt READ_MEDIA_AUDIO, ältere READ_EXTERNAL_STORAGE. --> <!-- Musik lesen: Android 13+ (API 33) nutzt READ_MEDIA_AUDIO, ältere READ_EXTERNAL_STORAGE. -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
@@ -0,0 +1,140 @@
package de.baka.melo
import android.app.Activity
import android.content.Intent
import android.media.audiofx.AudioEffect
import android.media.audiofx.BassBoost
import android.media.audiofx.Equalizer
import android.media.audiofx.Virtualizer
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Bruecke zu android.media.audiofx: Equalizer, Bass-Boost und Virtualizer
* ("Immersives Audio") auf der Audio-Session des Players.
*
* Effekte haengen an einer Session-ID. just_audio meldet die ID erst, wenn
* gespielt wird — bei jedem Wechsel ruft Dart erneut [attach] auf, deshalb
* werden die alten Effekte vorher freigegeben.
*/
class AudioEffectsBridge(private val activity: Activity) {
companion object {
const val CHANNEL = "de.baka.melo/audio_effects"
/** Prioritaet der Effekte gegenueber anderen Apps auf derselben Session. */
private const val PRIORITY = 0
}
private var equalizer: Equalizer? = null
private var bassBoost: BassBoost? = null
private var virtualizer: Virtualizer? = null
private var sessionId: Int = 0
private var enabled: Boolean = false
fun handle(call: MethodCall, result: MethodChannel.Result) {
try {
when (call.method) {
"attach" -> result.success(attach(call.argument<Int>("sessionId") ?: 0))
"setEnabled" -> {
enabled = call.argument<Boolean>("enabled") ?: false
equalizer?.enabled = enabled
bassBoost?.enabled = enabled
virtualizer?.enabled = enabled
result.success(null)
}
"setBandLevel" -> {
val band = (call.argument<Int>("band") ?: 0).toShort()
val level = (call.argument<Int>("level") ?: 0).toShort()
equalizer?.setBandLevel(band, level)
result.success(null)
}
"usePreset" -> {
val preset = (call.argument<Int>("preset") ?: 0).toShort()
equalizer?.usePreset(preset)
result.success(currentLevels())
}
"setBassStrength" -> {
val strength = (call.argument<Int>("strength") ?: 0).toShort()
bassBoost?.let { if (it.strengthSupported) it.setStrength(strength) }
result.success(null)
}
"setVirtualizerStrength" -> {
val strength = (call.argument<Int>("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<String, Any> {
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<Int> {
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
}
}
@@ -1,5 +1,25 @@
package de.baka.melo package de.baka.melo
import com.ryanheise.audioservice.AudioServiceActivity 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()
}
}
+15
View File
@@ -10,6 +10,7 @@ import 'library/library_service.dart';
import 'library/my_music_screen.dart'; import 'library/my_music_screen.dart';
import 'library/playlist_service.dart'; import 'library/playlist_service.dart';
import 'library/search_screen.dart'; import 'library/search_screen.dart';
import 'player/audio_effects.dart';
import 'player/audio_handler.dart'; import 'player/audio_handler.dart';
import 'player/mini_player.dart'; import 'player/mini_player.dart';
import 'services/logger_service.dart'; import 'services/logger_service.dart';
@@ -24,6 +25,7 @@ late final PlaylistService _playlists;
late final OfflineMode _offlineMode; late final OfflineMode _offlineMode;
late final CategoryService _categories; late final CategoryService _categories;
late final AppSettings _settings; late final AppSettings _settings;
late final AudioEffects _effects;
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -45,6 +47,18 @@ Future<void> main() async {
androidShowNotificationBadge: true, 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()); runApp(const MeloApp());
} }
@@ -62,6 +76,7 @@ class MeloApp extends StatelessWidget {
ChangeNotifierProvider<OfflineMode>.value(value: _offlineMode), ChangeNotifierProvider<OfflineMode>.value(value: _offlineMode),
ChangeNotifierProvider<CategoryService>.value(value: _categories), ChangeNotifierProvider<CategoryService>.value(value: _categories),
ChangeNotifierProvider<AppSettings>.value(value: _settings), ChangeNotifierProvider<AppSettings>.value(value: _settings),
ChangeNotifierProvider<AudioEffects>.value(value: _effects),
], ],
child: MaterialApp( child: MaterialApp(
title: 'Melo', title: 'Melo',
+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 'dart:async';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import '../library/database.dart'; import '../library/database.dart';
import '../services/cache_manager.dart'; import '../services/cache_manager.dart';
import '../services/navidrome_service.dart'; import '../services/navidrome_service.dart';
import 'headphone_autoplay.dart';
import 'sleep_timer.dart'; import 'sleep_timer.dart';
/// Entscheidet, ob eine gespeicherte Wiedergabeposition beim Laden /// Entscheidet, ob eine gespeicherte Wiedergabeposition beim Laden
@@ -37,6 +39,10 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
final NavidromeService _nav = NavidromeService(); final NavidromeService _nav = NavidromeService();
int? _lastCountedIndex; int? _lastCountedIndex;
/// Wiedergabe automatisch starten, sobald Kopfhörer verbunden werden.
/// Wird von den App-Einstellungen gesetzt.
bool autoPlayOnHeadphones = false;
MeloAudioHandler({required this.db}) { MeloAudioHandler({required this.db}) {
_cache = CacheManager(); _cache = CacheManager();
_cache.init(); _cache.init();
@@ -71,6 +77,8 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
} }
}); });
_watchHeadphones();
// Ermittelte Dauer nachtragen // Ermittelte Dauer nachtragen
_player.durationStream.listen((duration) { _player.durationStream.listen((duration) {
final item = mediaItem.value; 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]. /// Ersetzt die Warteschlange und startet ab [startIndex].
/// Auto-Caching: Streame von Server + speichere lokal gleichzeitig. /// Auto-Caching: Streame von Server + speichere lokal gleichzeitig.
Future<void> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async { 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);
}
+313
View File
@@ -0,0 +1,313 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../player/audio_effects.dart';
import '../shared/theme.dart';
/// Equalizer-Bildschirm: Klangprofile, manuelle Bänder und die beiden
/// Drehregler für Bass und Immersives Audio.
class EqualizerScreen extends StatelessWidget {
const EqualizerScreen({super.key});
@override
Widget build(BuildContext context) {
final effects = context.watch<AudioEffects>();
return Scaffold(
appBar: AppBar(
title: const Text('Equalizer'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: Switch(
value: effects.enabled,
onChanged: effects.available ? effects.setEnabled : null,
),
),
],
),
body: !effects.available
? _NotAvailable(error: effects.error)
: Opacity(
opacity: effects.enabled ? 1 : 0.4,
child: IgnorePointer(
ignoring: !effects.enabled,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
_Presets(effects: effects),
const SizedBox(height: 24),
const _SectionTitle('Manuelle Anpassung'),
const SizedBox(height: 8),
_Bands(effects: effects),
const SizedBox(height: 28),
const _SectionTitle('Sound verbessern'),
const Padding(
padding: EdgeInsets.only(top: 4, bottom: 12),
child: Row(
children: [
Icon(Icons.headphones, size: 14, color: Colors.white38),
SizedBox(width: 6),
Expanded(
child: Text(
'Kopfhörer aufsetzen, bevor du die Soundeffekte '
'einstellst',
style: TextStyle(
color: Colors.white38, fontSize: 12),
),
),
],
),
),
_Enhancers(effects: effects),
],
),
),
),
);
}
}
class _NotAvailable extends StatelessWidget {
const _NotAvailable({this.error});
final String? error;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.graphic_eq, size: 64, color: Colors.white24),
const SizedBox(height: 12),
const Text(
'Der Equalizer ist erst verfügbar, wenn Musik läuft',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white54),
),
if (error != null) ...[
const SizedBox(height: 8),
Text(error!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white24, fontSize: 12)),
],
],
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(text,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700));
}
}
class _Presets extends StatelessWidget {
const _Presets({required this.effects});
final AudioEffects effects;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (var i = 0; i < effects.presets.length; i++)
SizedBox(
width: (MediaQuery.of(context).size.width - 32 - 20) / 3,
height: 64,
child: Material(
color: MeloTheme.surfaceHigh,
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => effects.usePreset(i),
child: Center(
child: Text(effects.presets[i],
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis),
),
),
),
),
],
);
}
}
class _Bands extends StatelessWidget {
const _Bands({required this.effects});
final AudioEffects effects;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final band in effects.bands)
Expanded(
child: Column(
children: [
SizedBox(
height: 200,
child: RotatedBox(
quarterTurns: 3,
child: Slider(
value: band.level
.clamp(effects.minLevel, effects.maxLevel)
.toDouble(),
min: effects.minLevel.toDouble(),
max: effects.maxLevel.toDouble(),
onChanged: (v) =>
effects.setBandLevel(band.index, v.round()),
),
),
),
Text(band.label,
style:
const TextStyle(color: Colors.white54, fontSize: 11)),
Text('${(band.level / 100).round()}',
style:
const TextStyle(color: Colors.white38, fontSize: 11)),
],
),
),
],
);
}
}
class _Enhancers extends StatelessWidget {
const _Enhancers({required this.effects});
final AudioEffects effects;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Knob(
label: 'Bass',
value: effects.bass / 1000,
enabled: effects.bassSupported,
onChanged: (v) => effects.setBass((v * 1000).round()),
),
Knob(
label: 'Immersives Audio',
value: effects.virtualizer / 1000,
enabled: effects.virtualizerSupported,
onChanged: (v) => effects.setVirtualizer((v * 1000).round()),
),
],
);
}
}
/// Drehregler wie in der Vorlage: senkrecht wischen regelt von 0 bis 1.
class Knob extends StatelessWidget {
const Knob({
super.key,
required this.label,
required this.value,
required this.onChanged,
this.enabled = true,
});
final String label;
final double value;
final ValueChanged<double> onChanged;
final bool enabled;
static const _size = 120.0;
@override
Widget build(BuildContext context) {
return Opacity(
opacity: enabled ? 1 : 0.4,
child: Column(
children: [
GestureDetector(
onVerticalDragUpdate: enabled
// Ein voller Ausschlag entspricht ~200 Pixel Wischweg.
? (d) => onChanged((value - d.delta.dy / 200).clamp(0.0, 1.0))
: null,
child: CustomPaint(
size: const Size(_size, _size),
painter: _KnobPainter(value),
),
),
const SizedBox(height: 8),
Text(label,
style: const TextStyle(color: Colors.white54, fontSize: 12)),
Text('${(value * 100).round()}',
style: const TextStyle(color: Colors.white38, fontSize: 11)),
],
),
);
}
}
class _KnobPainter extends CustomPainter {
_KnobPainter(this.value);
final double value;
/// Der Regelbereich läuft von unten links (135°) über 270° nach unten rechts.
static const _start = math.pi * 0.75;
static const _sweep = math.pi * 1.5;
static const _dots = 24;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2;
for (var i = 0; i < _dots; i++) {
final t = i / (_dots - 1);
final angle = _start + _sweep * t;
final active = t <= value;
canvas.drawCircle(
center + Offset(math.cos(angle), math.sin(angle)) * (radius - 5),
active ? 3.5 : 2.5,
Paint()..color = active ? MeloTheme.red : Colors.white24,
);
}
canvas.drawCircle(
center,
radius - 16,
Paint()..color = MeloTheme.surfaceHigh,
);
canvas.drawCircle(
center,
radius - 16,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = Colors.white12,
);
final pointer = _start + _sweep * value;
canvas.drawLine(
center + Offset(math.cos(pointer), math.sin(pointer)) * (radius - 46),
center + Offset(math.cos(pointer), math.sin(pointer)) * (radius - 22),
Paint()
..color = Colors.white
..strokeWidth = 2
..strokeCap = StrokeCap.round,
);
}
@override
bool shouldRepaint(_KnobPainter oldDelegate) => oldDelegate.value != value;
}
+45
View File
@@ -9,7 +9,9 @@ import '../services/cache_manager.dart';
import '../services/logger_service.dart'; import '../services/logger_service.dart';
import '../services/navidrome_service.dart'; import '../services/navidrome_service.dart';
import '../services/offline_mode.dart'; import '../services/offline_mode.dart';
import '../player/audio_effects.dart';
import 'app_settings.dart'; import 'app_settings.dart';
import 'equalizer_screen.dart';
import 'library_stats.dart'; import 'library_stats.dart';
/// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Navidrome, Über Melo. /// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Navidrome, Über Melo.
@@ -37,6 +39,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) setState(() => _cacheSize = size); if (mounted) setState(() => _cacheSize = size);
} }
/// Öffnet die Klangeffekt-Oberfläche des Systems (auf POCO/Xiaomi ist das
/// Dolby Atmos). Nicht jedes Gerät bietet sie an.
Future<void> _oeffneSystemKlangeffekte(BuildContext context) async {
final effects = context.read<AudioEffects>();
final messenger = ScaffoldMessenger.of(context);
final geoeffnet = await effects.openSystemPanel();
if (!geoeffnet) {
messenger.showSnackBar(
const SnackBar(
content: Text('Dieses Gerät bietet keine System-Klangeffekte an'),
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final db = context.read<MeloDb>(); final db = context.read<MeloDb>();
@@ -89,6 +106,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
const Divider(height: 1), const Divider(height: 1),
const _SectionLabel('Ton'),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Equalizer'),
subtitle: const Text('Klangprofile, Bass und Immersives Audio'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const EqualizerScreen()),
),
),
ListTile(
leading: const Icon(Icons.surround_sound),
title: const Text('Dolby Atmos'),
subtitle: const Text('Klangeffekte des Systems öffnen'),
trailing: const Icon(Icons.open_in_new),
onTap: () => _oeffneSystemKlangeffekte(context),
),
Consumer<AppSettings>(
builder: (context, settings, _) => SwitchListTile(
secondary: const Icon(Icons.headset),
title: const Text('Automatische Wiedergabe'),
subtitle: const Text(
'Startet, sobald Kopfhörer verbunden werden (auch Bluetooth)'),
value: settings.autoPlayOnHeadphones,
onChanged: settings.setAutoPlayOnHeadphones,
),
),
const Divider(height: 1),
const _SectionLabel('Darstellung'), const _SectionLabel('Darstellung'),
Consumer<AppSettings>( Consumer<AppSettings>(
builder: (context, settings, _) => SwitchListTile( builder: (context, settings, _) => SwitchListTile(
+113
View File
@@ -0,0 +1,113 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/player/audio_effects.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('de.baka.melo/audio_effects');
late List<MethodCall> calls;
void mockChannel({Object? Function(MethodCall call)? handler}) {
calls = [];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
if (handler != null) return handler(call);
if (call.method == 'attach') {
return {
'bands': [
{'index': 0, 'centerFrequencyMilliHz': 60000, 'level': 0},
{'index': 1, 'centerFrequencyMilliHz': 14000000, 'level': 0},
],
'presets': ['Normal', 'Rock'],
'minLevel': -1500,
'maxLevel': 1500,
'bassSupported': true,
'virtualizerSupported': true,
};
}
return null;
});
}
setUp(() {
SharedPreferences.setMockInitialValues({});
mockChannel();
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
test('attach liest Bänder, Presets und Grenzen vom Gerät', () async {
final effects = AudioEffects();
await effects.attach(42);
expect(effects.available, isTrue);
expect(effects.bands.map((b) => b.centerHz), [60, 14000]);
expect(effects.presets, ['Normal', 'Rock']);
expect(effects.minLevel, -1500);
expect(effects.bassSupported, isTrue);
expect(calls.first.arguments['sessionId'], 42);
});
test('Frequenz-Beschriftung wie in der Vorlage', () {
expect(const EqBand(index: 0, centerHz: 60, level: 0).label, '60Hz');
expect(const EqBand(index: 4, centerHz: 14000, level: 0).label, '14.0kHz');
});
test('Bandpegel wird auf den erlaubten Bereich begrenzt', () async {
final effects = AudioEffects();
await effects.attach(1);
await effects.setBandLevel(0, 9999);
expect(effects.bands.first.level, 1500);
final call = calls.lastWhere((c) => c.method == 'setBandLevel');
expect(call.arguments['level'], 1500);
});
test('Bass und Immersives Audio bleiben zwischen 0 und 1000', () async {
final effects = AudioEffects();
await effects.attach(1);
await effects.setBass(2000);
await effects.setVirtualizer(-5);
expect(effects.bass, 1000);
expect(effects.virtualizer, 0);
});
test('gespeicherte Werte werden beim nächsten Attach wiederhergestellt',
() async {
final effects = AudioEffects();
await effects.attach(1);
await effects.setEnabled(true);
await effects.setBandLevel(1, 600);
await effects.setBass(300);
final neu = AudioEffects();
await neu.init();
await neu.attach(2);
expect(neu.enabled, isTrue);
expect(neu.bands[1].level, 600);
expect(neu.bass, 300);
});
test('Gerät ohne Effekte meldet nicht verfügbar statt abzustürzen', () async {
mockChannel(handler: (call) {
throw PlatformException(code: 'audio_effects', message: 'nicht möglich');
});
final effects = AudioEffects();
await effects.attach(1);
expect(effects.available, isFalse);
expect(effects.error, 'nicht möglich');
});
}
+85
View File
@@ -0,0 +1,85 @@
import 'package:audio_session/audio_session.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/player/headphone_autoplay.dart';
void main() {
group('shouldAutoPlay', () {
test('startet bei verbundenem Bluetooth-Kopfhörer', () {
expect(
shouldAutoPlay(
added: {AudioDeviceType.bluetoothA2dp},
enabled: true,
hasQueue: true,
playing: false,
),
isTrue,
);
});
test('startet auch bei Klinken-Kopfhörern', () {
for (final type in [
AudioDeviceType.wiredHeadset,
AudioDeviceType.wiredHeadphones,
]) {
expect(
shouldAutoPlay(
added: {type},
enabled: true,
hasQueue: true,
playing: false,
),
isTrue,
reason: '$type',
);
}
});
test('ignoriert andere Geräte wie den eingebauten Lautsprecher', () {
expect(
shouldAutoPlay(
added: {AudioDeviceType.builtInSpeaker},
enabled: true,
hasQueue: true,
playing: false,
),
isFalse,
);
});
test('startet nicht, wenn die Einstellung aus ist', () {
expect(
shouldAutoPlay(
added: {AudioDeviceType.bluetoothA2dp},
enabled: false,
hasQueue: true,
playing: false,
),
isFalse,
);
});
test('startet nicht ohne Warteschlange', () {
expect(
shouldAutoPlay(
added: {AudioDeviceType.bluetoothA2dp},
enabled: true,
hasQueue: false,
playing: false,
),
isFalse,
);
});
test('unterbricht nicht die bereits laufende Wiedergabe', () {
expect(
shouldAutoPlay(
added: {AudioDeviceType.bluetoothA2dp},
enabled: true,
hasQueue: true,
playing: true,
),
isFalse,
);
});
});
}