From 6781b882df73c4c38d0d19351a6eff1c2a77bb10 Mon Sep 17 00:00:00 2001 From: Valeri Gokadze Date: Mon, 16 Feb 2026 18:56:45 +0400 Subject: [PATCH] feat: add built-in equalizer (related to #700) --- lib/localization/app_en.arb | 7 + lib/screens/equalizer_page.dart | 205 +++++++++++++++++++++++++++++ lib/screens/settings_page.dart | 8 ++ lib/services/audio_service.dart | 154 ++++++++++++++++++++-- lib/services/settings_manager.dart | 18 +++ 5 files changed, 383 insertions(+), 9 deletions(-) create mode 100644 lib/screens/equalizer_page.dart diff --git a/lib/localization/app_en.arb b/lib/localization/app_en.arb index bac98f43..8fbea4b9 100644 --- a/lib/localization/app_en.arb +++ b/lib/localization/app_en.arb @@ -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.", diff --git a/lib/screens/equalizer_page.dart b/lib/screens/equalizer_page.dart new file mode 100644 index 00000000..6bf011cd --- /dev/null +++ b/lib/screens/equalizer_page.dart @@ -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 . + * + * + * 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 createState() => _EqualizerPageState(); +} + +class _EqualizerPageState extends State { + AndroidEqualizerParameters? _params; + List _gains = []; + bool _enabled = equalizerEnabled.value; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadEqualizer(); + } + + Future _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.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, + ); + }, + ), + ], + ), + ), + ), + ); + }), + ], + ), + ); + } +} diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart index e65a287d..a9a5544c 100644 --- a/lib/screens/settings_page.dart +++ b/lib/screens/settings_page.dart @@ -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, diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index daa6dba3..05b6bd11 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -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? _equalizerInitFuture; + DateTime _equalizerRetryNotBefore = DateTime.fromMillisecondsSinceEpoch(0); Timer? _sleepTimer; Timer? _debounceTimer; @@ -277,6 +287,131 @@ class MusifyAudioHandler extends BaseAudioHandler { } } + Future _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 _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 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 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 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 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.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) { diff --git a/lib/services/settings_manager.dart b/lib/services/settings_manager.dart index 5f0d5016..ad7de148 100644 --- a/lib/services/settings_manager.dart +++ b/lib/services/settings_manager.dart @@ -68,6 +68,24 @@ final audioQualitySetting = ValueNotifier( Hive.box('settings').get('audioQuality', defaultValue: 'high'), ); +List _readEqualizerGains() { + final raw = Hive.box( + 'settings', + ).get('equalizerBandGains', defaultValue: const []); + + if (raw is List) { + return raw.map((value) => value is num ? value.toDouble() : 0.0).toList(); + } + + return []; +} + +final equalizerEnabled = ValueNotifier( + Hive.box('settings').get('equalizerEnabled', defaultValue: false), +); + +final equalizerBandGains = ValueNotifier>(_readEqualizerGains()); + Locale languageSetting = getLocaleFromLanguageCode( Hive.box('settings').get('language', defaultValue: 'English') as String, );