- 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
314 lines
9.2 KiB
Dart
314 lines
9.2 KiB
Dart
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;
|
|
}
|