1919using Microsoft . Maui . Devices ;
2020using Microsoft . Maui . Handlers ;
2121using Serilog ;
22+ using System . Collections . Generic ;
2223using System . Linq ;
2324using System . Reflection ;
2425using 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 {
0 commit comments