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/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/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/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..790fedd26f 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,102 @@ 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(); + const string cHANNEL_ID = "media_playback_channel"; + const 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 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(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.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(); + loadControlBuilder.SetBufferDurationsMs( + minBufferMs: 15000, + maxBufferMs: 50000, + bufferForPlaybackMs: 2500, + 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"); + + 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..3547fa4aee 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; @@ -7,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)] @@ -40,22 +42,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.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) { - LayoutParameters = layout, + LayoutParameters = layout }; - relativeLayout.AddView(playerView); + SetBackgroundResource(Android.Resource.Color.Black); + } + public void SetView(AndroidX.Media3.Session.MediaController mediaController) + { + playerView.Player = mediaController; + relativeLayout.AddView(playerView); AddView(relativeLayout); } @@ -82,34 +95,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 7da0a27ea8..fe330f71f3 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -3,6 +3,7 @@ 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 +11,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 +61,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,35 +79,59 @@ 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, + stateBuffering => MediaElementState.Buffering, + stateEnded => MediaElementState.Stopped, + stateReady => playWhenReady + ? MediaElementState.Playing + : MediaElementState.Paused, + stateIdle => MediaElementState.None, + _ => MediaElementState.None, + }; - PlaybackState.StateConnecting - or PlaybackState.StateBuffering => MediaElementState.Buffering, + MediaElement.CurrentStateChanged(newState); - PlaybackState.StateNone => MediaElementState.None, - PlaybackState.StateStopped => MediaElement.CurrentState is not MediaElementState.Failed - ? MediaElementState.Stopped - : MediaElementState.Failed, + if (playbackState is stateReady) + { + 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(); + } + } - PlaybackState.StateError => MediaElementState.Failed, + public void OnIsPlayingChanged(bool isPlaying) + { + if (Player is null || MediaElement.Source is null) + { + return; + } - _ => MediaElementState.None, - }; + var newState = isPlaying + ? MediaElementState.Playing + : MediaElementState.Paused; MediaElement.CurrentStateChanged(newState); - if (playbackState is readyState) + } + + public void OnTracksChanged(Tracks? tracks) + { + if (tracks is null || tracks.IsEmpty) { - MediaElement.Duration = TimeSpan.FromMilliseconds(Player.Duration < 0 ? 0 : Player.Duration); - MediaElement.Position = TimeSpan.FromMilliseconds(Player.CurrentPosition < 0 ? 0 : Player.CurrentPosition); + return; + } + if(tracks.IsTypeSupported(C.TrackTypeText)) + { + PlayerView?.SetShowSubtitleButton(true); + } + else + { + PlayerView?.SetShowSubtitleButton(false); } } @@ -129,17 +140,13 @@ 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) @@ -160,7 +167,6 @@ 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) @@ -170,14 +176,44 @@ or PlaybackState.StateSkippingToQueueItem { 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(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(); + 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); + return Player ?? throw new InvalidOperationException("MediaController is null"); } /// @@ -198,15 +234,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; } @@ -344,18 +393,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) { - return; - } - - if (connection is null) - { - StartService(); + System.Diagnostics.Trace.WriteLine("IExoPlayer is null, cannot update source"); + return ValueTask.CompletedTask; } if (MediaElement.Source is null) @@ -364,14 +409,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) @@ -384,8 +427,8 @@ protected virtual async partial ValueTask PlatformUpdateSource() if (hasSetSource && Player.PlayerError is null) { MediaElement.MediaOpened(); - UpdateNotifications(); } + return ValueTask.CompletedTask; } protected virtual partial void PlatformUpdateAspect() @@ -469,7 +512,6 @@ protected virtual partial void PlatformUpdateVolume() volumeBeforeMute = (float)MediaElement.Volume; return; } - Player.Volume = (float)MediaElement.Volume; } @@ -519,141 +561,24 @@ 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); } } - 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; - } - } - - [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) + MediaItem.Builder? SetPlayerData() { if (MediaElement.Source is null) { @@ -667,7 +592,7 @@ void StopService(in BoundServiceConnection boundServiceConnection) var uri = uriMediaSource.Uri; if (!string.IsNullOrWhiteSpace(uri?.AbsoluteUri)) { - return await CreateMediaItem(uri.AbsoluteUri, cancellationToken).ConfigureAwait(false); + return CreateMediaItem(uri.AbsoluteUri); } break; @@ -677,7 +602,7 @@ void StopService(in BoundServiceConnection boundServiceConnection) var filePath = fileMediaSource.Path; if (!string.IsNullOrWhiteSpace(filePath)) { - return await CreateMediaItem(filePath, cancellationToken).ConfigureAwait(false); + return CreateMediaItem(filePath); } break; @@ -689,7 +614,7 @@ void StopService(in BoundServiceConnection boundServiceConnection) if (!string.IsNullOrWhiteSpace(path)) { var assetFilePath = $"asset://{package}{Path.PathSeparator}{path}"; - return await CreateMediaItem(assetFilePath, cancellationToken).ConfigureAwait(false); + return CreateMediaItem(assetFilePath); } break; @@ -701,22 +626,16 @@ void StopService(in BoundServiceConnection boundServiceConnection) 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); mediaItem.SetMediaMetadata(mediaMetaData.Build()); - return mediaItem; } @@ -728,7 +647,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) { } @@ -748,25 +666,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 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