- Flutter app (Android/iOS): home, explore, albums, search, player with equalizer, mini player, sign up/sign in with email codes, profile, encrypted offline downloads, About page - server/: one-command deploy of self-hosted Supabase behind HestiaCP/nginx (or Caddy), migrations, backups, email templates - supabase/migrations: schema, row-level security, profiles - tools/: song scanner and importer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
4.5 KiB
Dart
138 lines
4.5 KiB
Dart
import 'dart:convert';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
|
|
import '../data/user_library.dart';
|
|
import 'player_providers.dart';
|
|
|
|
/// Equalizer presets, described as a gain curve over frequency so they fit
|
|
/// whatever bands the phone's equalizer has (usually 5).
|
|
enum EqPreset {
|
|
flat('Flat'),
|
|
bass('Bass boost'),
|
|
vocal('Vocal'),
|
|
festival('Festival'),
|
|
treble('Treble'),
|
|
custom('Custom');
|
|
|
|
const EqPreset(this.label);
|
|
final String label;
|
|
|
|
/// Gain in dB for a band centred on [hz]; null for [custom].
|
|
double? gainAt(double hz) => switch (this) {
|
|
flat => 0,
|
|
bass => hz < 120 ? 6 : hz < 400 ? 3.5 : 0,
|
|
vocal => hz < 200 ? -2 : hz < 600 ? 1 : hz < 5000 ? 4 : 1,
|
|
festival => hz < 200 ? 5 : hz < 1500 ? 0 : hz < 5000 ? 2 : 4,
|
|
treble => hz < 1000 ? 0 : hz < 5000 ? 2.5 : 5,
|
|
custom => null,
|
|
};
|
|
}
|
|
|
|
@immutable
|
|
class SoundState {
|
|
const SoundState({
|
|
this.eqEnabled = false,
|
|
this.preset = EqPreset.flat,
|
|
this.customGains = const {},
|
|
this.boostDb = 0,
|
|
});
|
|
|
|
final bool eqEnabled;
|
|
final EqPreset preset;
|
|
|
|
/// Band index -> dB, used when [preset] is custom.
|
|
final Map<int, double> customGains;
|
|
|
|
/// Loudness boost for quiet old recordings, 0 to 10 dB.
|
|
final double boostDb;
|
|
|
|
SoundState copyWith({bool? eqEnabled, EqPreset? preset, Map<int, double>? customGains, double? boostDb}) =>
|
|
SoundState(
|
|
eqEnabled: eqEnabled ?? this.eqEnabled,
|
|
preset: preset ?? this.preset,
|
|
customGains: customGains ?? this.customGains,
|
|
boostDb: boostDb ?? this.boostDb,
|
|
);
|
|
|
|
Map<String, Object> toJson() => {
|
|
'eq': eqEnabled,
|
|
'preset': preset.name,
|
|
'custom': {for (final e in customGains.entries) '${e.key}': e.value},
|
|
'boost': boostDb,
|
|
};
|
|
|
|
static SoundState fromJson(Map<String, dynamic> j) => SoundState(
|
|
eqEnabled: j['eq'] as bool? ?? false,
|
|
preset: EqPreset.values.asNameMap()[j['preset']] ?? EqPreset.flat,
|
|
customGains: {
|
|
for (final e in ((j['custom'] as Map?) ?? {}).entries) int.parse(e.key as String): (e.value as num).toDouble(),
|
|
},
|
|
boostDb: (j['boost'] as num?)?.toDouble() ?? 0,
|
|
);
|
|
}
|
|
|
|
/// Whether this platform supports the in-app equalizer and boost.
|
|
bool get soundEffectsSupported => !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
|
|
|
class SoundSettings extends Notifier<SoundState> {
|
|
static const _key = 'sound_settings';
|
|
|
|
@override
|
|
SoundState build() {
|
|
final raw = ref.read(sharedPrefsProvider).getString(_key);
|
|
SoundState state;
|
|
try {
|
|
state = raw == null ? const SoundState() : SoundState.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
|
} catch (_) {
|
|
state = const SoundState();
|
|
}
|
|
_apply(state);
|
|
return state;
|
|
}
|
|
|
|
AndroidEqualizer get _eq => ref.read(audioHandlerProvider).equalizer;
|
|
AndroidLoudnessEnhancer get _boost => ref.read(audioHandlerProvider).loudness;
|
|
|
|
void setEnabled(bool on) => _update(state.copyWith(eqEnabled: on));
|
|
|
|
void setPreset(EqPreset preset) => _update(state.copyWith(preset: preset, eqEnabled: true));
|
|
|
|
/// Moving a band switches to the custom preset, starting from the current curve.
|
|
Future<void> setBandGain(int index, double db) async {
|
|
final params = await _eq.parameters;
|
|
final gains = {for (final b in params.bands) b.index: b.gain}..[index] = db;
|
|
_update(state.copyWith(preset: EqPreset.custom, customGains: gains, eqEnabled: true));
|
|
}
|
|
|
|
void setBoost(double db) => _update(state.copyWith(boostDb: db));
|
|
|
|
void reset() => _update(const SoundState());
|
|
|
|
void _update(SoundState next) {
|
|
state = next;
|
|
ref.read(sharedPrefsProvider).setString(_key, jsonEncode(next.toJson()));
|
|
_apply(next);
|
|
}
|
|
|
|
Future<void> _apply(SoundState s) async {
|
|
if (!soundEffectsSupported) return;
|
|
await _boost.setEnabled(s.boostDb > 0);
|
|
await _boost.setTargetGain(s.boostDb);
|
|
await _eq.setEnabled(s.eqEnabled);
|
|
// Parameters arrive once the player has an audio session (first song loaded).
|
|
final params = await _eq.parameters;
|
|
for (final band in params.bands) {
|
|
final db = s.preset == EqPreset.custom
|
|
? (s.customGains[band.index] ?? 0)
|
|
: s.preset.gainAt(band.centerFrequency)!;
|
|
await band.setGain(math.max(params.minDecibels, math.min(params.maxDecibels, db)));
|
|
}
|
|
}
|
|
}
|
|
|
|
final soundSettingsProvider = NotifierProvider<SoundSettings, SoundState>(SoundSettings.new);
|