feat: add built-in equalizer (related to #700)

This commit is contained in:
Valeri Gokadze
2026-02-16 18:56:45 +04:00
parent 8741a0a4c0
commit 6781b882df
5 changed files with 383 additions and 9 deletions

View File

@@ -64,6 +64,13 @@
"downloadCancelled": "Download cancelled",
"downloadFailed": "Download failed",
"downloadPlaylist": "Download playlist",
"equalizer": "Equalizer",
"equalizerAndroidOnly": "Equalizer is available on Android only.",
"equalizerInitFailed": "Equalizer could not be initialized right now. Try playing a song and reopen this page.",
"equalizerEnable": "Enable equalizer",
"equalizerEnabledHint": "Band gain adjustments are active.",
"equalizerDisabledHint": "Band gain adjustments are bypassed.",
"equalizerResetBands": "Reset bands",
"dynamicColor": "Dynamic accent color (Android 12+)",
"editPlaylist": "Edit playlist",
"emptyFolderMsg": "This folder is empty. Add playlists to organize your music.",

View File

@@ -0,0 +1,205 @@
/*
* Copyright (C) 2026 Valeri Gokadze
*
* Musify is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Musify is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*
* For more information about Musify, including how to contribute,
* please visit: https://github.com/gokadzev/Musify
*/
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'package:musify/extensions/l10n.dart';
import 'package:musify/main.dart';
import 'package:musify/services/settings_manager.dart';
import 'package:musify/utilities/common_variables.dart';
class EqualizerPage extends StatefulWidget {
const EqualizerPage({super.key});
@override
State<EqualizerPage> createState() => _EqualizerPageState();
}
class _EqualizerPageState extends State<EqualizerPage> {
AndroidEqualizerParameters? _params;
List<double> _gains = [];
bool _enabled = equalizerEnabled.value;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadEqualizer();
}
Future<void> _loadEqualizer() async {
if (!audioHandler.isEqualizerSupported) {
setState(() => _isLoading = false);
return;
}
try {
final params = await audioHandler.getEqualizerParameters();
if (!mounted) return;
if (params != null) {
setState(() {
_params = params;
_gains = params.bands.map((band) => band.gain).toList();
_enabled = equalizerEnabled.value;
_isLoading = false;
});
} else {
setState(() => _isLoading = false);
}
} catch (e, stackTrace) {
logger.log('Failed to load equalizer page', e, stackTrace);
if (mounted) {
setState(() => _isLoading = false);
}
}
}
String _formatFrequency(double hz) {
if (hz >= 1000) {
return hz >= 10000
? '${(hz / 1000).toStringAsFixed(0)} kHz'
: '${(hz / 1000).toStringAsFixed(1)} kHz';
}
return '${hz.round()} Hz';
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: Text(context.l10n!.equalizer)),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: !audioHandler.isEqualizerSupported
? Center(
child: Padding(
padding: commonSingleChildScrollViewPadding,
child: Text(
context.l10n!.equalizerAndroidOnly,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
)
: _params == null
? Center(
child: Padding(
padding: commonSingleChildScrollViewPadding,
child: Text(
context.l10n!.equalizerInitFailed,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
)
: ListView(
padding: commonSingleChildScrollViewPadding,
children: [
Card.outlined(
child: SwitchListTile.adaptive(
title: Text(context.l10n!.equalizerEnable),
subtitle: Text(
_enabled
? context.l10n!.equalizerEnabledHint
: context.l10n!.equalizerDisabledHint,
),
value: _enabled,
onChanged: (value) async {
await audioHandler.setEqualizerEnabled(value);
if (!mounted) return;
setState(() => _enabled = value);
},
),
),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: () async {
await audioHandler.resetEqualizerBands();
final params = _params;
if (!mounted || params == null) return;
setState(() {
_gains = List<double>.filled(params.bands.length, 0);
});
},
icon: const Icon(Icons.refresh),
label: Text(context.l10n!.equalizerResetBands),
),
const SizedBox(height: 12),
...List.generate(_params!.bands.length, (index) {
final band = _params!.bands[index];
final gain = _gains[index];
final min = _params!.minDecibels;
final max = _params!.maxDecibels;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatFrequency(band.centerFrequency),
style: Theme.of(context).textTheme.titleSmall,
),
Text(
'${gain.toStringAsFixed(1)} dB',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
Slider(
value: gain.clamp(min, max),
min: min,
max: max,
divisions: ((max - min) * 2).round(),
label: '${gain.toStringAsFixed(1)} dB',
onChanged: (value) {
setState(() {
_gains[index] = value;
});
},
onChangeEnd: (value) async {
await audioHandler.setEqualizerBandGain(
index,
value,
);
},
),
],
),
),
),
);
}),
],
),
);
}
}

