3 Commits
Author SHA1 Message Date
FreeTLab d3999a20fb fix(audio): restore the media notification by keeping custom actions out of controls
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s
Reported: 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. Working three days earlier.

The equalizer toggle added on 30-31 July was appended to the transport
controls list. That list feeds BOTH the phone notification and the car
playback screen, and AudioService.setState walks every control through
createCustomAction (AudioService.java:513-520) 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 through getIdentifier
(:415-420) -- 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 setState before the session is ever
published. ExoPlayer is independent, so audio continues; and until
asyncError got its first subscriber the exception was dropped silently.
That accounts for every detail of the report.

The car keeps its equalizer: the Ecualizador browse folder already lists
Desactivar plus every preset by name.

Tests: 1103 -> 1108.
2026-08-01 20:33:51 +02:00
FreeTLab f2d7e98813 merge: incorporate the CI version bump 2026-08-01 20:33:51 +02:00
FreeTLab cacd3ece57 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.
2026-08-01 20:33:34 +02:00
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`',
);
});
}