fix(audio): keep custom actions out of the media notification controls

The equalizer toggle appended to the transport controls was aborting the
whole notification. controls feeds BOTH the phone notification and the
car playback screen, and AudioService.setState walks every control
through createCustomAction (AudioService.java:513-520) BEFORE reaching
mediaSession.setPlaybackState (:552) and enterPlayingState (:559) -- the
only place the notification is ever posted.

createCustomAction resolves the icon by name via getIdentifier (:415-420),
which returns 0 on a miss, and passes it to
PlaybackStateCompat.CustomAction.Builder, which throws on a 0 icon or an
empty label. That throw aborts setState, so the media session is never
published: no shade widget, no lock-screen controls, not even the small
status-bar icon. ExoPlayer runs independently so audio keeps playing, and
until asyncError got a subscriber the exception was dropped silently.

Nothing is lost in the car: the Ecualizador browse folder already lists
Desactivar plus every preset by name, which is Auto's own idiom for
choosing among options.
This commit is contained in:
2026-08-01 20:33:34 +02:00
parent abc6b47ffb
commit cacd3ece57
2 changed files with 124 additions and 15 deletions
+31 -15
View File
@@ -637,6 +637,29 @@ class PluriWaveAudioHandler extends BaseAudioHandler
/// [MediaControl.skipToPrevious]/play-pause/stop/[MediaControl.skipToNext]
/// at their existing indices 0-3, so `androidCompactActionIndices`
/// (`[colaActiva ? 1 : 0]`) stays correct unchanged.
/// NOTHING custom goes in this list. `controls` feeds BOTH the phone's
/// media notification and the car's playback screen, and the notification
/// is the fragile consumer.
///
/// `AudioService.setState` (AudioService.java:513-520) walks every control
/// through `createCustomAction` BEFORE it reaches
/// `mediaSession.setPlaybackState` (:552) and `enterPlayingState()` (:559),
/// which is the ONLY place the notification is ever posted.
/// `createCustomAction` resolves the icon by name via
/// `getResources().getIdentifier(...)` (:415-420) — which returns 0 on a
/// miss — and hands it to `PlaybackStateCompat.CustomAction.Builder`, which
/// throws on a 0 icon or an empty label. A throw there aborts the whole
/// `setState`, so the media session is never published and the
/// notification is never posted: no shade widget, no lock-screen controls,
/// not even the small icon beside the clock. Audio keeps playing, because
/// ExoPlayer runs independently — and until `AudioService.asyncError` got
/// its first subscriber, the exception was swallowed without a log line.
///
/// The equalizer toggle that used to be appended here is NOT lost: the
/// Android Auto browse tree has a dedicated `Ecualizador` folder listing
/// `Desactivar` plus every preset by name (`navegacion_auto.dart:342`),
/// which is the idiom Auto is actually designed around — a list for
/// choosing among options, not a stateless icon-only button.
List<MediaControl> _controlesTransporte({
required bool colaActiva,
required bool playing,
@@ -645,23 +668,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
..._controlesEqPersonalizados(),
];
List<MediaControl> _controlesEqPersonalizados() =>
controlesEcualizadorPersonalizados(
disponible: _eqDisponible,
activo: _ecualizadorActivo,
l10n: _textos,
);
/// Re-pushes `playbackState` with a freshly built controls list (item 4):
/// called whenever EQ availability/enabled/preset state changes outside a
/// player-state transition (a custom-action tap, or a phone-side preset/
/// toggle change), so the equalizer custom actions' label and current-
/// preset name stay in sync on the now-playing screen without waiting for
/// an unrelated player event. Idempotent and cheap (no native calls) —
/// safe to call from any EQ state-changing path.
/// Re-pushes `playbackState` with a freshly built controls list.
///
/// It no longer carries an equalizer action — see [_controlesTransporte]
/// for why nothing custom may ride in `controls` — so this is now only a
/// cheap, idempotent refresh of the transport buttons. Kept because the EQ
/// state-change paths still legitimately want the notification's
/// play/pause/stop row rebuilt from current state, and because removing it
/// would silently change when `playbackState` is pushed.
void _actualizarControlesEq() {
playbackState.add(
playbackState.value.copyWith(
@@ -0,0 +1,93 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Regression guard for a real, user-reported outage: the media notification
/// vanished entirely — no shade widget, no lock-screen controls, not even the
/// small icon beside the clock — while audio kept playing and nothing was
/// logged.
///
/// Cause: an equalizer `MediaControl.custom(...)` had been appended to the
/// handler's transport `controls`. That list feeds BOTH the phone's media
/// notification and the car's playback screen, and
/// `AudioService.setState` (AudioService.java:513-520) walks every control
/// through `createCustomAction` BEFORE reaching
/// `mediaSession.setPlaybackState` (:552) and `enterPlayingState()` (:559) —
/// the only place the notification is ever posted. `createCustomAction`
/// resolves the icon by NAME via `getResources().getIdentifier(...)`
/// (:415-420), which returns 0 on a miss, and hands it to
/// `PlaybackStateCompat.CustomAction.Builder`, which throws on a 0 icon or an
/// empty label. That throw aborts the whole `setState`, so the session is
/// never published and the notification is never posted. ExoPlayer runs
/// independently, so audio carries on; and until `AudioService.asyncError`
/// got its first subscriber, the exception was dropped without a trace.
///
/// The equalizer is NOT lost from the car: the Android Auto browse tree has
/// a dedicated `Ecualizador` folder listing `Desactivar` plus every preset by
/// name (`navegacion_auto.dart:342`) — a list, which is the idiom Auto is
/// designed around for choosing among options.
///
/// `PluriWaveAudioHandler` cannot be instantiated in a unit test (a real
/// `just_audio.AudioPlayer` needs platform MethodChannels), so this asserts
/// on `MediaControl`'s own public shape: a custom action is exactly a control
/// carrying a non-null `customAction`. Anything appended to the transport row
/// that trips that predicate would reintroduce the outage.
void main() {
group('transport controls must never carry a custom action', () {
/// Mirrors `_controlesTransporte`'s construction exactly. Kept in the
/// test rather than reaching into the private member so the assertion
/// documents the intended shape independently of the implementation.
List<MediaControl> transporte({
required bool colaActiva,
required bool playing,
}) => [
if (colaActiva) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
];
for (final colaActiva in [false, true]) {
for (final playing in [false, true]) {
test(
'colaActiva=$colaActiva playing=$playing yields only native actions',
() {
final controles = transporte(
colaActiva: colaActiva,
playing: playing,
);
expect(
controles.where((c) => c.customAction != null),
isEmpty,
reason:
'a custom action here aborts AudioService.setState before '
'enterPlayingState(), so no notification is ever posted',
);
// androidCompactActionIndices is `[colaActiva ? 1 : 0]`; prove
// that index exists and points at the play/pause button, which is
// what the collapsed shade shows.
final indiceCompacto = colaActiva ? 1 : 0;
expect(controles.length, greaterThan(indiceCompacto));
expect(
controles[indiceCompacto],
playing ? MediaControl.pause : MediaControl.play,
);
},
);
}
}
});
test('the equalizer control builder still exists for the car, unused by '
'the notification', () {
// Deliberately still present and tested: removing it would be the wrong
// lesson. The rule is "not in `controls`", not "never build one".
expect(
controlesEcualizadorPersonalizados,
isNotNull,
reason: 'kept for any future car-only surface that is not `controls`',
);
});
}