View File

@@ -24,6 +24,7 @@ import 'package:flutter/material.dart';
import 'package:musify/API/musify.dart';
import 'package:musify/extensions/l10n.dart';
import 'package:musify/main.dart';
import 'package:musify/screens/equalizer_page.dart';
import 'package:musify/screens/search_page.dart';
import 'package:musify/services/data_manager.dart';
import 'package:musify/services/router_service.dart';
@@ -116,6 +117,13 @@ class SettingsPage extends StatelessWidget {
inactivatedColor,
),
),
CustomBar(
context.l10n!.equalizer,
Icons.equalizer,
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const EqualizerPage())),
),
CustomBar(
context.l10n!.dynamicColor,
FluentIcons.toggle_left_24_filled,

View File

@@ -36,6 +36,20 @@ import 'package:rxdart/rxdart.dart';
class MusifyAudioHandler extends BaseAudioHandler {
MusifyAudioHandler() {
_androidEqualizer = AndroidEqualizer();
audioPlayer = AudioPlayer(
audioPipeline: Platform.isAndroid
? AudioPipeline(androidAudioEffects: [_androidEqualizer])
: AudioPipeline(),
audioLoadConfiguration: const AudioLoadConfiguration(
androidLoadControl: AndroidLoadControl(
maxBufferDuration: Duration(seconds: 60),
bufferForPlaybackDuration: Duration(milliseconds: 500),
bufferForPlaybackAfterRebufferDuration: Duration(seconds: 3),
),
),
);
_setupEventSubscriptions();
_updatePlaybackState();
@@ -49,15 +63,11 @@ class MusifyAudioHandler extends BaseAudioHandler {
_initialize();
}
final AudioPlayer audioPlayer = AudioPlayer(
audioLoadConfiguration: const AudioLoadConfiguration(
androidLoadControl: AndroidLoadControl(
maxBufferDuration: Duration(seconds: 60),
bufferForPlaybackDuration: Duration(milliseconds: 500),
bufferForPlaybackAfterRebufferDuration: Duration(seconds: 3),
),
),
);
late final AndroidEqualizer _androidEqualizer;
late final AudioPlayer audioPlayer;
bool _equalizerInitialized = false;
Future<bool>? _equalizerInitFuture;
DateTime _equalizerRetryNotBefore = DateTime.fromMillisecondsSinceEpoch(0);
Timer? _sleepTimer;
Timer? _debounceTimer;
@@ -277,6 +287,131 @@ class MusifyAudioHandler extends BaseAudioHandler {
}
}
Future<bool> _ensureEqualizerConfigured({bool force = false}) async {
if (!Platform.isAndroid) return false;
if (_equalizerInitialized) return true;
final now = DateTime.now();
if (!force && now.isBefore(_equalizerRetryNotBefore)) {
return false;
}
if (!force && audioPlayer.audioSource == null) {
return false;
}
final inFlight = _equalizerInitFuture;
if (inFlight != null) {
return inFlight;
}
_equalizerInitFuture = _configureEqualizer();
try {
return await _equalizerInitFuture!;
} finally {
_equalizerInitFuture = null;
}
}
Future<bool> _configureEqualizer() async {
try {
final params = await _androidEqualizer.parameters.timeout(
const Duration(seconds: 3),
);
final savedGains = equalizerBandGains.value;
if (savedGains.isNotEmpty) {
for (var i = 0; i < params.bands.length && i < savedGains.length; i++) {
final clamped = savedGains[i].clamp(
params.minDecibels,
params.maxDecibels,
);
await params.bands[i].setGain(clamped);
}
}
await _androidEqualizer.setEnabled(equalizerEnabled.value);
_equalizerInitialized = true;
_equalizerRetryNotBefore = DateTime.fromMillisecondsSinceEpoch(0);
return true;
} catch (e, stackTrace) {
_equalizerRetryNotBefore = DateTime.now().add(
const Duration(seconds: 10),
);
logger.log('Equalizer initialization deferred', e, stackTrace);
return false;
}
}
bool get isEqualizerSupported => Platform.isAndroid;
Future<AndroidEqualizerParameters?> getEqualizerParameters() async {
if (!Platform.isAndroid) return null;
final initialized = await _ensureEqualizerConfigured();
if (!initialized) return null;
try {
return await _androidEqualizer.parameters.timeout(
const Duration(seconds: 2),
);
} catch (e, stackTrace) {
logger.log('Failed to get equalizer parameters', e, stackTrace);
return null;
}
}
Future<void> setEqualizerEnabled(bool enabled) async {
if (!Platform.isAndroid) return;
final initialized = await _ensureEqualizerConfigured(force: true);
if (!initialized) return;
try {
await _androidEqualizer.setEnabled(enabled);
equalizerEnabled.value = enabled;
unawaited(addOrUpdateData('settings', 'equalizerEnabled', enabled));
} catch (e, stackTrace) {
logger.log('Failed to set equalizer enabled state', e, stackTrace);
}
}
Future<void> setEqualizerBandGain(int index, double gain) async {
if (!Platform.isAndroid) return;
final initialized = await _ensureEqualizerConfigured(force: true);
if (!initialized) return;
try {
final params = await _androidEqualizer.parameters;
if (index < 0 || index >= params.bands.length) {
return;
}
final clamped = gain.clamp(params.minDecibels, params.maxDecibels);
await params.bands[index].setGain(clamped);
final gains = params.bands.map((band) => band.gain).toList();
equalizerBandGains.value = gains;
unawaited(addOrUpdateData('settings', 'equalizerBandGains', gains));
} catch (e, stackTrace) {
logger.log('Failed to set equalizer band gain', e, stackTrace);
}
}
Future<void> resetEqualizerBands() async {
if (!Platform.isAndroid) return;
final initialized = await _ensureEqualizerConfigured(force: true);
if (!initialized) return;
try {
final params = await _androidEqualizer.parameters;
for (final band in params.bands) {
await band.setGain(0);
}
final gains = List<double>.filled(params.bands.length, 0);
equalizerBandGains.value = gains;
unawaited(addOrUpdateData('settings', 'equalizerBandGains', gains));
} catch (e, stackTrace) {
logger.log('Failed to reset equalizer bands', e, stackTrace);
}
}
void _updatePlaybackState() {
if (_isUpdatingState) return;
@@ -1123,6 +1258,7 @@ class MusifyAudioHandler extends BaseAudioHandler {
await audioPlayer
.setAudioSource(audioSource)
.timeout(_songTransitionTimeout);
unawaited(_ensureEqualizerConfigured(force: true));
await Future.delayed(const Duration(milliseconds: 100));
if (audioPlayer.duration != null) {

View File

@@ -68,6 +68,24 @@ final audioQualitySetting = ValueNotifier<String>(
Hive.box('settings').get('audioQuality', defaultValue: 'high'),
);
List<double> _readEqualizerGains() {
final raw = Hive.box(
'settings',
).get('equalizerBandGains', defaultValue: const <dynamic>[]);
if (raw is List) {
return raw.map((value) => value is num ? value.toDouble() : 0.0).toList();
}
return <double>[];
}
final equalizerEnabled = ValueNotifier<bool>(
Hive.box('settings').get('equalizerEnabled', defaultValue: false),
);
final equalizerBandGains = ValueNotifier<List<double>>(_readEqualizerGains());
Locale languageSetting = getLocaleFromLanguageCode(
Hive.box('settings').get('language', defaultValue: 'English') as String,
);