Files
SpotiFLAC-Mobile/lib/screens/equalizer_screen.dart
T
zarzet 68c739dfa1 fix(equalizer): keep headroom and limiter choices when applying a preset
Applying a preset replaced every setting, so built-in presets silently
turned automatic headroom, the limiter and the compressor back on and
reset balance. Presets now carry only the curve, preamp, bass and treble.
The active preset is matched on the same fields, so it no longer shows as
Custom once headroom is off.
2026-10-18 20:58:36 +07:00

1499 lines
49 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_3_expressive/material_3_expressive.dart';
import 'package:share_plus/share_plus.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/equalizer_settings.dart';
import 'package:spotiflac_android/providers/equalizer_provider.dart';
import 'package:spotiflac_android/services/playback_equalizer.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/app_choice_chip.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/app_menu_button.dart';
import 'package:spotiflac_android/widgets/app_slider.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
import 'package:spotiflac_android/widgets/app_switch.dart';
import 'package:spotiflac_android/widgets/app_text_field.dart';
import 'package:spotiflac_android/widgets/beta_badge.dart';
import 'package:spotiflac_android/widgets/expressive_button.dart';
import 'package:spotiflac_android/widgets/expressive_icon_button.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/widgets/mornye_level_slider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class EqualizerScreen extends ConsumerStatefulWidget {
const EqualizerScreen({super.key});
@override
ConsumerState<EqualizerScreen> createState() => _EqualizerScreenState();
}
enum _PresetAction { manage, import, export }
class _EqualizerScreenState extends ConsumerState<EqualizerScreen> {
bool _busy = false;
void _message(String message) => ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
Future<void> _savePreset() async {
final name = await showAppDialog<String>(
context: context,
builder: (context) => const _PresetNameDialog(),
);
if (!mounted || name == null) return;
try {
ref.read(equalizerProvider.notifier).savePreset(name);
_message(context.l10n.equalizerSaved);
} on FormatException catch (error) {
_message(error.message);
}
}
Future<void> _import() async {
setState(() => _busy = true);
try {
final file = await FilePicker.pickFile(
type: FileType.custom,
allowedExtensions: ['json'],
);
if (file == null) return;
if ((await file.length() ?? 65537) > 65536) throw const FormatException();
final source = utf8.decode(await file.readAsBytes());
if (!mounted) return;
ref.read(equalizerProvider.notifier).importPreset(source);
_message(context.l10n.equalizerSaved);
} catch (_) {
if (mounted) _message(context.l10n.equalizerImportInvalid);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _export() async {
final source = ref
.read(equalizerProvider.notifier)
.exportPreset('Custom EQ');
final box = context.findRenderObject() as RenderBox?;
try {
await SharePlus.instance.share(
ShareParams(
files: [
XFile.fromData(
Uint8List.fromList(utf8.encode(source)),
mimeType: 'application/json',
),
],
fileNameOverrides: ['spotiflac-equalizer.json'],
sharePositionOrigin: box == null
? null
: box.localToGlobal(Offset.zero) & box.size,
),
);
} catch (error) {
if (mounted) _message(context.l10n.snackbarError(error.toString()));
}
}
Future<void> _presets() => showAppBottomSheet<void>(
context: context,
useRootNavigator: true,
title: context.l10n.equalizerPresets,
maxHeightFactor: 0.85,
builder: (sheetContext) => Consumer(
builder: (context, ref, _) {
final state = ref.watch(equalizerProvider);
final selected = _matchingPreset(state);
final mornye = context.isMornye;
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final preset in [
...EqualizerPreset.builtIn,
...state.presets,
])
AppSheetOption(
title: Text(preset.name),
selected: preset == selected,
// iOS lists mark the choice with a trailing checkmark.
leading: mornye
? null
: preset == selected
? const AppIcon(Icons.check)
: const AppIcon(Icons.graphic_eq),
trailing:
(mornye && preset == selected) ||
state.presets.contains(preset)
? Row(
mainAxisSize: MainAxisSize.min,
children: [
if (mornye && preset == selected)
AppIcon(
Icons.check,
color: Theme.of(context).colorScheme.primary,
),
if (state.presets.contains(preset))
IconButton(
tooltip: context.l10n.equalizerDeletePreset,
icon: const AppIcon(Icons.delete_outline),
onPressed: () => ref
.read(equalizerProvider.notifier)
.deletePreset(preset.name),
),
],
)
: null,
onTap: () {
ref.read(equalizerProvider.notifier).applyPreset(preset);
Navigator.pop(sheetContext);
},
),
],
),
);
},
),
);
Future<void> _reset() async {
final accepted = await showAppDialog<bool>(
context: context,
builder: (context) => AppAlertDialog(
title: Text(context.l10n.equalizerResetTitle),
content: Text(context.l10n.equalizerResetDescription),
actions: [
AppDialogAction(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.libraryFilterReset),
),
],
),
);
if (mounted && accepted == true) {
final enabled = ref.read(equalizerProvider).settings.enabled;
ref
.read(equalizerProvider.notifier)
.update(EqualizerSettings(enabled: enabled));
}
}
@override
Widget build(BuildContext context) => MornyeSettingsTheme(
// Reached from Playback and Now Playing alike, so the page brings its own
// inset grouped surfaces in Mornye.
child: Builder(builder: _buildPage),
);
Widget _buildPage(BuildContext context) {
final state = ref.watch(equalizerProvider);
final settings = state.settings;
final notifier = ref.read(equalizerProvider.notifier);
final l10n = context.l10n;
final mornye = context.isMornye;
final selectedPreset = _matchingPreset(state);
final presetName = selectedPreset?.name ?? l10n.equalizerCustom;
void update(EqualizerSettings next) => notifier.update(next);
void setEnabled(bool v) => update(settings.copyWith(enabled: v));
Widget section(Widget child) => SliverToBoxAdapter(child: child);
final error = section(
ValueListenableBuilder<String?>(
valueListenable: playbackEqualizerError,
builder: (context, error, _) {
final message = error ?? state.error;
return message == null
? const SizedBox.shrink()
: SettingsInfoCard(
message: message,
icon: Icons.error_outline,
tone: SettingsInfoTone.error,
glass: true,
);
},
),
);
return Scaffold(
body: CustomScrollView(
slivers: [
AppSliverHeader.page(title: l10n.equalizerTitle),
if (mornye) ...[
// iOS order: the master switch, its footnote, then the response.
section(
SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.graphic_eq,
title: l10n.equalizerEnable,
titleTrailing: const BetaBadge(),
value: settings.enabled,
onChanged: setEnabled,
showDivider: false,
),
],
),
),
section(_Footnote(l10n.equalizerBypass)),
error,
section(
SettingsGroup(
children: [
_EqualizerOverview(
settings: settings,
presetName: presetName,
),
],
),
),
// The value cell names the group, so it needs no header.
const SliverToBoxAdapter(child: SizedBox(height: 28)),
section(
SettingsGroup(
children: [
SettingsItem(
title: l10n.equalizerPresets,
trailing: _DetailValue(presetName),
onTap: _presets,
),
_ActionRow(
label: l10n.equalizerSavePreset,
onTap: _savePreset,
),
_ActionRow(
label: l10n.equalizerImport,
onTap: _busy ? null : _import,
),
_ActionRow(
label: l10n.equalizerExport,
onTap: _export,
last: true,
),
],
),
),
] else ...[
section(
SettingsGroup(
children: [
_EqualizerOverview(
settings: settings,
presetName: presetName,
onEnabledChanged: setEnabled,
),
],
),
),
error,
section(
SettingsInfoCard(
message: l10n.equalizerBypass,
icon: Icons.info_outline,
),
),
section(SettingsSectionHeader(title: l10n.equalizerPresets)),
section(
_PresetBar(
presets: [...EqualizerPreset.builtIn, ...state.presets],
selected: selectedPreset,
busy: _busy,
onApply: notifier.applyPreset,
onSave: _savePreset,
onReset: _reset,
onAction: (action) => switch (action) {
_PresetAction.manage => _presets(),
_PresetAction.import => _import(),
_PresetAction.export => _export(),
},
),
),
],
section(SettingsSectionHeader(title: l10n.equalizerBands)),
section(
SettingsGroup(
children: [_BandEditor(settings: settings, onChanged: update)],
),
),
section(SettingsSectionHeader(title: l10n.equalizerTone)),
section(
SettingsGroup(
connected: true,
children: [
_SliderTile(
icon: Icons.volume_up_outlined,
title: l10n.equalizerPreamp,
subtitle:
'${l10n.equalizerEffectivePreamp}: ${_signed(settings.inputGain)} dB',
value: settings.preamp,
min: -24,
max: 12,
step: 0.5,
unit: 'dB',
onChanged: (v) => update(settings.copyWith(preamp: v)),
),
_SliderTile(
icon: Icons.speaker_outlined,
title: l10n.equalizerBass,
value: settings.bass,
min: -12,
max: 12,
step: 0.5,
unit: 'dB',
centered: true,
onChanged: (v) => update(settings.copyWith(bass: v)),
),
_SliderTile(
icon: Icons.music_note_outlined,
title: l10n.equalizerTreble,
value: settings.treble,
min: -12,
max: 12,
step: 0.5,
unit: 'dB',
centered: true,
onChanged: (v) => update(settings.copyWith(treble: v)),
),
_SliderTile(
icon: Icons.surround_sound_outlined,
title: l10n.equalizerBalance,
value: settings.balance * 100,
min: -100,
max: 100,
step: 1,
unit: '%',
centered: true,
last: true,
onChanged: (v) => update(settings.copyWith(balance: v / 100)),
),
],
),
),
section(SettingsSectionHeader(title: l10n.equalizerDynamics)),
section(
SettingsGroup(
connected: true,
children: [
SettingsSwitchItem(
icon: Icons.vertical_align_bottom,
title: l10n.equalizerHeadroom,
subtitle: l10n.equalizerHeadroomDescription,
value: settings.autoHeadroom,
onChanged: (v) => update(settings.copyWith(autoHeadroom: v)),
),
SettingsSwitchItem(
icon: Icons.health_and_safety_outlined,
title: l10n.equalizerLimiter,
subtitle: l10n.equalizerLimiterDescription,
value: settings.limiter,
onChanged: (v) => update(settings.copyWith(limiter: v)),
),
SettingsSwitchItem(
icon: Icons.compress,
title: l10n.equalizerCompressor,
subtitle: l10n.equalizerCompressorDescription,
value: settings.compressor,
onChanged: (v) => update(settings.copyWith(compressor: v)),
showDivider: settings.compressor,
),
if (settings.compressor) ...[
_SliderTile(
title: l10n.equalizerThreshold,
indented: true,
value: settings.threshold,
min: -60,
max: 0,
step: 0.5,
unit: 'dB',
onChanged: (v) => update(settings.copyWith(threshold: v)),
),
_SliderTile(
title: l10n.equalizerRatio,
indented: true,
value: settings.ratio,
min: 1,
max: 20,
step: 0.5,
unit: ':1',
signed: false,
onChanged: (v) => update(settings.copyWith(ratio: v)),
),
_SliderTile(
title: l10n.equalizerAttack,
indented: true,
value: settings.attack,
min: 1,
max: 100,
step: 1,
unit: 'ms',
signed: false,
onChanged: (v) => update(settings.copyWith(attack: v)),
),
_SliderTile(
title: l10n.equalizerRelease,
indented: true,
value: settings.release,
min: 20,
max: 1000,
step: 10,
unit: 'ms',
signed: false,
last: true,
onChanged: (v) => update(settings.copyWith(release: v)),
),
],
],
),
),
if (mornye) ...[
// iOS keeps a destructive command in its own group at the end.
const SliverToBoxAdapter(child: SizedBox(height: 28)),
section(
SettingsGroup(
children: [
_ActionRow(
label: l10n.libraryFilterReset,
onTap: _reset,
destructive: true,
last: true,
),
],
),
),
],
SliverToBoxAdapter(
child: SizedBox(height: 40 + MediaQuery.paddingOf(context).bottom),
),
],
),
);
}
}
/// The preset whose curve, tone and dynamics match the current settings.
EqualizerPreset? _matchingPreset(EqualizerState state) {
// Matches when applying the preset would change nothing.
String key(EqualizerSettings settings) => jsonEncode(settings.toJson());
final current = key(state.settings);
for (final preset in [...EqualizerPreset.builtIn, ...state.presets]) {
if (key(state.settings.withToneOf(preset.settings)) == current) {
return preset;
}
}
return null;
}
/// iOS section footer: secondary text under the group it explains.
class _Footnote extends StatelessWidget {
const _Footnote(this.text);
final String text;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
final inset = settingsHorizontalInset(constraints.maxWidth) + 16;
final theme = Theme.of(context);
return Padding(
padding: EdgeInsets.fromLTRB(inset, 4, inset, 8),
child: Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
);
},
);
}
/// iOS "value" cell detail: the current choice in secondary text before the
/// disclosure chevron.
class _DetailValue extends StatelessWidget {
const _DetailValue(this.value);
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final secondary = theme.colorScheme.onSurfaceVariant;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 160),
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(color: secondary),
),
),
const SizedBox(width: 6),
AppIcon(Icons.chevron_right, size: 20, color: secondary),
],
);
}
}
/// iOS action cell: tinted text that runs a command instead of navigating.
class _ActionRow extends StatelessWidget {
const _ActionRow({
required this.label,
required this.onTap,
this.destructive = false,
this.last = false,
});
final String label;
final VoidCallback? onTap;
final bool destructive, last;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final tokens = context.tokens;
final color = onTap == null
? theme.disabledColor
: destructive
? colors.error
: colors.primary;
return Semantics(
button: true,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
highlightColor: colors.onSurface.withValues(alpha: 0.06),
splashFactory: NoSplash.splashFactory,
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: tokens.minTouchTarget),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: tokens.rowPaddingH,
vertical: tokens.rowPaddingV,
),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
label,
style: theme.textTheme.bodyLarge?.copyWith(color: color),
),
),
),
),
),
if (!last)
Divider(
height: 1,
indent: tokens.rowPaddingH,
endIndent: tokens.rowPaddingH,
color: colors.outlineVariant,
),
],
),
);
}
}
/// Status, master switch and the estimated response of the whole chain.
class _EqualizerOverview extends StatelessWidget {
const _EqualizerOverview({
required this.settings,
required this.presetName,
this.onEnabledChanged,
});
final EqualizerSettings settings;
final String presetName;
/// Shows the master switch beside the title; Mornye keeps it in its own
/// row above.
final ValueChanged<bool>? onEnabledChanged;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final colors = theme.colorScheme;
final tokens = context.tokens;
final enabled = settings.enabled;
final curveColor = enabled ? colors.primary : colors.outline;
return Padding(
padding: EdgeInsets.fromLTRB(
tokens.rowPaddingH,
tokens.gapLg,
tokens.gapLg,
tokens.gapLg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedDefaultTextStyle(
duration: tokens.motionMedium,
style: theme.textTheme.headlineSmall!.copyWith(
fontWeight: FontWeight.w600,
color: enabled
? colors.onSurface
: colors.onSurfaceVariant,
),
child: Text(
presetName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
SizedBox(height: tokens.gapXs),
Row(
children: [
const BetaBadge(),
const SizedBox(width: 8),
Flexible(
child: Text(
'${settings.gains.length} ${l10n.equalizerBands.toLowerCase()} · ${l10n.equalizerPreamp} ${_signed(settings.inputGain)} dB',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
fontFeatures: const [
FontFeature.tabularFigures(),
],
),
),
),
],
),
],
),
),
if (onEnabledChanged != null) ...[
SizedBox(width: tokens.gapMd),
AppSwitch(
value: enabled,
semanticLabel: l10n.equalizerEnable,
onChanged: onEnabledChanged,
),
],
],
),
SizedBox(height: tokens.gapLg),
Semantics(
label: l10n.equalizerCurveDescription,
child: SizedBox(
height: 152,
child: TweenAnimationBuilder<Color?>(
tween: ColorTween(end: curveColor),
duration: tokens.motionMedium,
builder: (context, color, _) => CustomPaint(
size: Size.infinite,
painter: _EqualizerCurve(
settings: settings,
color: color ?? curveColor,
grid: colors.outlineVariant,
label: colors.onSurfaceVariant,
labelStyle: theme.textTheme.labelSmall!,
textScaler: MediaQuery.textScalerOf(context),
),
),
),
),
),
SizedBox(height: tokens.gapSm),
Text(
l10n.equalizerCurve,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
);
}
}
/// Quick-apply chips for every preset, plus save, reset and overflow actions.
class _PresetBar extends StatelessWidget {
const _PresetBar({
required this.presets,
required this.selected,
required this.busy,
required this.onApply,
required this.onSave,
required this.onReset,
required this.onAction,
});
final List<EqualizerPreset> presets;
final EqualizerPreset? selected;
final bool busy;
final ValueChanged<EqualizerPreset> onApply;
final VoidCallback onSave;
final VoidCallback onReset;
final ValueChanged<_PresetAction> onAction;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final tokens = context.tokens;
return LayoutBuilder(
builder: (context, constraints) {
final inset = settingsHorizontalInset(constraints.maxWidth);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: inset),
child: Row(
children: [
for (final (index, preset) in presets.indexed) ...[
if (index > 0) SizedBox(width: tokens.gapSm),
AppChoiceChip(
label: Text(preset.name),
selected: preset == selected,
singleChoice: true,
onSelected: (_) => onApply(preset),
),
],
],
),
),
SizedBox(height: tokens.gapMd),
Padding(
padding: EdgeInsets.symmetric(horizontal: inset),
child: Row(
children: [
Flexible(
child: ExpressiveButton(
onPressed: onSave,
tonal: true,
icon: const AppIcon(Icons.bookmark_add_outlined),
child: Text(
l10n.equalizerSavePreset,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const Spacer(),
ExpressiveIconButton(
tooltip: l10n.libraryFilterReset,
icon: const AppIcon(Icons.restart_alt),
onPressed: onReset,
),
AppMenuButton<_PresetAction>(
icon: const AppIcon(Icons.more_vert),
tooltip: l10n.equalizerPresets,
onSelected: onAction,
items: [
AppMenuItem(
value: _PresetAction.manage,
label: l10n.equalizerManagePresets,
leading: const AppIcon(Icons.tune),
),
if (!busy)
AppMenuItem(
value: _PresetAction.import,
label: l10n.equalizerImport,
leading: const AppIcon(Icons.file_open_outlined),
),
AppMenuItem(
value: _PresetAction.export,
label: l10n.equalizerExport,
leading: const AppIcon(Icons.ios_share),
),
],
),
],
),
),
],
);
},
);
}
}
/// Band count picker and one vertical gain fader per band.
class _BandEditor extends StatelessWidget {
const _BandEditor({required this.settings, required this.onChanged});
final EqualizerSettings settings;
final ValueChanged<EqualizerSettings> onChanged;
static const _minColumnWidth = 32.0;
static const _faderHeight = 208.0;
void _setGain(int index, double value) {
final gains = [...settings.gains];
gains[index] = value;
onChanged(settings.copyWith(gains: gains));
}
Widget _countPicker(BuildContext context) {
final count = settings.gains.length;
const counts = EqualizerSettings.bandCounts;
void select(int index) => onChanged(settings.withBandCount(counts[index]));
if (context.isMornye) {
return Semantics(
label: context.l10n.equalizerBands,
child: MornyeSegmentedControl(
labels: [for (final value in counts) '$value'],
selectedIndex: counts.indexOf(count),
onChanged: select,
),
);
}
if (materialExpressiveEnabled(context)) {
return MaterialExpressiveScope(
child: M3EButtonGroup(
type: M3EButtonGroupType.connected,
style: M3EButtonStyle.filled,
// Connected segments fill the row, so the 40 dp small button gets
// no inflated target; 48 keeps each segment a full touch target.
size: M3EButtonSize.sm.copyWith(height: 48),
selectedIndex: counts.indexOf(count),
selectionRequired: true,
onSelectedIndexChanged: (index) {
if (index != null) select(index);
},
semanticLabel: context.l10n.equalizerBands,
actions: [
for (final value in counts)
M3EButtonGroupAction(label: Text('$value')),
],
),
);
}
return SegmentedButton<int>(
showSelectedIcon: false,
segments: [
for (final value in counts)
ButtonSegment(value: value, label: Text('$value')),
],
selected: {count},
onSelectionChanged: (value) => select(counts.indexOf(value.first)),
);
}
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
final theme = Theme.of(context);
final colors = theme.colorScheme;
final l10n = context.l10n;
final count = settings.gains.length;
return Padding(
padding: EdgeInsets.symmetric(vertical: tokens.gapLg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: tokens.gapLg),
child: SizedBox(
width: double.infinity,
child: _countPicker(context),
),
),
SizedBox(height: tokens.gapLg),
LayoutBuilder(
builder: (context, constraints) {
final available = constraints.maxWidth - tokens.gapSm * 2;
final width = math.max(_minColumnWidth, available / count);
final scrolls = width * count > available + 0.5;
final faders = Row(
children: [
for (var i = 0; i < count; i++)
SizedBox(
width: width,
child: _BandFader(
frequency: settings.frequencies[i],
gain: settings.gains[i],
width: width,
height: _faderHeight,
resetLabel: l10n.libraryFilterReset,
onChanged: (v) => _setGain(i, v),
),
),
],
);
return Stack(
children: [
// 0 dB reference across every fader.
Positioned(
left: tokens.gapSm,
right: tokens.gapSm,
top: _BandFader.labelExtent + _faderHeight / 2,
child: Divider(
height: 1,
color: colors.outlineVariant.withValues(alpha: 0.6),
),
),
scrolls
? SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(
horizontal: tokens.gapSm,
),
child: faders,
)
: Padding(
padding: EdgeInsets.symmetric(
horizontal: tokens.gapSm,
),
child: faders,
),
],
);
},
),
],
),
);
}
}
class _BandFader extends StatelessWidget {
const _BandFader({
required this.frequency,
required this.gain,
required this.width,
required this.height,
required this.resetLabel,
required this.onChanged,
});
final double frequency;
final double gain;
final double width;
final double height;
final String resetLabel;
final ValueChanged<double> onChanged;
/// Height of the gain label and its gap above the fader, so the 0 dB line
/// can align with the faders' centers.
static const labelExtent = 32.0 + 4;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final tokens = context.tokens;
final active = gain != 0;
final mornye = context.isMornye;
final hz = _frequency(frequency);
final valueStyle = theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()],
color: !active
? colors.onSurfaceVariant
: mornye
? colors.primary
: colors.onSecondaryContainer,
);
final semanticLabel = '$hz Hz';
String semantics(double v) => '${v.toStringAsFixed(1)} dB';
return Column(
children: [
// Tapping the gain returns the band to 0 dB.
Tooltip(
message: '$resetLabel $hz Hz',
child: SizedBox(
height: 32,
child: Center(
child: Material(
// iOS shows the gain as plain tinted text, Material as a badge.
color: active && !mornye
? colors.secondaryContainer
: Colors.transparent,
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
animationDuration: tokens.motionFast,
child: InkWell(
onTap: active ? () => onChanged(0) : null,
child: Container(
height: 28,
constraints: BoxConstraints(maxWidth: width - 2),
padding: const EdgeInsets.symmetric(horizontal: 4),
alignment: Alignment.center,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(_signed(gain), style: valueStyle),
),
),
),
),
),
),
),
const SizedBox(height: 4),
SizedBox(
height: height,
child: mornye
? MornyeLevelSlider(
vertical: true,
value: gain,
min: -15,
max: 15,
origin: 0,
step: 0.5,
knobRadius: ((width - 8) / 2).clamp(9.0, 14.0),
semanticLabel: semanticLabel,
semanticFormatterCallback: semantics,
onChanged: (v) => onChanged(_snap(v, 0.5)),
)
: AppSlider(
vertical: true,
centered: true,
value: gain,
min: -15,
max: 15,
trackThickness: (width * 0.4).clamp(12.0, 24.0),
thumbLength: (width * 0.75).clamp(24.0, 44.0),
semanticLabel: semanticLabel,
semanticFormatterCallback: semantics,
onChanged: (v) => onChanged(_snap(v, 0.5)),
),
),
SizedBox(height: tokens.gapSm),
SizedBox(
height: 20,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
hz,
style: theme.textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
),
],
);
}
}
/// A labelled slider row with its live value in a tonal badge.
class _SliderTile extends StatelessWidget {
const _SliderTile({
required this.title,
required this.value,
required this.min,
required this.max,
required this.step,
required this.unit,
required this.onChanged,
this.icon,
this.subtitle,
this.centered = false,
this.signed = true,
this.indented = false,
this.last = false,
});
final IconData? icon;
final String title;
final String? subtitle;
final double value, min, max, step;
final String unit;
final bool centered, signed;
/// Aligns an icon-less sub-setting with the text of the rows above it.
final bool indented;
/// Ends a Mornye group, which separates rows with hairlines.
final bool last;
final ValueChanged<double> onChanged;
String _format(double v) {
final digits = step < 1 ? 1 : 0;
final text = signed && v > 0
? '+${v.toStringAsFixed(digits)}'
: v.toStringAsFixed(digits).replaceFirst('-', '−');
return unit == '%' || unit.startsWith(':') ? '$text$unit' : '$text $unit';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final tokens = context.tokens;
final mornye = context.isMornye;
final label = _format(value);
final valueStyle = theme.textTheme.labelLarge?.copyWith(
color: colors.onSecondaryContainer,
fontFeatures: const [FontFeature.tabularFigures()],
);
final content = Padding(
padding: EdgeInsets.fromLTRB(
tokens.rowPaddingH + (indented && !mornye ? 24 + tokens.rowIconGap : 0),
tokens.rowPaddingVCompact,
tokens.rowPaddingH,
mornye ? 0 : tokens.gapXs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Mornye rows, like iOS Settings, carry no leading glyphs.
if (icon != null && !mornye) ...[
AppIcon.adaptive(
icon!,
color: colors.onSurfaceVariant,
size: 24,
),
SizedBox(width: tokens.rowIconGap),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.bodyLarge),
if (subtitle != null)
Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
),
SizedBox(width: tokens.gapSm),
if (mornye)
// iOS detail text: the value in secondary label color.
Text(
label,
style: theme.textTheme.bodyLarge?.copyWith(
color: colors.onSurfaceVariant,
fontFeatures: const [FontFeature.tabularFigures()],
),
)
else
DecoratedBox(
decoration: ShapeDecoration(
color: colors.secondaryContainer,
shape: const StadiumBorder(),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
child: Text(label, style: valueStyle),
),
),
],
),
if (mornye)
MornyeLevelSlider(
value: value.clamp(min, max),
min: min,
max: max,
origin: centered ? (min + max) / 2 : null,
step: step,
semanticLabel: title,
semanticFormatterCallback: _format,
onChanged: (v) => onChanged(_snap(v, step).clamp(min, max)),
)
else
AppSlider(
value: value.clamp(min, max),
min: min,
max: max,
centered: centered,
semanticLabel: title,
semanticFormatterCallback: _format,
onChanged: (v) => onChanged(_snap(v, step).clamp(min, max)),
),
],
),
);
return SettingsSearchTarget(
label: title,
child: mornye && !last
? Column(
mainAxisSize: MainAxisSize.min,
children: [
content,
Divider(
height: 1,
indent: tokens.rowPaddingH,
endIndent: tokens.rowPaddingH,
color: colors.outlineVariant,
),
],
)
: content,
);
}
}
class _PresetNameDialog extends StatefulWidget {
const _PresetNameDialog();
@override
State<_PresetNameDialog> createState() => _PresetNameDialogState();
}
class _PresetNameDialogState extends State<_PresetNameDialog> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final name = _controller.text.trim();
if (name.isNotEmpty) Navigator.pop(context, name);
}
@override
Widget build(BuildContext context) => AppAlertDialog(
title: Text(context.l10n.equalizerSavePreset),
content: AppTextField(
controller: _controller,
label: context.l10n.equalizerPresetName,
maxLength: 80,
autofocus: true,
onSubmitted: (_) => _submit(),
),
actions: [
AppDialogAction(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(onPressed: _submit, child: Text(context.l10n.dialogSave)),
],
);
}
double _snap(double value, double step) => (value / step).round() * step;
/// Signed gain with a true minus sign, e.g. `+3.5`, `0.0`, `−2.0`.
String _signed(double value) {
final text = value.abs().toStringAsFixed(1);
if (text == '0.0') return text;
return value > 0 ? '+$text' : '−$text';
}
String _frequency(double hz) => hz >= 1000
? '${(hz / 1000).toStringAsFixed(hz >= 10000 ? 0 : 1)}k'
: hz.toStringAsFixed(0);
class _EqualizerCurve extends CustomPainter {
_EqualizerCurve({
required this.settings,
required this.color,
required this.grid,
required this.label,
required this.labelStyle,
required this.textScaler,
});
final EqualizerSettings settings;
final Color color, grid, label;
final TextStyle labelStyle;
final TextScaler textScaler;
static const _range = 18.0;
static const _minHz = 20.0, _maxHz = 20000.0;
TextPainter _text(String text) => TextPainter(
text: TextSpan(
text: text,
style: labelStyle.copyWith(color: label),
),
textDirection: TextDirection.ltr,
textScaler: textScaler.clamp(maxScaleFactor: 1.3),
)..layout();
@override
void paint(Canvas canvas, Size size) {
final freqLabels = [
for (final hz in const [100.0, 1000.0, 10000.0])
(hz, _text(_frequency(hz))),
];
final bottom = size.height - freqLabels.first.$2.height - 6;
final gutter = _text('+12').width + 8;
final plot = Rect.fromLTRB(gutter, 4, size.width, bottom);
double x(double hz) =>
plot.left +
math.log(hz / _minHz) / math.log(_maxHz / _minHz) * plot.width;
double y(double gain) =>
plot.center.dy - gain.clamp(-_range, _range) / _range * plot.height / 2;
final guide = Paint()
..color = grid.withValues(alpha: 0.5)
..strokeWidth = 1;
for (final gain in const [12.0, -12.0]) {
canvas.drawLine(
Offset(plot.left, y(gain)),
Offset(plot.right, y(gain)),
guide,
);
}
for (final (hz, _) in freqLabels) {
canvas.drawLine(
Offset(x(hz), plot.top),
Offset(x(hz), plot.bottom),
guide,
);
}
// Dashed 0 dB baseline.
final baseline = Paint()
..color = grid
..strokeWidth = 1.5;
for (var dx = plot.left; dx < plot.right; dx += 8) {
canvas.drawLine(
Offset(dx, plot.center.dy),
Offset(math.min(dx + 4, plot.right), plot.center.dy),
baseline,
);
}
for (final (gain, text) in [(12.0, '+12'), (0.0, '0'), (-12.0, '−12')]) {
final painter = _text(text);
painter.paint(
canvas,
Offset(gutter - 8 - painter.width, y(gain) - painter.height / 2),
);
}
for (final (hz, painter) in freqLabels) {
painter.paint(canvas, Offset(x(hz) - painter.width / 2, bottom + 6));
}
final curve = settings.copyWith(gains: settings.effectiveGains);
final points = <Offset>[Offset(plot.left, y(curve.gains.first))];
for (var i = 0; i < curve.gains.length; i++) {
final point = Offset(x(settings.frequencies[i]), y(curve.gains[i]));
if (point.dx > points.last.dx + 0.5 && point.dx < plot.right - 0.5) {
points.add(point);
}
}
points.add(Offset(plot.right, y(curve.gains.last)));
final line = _monotoneSpline(points);
final area = Path.from(line)
..lineTo(plot.right, plot.center.dy)
..lineTo(plot.left, plot.center.dy)
..close();
canvas.save();
canvas.clipRect(plot);
canvas.drawPath(
area,
Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color.withValues(alpha: 0.42),
color.withValues(alpha: 0.12),
color.withValues(alpha: 0.42),
],
).createShader(plot),
);
canvas.restore();
canvas.drawPath(
line,
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 3
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round,
);
final dot = Paint()..color = color;
final effective = curve.gains;
for (var i = 0; i < effective.length; i++) {
final hz = settings.frequencies[i];
if (hz < _minHz || hz > _maxHz) continue;
canvas.drawCircle(
Offset(x(hz), y(effective[i])),
effective.length > 15 ? 2.5 : 4,
dot,
);
}
}
/// Smooth curve through [points] that never overshoots between bands
/// (Fritsch–Carlson monotone cubic interpolation).
static Path _monotoneSpline(List<Offset> points) {
final n = points.length;
final path = Path()..moveTo(points.first.dx, points.first.dy);
if (n < 3) {
for (final point in points.skip(1)) {
path.lineTo(point.dx, point.dy);
}
return path;
}
final slopes = [
for (var i = 0; i < n - 1; i++)
(points[i + 1].dy - points[i].dy) / (points[i + 1].dx - points[i].dx),
];
final tangents = [
slopes.first,
for (var i = 1; i < n - 1; i++)
slopes[i - 1] * slopes[i] <= 0 ? 0.0 : (slopes[i - 1] + slopes[i]) / 2,
slopes.last,
];
for (var i = 0; i < n - 1; i++) {
if (slopes[i] == 0) {
tangents[i] = tangents[i + 1] = 0;
continue;
}
final a = tangents[i] / slopes[i], b = tangents[i + 1] / slopes[i];
final s = a * a + b * b;
if (s > 9) {
final t = 3 / math.sqrt(s);
tangents[i] = t * a * slopes[i];
tangents[i + 1] = t * b * slopes[i];
}
}
for (var i = 0; i < n - 1; i++) {
final p0 = points[i], p1 = points[i + 1];
final h = (p1.dx - p0.dx) / 3;
path.cubicTo(
p0.dx + h,
p0.dy + tangents[i] * h,
p1.dx - h,
p1.dy - tangents[i + 1] * h,
p1.dx,
p1.dy,
);
}
return path;
}
@override
bool shouldRepaint(_EqualizerCurve oldDelegate) =>
oldDelegate.settings != settings ||
oldDelegate.color != color ||
oldDelegate.grid != grid ||
oldDelegate.label != label ||
oldDelegate.labelStyle != labelStyle ||
oldDelegate.textScaler != textScaler;
}