From b631602b3d0e18a9eba62cf7401e281bbd5a9dbb Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Wed, 1 Oct 2025 19:50:02 -0700 Subject: [PATCH 1/8] Refactor Android MediaElement to use MediaSession Refactored `MediaElement` implementation for Android to replace the bound service (`MediaControlsService`) with `MediaSession` and `MediaController`, simplifying the architecture and aligning with modern Android media playback practices. - Removed `BoundServiceBinder` and `BoundServiceConnection` classes. - Updated `MediaControlsService` to use `ExoPlayer` and `MediaSession`. - Simplified notification management with `NotificationCompat`. - Refactored `MediaManager` to remove session and bound service logic. - Added `CreateMediaController` for asynchronous `MediaController` setup. - Updated `MauiMediaElement` to support `MediaController` integration. - Removed legacy code, unused imports, and redundant methods. --- .../AppBuilderExtensions.shared.cs | 4 - .../Handlers/MediaElementHandler.android.cs | 18 +- .../Services/BoundServiceBinder.android.cs | 9 - .../BoundServiceConnection.android.cs | 49 ---- .../Services/MediaControlsService.android.cs | 200 ++++++---------- .../Views/MauiMediaElement.android.cs | 45 ++-- .../Views/MediaManager.android.cs | 219 +++++++++--------- .../Views/MediaManager.shared.cs | 2 +- 8 files changed, 207 insertions(+), 339 deletions(-) delete mode 100644 src/CommunityToolkit.Maui.MediaElement/Services/BoundServiceBinder.android.cs delete mode 100644 src/CommunityToolkit.Maui.MediaElement/Services/BoundServiceConnection.android.cs diff --git a/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs b/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs index 3f7eb88638..a23e7da616 100644 --- a/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs @@ -32,10 +32,6 @@ public static MauiAppBuilder UseMauiCommunityToolkitMediaElement(this MauiAppBui h.AddHandler(); }); -#if ANDROID - builder.Services.AddSingleton(); -#endif - return builder; } } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.android.cs b/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.android.cs index bd1e00e6e7..4a8f0a0902 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.android.cs @@ -24,10 +24,24 @@ protected override MauiMediaElement CreatePlatformView() VirtualView, Dispatcher.GetForCurrentThread() ?? throw new InvalidOperationException($"{nameof(IDispatcher)} cannot be null")); - var (_, playerView) = MediaManager.CreatePlatformView(VirtualView.AndroidViewType); + var playerView = MediaManager.CreatePlatformView(VirtualView.AndroidViewType); return new(Context, playerView); } - + protected override async void ConnectHandler(MauiMediaElement platformView) + { + base.ConnectHandler(platformView); + if(platformView is null) + { + throw new InvalidOperationException($"{nameof(platformView)} cannot be null"); + } + if (MediaManager is null) + { + throw new InvalidOperationException($"{nameof(MediaManager)} cannot be null"); + } + var mediaController = await MediaManager.CreateMediaController(); + platformView?.SetView(mediaController); + await MediaManager.UpdateSource(); + } protected override void DisconnectHandler(MauiMediaElement platformView) { platformView.Dispose(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Services/BoundServiceBinder.android.cs b/src/CommunityToolkit.Maui.MediaElement/Services/BoundServiceBinder.android.cs deleted file mode 100644 index e095271c31..0000000000 --- a/src/CommunityToolkit.Maui.MediaElement/Services/BoundServiceBinder.android.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Android.OS; -using CommunityToolkit.Maui.Media.Services; - -namespace CommunityToolkit.Maui.Services; - -sealed class BoundServiceBinder(MediaControlsService mediaControlsService) : Binder -{ - public MediaControlsService Service { get; } = mediaControlsService; -} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Services/BoundServiceConnection.android.cs b/src/CommunityToolkit.Maui.MediaElement/Services/BoundServiceConnection.android.cs deleted file mode 100644 index 229ada44d6..0000000000 --- a/src/CommunityToolkit.Maui.MediaElement/Services/BoundServiceConnection.android.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Android.Content; -using Android.OS; -using CommunityToolkit.Maui.Core.Views; - -namespace CommunityToolkit.Maui.Services; - -sealed partial class BoundServiceConnection(MediaManager mediaManager) : Java.Lang.Object, IServiceConnection -{ - readonly WeakEventManager taskRemovedEventManager = new(); - - public event EventHandler MediaControlsServiceTaskRemoved - { - add => taskRemovedEventManager.AddEventHandler(value); - remove => taskRemovedEventManager.RemoveEventHandler(value); - } - - public MediaManager? Activity { get; } = mediaManager; - - public bool IsConnected => Binder is not null; - - public BoundServiceBinder? Binder { get; private set; } - - void HandleTaskRemoved(object? sender, EventArgs e) - { - taskRemovedEventManager.HandleEvent(this, EventArgs.Empty, nameof(MediaControlsServiceTaskRemoved)); - } - - void IServiceConnection.OnServiceConnected(ComponentName? name, IBinder? service) - { - Binder = service as BoundServiceBinder; - - if (Binder is not null) - { - Binder.Service.TaskRemoved += HandleTaskRemoved; - } - - // UpdateNotifications needs to be called as it may have been called before the service was connected - Activity?.UpdateNotifications(); - } - - void IServiceConnection.OnServiceDisconnected(ComponentName? name) - { - if (Binder is not null) - { - Binder.Service.TaskRemoved -= HandleTaskRemoved; - Binder = null; - } - } -} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs b/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs index db27fa8054..d320606736 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs @@ -1,13 +1,14 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.Versioning; +using System.Runtime.Versioning; using Android.App; using Android.Content; using Android.Content.PM; -using Android.OS; using AndroidX.Core.App; +using AndroidX.Media3.Common; +using AndroidX.Media3.DataSource; +using AndroidX.Media3.ExoPlayer; +using AndroidX.Media3.ExoPlayer.TrackSelection; using AndroidX.Media3.Session; -using AndroidX.Media3.UI; -using CommunityToolkit.Maui.Services; +using Java.Util; using Resource = Microsoft.Maui.Controls.Resource; namespace CommunityToolkit.Maui.Media.Services; @@ -15,166 +16,95 @@ namespace CommunityToolkit.Maui.Media.Services; [SupportedOSPlatform("Android26.0")] [IntentFilter(["androidx.media3.session.MediaSessionService"])] [Service(Exported = false, Enabled = true, Name = "communityToolkit.maui.media.services", ForegroundServiceType = ForegroundService.TypeMediaPlayback)] -sealed partial class MediaControlsService : Service +sealed partial class MediaControlsService : MediaSessionService { - readonly WeakEventManager taskRemovedEventManager = new(); + static readonly string cHANNEL_ID = "media_playback_channel"; + static readonly int nOTIFICATION_ID = 1001; - bool isDisposed; + MediaSession? mediaSession; + public IExoPlayer? ExoPlayer; - PlayerNotificationManager? playerNotificationManager; - NotificationCompat.Builder? notificationBuilder; - - public event EventHandler TaskRemoved + public override void OnTaskRemoved(Intent? rootIntent) { - add => taskRemovedEventManager.AddEventHandler(value); - remove => taskRemovedEventManager.RemoveEventHandler(value); + base.OnTaskRemoved(rootIntent); + PauseAllPlayersAndStopSelf(); } - public BoundServiceBinder? Binder { get; private set; } - public NotificationManager? NotificationManager { get; private set; } + public NotificationManager? NotificationManager { get; set; } - public override IBinder? OnBind(Intent? intent) - { - Binder = new BoundServiceBinder(this); - return Binder; - } public override void OnCreate() { base.OnCreate(); - StartForegroundServices(); - } - - public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId) - => StartCommandResult.NotSticky; - - public override void OnTaskRemoved(Intent? rootIntent) - { - base.OnTaskRemoved(rootIntent); - taskRemovedEventManager.HandleEvent(this, EventArgs.Empty, nameof(TaskRemoved)); - - playerNotificationManager?.SetPlayer(null); - NotificationManager?.CancelAll(); + CreateNotificationChannel(); + + StartForeground(nOTIFICATION_ID, CreateNotification()); + + var trackSelector = new DefaultTrackSelector(this); + var trackSelectionParameters = trackSelector.BuildUponParameters()? + .SetPreferredAudioLanguage("en")? + .SetPreferredTextLanguage("en")? + .SetIgnoredTextSelectionFlags(C.SelectionFlagAutoselect); + trackSelector.SetParameters((DefaultTrackSelector.Parameters.Builder?)trackSelectionParameters); + + var loadControlBuilder = new DefaultLoadControl.Builder(); + loadControlBuilder.SetBufferDurationsMs( + minBufferMs: 15000, + maxBufferMs: 50000, + bufferForPlaybackMs: 2500, + bufferForPlaybackAfterRebufferMs: 5000); + + var builder = new ExoPlayerBuilder(this) ?? throw new InvalidOperationException("ExoPlayerBuilder.Build() returned null"); + builder.SetTrackSelector(trackSelector); + builder.SetLoadControl(loadControlBuilder.Build()); + ExoPlayer = builder.Build() ?? throw new InvalidOperationException("ExoPlayerBuilder.Build() returned null"); + + var mediaSessionBuilder = new MediaSession.Builder(this, ExoPlayer); + UUID sessionId = UUID.RandomUUID() ?? throw new InvalidOperationException("UUID.RandomUUID() returned null"); + mediaSessionBuilder.SetId(sessionId.ToString()); + + var dataSourceBitmapFactory = new DataSourceBitmapLoader(this); + mediaSessionBuilder.SetBitmapLoader(dataSourceBitmapFactory); + mediaSession = mediaSessionBuilder.Build() ?? throw new InvalidOperationException("MediaSession.Builder.Build() returned null"); } public override void OnDestroy() { base.OnDestroy(); - - playerNotificationManager?.SetPlayer(null); - NotificationManager?.CancelAll(); - if (!OperatingSystem.IsAndroidVersionAtLeast(33)) - { - StopForeground(true); - } - - StopSelf(); + PauseAllPlayersAndStopSelf(); } public override void OnRebind(Intent? intent) { base.OnRebind(intent); - StartForegroundServices(); } - - [MemberNotNull(nameof(NotificationManager), nameof(notificationBuilder))] - public void UpdateNotifications(in MediaSession session, in PlatformMediaElement mediaElement) + void CreateNotificationChannel() { - ArgumentNullException.ThrowIfNull(notificationBuilder); - ArgumentNullException.ThrowIfNull(NotificationManager); - - var style = new MediaStyleNotificationHelper.MediaStyle(session); - if (!OperatingSystem.IsAndroidVersionAtLeast(33)) + var channel = new Android.App.NotificationChannel(cHANNEL_ID, "Media Playback", NotificationImportance.Low) { - SetLegacyNotifications(session, mediaElement); - } - - notificationBuilder.SetStyle(style); - NotificationManagerCompat.From(Platform.AppContext)?.Notify(1, notificationBuilder.Build()); + Description = "Media playback controls", + Name = "Media Playback" + }; + channel.SetShowBadge(false); + channel.LockscreenVisibility = NotificationVisibility.Public; + NotificationManager manager = GetSystemService(NotificationService) as NotificationManager ?? throw new InvalidOperationException($"{nameof(NotificationManager)} cannot be null"); + manager.CreateNotificationChannel(channel); } - [MemberNotNull(nameof(playerNotificationManager))] - public void SetLegacyNotifications(in MediaSession session, in PlatformMediaElement mediaElement) + static Notification CreateNotification() { - ArgumentNullException.ThrowIfNull(session); - playerNotificationManager ??= new PlayerNotificationManager.Builder(Platform.AppContext, 1, "1").Build() - ?? throw new InvalidOperationException("PlayerNotificationManager cannot be null"); - - playerNotificationManager.SetUseFastForwardAction(true); - playerNotificationManager.SetUseFastForwardActionInCompactView(true); - playerNotificationManager.SetUseRewindAction(true); - playerNotificationManager.SetUseRewindActionInCompactView(true); - playerNotificationManager.SetUseNextAction(true); - playerNotificationManager.SetUseNextActionInCompactView(true); - playerNotificationManager.SetUsePlayPauseActions(true); - playerNotificationManager.SetUsePreviousAction(true); - playerNotificationManager.SetColor(Resource.Color.abc_primary_text_material_dark); - playerNotificationManager.SetUsePreviousActionInCompactView(true); - playerNotificationManager.SetVisibility(NotificationCompat.VisibilityPublic); - playerNotificationManager.SetMediaSessionToken(session.SessionCompatToken); - playerNotificationManager.SetPlayer(mediaElement); - playerNotificationManager.SetColorized(true); - playerNotificationManager.SetShowPlayButtonIfPlaybackIsSuppressed(true); - playerNotificationManager.SetSmallIcon(Resource.Drawable.media3_notification_small_icon); - playerNotificationManager.SetPriority(NotificationCompat.PriorityDefault); - playerNotificationManager.SetUseChronometer(true); + return new NotificationCompat.Builder(Platform.AppContext ?? throw new InvalidOperationException("AppContext cannot be null"), cHANNEL_ID) + .SetContentTitle("Playing Media")? + .SetContentText("Artist")? + .SetSmallIcon(Resource.Drawable.notification_bg_low)? + .SetVisibility((int)NotificationVisibility.Public)? + .SetOngoing(true)? + .Build() ?? throw new InvalidOperationException("Notification cannot be null"); } - protected override void Dispose(bool disposing) + public override MediaSession? OnGetSession(MediaSession.ControllerInfo? p0) { - if (!isDisposed) - { - if (disposing) - { - NotificationManager?.Dispose(); - NotificationManager = null; - - playerNotificationManager?.Dispose(); - playerNotificationManager = null; - - if (!OperatingSystem.IsAndroidVersionAtLeast(33)) - { - StopForeground(true); - } - - StopSelf(); - } - - isDisposed = true; - } - - base.Dispose(disposing); + return mediaSession; } - static void CreateNotificationChannel(in NotificationManager notificationMnaManager) - { - var channel = new NotificationChannel("1", "1", NotificationImportance.Low); - notificationMnaManager.CreateNotificationChannel(channel); - } - - [MemberNotNull(nameof(notificationBuilder), nameof(NotificationManager))] - void StartForegroundServices() - { - NotificationManager ??= GetSystemService(NotificationService) as NotificationManager ?? throw new InvalidOperationException($"{nameof(NotificationManager)} cannot be null"); - notificationBuilder ??= new NotificationCompat.Builder(Platform.AppContext, "1"); - - notificationBuilder.SetSmallIcon(Resource.Drawable.media3_notification_small_icon); - notificationBuilder.SetAutoCancel(false); - notificationBuilder.SetForegroundServiceBehavior(NotificationCompat.ForegroundServiceImmediate); - notificationBuilder.SetVisibility(NotificationCompat.VisibilityPublic); - - CreateNotificationChannel(NotificationManager); - - if (OperatingSystem.IsAndroidVersionAtLeast(29)) - { - if (notificationBuilder.Build() is Notification notification) - { - StartForeground(1, notification, ForegroundService.TypeMediaPlayback); - } - } - else - { - StartForeground(1, notificationBuilder.Build()); - } - } } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs index b9799a3874..2b32493a4e 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs @@ -40,22 +40,33 @@ public MauiMediaElement(nint ptr, JniHandleOwnership jni) : base(Platform.AppCon /// Initializes a new instance of the class. /// /// The application's . - /// The that acts as the platform media player. + /// The that acts as the platform media player. public MauiMediaElement(Context context, PlayerView playerView) : base(context) { this.playerView = playerView; - this.playerView.SetBackgroundColor(Android.Graphics.Color.Black); playerView.FullscreenButtonClick += OnFullscreenButtonClick; + var layout = new RelativeLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent); layout.AddRule(LayoutRules.CenterInParent); layout.AddRule(LayoutRules.CenterVertical); layout.AddRule(LayoutRules.CenterHorizontal); + relativeLayout = new RelativeLayout(Platform.AppContext) { LayoutParameters = layout, }; - relativeLayout.AddView(playerView); + SetBackgroundColor(Android.Graphics.Color.Black); + this.playerView.SetBackgroundColor(Android.Graphics.Color.Black); + relativeLayout.SetBackgroundColor(Android.Graphics.Color.Black); + } + public void SetView(AndroidX.Media3.Session.MediaController mediaController) + { + playerView.Player = mediaController; + relativeLayout.AddView(playerView); + SetBackgroundColor(Android.Graphics.Color.Black); + playerView.SetBackgroundColor(Android.Graphics.Color.Black); + relativeLayout.SetBackgroundColor(Android.Graphics.Color.Black); AddView(relativeLayout); } @@ -82,34 +93,6 @@ protected override void OnVisibilityChanged(Android.Views.View changedView, [Gen } } - /// - /// Releases the unmanaged resources used by the and optionally releases the managed resources. - /// - /// to release both managed and unmanaged resources; to release only unmanaged resources. - protected override void Dispose(bool disposing) - { - if (disposing) - { - try - { - if (playerView.Player is not null) - { - playerView.Player.PlayWhenReady = false; - } - // https://github.com/google/ExoPlayer/issues/1855#issuecomment-251041500 - playerView.Player?.Release(); - playerView.Player?.Dispose(); - playerView.Dispose(); - } - catch (ObjectDisposedException) - { - // playerView already disposed - } - } - - base.Dispose(disposing); - } - void OnFullscreenButtonClick(object? sender, PlayerView.FullscreenButtonClickEventArgs e) { // Ensure there is a player view diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index 33a1e0b6ef..cf99f15c41 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis; using Android.Content; using Android.Views; -using Android.Widget; +using AndroidX.Core.Content; using AndroidX.Media3.Common; using AndroidX.Media3.Common.Text; using AndroidX.Media3.Common.Util; @@ -10,33 +10,33 @@ using AndroidX.Media3.Session; using AndroidX.Media3.UI; using CommunityToolkit.Maui.Media.Services; -using CommunityToolkit.Maui.Services; using CommunityToolkit.Maui.Views; +using Java.Lang; using Microsoft.Extensions.Logging; using AudioAttributes = AndroidX.Media3.Common.AudioAttributes; using DeviceInfo = AndroidX.Media3.Common.DeviceInfo; +using Exception = System.Exception; +using MediaController = AndroidX.Media3.Session.MediaController; using MediaMetadata = AndroidX.Media3.Common.MediaMetadata; namespace CommunityToolkit.Maui.Core.Views; public partial class MediaManager : Java.Lang.Object, IPlayerListener { - const int bufferState = 2; - const int readyState = 3; - const int endedState = 4; + const int stateIdle = 1; + const int stateBuffering = 2; + const int stateReady = 3; + const int stateEnded = 4; static readonly HttpClient client = new(); readonly SemaphoreSlim seekToSemaphoreSlim = new(1, 1); double? previousSpeed; float volumeBeforeMute = 1; - TaskCompletionSource? seekToTaskCompletionSource; CancellationTokenSource? cancellationTokenSource; - MediaSession? session; MediaItem.Builder? mediaItem; - BoundServiceConnection? connection; - + /// /// The platform native counterpart of . /// @@ -60,20 +60,6 @@ public void OnPlaybackParametersChanged(PlaybackParameters? playbackParameters) MediaElement.Speed = playbackParameters.Speed; } - public void UpdateNotifications() - { - if (connection?.Binder?.Service is null) - { - System.Diagnostics.Trace.TraceInformation("Notification Service not running."); - return; - } - - if (session is not null && Player is not null) - { - connection.Binder.Service.UpdateNotifications(session, Player); - } - } - /// /// Occurs when ExoPlayer changes the player state. /// @@ -92,36 +78,44 @@ public void OnPlayerStateChanged(bool playWhenReady, int playbackState) var newState = playbackState switch { - PlaybackState.StateFastForwarding - or PlaybackState.StateRewinding - or PlaybackState.StateSkippingToNext - or PlaybackState.StateSkippingToPrevious - or PlaybackState.StateSkippingToQueueItem - or PlaybackState.StatePlaying => playWhenReady - ? MediaElementState.Playing - : MediaElementState.Paused, - - PlaybackState.StatePaused => MediaElementState.Paused, - - PlaybackState.StateConnecting - or PlaybackState.StateBuffering => MediaElementState.Buffering, - - PlaybackState.StateNone => MediaElementState.None, - PlaybackState.StateStopped => MediaElement.CurrentState is not MediaElementState.Failed - ? MediaElementState.Stopped - : MediaElementState.Failed, - - PlaybackState.StateError => MediaElementState.Failed, - + stateBuffering => MediaElementState.Buffering, + stateEnded => MediaElementState.Stopped, + stateReady => playWhenReady + ? MediaElementState.Playing + : MediaElementState.Paused, + stateIdle => MediaElementState.None, _ => MediaElementState.None, }; MediaElement.CurrentStateChanged(newState); - if (playbackState is readyState) + + if (playbackState is stateReady) { - MediaElement.Duration = TimeSpan.FromMilliseconds(Player.Duration < 0 ? 0 : Player.Duration); - MediaElement.Position = TimeSpan.FromMilliseconds(Player.CurrentPosition < 0 ? 0 : Player.CurrentPosition); + MediaElement.Duration = TimeSpan.FromMilliseconds( + Player.Duration < 0 ? 0 : Player.Duration + ); + MediaElement.Position = TimeSpan.FromMilliseconds( + Player.CurrentPosition < 0 ? 0 : Player.CurrentPosition + ); } + else if (playbackState is stateEnded) + { + MediaElement.MediaEnded(); + } + } + + public void OnIsPlayingChanged(bool isPlaying) + { + if (Player is null || MediaElement.Source is null) + { + return; + } + + var newState = isPlaying + ? MediaElementState.Playing + : MediaElementState.Paused; + + MediaElement.CurrentStateChanged(newState); } /// @@ -129,20 +123,16 @@ or PlaybackState.StateSkippingToQueueItem /// /// The platform native counterpart of . /// Thrown when is or when the platform view could not be created. - [MemberNotNull(nameof(Player), nameof(PlayerView), nameof(session))] - public (PlatformMediaElement platformView, PlayerView PlayerView) CreatePlatformView(AndroidViewType androidViewType) + [MemberNotNull(nameof(PlayerView))] + public PlayerView CreatePlatformView(AndroidViewType androidViewType) { - Player = new ExoPlayerBuilder(MauiContext.Context).Build() ?? throw new InvalidOperationException("Player cannot be null"); - Player.AddListener(this); - if (androidViewType is AndroidViewType.SurfaceView) { PlayerView = new PlayerView(MauiContext.Context) { - Player = Player, UseController = false, ControllerAutoShow = false, - LayoutParameters = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent) + LayoutParameters = new Android.Widget.RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent) }; } else if (androidViewType is AndroidViewType.TextureView) @@ -160,24 +150,53 @@ or PlaybackState.StateSkippingToQueueItem PlayerView = new PlayerView(MauiContext.Context, attributes) { - Player = Player, UseController = false, ControllerAutoShow = false, - LayoutParameters = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent) + LayoutParameters = new Android.Widget.RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent) }; } else { throw new NotSupportedException($"{androidViewType} is not yet supported"); } + return PlayerView; + } - var mediaSession = new MediaSession.Builder(Platform.AppContext, Player); - mediaSession.SetId(Convert.ToBase64String(Guid.NewGuid().ToByteArray())[..8]); - - session ??= mediaSession.Build() ?? throw new InvalidOperationException("Session cannot be null"); - ArgumentNullException.ThrowIfNull(session.Id); - - return (Player, PlayerView); + public async Task CreateMediaController() + { + var tcs = new TaskCompletionSource(); + var future = new MediaController.Builder(Platform.AppContext, new SessionToken(Platform.AppContext, new ComponentName(Platform.AppContext, Java.Lang.Class.FromType(typeof(MediaControlsService))))).BuildAsync(); + future?.AddListener(new Runnable(() => + { + try + { + var result = future.Get() ?? throw new InvalidOperationException("MediaController.Builder.BuildAsync().Get() returned null"); + if (result is MediaController mc) + { + Player = mc ?? throw new InvalidOperationException("MediaController cannot be set on Player"); + Player.AddListener(this); + if (PlayerView is null) + { + throw new InvalidOperationException($"{nameof(PlayerView)} cannot be null"); + } + PlayerView.SetBackgroundColor(Android.Graphics.Color.Black); + PlayerView.Player = Player; + var intent = new Intent(Android.App.Application.Context, typeof(MediaControlsService)); + Android.App.Application.Context.StartForegroundService(intent); + tcs.SetResult(); + } + else + { + tcs.SetException(new InvalidOperationException("MediaController.Builder.BuildAsync().Get() did not return a MediaController")); + } + } + catch (Exception ex) + { + Trace.WriteLine($"Error creating MediaController: {ex}"); + } + }), ContextCompat.GetMainExecutor(Platform.AppContext)); + await tcs.Task.WaitAsync(CancellationToken.None); + return Player ?? throw new InvalidOperationException("MediaController is null"); } /// @@ -198,15 +217,28 @@ public void OnPlaybackStateChanged(int playbackState) MediaElementState newState = MediaElement.CurrentState; switch (playbackState) { - case bufferState: + case stateBuffering: newState = MediaElementState.Buffering; break; - case endedState: + case stateEnded: newState = MediaElementState.Stopped; MediaElement.MediaEnded(); break; - case readyState: + case stateReady: seekToTaskCompletionSource?.TrySetResult(); + // Update duration and position when ready + if (Player is not null) + { + MediaElement.Duration = TimeSpan.FromMilliseconds( + Player.Duration < 0 ? 0 : Player.Duration + ); + MediaElement.Position = TimeSpan.FromMilliseconds( + Player.CurrentPosition < 0 ? 0 : Player.CurrentPosition + ); + } + break; + case stateIdle: + newState = MediaElementState.None; break; } @@ -350,14 +382,10 @@ protected virtual async partial ValueTask PlatformUpdateSource() if (Player is null) { + System.Diagnostics.Debug.WriteLine("IExoPlayer is null, cannot update source"); return; } - if (connection is null) - { - StartService(); - } - if (MediaElement.Source is null) { Player.ClearMediaItems(); @@ -384,7 +412,6 @@ protected virtual async partial ValueTask PlatformUpdateSource() if (hasSetSource && Player.PlayerError is null) { MediaElement.MediaOpened(); - UpdateNotifications(); } } @@ -469,7 +496,6 @@ protected virtual partial void PlatformUpdateVolume() volumeBeforeMute = (float)MediaElement.Volume; return; } - Player.Volume = (float)MediaElement.Volume; } @@ -519,21 +545,20 @@ protected override void Dispose(bool disposing) if (disposing) { - session?.Release(); - session?.Dispose(); - session = null; - cancellationTokenSource?.Dispose(); cancellationTokenSource = null; - - if (connection is not null) - { - StopService(connection); - connection.Dispose(); - connection = null; - } - + + Player?.Stop(); + Player?.ClearMediaItems(); + Player?.RemoveListener(this); + Player?.Release(); + Player?.Dispose(); + Player = null; + PlayerView?.Dispose(); + PlayerView = null; client.Dispose(); + var serviceIntent = new Intent(Platform.AppContext, typeof(MediaControlsService)); + Android.App.Application.Context.StopService(serviceIntent); } } @@ -630,28 +655,7 @@ static async ValueTask GetByteCountFromStream(Stream stream, CancellationT return countedStreamBytes; } } - - [MemberNotNull(nameof(connection))] - void StartService() - { - var intent = new Intent(Android.App.Application.Context, typeof(MediaControlsService)); - connection = new BoundServiceConnection(this); - connection.MediaControlsServiceTaskRemoved += HandleMediaControlsServiceTaskRemoved; - - Android.App.Application.Context.StartForegroundService(intent); - Android.App.Application.Context.ApplicationContext?.BindService(intent, connection, Bind.AutoCreate); - } - - void StopService(in BoundServiceConnection boundServiceConnection) - { - boundServiceConnection.MediaControlsServiceTaskRemoved -= HandleMediaControlsServiceTaskRemoved; - - var serviceIntent = new Intent(Platform.AppContext, typeof(MediaControlsService)); - Android.App.Application.Context.StopService(serviceIntent); - Platform.AppContext.UnbindService(boundServiceConnection); - } - - void HandleMediaControlsServiceTaskRemoved(object? sender, EventArgs e) => Player?.Stop(); + async Task SetPlayerData(CancellationToken cancellationToken = default) { @@ -728,7 +732,6 @@ public void OnDeviceInfoChanged(DeviceInfo? deviceInfo) { } public void OnDeviceVolumeChanged(int volume, bool muted) { } public void OnEvents(IPlayer? player, PlayerEvents? playerEvents) { } public void OnIsLoadingChanged(bool isLoading) { } - public void OnIsPlayingChanged(bool isPlaying) { } public void OnLoadingChanged(bool isLoading) { } public void OnMaxSeekToPreviousPositionChanged(long maxSeekToPreviousPositionMs) { } public void OnMediaItemTransition(MediaItem? mediaItem, int reason) { } diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.shared.cs index 195537fb62..5b63724c51 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.shared.cs @@ -1,7 +1,7 @@ #if !(ANDROID || IOS || WINDOWS || MACCATALYST || TIZEN) global using PlatformMediaElement = System.Object; #elif ANDROID -global using PlatformMediaElement = AndroidX.Media3.ExoPlayer.IExoPlayer; +global using PlatformMediaElement = AndroidX.Media3.Session.MediaController; #elif IOS || MACCATALYST global using PlatformMediaElement = AVFoundation.AVPlayer; #elif WINDOWS From ea992b334804ce11c364ddd981dfca830c24a0d0 Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Wed, 1 Oct 2025 21:33:12 -0700 Subject: [PATCH 2/8] Refactor getting artwork to use native methods. Fixes issue with navigating a second time to same item and now the image does display in notifications. It did not previously. This affects load times. It is now faster. --- .../Views/MediaManager.android.cs | 123 ++---------------- 1 file changed, 11 insertions(+), 112 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index cf99f15c41..062a97195c 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -376,14 +376,14 @@ protected virtual partial void PlatformStop() MediaElement.Position = TimeSpan.Zero; } - protected virtual async partial ValueTask PlatformUpdateSource() + protected virtual partial ValueTask PlatformUpdateSource() { var hasSetSource = false; if (Player is null) { System.Diagnostics.Debug.WriteLine("IExoPlayer is null, cannot update source"); - return; + return ValueTask.CompletedTask; } if (MediaElement.Source is null) @@ -392,14 +392,12 @@ protected virtual async partial ValueTask PlatformUpdateSource() MediaElement.Duration = TimeSpan.Zero; MediaElement.CurrentStateChanged(MediaElementState.None); - return; + return ValueTask.CompletedTask; } MediaElement.CurrentStateChanged(MediaElementState.Opening); Player.PlayWhenReady = MediaElement.ShouldAutoPlay; - cancellationTokenSource ??= new(); - // ConfigureAwait(true) is required to prevent crash on startup - var result = await SetPlayerData(cancellationTokenSource.Token).ConfigureAwait(true); + var result = SetPlayerData(); var item = result?.Build(); if (item?.MediaMetadata is not null) @@ -413,6 +411,7 @@ protected virtual async partial ValueTask PlatformUpdateSource() { MediaElement.MediaOpened(); } + return ValueTask.CompletedTask; } protected virtual partial void PlatformUpdateAspect() @@ -562,102 +561,7 @@ protected override void Dispose(bool disposing) } } - static async Task GetBytesFromMetadataArtworkUrl(string url, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(url)) - { - return []; - } - - Stream? stream = null; - Uri.TryCreate(url, UriKind.Absolute, out var uri); - - try - { - byte[] artworkData = []; - long? contentLength = null; - - // HTTP or HTTPS URL - if (uri is not null && - (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) - { - var request = new HttpRequestMessage(HttpMethod.Head, url); - var contentLengthResponse = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - contentLength = contentLengthResponse.Content.Headers.ContentLength ?? 0; - - var response = await client.GetAsync(url, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); - stream = response.IsSuccessStatusCode ? await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false) : null; - } - // Absolute File Path - else if (uri is not null && uri.Scheme == Uri.UriSchemeFile) - { - var normalizedFilePath = NormalizeFilePath(url); - - stream = File.Open(normalizedFilePath, FileMode.Create); - contentLength = await GetByteCountFromStream(stream, cancellationToken); - } - // Relative File Path - else if (Uri.TryCreate(url, UriKind.Relative, out _)) - { - var normalizedFilePath = NormalizeFilePath(url); - - stream = Platform.AppContext.Assets?.Open(normalizedFilePath) ?? throw new InvalidOperationException("Assets cannot be null"); - contentLength = await GetByteCountFromStream(stream, cancellationToken); - } - - if (stream is not null) - { - if (!contentLength.HasValue) - { - throw new InvalidOperationException($"{nameof(contentLength)} must be set when {nameof(stream)} is not null"); - } - - artworkData = new byte[contentLength.Value]; - using var memoryStream = new MemoryStream(artworkData); - await stream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); - } - - return artworkData; - } - catch (Exception e) - { - Trace.WriteLine($"Unable to retrieve {nameof(MediaElement.MetadataArtworkUrl)} for {url}.{e}\n"); - return []; - } - finally - { - if (stream is not null) - { - stream.Close(); - await stream.DisposeAsync(); - } - } - - static string NormalizeFilePath(string filePath) => filePath.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); - - static async ValueTask GetByteCountFromStream(Stream stream, CancellationToken token) - { - if (stream.CanSeek) - { - return stream.Length; - } - - long countedStreamBytes = 0; - - var buffer = new byte[8192]; - int bytesRead; - - while ((bytesRead = await stream.ReadAsync(buffer, token)) > 0) - { - countedStreamBytes += bytesRead; - } - - return countedStreamBytes; - } - } - - - async Task SetPlayerData(CancellationToken cancellationToken = default) + MediaItem.Builder? SetPlayerData() { if (MediaElement.Source is null) { @@ -671,7 +575,7 @@ static async ValueTask GetByteCountFromStream(Stream stream, CancellationT var uri = uriMediaSource.Uri; if (!string.IsNullOrWhiteSpace(uri?.AbsoluteUri)) { - return await CreateMediaItem(uri.AbsoluteUri, cancellationToken).ConfigureAwait(false); + return CreateMediaItem(uri.AbsoluteUri); } break; @@ -681,7 +585,7 @@ static async ValueTask GetByteCountFromStream(Stream stream, CancellationT var filePath = fileMediaSource.Path; if (!string.IsNullOrWhiteSpace(filePath)) { - return await CreateMediaItem(filePath, cancellationToken).ConfigureAwait(false); + return CreateMediaItem(filePath); } break; @@ -693,7 +597,7 @@ static async ValueTask GetByteCountFromStream(Stream stream, CancellationT if (!string.IsNullOrWhiteSpace(path)) { var assetFilePath = $"asset://{package}{Path.PathSeparator}{path}"; - return await CreateMediaItem(assetFilePath, cancellationToken).ConfigureAwait(false); + return CreateMediaItem(assetFilePath); } break; @@ -705,17 +609,12 @@ static async ValueTask GetByteCountFromStream(Stream stream, CancellationT return mediaItem; } - async Task CreateMediaItem(string url, CancellationToken cancellationToken = default) + MediaItem.Builder CreateMediaItem(string url) { MediaMetadata.Builder mediaMetaData = new(); mediaMetaData.SetArtist(MediaElement.MetadataArtist); mediaMetaData.SetTitle(MediaElement.MetadataTitle); - var data = await GetBytesFromMetadataArtworkUrl(MediaElement.MetadataArtworkUrl, cancellationToken).ConfigureAwait(true); - if (data is not null && data.Length > 0) - { - mediaMetaData.SetArtworkData(data, (Java.Lang.Integer)MediaMetadata.PictureTypeFrontCover); - } - + mediaMetaData.SetArtworkUri(Android.Net.Uri.Parse(MediaElement.MetadataArtworkUrl ?? "")); mediaItem = new MediaItem.Builder(); mediaItem.SetUri(url); mediaItem.SetMediaId(url); From dc0cc91b53ae153f46fc44de6740d1c9bc441524 Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Thu, 2 Oct 2025 14:38:45 -0700 Subject: [PATCH 3/8] Refactor media playback and UI improvements - Updated `textureview.xml` to use black backgrounds for better consistency. - Replaced `RelativeLayout` with `FrameLayout` in `MauiMediaElement.android.cs` for improved layout management. - Refactored `MediaControlsService.android.cs`: - Replaced `static readonly` fields with `const` for immutability. - Added audio attributes for better playback handling. - Updated track selector to fallback to system language. - Added handling for audio interruptions (e.g., unplugging headphones). - Added `OnTracksChanged` in `MediaManager.android.cs` to manage subtitle button visibility. - Removed unused imports and redundant constants for cleaner code. - Improved buffering strategies for smoother playback. --- .../Android/Resources/layout/textureview.xml | 4 +- .../Services/MediaControlsService.android.cs | 21 ++++++---- .../Views/MauiMediaElement.android.cs | 41 ++++++++++--------- .../Views/MediaManager.android.cs | 41 ++++++++----------- 4 files changed, 54 insertions(+), 53 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Platforms/Android/Resources/layout/textureview.xml b/src/CommunityToolkit.Maui.MediaElement/Platforms/Android/Resources/layout/textureview.xml index 58c65010ec..631f502a40 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Platforms/Android/Resources/layout/textureview.xml +++ b/src/CommunityToolkit.Maui.MediaElement/Platforms/Android/Resources/layout/textureview.xml @@ -4,8 +4,8 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/texture_view_media_element" app:surface_type="texture_view" - app:shutter_background_color="#FFFFFF" + app:shutter_background_color="#000000" android:layout_width= "match_parent" android:layout_height= "match_parent" - android:background="@android:color/transparent" + android:background="@android:color/black" /> \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs b/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs index d320606736..d761f8459b 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs @@ -18,8 +18,8 @@ namespace CommunityToolkit.Maui.Media.Services; [Service(Exported = false, Enabled = true, Name = "communityToolkit.maui.media.services", ForegroundServiceType = ForegroundService.TypeMediaPlayback)] sealed partial class MediaControlsService : MediaSessionService { - static readonly string cHANNEL_ID = "media_playback_channel"; - static readonly int nOTIFICATION_ID = 1001; + const string cHANNEL_ID = "media_playback_channel"; + const int nOTIFICATION_ID = 1001; MediaSession? mediaSession; public IExoPlayer? ExoPlayer; @@ -40,22 +40,29 @@ public override void OnCreate() StartForeground(nOTIFICATION_ID, CreateNotification()); + var audioAttribute = new AndroidX.Media3.Common.AudioAttributes.Builder()? + .SetContentType(C.AudioContentTypeMusic)? // When phonecalls come in, music is paused + .SetUsage(C.UsageMedia)? + .Build(); + var trackSelector = new DefaultTrackSelector(this); var trackSelectionParameters = trackSelector.BuildUponParameters()? - .SetPreferredAudioLanguage("en")? - .SetPreferredTextLanguage("en")? - .SetIgnoredTextSelectionFlags(C.SelectionFlagAutoselect); - trackSelector.SetParameters((DefaultTrackSelector.Parameters.Builder?)trackSelectionParameters); + .SetPreferredAudioLanguage(C.LanguageUndetermined)? // Fallback to system language if no preferred language found + .SetPreferredTextLanguage(C.LanguageUndetermined)? // Fallback to system language if no preferred language found + .SetIgnoredTextSelectionFlags(C.SelectionReasonUnknown); // Ignore text tracks that are not explicitly selected by the user + trackSelector.SetParameters((DefaultTrackSelector.Parameters.Builder?)trackSelectionParameters); // Allows us to select tracks based on user preferences var loadControlBuilder = new DefaultLoadControl.Builder(); loadControlBuilder.SetBufferDurationsMs( minBufferMs: 15000, maxBufferMs: 50000, bufferForPlaybackMs: 2500, - bufferForPlaybackAfterRebufferMs: 5000); + bufferForPlaybackAfterRebufferMs: 5000); // Custom buffering strategy var builder = new ExoPlayerBuilder(this) ?? throw new InvalidOperationException("ExoPlayerBuilder.Build() returned null"); builder.SetTrackSelector(trackSelector); + builder.SetAudioAttributes(audioAttribute, true); + builder.SetHandleAudioBecomingNoisy(true); // Unplugging headphones will pause playback builder.SetLoadControl(loadControlBuilder.Build()); ExoPlayer = builder.Build() ?? throw new InvalidOperationException("ExoPlayerBuilder.Build() returned null"); diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs index 2b32493a4e..9f5f96dbaa 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs @@ -1,5 +1,6 @@ using Android.App; using Android.Content; +using Android.Graphics.Drawables; using Android.Runtime; using Android.Views; using Android.Widget; @@ -20,7 +21,7 @@ namespace CommunityToolkit.Maui.Core.Views; /// public class MauiMediaElement : CoordinatorLayout { - readonly RelativeLayout relativeLayout; + readonly FrameLayout frameLayout; readonly PlayerView playerView; int defaultSystemUiVisibility; @@ -44,30 +45,30 @@ public MauiMediaElement(nint ptr, JniHandleOwnership jni) : base(Platform.AppCon public MauiMediaElement(Context context, PlayerView playerView) : base(context) { this.playerView = playerView; + playerView.Background = new ColorDrawable(Android.Graphics.Color.Black); + playerView.SetBackgroundColor(Android.Graphics.Color.Black); playerView.FullscreenButtonClick += OnFullscreenButtonClick; + playerView.SetShowBuffering(PlayerView.ShowBufferingAlways); + playerView.Alpha = 1.0f; + playerView.ArtworkDisplayMode = PlayerView.ArtworkDisplayModeFit; + playerView.DefaultArtwork = new ColorDrawable(Android.Graphics.Color.Black); - var layout = new RelativeLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent); - layout.AddRule(LayoutRules.CenterInParent); - layout.AddRule(LayoutRules.CenterVertical); - layout.AddRule(LayoutRules.CenterHorizontal); - - relativeLayout = new RelativeLayout(Platform.AppContext) + var containerLayoutParams = new CoordinatorLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent); + frameLayout = new FrameLayout(Platform.AppContext) { - LayoutParameters = layout, + Background = new ColorDrawable(Android.Graphics.Color.Black), + LayoutParameters = containerLayoutParams }; - SetBackgroundColor(Android.Graphics.Color.Black); - this.playerView.SetBackgroundColor(Android.Graphics.Color.Black); - relativeLayout.SetBackgroundColor(Android.Graphics.Color.Black); + + playerView.LayoutParameters = new FrameLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent, GravityFlags.Center); + SetBackgroundResource(Android.Resource.Color.Black); } public void SetView(AndroidX.Media3.Session.MediaController mediaController) { playerView.Player = mediaController; - relativeLayout.AddView(playerView); - SetBackgroundColor(Android.Graphics.Color.Black); - playerView.SetBackgroundColor(Android.Graphics.Color.Black); - relativeLayout.SetBackgroundColor(Android.Graphics.Color.Black); - AddView(relativeLayout); + frameLayout.AddView(playerView); + AddView(frameLayout); } public override void OnDetachedFromWindow() @@ -106,14 +107,14 @@ void OnFullscreenButtonClick(object? sender, PlayerView.FullscreenButtonClickEve if (e.P0) { isFullScreen = true; - RemoveView(relativeLayout); - layout?.AddView(relativeLayout); + RemoveView(frameLayout); + layout?.AddView(frameLayout); } else { isFullScreen = false; - layout?.RemoveView(relativeLayout); - AddView(relativeLayout); + layout?.RemoveView(frameLayout); + AddView(frameLayout); } // Hide/Show the SystemBars and Status bar SetSystemBarsVisibility(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index 062a97195c..b658d6bb28 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Android.Content; -using Android.Views; using AndroidX.Core.Content; using AndroidX.Media3.Common; using AndroidX.Media3.Common.Text; @@ -118,6 +117,23 @@ public void OnIsPlayingChanged(bool isPlaying) MediaElement.CurrentStateChanged(newState); } + public void OnTracksChanged(Tracks? tracks) + { + if (tracks is null || tracks.IsEmpty) + { + return; + } + tracks.IsTypeSelected(C.TrackTypeText); + if(tracks.IsTypeSupported(C.TrackTypeText)) + { + PlayerView?.SetShowSubtitleButton(true); + } + else + { + PlayerView?.SetShowSubtitleButton(false); + } + } + /// /// Creates the corresponding platform view of on Android. /// @@ -132,7 +148,6 @@ public PlayerView CreatePlatformView(AndroidViewType androidViewType) { UseController = false, ControllerAutoShow = false, - LayoutParameters = new Android.Widget.RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent) }; } else if (androidViewType is AndroidViewType.TextureView) @@ -152,7 +167,6 @@ public PlayerView CreatePlatformView(AndroidViewType androidViewType) { UseController = false, ControllerAutoShow = false, - LayoutParameters = new Android.Widget.RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent) }; } else @@ -619,7 +633,6 @@ MediaItem.Builder CreateMediaItem(string url) mediaItem.SetUri(url); mediaItem.SetMediaId(url); mediaItem.SetMediaMetadata(mediaMetaData.Build()); - return mediaItem; } @@ -648,25 +661,5 @@ public void OnSkipSilenceEnabledChanged(bool skipSilenceEnabled) { } public void OnSurfaceSizeChanged(int width, int height) { } public void OnTimelineChanged(Timeline? timeline, int reason) { } public void OnTrackSelectionParametersChanged(TrackSelectionParameters? trackSelectionParameters) { } - public void OnTracksChanged(Tracks? tracks) { } #endregion - - static class PlaybackState - { - public const int StateBuffering = 6; - public const int StateConnecting = 8; - public const int StateFailed = 7; - public const int StateFastForwarding = 4; - public const int StateNone = 0; - public const int StatePaused = 2; - public const int StatePlaying = 3; - public const int StateRewinding = 5; - public const int StateSkippingToNext = 10; - public const int StateSkippingToPrevious = 9; - public const int StateSkippingToQueueItem = 11; - public const int StateStopped = 1; - public const int StateError = 7; - } - - } \ No newline at end of file From c0c7eb84d5672e3da41da687b1ac696013bf24f7 Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Thu, 2 Oct 2025 14:43:44 -0700 Subject: [PATCH 4/8] Update src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs Good catch Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Views/MediaManager.android.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index b658d6bb28..74fe4907c0 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -123,7 +123,6 @@ public void OnTracksChanged(Tracks? tracks) { return; } - tracks.IsTypeSelected(C.TrackTypeText); if(tracks.IsTypeSupported(C.TrackTypeText)) { PlayerView?.SetShowSubtitleButton(true); From efdde5b1d51fb6eaa4757e307ad31881fbbe390b Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Thu, 2 Oct 2025 14:43:57 -0700 Subject: [PATCH 5/8] Update src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Views/MediaManager.android.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index 74fe4907c0..c136ee3960 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -395,7 +395,7 @@ protected virtual partial ValueTask PlatformUpdateSource() if (Player is null) { - System.Diagnostics.Debug.WriteLine("IExoPlayer is null, cannot update source"); + System.Diagnostics.Trace.WriteLine("IExoPlayer is null, cannot update source"); return ValueTask.CompletedTask; } From 336d5abbbfdf77299c5e6bbf47239937de4343a4 Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Thu, 2 Oct 2025 14:50:16 -0700 Subject: [PATCH 6/8] Add CancellationToken support to CreateMediaController Updated the CreateMediaController method to include an optional CancellationToken parameter, allowing the operation to be canceled if needed. Modified the WaitAsync call to respect the provided cancellation token, improving the method's flexibility and robustness. --- .../Views/MediaManager.android.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index c136ee3960..96cf06e83d 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -175,7 +175,7 @@ public PlayerView CreatePlatformView(AndroidViewType androidViewType) return PlayerView; } - public async Task CreateMediaController() + public async Task CreateMediaController(CancellationToken cancellationToken = default) { var tcs = new TaskCompletionSource(); var future = new MediaController.Builder(Platform.AppContext, new SessionToken(Platform.AppContext, new ComponentName(Platform.AppContext, Java.Lang.Class.FromType(typeof(MediaControlsService))))).BuildAsync(); @@ -208,7 +208,7 @@ public PlayerView CreatePlatformView(AndroidViewType androidViewType) Trace.WriteLine($"Error creating MediaController: {ex}"); } }), ContextCompat.GetMainExecutor(Platform.AppContext)); - await tcs.Task.WaitAsync(CancellationToken.None); + await tcs.Task.WaitAsync(cancellationToken); return Player ?? throw new InvalidOperationException("MediaController is null"); } From 33da943969709287354ec75f0286a2f8e3904cfa Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Thu, 2 Oct 2025 17:07:34 -0700 Subject: [PATCH 7/8] Fix text selection and add dash sample to sample app --- .../Pages/Views/MediaElement/MediaElementPage.xaml.cs | 11 +++++++++-- .../Services/MediaControlsService.android.cs | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs index 0163e3e02f..e18a74ccf3 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -12,15 +12,16 @@ public partial class MediaElementPage : BasePage { const string loadOnlineMp4 = "Load Online MP4"; const string loadHls = "Load HTTP Live Stream (HLS)"; + const string loadDASH = "Load MPEG-DASH (not supported on iOS/MacCatalyst)"; const string loadLocalResource = "Load Local Resource"; const string resetSource = "Reset Source to null"; const string loadMusic = "Load Music"; const string botImageUrl = "https://lh3.googleusercontent.com/pw/AP1GczNRrebWCJvfdIau1EbsyyYiwAfwHS0JXjbioXvHqEwYIIdCzuLodQCZmA57GADIo5iB3yMMx3t_vsefbfoHwSg0jfUjIXaI83xpiih6d-oT7qD_slR0VgNtfAwJhDBU09kS5V2T5ZML-WWZn8IrjD4J-g=w1792-h1024-s-no-gm"; const string hlsStreamTestUrl = "https://mtoczko.github.io/hls-test-streams/test-gap/playlist.m3u8"; + const string dashTestUrl = "https://livesim.dashif.org/dash/vod/testpic_2s/multi_subs.mpd"; const string hal9000AudioUrl = "https://github.com/prof3ssorSt3v3/media-sample-files/raw/master/hal-9000.mp3"; - readonly ILogger logger; readonly IDeviceInfo deviceInfo; readonly IFileSystem fileSystem; @@ -166,7 +167,7 @@ void Button_Clicked(object? sender, EventArgs e) async void ChangeSourceClicked(Object sender, EventArgs e) { var result = await DisplayActionSheet("Choose a source", "Cancel", null, - loadOnlineMp4, loadHls, loadLocalResource, resetSource, loadMusic); + loadOnlineMp4, loadHls, loadDASH, loadLocalResource, resetSource, loadMusic); MediaElement.Stop(); MediaElement.Source = null; @@ -188,6 +189,12 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.Source = MediaSource.FromUri(hlsStreamTestUrl); return; + case loadDASH: + MediaElement.MetadataArtist = "DASH Album"; + MediaElement.MetadataArtworkUrl = botImageUrl; + MediaElement.MetadataTitle = "DASH Title"; + MediaElement.Source = MediaSource.FromUri(dashTestUrl); + return; case resetSource: MediaElement.MetadataArtworkUrl = string.Empty; MediaElement.MetadataTitle = string.Empty; diff --git a/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs b/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs index d761f8459b..790fedd26f 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs @@ -49,7 +49,7 @@ public override void OnCreate() var trackSelectionParameters = trackSelector.BuildUponParameters()? .SetPreferredAudioLanguage(C.LanguageUndetermined)? // Fallback to system language if no preferred language found .SetPreferredTextLanguage(C.LanguageUndetermined)? // Fallback to system language if no preferred language found - .SetIgnoredTextSelectionFlags(C.SelectionReasonUnknown); // Ignore text tracks that are not explicitly selected by the user + .SetIgnoredTextSelectionFlags(C.SelectionFlagAutoselect); // Ignore text tracks that are not explicitly selected by the user trackSelector.SetParameters((DefaultTrackSelector.Parameters.Builder?)trackSelectionParameters); // Allows us to select tracks based on user preferences var loadControlBuilder = new DefaultLoadControl.Builder(); From a712311f9afe1e63308798e1968b471898aa3a16 Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Thu, 2 Oct 2025 17:31:05 -0700 Subject: [PATCH 8/8] Fix layout issue --- .../Views/MauiMediaElement.android.cs | 27 ++++++++++--------- .../Views/MediaManager.android.cs | 4 +++ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs index 9f5f96dbaa..3547fa4aee 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs @@ -8,6 +8,7 @@ using AndroidX.Core.View; using AndroidX.Media3.UI; using CommunityToolkit.Maui.Views; +using RelativeLayout = Android.Widget.RelativeLayout; [assembly: UsesPermission(Android.Manifest.Permission.ForegroundServiceMediaPlayback)] [assembly: UsesPermission(Android.Manifest.Permission.ForegroundService)] @@ -21,7 +22,7 @@ namespace CommunityToolkit.Maui.Core.Views; /// public class MauiMediaElement : CoordinatorLayout { - readonly FrameLayout frameLayout; + readonly RelativeLayout relativeLayout; readonly PlayerView playerView; int defaultSystemUiVisibility; @@ -53,22 +54,22 @@ public MauiMediaElement(Context context, PlayerView playerView) : base(context) playerView.ArtworkDisplayMode = PlayerView.ArtworkDisplayModeFit; playerView.DefaultArtwork = new ColorDrawable(Android.Graphics.Color.Black); - var containerLayoutParams = new CoordinatorLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent); - frameLayout = new FrameLayout(Platform.AppContext) + var layout = new RelativeLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent); + layout.AddRule(LayoutRules.CenterInParent); + layout.AddRule(LayoutRules.CenterVertical); + layout.AddRule(LayoutRules.CenterHorizontal); + relativeLayout = new RelativeLayout(Platform.AppContext) { - Background = new ColorDrawable(Android.Graphics.Color.Black), - LayoutParameters = containerLayoutParams + LayoutParameters = layout }; - - playerView.LayoutParameters = new FrameLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent, GravityFlags.Center); SetBackgroundResource(Android.Resource.Color.Black); } public void SetView(AndroidX.Media3.Session.MediaController mediaController) { playerView.Player = mediaController; - frameLayout.AddView(playerView); - AddView(frameLayout); + relativeLayout.AddView(playerView); + AddView(relativeLayout); } public override void OnDetachedFromWindow() @@ -107,14 +108,14 @@ void OnFullscreenButtonClick(object? sender, PlayerView.FullscreenButtonClickEve if (e.P0) { isFullScreen = true; - RemoveView(frameLayout); - layout?.AddView(frameLayout); + RemoveView(relativeLayout); + layout?.AddView(relativeLayout); } else { isFullScreen = false; - layout?.RemoveView(frameLayout); - AddView(frameLayout); + layout?.RemoveView(relativeLayout); + AddView(relativeLayout); } // Hide/Show the SystemBars and Status bar SetSystemBarsVisibility(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index 96cf06e83d..726e7aad9c 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -1,6 +1,8 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Android.Content; +using Android.Views; +using Android.Widget; using AndroidX.Core.Content; using AndroidX.Media3.Common; using AndroidX.Media3.Common.Text; @@ -147,6 +149,7 @@ public PlayerView CreatePlatformView(AndroidViewType androidViewType) { UseController = false, ControllerAutoShow = false, + LayoutParameters = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent) }; } else if (androidViewType is AndroidViewType.TextureView) @@ -166,6 +169,7 @@ public PlayerView CreatePlatformView(AndroidViewType androidViewType) { UseController = false, ControllerAutoShow = false, + LayoutParameters = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent) }; } else