Playback issues on Android
For about a year, I have been working on a podcast app myself. Implementing playback support for Android was something I was hesitating to do, and then luckily for me dotnet-podcasts were released that helped me a lot with this. Thank you for this project!
However, I have found a few issues, and solutions (at least I think so, I haven’t I run dotnet-podcasts myself). I’m too lazy to make a PR, so instead I share these here.
Stopping notifications
If the application for some reason crashes, in many cases the player notification remains open. To solve this (or at least reduce the risk for this), I modified MainApplication and added several calls to StopNotification like this:
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
: base(handle, ownership)
{
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
AndroidEnvironment.UnhandledExceptionRaiser += AndroidEnvironment_UnhandledExceptionRaiser;
NotificationHelper.StopNotification(this);
}
private ILogger? _Logger;
private ILogger Logger
{
get
{
return _Logger ??= Services.GetRequiredService<ILogger<App>>();
}
}
private void AndroidEnvironment_UnhandledExceptionRaiser(object? sender, RaiseThrowableEventArgs e)
{
Logger.LogError(e.Exception, nameof(AndroidEnvironment_UnhandledExceptionRaiser));
NotificationHelper.StopNotification(this);
}
private void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
Logger.LogError(e.Exception, nameof(TaskScheduler_UnobservedTaskException));
NotificationHelper.StopNotification(this);
}
private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
Logger.LogError(e.ExceptionObject as Exception, nameof(CurrentDomain_UnhandledException));
NotificationHelper.StopNotification(this);
}
public override void OnTerminate()
{
Logger.LogWarning($"Is terminating.");
NotificationHelper.StopNotification(this);
base.OnTerminate();
}
Pause button in notification
The pause button in the notification is visible when the audio is playing. But it should also be visible when the state is buffering or stopped. In MediaPlayerService.UpdateNotification, replace:
MediaPlayerState == PlaybackStateCode.Playing
With:
MediaPlayerState is PlaybackStateCode.Playing or PlaybackStateCode.Buffering or PlaybackStateCode.Stopped
Position and Duration properties in MediaPlayerService
The properties Position and Duration in MediaPlayerService have valid values depending on state. I modified these to provide a cached value if the underlying media player cannot provide this:
private int _LatestValidPosition = -1;
public int Position
{
get
{
var pos = RawPosition;
if (pos >= 0)
{
_LatestValidPosition = pos;
}
return _LatestValidPosition;
}
private set
{
_LatestValidPosition = value;
}
}
private int RawPosition
{
get
{
if (mediaPlayer is null ||
!(MediaPlayerState is PlaybackStateCode.Playing or PlaybackStateCode.Paused or PlaybackStateCode.Buffering)
)
{
return -1;
}
else
{
return mediaPlayer.CurrentPosition;
}
}
}
private int _LatestValidDuration = -1;
public int Duration
{
get
{
var duration = RawDuration;
if (duration > 0)
{
_LatestValidDuration = duration;
}
return _LatestValidDuration;
}
set
{
_LatestValidDuration = value;
}
}
private int RawDuration
{
get
{
if (mediaPlayer is null ||
!(MediaPlayerState is PlaybackStateCode.Playing or PlaybackStateCode.Paused or PlaybackStateCode.Buffering)
{
return 0;
}
else
{
return mediaPlayer.Duration;
}
}
}
_LatestValidDuration and _LatestValidPosition should be set to -1 in PrepareAndPlayMediaPlayerAsync.
MediaPlayerService.Seek
The seek method I have modified to this:
public async Task Seek(int position)
{
Logger.LogInformation($"{nameof(Seek)} - position {position}");
UpdatePlaybackState(PlaybackStateCode.Buffering);
await Task.Run(() =>
{
if (mediaPlayer is not null)
{
Position = position;
mediaPlayer.SeekTo(position);
}
});
}
MediaPlayerService.PrepareAndPlayMediaPlayerAsync
In PrepareAndPlayMediaPlayerAsync I have added:
mediaPlayer.Pause();
mediaPlayer.Reset();
before the call to SetDataSourceAsync. If I remember correctly this solved a couple of cases where an IllegalStateException was thrown. But I have done other changes too, not sure if this is needed.
MediaPlayerService.OnAudioFocusChange
The MediaPlayerService automatically starts media player when it gains audio focus. This is great – if the service also has automatically paused the audio. Currently, if the audio is manually paused by the user and then the user gets a phone call (or a notification), the audio will be restarted after the phone call is completed. It is very surprising :-). I rewrote the method like this:
private bool RestartAudioOnGainAudioFocus = false;
public async void OnAudioFocusChange(AudioFocus focusChange)
{
Logger.LogInformation($"{nameof(OnAudioFocusChange)} - {focusChange}");
switch (focusChange)
{
case AudioFocus.Gain:
Logger.LogInformation("Gaining audio focus.");
mediaPlayer.SetVolume(1f, 1f);
if (RestartAudioOnGainAudioFocus)
{
Logger.LogInformation("Restarting audio.");
_ = Play();
}
else
{
Logger.LogInformation("Restarting audio not needed.");
}
break;
case AudioFocus.Loss:
Logger.LogInformation("Permanent lost audio focus.");
RestartAudioOnGainAudioFocus = false;
//We have lost focus stop!
await Stop(true);
break;
case AudioFocus.LossTransient:
Logger.LogInformation("Transient lost audio focus.");
//We have lost focus for a short time
// Restart if playing
if (this.MediaPlayerState == PlaybackStateCode.Playing)
{
Logger.LogInformation("Was playing. Will restart audio on gain audio focus.");
RestartAudioOnGainAudioFocus = true;
}
else
{
Logger.LogInformation("Was not playing. Will not restart audio on gain audio focus.");
RestartAudioOnGainAudioFocus = false;
}
await Pause();
break;
case AudioFocus.LossTransientCanDuck:
//We have lost focus but should till play at a muted 10% volume
if (mediaPlayer.IsPlaying)
{
mediaPlayer.SetVolume(.1f, .1f);
}
break;
}
}
WifiLock
MediaPlayerService uses a Wifi-lock. The reason for this seems to be:
“Lock the wifi so we can still stream under lock screen”
Assuming the user has access to mobile data, a Wifi-lock is not required. However, to properly support streaming when the device is locked, a foreground services should be used, see next topic.
Services doesn't need to be exported
I see this in the code:
[Service(Exported = true)
I have changed Exported to false, and it works simply fine.
RemoteControlBroadcastReceiver
The RemoteControlBroadcastReceiver could be removed. As I understand it, this is used to handle ACTION_MEDIA_BUTTON intent. But in Android 5, this was replaced with MediaButtonReceiver that is also implemented. See more details here: https://developer.android.com/guide/topics/media-apps/mediabuttons
I have tried without RemoteControlBroadcastReceiver and haven't found any issues.
Foreground service
Lastly, a bug that took me several months to solve :-( In my application, I could start audio and then after about 20 minutes that playback would be blocked. This only happen if my device wasn’t charging or connected to a computer. When I started the device, the audio started to play again. The reason for this was that the device lost connection to the Internet. The WifiLock mentioned earlier didn’t solve this. It looks like the internal media player is buffering about 20 minutes of audio. Just to prove that internet connection was solved I created this little utility:
class ConnectionAliveChecker
{
private ILogger Logger { get; }
public ConnectionAliveChecker(IServiceProvider serviceProvider)
{
Logger = serviceProvider.GetRequiredService<ILogger<ConnectionAliveChecker>>();
}
public async void RunCheck()
{
// Infinite loop. Check if vecka.nu is accessible every 10 second
while (true)
{
try
{
using (var client = new HttpClient())
{
var response = await client.GetAsync("https://vecka.nu");
if (response.IsSuccessStatusCode)
{
Logger.LogWarning("ConnectionAliveChecker is alive");
}
else
{
Logger.LogError("ConnectionAliveChecker is not alive");
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "ConnectionAliveChecker is not alive");
}
await Task.Delay(10000);
}
}
}
Then I started this when the application was started. About 5-10 minutes after the device was locked and USB was not connected, it was unable to connect to Internet.
To solve this, the MediaPlayerService needs to be a foreground service. Currently it is just a bound service. I have found no documentation about this limitation :-( First step is to add the android.permission.FOREGROUND_SERVICE should be added to AndroidManifest.xml:
<uses-permission android: name="android.permission.FOREGROUND_SERVICE" />
The second step, that I’m not sure if it’s required, is to change the MediaPlayerService attribute:
[Service(Exported = true)]
To:
[Service(Exported = true, ForegroundServiceType = global::Android.Content.PM.ForegroundService.TypeMediaPlayback)]
See this documentation: https://developer.android.com/guide/topics/manifest/service-element
Third step is to change how the notification is published. In NotificationHelper.StartNotification add Service service as a parameter and replace:
NotificationManagerCompat.From(context).Notify(NotificationId, builder. Build());
With:
if (Build.VERSION.SdkInt >= BuildVersionCodes.Q)
{
service.StartForeground(NotificationId, builder.Build(), ForegroundService.TypeMediaPlayback);
}
else
{
service.StartForeground(NotificationId, builder. Build());
}