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:
@@ -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") —
|
||||
|
||||
@@ -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/**
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<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. -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
||||
<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
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -45,6 +47,18 @@ Future<void> 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<OfflineMode>.value(value: _offlineMode),
|
||||
ChangeNotifierProvider<CategoryService>.value(value: _categories),
|
||||
ChangeNotifierProvider<AppSettings>.value(value: _settings),
|
||||
ChangeNotifierProvider<AudioEffects>.value(value: _effects),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'Melo',
|
||||
|
||||
@@ -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 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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<SettingsScreen> {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final db = context.read<MeloDb>();
|
||||
@@ -89,6 +106,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
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'),
|
||||
Consumer<AppSettings>(
|
||||
builder: (context, settings, _) => SwitchListTile(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user