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:
@@ -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`',
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user