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()) + 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(); + _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 setEnabled(bool value) async { + _enabled = value; + notifyListeners(); + await _invoke('setEnabled', {'enabled': value}); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyEnabled, value); + } + + Future 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 usePreset(int preset) async { + final levels = await _invoke>('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 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 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 openSystemPanel() async { + return await _invoke('openSystemPanel', null) ?? false; + } + + Future _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 _saveLevels() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _keyLevels, [for (final b in _bands) b.level.toString()]); + } + + Future _invoke(String method, Map? args) async { + try { + return await _channel.invokeMethod(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; + } + } +} diff --git a/lib/player/audio_handler.dart b/lib/player/audio_handler.dart index f2fec7d..412654c 100644 --- a/lib/player/audio_handler.dart +++ b/lib/player/audio_handler.dart @@ -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 _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 get androidAudioSessionIdStream => + _player.androidAudioSessionIdStream; + /// Ersetzt die Warteschlange und startet ab [startIndex]. /// Auto-Caching: Streame von Server + speichere lokal gleichzeitig. Future loadPlaylist(List items, {int startIndex = 0}) async { diff --git a/lib/player/headphone_autoplay.dart b/lib/player/headphone_autoplay.dart new file mode 100644 index 0000000..5e6e140 --- /dev/null +++ b/lib/player/headphone_autoplay.dart @@ -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 added, + required bool enabled, + required bool hasQueue, + required bool playing, +}) { + if (!enabled || !hasQueue || playing) return false; + return added.any(_headphoneTypes.contains); +} diff --git a/lib/settings/equalizer_screen.dart b/lib/settings/equalizer_screen.dart new file mode 100644 index 0000000..393353b --- /dev/null +++ b/lib/settings/equalizer_screen.dart @@ -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(); + 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 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; +} diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index dc31f52..234fad5 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -9,7 +9,9 @@ import '../services/cache_manager.dart'; import '../services/logger_service.dart'; import '../services/navidrome_service.dart'; import '../services/offline_mode.dart'; +import '../player/audio_effects.dart'; import 'app_settings.dart'; +import 'equalizer_screen.dart'; import 'library_stats.dart'; /// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Navidrome, Über Melo. @@ -37,6 +39,21 @@ class _SettingsScreenState extends State { 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 _oeffneSystemKlangeffekte(BuildContext context) async { + final effects = context.read(); + 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 Widget build(BuildContext context) { final db = context.read(); @@ -89,6 +106,34 @@ class _SettingsScreenState extends State { }, ), 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( + 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'), Consumer( builder: (context, settings, _) => SwitchListTile( diff --git a/test/player/audio_effects_test.dart b/test/player/audio_effects_test.dart new file mode 100644 index 0000000..cd861c3 --- /dev/null +++ b/test/player/audio_effects_test.dart @@ -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 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'); + }); +} diff --git a/test/player/headphone_autoplay_test.dart b/test/player/headphone_autoplay_test.dart new file mode 100644 index 0000000..4f6f060 --- /dev/null +++ b/test/player/headphone_autoplay_test.dart @@ -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, + ); + }); + }); +}