Skip to content

Commit 0a80adf

Browse files
committed
Fix nex/prev track click from android lock screen
1 parent 60f41ad commit 0a80adf

File tree

5 files changed

+122
-188
lines changed

5 files changed

+122
-188
lines changed

src/Bible.Alarm/Platforms/Android/Services/Media/AndroidPlayerNotificationService.cs

Lines changed: 110 additions & 182 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
using Microsoft.Maui.Devices;
2020
using Microsoft.Maui.Handlers;
2121
using Serilog;
22+
using System.Collections.Generic;
2223
using System.Linq;
2324
using System.Reflection;
2425
using System.Threading.Tasks;
@@ -44,11 +45,16 @@ public AndroidPlayerNotificationService(ILogger logger)
4445

4546
/// <summary>
4647
/// Sets a multi-item queue via ExoPlayer using ConcatenatingMediaSource to enable both Next and Previous buttons.
47-
/// Uses three distinct MediaItems (dummy previous, current, dummy next) with different MediaIds and URI fragments pointing to the same file.
48+
/// Uses distinct MediaItems (dummy previous, current, dummy next) with different MediaIds and URI fragments pointing to the same file.
4849
/// This creates a proper multi-item timeline that MediaSessionConnector recognizes,
4950
/// unlike duplicate MediaItems which ExoPlayer may deduplicate.
51+
/// Only creates dummy items when needed (previous dummy only if not first track, next dummy only if not last track).
5052
/// </summary>
51-
public void SetSourceWithDummyQueue(MediaElement mediaElement, string uri)
53+
/// <param name="mediaElement">The MediaElement instance to use</param>
54+
/// <param name="uri">The URI of the current track</param>
55+
/// <param name="isFirstTrack">True if this is the first track (no previous dummy needed)</param>
56+
/// <param name="isLastTrack">True if this is the last track (no next dummy needed)</param>
57+
public void SetSourceWithDummyQueue(MediaElement mediaElement, string uri, bool isFirstTrack = false, bool isLastTrack = false)
5258
{
5359
try
5460
{
@@ -67,36 +73,48 @@ public void SetSourceWithDummyQueue(MediaElement mediaElement, string uri)
6773
// Parse the URI
6874
var androidUri = global::Android.Net.Uri.Parse(uri);
6975

70-
// Dummy previous item with distinct URI (add fragment) and ID
71-
var dummyPreviousUri = androidUri.BuildUpon().Fragment("previous").Build();
72-
var dummyPreviousItem = new MediaItem.Builder()
73-
.SetUri(dummyPreviousUri)
74-
.SetMediaId("bible_alarm_previous_dummy")
75-
.Build();
76+
// Build list of sources based on track position
77+
var sources = new List<IMediaSource>();
7678

77-
// Create current item
79+
// Create current item (always needed)
7880
var currentItem = new MediaItem.Builder()
7981
.SetUri(androidUri)
8082
.SetMediaId("bible_alarm_current")
8183
.Build();
84+
var currentSource = new ProgressiveMediaSource.Factory(dataSourceFactory)
85+
.CreateMediaSource(currentItem);
8286

83-
// Dummy next item with distinct URI (add fragment) and ID
84-
var dummyNextUri = androidUri.BuildUpon().Fragment("next").Build();
85-
var dummyNextItem = new MediaItem.Builder()
86-
.SetUri(dummyNextUri)
87-
.SetMediaId("bible_alarm_next_dummy")
88-
.Build();
87+
// Add previous dummy only if not first track
88+
if (!isFirstTrack)
89+
{
90+
var dummyPreviousUri = androidUri.BuildUpon().Fragment("previous").Build();
91+
var dummyPreviousItem = new MediaItem.Builder()
92+
.SetUri(dummyPreviousUri)
93+
.SetMediaId("bible_alarm_previous_dummy")
94+
.Build();
95+
var previousSource = new ProgressiveMediaSource.Factory(dataSourceFactory)
96+
.CreateMediaSource(dummyPreviousItem);
97+
sources.Add(previousSource);
98+
}
8999

90-
// Create ProgressiveMediaSource for each item
91-
var source1 = new ProgressiveMediaSource.Factory(dataSourceFactory)
92-
.CreateMediaSource(dummyPreviousItem);
93-
var source2 = new ProgressiveMediaSource.Factory(dataSourceFactory)
94-
.CreateMediaSource(currentItem);
95-
var source3 = new ProgressiveMediaSource.Factory(dataSourceFactory)
96-
.CreateMediaSource(dummyNextItem);
100+
// Add current item
101+
sources.Add(currentSource);
97102

98-
// Create ConcatenatingMediaSource with all three sources: [dummy_previous, current, dummy_next]
99-
var concatenatingSource = new ConcatenatingMediaSource(source1, source2, source3);
103+
// Add next dummy only if not last track
104+
if (!isLastTrack)
105+
{
106+
var dummyNextUri = androidUri.BuildUpon().Fragment("next").Build();
107+
var dummyNextItem = new MediaItem.Builder()
108+
.SetUri(dummyNextUri)
109+
.SetMediaId("bible_alarm_next_dummy")
110+
.Build();
111+
var nextSource = new ProgressiveMediaSource.Factory(dataSourceFactory)
112+
.CreateMediaSource(dummyNextItem);
113+
sources.Add(nextSource);
114+
}
115+
116+
// Create ConcatenatingMediaSource with the needed sources
117+
var concatenatingSource = new ConcatenatingMediaSource(sources.ToArray());
100118

101119
// Set the concatenating source on the player
102120
player.SetMediaSource(concatenatingSource);
@@ -105,12 +123,20 @@ public void SetSourceWithDummyQueue(MediaElement mediaElement, string uri)
105123
// This is what makes MediaSessionConnector see HasNextMediaItem and HasPreviousMediaItem = true
106124
player.Prepare();
107125

108-
// Seek to the middle item (current) - index 1 in the queue [0=dummy_previous, 1=current, 2=dummy_next]
109-
player.SeekTo(1, 0);
126+
// Seek to the current item index
127+
// If previous dummy exists, current is at index 1, otherwise at index 0
128+
var currentItemIndex = isFirstTrack ? 0 : 1;
129+
player.SeekTo(currentItemIndex, 0);
110130

111131
// Do NOT call player.Play() here - let the normal Play() flow handle it
112132

113-
_logger.Information("Set ConcatenatingMediaSource queue with three items — Next and Previous buttons will appear.");
133+
var itemCount = sources.Count;
134+
var itemsDescription = isFirstTrack && isLastTrack ? "current only" :
135+
isFirstTrack ? "current + next dummy" :
136+
isLastTrack ? "previous dummy + current" :
137+
"previous dummy + current + next dummy";
138+
_logger.Information("Set ConcatenatingMediaSource queue with {ItemCount} items ({ItemsDescription}) — Next and Previous buttons will appear conditionally.",
139+
itemCount, itemsDescription);
114140

115141
// Verify HasNextMediaItem and HasPreviousMediaItem are true
116142
if (player.HasNextMediaItem)
@@ -285,24 +311,65 @@ private void TryUpdateMediaSessionActions(MediaElement mediaElement)
285311
}
286312

287313
/// <summary>
288-
/// Sets up the ExoPlayer listener to intercept Next/Previous button presses from system controls.
289-
/// Uses reflection to call AddListener/RemoveListener with IPlayerListener parameter.
314+
/// Removes the ExoPlayer listener and cleans up references.
315+
/// Should be called before DisconnectHandler() to avoid accessing disposed ExoPlayer.
290316
/// </summary>
291-
private void SetupExoPlayerListener(IExoPlayer player)
317+
private void RemoveExoPlayerListener()
292318
{
293319
try
294320
{
295-
// Clean up old listener
296321
if (_exoPlayerListener != null && _currentPlayer != null)
297322
{
298-
var removeMethod = _currentPlayer.GetType().GetMethod("RemoveListener", new[] { typeof(IPlayerListener) });
299-
if (removeMethod != null)
323+
try
324+
{
325+
var removeMethod = _currentPlayer.GetType().GetMethod("RemoveListener", new[] { typeof(IPlayerListener) });
326+
if (removeMethod != null)
327+
{
328+
removeMethod.Invoke(_currentPlayer, new object[] { _exoPlayerListener });
329+
_logger.Debug("Removed ExoPlayer listener from player");
330+
}
331+
}
332+
catch (ObjectDisposedException)
300333
{
301-
removeMethod.Invoke(_currentPlayer, new object[] { _exoPlayerListener });
334+
// Player is already disposed - this is expected when handler is disconnected
335+
_logger.Debug("ExoPlayer is already disposed, skipping RemoveListener call");
302336
}
303-
_exoPlayerListener.Dispose();
337+
catch (Exception ex)
338+
{
339+
// Other exceptions during removal - log but continue
340+
_logger.Debug(ex, "Error removing listener from player (may be disposed)");
341+
}
342+
343+
// Dispose the listener
344+
try
345+
{
346+
_exoPlayerListener.Dispose();
347+
_logger.Debug("Disposed ExoPlayer listener");
348+
}
349+
catch (Exception ex)
350+
{
351+
_logger.Debug(ex, "Error disposing listener");
352+
}
353+
354+
_exoPlayerListener = null;
304355
}
356+
357+
_currentPlayer = null;
358+
}
359+
catch (Exception ex)
360+
{
361+
_logger.Debug(ex, "Error in RemoveExoPlayerListener");
362+
}
363+
}
305364

365+
/// <summary>
366+
/// Sets up the ExoPlayer listener to intercept Next/Previous button presses from system controls.
367+
/// Uses reflection to call AddListener with IPlayerListener parameter.
368+
/// </summary>
369+
private void SetupExoPlayerListener(IExoPlayer player)
370+
{
371+
try
372+
{
306373
// Create and add new listener
307374
_currentPlayer = player;
308375
_exoPlayerListener = new ExoPlayerListener(this, _logger);
@@ -398,15 +465,18 @@ await MainThread.InvokeOnMainThreadAsync(() =>
398465
mediaElement.Source = null; // Forces session reset
399466
});
400467

401-
// 2. Disconnect handler - this properly releases the MediaSession and removes the sticky notification
468+
// 2. Remove ExoPlayer listener before disconnecting handler (ExoPlayer will be disposed)
469+
RemoveExoPlayerListener();
470+
471+
// 3. Disconnect handler - this properly releases the MediaSession and removes the sticky notification
402472
mediaElement.Handler?.DisconnectHandler();
403473
_logger.Debug("Disconnected MediaElement handler - MediaSession released");
404474

405-
// 3. Send message to BootstrapPage to recreate MediaElement with fresh ExoPlayer instance
475+
// 4. Send message to BootstrapPage to recreate MediaElement with fresh ExoPlayer instance
406476
WeakReferenceMessenger.Default.Send(new RecreateMediaElementMessage());
407477
_logger.Information("Sent RecreateMediaElementMessage to BootstrapPage - MediaElement will be recreated");
408478

409-
// 4. Cancel the hard-coded notification ID + all (extra safety to ensure notification is gone)
479+
// 5. Cancel the hard-coded notification ID + all (extra safety to ensure notification is gone)
410480
var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity;
411481
if (activity == null)
412482
{
@@ -461,157 +531,15 @@ await MainThread.InvokeOnMainThreadAsync(() =>
461531
}
462532
}
463533

464-
465-
/// <summary>
466-
/// Permanently deletes the Media3 notification channel to prevent the system from re-creating it.
467-
/// This is only available on Android 8.0 (API 26) and above where notification channels exist.
468-
/// </summary>
469-
private void KillMedia3ChannelPermanently()
470-
{
471-
if (OperatingSystem.IsAndroidVersionAtLeast(26))
472-
{
473-
try
474-
{
475-
var appContext = Microsoft.Maui.ApplicationModel.Platform.AppContext;
476-
if (appContext == null)
477-
{
478-
_logger.Debug("Platform.AppContext is null, cannot delete notification channel");
479-
return;
480-
}
481-
482-
var notificationManager = appContext.GetSystemService(Context.NotificationService) as NotificationManager;
483-
484-
if (notificationManager != null)
485-
{
486-
notificationManager.DeleteNotificationChannel("media_session");
487-
_logger.Information("Deleted Media3 notification channel 'media_session'");
488-
}
489-
}
490-
catch (Exception ex)
491-
{
492-
// Channel already deleted, doesn't exist, or permission issue - not critical
493-
_logger.Debug(ex, "Could not delete Media3 notification channel (may already be deleted)");
494-
}
495-
}
496-
}
497-
498-
/// <summary>
499-
/// Gets the MediaManager from MediaElement via reflection.
500-
/// </summary>
501-
private object? GetMediaManager(MediaElement mediaElement)
502-
{
503-
try
504-
{
505-
var handler = mediaElement.Handler;
506-
if (handler == null)
507-
{
508-
_logger.Debug("MediaElement handler is null");
509-
return null;
510-
}
511-
512-
var handlerType = handler.GetType();
513-
_logger.Debug("Handler type: {HandlerType}", handlerType.FullName);
514-
515-
var mediaManagerProperty = handlerType.GetProperty("MediaManager", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);
516-
if (mediaManagerProperty == null)
517-
{
518-
_logger.Debug("MediaManager property not found in handler type: {HandlerType}", handlerType.Name);
519-
return null;
520-
}
521-
522-
var mediaManager = mediaManagerProperty.GetValue(handler);
523-
if (mediaManager == null)
524-
{
525-
_logger.Debug("MediaManager property exists but is null");
526-
}
527-
else
528-
{
529-
_logger.Debug("MediaManager retrieved successfully: {Type}", mediaManager.GetType().FullName);
530-
}
531-
532-
return mediaManager;
533-
}
534-
catch (Exception ex)
535-
{
536-
_logger.Debug(ex, "Failed to get MediaManager via reflection");
537-
return null;
538-
}
539-
}
540-
541-
/// <summary>
542-
/// Force-kill the notification the official Microsoft-approved way for MediaElement 7.0.0+.
543-
/// MediaElement on Android always uses notification ID = 16777216 (0x1000000) in 7.0.x.
544-
/// This is the only method that works reliably with Maui MediaElement 7.0.x.
545-
/// Reference: https://github.com/dotnet/maui/blob/7.0.0/src/Core/src/Platform/Android/MediaElementHandler.cs#L198
546-
/// </summary>
547-
private void TryCancelNotificationManually()
548-
{
549-
try
550-
{
551-
_logger.Information("Attempting to cancel media notification using Microsoft-approved method (ID: 16777216)");
552-
553-
// Get NotificationManager from the current activity
554-
var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity;
555-
if (activity == null)
556-
{
557-
_logger.Warning("CurrentActivity is null, cannot cancel notification");
558-
return;
559-
}
560-
561-
var notificationManager = activity.GetSystemService(global::Android.Content.Context.NotificationService) as global::Android.App.NotificationManager;
562-
563-
if (notificationManager == null)
564-
{
565-
_logger.Warning("Could not get Android NotificationManager");
566-
return;
567-
}
568-
569-
// MediaElement on Android always uses notification ID = 16777216 (0x1000000) in 7.0.x
570-
const int MediaElementNotificationId = 16777216; // 0x1000000
571-
572-
// Cancel with the hard-coded ID
573-
notificationManager.Cancel(MediaElementNotificationId);
574-
_logger.Information("Canceled notification with MediaElement ID: {Id}", MediaElementNotificationId);
575-
576-
// Extra paranoia – also cancel the built-in media session tag
577-
notificationManager.Cancel("media_session_tag", MediaElementNotificationId);
578-
_logger.Debug("Also attempted to cancel with tag 'media_session_tag' and ID: {Id}", MediaElementNotificationId);
579-
580-
// And cancel everything just in case (nuclear option)
581-
notificationManager.CancelAll();
582-
_logger.Debug("Called CancelAll() as final cleanup");
583-
}
584-
catch (Exception ex)
585-
{
586-
_logger.Warning(ex, "Error in TryCancelNotificationManually (Microsoft-approved method)");
587-
}
588-
}
589-
590534
public void Dispose()
591535
{
592536
try
593537
{
594538
// Note: ReleaseMediaSession is now called from AudioPlayer with MediaElement instance
595539
// We don't call it here since we no longer have a MediaElement reference
596540

597-
if (_exoPlayerListener != null && _currentPlayer != null)
598-
{
599-
try
600-
{
601-
var removeMethod = _currentPlayer.GetType().GetMethod("RemoveListener", new[] { typeof(IPlayerListener) });
602-
if (removeMethod != null)
603-
{
604-
removeMethod.Invoke(_currentPlayer, new object[] { _exoPlayerListener });
605-
}
606-
_exoPlayerListener.Dispose();
607-
}
608-
catch (Exception ex)
609-
{
610-
_logger.Debug(ex, "Error removing listener during dispose");
611-
}
612-
_exoPlayerListener = null;
613-
_currentPlayer = null;
614-
}
541+
// Remove listener using the same method used during handler disconnect
542+
RemoveExoPlayerListener();
615543
}
616544
catch (Exception ex)
617545
{

src/Bible.Alarm/Services/Media/AudioPlayer.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ public AudioPlayer(ILogger logger, IMediaElementService mediaElementService, IDi
100100
_positionTimer.AutoReset = true;
101101
}
102102

103-
public async Task PrepareAsync(AudioPlayerTrack track)
103+
public async Task PrepareAsync(AudioPlayerTrack track, bool isFirstTrack = false, bool isLastTrack = false)
104104
{
105105
ArgumentNullException.ThrowIfNull(track);
106106
if (string.IsNullOrEmpty(track.Uri))
@@ -149,8 +149,8 @@ await MainThread.InvokeOnMainThreadAsync(() =>
149149
_mediaElement.Source = MediaSource.FromUri(track.Uri);
150150

151151
// Now set the real (multi-item) queue via ExoPlayer to enable Next button
152-
// Pass MediaElement to the service
153-
_androidPlayerNotificationService?.SetSourceWithDummyQueue(_mediaElement, track.Uri);
152+
// Pass MediaElement to the service with flags indicating track position
153+
_androidPlayerNotificationService?.SetSourceWithDummyQueue(_mediaElement, track.Uri, isFirstTrack, isLastTrack);
154154
#else
155155
_mediaElement.Source = track.Uri;
156156
#endif

0 commit comments

Comments
 (0)