fix(audio): restore the media notification by keeping custom actions out of controls
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.
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