feat(auto): equalizer enable/disable and preset cycling from the car

Expose the equalizer's on/off toggle and preset choice as PlaybackStateCompat
custom actions on the now-playing screen. The redesign's removal of the
in-car equalizer FOLDER from the browse tree stays as-is (2403da3) -- this
is a different surface (playback screen custom actions, not a browse
folder) and does not reintroduce it.

Deliberately just 2 actions -- an on/off toggle plus a cycling preset
action, not one action per preset -- since Android Auto only surfaces a
limited number of custom actions. Both reuse the existing
setEcualizadorActivo/aplicarPreset entry points (the same ones
EstadoEcualizador's phone settings screen uses), so a car tap and a phone
tap behave identically and both keep the action labels in sync. Reuses the
bundled ic_stat_pluriwave drawable (the notification's own equalizer-bars
icon) -- zero new native assets. The 5-band constraint is untouched.

New pure, unit-tested functions in servicio_audio.dart: presetSiguiente,
nombrePresetVisible, controlesEcualizadorPersonalizados. New ARB keys
(eqCustomActionEnableLabel/DisableLabel/PresetLabel) across all 13 locales,
regenerated via flutter gen-l10n.
This commit is contained in:
2026-07-31 00:54:05 +02:00
parent 1b0bea5492
commit 9eff760462
29 changed files with 688 additions and 45 deletions
@@ -0,0 +1,189 @@
import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Item 4 (Android Auto: equalizer custom actions) — the pure, handler-
/// independent half of the fix. `PluriWaveAudioHandler` cannot be
/// instantiated in unit tests (a real `just_audio.AudioPlayer` requires
/// platform MethodChannels), so the preset-cycling decision, the preset-name
/// localization and the `MediaControl` list construction are extracted as
/// pure top-level functions here. The handler's own `customAction` dispatch
/// and `playbackState` wiring are static-review-only, same as the existing
/// EQ re-apply/session-id wiring.
void main() {
final l10n = lookupAppLocalizations(const Locale('es'));
group('presetSiguiente (item 4 — cycling presets)', () {
test('advances to the next preset in order', () {
expect(
presetSiguiente(PresetEcualizador.flat),
PresetEcualizador.rock,
);
expect(
presetSiguiente(PresetEcualizador.rock),
PresetEcualizador.pop,
);
});
test('wraps around after the last preset', () {
expect(
presetSiguiente(PresetEcualizador.presets.last),
PresetEcualizador.presets.first,
);
});
test(
'an unknown/custom preset (e.g. a user-tweaked "Personalizado" band '
'set) starts from the FIRST preset instead of throwing',
() {
final personalizado = PresetEcualizador(
nombre: 'Personalizado',
bandas: [1.0, 2.0, 3.0, 4.0, 5.0],
);
expect(
presetSiguiente(personalizado),
PresetEcualizador.presets.first,
);
},
);
test('respects an injected presets list instead of the default 6', () {
final propios = [PresetEcualizador.jazz, PresetEcualizador.voz];
expect(
presetSiguiente(PresetEcualizador.jazz, presets: propios),
PresetEcualizador.voz,
);
expect(
presetSiguiente(PresetEcualizador.voz, presets: propios),
PresetEcualizador.jazz,
);
});
});
group('nombrePresetVisible (item 4)', () {
test('maps every factory preset name to its localized ARB string', () {
expect(nombrePresetVisible(l10n, 'Flat'), l10n.equalizerPresetFlat);
expect(nombrePresetVisible(l10n, 'Rock'), l10n.equalizerPresetRock);
expect(nombrePresetVisible(l10n, 'Pop'), l10n.equalizerPresetPop);
expect(
nombrePresetVisible(l10n, 'Bass Boost'),
l10n.equalizerPresetBassBoost,
);
expect(nombrePresetVisible(l10n, 'Jazz'), l10n.equalizerPresetJazz);
expect(nombrePresetVisible(l10n, 'Voz'), l10n.equalizerPresetVoice);
expect(
nombrePresetVisible(l10n, 'Personalizado'),
l10n.equalizerPresetCustom,
);
});
test('an unrecognized name falls through verbatim', () {
expect(nombrePresetVisible(l10n, 'Mi Preset Guardado'), 'Mi Preset Guardado');
});
});
group('controlesEcualizadorPersonalizados (item 4)', () {
test('empty when the equalizer is not available on this device', () {
final controles = controlesEcualizadorPersonalizados(
disponible: false,
activo: true,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
expect(controles, isEmpty);
});
test(
'exactly 2 custom actions when available: on/off toggle + preset '
'cycle -- Android Auto shows a limited number of custom actions, so '
'this is deliberately NOT one action per preset',
() {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
presetActual: PresetEcualizador.rock,
l10n: l10n,
);
expect(controles, hasLength(2));
expect(controles.every((c) => c.action == MediaAction.custom), isTrue);
},
);
test('toggle label reflects ON -> shows "disable" action', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
final toggle = controles.firstWhere(
(c) => c.customAction?.name == accionEqToggle,
);
expect(toggle.label, l10n.eqCustomActionDisableLabel);
});
test('toggle label reflects OFF -> shows "enable" action', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: false,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
final toggle = controles.firstWhere(
(c) => c.customAction?.name == accionEqToggle,
);
expect(toggle.label, l10n.eqCustomActionEnableLabel);
});
test('preset-cycle label shows the CURRENT preset localized name', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
presetActual: PresetEcualizador.jazz,
l10n: l10n,
);
final ciclo = controles.firstWhere(
(c) => c.customAction?.name == accionEqPresetSiguiente,
);
expect(
ciclo.label,
l10n.eqCustomActionPresetLabel(l10n.equalizerPresetJazz),
);
});
test('both actions reuse the bundled notification drawable (zero new '
'native assets)', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
expect(
controles.every((c) => c.androidIcon == 'drawable/ic_stat_pluriwave'),
isTrue,
);
});
});
group('action name constants (item 4 — collision-free with car-tree ids)', () {
test('names are non-empty, distinct, and do not collide with any '
'existing browse-tree media-id prefix', () {
expect(accionEqToggle, isNotEmpty);
expect(accionEqPresetSiguiente, isNotEmpty);
expect(accionEqToggle, isNot(equals(accionEqPresetSiguiente)));
});
});
}