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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user