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
+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;
}