[go: up one dir, main page]

Skip to content
This repository has been archived by the owner on May 6, 2024. It is now read-only.

Playback issues on Android #152

Open
pekspro opened this issue Sep 11, 2022 · 0 comments
Open

Playback issues on Android #152

pekspro opened this issue Sep 11, 2022 · 0 comments

Comments

@pekspro
Copy link
pekspro commented Sep 11, 2022

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());
        }
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant