mirror of
https://github.com/gokadzev/Musify.git
synced 2026-03-13 15:20:46 +08:00
feat: add built-in equalizer (related to #700)
This commit is contained in:
@@ -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.",
|
||||
|
||||
205
lib/screens/equalizer_page.dart
Normal file
205
lib/screens/equalizer_page.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user