From 96fc21a6be445fd153f8f71aa9d51ff12e823440 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Mon, 6 May 2024 19:13:55 -0700 Subject: [PATCH 01/98] The most important changes involve the introduction of subtitle support for video players across different platforms (Android, iOS/macOS, and Windows) within the CommunityToolkit.Maui.Extensions namespace. This includes the ability to load subtitles from a URL, parse SRT and VTT subtitle formats, and display the subtitles synchronized with video playback. Key implementations include the `SubtitleExtensions` class for platform-specific handling, `SrtParser` and `VttParser` classes for parsing subtitle files, and the integration of subtitle functionality into the `MediaManager` classes for each platform. Additionally, UI enhancements allow users to select subtitles, and platform-specific adjustments ensure correct subtitle display in various modes. List of changes: 1. **Cross-Platform Subtitle Support**: Added functionality to display subtitles in the `MediaElement` control, supporting SRT and VTT formats across Android, iOS/macOS, and Windows. 2. **Subtitle File Loading**: Introduced a `SubtitleUrl` property in the `MediaElement` control for specifying the subtitle file URL. 3. **Platform-Specific `SubtitleExtensions`**: Implemented on Android, iOS/macOS, and Windows to handle subtitle loading, parsing, and displaying, with adjustments for fullscreen changes. 4. **Subtitle Parsing Classes**: Added `SrtParser` and `VttParser` static classes for parsing SRT and VTT files into `SubtitleCue` objects. 5. **`SubtitleCue` Class**: Created to represent individual subtitle cues with start/end times and text. 6. **MediaManager Integration**: Modified to incorporate subtitle functionality, including subtitle display based on video playback state and volume changes. 7. **UI Enhancements**: Updated the user interface to allow subtitle selection and ensure subtitles are relevant to the video being played. 8. **Platform-Specific Adjustments**: Implemented adjustments for correct subtitle display in fullscreen mode and during layout changes. 9. **Error Handling and Logging**: Included basic error handling and logging, particularly for subtitle loading methods. 10. **Memory Management**: For Windows, ensured proper disposal of unmanaged resources in the `SubtitleExtensions` class. These changes significantly enhance the video playback experience by providing users with the ability to display subtitles, thereby making content more accessible and enjoyable across different platforms and devices. --- .../Views/MediaElement/MediaElementPage.xaml | 2 + .../MediaElement/MediaElementPage.xaml.cs | 14 +- .../Extensions/SrtParser.cs | 72 ++++++ .../Extensions/SubtitleCue.cs | 26 +++ .../Extensions/SubtitleExtensions.android.cs | 209 ++++++++++++++++++ .../Extensions/SubtitleExtensions.macios.cs | 144 ++++++++++++ .../Extensions/SubtitleExtensions.windows.cs | 202 +++++++++++++++++ .../Extensions/VttParser.cs | 63 ++++++ .../Interfaces/IMediaElement.cs | 5 + .../MediaElement.shared.cs | 13 ++ .../Primitives/WindowsEventArgs.cs | 20 ++ .../Views/MauiMediaElement.android.cs | 14 ++ .../Views/MauiMediaElement.windows.cs | 18 +- .../Views/MediaManager.android.cs | 19 +- .../Views/MediaManager.macios.cs | 27 ++- .../Views/MediaManager.windows.cs | 27 ++- 16 files changed, 865 insertions(+), 10 deletions(-) create mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs create mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs create mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs create mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs create mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs create mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs create mode 100644 src/CommunityToolkit.Maui.MediaElement/Primitives/WindowsEventArgs.cs diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml index 9ec5708358..5fb5526112 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml @@ -29,7 +29,9 @@ const string loadHls = "Load HTTP Live Stream (HLS)"; const string loadLocalResource = "Load Local Resource"; const string resetSource = "Reset Source to null"; + const string loadSubTitles = "Load Subtitles"; public MediaElementPage(MediaElementViewModel viewModel, ILogger logger) : base(viewModel) { @@ -154,23 +155,26 @@ 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); + loadOnlineMp4, loadHls, loadLocalResource, resetSource, loadSubTitles); switch (result) { case loadOnlineMp4: + MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = MediaSource.FromUri( "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"); return; case loadHls: + MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = MediaSource.FromUri( "https://mtoczko.github.io/hls-test-streams/test-gap/playlist.m3u8"); return; case resetSource: + MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = null; return; @@ -178,17 +182,24 @@ async void ChangeSourceClicked(Object sender, EventArgs e) if (DeviceInfo.Platform == DevicePlatform.MacCatalyst || DeviceInfo.Platform == DevicePlatform.iOS) { + MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = MediaSource.FromResource("AppleVideo.mp4"); } else if (DeviceInfo.Platform == DevicePlatform.Android) { + MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = MediaSource.FromResource("AndroidVideo.mp4"); } else if (DeviceInfo.Platform == DevicePlatform.WinUI) { + MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); } return; + case loadSubTitles: + MediaElement.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; + MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); + return; } } @@ -214,7 +225,6 @@ await DisplayAlert("Error", "There was an error determining the selected aspect" MediaElement.Aspect = (Aspect)aspectEnum; } - void DisplayPopup(object sender, EventArgs e) { MediaElement.Pause(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs new file mode 100644 index 0000000000..e67763adf3 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Maui.Extensions; +/// +/// a class that provides subtitle parser for SRT files. +/// +public static partial class SrtParser +{ + static readonly string[] separator = ["\r\n", "\n"]; + + /// + /// a method that parses the SRT content and returns a list of SubtitleCue objects. + /// + /// + /// + public static List ParseSrtContent(string srtContent) + { + List cues = []; + string[] lines = srtContent.Split(separator, StringSplitOptions.None); + + Regex timecodePattern = MyRegex(); + + SubtitleCue? currentCue = null; + foreach (var line in lines) + { + if (int.TryParse(line, out _)) // Skip lines that contain only numbers + { + continue; + } + var match = timecodePattern.Match(line); + if (match.Success) + { + if (currentCue != null) + { + cues.Add(currentCue); + } + + currentCue = new SubtitleCue + { + StartTime = ParseTimecode(match.Groups[1].Value), + EndTime = ParseTimecode(match.Groups[2].Value), + Text = "" + }; + } + else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) + { + if (!string.IsNullOrEmpty(currentCue.Text)) + { + currentCue.Text += Environment.NewLine; + } + currentCue.Text += line.Trim(); // Trim leading/trailing spaces + } + } + + if (currentCue is not null) + { + cues.Add(currentCue); + } + + return cues; + } + + static TimeSpan ParseTimecode(string timecode) + { + return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); + } + + [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})")] + private static partial Regex MyRegex(); +} + diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs new file mode 100644 index 0000000000..05ad5ea1d2 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs @@ -0,0 +1,26 @@ +namespace CommunityToolkit.Maui.Extensions; + +/// +/// The SubtitleCue class represents a single cue in a subtitle file. +/// +public class SubtitleCue +{ + /// + /// The number of the cue. + /// + public int Number { get; set; } + /// + /// The start time of the cue. + /// + public TimeSpan? StartTime { get; set; } + + /// + /// The end time of the cue. + /// + public TimeSpan? EndTime { get; set; } + + /// + /// The text of the cue. + /// + public string? Text { get; set; } +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs new file mode 100644 index 0000000000..8266aa41e1 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -0,0 +1,209 @@ +using Android.Content; +using Android.Views; +using Android.Widget; +using AndroidX.CoordinatorLayout.Widget; +using Com.Google.Android.Exoplayer2.UI; +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Core.Views; +using CommunityToolkit.Maui.Primitives; + +namespace CommunityToolkit.Maui.Extensions; +/// +/// A class that provides subtitle support for a video player. +/// +public partial class SubtitleExtensions : CoordinatorLayout +{ + readonly HttpClient httpClient; + System.Timers.Timer? timer; + List cues; + bool disposedValue; + bool isFullScreen = false; + IMediaElement? mediaElement; + TextView? textBlock; + readonly StyledPlayerView styledPlayerView; + readonly IDispatcher dispatcher; + RelativeLayout? relativeLayout; + RelativeLayout? fullScreenLayout; + + /// + /// The SubtitleExtensions class provides a way to display subtitles on a video player. + /// + /// + /// + public SubtitleExtensions(Context context, StyledPlayerView styledPlayerView, IDispatcher dispatcher) : base(context) + { + httpClient = new HttpClient(); + this.dispatcher = dispatcher; + this.styledPlayerView = styledPlayerView; + cues = []; + MauiMediaElement.WindowsChanged += MauiMediaElement_WindowsChanged; + } + + void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) + { + if (e.data is not ViewGroup viewGroup || styledPlayerView.Parent is not ViewGroup parent || string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) + { + return; + } + if(isFullScreen) + { + relativeLayout = new(Platform.AppContext) + { + LayoutParameters = new CoordinatorLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent) + { + Gravity = (int)GravityFlags.Bottom + } + }; + dispatcher.Dispatch(() => + { + viewGroup.RemoveView(fullScreenLayout); + fullScreenLayout?.RemoveView(textBlock); + relativeLayout?.AddView(textBlock); + parent.AddView(relativeLayout); + }); + isFullScreen = false; + return; + } + + dispatcher.Dispatch(() => + { + parent.RemoveView(relativeLayout); + relativeLayout?.RemoveView(textBlock); + fullScreenLayout = new(Platform.AppContext) + { + LayoutParameters = new CoordinatorLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent) + { + Gravity = (int)GravityFlags.Bottom + } + }; + fullScreenLayout.AddView(textBlock); + viewGroup.AddView(fullScreenLayout); + }); + isFullScreen = true; + } + + /// + /// Loads the subtitles from the provided URL. + /// + /// + public async Task LoadSubtitles(IMediaElement mediaElement) + { + this.mediaElement = mediaElement; + string? vttContent; + try + { + vttContent = await httpClient.GetStringAsync(mediaElement.SubtitleUrl); + } + catch (Exception ex) + { + System.Diagnostics.Trace.TraceError(ex.Message); + return; + } + cues = mediaElement.SubtitleUrl switch + { + var url when url.EndsWith("srt") => SrtParser.ParseSrtContent(vttContent), + var url when url.EndsWith("vtt") => VttParser.ParseVttContent(vttContent), + _ => throw new NotSupportedException("Unsupported subtitle format"), + }; + } + + /// + /// Starts the subtitle display. + /// + public void StartSubtitleDisplay() + { + textBlock = new(Platform.AppContext) + { + Text = string.Empty, + HorizontalScrollBarEnabled = false, + VerticalScrollBarEnabled = false, + TextSize = 16, + TextAlignment = Android.Views.TextAlignment.Center, + Visibility = Android.Views.ViewStates.Gone, + }; + + textBlock.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); + textBlock.SetPadding(10, 10, 10, 10); + textBlock.SetTextColor(Android.Graphics.Color.White); + if (styledPlayerView.Parent is not ViewGroup parent) + { + return; + } + relativeLayout = new(Platform.AppContext) + { + LayoutParameters = new CoordinatorLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent) + { + Gravity = (int)GravityFlags.Bottom + } + }; + relativeLayout.AddView(textBlock); + dispatcher.Dispatch(() => parent.AddView(relativeLayout)); + timer = new System.Timers.Timer(1000); + timer.Elapsed += Timer_Elapsed; + timer.Start(); + } + + void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) + { + if (mediaElement?.Position is null || textBlock is null || cues.Count == 0) + { + return; + } + + var cue = cues.Find(c => c.StartTime <= mediaElement.Position && c.EndTime >= mediaElement.Position); + dispatcher.Dispatch(() => + { + if (cue is not null) + { + textBlock.Text = cue.Text; + textBlock.Visibility = Android.Views.ViewStates.Visible; + } + else + { + textBlock.Text = string.Empty; + textBlock.Visibility = Android.Views.ViewStates.Gone; + } + }); + } + + /// + /// Stops the subtitle timer. + /// + public void StopSubtitleDisplay() + { + if (timer is null) + { + return; + } + if(styledPlayerView.Parent is ViewGroup parent) + { + dispatcher.Dispatch(() => + { + parent.RemoveView(relativeLayout); + relativeLayout?.RemoveView(textBlock); + }); + } + timer.Stop(); + timer.Elapsed -= Timer_Elapsed; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (!disposedValue) + { + if (disposing) + { + timer?.Stop(); + if(timer is not null) + { + timer.Elapsed -= Timer_Elapsed; + } + httpClient?.Dispose(); + timer?.Dispose(); + } + timer = null; + disposedValue = true; + } + } +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs new file mode 100644 index 0000000000..556596e0f6 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -0,0 +1,144 @@ +using CommunityToolkit.Maui.Core; +using CoreFoundation; +using CoreGraphics; +using CoreMedia; +using Foundation; +using UIKit; + +namespace CommunityToolkit.Maui.Extensions; + +/// +/// A class that provides subtitle support for a video player. +/// +public partial class SubtitleExtensions : UIViewController +{ + readonly HttpClient httpClient; + readonly UIViewController playerViewController; + readonly PlatformMediaElement? player; + readonly UILabel subtitleLabel; + List cues; + NSObject? playerObserver; + + /// + /// The SubtitleExtensions class provides a way to display subtitles on a video player. + /// + /// + /// + public SubtitleExtensions(PlatformMediaElement? player, UIViewController? playerViewController) + { + ArgumentNullException.ThrowIfNull(player); + ArgumentNullException.ThrowIfNull(playerViewController?.View?.Bounds); + this.playerViewController = playerViewController; + this.player = player; + cues = []; + httpClient = new HttpClient(); + subtitleLabel = new UILabel + { + Frame = CalculateSubtitleFrame(), + TextColor = UIColor.White, + TextAlignment = UITextAlignment.Center, + Font = UIFont.SystemFontOfSize(16), + Text = "", + Lines = 0, + AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleTopMargin | UIViewAutoresizing.FlexibleHeight | UIViewAutoresizing.FlexibleBottomMargin + }; + playerViewController.View.AddSubview(subtitleLabel); + } + + /// + /// Loads the subtitles from the provided URL. + /// + /// + public async Task LoadSubtitles(IMediaElement mediaElement) + { + string vttContent = string.Empty; + try + { + vttContent = await httpClient.GetStringAsync(mediaElement.SubtitleUrl).ConfigureAwait(true) ?? string.Empty; + } + catch (Exception ex) + { + System.Diagnostics.Trace.TraceError(ex.Message); + } + + if (string.IsNullOrEmpty(vttContent)) + { + System.Diagnostics.Trace.TraceError("VTT Subtitle Content is Empty"); + return; + } + if (mediaElement.SubtitleUrl.EndsWith("srt")) + { + cues = SrtParser.ParseSrtContent(vttContent); + } + else if (mediaElement.SubtitleUrl.EndsWith("vtt")) + { + cues = VttParser.ParseVttContent(vttContent); + } + } + + /// + /// Starts the subtitle display. + /// + public void StartSubtitleDisplay() + { + ArgumentNullException.ThrowIfNull(player); + if(cues?.Count == 0) + { + return; + } + if (playerObserver is not null) + { + StopSubtitleDisplay(); + } + playerObserver = player.AddPeriodicTimeObserver(CMTime.FromSeconds(1, 1), null, (time) => + { + TimeSpan currentPlaybackTime = TimeSpan.FromSeconds(time.Seconds); + ArgumentNullException.ThrowIfNull(subtitleLabel); + DispatchQueue.MainQueue.DispatchAsync(() => UpdateSubtitle(currentPlaybackTime)); + }); + } + + /// + /// Stops the subtitle display. + /// + public void StopSubtitleDisplay() + { + ArgumentNullException.ThrowIfNull(player); + if (playerObserver is not null) + { + player.RemoveTimeObserver(playerObserver); + playerObserver.Dispose(); + playerObserver = null; + subtitleLabel.RemoveFromSuperview(); + } + } + void UpdateSubtitle(TimeSpan currentPlaybackTime) + { + ArgumentNullException.ThrowIfNull(subtitleLabel); + ArgumentNullException.ThrowIfNull(playerViewController.View); + subtitleLabel.RemoveFromSuperview(); + foreach (var cue in cues) + { + if (currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime) + { + subtitleLabel.Text = cue.Text; + subtitleLabel.BackgroundColor = UIColor.FromRGBA(0, 0, 0, 128); + break; + } + else + { + subtitleLabel.Text = ""; + subtitleLabel.BackgroundColor = UIColor.FromRGBA(0, 0, 0, 0); + } + } + subtitleLabel.Frame = CalculateSubtitleFrame(); + playerViewController.View.AddSubview(subtitleLabel); + } + CGRect CalculateSubtitleFrame() + { + ArgumentNullException.ThrowIfNull(playerViewController?.View?.Bounds); + return new CGRect(0, playerViewController.View.Bounds.Height - 60, playerViewController.View.Bounds.Width, 50); + } + +} + diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs new file mode 100644 index 0000000000..ca11c67f61 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -0,0 +1,202 @@ +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Core.Views; +using CommunityToolkit.Maui.Primitives; + +namespace CommunityToolkit.Maui.Extensions; + +/// +/// A class that provides subtitle support for a video player. +/// +public partial class SubtitleExtensions : Grid, IDisposable +{ + readonly HttpClient httpClient; + Microsoft.UI.Xaml.Controls.Grid? item; + System.Timers.Timer? timer; + List cues; + bool disposedValue; + IMediaElement? mediaElement; + readonly Label textBlock; + Microsoft.UI.Xaml.Controls.TextBlock? xamlTextBlock; + + /// + /// The SubtitleExtensions class provides a way to display subtitles on a video player. + /// + public SubtitleExtensions() + { + httpClient = new HttpClient(); + cues = []; + MauiMediaElement.WindowsChanged += MauiMediaElement_WindowsChanged; + textBlock = new() + { + Text = string.Empty, + IsVisible = false, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.End, + TextColor = Microsoft.Maui.Graphics.Colors.White, + FontSize = 16, + LineBreakMode = LineBreakMode.WordWrap, + }; + } + void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) + { + System.Diagnostics.Trace.TraceInformation("Windows Changed"); + if (mediaElement?.Parent is not Grid mediaElementParent || e.data is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(mediaElement.SubtitleUrl)) + { + return; + } + this.item = gridItem; + if (mediaElementParent.Children.Contains(textBlock)) + { + Dispatcher.Dispatch(() => mediaElementParent.Children.Remove(textBlock)); + } + if (item.Children.Contains(xamlTextBlock)) + { + Dispatcher.Dispatch(() => item.Children.Remove(xamlTextBlock)); + Dispatcher.Dispatch(() => mediaElementParent.Children.Add(textBlock)); + xamlTextBlock = null; + return; + } + Dispatcher.Dispatch(() => item.Children.Add(xamlTextBlock)); + } + + /// + /// Loads the subtitles from the provided URL. + /// + /// + public async Task LoadSubtitles(IMediaElement mediaElement) + { + this.mediaElement = mediaElement; + string? vttContent; + try + { + vttContent = await httpClient.GetStringAsync(mediaElement.SubtitleUrl); + } + catch (Exception ex) + { + System.Diagnostics.Trace.TraceError(ex.Message); + return; + } + xamlTextBlock ??= new() + { + Text = string.Empty, + Visibility = Microsoft.UI.Xaml.Visibility.Collapsed, + HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, + VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Bottom, + Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White), + FontSize = 16, + TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap, + }; + cues = mediaElement.SubtitleUrl switch + { + var url when url.EndsWith("srt") => SrtParser.ParseSrtContent(vttContent), + var url when url.EndsWith("vtt") => VttParser.ParseVttContent(vttContent), + _ => throw new NotSupportedException("Unsupported subtitle format"), + }; + } + + /// + /// Starts the subtitle display. + /// + public void StartSubtitleDisplay() + { + if(mediaElement?.Parent is not Grid parent) + { + return; + } + + Dispatcher.Dispatch(() => parent.Children.Add(textBlock)); + timer = new System.Timers.Timer(1000); + timer.Elapsed += Timer_Elapsed; + timer.Start(); + } + + void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) + { + if (mediaElement?.Position is null || cues.Count == 0 || string.IsNullOrEmpty(mediaElement.SubtitleUrl)) + { + return; + } + var cue = cues.Find(c => c.StartTime <= mediaElement.Position && c.EndTime >= mediaElement.Position); + Dispatcher.Dispatch(() => + { + if (cue is not null) + { + if (xamlTextBlock is not null) + { + xamlTextBlock.Text = cue.Text; + xamlTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; + } + textBlock.Text = cue.Text; + textBlock.IsVisible = true; + } + else + { + if (xamlTextBlock is not null) + { + xamlTextBlock.Text = string.Empty; + xamlTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; + } + textBlock.Text = string.Empty; + textBlock.IsVisible = false; + } + }); + } + + /// + /// Stops the subtitle timer. + /// + public void StopSubtitleDisplay() + { + if (timer is null) + { + return; + } + timer.Stop(); + timer.Elapsed -= Timer_Elapsed; + if (mediaElement?.Parent is not Grid parent) + { + return; + } + Dispatcher.Dispatch(() => parent.Children.Remove(textBlock)); + } + + /// + /// The Dispose method. For the class."/> + /// + /// + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (timer is not null) + { + timer.Stop(); + timer.Elapsed -= Timer_Elapsed; + } + if (disposing) + { + httpClient?.Dispose(); + timer?.Dispose(); + } + timer = null; + disposedValue = true; + } + } + + /// + /// A finalizer for the . + /// + ~SubtitleExtensions() + { + Dispose(disposing: false); + } + + /// + /// + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs new file mode 100644 index 0000000000..47f43eec71 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -0,0 +1,63 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Maui.Extensions; + +/// +/// A class that provides subtitle parser for VTT files. +/// +public static partial class VttParser +{ + static readonly string[] separator = ["\r\n", "\n"]; + + /// + /// The ParseVttContent method parses the VTT content and returns a list of SubtitleCue objects. + /// + /// + /// + public static List ParseVttContent(string vttContent) + { + List cues = []; + string[] lines = vttContent.Split(separator, StringSplitOptions.None); + + Regex timecodePattern = MyRegex(); + + SubtitleCue? currentCue = null; + foreach (var line in lines) + { + var match = timecodePattern.Match(line); + if (match.Success) + { + if (currentCue is not null) + { + cues.Add(currentCue); + } + + currentCue = new SubtitleCue + { + StartTime = TimeSpan.Parse(match.Groups[1].Value, new CultureInfo("en-US")), + EndTime = TimeSpan.Parse(match.Groups[2].Value, new CultureInfo("en-US")), + Text = "" + }; + } + else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) + { + if (!string.IsNullOrEmpty(currentCue.Text)) + { + currentCue.Text += " "; + } + currentCue.Text += line.Trim('-').Trim(); // Trim hyphens and spaces + } + } + + if (currentCue is not null) + { + cues.Add(currentCue); + } + + return cues; + } + + [GeneratedRegex(@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})")] + private static partial Regex MyRegex(); +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs index fc621bc902..f0d5f0706c 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs @@ -86,6 +86,11 @@ public interface IMediaElement : IView, IAsynchronousMediaElementHandler /// A value of 1 means full volume, 0 is silence. double Volume { get; set; } + /// + /// Gets or sets the URL of the subtitle file to display. + /// + string SubtitleUrl { get; set; } + /// /// Occurs when changed. /// diff --git a/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs b/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs index f08c4435c8..352859dc50 100644 --- a/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs @@ -74,6 +74,10 @@ public class MediaElement : View, IMediaElement, IDisposable BindableProperty.Create(nameof(Source), typeof(MediaSource), typeof(MediaElement), propertyChanging: OnSourcePropertyChanging, propertyChanged: OnSourcePropertyChanged); + /// + /// Backing store for the property. + /// + public static readonly BindableProperty SubtitleProperty = BindableProperty.Create(nameof(SubtitleUrl), typeof(string), typeof(MediaElement), string.Empty); /// /// Backing store for the property. /// @@ -266,6 +270,15 @@ public MediaSource? Source set => SetValue(SourceProperty, value); } + /// + /// Gets or sets the URL of the subtitle file to display. + /// + public string SubtitleUrl + { + get => (string)GetValue(SubtitleProperty); + set => SetValue(SubtitleProperty, value); + } + /// /// Gets or sets the volume of the audio for the media. /// diff --git a/src/CommunityToolkit.Maui.MediaElement/Primitives/WindowsEventArgs.cs b/src/CommunityToolkit.Maui.MediaElement/Primitives/WindowsEventArgs.cs new file mode 100644 index 0000000000..92d4e1e031 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Primitives/WindowsEventArgs.cs @@ -0,0 +1,20 @@ + +namespace CommunityToolkit.Maui.Primitives; +/// +/// +/// +public class WindowsEventArgs : EventArgs +{ + /// + /// + /// + public object? data { get; } + /// + /// + /// + /// + public WindowsEventArgs(object? data) + { + this.data = data; + } +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs index 669ff5d5b3..cbae5a5d69 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.CoordinatorLayout.Widget; using AndroidX.Core.View; using Com.Google.Android.Exoplayer2.UI; +using CommunityToolkit.Maui.Primitives; using CommunityToolkit.Maui.Views; namespace CommunityToolkit.Maui.Core.Views; @@ -17,6 +18,10 @@ namespace CommunityToolkit.Maui.Core.Views; /// public class MauiMediaElement : CoordinatorLayout { + /// + /// Handles the event when the windows change. + /// + public static event EventHandler? WindowsChanged; readonly StyledPlayerView playerView; int defaultSystemUiVisibility; bool isSystemBarVisible; @@ -48,6 +53,13 @@ public MauiMediaElement(Context context, StyledPlayerView playerView) : base(con AddView(playerView); } + /// + /// A method that raises the WindowsChanged event. + /// + protected virtual void OnWindowsChanged(WindowsEventArgs e) + { + WindowsChanged?.Invoke(null, e); + } public override void OnDetachedFromWindow() { if (isFullScreen) @@ -146,6 +158,7 @@ void OnFullscreenButtonClick(object? sender, StyledPlayerView.FullscreenButtonCl item.Height = displayMetrics.HeightPixels; layout?.AddView(playerView, item); SetSystemBarsVisibility(); + OnWindowsChanged(new Maui.Primitives.WindowsEventArgs(layout)); } else { @@ -158,6 +171,7 @@ void OnFullscreenButtonClick(object? sender, StyledPlayerView.FullscreenButtonCl layout?.RemoveView(playerView); AddView(playerView, item); SetSystemBarsVisibility(); + OnWindowsChanged(new Maui.Primitives.WindowsEventArgs(layout)); } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs index 9b830b80f5..542aecd0dd 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Maui.Extensions; +using CommunityToolkit.Maui.Primitives; using CommunityToolkit.Maui.Views; using Microsoft.UI; using Microsoft.UI.Windowing; @@ -22,6 +23,10 @@ namespace CommunityToolkit.Maui.Core.Views; /// public class MauiMediaElement : Grid, IDisposable { + /// + /// Handles the event when the windows change. + /// + public static event EventHandler? WindowsChanged; static readonly AppWindow appWindow = GetAppWindowForCurrentWindow(); static readonly ImageSource source = new BitmapImage(new Uri("ms-appx:///fullscreen.png")); @@ -82,6 +87,14 @@ public MauiMediaElement(MediaPlayerElement mediaPlayerElement) /// ~MauiMediaElement() => Dispose(false); + /// + /// A method that raises the WindowsChanged event. + /// + protected virtual void OnWindowsChanged(WindowsEventArgs e) + { + WindowsChanged?.Invoke(null, e); + } + /// /// Releases the managed and unmanaged resources used by the . /// @@ -160,6 +173,7 @@ void OnFullScreenButtonClick(object sender, RoutedEventArgs e) appWindow.SetPresenter(AppWindowPresenterKind.Default); Shell.SetNavBarIsVisible(CurrentPage, doesNavigationBarExistBeforeFullScreen); + OnWindowsChanged(new Maui.Primitives.WindowsEventArgs(fullScreenGrid)); if (popup.IsOpen) { popup.IsOpen = false; @@ -169,7 +183,6 @@ void OnFullScreenButtonClick(object sender, RoutedEventArgs e) Children.Add(mediaPlayerElement); Children.Add(buttonContainer); - var parent = mediaPlayerElement.Parent as FrameworkElement; mediaPlayerElement.Width = parent?.Width ?? mediaPlayerElement.Width; mediaPlayerElement.Height = parent?.Height ?? mediaPlayerElement.Height; @@ -187,7 +200,7 @@ void OnFullScreenButtonClick(object sender, RoutedEventArgs e) Children.Clear(); fullScreenGrid.Children.Add(mediaPlayerElement); fullScreenGrid.Children.Add(buttonContainer); - + popup.XamlRoot = mediaPlayerElement.XamlRoot; popup.HorizontalOffset = 0; popup.VerticalOffset = 0; @@ -200,6 +213,7 @@ void OnFullScreenButtonClick(object sender, RoutedEventArgs e) { popup.IsOpen = true; } + OnWindowsChanged(new Maui.Primitives.WindowsEventArgs(fullScreenGrid)); } } } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index f12b0e61a8..955118d788 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -9,6 +9,7 @@ using Com.Google.Android.Exoplayer2.UI; using Com.Google.Android.Exoplayer2.Video; using CommunityToolkit.Maui.Core.Primitives; +using CommunityToolkit.Maui.Extensions; using CommunityToolkit.Maui.Views; using Microsoft.Extensions.Logging; @@ -21,6 +22,9 @@ public partial class MediaManager : Java.Lang.Object, IPlayer.IListener double? previousSpeed; float volumeBeforeMute = 1; TaskCompletionSource? seekToTaskCompletionSource; + SubtitleExtensions? subtitleExtensions; + CancellationTokenSource subTitles = new(); + Task? startSubtitles; /// @@ -47,6 +51,7 @@ public partial class MediaManager : Java.Lang.Object, IPlayer.IListener LayoutParameters = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent) }; + subtitleExtensions = new(Platform.AppContext, PlayerView, Dispatcher); return (Player, PlayerView); } @@ -301,6 +306,7 @@ protected virtual partial void PlatformUpdateSource() { return; } + subtitleExtensions?.StopSubtitleDisplay(); if (MediaElement.Source is null) { @@ -355,9 +361,20 @@ protected virtual partial void PlatformUpdateSource() if (hasSetSource && Player.PlayerError is null) { MediaElement.MediaOpened(); + CancellationToken token = subTitles.Token; + startSubtitles = LoadSubtitles(token); } } - + async Task LoadSubtitles(CancellationToken cancellationToken = default) + { + subtitleExtensions?.StopSubtitleDisplay(); + if (subtitleExtensions is null || string.IsNullOrEmpty(MediaElement.SubtitleUrl)) + { + return; + } + await subtitleExtensions.LoadSubtitles(MediaElement).WaitAsync(cancellationToken).ConfigureAwait(false); + subtitleExtensions.StartSubtitleDisplay(); + } protected virtual partial void PlatformUpdateAspect() { if (PlayerView is null) diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index 20c34fa971..18e2d7028d 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -1,6 +1,7 @@ using AVFoundation; using AVKit; using CommunityToolkit.Maui.Core.Primitives; +using CommunityToolkit.Maui.Extensions; using CommunityToolkit.Maui.Views; using CoreFoundation; using CoreMedia; @@ -11,6 +12,10 @@ namespace CommunityToolkit.Maui.Core.Views; public partial class MediaManager : IDisposable { + SubtitleExtensions? subtitleExtensions; + CancellationTokenSource subTitles = new(); + Task? startSubtitles; + // Media would still start playing when Speed was set although ShouldAutoPlay=False // This field was added to overcome that. bool initialSpeedSet; @@ -201,8 +206,9 @@ protected virtual partial void PlatformUpdateAspect() protected virtual partial void PlatformUpdateSource() { MediaElement.CurrentStateChanged(MediaElementState.Opening); - + AVAsset? asset = null; + subtitleExtensions?.StopSubtitleDisplay(); if (MediaElement.Source is UriMediaSource uriMediaSource) { @@ -250,7 +256,7 @@ protected virtual partial void PlatformUpdateSource() CurrentItemErrorObserver?.Dispose(); Player?.ReplaceCurrentItemWithPlayerItem(PlayerItem); - + subtitleExtensions ??= new(Player, PlayerViewController); CurrentItemErrorObserver = PlayerItem?.AddObserver("error", valueObserverOptions, (NSObservedChange change) => { @@ -276,6 +282,8 @@ protected virtual partial void PlatformUpdateSource() { Player?.Play(); } + CancellationToken token = subTitles.Token; + startSubtitles = LoadSubtitles(token); } else if (PlayerItem is null) { @@ -283,6 +291,17 @@ protected virtual partial void PlatformUpdateSource() } } + async Task LoadSubtitles(CancellationToken cancellationToken = default) + { + subtitleExtensions?.StopSubtitleDisplay(); + if (subtitleExtensions is null || string.IsNullOrEmpty(MediaElement.SubtitleUrl)) + { + return; + } + await subtitleExtensions.LoadSubtitles(MediaElement).WaitAsync(cancellationToken).ConfigureAwait(false); + subtitleExtensions.StartSubtitleDisplay(); + } + protected virtual partial void PlatformUpdateSpeed() { if (PlayerViewController?.Player is null) @@ -395,7 +414,9 @@ protected virtual void Dispose(bool disposing) Player.Pause(); DestroyErrorObservers(); DestroyPlayedToEndObserver(); - + subtitleExtensions?.StopSubtitleDisplay(); + subtitleExtensions?.Dispose(); + subTitles.Dispose(); RateObserver?.Dispose(); CurrentItemErrorObserver?.Dispose(); Player.ReplaceCurrentItemWithPlayerItem(null); diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs index aba821f579..23c1705288 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Maui.Core.Primitives; +using CommunityToolkit.Maui.Extensions; using CommunityToolkit.Maui.Views; using Microsoft.Extensions.Logging; using Microsoft.UI.Xaml.Controls; @@ -12,6 +13,9 @@ namespace CommunityToolkit.Maui.Core.Views; partial class MediaManager : IDisposable { + SubtitleExtensions? subtitleExtensions; + readonly CancellationTokenSource subTitles = new(); + Task? startSubtitles; // States that allow changing position readonly IReadOnlyList allowUpdatePositionStates = [ @@ -43,6 +47,7 @@ public PlatformMediaElement CreatePlatformView() MediaElement.MediaOpened += OnMediaElementMediaOpened; Player.SetMediaPlayer(MediaElement); + subtitleExtensions = []; Player.MediaPlayer.PlaybackSession.PlaybackRateChanged += OnPlaybackSessionPlaybackRateChanged; Player.MediaPlayer.PlaybackSession.PlaybackStateChanged += OnPlaybackSessionPlaybackStateChanged; @@ -245,7 +250,7 @@ protected virtual async partial void PlatformUpdateSource() { return; } - + subtitleExtensions?.StopSubtitleDisplay(); if (MediaElement.Source is null) { Player.Source = null; @@ -282,8 +287,21 @@ protected virtual async partial void PlatformUpdateSource() Player.Source = WinMediaSource.CreateFromUri(new Uri(path)); } } + + CancellationToken token = subTitles.Token; + startSubtitles = LoadSubtitles(token); } + async Task LoadSubtitles(CancellationToken cancellationToken = default) + { + subtitleExtensions?.StopSubtitleDisplay(); + if (subtitleExtensions is null || string.IsNullOrEmpty(MediaElement.SubtitleUrl)) + { + return; + } + await subtitleExtensions.LoadSubtitles(MediaElement).WaitAsync(cancellationToken).ConfigureAwait(false); + subtitleExtensions.StartSubtitleDisplay(); + } protected virtual partial void PlatformUpdateShouldLoopPlayback() { if (Player is null) @@ -302,6 +320,11 @@ protected virtual void Dispose(bool disposing) { if (disposing) { + if(subtitleExtensions is not null) + { + subtitleExtensions.Dispose(); + subtitleExtensions = null; + } if (Player?.MediaPlayer is not null) { if (displayActiveRequested) @@ -309,7 +332,7 @@ protected virtual void Dispose(bool disposing) DisplayRequest.RequestRelease(); displayActiveRequested = false; } - + subTitles.Dispose(); Player.MediaPlayer.MediaOpened -= OnMediaElementMediaOpened; Player.MediaPlayer.MediaFailed -= OnMediaElementMediaFailed; Player.MediaPlayer.MediaEnded -= OnMediaElementMediaEnded; From 1667b8d9a4d37ab47404c20e247b2489a03b09a4 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 7 May 2024 08:06:08 -0700 Subject: [PATCH 02/98] The primary focus of these code changes is to enhance the subtitle functionality within a MAUI application, specifically targeting Windows platforms. The modifications aim to improve the integration of subtitles with the MAUI media element, manage subtitle display based on the video player's full-screen status, and ensure that subtitles are correctly managed and displayed in relation to the media player's state. Here's a summary of the most significant changes: 1. **Integration with MAUI Media Element**: The `SubtitleExtensions` class has been updated to support a new `MauiMediaElement` property. This change involves replacing a `Label` text block with a `Microsoft.UI.Xaml.Controls.TextBlock` for displaying subtitles, facilitating better integration with MAUI's media element on Windows platforms. 2. **Full-Screen State Management**: A new boolean property `isFullScreen` has been introduced to manage the subtitle display based on the video player's full-screen status, enhancing the user experience during full-screen playback. 3. **Enhanced Subtitle Loading**: The `LoadSubtitles` method now requires an additional parameter of type `Microsoft.UI.Xaml.Controls.MediaPlayerElement`, linking the subtitle functionality directly with the media player element. This adjustment increases the flexibility and usability of the subtitle system. 4. **Improved Subtitle Display Logic**: Adjustments have been made to various methods (`MauiMediaElement_WindowsChanged`, `StartSubtitleDisplay`, `Timer_Elapsed`, and `StopSubtitleDisplay`) to accommodate the changes in subtitle management. These modifications ensure that subtitles are correctly added to or removed from the media element's visual tree based on the player's state. 5. **Diagnostic Trace Statements**: The `Timer_Elapsed` and `StopSubtitleDisplay` methods now include diagnostic trace statements to aid in debugging and monitoring subtitle display functionality. These statements log the displayed subtitle cue text and confirm the removal of the subtitle text block from the media element, respectively. 6. **Error Handling and Diagnostics in `MediaManager.windows.cs`**: The `LoadSubtitles` method has been updated to require the `Player` object as a parameter for the `subtitleExtensions.LoadSubtitles` call, ensuring correct association with the media player element. Additionally, a diagnostic trace error statement has been added to log situations where the subtitle extensions, subtitle URL, or player object are not correctly initialized. List of Changes: - Updated `SubtitleExtensions` class for better integration with MAUI's media element. [Integration with MAUI Media Element] - Added `isFullScreen` property for full-screen state management. [Full-Screen State Management] - Modified `LoadSubtitles` method to enhance subtitle loading. [Enhanced Subtitle Loading] - Adjusted subtitle display logic in multiple methods. [Improved Subtitle Display Logic] - Included diagnostic trace statements in `Timer_Elapsed` and `StopSubtitleDisplay` methods. [Diagnostic Trace Statements] - Improved error handling and diagnostics in `MediaManager.windows.cs`. [Error Handling and Diagnostics in `MediaManager.windows.cs`] --- .../Extensions/SubtitleExtensions.windows.cs | 60 +++++++++---------- .../Views/MediaManager.windows.cs | 5 +- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index ca11c67f61..e1a8dea2f0 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -9,14 +9,15 @@ namespace CommunityToolkit.Maui.Extensions; /// public partial class SubtitleExtensions : Grid, IDisposable { + bool isFullScreen = false; readonly HttpClient httpClient; Microsoft.UI.Xaml.Controls.Grid? item; System.Timers.Timer? timer; List cues; bool disposedValue; IMediaElement? mediaElement; - readonly Label textBlock; Microsoft.UI.Xaml.Controls.TextBlock? xamlTextBlock; + MauiMediaElement? mauiMediaElement; /// /// The SubtitleExtensions class provides a way to display subtitles on a video player. @@ -26,46 +27,45 @@ public SubtitleExtensions() httpClient = new HttpClient(); cues = []; MauiMediaElement.WindowsChanged += MauiMediaElement_WindowsChanged; - textBlock = new() - { - Text = string.Empty, - IsVisible = false, - HorizontalOptions = LayoutOptions.Center, - VerticalOptions = LayoutOptions.End, - TextColor = Microsoft.Maui.Graphics.Colors.White, - FontSize = 16, - LineBreakMode = LineBreakMode.WordWrap, - }; } void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) { System.Diagnostics.Trace.TraceInformation("Windows Changed"); - if (mediaElement?.Parent is not Grid mediaElementParent || e.data is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(mediaElement.SubtitleUrl)) + if (mauiMediaElement is null || e.data is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) { return; } this.item = gridItem; - if (mediaElementParent.Children.Contains(textBlock)) + + if (!isFullScreen) { - Dispatcher.Dispatch(() => mediaElementParent.Children.Remove(textBlock)); + if (mauiMediaElement.Children.Contains(xamlTextBlock)) + { + Dispatcher.Dispatch(() => mauiMediaElement.Children.Remove(xamlTextBlock)); + } + Dispatcher.Dispatch(() => item.Children.Add(xamlTextBlock)); + isFullScreen = true; } - if (item.Children.Contains(xamlTextBlock)) + else { - Dispatcher.Dispatch(() => item.Children.Remove(xamlTextBlock)); - Dispatcher.Dispatch(() => mediaElementParent.Children.Add(textBlock)); - xamlTextBlock = null; - return; + if (item.Children.Contains(xamlTextBlock)) + { + Dispatcher.Dispatch(() => item.Children.Remove(xamlTextBlock)); + } + Dispatcher.Dispatch(() => mauiMediaElement.Children.Add(xamlTextBlock)); + isFullScreen = false; } - Dispatcher.Dispatch(() => item.Children.Add(xamlTextBlock)); } /// /// Loads the subtitles from the provided URL. /// /// - public async Task LoadSubtitles(IMediaElement mediaElement) + /// + public async Task LoadSubtitles(IMediaElement mediaElement, Microsoft.UI.Xaml.Controls.MediaPlayerElement player) { this.mediaElement = mediaElement; + mauiMediaElement = player?.Parent as MauiMediaElement; string? vttContent; try { @@ -79,6 +79,7 @@ public async Task LoadSubtitles(IMediaElement mediaElement) xamlTextBlock ??= new() { Text = string.Empty, + Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 10), Visibility = Microsoft.UI.Xaml.Visibility.Collapsed, HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Bottom, @@ -99,12 +100,7 @@ var url when url.EndsWith("vtt") => VttParser.ParseVttContent(vttContent), /// public void StartSubtitleDisplay() { - if(mediaElement?.Parent is not Grid parent) - { - return; - } - - Dispatcher.Dispatch(() => parent.Children.Add(textBlock)); + Dispatcher.Dispatch(() => mauiMediaElement?.Children.Add(xamlTextBlock)); timer = new System.Timers.Timer(1000); timer.Elapsed += Timer_Elapsed; timer.Start(); @@ -124,10 +120,9 @@ void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) if (xamlTextBlock is not null) { xamlTextBlock.Text = cue.Text; + System.Diagnostics.Trace.TraceInformation("Cue Text: {0}", cue.Text); xamlTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } - textBlock.Text = cue.Text; - textBlock.IsVisible = true; } else { @@ -136,8 +131,6 @@ void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) xamlTextBlock.Text = string.Empty; xamlTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; } - textBlock.Text = string.Empty; - textBlock.IsVisible = false; } }); } @@ -153,11 +146,12 @@ public void StopSubtitleDisplay() } timer.Stop(); timer.Elapsed -= Timer_Elapsed; - if (mediaElement?.Parent is not Grid parent) + if(mauiMediaElement is null) { return; } - Dispatcher.Dispatch(() => parent.Children.Remove(textBlock)); + Dispatcher.Dispatch(() => mauiMediaElement.Children.Remove(xamlTextBlock)); + System.Diagnostics.Trace.TraceInformation("Removed text block from player parent"); } /// diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs index 23c1705288..ca6e9c7d59 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs @@ -295,11 +295,12 @@ protected virtual async partial void PlatformUpdateSource() async Task LoadSubtitles(CancellationToken cancellationToken = default) { subtitleExtensions?.StopSubtitleDisplay(); - if (subtitleExtensions is null || string.IsNullOrEmpty(MediaElement.SubtitleUrl)) + if (subtitleExtensions is null || string.IsNullOrEmpty(MediaElement.SubtitleUrl) || Player is null) { + System.Diagnostics.Trace.TraceError("SubtitleExtensions is null or SubtitleUrl is null or Player is null"); return; } - await subtitleExtensions.LoadSubtitles(MediaElement).WaitAsync(cancellationToken).ConfigureAwait(false); + await subtitleExtensions.LoadSubtitles(MediaElement, Player).WaitAsync(cancellationToken).ConfigureAwait(false); subtitleExtensions.StartSubtitleDisplay(); } protected virtual partial void PlatformUpdateShouldLoopPlayback() From 5c70eb5581bc760993aa46e3749561100723fdb5 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 7 May 2024 08:33:03 -0700 Subject: [PATCH 03/98] The code changes made primarily focus on improving code readability, maintainability, and robustness. Here's a summary of the most important changes: 1. **Simplified Object Initialization**: The initialization of `HttpClient` has been simplified using the target-typed `new` expression, making the code more concise. 2. **Refactoring Conditional Logic**: The handling of the `isFullScreen` state has been refactored from an if-else statement to a switch statement, utilizing pattern matching for better readability. 3. **Improving Null Checks**: An additional null check for `xamlTextBlock` in the `Timer_Elapsed` method has been added to prevent potential null reference exceptions. 4. **Streamlining Visibility Handling**: Redundant null checks for `xamlTextBlock` have been removed, as an earlier check already ensures its non-nullity, thus cleaning up the code. 5. **Removing Redundant Logging**: A redundant logging statement in the `StopSubtitleDisplay` method has been removed, likely to clean up the logging output. Details of the changes: - **Simplified Object Initialization**: Changed `httpClient = new HttpClient();` to `httpClient = new();` for cleaner code. - **Refactoring Conditional Logic**: Refactored `if (!isFullScreen)` to `switch(isFullScreen)` and replaced the else block with `case false:`, improving code maintainability. - **Improving Null Checks**: Added a null check `if (mediaElement?.Position is null || cues.Count == 0 || string.IsNullOrEmpty(mediaElement.SubtitleUrl) || xamlTextBlock is null)` to enhance code robustness. - **Streamlining Visibility Handling**: Removed unnecessary null checks `if (xamlTextBlock is not null)` after ensuring `xamlTextBlock` is not null earlier in the code. - **Removing Redundant Logging**: Removed the logging statement `System.Diagnostics.Trace.TraceInformation("Removed text block from player parent");` to clean up logging output. --- .../Extensions/SubtitleExtensions.windows.cs | 48 +++++++------------ 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index e1a8dea2f0..70364568c8 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -24,36 +24,27 @@ public partial class SubtitleExtensions : Grid, IDisposable /// public SubtitleExtensions() { - httpClient = new HttpClient(); + httpClient = new(); cues = []; MauiMediaElement.WindowsChanged += MauiMediaElement_WindowsChanged; } void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) { - System.Diagnostics.Trace.TraceInformation("Windows Changed"); if (mauiMediaElement is null || e.data is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) { return; } this.item = gridItem; - - if (!isFullScreen) + switch(isFullScreen) { - if (mauiMediaElement.Children.Contains(xamlTextBlock)) - { - Dispatcher.Dispatch(() => mauiMediaElement.Children.Remove(xamlTextBlock)); - } - Dispatcher.Dispatch(() => item.Children.Add(xamlTextBlock)); - isFullScreen = true; - } - else - { - if (item.Children.Contains(xamlTextBlock)) - { - Dispatcher.Dispatch(() => item.Children.Remove(xamlTextBlock)); - } - Dispatcher.Dispatch(() => mauiMediaElement.Children.Add(xamlTextBlock)); - isFullScreen = false; + case true: + Dispatcher.Dispatch(() => { item.Children.Remove(xamlTextBlock); mauiMediaElement.Children.Add(xamlTextBlock); }); + isFullScreen = false; + break; + case false: + Dispatcher.Dispatch(() => { mauiMediaElement.Children.Remove(xamlTextBlock); item.Children.Add(xamlTextBlock); }); + isFullScreen = true; + break; } } @@ -108,7 +99,7 @@ public void StartSubtitleDisplay() void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { - if (mediaElement?.Position is null || cues.Count == 0 || string.IsNullOrEmpty(mediaElement.SubtitleUrl)) + if (mediaElement?.Position is null || cues.Count == 0 || string.IsNullOrEmpty(mediaElement.SubtitleUrl) || xamlTextBlock is null) { return; } @@ -117,20 +108,14 @@ void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { if (cue is not null) { - if (xamlTextBlock is not null) - { - xamlTextBlock.Text = cue.Text; - System.Diagnostics.Trace.TraceInformation("Cue Text: {0}", cue.Text); - xamlTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; - } + xamlTextBlock.Text = cue.Text; + System.Diagnostics.Trace.TraceInformation("Cue Text: {0}", cue.Text); + xamlTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } else { - if (xamlTextBlock is not null) - { - xamlTextBlock.Text = string.Empty; - xamlTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - } + xamlTextBlock.Text = string.Empty; + xamlTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; } }); } @@ -151,7 +136,6 @@ public void StopSubtitleDisplay() return; } Dispatcher.Dispatch(() => mauiMediaElement.Children.Remove(xamlTextBlock)); - System.Diagnostics.Trace.TraceInformation("Removed text block from player parent"); } /// From 4e35194feb0ffec7575689a41cde1214dd94b190 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 7 May 2024 08:34:51 -0700 Subject: [PATCH 04/98] The primary change made to the code involves the removal of a logging functionality. Specifically, the application will no longer log information related to cue text to trace listeners following the elapse of a timer and the presence of a cue. This modification could impact how developers or users monitor and debug the application, as they will no longer receive automatic trace information about cue text events. ### Summary of Change: - **Removal of Cue Text Logging**: The specific line of code responsible for logging cue text information using `System.Diagnostics.Trace.TraceInformation` has been removed. This action disables the application's ability to output cue text information to trace listeners upon the occurrence of a cue event post-timer elapse. _Reference to Code Change: Removal of `System.Diagnostics.Trace.TraceInformation` for cue text logging._ --- .../Extensions/SubtitleExtensions.windows.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 70364568c8..3ac8ac838c 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -109,7 +109,6 @@ void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) if (cue is not null) { xamlTextBlock.Text = cue.Text; - System.Diagnostics.Trace.TraceInformation("Cue Text: {0}", cue.Text); xamlTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } else From a2b1ab1d4725c9bd6ea2d95c66cc8f584a9be89a Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 7 May 2024 08:39:48 -0700 Subject: [PATCH 05/98] The primary change involves making the `xamlTextBlock` field in the `SubtitleExtensions` class immutable by converting it from a nullable, modifiable field to a read-only nullable field. This ensures that once `xamlTextBlock` is assigned, it cannot be modified. To accommodate this change, the constructor of the `SubtitleExtensions` class has been updated to initialize `xamlTextBlock` with a new `TextBlock` instance, setting up default properties for this instance. Consequently, the conditional initialization of `xamlTextBlock` within the `LoadSubtitles` method has been removed, as it is no longer necessary or possible due to the field's read-only status. List of changes: 1. The `xamlTextBlock` field in the `SubtitleExtensions` class has been changed to a read-only nullable field to enforce immutability after its initial assignment. This modification ensures that the `xamlTextBlock` cannot be altered once it is set, enhancing the stability and predictability of how `xamlTextBlock` is used within the class. 2. In the `SubtitleExtensions` constructor, `xamlTextBlock` is now initialized with a new `TextBlock` instance. This instance is configured with default settings, including empty text, a specific margin, visibility, alignment, foreground color, font size, and text wrapping settings. This change ensures that `xamlTextBlock` is always in a ready-to-use state immediately after an instance of `SubtitleExtensions` is created. 3. The removal of conditional initialization of `xamlTextBlock` within the `LoadSubtitles` method. Since `xamlTextBlock` is initialized in the constructor and is read-only, there's no need or ability to reinitialize or modify it in the `LoadSubtitles` method, simplifying the method's logic and ensuring consistency in how `xamlTextBlock` is used. --- .../Extensions/SubtitleExtensions.windows.cs | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 3ac8ac838c..c69a128ae9 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -16,7 +16,7 @@ public partial class SubtitleExtensions : Grid, IDisposable List cues; bool disposedValue; IMediaElement? mediaElement; - Microsoft.UI.Xaml.Controls.TextBlock? xamlTextBlock; + readonly Microsoft.UI.Xaml.Controls.TextBlock? xamlTextBlock; MauiMediaElement? mauiMediaElement; /// @@ -27,6 +27,17 @@ public SubtitleExtensions() httpClient = new(); cues = []; MauiMediaElement.WindowsChanged += MauiMediaElement_WindowsChanged; + xamlTextBlock = new() + { + Text = string.Empty, + Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 10), + Visibility = Microsoft.UI.Xaml.Visibility.Collapsed, + HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, + VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Bottom, + Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White), + FontSize = 16, + TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap, + }; } void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) { @@ -67,17 +78,7 @@ public async Task LoadSubtitles(IMediaElement mediaElement, Microsoft.UI.Xaml.Co System.Diagnostics.Trace.TraceError(ex.Message); return; } - xamlTextBlock ??= new() - { - Text = string.Empty, - Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 10), - Visibility = Microsoft.UI.Xaml.Visibility.Collapsed, - HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, - VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Bottom, - Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White), - FontSize = 16, - TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap, - }; + cues = mediaElement.SubtitleUrl switch { var url when url.EndsWith("srt") => SrtParser.ParseSrtContent(vttContent), From df4d9eebe76ad662bd1b81d5718722fb304c08d5 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 7 May 2024 11:50:08 -0700 Subject: [PATCH 06/98] The code changes primarily focus on enhancing the subtitle functionality within a media playback context, introducing more robust and flexible handling of subtitles, improving thread safety, and ensuring UI updates are performed correctly. The most significant changes include the modification of the `SubtitleExtensions` constructor and methods, refactoring of the `LoadSubtitles` method, adjustments to subtitle display lifecycle methods (`StartSubtitleDisplay`, `StopSubtitleDisplay`, and `UpdateSubtitle`), and improvements to the `MediaManager` class for better immutability and thread safety. ### Important Changes Summary: 1. **Namespace and Class Imports:** Introduction of new imports to support enhanced subtitle functionality. 2. **SubtitleExtensions Constructor Modification:** Changes to how and when the subtitle label is initialized and added to the view, aiming for better flexibility and control. 3. **LoadSubtitles Method Refactoring:** Simplification and enhancement of subtitle content loading, including support for nullability and a more concise approach to parsing based on file extension. 4. **StartSubtitleDisplay Method Changes:** Ensures UI updates are performed on the main thread and updates subtitle label positioning logic to be more dynamic and responsive during playback. 5. **StopSubtitleDisplay and UpdateSubtitle Method Adjustments:** Adjustments to maintain the subtitle label in the view hierarchy, potentially for performance or state management reasons. 6. **CalculateSubtitleFrame Method Signature Change:** Decouples the frame calculation method from instance state, allowing for more flexible usage. 7. **MediaManager Class Adjustments:** Enforces immutability of the `CancellationTokenSource` to ensure predictable behavior and thread safety. ### Detailed Changes: - **Namespace and Class Imports:** Added to support new functionality in `SubtitleExtensions`. [Namespace and Class Imports] - **SubtitleExtensions Constructor Modification:** Improved subtitle frame calculation and modified when the subtitle label is added to the view. [SubtitleExtensions Constructor Modification] - **LoadSubtitles Method Refactoring:** Enhanced loading logic with nullability support and a more streamlined approach to parsing subtitle formats. [LoadSubtitles Method Refactoring] - **StartSubtitleDisplay Method Changes:** Added main thread requirement for UI updates and refined frame calculation during playback. [StartSubtitleDisplay Method Changes] - **StopSubtitleDisplay and UpdateSubtitle Method Adjustments:** Removed unnecessary removal of the subtitle label from the view hierarchy in `UpdateSubtitle`. [StopSubtitleDisplay and UpdateSubtitle Method Adjustments] - **CalculateSubtitleFrame Method Signature Change:** Made the method static and changed its signature for broader applicability. [CalculateSubtitleFrame Method Signature Change] - **MediaManager Class Adjustments:** Changed `subTitles` field to be read-only for better immutability and thread safety. [MediaManager Class Adjustments] --- .../Extensions/SubtitleExtensions.macios.cs | 51 +++++++------------ .../Views/MediaManager.macios.cs | 2 +- 2 files changed, 20 insertions(+), 33 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 556596e0f6..cf6c6a62ed 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -1,4 +1,6 @@ using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Core.Views; +using CommunityToolkit.Maui.Primitives; using CoreFoundation; using CoreGraphics; using CoreMedia; @@ -34,7 +36,7 @@ public SubtitleExtensions(PlatformMediaElement? player, UIViewController? player httpClient = new HttpClient(); subtitleLabel = new UILabel { - Frame = CalculateSubtitleFrame(), + Frame = CalculateSubtitleFrame(playerViewController), TextColor = UIColor.White, TextAlignment = UITextAlignment.Center, Font = UIFont.SystemFontOfSize(16), @@ -42,7 +44,6 @@ public SubtitleExtensions(PlatformMediaElement? player, UIViewController? player Lines = 0, AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleTopMargin | UIViewAutoresizing.FlexibleHeight | UIViewAutoresizing.FlexibleBottomMargin }; - playerViewController.View.AddSubview(subtitleLabel); } /// @@ -51,29 +52,23 @@ public SubtitleExtensions(PlatformMediaElement? player, UIViewController? player /// public async Task LoadSubtitles(IMediaElement mediaElement) { - string vttContent = string.Empty; + string? vttContent; try { - vttContent = await httpClient.GetStringAsync(mediaElement.SubtitleUrl).ConfigureAwait(true) ?? string.Empty; + vttContent = await httpClient.GetStringAsync(mediaElement.SubtitleUrl); } catch (Exception ex) { System.Diagnostics.Trace.TraceError(ex.Message); - } - - if (string.IsNullOrEmpty(vttContent)) - { - System.Diagnostics.Trace.TraceError("VTT Subtitle Content is Empty"); return; } - if (mediaElement.SubtitleUrl.EndsWith("srt")) - { - cues = SrtParser.ParseSrtContent(vttContent); - } - else if (mediaElement.SubtitleUrl.EndsWith("vtt")) + + cues = mediaElement.SubtitleUrl switch { - cues = VttParser.ParseVttContent(vttContent); - } + var url when url.EndsWith("srt") => SrtParser.ParseSrtContent(vttContent), + var url when url.EndsWith("vtt") => VttParser.ParseVttContent(vttContent), + _ => throw new NotSupportedException("Unsupported subtitle format"), + }; } /// @@ -81,19 +76,13 @@ public async Task LoadSubtitles(IMediaElement mediaElement) /// public void StartSubtitleDisplay() { - ArgumentNullException.ThrowIfNull(player); - if(cues?.Count == 0) - { - return; - } - if (playerObserver is not null) - { - StopSubtitleDisplay(); - } - playerObserver = player.AddPeriodicTimeObserver(CMTime.FromSeconds(1, 1), null, (time) => + DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); + playerObserver = player?.AddPeriodicTimeObserver(CMTime.FromSeconds(1, 1), null, (time) => { TimeSpan currentPlaybackTime = TimeSpan.FromSeconds(time.Seconds); ArgumentNullException.ThrowIfNull(subtitleLabel); + subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); + playerViewController.View?.AddSubview(subtitleLabel); DispatchQueue.MainQueue.DispatchAsync(() => UpdateSubtitle(currentPlaybackTime)); }); } @@ -116,7 +105,6 @@ void UpdateSubtitle(TimeSpan currentPlaybackTime) { ArgumentNullException.ThrowIfNull(subtitleLabel); ArgumentNullException.ThrowIfNull(playerViewController.View); - subtitleLabel.RemoveFromSuperview(); foreach (var cue in cues) { if (currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime) @@ -131,13 +119,12 @@ void UpdateSubtitle(TimeSpan currentPlaybackTime) subtitleLabel.BackgroundColor = UIColor.FromRGBA(0, 0, 0, 0); } } - subtitleLabel.Frame = CalculateSubtitleFrame(); - playerViewController.View.AddSubview(subtitleLabel); } - CGRect CalculateSubtitleFrame() + + static CGRect CalculateSubtitleFrame(UIViewController uIViewController) { - ArgumentNullException.ThrowIfNull(playerViewController?.View?.Bounds); - return new CGRect(0, playerViewController.View.Bounds.Height - 60, playerViewController.View.Bounds.Width, 50); + ArgumentNullException.ThrowIfNull(uIViewController?.View?.Bounds); + return new CGRect(0, uIViewController.View.Bounds.Height - 60, uIViewController.View.Bounds.Width, 50); } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index 18e2d7028d..8b6efd7857 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -13,7 +13,7 @@ namespace CommunityToolkit.Maui.Core.Views; public partial class MediaManager : IDisposable { SubtitleExtensions? subtitleExtensions; - CancellationTokenSource subTitles = new(); + readonly CancellationTokenSource subTitles = new(); Task? startSubtitles; // Media would still start playing when Speed was set although ShouldAutoPlay=False From 3ec12b0d04872f3dfb5f77c9f94d613944c85f64 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 7 May 2024 11:54:16 -0700 Subject: [PATCH 07/98] The primary change involves updating the null check syntax for the `currentCue` variable in a C# codebase. This modification enhances readability and aligns with modern C# coding practices by replacing the traditional inequality operator (`!=`) with the more contemporary `is not` pattern for null checks. ### Change Summary: - Updated the null check for `currentCue` from using `!=` to `is not` to improve code readability and adhere to modern C# standards. Reference to the code change: - The code change modifies the null check of `currentCue` from using `!=` to using the `is not` pattern, transitioning to a more modern and readable syntax for checking non-null values in C#. --- src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index e67763adf3..f942471371 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -31,7 +31,7 @@ public static List ParseSrtContent(string srtContent) var match = timecodePattern.Match(line); if (match.Success) { - if (currentCue != null) + if (currentCue is not null) { cues.Add(currentCue); } From 64ac62b4e2755010323de1659492d77d7308ceb3 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Wed, 8 May 2024 08:45:27 -0700 Subject: [PATCH 08/98] The primary change involves the removal of code that added a subtitle label as a subview to the playerViewController's view within a method responsible for starting subtitle display. This modification suggests a change in how subtitles are managed or displayed in the application, potentially impacting the user interface or the way subtitles are rendered during video playback. ### Summary of Change: - **Removal of Subtitle Label Addition**: The specific line of code that added the subtitle label to the playerViewController's view has been removed from the `StartSubtitleDisplay` method. This indicates a significant change in the approach to displaying subtitles, possibly due to a redesign of the subtitle rendering system or an optimization of the existing process. ### Detailed Change: - In the file `SubtitleExtensions.macios.cs`, within the method `StartSubtitleDisplay`, the line `playerViewController.View?.AddSubview(subtitleLabel);` was removed. This action signifies that the subtitle label is no longer directly added to the playerViewController's view as part of initiating subtitle display. This could reflect a shift towards a different method of subtitle integration or an update to the user interface design that necessitates this change. --- .../Extensions/SubtitleExtensions.macios.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index cf6c6a62ed..ebd81cf360 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -82,7 +82,6 @@ public void StartSubtitleDisplay() TimeSpan currentPlaybackTime = TimeSpan.FromSeconds(time.Seconds); ArgumentNullException.ThrowIfNull(subtitleLabel); subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); - playerViewController.View?.AddSubview(subtitleLabel); DispatchQueue.MainQueue.DispatchAsync(() => UpdateSubtitle(currentPlaybackTime)); }); } From 0cfbd6fc945dabd61c66b6d997153cf6f09b6645 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Wed, 8 May 2024 12:46:49 -0700 Subject: [PATCH 09/98] The primary change made to the code involves adjusting the font size of a text block element (`xamlTextBlock`) based on a condition. This condition checks the state of a variable named `isFullScreen`. If `isFullScreen` is true, indicating that a full-screen mode is active, the font size of the text block is increased to 24. Conversely, if `isFullScreen` is false, suggesting that the full-screen mode is not active, the font size is set to a smaller size of 16. This modification ensures that the text size is dynamically adjusted to enhance readability and user experience depending on the display mode. ### List of Changes: - Conditional setting of `xamlTextBlock` font size based on the `isFullScreen` variable. The font size is set to 24 when `isFullScreen` is true and 16 when it is false. --- .../Extensions/SubtitleExtensions.windows.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index c69a128ae9..ba48dc6f8a 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -109,6 +109,7 @@ void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { if (cue is not null) { + xamlTextBlock.FontSize = isFullScreen ? 24 : 16; xamlTextBlock.Text = cue.Text; xamlTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } From 27d2af435a90d3d06117a96cbcc028c1d4c097d5 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Wed, 8 May 2024 16:14:55 -0700 Subject: [PATCH 10/98] The modifications to the `SubtitleExtensions.android.cs` file within the `CommunityToolkit.Maui.Extensions` namespace primarily focus on refactoring and enhancing the subtitle display capabilities for a video player. These changes aim to improve clarity, maintainability, and performance. The most significant updates include the reorganization of field declarations and initializations, enhancements to the constructor for better subtitle styling, adjustments for full-screen layout changes, and optimizations in the subtitle display start and stop methods. Additionally, general code cleanup has been performed to remove unnecessary code and simplify logic. ### Important Changes: 1. **Initialization and Declaration Changes:** - Fields such as `httpClient`, `textBlock`, `timer`, `cues`, and `mediaElement` have been reorganized for better clarity and maintainability. - `textBlock` is now initialized within the constructor with predefined properties like background color, padding, text color, and visibility. 2. **Constructor Enhancements:** - The constructor now includes detailed initialization of `textBlock`, setting up text size, alignment, scroll bar settings, and visibility state to ensure it's ready for displaying subtitles with the desired styling. 3. **FullScreen Layout Adjustments:** - Code has been simplified for handling full-screen layout changes, focusing on efficiently adding and removing subtitle display views and adjusting layout parameters for correct positioning. 4. **Subtitle Display Start and Stop Methods:** - `StartSubtitleDisplay` method streamlined by removing redundant `textBlock` initialization and adding a null check. - `StopSubtitleDisplay` method now clears the displayed subtitles by setting `textBlock` text to an empty string. 5. **General Code Cleanup:** - Unnecessary dispatcher invocations removed, conditional checks simplified, and redundant code blocks eliminated, enhancing readability and performance. These changes collectively contribute to a more efficient, maintainable, and user-friendly subtitle display functionality within the video player component. --- .../Extensions/SubtitleExtensions.android.cs | 90 ++++++++++--------- 1 file changed, 46 insertions(+), 44 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 8266aa41e1..742c948df1 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -13,18 +13,22 @@ namespace CommunityToolkit.Maui.Extensions; /// public partial class SubtitleExtensions : CoordinatorLayout { - readonly HttpClient httpClient; - System.Timers.Timer? timer; - List cues; bool disposedValue; bool isFullScreen = false; - IMediaElement? mediaElement; - TextView? textBlock; + + readonly HttpClient httpClient; + readonly TextView textBlock; readonly StyledPlayerView styledPlayerView; readonly IDispatcher dispatcher; + + System.Timers.Timer? timer; + List cues; + + IMediaElement? mediaElement; + RelativeLayout? relativeLayout; RelativeLayout? fullScreenLayout; - + /// /// The SubtitleExtensions class provides a way to display subtitles on a video player. /// @@ -36,6 +40,19 @@ public SubtitleExtensions(Context context, StyledPlayerView styledPlayerView, ID this.dispatcher = dispatcher; this.styledPlayerView = styledPlayerView; cues = []; + textBlock = new(Platform.AppContext) + { + Text = string.Empty, + HorizontalScrollBarEnabled = false, + VerticalScrollBarEnabled = false, + TextSize = 16, + TextAlignment = Android.Views.TextAlignment.Center, + Visibility = Android.Views.ViewStates.Gone, + }; + textBlock.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); + textBlock.SetPadding(10, 10, 10, 10); + textBlock.SetTextColor(Android.Graphics.Color.White); + MauiMediaElement.WindowsChanged += MauiMediaElement_WindowsChanged; } @@ -45,40 +62,36 @@ void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) { return; } - if(isFullScreen) + if (isFullScreen) { relativeLayout = new(Platform.AppContext) { LayoutParameters = new CoordinatorLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent) { - Gravity = (int)GravityFlags.Bottom + Gravity = (int)GravityFlags.Bottom, + BottomMargin = 10, } }; - dispatcher.Dispatch(() => - { - viewGroup.RemoveView(fullScreenLayout); - fullScreenLayout?.RemoveView(textBlock); - relativeLayout?.AddView(textBlock); - parent.AddView(relativeLayout); - }); + viewGroup.RemoveView(fullScreenLayout); + fullScreenLayout?.RemoveView(textBlock); + relativeLayout.AddView(textBlock); + parent.AddView(relativeLayout); isFullScreen = false; return; } - - dispatcher.Dispatch(() => + parent.RemoveView(relativeLayout); + relativeLayout?.RemoveView(textBlock); + fullScreenLayout = new(Platform.AppContext) { - parent.RemoveView(relativeLayout); - relativeLayout?.RemoveView(textBlock); - fullScreenLayout = new(Platform.AppContext) + LayoutParameters = new CoordinatorLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent) { - LayoutParameters = new CoordinatorLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent) - { - Gravity = (int)GravityFlags.Bottom - } - }; - fullScreenLayout.AddView(textBlock); - viewGroup.AddView(fullScreenLayout); - }); + Gravity = (int)GravityFlags.Bottom, + BottomMargin = 10, + } + }; + fullScreenLayout.AddView(textBlock); + + viewGroup.AddView(fullScreenLayout); isFullScreen = true; } @@ -112,28 +125,16 @@ var url when url.EndsWith("vtt") => VttParser.ParseVttContent(vttContent), /// public void StartSubtitleDisplay() { - textBlock = new(Platform.AppContext) - { - Text = string.Empty, - HorizontalScrollBarEnabled = false, - VerticalScrollBarEnabled = false, - TextSize = 16, - TextAlignment = Android.Views.TextAlignment.Center, - Visibility = Android.Views.ViewStates.Gone, - }; - - textBlock.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); - textBlock.SetPadding(10, 10, 10, 10); - textBlock.SetTextColor(Android.Graphics.Color.White); - if (styledPlayerView.Parent is not ViewGroup parent) + if(textBlock is null || styledPlayerView.Parent is not ViewGroup parent) { return; } - relativeLayout = new(Platform.AppContext) + relativeLayout = new RelativeLayout(Platform.AppContext) { LayoutParameters = new CoordinatorLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent) { - Gravity = (int)GravityFlags.Bottom + Gravity = (int)GravityFlags.Bottom, + BottomMargin = 10, } }; relativeLayout.AddView(textBlock); @@ -183,6 +184,7 @@ public void StopSubtitleDisplay() relativeLayout?.RemoveView(textBlock); }); } + textBlock.Text = string.Empty; timer.Stop(); timer.Elapsed -= Timer_Elapsed; } From 8134fa5cf98a6682bc37955f3551b9acc6fb3f21 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Wed, 8 May 2024 18:24:54 -0700 Subject: [PATCH 11/98] The most important changes revolve around the introduction of subtitle customization features across various platforms (Android, iOS/macOS, and Windows) for media playback. These changes allow users to customize the font and font size of subtitles, enhancing the accessibility and user experience of media elements. The changes include the addition of `SubtitleFont` and `SubtitleFontSize` properties to the `IMediaElement` interface and `MediaElement` class, adjustments in subtitle display logic on different platforms to utilize these new properties, and general code cleanup and refactoring to support these enhancements. ### List of Changes: 1. **MediaElement Subtitle Customization** - Introduced `SubtitleFont` and `SubtitleFontSize` properties to `IMediaElement` and `MediaElement` for subtitle font and size customization. 2. **Android Subtitle Display Adjustments** - Updated subtitle display logic in `SubtitleExtensions.android.cs` to use the new customization properties, ensuring subtitles are displayed with the specified font and size on Android devices. 3. **iOS/macOS Subtitle Display Adjustments** - Added logic in `SubtitleExtensions.macios.cs` to apply `SubtitleFont` and `SubtitleFontSize` for subtitle display, enabling custom font and size on iOS and macOS. 4. **Windows Subtitle Display Adjustments** - Adjusted subtitle display in `SubtitleExtensions.windows.cs` to utilize the new properties for subtitle customization on Windows platforms. 5. **General Code Cleanup and Refactoring** - Removed unused `using` directives in `SubtitleExtensions.macios.cs` and made code adjustments for consistency with the new subtitle customization features. 6. **Subtitle Loading and Display Enhancements** - Enhanced subtitle loading and display across Android, iOS/macOS, and Windows to support the new customization options, ensuring correct display according to specified settings. 7. **API and Property Changes** - Added new properties (`SubtitleFont`, `SubtitleFontSize`) for subtitle customization in the `IMediaElement` interface and `MediaElement` class, and adjusted existing methods and properties to integrate these features seamlessly with existing media playback functionality. --- .../MediaElement/MediaElementPage.xaml.cs | 2 ++ .../Extensions/SubtitleExtensions.android.cs | 3 +- .../Extensions/SubtitleExtensions.macios.cs | 6 ++-- .../Extensions/SubtitleExtensions.windows.cs | 4 +-- .../Interfaces/IMediaElement.cs | 18 ++++++++---- .../MediaElement.shared.cs | 28 +++++++++++++++++++ 6 files changed, 51 insertions(+), 10 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 9608ff9f00..c2050aca53 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -197,6 +197,8 @@ async void ChangeSourceClicked(Object sender, EventArgs e) } return; case loadSubTitles: + MediaElement.SubtitleFont = "monospace"; + MediaElement.SubtitleFontSize = 16; MediaElement.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); return; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 742c948df1..ae16e2fa15 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -45,7 +45,6 @@ public SubtitleExtensions(Context context, StyledPlayerView styledPlayerView, ID Text = string.Empty, HorizontalScrollBarEnabled = false, VerticalScrollBarEnabled = false, - TextSize = 16, TextAlignment = Android.Views.TextAlignment.Center, Visibility = Android.Views.ViewStates.Gone, }; @@ -156,7 +155,9 @@ void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { if (cue is not null) { + textBlock.FontFeatureSettings = !string.IsNullOrEmpty(mediaElement.SubtitleFont) ? mediaElement.SubtitleFont : default; textBlock.Text = cue.Text; + textBlock.TextSize = (float)mediaElement.SubtitleFontSize; textBlock.Visibility = Android.Views.ViewStates.Visible; } else diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index ebd81cf360..f63760caea 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -1,6 +1,4 @@ using CommunityToolkit.Maui.Core; -using CommunityToolkit.Maui.Core.Views; -using CommunityToolkit.Maui.Primitives; using CoreFoundation; using CoreGraphics; using CoreMedia; @@ -20,6 +18,7 @@ public partial class SubtitleExtensions : UIViewController readonly UILabel subtitleLabel; List cues; NSObject? playerObserver; + IMediaElement? mediaElement; /// /// The SubtitleExtensions class provides a way to display subtitles on a video player. @@ -52,6 +51,7 @@ public SubtitleExtensions(PlatformMediaElement? player, UIViewController? player /// public async Task LoadSubtitles(IMediaElement mediaElement) { + this.mediaElement = mediaElement; string? vttContent; try { @@ -104,11 +104,13 @@ void UpdateSubtitle(TimeSpan currentPlaybackTime) { ArgumentNullException.ThrowIfNull(subtitleLabel); ArgumentNullException.ThrowIfNull(playerViewController.View); + ArgumentNullException.ThrowIfNull(mediaElement); foreach (var cue in cues) { if (currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime) { subtitleLabel.Text = cue.Text; + subtitleLabel.Font = !string.IsNullOrEmpty(mediaElement.SubtitleFont) ? UIFont.FromName(mediaElement.SubtitleFont, (int)mediaElement.SubtitleFontSize) : UIFont.SystemFontOfSize((int)mediaElement.SubtitleFontSize); subtitleLabel.BackgroundColor = UIColor.FromRGBA(0, 0, 0, 128); break; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index ba48dc6f8a..16c5456010 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -35,7 +35,6 @@ public SubtitleExtensions() HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Bottom, Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White), - FontSize = 16, TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap, }; } @@ -109,7 +108,8 @@ void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { if (cue is not null) { - xamlTextBlock.FontSize = isFullScreen ? 24 : 16; + xamlTextBlock.FontFamily = !string.IsNullOrEmpty(mediaElement.SubtitleFont) ? new Microsoft.UI.Xaml.Media.FontFamily(mediaElement.SubtitleFont) : default; + xamlTextBlock.FontSize = isFullScreen ? (mediaElement.SubtitleFontSize + 8.0) : mediaElement.SubtitleFontSize; xamlTextBlock.Text = cue.Text; xamlTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs index f0d5f0706c..f5a7754907 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs @@ -68,6 +68,15 @@ public interface IMediaElement : IView, IAsynchronousMediaElementHandler /// bool ShouldShowPlaybackControls { get; set; } + /// + /// Gets or sets the font to use for the subtitle text. + /// + string SubtitleFont { get; set; } + /// + /// Gets or sets the URL of the subtitle file to display. + /// + string SubtitleUrl { get; set; } + /// /// Gets or sets the source of the media to play. /// @@ -80,17 +89,16 @@ public interface IMediaElement : IView, IAsynchronousMediaElementHandler /// Anything more than 1 is faster speed, anything less than 1 is slower speed. double Speed { get; set; } + /// + /// Gets or sets the font size of the subtitle text. + /// + double SubtitleFontSize { get; set; } /// /// Gets or sets the volume of the audio for the media. /// /// A value of 1 means full volume, 0 is silence. double Volume { get; set; } - /// - /// Gets or sets the URL of the subtitle file to display. - /// - string SubtitleUrl { get; set; } - /// /// Occurs when changed. /// diff --git a/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs b/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs index 352859dc50..df5ab6981c 100644 --- a/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs @@ -74,6 +74,16 @@ public class MediaElement : View, IMediaElement, IDisposable BindableProperty.Create(nameof(Source), typeof(MediaSource), typeof(MediaElement), propertyChanging: OnSourcePropertyChanging, propertyChanged: OnSourcePropertyChanged); + /// + /// Backing store for the property. + /// + public static readonly BindableProperty SubtitleFontProperty = BindableProperty.Create(nameof(SubtitleFont), typeof(string), typeof(MediaElement), string.Empty); + + /// + /// Backing store for the property. + /// + public static readonly BindableProperty SubtitleFontSizeProperty = BindableProperty.Create(nameof(SubtitleFontSize), typeof(double), typeof(MediaElement), 16.0); + /// /// Backing store for the property. /// @@ -279,6 +289,24 @@ public string SubtitleUrl set => SetValue(SubtitleProperty, value); } + /// + /// Gets or sets the font to use for the subtitle text. + /// + public string SubtitleFont + { + get => (string)GetValue(SubtitleFontProperty); + set => SetValue(SubtitleFontProperty, value); + } + + /// + /// Gets or sets the font size of the subtitle text. + /// + public double SubtitleFontSize + { + get => (double)GetValue(SubtitleFontSizeProperty); + set => SetValue(SubtitleFontSizeProperty, value); + } + /// /// Gets or sets the volume of the audio for the media. /// From dc3b1508177fabebd609d572279a8b4be32f7d8a Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Wed, 8 May 2024 18:31:49 -0700 Subject: [PATCH 12/98] Fix spacing issue --- src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs b/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs index df5ab6981c..84acd372cb 100644 --- a/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs @@ -88,6 +88,7 @@ public class MediaElement : View, IMediaElement, IDisposable /// Backing store for the property. /// public static readonly BindableProperty SubtitleProperty = BindableProperty.Create(nameof(SubtitleUrl), typeof(string), typeof(MediaElement), string.Empty); + /// /// Backing store for the property. /// From 4de0281d24b2cb1ae27bddd46ffdb2e0eb2ac3c5 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 9 May 2024 16:44:18 -0700 Subject: [PATCH 13/98] The most important changes revolve around enhancing subtitle functionality and handling window state changes more dynamically across different platforms, particularly focusing on iOS and MacCatalyst. These changes include adjustments to subtitle font settings based on the operating system, improvements in subtitle display and positioning, and better handling of window state changes such as entering or exiting full screen. ### Summary of Key Changes: 1. **Subtitle Font Setting Logic Enhancement**: The logic for setting subtitle fonts has been adjusted to cater to different platforms, setting "Avenir-Book" for iOS and MacCatalyst, and "monospace" for others within the `ChangeSourceClicked` method in `MediaElementPage.xaml.cs`. 2. **Dynamic Subtitle Display Handling**: The `SubtitleExtensions` class has been modified to handle nullable UILabels for subtitles and introduced dynamic management of subtitle display and font settings through a nullable `UIViewController` and `UIFont`. 3. **Window State Change Event Handling**: A new event, `WindowsChanged`, has been introduced and is subscribed to in the `SubtitleExtensions` class to adjust subtitle properties and positioning dynamically based on window state changes. 4. **Enhanced Subtitle Functionality**: Methods within `SubtitleExtensions` such as `LoadSubtitles`, `StartSubtitleDisplay`, `StopSubtitleDisplay`, and `UpdateSubtitle` have been updated or modified to improve subtitle loading, display, positioning, and updating during playback. 5. **Full-Screen Presentation Event Handling**: In `MediaManager.macios.cs`, a delegate for the `AVPlayerViewController` has been set to handle full-screen presentation events, and a new class `MediaManagerDelegate` has been introduced to manage these events, allowing for a more responsive UI during window state changes. ### Detailed Changes: - **MediaElementPage.xaml.cs Modifications**: Adjusted subtitle font setting logic for different platforms within the `ChangeSourceClicked` method. - **SubtitleExtensions.macios.cs Additions and Modifications**: Enhanced functionality with additional `using` directives, modified the `SubtitleExtensions` class for dynamic subtitle management, and introduced handling for the `WindowsChanged` event. - **MediaManager.macios.cs Modifications**: Updated to set a delegate for handling full-screen presentation events and introduced `MediaManagerDelegate` for managing window state changes. These changes collectively aim to improve the user experience by ensuring subtitles are displayed correctly and responsively across different platforms and during various window state changes, such as entering or exiting full screen. --- .../MediaElement/MediaElementPage.xaml.cs | 9 ++- .../Extensions/SubtitleExtensions.macios.cs | 61 ++++++++++++++++--- .../Views/MediaManager.macios.cs | 29 ++++++++- 3 files changed, 88 insertions(+), 11 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 c2050aca53..510cea3747 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -197,7 +197,14 @@ async void ChangeSourceClicked(Object sender, EventArgs e) } return; case loadSubTitles: - MediaElement.SubtitleFont = "monospace"; + if(OperatingSystem.IsIOS() || OperatingSystem.IsMacCatalyst()) + { + MediaElement.SubtitleFont = "Avenir-Book"; + } + else + { + MediaElement.SubtitleFont = "monospace"; + } MediaElement.SubtitleFontSize = 16; MediaElement.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index f63760caea..c55a2eb1b0 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -1,4 +1,6 @@ using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Core.Views; +using CommunityToolkit.Maui.Primitives; using CoreFoundation; using CoreGraphics; using CoreMedia; @@ -14,11 +16,13 @@ public partial class SubtitleExtensions : UIViewController { readonly HttpClient httpClient; readonly UIViewController playerViewController; - readonly PlatformMediaElement? player; - readonly UILabel subtitleLabel; + readonly PlatformMediaElement player; + UILabel? subtitleLabel; List cues; NSObject? playerObserver; IMediaElement? mediaElement; + UIViewController? viewController; + UIFont? font; /// /// The SubtitleExtensions class provides a way to display subtitles on a video player. @@ -31,6 +35,7 @@ public SubtitleExtensions(PlatformMediaElement? player, UIViewController? player ArgumentNullException.ThrowIfNull(playerViewController?.View?.Bounds); this.playerViewController = playerViewController; this.player = player; + MediaManagerDelegate.WindowsChanged += MauiMediaElement_WindowsChanged; cues = []; httpClient = new HttpClient(); subtitleLabel = new UILabel @@ -45,13 +50,47 @@ public SubtitleExtensions(PlatformMediaElement? player, UIViewController? player }; } + void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) + { + ArgumentNullException.ThrowIfNull(font); + if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl) || e.data is null) + { + return; + } + subtitleLabel = new UILabel + { + TextColor = UIColor.White, + TextAlignment = UITextAlignment.Center, + Font = font, + Text = "", + Lines = 0, + AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleTopMargin | UIViewAutoresizing.FlexibleHeight | UIViewAutoresizing.FlexibleBottomMargin + }; + switch (e.data.Equals(true)) + { + case true: + viewController = WindowStateManager.Default.GetCurrentUIViewController(); + ArgumentNullException.ThrowIfNull(viewController?.View); + subtitleLabel.Frame = CalculateSubtitleFrame(viewController); + viewController.View.AddSubview(subtitleLabel); + break; + case false: + subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); + viewController = null; + break; + } + } + /// /// Loads the subtitles from the provided URL. /// /// public async Task LoadSubtitles(IMediaElement mediaElement) { + ArgumentNullException.ThrowIfNull(subtitleLabel); this.mediaElement = mediaElement; + font = UIFont.FromName(mediaElement.SubtitleFont, (float)mediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize((float)mediaElement.SubtitleFontSize); + subtitleLabel.Font = font; string? vttContent; try { @@ -76,12 +115,20 @@ var url when url.EndsWith("vtt") => VttParser.ParseVttContent(vttContent), /// public void StartSubtitleDisplay() { - DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); + ArgumentNullException.ThrowIfNull(subtitleLabel); playerObserver = player?.AddPeriodicTimeObserver(CMTime.FromSeconds(1, 1), null, (time) => { TimeSpan currentPlaybackTime = TimeSpan.FromSeconds(time.Seconds); - ArgumentNullException.ThrowIfNull(subtitleLabel); - subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); + if (viewController is not null) + { + DispatchQueue.MainQueue.DispatchAsync(() => viewController?.View?.AddSubview(subtitleLabel)); + subtitleLabel.Frame = CalculateSubtitleFrame(viewController); + } + else + { + DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); + subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); + } DispatchQueue.MainQueue.DispatchAsync(() => UpdateSubtitle(currentPlaybackTime)); }); } @@ -92,6 +139,7 @@ public void StartSubtitleDisplay() public void StopSubtitleDisplay() { ArgumentNullException.ThrowIfNull(player); + ArgumentNullException.ThrowIfNull(subtitleLabel); if (playerObserver is not null) { player.RemoveTimeObserver(playerObserver); @@ -103,14 +151,11 @@ public void StopSubtitleDisplay() void UpdateSubtitle(TimeSpan currentPlaybackTime) { ArgumentNullException.ThrowIfNull(subtitleLabel); - ArgumentNullException.ThrowIfNull(playerViewController.View); - ArgumentNullException.ThrowIfNull(mediaElement); foreach (var cue in cues) { if (currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime) { subtitleLabel.Text = cue.Text; - subtitleLabel.Font = !string.IsNullOrEmpty(mediaElement.SubtitleFont) ? UIFont.FromName(mediaElement.SubtitleFont, (int)mediaElement.SubtitleFontSize) : UIFont.SystemFontOfSize((int)mediaElement.SubtitleFontSize); subtitleLabel.BackgroundColor = UIColor.FromRGBA(0, 0, 0, 128); break; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index 8b6efd7857..1a49e4d3d8 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -2,11 +2,13 @@ using AVKit; using CommunityToolkit.Maui.Core.Primitives; using CommunityToolkit.Maui.Extensions; +using CommunityToolkit.Maui.Primitives; using CommunityToolkit.Maui.Views; using CoreFoundation; using CoreMedia; using Foundation; using Microsoft.Extensions.Logging; +using UIKit; namespace CommunityToolkit.Maui.Core.Views; @@ -95,7 +97,8 @@ public partial class MediaManager : IDisposable Player = new(); PlayerViewController = new() { - Player = Player + Player = Player, + Delegate = new MediaManagerDelegate() }; // Pre-initialize Volume and Muted properties to the player object @@ -109,7 +112,7 @@ public partial class MediaManager : IDisposable AddStatusObservers(); AddPlayedToEndObserver(); AddErrorObservers(); - + return (Player, PlayerViewController); } @@ -609,4 +612,26 @@ void RateChanged(object? sender, NSNotificationEventArgs args) MediaElement.Speed = Player.Rate; } } +} +sealed class MediaManagerDelegate : AVPlayerViewControllerDelegate +{ + /// + /// Handles the event when the windows change. + /// + public static event EventHandler? WindowsChanged; + public override void WillBeginFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) + { + OnWindowsChanged(new WindowsEventArgs(true)); + } + public override void WillEndFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) + { + OnWindowsChanged(new WindowsEventArgs(false)); + } + /// + /// A method that raises the WindowsChanged event. + /// + void OnWindowsChanged(WindowsEventArgs e) + { + WindowsChanged?.Invoke(null, e); + } } \ No newline at end of file From ef34b04c90184a9574db8f69762f38a5473f0d8b Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 10 May 2024 14:07:48 -0700 Subject: [PATCH 14/98] The code changes primarily focus on simplifying the subtitle display mechanism in an Android context, enhancing maintainability, readability, and potentially performance. Key adjustments include the removal of the `WidthRequest` property from `MediaElementPage.xaml` to streamline responsive design, the addition of a dependency comment in `SubtitleExtensions.android.cs`, and significant refactoring within the `SubtitleExtensions` class to simplify layout management and subtitle display methods. ### Most Important Changes: 1. **Simplification of Responsive Design in MediaElementPage.xaml**: The removal of the `WidthRequest` property for different idioms indicates a move towards a simpler or refactored responsive design approach. 2. **Dependency Annotation in SubtitleExtensions.android.cs**: A comment was added to highlight a dependency on a specific pull request, suggesting that the changes are contingent on the acceptance of related modifications. 3. **Refactoring of SubtitleExtensions Class**: This includes reorganization and simplification of class fields, introduction of a new `textBlockLayout` for flexible subtitle display, and significant method adjustments to streamline fullscreen toggling and subtitle display management. ### Detailed List of Changes: - **MediaElementPage.xaml Adjustments**: Removal of `WidthRequest` property for `MediaElement` to simplify responsive design. [MediaElementPage.xaml] - **SubtitleExtensions.android.cs Namespace and Using Directives**: Added a dependency comment and adjusted namespace usage for better readability. [SubtitleExtensions.android.cs] - **SubtitleExtensions Class Field Modifications**: Reorganized fields, introduced `textBlockLayout`, and removed complex layout fields like `relativeLayout` and `fullScreenLayout` to simplify layout management. [SubtitleExtensions] - **Constructor and Method Adjustments in SubtitleExtensions**: Added initialization for `textBlockLayout`, refactored `MauiMediaElement_WindowsChanged` method for better readability, and simplified `StartSubtitleDisplay` and `StopSubtitleDisplay` methods by directly managing the `textBlock`. [SubtitleExtensions] These changes collectively aim to enhance the subtitle functionality within an Android application by making the codebase more manageable and the user interface more efficient. --- .../Views/MediaElement/MediaElementPage.xaml | 1 - .../Extensions/SubtitleExtensions.android.cs | 81 +++++++------------ 2 files changed, 28 insertions(+), 54 deletions(-) diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml index 5fb5526112..02e3cf96f4 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml @@ -29,7 +29,6 @@ cues; - IMediaElement? mediaElement; - - RelativeLayout? relativeLayout; - RelativeLayout? fullScreenLayout; - + List cues; + System.Timers.Timer? timer; + /// /// The SubtitleExtensions class provides a way to display subtitles on a video player. /// @@ -40,6 +38,10 @@ public SubtitleExtensions(Context context, StyledPlayerView styledPlayerView, ID this.dispatcher = dispatcher; this.styledPlayerView = styledPlayerView; cues = []; + + textBlockLayout = new RelativeLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent); + textBlockLayout.AddRule(LayoutRules.AlignParentBottom); + textBlockLayout.AddRule(LayoutRules.CenterHorizontal); textBlock = new(Platform.AppContext) { Text = string.Empty, @@ -47,11 +49,12 @@ public SubtitleExtensions(Context context, StyledPlayerView styledPlayerView, ID VerticalScrollBarEnabled = false, TextAlignment = Android.Views.TextAlignment.Center, Visibility = Android.Views.ViewStates.Gone, + LayoutParameters = textBlockLayout }; textBlock.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); textBlock.SetPadding(10, 10, 10, 10); textBlock.SetTextColor(Android.Graphics.Color.White); - + MauiMediaElement.WindowsChanged += MauiMediaElement_WindowsChanged; } @@ -61,39 +64,21 @@ void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) { return; } - if (isFullScreen) + switch(isFullScreen) { - relativeLayout = new(Platform.AppContext) - { - LayoutParameters = new CoordinatorLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent) - { - Gravity = (int)GravityFlags.Bottom, - BottomMargin = 10, - } - }; - viewGroup.RemoveView(fullScreenLayout); - fullScreenLayout?.RemoveView(textBlock); - relativeLayout.AddView(textBlock); - parent.AddView(relativeLayout); - isFullScreen = false; - return; + case true: + viewGroup.RemoveView(textBlock); + parent.AddView(textBlock); + isFullScreen = false; + break; + case false: + parent.RemoveView(textBlock); + viewGroup.AddView(textBlock); + isFullScreen = true; + break; } - parent.RemoveView(relativeLayout); - relativeLayout?.RemoveView(textBlock); - fullScreenLayout = new(Platform.AppContext) - { - LayoutParameters = new CoordinatorLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent) - { - Gravity = (int)GravityFlags.Bottom, - BottomMargin = 10, - } - }; - fullScreenLayout.AddView(textBlock); - - viewGroup.AddView(fullScreenLayout); - isFullScreen = true; } - + /// /// Loads the subtitles from the provided URL. /// @@ -128,16 +113,7 @@ public void StartSubtitleDisplay() { return; } - relativeLayout = new RelativeLayout(Platform.AppContext) - { - LayoutParameters = new CoordinatorLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent) - { - Gravity = (int)GravityFlags.Bottom, - BottomMargin = 10, - } - }; - relativeLayout.AddView(textBlock); - dispatcher.Dispatch(() => parent.AddView(relativeLayout)); + dispatcher.Dispatch(() => parent.AddView(textBlock)); timer = new System.Timers.Timer(1000); timer.Elapsed += Timer_Elapsed; timer.Start(); @@ -181,8 +157,7 @@ public void StopSubtitleDisplay() { dispatcher.Dispatch(() => { - parent.RemoveView(relativeLayout); - relativeLayout?.RemoveView(textBlock); + parent.RemoveView(textBlock); }); } textBlock.Text = string.Empty; From 48d357be5501f1fdacac7eeb89300861a7b830cc Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 10 May 2024 18:46:12 -0700 Subject: [PATCH 15/98] Change padding to relative --- .../Extensions/SubtitleExtensions.android.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index c95abbddea..844bed23cf 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -38,7 +38,7 @@ public SubtitleExtensions(Context context, StyledPlayerView styledPlayerView, ID this.dispatcher = dispatcher; this.styledPlayerView = styledPlayerView; cues = []; - + textBlockLayout = new RelativeLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent); textBlockLayout.AddRule(LayoutRules.AlignParentBottom); textBlockLayout.AddRule(LayoutRules.CenterHorizontal); @@ -52,7 +52,7 @@ public SubtitleExtensions(Context context, StyledPlayerView styledPlayerView, ID LayoutParameters = textBlockLayout }; textBlock.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); - textBlock.SetPadding(10, 10, 10, 10); + textBlock.SetPaddingRelative(10, 10, 10, 10); textBlock.SetTextColor(Android.Graphics.Color.White); MauiMediaElement.WindowsChanged += MauiMediaElement_WindowsChanged; From 96f0d81c4251d547df447fbc38186d7965930b7c Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 6 Jun 2024 15:16:03 -0700 Subject: [PATCH 16/98] Merge Main into AddSubtitleSupport --- .../Views/MediaManager.android.cs | 1 + .../Views/MediaManager.macios.cs | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index e2132b7caf..ad4e505d67 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -202,6 +202,7 @@ or PlaybackStateCompat.StateSkippingToQueueItem LayoutParameters = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent) }; + subtitleExtensions = new(Platform.AppContext, PlayerView, Dispatcher); checkPermissionsTask = CheckAndRequestForegroundPermission(checkPermissionSourceToken.Token); return (Player, PlayerView); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index ff7118c4f8..3eea71e05f 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -224,6 +224,7 @@ protected virtual partial void PlatformUpdateSource() MediaElement.CurrentStateChanged(MediaElementState.Opening); AVAsset? asset = null; + subtitleExtensions?.StopSubtitleDisplay(); if (Player is null) { return; @@ -231,7 +232,7 @@ protected virtual partial void PlatformUpdateSource() metaData ??= new(Player); metaData.ClearNowPlaying(); - + if (MediaElement.Source is UriMediaSource uriMediaSource) { var uri = uriMediaSource.Uri; @@ -277,8 +278,6 @@ protected virtual partial void PlatformUpdateSource() CurrentItemErrorObserver?.Dispose(); Player.ReplaceCurrentItemWithPlayerItem(PlayerItem); - - Player?.ReplaceCurrentItemWithPlayerItem(PlayerItem); subtitleExtensions ??= new(Player, PlayerViewController); CurrentItemErrorObserver = PlayerItem?.AddObserver("error", valueObserverOptions, (NSObservedChange change) => From d9d14814cf85bb883633a7cd33533d6919720a95 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 6 Jun 2024 15:18:20 -0700 Subject: [PATCH 17/98] Fix merge error by adding using statement pointing to extensions --- .../Views/MediaManager.android.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index ad4e505d67..7e68cb4999 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -17,6 +17,7 @@ using Com.Google.Android.Exoplayer2.Video; using CommunityToolkit.Maui.ApplicationModel.Permissions; using CommunityToolkit.Maui.Core.Primitives; +using CommunityToolkit.Maui.Extensions; using CommunityToolkit.Maui.Media.Services; using CommunityToolkit.Maui.Views; using Microsoft.Extensions.Logging; From 8af58359db113bb903f0388f1401609e9533da15 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sun, 16 Jun 2024 21:03:19 -0700 Subject: [PATCH 18/98] Refactor UI and resource handling in media element - Removed outdated conditional comment related to PR# 1873. - Enhanced namespace access with additional `using` directives for Android and CommunityToolkit. - Modified `textBlock` to be nullable, supporting dynamic UI updates. - Centralized `textBlock` initialization in the constructor via `InitializeTextBlock()`. - Introduced `VerifyAndRetrieveCurrentWindowResources` for resource validation. - Updated `MauiMediaElement_WindowsChanged` for null checks and re-initialization of `textBlock`. - Added dynamic re-initialization of `textBlock` in `InitializeTextBlock()`, adjusting properties like text alignment and color. - Improved safety in `StopSubtitleDisplay` with null checks for `textBlock`. - Made Windows platform-specific UI adjustments, including increased `xamlTextBlock` bottom margin for better visual separation. --- .../Extensions/SubtitleExtensions.android.cs | 84 +++++++++++++++---- .../Extensions/SubtitleExtensions.windows.cs | 6 +- 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 844bed23cf..ec0230c347 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -1,5 +1,6 @@ -// Works if PR# 1873 merged +using System.Data; using Android.Content; +using Android.Content.Res; using Android.Views; using Android.Widget; using AndroidX.CoordinatorLayout.Widget; @@ -7,6 +8,7 @@ using CommunityToolkit.Maui.Core; using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; +using Activity = Android.App.Activity; namespace CommunityToolkit.Maui.Extensions; /// @@ -21,7 +23,7 @@ public partial class SubtitleExtensions : CoordinatorLayout readonly IDispatcher dispatcher; readonly RelativeLayout.LayoutParams? textBlockLayout; readonly StyledPlayerView styledPlayerView; - readonly TextView textBlock; + TextView? textBlock; IMediaElement? mediaElement; List cues; @@ -42,42 +44,88 @@ public SubtitleExtensions(Context context, StyledPlayerView styledPlayerView, ID textBlockLayout = new RelativeLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent); textBlockLayout.AddRule(LayoutRules.AlignParentBottom); textBlockLayout.AddRule(LayoutRules.CenterHorizontal); - textBlock = new(Platform.AppContext) - { - Text = string.Empty, - HorizontalScrollBarEnabled = false, - VerticalScrollBarEnabled = false, - TextAlignment = Android.Views.TextAlignment.Center, - Visibility = Android.Views.ViewStates.Gone, - LayoutParameters = textBlockLayout - }; - textBlock.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); - textBlock.SetPaddingRelative(10, 10, 10, 10); - textBlock.SetTextColor(Android.Graphics.Color.White); - + InitializeTextBlock(); + MauiMediaElement.WindowsChanged += MauiMediaElement_WindowsChanged; } + static (Activity CurrentActivity, Android.Views.Window CurrentWindow, Resources CurrentWindowResources, Configuration CurrentWindowConfiguration) VerifyAndRetrieveCurrentWindowResources() + { + // Ensure current activity and window are available + if (Platform.CurrentActivity is not Activity currentActivity) + { + throw new InvalidOperationException("CurrentActivity cannot be null when the FullScreen button is tapped"); + } + if (currentActivity.Window is not Android.Views.Window currentWindow) + { + throw new InvalidOperationException("CurrentActivity Window cannot be null when the FullScreen button is tapped"); + } + + if (currentActivity.Resources is not Resources currentResources) + { + throw new InvalidOperationException("CurrentActivity Resources cannot be null when the FullScreen button is tapped"); + } + + if (currentResources.Configuration is not Configuration configuration) + { + throw new InvalidOperationException("CurrentActivity Configuration cannot be null when the FullScreen button is tapped"); + } + + return (currentActivity, currentWindow, currentResources, configuration); + } + void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) { - if (e.data is not ViewGroup viewGroup || styledPlayerView.Parent is not ViewGroup parent || string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) + if(textBlock is null) + { + throw new InvalidExpressionException("TextBlock cannot be null when the FullScreen button is tapped"); + } + if(string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) { return; } - switch(isFullScreen) + var (_ , currentWindow, _, _) = VerifyAndRetrieveCurrentWindowResources(); + if (currentWindow?.DecorView is not ViewGroup viewGroup) + { + return; + } + if (viewGroup.Parent is not ViewGroup parent) + { + return; + } + switch (isFullScreen) { case true: viewGroup.RemoveView(textBlock); + InitializeTextBlock(); parent.AddView(textBlock); isFullScreen = false; break; case false: parent.RemoveView(textBlock); + InitializeTextBlock(); viewGroup.AddView(textBlock); isFullScreen = true; break; } } + void InitializeTextBlock() + { + var (currentActivity, _, _, _) = VerifyAndRetrieveCurrentWindowResources(); + var activity = currentActivity; + textBlock = new(activity.ApplicationContext) + { + Text = string.Empty, + HorizontalScrollBarEnabled = false, + VerticalScrollBarEnabled = false, + TextAlignment = Android.Views.TextAlignment.Center, + Visibility = Android.Views.ViewStates.Gone, + LayoutParameters = textBlockLayout + }; + textBlock.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); + textBlock.SetTextColor(Android.Graphics.Color.White); + textBlock.SetPaddingRelative(10, 10, 10, 20); + } /// /// Loads the subtitles from the provided URL. @@ -149,7 +197,7 @@ void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) /// public void StopSubtitleDisplay() { - if (timer is null) + if (timer is null || textBlock is null) { return; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 16c5456010..62e2554a7a 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -30,7 +30,7 @@ public SubtitleExtensions() xamlTextBlock = new() { Text = string.Empty, - Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 10), + Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20), Visibility = Microsoft.UI.Xaml.Visibility.Collapsed, HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Bottom, @@ -40,7 +40,7 @@ public SubtitleExtensions() } void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) { - if (mauiMediaElement is null || e.data is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) + if (mauiMediaElement is null || e.data is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(mediaElement?.SubtitleUrl) || xamlTextBlock is null) { return; } @@ -48,10 +48,12 @@ void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) switch(isFullScreen) { case true: + xamlTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20); Dispatcher.Dispatch(() => { item.Children.Remove(xamlTextBlock); mauiMediaElement.Children.Add(xamlTextBlock); }); isFullScreen = false; break; case false: + xamlTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 300); Dispatcher.Dispatch(() => { mauiMediaElement.Children.Remove(xamlTextBlock); item.Children.Add(xamlTextBlock); }); isFullScreen = true; break; From cb80c9cba010cad103dd6434d01126570476401d Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sun, 16 Jun 2024 21:37:06 -0700 Subject: [PATCH 19/98] Refactor SubtitleExtensions class Refactored and simplified the SubtitleExtensions class by removing Android-specific dependencies and changing its base class to IDisposable for better resource management. Introduced a destructor and an explicit Dispose method to enhance cleanup of unmanaged resources. Added and improved methods for subtitle display management, including a new StopSubtitleDisplay method and reorganization of textBlock initialization. Restored functionality for dynamic UI adjustments with the reintroduction of VerifyAndRetrieveCurrentWindowResources method and event handler. Updated fullscreen handling logic for better subtitle visibility control. General code cleanup was performed, including the removal of unused imports, unnecessary null checks, and making the Dispose method virtual for extensibility. Adjusted instantiation in MediaManager.android.cs to align with constructor changes. --- .../Extensions/SubtitleExtensions.android.cs | 219 +++++++++--------- .../Views/MediaManager.android.cs | 2 +- 2 files changed, 114 insertions(+), 107 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index ec0230c347..33462558df 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -1,40 +1,38 @@ using System.Data; -using Android.Content; using Android.Content.Res; using Android.Views; using Android.Widget; -using AndroidX.CoordinatorLayout.Widget; using Com.Google.Android.Exoplayer2.UI; using CommunityToolkit.Maui.Core; using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; +using static Android.Views.ViewGroup; using Activity = Android.App.Activity; namespace CommunityToolkit.Maui.Extensions; /// /// A class that provides subtitle support for a video player. /// -public partial class SubtitleExtensions : CoordinatorLayout +public partial class SubtitleExtensions : IDisposable { - bool disposedValue; bool isFullScreen = false; - + bool disposedValue; + readonly HttpClient httpClient; readonly IDispatcher dispatcher; readonly RelativeLayout.LayoutParams? textBlockLayout; readonly StyledPlayerView styledPlayerView; - TextView? textBlock; IMediaElement? mediaElement; List cues; System.Timers.Timer? timer; + TextView? textBlock; /// /// The SubtitleExtensions class provides a way to display subtitles on a video player. /// - /// /// - public SubtitleExtensions(Context context, StyledPlayerView styledPlayerView, IDispatcher dispatcher) : base(context) + public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatcher) { httpClient = new HttpClient(); this.dispatcher = dispatcher; @@ -48,84 +46,6 @@ public SubtitleExtensions(Context context, StyledPlayerView styledPlayerView, ID MauiMediaElement.WindowsChanged += MauiMediaElement_WindowsChanged; } - - static (Activity CurrentActivity, Android.Views.Window CurrentWindow, Resources CurrentWindowResources, Configuration CurrentWindowConfiguration) VerifyAndRetrieveCurrentWindowResources() - { - // Ensure current activity and window are available - if (Platform.CurrentActivity is not Activity currentActivity) - { - throw new InvalidOperationException("CurrentActivity cannot be null when the FullScreen button is tapped"); - } - if (currentActivity.Window is not Android.Views.Window currentWindow) - { - throw new InvalidOperationException("CurrentActivity Window cannot be null when the FullScreen button is tapped"); - } - - if (currentActivity.Resources is not Resources currentResources) - { - throw new InvalidOperationException("CurrentActivity Resources cannot be null when the FullScreen button is tapped"); - } - - if (currentResources.Configuration is not Configuration configuration) - { - throw new InvalidOperationException("CurrentActivity Configuration cannot be null when the FullScreen button is tapped"); - } - - return (currentActivity, currentWindow, currentResources, configuration); - } - - void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) - { - if(textBlock is null) - { - throw new InvalidExpressionException("TextBlock cannot be null when the FullScreen button is tapped"); - } - if(string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) - { - return; - } - var (_ , currentWindow, _, _) = VerifyAndRetrieveCurrentWindowResources(); - if (currentWindow?.DecorView is not ViewGroup viewGroup) - { - return; - } - if (viewGroup.Parent is not ViewGroup parent) - { - return; - } - switch (isFullScreen) - { - case true: - viewGroup.RemoveView(textBlock); - InitializeTextBlock(); - parent.AddView(textBlock); - isFullScreen = false; - break; - case false: - parent.RemoveView(textBlock); - InitializeTextBlock(); - viewGroup.AddView(textBlock); - isFullScreen = true; - break; - } - } - void InitializeTextBlock() - { - var (currentActivity, _, _, _) = VerifyAndRetrieveCurrentWindowResources(); - var activity = currentActivity; - textBlock = new(activity.ApplicationContext) - { - Text = string.Empty, - HorizontalScrollBarEnabled = false, - VerticalScrollBarEnabled = false, - TextAlignment = Android.Views.TextAlignment.Center, - Visibility = Android.Views.ViewStates.Gone, - LayoutParameters = textBlockLayout - }; - textBlock.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); - textBlock.SetTextColor(Android.Graphics.Color.White); - textBlock.SetPaddingRelative(10, 10, 10, 20); - } /// /// Loads the subtitles from the provided URL. @@ -167,6 +87,27 @@ public void StartSubtitleDisplay() timer.Start(); } + /// + /// Stops the subtitle timer. + /// + public void StopSubtitleDisplay() + { + if (timer is null || textBlock is null) + { + return; + } + if (styledPlayerView.Parent is ViewGroup parent) + { + dispatcher.Dispatch(() => + { + parent.RemoveView(textBlock); + }); + } + textBlock.Text = string.Empty; + timer.Stop(); + timer.Elapsed -= Timer_Elapsed; + } + void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { if (mediaElement?.Position is null || textBlock is null || cues.Count == 0) @@ -192,44 +133,110 @@ void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) }); } - /// - /// Stops the subtitle timer. - /// - public void StopSubtitleDisplay() + void InitializeTextBlock() { - if (timer is null || textBlock is null) + var (currentActivity, _, _, _) = VerifyAndRetrieveCurrentWindowResources(); + var activity = currentActivity; + textBlock = new(activity.ApplicationContext) + { + Text = string.Empty, + HorizontalScrollBarEnabled = false, + VerticalScrollBarEnabled = false, + TextAlignment = Android.Views.TextAlignment.Center, + Visibility = Android.Views.ViewStates.Gone, + LayoutParameters = textBlockLayout + }; + textBlock.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); + textBlock.SetTextColor(Android.Graphics.Color.White); + textBlock.SetPaddingRelative(10, 10, 10, 20); + } + + static (Activity CurrentActivity, Android.Views.Window CurrentWindow, Resources CurrentWindowResources, Configuration CurrentWindowConfiguration) VerifyAndRetrieveCurrentWindowResources() + { + // Ensure current activity and window are available + if (Platform.CurrentActivity is not Activity currentActivity) + { + throw new InvalidOperationException("CurrentActivity cannot be null when the FullScreen button is tapped"); + } + if (currentActivity.Window is not Android.Views.Window currentWindow) + { + throw new InvalidOperationException("CurrentActivity Window cannot be null when the FullScreen button is tapped"); + } + + if (currentActivity.Resources is not Resources currentResources) + { + throw new InvalidOperationException("CurrentActivity Resources cannot be null when the FullScreen button is tapped"); + } + + if (currentResources.Configuration is not Configuration configuration) + { + throw new InvalidOperationException("CurrentActivity Configuration cannot be null when the FullScreen button is tapped"); + } + + return (currentActivity, currentWindow, currentResources, configuration); + } + + void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) + { + if (textBlock is null) + { + throw new InvalidExpressionException("TextBlock cannot be null when the FullScreen button is tapped"); + } + if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) { return; } - if(styledPlayerView.Parent is ViewGroup parent) + var (_, currentWindow, _, _) = VerifyAndRetrieveCurrentWindowResources(); + if (currentWindow?.DecorView is not ViewGroup viewGroup) { - dispatcher.Dispatch(() => - { + return; + } + if (viewGroup.Parent is not ViewGroup parent) + { + return; + } + switch (isFullScreen) + { + case true: + viewGroup.RemoveView(textBlock); + InitializeTextBlock(); + parent.AddView(textBlock); + isFullScreen = false; + break; + case false: parent.RemoveView(textBlock); - }); + InitializeTextBlock(); + viewGroup.AddView(textBlock); + isFullScreen = true; + break; } - textBlock.Text = string.Empty; - timer.Stop(); - timer.Elapsed -= Timer_Elapsed; } - protected override void Dispose(bool disposing) + protected virtual void Dispose(bool disposing) { - base.Dispose(disposing); if (!disposedValue) { if (disposing) { - timer?.Stop(); - if(timer is not null) - { - timer.Elapsed -= Timer_Elapsed; - } - httpClient?.Dispose(); + httpClient.Dispose(); timer?.Dispose(); + textBlock?.Dispose(); } + timer = null; + textBlock = null; disposedValue = true; } } + + ~SubtitleExtensions() + { + Dispose(disposing: false); + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index 7e68cb4999..9da04ac0da 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -203,7 +203,7 @@ or PlaybackStateCompat.StateSkippingToQueueItem LayoutParameters = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent) }; - subtitleExtensions = new(Platform.AppContext, PlayerView, Dispatcher); + subtitleExtensions = new(PlayerView, Dispatcher); checkPermissionsTask = CheckAndRequestForegroundPermission(checkPermissionSourceToken.Token); return (Player, PlayerView); } From c40cacf65acdbcc797ee0245a2c6bf14c3c9595d Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Mon, 17 Jun 2024 02:25:09 -0700 Subject: [PATCH 20/98] Improve subtitle handling and code cleanup This commit introduces several key improvements and cleanups across the codebase, focusing on enhancing media playback and subtitle management. Notably, we've added initialization for empty subtitle URLs in `MediaElementPage.xaml.cs` to prevent issues when changing media sources. Both `SrtParser.cs` and `VttParser.cs` have been updated to return an empty list of cues for null or empty subtitle content, optimizing parsing efficiency and reliability. We've removed unused namespaces, such as `System.Data;` in `SubtitleExtensions.android.cs`, and refactored `SubtitleExtensions` classes across platforms to include better error handling, clearer event management, and more robust null checking using `ArgumentNullException.ThrowIfNull`. These changes ensure a cleaner, more maintainable codebase with improved readability and compile-time efficiency. Additionally, we've made significant enhancements in media manager classes by ensuring proper subtitle and session management, including stopping and clearing subtitles appropriately and following proper disposal patterns to prevent memory leaks. Minor code improvements were also made to enhance readability, efficiency, and robustness, including the removal of redundant null checks, the use of pattern matching, and the simplification of switch statements. Lastly, we've adjusted the raising of `WindowsChanged` events to ensure consistent behavior across different media playback scenarios. These collective efforts aim to bolster the codebase's maintainability, efficiency, and reliability, especially in handling media playback and subtitles across various platforms. --- .../MediaElement/MediaElementPage.xaml.cs | 4 ++ .../Extensions/SrtParser.cs | 4 ++ .../Extensions/SubtitleExtensions.android.cs | 33 ++++++------ .../Extensions/SubtitleExtensions.macios.cs | 53 +++++++------------ .../Extensions/SubtitleExtensions.windows.cs | 20 +++---- .../Extensions/VttParser.cs | 4 ++ .../Views/MauiMediaElement.android.cs | 14 ++--- .../Views/MediaManager.android.cs | 30 +++++++---- .../Views/MediaManager.macios.cs | 14 +++-- .../Views/MediaManager.windows.cs | 3 +- 10 files changed, 91 insertions(+), 88 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 f27e66b6c9..3334032397 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -167,6 +167,7 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.Source = MediaSource.FromUri( "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"); + MediaElement.SubtitleUrl = string.Empty; return; case loadHls: @@ -176,6 +177,7 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.Source = MediaSource.FromUri( "https://mtoczko.github.io/hls-test-streams/test-gap/playlist.m3u8"); + MediaElement.SubtitleUrl = string.Empty; return; case resetSource: @@ -183,6 +185,7 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.MetadataTitle = string.Empty; MediaElement.MetadataArtist = string.Empty; MediaElement.Source = null; + MediaElement.SubtitleUrl = string.Empty; return; case loadLocalResource: @@ -206,6 +209,7 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); } + MediaElement.SubtitleUrl = string.Empty; return; case loadSubTitles: if(OperatingSystem.IsIOS() || OperatingSystem.IsMacCatalyst()) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index f942471371..92632f7987 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -17,6 +17,10 @@ public static partial class SrtParser public static List ParseSrtContent(string srtContent) { List cues = []; + if(string.IsNullOrEmpty(srtContent)) + { + return cues; + } string[] lines = srtContent.Split(separator, StringSplitOptions.None); Regex timecodePattern = MyRegex(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 33462558df..2f9638f9e1 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -1,5 +1,4 @@ -using System.Data; -using Android.Content.Res; +using Android.Content.Res; using Android.Views; using Android.Widget; using Com.Google.Android.Exoplayer2.UI; @@ -15,7 +14,6 @@ namespace CommunityToolkit.Maui.Extensions; /// public partial class SubtitleExtensions : IDisposable { - bool isFullScreen = false; bool disposedValue; readonly HttpClient httpClient; @@ -23,10 +21,10 @@ public partial class SubtitleExtensions : IDisposable readonly RelativeLayout.LayoutParams? textBlockLayout; readonly StyledPlayerView styledPlayerView; - IMediaElement? mediaElement; List cues; - System.Timers.Timer? timer; + IMediaElement? mediaElement; TextView? textBlock; + System.Timers.Timer? timer; /// /// The SubtitleExtensions class provides a way to display subtitles on a video player. @@ -54,6 +52,7 @@ public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatc public async Task LoadSubtitles(IMediaElement mediaElement) { this.mediaElement = mediaElement; + cues.Clear(); string? vttContent; try { @@ -77,8 +76,10 @@ var url when url.EndsWith("vtt") => VttParser.ParseVttContent(vttContent), /// public void StartSubtitleDisplay() { - if(textBlock is null || styledPlayerView.Parent is not ViewGroup parent) + ArgumentNullException.ThrowIfNull(textBlock); + if(styledPlayerView.Parent is not ViewGroup parent) { + System.Diagnostics.Trace.TraceError("StyledPlayerView parent is not a ViewGroup"); return; } dispatcher.Dispatch(() => parent.AddView(textBlock)); @@ -98,10 +99,7 @@ public void StopSubtitleDisplay() } if (styledPlayerView.Parent is ViewGroup parent) { - dispatcher.Dispatch(() => - { - parent.RemoveView(textBlock); - }); + dispatcher.Dispatch(() => parent.RemoveView(textBlock)); } textBlock.Text = string.Empty; timer.Stop(); @@ -110,7 +108,9 @@ public void StopSubtitleDisplay() void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { - if (mediaElement?.Position is null || textBlock is null || cues.Count == 0) + ArgumentNullException.ThrowIfNull(textBlock); + ArgumentNullException.ThrowIfNull(mediaElement); + if (cues.Count == 0) { return; } @@ -178,10 +178,9 @@ void InitializeTextBlock() void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) { - if (textBlock is null) - { - throw new InvalidExpressionException("TextBlock cannot be null when the FullScreen button is tapped"); - } + ArgumentNullException.ThrowIfNull(textBlock); + + // If the subtitle URL is empty do nothing if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) { return; @@ -195,19 +194,17 @@ void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) { return; } - switch (isFullScreen) + switch (e.data) { case true: viewGroup.RemoveView(textBlock); InitializeTextBlock(); parent.AddView(textBlock); - isFullScreen = false; break; case false: parent.RemoveView(textBlock); InitializeTextBlock(); viewGroup.AddView(textBlock); - isFullScreen = true; break; } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index c55a2eb1b0..1fd85de25d 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -15,27 +15,24 @@ namespace CommunityToolkit.Maui.Extensions; public partial class SubtitleExtensions : UIViewController { readonly HttpClient httpClient; - readonly UIViewController playerViewController; readonly PlatformMediaElement player; - UILabel? subtitleLabel; + readonly UIViewController playerViewController; + readonly UILabel subtitleLabel; + List cues; - NSObject? playerObserver; IMediaElement? mediaElement; + NSObject? playerObserver; UIViewController? viewController; - UIFont? font; /// /// The SubtitleExtensions class provides a way to display subtitles on a video player. /// /// /// - public SubtitleExtensions(PlatformMediaElement? player, UIViewController? playerViewController) + public SubtitleExtensions(PlatformMediaElement player, UIViewController playerViewController) { - ArgumentNullException.ThrowIfNull(player); - ArgumentNullException.ThrowIfNull(playerViewController?.View?.Bounds); this.playerViewController = playerViewController; this.player = player; - MediaManagerDelegate.WindowsChanged += MauiMediaElement_WindowsChanged; cues = []; httpClient = new HttpClient(); subtitleLabel = new UILabel @@ -48,29 +45,20 @@ public SubtitleExtensions(PlatformMediaElement? player, UIViewController? player Lines = 0, AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleTopMargin | UIViewAutoresizing.FlexibleHeight | UIViewAutoresizing.FlexibleBottomMargin }; + MediaManagerDelegate.WindowsChanged += MauiMediaElement_WindowsChanged; } void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) { - ArgumentNullException.ThrowIfNull(font); if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl) || e.data is null) { return; } - subtitleLabel = new UILabel - { - TextColor = UIColor.White, - TextAlignment = UITextAlignment.Center, - Font = font, - Text = "", - Lines = 0, - AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleTopMargin | UIViewAutoresizing.FlexibleHeight | UIViewAutoresizing.FlexibleBottomMargin - }; - switch (e.data.Equals(true)) + switch (e.data) { case true: - viewController = WindowStateManager.Default.GetCurrentUIViewController(); - ArgumentNullException.ThrowIfNull(viewController?.View); + viewController = WindowStateManager.Default.GetCurrentUIViewController() ?? throw new ArgumentException(nameof(viewController)); + ArgumentNullException.ThrowIfNull(viewController.View); subtitleLabel.Frame = CalculateSubtitleFrame(viewController); viewController.View.AddSubview(subtitleLabel); break; @@ -87,10 +75,9 @@ void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) /// public async Task LoadSubtitles(IMediaElement mediaElement) { - ArgumentNullException.ThrowIfNull(subtitleLabel); this.mediaElement = mediaElement; - font = UIFont.FromName(mediaElement.SubtitleFont, (float)mediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize((float)mediaElement.SubtitleFontSize); - subtitleLabel.Font = font; + cues.Clear(); + subtitleLabel.Font = UIFont.FromName(mediaElement.SubtitleFont, (float)mediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize((float)mediaElement.SubtitleFontSize); string? vttContent; try { @@ -119,16 +106,16 @@ public void StartSubtitleDisplay() playerObserver = player?.AddPeriodicTimeObserver(CMTime.FromSeconds(1, 1), null, (time) => { TimeSpan currentPlaybackTime = TimeSpan.FromSeconds(time.Seconds); - if (viewController is not null) + switch(viewController) { - DispatchQueue.MainQueue.DispatchAsync(() => viewController?.View?.AddSubview(subtitleLabel)); - subtitleLabel.Frame = CalculateSubtitleFrame(viewController); - } - else - { - DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); - subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); + case null: + DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); + break; + default: + DispatchQueue.MainQueue.DispatchAsync(() => viewController?.View?.AddSubview(subtitleLabel)); + break; } + subtitleLabel.Frame = viewController is not null ? CalculateSubtitleFrame(viewController) : CalculateSubtitleFrame(playerViewController); DispatchQueue.MainQueue.DispatchAsync(() => UpdateSubtitle(currentPlaybackTime)); }); } @@ -138,8 +125,6 @@ public void StartSubtitleDisplay() /// public void StopSubtitleDisplay() { - ArgumentNullException.ThrowIfNull(player); - ArgumentNullException.ThrowIfNull(subtitleLabel); if (playerObserver is not null) { player.RemoveTimeObserver(playerObserver); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 62e2554a7a..adfb6b35ab 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -9,15 +9,16 @@ namespace CommunityToolkit.Maui.Extensions; /// public partial class SubtitleExtensions : Grid, IDisposable { + bool disposedValue; bool isFullScreen = false; + readonly HttpClient httpClient; - Microsoft.UI.Xaml.Controls.Grid? item; - System.Timers.Timer? timer; + readonly Microsoft.UI.Xaml.Controls.TextBlock xamlTextBlock; + List cues; - bool disposedValue; IMediaElement? mediaElement; - readonly Microsoft.UI.Xaml.Controls.TextBlock? xamlTextBlock; MauiMediaElement? mauiMediaElement; + System.Timers.Timer? timer; /// /// The SubtitleExtensions class provides a way to display subtitles on a video player. @@ -40,21 +41,21 @@ public SubtitleExtensions() } void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) { - if (mauiMediaElement is null || e.data is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(mediaElement?.SubtitleUrl) || xamlTextBlock is null) + if (e.data is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) { return; } - this.item = gridItem; + ArgumentNullException.ThrowIfNull(mauiMediaElement); switch(isFullScreen) { case true: xamlTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20); - Dispatcher.Dispatch(() => { item.Children.Remove(xamlTextBlock); mauiMediaElement.Children.Add(xamlTextBlock); }); + Dispatcher.Dispatch(() => { gridItem.Children.Remove(xamlTextBlock); mauiMediaElement.Children.Add(xamlTextBlock); }); isFullScreen = false; break; case false: xamlTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 300); - Dispatcher.Dispatch(() => { mauiMediaElement.Children.Remove(xamlTextBlock); item.Children.Add(xamlTextBlock); }); + Dispatcher.Dispatch(() => { mauiMediaElement.Children.Remove(xamlTextBlock); gridItem.Children.Add(xamlTextBlock); }); isFullScreen = true; break; } @@ -69,6 +70,7 @@ public async Task LoadSubtitles(IMediaElement mediaElement, Microsoft.UI.Xaml.Co { this.mediaElement = mediaElement; mauiMediaElement = player?.Parent as MauiMediaElement; + cues.Clear(); string? vttContent; try { @@ -101,7 +103,7 @@ public void StartSubtitleDisplay() void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { - if (mediaElement?.Position is null || cues.Count == 0 || string.IsNullOrEmpty(mediaElement.SubtitleUrl) || xamlTextBlock is null) + if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) { return; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index 47f43eec71..aeec7acecd 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -18,6 +18,10 @@ public static partial class VttParser public static List ParseVttContent(string vttContent) { List cues = []; + if (string.IsNullOrEmpty(vttContent)) + { + return cues; + } string[] lines = vttContent.Split(separator, StringSplitOptions.None); Regex timecodePattern = MyRegex(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs index 4401acffd5..4e23deb409 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs @@ -22,11 +22,13 @@ public class MauiMediaElement : CoordinatorLayout /// Handles the event when the windows change. /// public static event EventHandler? WindowsChanged; - readonly StyledPlayerView playerView; + int defaultSystemUiVisibility; bool isSystemBarVisible; bool isFullScreen; + readonly RelativeLayout relativeLayout; + readonly StyledPlayerView playerView; #pragma warning disable CS8618 // Non-nullable field is uninitialized. Consider declaring as nullable. #pragma warning disable IDE0060 // Remove unused parameter @@ -61,13 +63,11 @@ public MauiMediaElement(Context context, StyledPlayerView playerView) : base(con AddView(relativeLayout); } + /// /// A method that raises the WindowsChanged event. /// - protected virtual void OnWindowsChanged(WindowsEventArgs e) - { - WindowsChanged?.Invoke(null, e); - } + protected virtual void OnWindowsChanged(WindowsEventArgs e) => WindowsChanged?.Invoke(null, e); public override void OnDetachedFromWindow() { if (isFullScreen) @@ -156,14 +156,14 @@ void OnFullscreenButtonClick(object? sender, StyledPlayerView.FullscreenButtonCl isFullScreen = true; RemoveView(relativeLayout); layout?.AddView(relativeLayout); - OnWindowsChanged(new Maui.Primitives.WindowsEventArgs(layout)); + OnWindowsChanged(new Maui.Primitives.WindowsEventArgs(isFullScreen)); } else { isFullScreen = false; layout?.RemoveView(relativeLayout); AddView(relativeLayout); - OnWindowsChanged(new Maui.Primitives.WindowsEventArgs(layout)); + OnWindowsChanged(new Maui.Primitives.WindowsEventArgs(isFullScreen)); } // 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 9da04ac0da..ad275e150b 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -26,24 +26,26 @@ namespace CommunityToolkit.Maui.Core.Views; public partial class MediaManager : Java.Lang.Object, IPlayer.IListener { - static readonly HttpClient client = new(); + SubtitleExtensions? subtitleExtensions; + static readonly HttpClient httpClient = new(); readonly SemaphoreSlim seekToSemaphoreSlim = new(1, 1); - - Task? checkPermissionsTask; - CancellationTokenSource checkPermissionSourceToken = new(); - CancellationTokenSource startServiceSourceToken = new(); + + MediaElementState currentState; double? previousSpeed; float volumeBeforeMute = 1; + MediaControllerCompat? mediaControllerCompat; - TaskCompletionSource? seekToTaskCompletionSource; MediaSessionConnector? mediaSessionConnector; MediaSessionCompat? mediaSession; UIUpdateReceiver? uiUpdateReceiver; - MediaElementState currentState; - SubtitleExtensions? subtitleExtensions; + + TaskCompletionSource? seekToTaskCompletionSource; + CancellationTokenSource checkPermissionSourceToken = new(); + CancellationTokenSource startServiceSourceToken = new(); CancellationTokenSource subTitles = new(); Task? startSubtitles; + Task? checkPermissionsTask; /// /// The platform native counterpart of . @@ -69,7 +71,7 @@ public partial class MediaManager : Java.Lang.Object, IPlayer.IListener try { - var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); + var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); var stream = response.IsSuccessStatusCode ? await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false) : null; return stream switch @@ -188,6 +190,7 @@ or PlaybackStateCompat.StateSkippingToQueueItem [MemberNotNull(nameof(checkPermissionsTask))] [MemberNotNull(nameof(mediaSessionConnector))] [MemberNotNull(nameof(mediaControllerCompat))] + [MemberNotNull(nameof(subtitleExtensions))] public (PlatformMediaElement platformView, StyledPlayerView PlayerView) CreatePlatformView() { ArgumentNullException.ThrowIfNull(MauiContext.Context); @@ -457,11 +460,11 @@ protected virtual partial void PlatformUpdateSource() } async Task LoadSubtitles(CancellationToken cancellationToken = default) { - subtitleExtensions?.StopSubtitleDisplay(); if (subtitleExtensions is null || string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { return; } + subtitleExtensions.StopSubtitleDisplay(); await subtitleExtensions.LoadSubtitles(MediaElement).WaitAsync(cancellationToken).ConfigureAwait(false); subtitleExtensions.StartSubtitleDisplay(); } @@ -598,6 +601,11 @@ protected override void Dispose(bool disposing) if (disposing) { StopService(); + subtitleExtensions?.StopSubtitleDisplay(); + subtitleExtensions = null; + subTitles?.Dispose(); + startSubtitles?.Dispose(); + startSubtitles = null; mediaSessionConnector?.SetPlayer(null); mediaSessionConnector?.Dispose(); @@ -618,7 +626,7 @@ protected override void Dispose(bool disposing) checkPermissionSourceToken.Dispose(); startServiceSourceToken.Dispose(); - client.Dispose(); + httpClient.Dispose(); } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index 3eea71e05f..dc1f164664 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -16,7 +16,6 @@ namespace CommunityToolkit.Maui.Core.Views; public partial class MediaManager : IDisposable { Metadata? metaData; - SubtitleExtensions? subtitleExtensions; readonly CancellationTokenSource subTitles = new(); Task? startSubtitles; @@ -125,7 +124,7 @@ public partial class MediaManager : IDisposable AddStatusObservers(); AddPlayedToEndObserver(); AddErrorObservers(); - + subtitleExtensions = new(Player, PlayerViewController); return (Player, PlayerViewController); } @@ -278,7 +277,6 @@ protected virtual partial void PlatformUpdateSource() CurrentItemErrorObserver?.Dispose(); Player.ReplaceCurrentItemWithPlayerItem(PlayerItem); - subtitleExtensions ??= new(Player, PlayerViewController); CurrentItemErrorObserver = PlayerItem?.AddObserver("error", valueObserverOptions, (NSObservedChange change) => { @@ -315,11 +313,11 @@ protected virtual partial void PlatformUpdateSource() async Task LoadSubtitles(CancellationToken cancellationToken = default) { - subtitleExtensions?.StopSubtitleDisplay(); if (subtitleExtensions is null || string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { return; } + subtitleExtensions.StopSubtitleDisplay(); await subtitleExtensions.LoadSubtitles(MediaElement).WaitAsync(cancellationToken).ConfigureAwait(false); subtitleExtensions.StartSubtitleDisplay(); } @@ -446,9 +444,12 @@ protected virtual void Dispose(bool disposing) DestroyPlayedToEndObserver(); subtitleExtensions?.StopSubtitleDisplay(); subtitleExtensions?.Dispose(); + subtitleExtensions = null; subTitles.Dispose(); RateObserver?.Dispose(); RateObserver = null; + startSubtitles?.Dispose(); + startSubtitles = null; CurrentItemErrorObserver?.Dispose(); CurrentItemErrorObserver = null; @@ -665,8 +666,5 @@ public override void WillEndFullScreenPresentation(AVPlayerViewController player /// /// A method that raises the WindowsChanged event. /// - void OnWindowsChanged(WindowsEventArgs e) - { - WindowsChanged?.Invoke(null, e); - } + static void OnWindowsChanged(WindowsEventArgs e) => WindowsChanged?.Invoke(null, e); } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs index 4f5fa9cb54..ae10e02fd9 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs @@ -299,7 +299,6 @@ protected virtual async partial void PlatformUpdateSource() async Task LoadSubtitles(CancellationToken cancellationToken = default) { - subtitleExtensions?.StopSubtitleDisplay(); if (subtitleExtensions is null || string.IsNullOrEmpty(MediaElement.SubtitleUrl) || Player is null) { System.Diagnostics.Trace.TraceError("SubtitleExtensions is null or SubtitleUrl is null or Player is null"); @@ -339,6 +338,8 @@ protected virtual void Dispose(bool disposing) displayActiveRequested = false; } subTitles.Dispose(); + startSubtitles?.Dispose(); + startSubtitles = null; Player.MediaPlayer.MediaOpened -= OnMediaElementMediaOpened; Player.MediaPlayer.MediaFailed -= OnMediaElementMediaFailed; Player.MediaPlayer.MediaEnded -= OnMediaElementMediaEnded; From 8086827ef249601b6598ea6873b16ef2d48bd1b7 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Mon, 17 Jun 2024 02:27:56 -0700 Subject: [PATCH 21/98] remove redundant string assignments --- .../Pages/Views/MediaElement/MediaElementPage.xaml.cs | 6 +----- 1 file changed, 1 insertion(+), 5 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 3334032397..b9196547bd 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -192,24 +192,20 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.MetadataArtworkUrl = "https://lh3.googleusercontent.com/pw/AP1GczNRrebWCJvfdIau1EbsyyYiwAfwHS0JXjbioXvHqEwYIIdCzuLodQCZmA57GADIo5iB3yMMx3t_vsefbfoHwSg0jfUjIXaI83xpiih6d-oT7qD_slR0VgNtfAwJhDBU09kS5V2T5ZML-WWZn8IrjD4J-g=w1792-h1024-s-no-gm"; MediaElement.MetadataTitle = "Local Resource Title"; MediaElement.MetadataArtist = "Local Resource Album"; - + MediaElement.SubtitleUrl = string.Empty; if (DeviceInfo.Platform == DevicePlatform.MacCatalyst || DeviceInfo.Platform == DevicePlatform.iOS) { - MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = MediaSource.FromResource("AppleVideo.mp4"); } else if (DeviceInfo.Platform == DevicePlatform.Android) { - MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = MediaSource.FromResource("AndroidVideo.mp4"); } else if (DeviceInfo.Platform == DevicePlatform.WinUI) { - MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); } - MediaElement.SubtitleUrl = string.Empty; return; case loadSubTitles: if(OperatingSystem.IsIOS() || OperatingSystem.IsMacCatalyst()) From 261bfaa97a087624a5e06229e8b1f0429ad0f36b Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Mon, 17 Jun 2024 04:14:17 -0700 Subject: [PATCH 22/98] Improve subtitle sizing in full-screen mode Enhanced the `MauiMediaElement_WindowsChanged` method in `SubtitleExtensions.windows.cs` to adjust subtitle text size based on the full-screen state of the media element. Specifically, when in full-screen mode, the subtitle font size is now increased by 8.0 units over the base `mediaElement.SubtitleFontSize`. Conversely, when not in full-screen mode, the subtitle font size reverts to the base size. Additionally, simplified the `Timer_Elapsed` method by removing redundant code for setting `xamlTextBlock` font properties, streamlining the subtitle display logic. --- .../Extensions/SubtitleExtensions.windows.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index adfb6b35ab..10685e1830 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -46,14 +46,17 @@ void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) return; } ArgumentNullException.ThrowIfNull(mauiMediaElement); - switch(isFullScreen) + + switch (isFullScreen) { case true: + xamlTextBlock.FontSize = mediaElement.SubtitleFontSize + 8.0; xamlTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20); Dispatcher.Dispatch(() => { gridItem.Children.Remove(xamlTextBlock); mauiMediaElement.Children.Add(xamlTextBlock); }); isFullScreen = false; break; case false: + xamlTextBlock.FontSize = mediaElement.SubtitleFontSize; xamlTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 300); Dispatcher.Dispatch(() => { mauiMediaElement.Children.Remove(xamlTextBlock); gridItem.Children.Add(xamlTextBlock); }); isFullScreen = true; @@ -112,8 +115,6 @@ void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { if (cue is not null) { - xamlTextBlock.FontFamily = !string.IsNullOrEmpty(mediaElement.SubtitleFont) ? new Microsoft.UI.Xaml.Media.FontFamily(mediaElement.SubtitleFont) : default; - xamlTextBlock.FontSize = isFullScreen ? (mediaElement.SubtitleFontSize + 8.0) : mediaElement.SubtitleFontSize; xamlTextBlock.Text = cue.Text; xamlTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } From 1b5dfda53a468cf90a49160c4b85431604a142e9 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 18 Jun 2024 22:43:40 -0700 Subject: [PATCH 23/98] 1. remove uneeded using 2. fix windows font size when loading 3. fix windows font size when changing from normal to full screen and back. --- .../AppBuilderExtensions.shared.cs | 1 - .../Extensions/SubtitleExtensions.windows.cs | 5 +++-- .../Primitives/Metadata.macios.cs | 1 - .../Views/MauiMediaElement.android.cs | 1 - .../Views/MauiMediaElement.macios.cs | 5 +---- .../Views/MauiMediaElement.windows.cs | 2 -- 6 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs b/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs index 48e82df15d..6d1cd023ac 100644 --- a/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs @@ -1,5 +1,4 @@ using CommunityToolkit.Maui.Core.Handlers; -using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Views; namespace CommunityToolkit.Maui; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 10685e1830..547f52f79f 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -50,13 +50,13 @@ void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) switch (isFullScreen) { case true: - xamlTextBlock.FontSize = mediaElement.SubtitleFontSize + 8.0; + xamlTextBlock.FontSize = mediaElement.SubtitleFontSize; xamlTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20); Dispatcher.Dispatch(() => { gridItem.Children.Remove(xamlTextBlock); mauiMediaElement.Children.Add(xamlTextBlock); }); isFullScreen = false; break; case false: - xamlTextBlock.FontSize = mediaElement.SubtitleFontSize; + xamlTextBlock.FontSize = mediaElement.SubtitleFontSize + 8.0; xamlTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 300); Dispatcher.Dispatch(() => { mauiMediaElement.Children.Remove(xamlTextBlock); gridItem.Children.Add(xamlTextBlock); }); isFullScreen = true; @@ -74,6 +74,7 @@ public async Task LoadSubtitles(IMediaElement mediaElement, Microsoft.UI.Xaml.Co this.mediaElement = mediaElement; mauiMediaElement = player?.Parent as MauiMediaElement; cues.Clear(); + xamlTextBlock.FontSize = mediaElement.SubtitleFontSize; string? vttContent; try { diff --git a/src/CommunityToolkit.Maui.MediaElement/Primitives/Metadata.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Primitives/Metadata.macios.cs index 53bea82acb..34ce710f47 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Primitives/Metadata.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Primitives/Metadata.macios.cs @@ -1,5 +1,4 @@ using AVFoundation; -using CommunityToolkit.Maui.Core; using CoreMedia; using Foundation; using MediaPlayer; diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs index 4e23deb409..7a08ed7708 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs @@ -2,7 +2,6 @@ using Android.Content; using Android.Content.Res; using Android.Runtime; -using Android.Util; using Android.Views; using Android.Widget; using AndroidX.CoordinatorLayout.Widget; diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.macios.cs index a7126e62c2..9a198f2fe8 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.macios.cs @@ -1,8 +1,5 @@ -using System.ComponentModel; -using AVKit; +using AVKit; using CommunityToolkit.Maui.Views; -using Microsoft.Maui.Controls.Platform.Compatibility; -using Microsoft.Maui.Platform; using UIKit; namespace CommunityToolkit.Maui.Core.Views; diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs index 687d99f1e3..502c30db9a 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs @@ -8,13 +8,11 @@ using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media; -using Microsoft.UI.Xaml.Media.Imaging; using WinRT.Interop; using Application = Microsoft.Maui.Controls.Application; using Button = Microsoft.UI.Xaml.Controls.Button; using Colors = Microsoft.UI.Colors; using Grid = Microsoft.UI.Xaml.Controls.Grid; -using ImageSource = Microsoft.UI.Xaml.Media.ImageSource; using Page = Microsoft.Maui.Controls.Page; using SolidColorBrush = Microsoft.UI.Xaml.Media.SolidColorBrush; using Thickness = Microsoft.UI.Xaml.Thickness; From 5c1f8eaccef6d29fdb4804a905b5b1f61f36b480 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 20 Jun 2024 03:30:31 -0700 Subject: [PATCH 24/98] Added sample font to try and set fonts in Media Element --- .../CommunityToolkit.Maui.Sample.csproj | 4 ++-- .../MauiProgram.cs | 1 + .../MediaElement/MediaElementPage.xaml.cs | 12 ++++-------- .../Resources/Fonts/FontFamilies.cs | 1 + .../Resources/Fonts/PlaywriteSK-Regular.ttf | Bin 0 -> 240456 bytes .../Extensions/SubtitleExtensions.windows.cs | 2 ++ 6 files changed, 10 insertions(+), 10 deletions(-) create mode 100644 samples/CommunityToolkit.Maui.Sample/Resources/Fonts/PlaywriteSK-Regular.ttf diff --git a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj index 674e05f90f..fb2de7816f 100644 --- a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj +++ b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj @@ -51,8 +51,8 @@ - - + + diff --git a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs index 814854054e..c4046b17af 100644 --- a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs +++ b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs @@ -62,6 +62,7 @@ public static MauiApp CreateMauiApp() .ConfigureFonts(fonts => { fonts.AddFont("Font Awesome 6 Brands-Regular-400.otf", FontFamilies.FontAwesomeBrands); + fonts.AddFont("PlaywriteSK-Regular.ttf", FontFamilies.PlaywriteSK); }); builder.Services.AddHttpClient() 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 b9196547bd..aa277bec32 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -4,6 +4,7 @@ using CommunityToolkit.Maui.Sample.ViewModels.Views; using CommunityToolkit.Maui.Views; using Microsoft.Extensions.Logging; +using CommunityToolkit.Maui.Sample.Resources.Fonts; using LayoutAlignment = Microsoft.Maui.Primitives.LayoutAlignment; namespace CommunityToolkit.Maui.Sample.Pages.Views; @@ -208,17 +209,12 @@ async void ChangeSourceClicked(Object sender, EventArgs e) } return; case loadSubTitles: - if(OperatingSystem.IsIOS() || OperatingSystem.IsMacCatalyst()) - { - MediaElement.SubtitleFont = "Avenir-Book"; - } - else - { - MediaElement.SubtitleFont = "monospace"; - } + MediaElement.SubtitleFont = FontFamilies.PlaywriteSK; MediaElement.SubtitleFontSize = 16; MediaElement.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); + //MediaElement.FontAttributes = FontAttributes.Bold; + //MediaElement.FontFamily = FontFamilies.PlaywriteSK; return; } } diff --git a/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/FontFamilies.cs b/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/FontFamilies.cs index 54039a325f..3e05552458 100644 --- a/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/FontFamilies.cs +++ b/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/FontFamilies.cs @@ -3,4 +3,5 @@ public static class FontFamilies { public const string FontAwesomeBrands = nameof(FontAwesomeBrands); + public const string PlaywriteSK = nameof(PlaywriteSK); } \ No newline at end of file diff --git a/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/PlaywriteSK-Regular.ttf b/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/PlaywriteSK-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0e6b229d4522d6e469b3d0caf526aa07cfdac369 GIT binary patch literal 240456 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�j~A3z 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 literal 0 HcmV?d00001 diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 547f52f79f..de9cafca33 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -1,6 +1,7 @@ using CommunityToolkit.Maui.Core; using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; +using Microsoft.UI.Xaml.Media; namespace CommunityToolkit.Maui.Extensions; @@ -51,6 +52,7 @@ void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) { case true: xamlTextBlock.FontSize = mediaElement.SubtitleFontSize; + xamlTextBlock.FontFamily = new FontFamily(mediaElement.SubtitleFont); xamlTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20); Dispatcher.Dispatch(() => { gridItem.Children.Remove(xamlTextBlock); mauiMediaElement.Children.Add(xamlTextBlock); }); isFullScreen = false; From 665f629ad1bc7296385cd5bebd70e2ca566619c5 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 20 Jun 2024 03:31:09 -0700 Subject: [PATCH 25/98] remove old code --- .../Pages/Views/MediaElement/MediaElementPage.xaml.cs | 2 -- 1 file changed, 2 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 aa277bec32..ad5055d255 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -213,8 +213,6 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.SubtitleFontSize = 16; MediaElement.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); - //MediaElement.FontAttributes = FontAttributes.Bold; - //MediaElement.FontFamily = FontFamilies.PlaywriteSK; return; } } From e8b172020e612fa215ebaed1daf9b362ab830b26 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 20 Jun 2024 15:21:18 -0700 Subject: [PATCH 26/98] remove empty spacing --- .../CommunityToolkit.Maui.Sample.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj index fb2de7816f..674e05f90f 100644 --- a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj +++ b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj @@ -51,8 +51,8 @@ - - + + From ea1d05490da99c947e2db2a63e2795672e63d570 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 20 Jun 2024 16:06:44 -0700 Subject: [PATCH 27/98] Restrict class visibility Made several classes internal by changing their access modifiers from public to internal or removing the public keyword, affecting SrtParser, SubtitleCue, and various SubtitleExtensions files. This change limits their accessibility to within the assembly, aiming for better encapsulation. Additionally, documentation comments have been removed from these classes. --- .../Extensions/SrtParser.cs | 6 ++---- .../Extensions/SubtitleCue.cs | 5 +---- .../Extensions/SubtitleExtensions.android.cs | 6 ++---- .../Extensions/SubtitleExtensions.macios.cs | 5 +---- .../Extensions/SubtitleExtensions.windows.cs | 5 +---- .../Extensions/VttParser.cs | 5 +---- 6 files changed, 8 insertions(+), 24 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index 92632f7987..a360cc8c68 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -2,10 +2,8 @@ using System.Text.RegularExpressions; namespace CommunityToolkit.Maui.Extensions; -/// -/// a class that provides subtitle parser for SRT files. -/// -public static partial class SrtParser + +static partial class SrtParser { static readonly string[] separator = ["\r\n", "\n"]; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs index 05ad5ea1d2..271f3d9790 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs @@ -1,9 +1,6 @@ namespace CommunityToolkit.Maui.Extensions; -/// -/// The SubtitleCue class represents a single cue in a subtitle file. -/// -public class SubtitleCue +class SubtitleCue { /// /// The number of the cue. diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 2f9638f9e1..4615e43eeb 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -9,10 +9,8 @@ using Activity = Android.App.Activity; namespace CommunityToolkit.Maui.Extensions; -/// -/// A class that provides subtitle support for a video player. -/// -public partial class SubtitleExtensions : IDisposable + +partial class SubtitleExtensions : IDisposable { bool disposedValue; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 1fd85de25d..dae9f7940f 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -9,10 +9,7 @@ namespace CommunityToolkit.Maui.Extensions; -/// -/// A class that provides subtitle support for a video player. -/// -public partial class SubtitleExtensions : UIViewController +partial class SubtitleExtensions : UIViewController { readonly HttpClient httpClient; readonly PlatformMediaElement player; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index de9cafca33..55f99763ac 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -5,10 +5,7 @@ namespace CommunityToolkit.Maui.Extensions; -/// -/// A class that provides subtitle support for a video player. -/// -public partial class SubtitleExtensions : Grid, IDisposable +partial class SubtitleExtensions : Grid, IDisposable { bool disposedValue; bool isFullScreen = false; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index aeec7acecd..8bf63d3366 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -3,10 +3,7 @@ namespace CommunityToolkit.Maui.Extensions; -/// -/// A class that provides subtitle parser for VTT files. -/// -public static partial class VttParser +static partial class VttParser { static readonly string[] separator = ["\r\n", "\n"]; From bf24dd38399afd46399e7cc5aec48c08764b7dad Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 20 Jun 2024 16:18:55 -0700 Subject: [PATCH 28/98] Refactor subtitle parsers for code compliance. 'private' is not allowed in code compliance. - Added static `timecodePattern` Regex fields in `SrtParser.cs` and `VttParser.cs` for matching timecodes in SRT and VTT formats, respectively. - Removed `MyRegex` method and `[GeneratedRegex]` attribute from both parsers, leveraging the new static regex patterns for improved consistency and performance. - Updated `ParseSrtContent` in `SrtParser.cs` and `ParseVttContent` in `VttParser.cs` to use the static `timecodePattern` fields, eliminating redundant Regex object instantiation. --- .../Extensions/SrtParser.cs | 6 +----- .../Extensions/VttParser.cs | 7 +------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index a360cc8c68..c86d0aa655 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -6,6 +6,7 @@ namespace CommunityToolkit.Maui.Extensions; static partial class SrtParser { static readonly string[] separator = ["\r\n", "\n"]; + static readonly Regex timecodePattern = new(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})"); /// /// a method that parses the SRT content and returns a list of SubtitleCue objects. @@ -21,8 +22,6 @@ public static List ParseSrtContent(string srtContent) } string[] lines = srtContent.Split(separator, StringSplitOptions.None); - Regex timecodePattern = MyRegex(); - SubtitleCue? currentCue = null; foreach (var line in lines) { @@ -67,8 +66,5 @@ static TimeSpan ParseTimecode(string timecode) { return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); } - - [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})")] - private static partial Regex MyRegex(); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index 8bf63d3366..341f5ecd4f 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -6,7 +6,7 @@ namespace CommunityToolkit.Maui.Extensions; static partial class VttParser { static readonly string[] separator = ["\r\n", "\n"]; - + static readonly Regex timecodePattern = new((@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})")); /// /// The ParseVttContent method parses the VTT content and returns a list of SubtitleCue objects. /// @@ -21,8 +21,6 @@ public static List ParseVttContent(string vttContent) } string[] lines = vttContent.Split(separator, StringSplitOptions.None); - Regex timecodePattern = MyRegex(); - SubtitleCue? currentCue = null; foreach (var line in lines) { @@ -58,7 +56,4 @@ public static List ParseVttContent(string vttContent) return cues; } - - [GeneratedRegex(@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})")] - private static partial Regex MyRegex(); } From 61c674536589a5d92dc09269bbcb495de02430f7 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 20 Jun 2024 16:39:19 -0700 Subject: [PATCH 29/98] Refactor HttpClient usage in SubtitleExtensions This commit centralizes the HttpClient instance across the SubtitleExtensions class for Android, macOS, and Windows platforms by changing the `httpClient` variable from an instance to a static variable. This change promotes efficient resource management and ensures consistency in handling HTTP requests across different platform implementations. Additionally, constructors have been adjusted to remove the now unnecessary instantiation of `HttpClient`, further streamlining the codebase. --- .../Extensions/SubtitleExtensions.android.cs | 3 +-- .../Extensions/SubtitleExtensions.macios.cs | 3 +-- .../Extensions/SubtitleExtensions.windows.cs | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 4615e43eeb..62a4360ca3 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -14,7 +14,7 @@ partial class SubtitleExtensions : IDisposable { bool disposedValue; - readonly HttpClient httpClient; + static readonly HttpClient httpClient = new(); readonly IDispatcher dispatcher; readonly RelativeLayout.LayoutParams? textBlockLayout; readonly StyledPlayerView styledPlayerView; @@ -30,7 +30,6 @@ partial class SubtitleExtensions : IDisposable /// public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatcher) { - httpClient = new HttpClient(); this.dispatcher = dispatcher; this.styledPlayerView = styledPlayerView; cues = []; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index dae9f7940f..a59a5b30df 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -11,7 +11,7 @@ namespace CommunityToolkit.Maui.Extensions; partial class SubtitleExtensions : UIViewController { - readonly HttpClient httpClient; + static readonly HttpClient httpClient = new(); readonly PlatformMediaElement player; readonly UIViewController playerViewController; readonly UILabel subtitleLabel; @@ -31,7 +31,6 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi this.playerViewController = playerViewController; this.player = player; cues = []; - httpClient = new HttpClient(); subtitleLabel = new UILabel { Frame = CalculateSubtitleFrame(playerViewController), diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 55f99763ac..e3361b6a40 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -10,7 +10,7 @@ partial class SubtitleExtensions : Grid, IDisposable bool disposedValue; bool isFullScreen = false; - readonly HttpClient httpClient; + static readonly HttpClient httpClient = new(); readonly Microsoft.UI.Xaml.Controls.TextBlock xamlTextBlock; List cues; @@ -23,7 +23,6 @@ partial class SubtitleExtensions : Grid, IDisposable /// public SubtitleExtensions() { - httpClient = new(); cues = []; MauiMediaElement.WindowsChanged += MauiMediaElement_WindowsChanged; xamlTextBlock = new() From cfb47ec3dc42224c75449ca8159fbd382f15bba5 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 20 Jun 2024 17:03:19 -0700 Subject: [PATCH 30/98] Refactor subtitle handling and UI adjustments This commit overhauls the subtitle display and management across Android, iOS/macOS, and Windows platforms. Key changes include: - Renaming `textBlock` and `textBlockLayout` to `subtitleView` and `subtitleLayout`, respectively, for clarity in their purpose. - Correcting the event name from `WindowsChanged` to `WindowChanged` in multiple files to fix a typo and standardize the event across the codebase. - Updating method signatures and event handler implementations to align with the new variable and event names, ensuring consistency in subtitle initialization, display, and removal. - Adjusting subtitle-related UI component properties (e.g., font settings, visibility, layout parameters) to improve subtitle presentation in line with media element settings. - Modifying event handling logic to support the updated event name and improve subtitle management during window state changes. - Enhancing resource cleanup by updating `Dispose` methods for new subtitle-related variables to prevent memory leaks. - Conducting general code cleanup to remove unused code and improve code clarity and maintainability. These changes aim to enhance the readability, maintainability, and functionality of subtitle handling in media elements, providing a more intuitive and smooth user experience. --- .../Extensions/SubtitleExtensions.android.cs | 62 ++++++------- .../Extensions/SubtitleExtensions.macios.cs | 45 +++++----- .../Extensions/SubtitleExtensions.windows.cs | 89 ++++++++++--------- .../Views/MauiMediaElement.android.cs | 6 +- .../Views/MauiMediaElement.windows.cs | 6 +- .../Views/MediaManager.macios.cs | 6 +- 6 files changed, 107 insertions(+), 107 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 62a4360ca3..ad93436ae2 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -16,12 +16,12 @@ partial class SubtitleExtensions : IDisposable static readonly HttpClient httpClient = new(); readonly IDispatcher dispatcher; - readonly RelativeLayout.LayoutParams? textBlockLayout; + readonly RelativeLayout.LayoutParams? subtitleLayout; readonly StyledPlayerView styledPlayerView; List cues; IMediaElement? mediaElement; - TextView? textBlock; + TextView? subtitleView; System.Timers.Timer? timer; /// @@ -34,12 +34,12 @@ public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatc this.styledPlayerView = styledPlayerView; cues = []; - textBlockLayout = new RelativeLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent); - textBlockLayout.AddRule(LayoutRules.AlignParentBottom); - textBlockLayout.AddRule(LayoutRules.CenterHorizontal); + subtitleLayout = new RelativeLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent); + subtitleLayout.AddRule(LayoutRules.AlignParentBottom); + subtitleLayout.AddRule(LayoutRules.CenterHorizontal); InitializeTextBlock(); - MauiMediaElement.WindowsChanged += MauiMediaElement_WindowsChanged; + MauiMediaElement.WindowChanged += OnWindowStatusChanged; } /// @@ -73,13 +73,13 @@ var url when url.EndsWith("vtt") => VttParser.ParseVttContent(vttContent), /// public void StartSubtitleDisplay() { - ArgumentNullException.ThrowIfNull(textBlock); + ArgumentNullException.ThrowIfNull(subtitleView); if(styledPlayerView.Parent is not ViewGroup parent) { System.Diagnostics.Trace.TraceError("StyledPlayerView parent is not a ViewGroup"); return; } - dispatcher.Dispatch(() => parent.AddView(textBlock)); + dispatcher.Dispatch(() => parent.AddView(subtitleView)); timer = new System.Timers.Timer(1000); timer.Elapsed += Timer_Elapsed; timer.Start(); @@ -90,22 +90,22 @@ public void StartSubtitleDisplay() /// public void StopSubtitleDisplay() { - if (timer is null || textBlock is null) + if (timer is null || subtitleView is null) { return; } if (styledPlayerView.Parent is ViewGroup parent) { - dispatcher.Dispatch(() => parent.RemoveView(textBlock)); + dispatcher.Dispatch(() => parent.RemoveView(subtitleView)); } - textBlock.Text = string.Empty; + subtitleView.Text = string.Empty; timer.Stop(); timer.Elapsed -= Timer_Elapsed; } void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { - ArgumentNullException.ThrowIfNull(textBlock); + ArgumentNullException.ThrowIfNull(subtitleView); ArgumentNullException.ThrowIfNull(mediaElement); if (cues.Count == 0) { @@ -117,15 +117,15 @@ void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { if (cue is not null) { - textBlock.FontFeatureSettings = !string.IsNullOrEmpty(mediaElement.SubtitleFont) ? mediaElement.SubtitleFont : default; - textBlock.Text = cue.Text; - textBlock.TextSize = (float)mediaElement.SubtitleFontSize; - textBlock.Visibility = Android.Views.ViewStates.Visible; + subtitleView.FontFeatureSettings = !string.IsNullOrEmpty(mediaElement.SubtitleFont) ? mediaElement.SubtitleFont : default; + subtitleView.Text = cue.Text; + subtitleView.TextSize = (float)mediaElement.SubtitleFontSize; + subtitleView.Visibility = Android.Views.ViewStates.Visible; } else { - textBlock.Text = string.Empty; - textBlock.Visibility = Android.Views.ViewStates.Gone; + subtitleView.Text = string.Empty; + subtitleView.Visibility = Android.Views.ViewStates.Gone; } }); } @@ -134,18 +134,18 @@ void InitializeTextBlock() { var (currentActivity, _, _, _) = VerifyAndRetrieveCurrentWindowResources(); var activity = currentActivity; - textBlock = new(activity.ApplicationContext) + subtitleView = new(activity.ApplicationContext) { Text = string.Empty, HorizontalScrollBarEnabled = false, VerticalScrollBarEnabled = false, TextAlignment = Android.Views.TextAlignment.Center, Visibility = Android.Views.ViewStates.Gone, - LayoutParameters = textBlockLayout + LayoutParameters = subtitleLayout }; - textBlock.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); - textBlock.SetTextColor(Android.Graphics.Color.White); - textBlock.SetPaddingRelative(10, 10, 10, 20); + subtitleView.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); + subtitleView.SetTextColor(Android.Graphics.Color.White); + subtitleView.SetPaddingRelative(10, 10, 10, 20); } static (Activity CurrentActivity, Android.Views.Window CurrentWindow, Resources CurrentWindowResources, Configuration CurrentWindowConfiguration) VerifyAndRetrieveCurrentWindowResources() @@ -173,9 +173,9 @@ void InitializeTextBlock() return (currentActivity, currentWindow, currentResources, configuration); } - void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) + void OnWindowStatusChanged(object? sender, WindowsEventArgs e) { - ArgumentNullException.ThrowIfNull(textBlock); + ArgumentNullException.ThrowIfNull(subtitleView); // If the subtitle URL is empty do nothing if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) @@ -194,14 +194,14 @@ void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) switch (e.data) { case true: - viewGroup.RemoveView(textBlock); + viewGroup.RemoveView(subtitleView); InitializeTextBlock(); - parent.AddView(textBlock); + parent.AddView(subtitleView); break; case false: - parent.RemoveView(textBlock); + parent.RemoveView(subtitleView); InitializeTextBlock(); - viewGroup.AddView(textBlock); + viewGroup.AddView(subtitleView); break; } } @@ -214,11 +214,11 @@ protected virtual void Dispose(bool disposing) { httpClient.Dispose(); timer?.Dispose(); - textBlock?.Dispose(); + subtitleView?.Dispose(); } timer = null; - textBlock = null; + subtitleView = null; disposedValue = true; } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index a59a5b30df..868a46a767 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -41,28 +41,7 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi Lines = 0, AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleTopMargin | UIViewAutoresizing.FlexibleHeight | UIViewAutoresizing.FlexibleBottomMargin }; - MediaManagerDelegate.WindowsChanged += MauiMediaElement_WindowsChanged; - } - - void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) - { - if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl) || e.data is null) - { - return; - } - switch (e.data) - { - case true: - viewController = WindowStateManager.Default.GetCurrentUIViewController() ?? throw new ArgumentException(nameof(viewController)); - ArgumentNullException.ThrowIfNull(viewController.View); - subtitleLabel.Frame = CalculateSubtitleFrame(viewController); - viewController.View.AddSubview(subtitleLabel); - break; - case false: - subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); - viewController = null; - break; - } + MediaManagerDelegate.WindowChanged += OnWindowStatusChanged; } /// @@ -153,6 +132,26 @@ static CGRect CalculateSubtitleFrame(UIViewController uIViewController) ArgumentNullException.ThrowIfNull(uIViewController?.View?.Bounds); return new CGRect(0, uIViewController.View.Bounds.Height - 60, uIViewController.View.Bounds.Width, 50); } - + + void OnWindowStatusChanged(object? sender, WindowsEventArgs e) + { + if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl) || e.data is null) + { + return; + } + switch (e.data) + { + case true: + viewController = WindowStateManager.Default.GetCurrentUIViewController() ?? throw new ArgumentException(nameof(viewController)); + ArgumentNullException.ThrowIfNull(viewController.View); + subtitleLabel.Frame = CalculateSubtitleFrame(viewController); + viewController.View.AddSubview(subtitleLabel); + break; + case false: + subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); + viewController = null; + break; + } + } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index e3361b6a40..02d0af60eb 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -11,7 +11,7 @@ partial class SubtitleExtensions : Grid, IDisposable bool isFullScreen = false; static readonly HttpClient httpClient = new(); - readonly Microsoft.UI.Xaml.Controls.TextBlock xamlTextBlock; + readonly Microsoft.UI.Xaml.Controls.TextBlock subtitleTextBlock; List cues; IMediaElement? mediaElement; @@ -24,8 +24,8 @@ partial class SubtitleExtensions : Grid, IDisposable public SubtitleExtensions() { cues = []; - MauiMediaElement.WindowsChanged += MauiMediaElement_WindowsChanged; - xamlTextBlock = new() + MauiMediaElement.WindowChanged += OnWindowStatusChanged; + subtitleTextBlock = new() { Text = string.Empty, Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20), @@ -36,31 +36,6 @@ public SubtitleExtensions() TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap, }; } - void MauiMediaElement_WindowsChanged(object? sender, WindowsEventArgs e) - { - if (e.data is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) - { - return; - } - ArgumentNullException.ThrowIfNull(mauiMediaElement); - - switch (isFullScreen) - { - case true: - xamlTextBlock.FontSize = mediaElement.SubtitleFontSize; - xamlTextBlock.FontFamily = new FontFamily(mediaElement.SubtitleFont); - xamlTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20); - Dispatcher.Dispatch(() => { gridItem.Children.Remove(xamlTextBlock); mauiMediaElement.Children.Add(xamlTextBlock); }); - isFullScreen = false; - break; - case false: - xamlTextBlock.FontSize = mediaElement.SubtitleFontSize + 8.0; - xamlTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 300); - Dispatcher.Dispatch(() => { mauiMediaElement.Children.Remove(xamlTextBlock); gridItem.Children.Add(xamlTextBlock); }); - isFullScreen = true; - break; - } - } /// /// Loads the subtitles from the provided URL. @@ -72,7 +47,7 @@ public async Task LoadSubtitles(IMediaElement mediaElement, Microsoft.UI.Xaml.Co this.mediaElement = mediaElement; mauiMediaElement = player?.Parent as MauiMediaElement; cues.Clear(); - xamlTextBlock.FontSize = mediaElement.SubtitleFontSize; + subtitleTextBlock.FontSize = mediaElement.SubtitleFontSize; string? vttContent; try { @@ -97,12 +72,30 @@ var url when url.EndsWith("vtt") => VttParser.ParseVttContent(vttContent), /// public void StartSubtitleDisplay() { - Dispatcher.Dispatch(() => mauiMediaElement?.Children.Add(xamlTextBlock)); + Dispatcher.Dispatch(() => mauiMediaElement?.Children.Add(subtitleTextBlock)); timer = new System.Timers.Timer(1000); timer.Elapsed += Timer_Elapsed; timer.Start(); } + /// + /// Stops the subtitle timer. + /// + public void StopSubtitleDisplay() + { + if (timer is null) + { + return; + } + timer.Stop(); + timer.Elapsed -= Timer_Elapsed; + if(mauiMediaElement is null) + { + return; + } + Dispatcher.Dispatch(() => mauiMediaElement.Children.Remove(subtitleTextBlock)); + } + void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) @@ -114,33 +107,41 @@ void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { if (cue is not null) { - xamlTextBlock.Text = cue.Text; - xamlTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; + subtitleTextBlock.Text = cue.Text; + subtitleTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } else { - xamlTextBlock.Text = string.Empty; - xamlTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; + subtitleTextBlock.Text = string.Empty; + subtitleTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; } }); } - /// - /// Stops the subtitle timer. - /// - public void StopSubtitleDisplay() + void OnWindowStatusChanged(object? sender, WindowsEventArgs e) { - if (timer is null) + if (e.data is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) { return; } - timer.Stop(); - timer.Elapsed -= Timer_Elapsed; - if(mauiMediaElement is null) + ArgumentNullException.ThrowIfNull(mauiMediaElement); + + switch (isFullScreen) { - return; + case true: + subtitleTextBlock.FontSize = mediaElement.SubtitleFontSize; + subtitleTextBlock.FontFamily = new FontFamily(mediaElement.SubtitleFont); + subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20); + Dispatcher.Dispatch(() => { gridItem.Children.Remove(subtitleTextBlock); mauiMediaElement.Children.Add(subtitleTextBlock); }); + isFullScreen = false; + break; + case false: + subtitleTextBlock.FontSize = mediaElement.SubtitleFontSize + 8.0; + subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 300); + Dispatcher.Dispatch(() => { mauiMediaElement.Children.Remove(subtitleTextBlock); gridItem.Children.Add(subtitleTextBlock); }); + isFullScreen = true; + break; } - Dispatcher.Dispatch(() => mauiMediaElement.Children.Remove(xamlTextBlock)); } /// diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs index 7a08ed7708..eba2673e5f 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs @@ -20,7 +20,7 @@ public class MauiMediaElement : CoordinatorLayout /// /// Handles the event when the windows change. /// - public static event EventHandler? WindowsChanged; + public static event EventHandler? WindowChanged; int defaultSystemUiVisibility; bool isSystemBarVisible; @@ -64,9 +64,9 @@ public MauiMediaElement(Context context, StyledPlayerView playerView) : base(con } /// - /// A method that raises the WindowsChanged event. + /// A method that raises the WindowChanged event. /// - protected virtual void OnWindowsChanged(WindowsEventArgs e) => WindowsChanged?.Invoke(null, e); + protected virtual void OnWindowsChanged(WindowsEventArgs e) => WindowChanged?.Invoke(null, e); public override void OnDetachedFromWindow() { if (isFullScreen) diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs index 502c30db9a..918f26b596 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs @@ -27,7 +27,7 @@ public class MauiMediaElement : Grid, IDisposable /// /// Handles the event when the windows change. /// - public static event EventHandler? WindowsChanged; + public static event EventHandler? WindowChanged; static readonly AppWindow appWindow = GetAppWindowForCurrentWindow(); readonly Popup popup = new(); readonly Grid fullScreenGrid = new(); @@ -81,11 +81,11 @@ public MauiMediaElement(MediaPlayerElement mediaPlayerElement) ~MauiMediaElement() => Dispose(false); /// - /// A method that raises the WindowsChanged event. + /// A method that raises the WindowChanged event. /// protected virtual void OnWindowsChanged(WindowsEventArgs e) { - WindowsChanged?.Invoke(null, e); + WindowChanged?.Invoke(null, e); } /// diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index 0ff8bfb66a..3cebbc8fa3 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -654,7 +654,7 @@ sealed class MediaManagerDelegate : AVPlayerViewControllerDelegate /// /// Handles the event when the windows change. /// - public static event EventHandler? WindowsChanged; + public static event EventHandler? WindowChanged; public override void WillBeginFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) { OnWindowsChanged(new WindowsEventArgs(true)); @@ -664,7 +664,7 @@ public override void WillEndFullScreenPresentation(AVPlayerViewController player OnWindowsChanged(new WindowsEventArgs(false)); } /// - /// A method that raises the WindowsChanged event. + /// A method that raises the WindowChanged event. /// - static void OnWindowsChanged(WindowsEventArgs e) => WindowsChanged?.Invoke(null, e); + static void OnWindowsChanged(WindowsEventArgs e) => WindowChanged?.Invoke(null, e); } \ No newline at end of file From d1f3133b42658e5a870ccaee113ceeeffd5c1398 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 20 Jun 2024 17:10:50 -0700 Subject: [PATCH 31/98] Refactor subtitle update and cleanup logic - Renamed `Timer_Elapsed` to `UpdateSubtitle` in `SubtitleExtensions` files to better reflect its purpose. - Updated event subscription/unsubscription to use `UpdateSubtitle`, ensuring correct event handling. - Enhanced disposal pattern in both Android and Windows extensions to include safety checks and ensure proper cleanup of the timer and `httpClient`, preventing memory leaks and null reference exceptions. --- .../Extensions/SubtitleExtensions.android.cs | 12 +++++++++--- .../Extensions/SubtitleExtensions.windows.cs | 9 +++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index ad93436ae2..f111696ad4 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -81,7 +81,7 @@ public void StartSubtitleDisplay() } dispatcher.Dispatch(() => parent.AddView(subtitleView)); timer = new System.Timers.Timer(1000); - timer.Elapsed += Timer_Elapsed; + timer.Elapsed += UpdateSubtitle; timer.Start(); } @@ -100,10 +100,10 @@ public void StopSubtitleDisplay() } subtitleView.Text = string.Empty; timer.Stop(); - timer.Elapsed -= Timer_Elapsed; + timer.Elapsed -= UpdateSubtitle; } - void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) + void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { ArgumentNullException.ThrowIfNull(subtitleView); ArgumentNullException.ThrowIfNull(mediaElement); @@ -210,6 +210,12 @@ protected virtual void Dispose(bool disposing) { if (!disposedValue) { + if (timer is not null) + { + timer.Stop(); + timer.Elapsed -= UpdateSubtitle; + } + if (disposing) { httpClient.Dispose(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 02d0af60eb..f94c1832ef 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -74,7 +74,7 @@ public void StartSubtitleDisplay() { Dispatcher.Dispatch(() => mauiMediaElement?.Children.Add(subtitleTextBlock)); timer = new System.Timers.Timer(1000); - timer.Elapsed += Timer_Elapsed; + timer.Elapsed += UpdateSubtitle; timer.Start(); } @@ -88,7 +88,7 @@ public void StopSubtitleDisplay() return; } timer.Stop(); - timer.Elapsed -= Timer_Elapsed; + timer.Elapsed -= UpdateSubtitle; if(mauiMediaElement is null) { return; @@ -96,7 +96,7 @@ public void StopSubtitleDisplay() Dispatcher.Dispatch(() => mauiMediaElement.Children.Remove(subtitleTextBlock)); } - void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) + void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) { @@ -155,8 +155,9 @@ protected virtual void Dispose(bool disposing) if (timer is not null) { timer.Stop(); - timer.Elapsed -= Timer_Elapsed; + timer.Elapsed -= UpdateSubtitle; } + if (disposing) { httpClient?.Dispose(); From 386ad50cebb74fc2ff449fb28debd283f9a6657d Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 20 Jun 2024 22:17:33 -0700 Subject: [PATCH 32/98] Refactor and enhance subtitle handling This commit introduces several key changes aimed at improving the performance, clarity, and resource management within the CommunityToolkit.Maui.Extensions library, particularly around media playback and subtitle handling across various platforms. Key changes include: - Refactoring `SrtParser` and `VttParser` classes to utilize the `[GeneratedRegex]` attribute for more efficient Regex initialization. - Converting the `SubtitleCue` class to sealed to prevent inheritance and ensure its integrity. - Modifying the base class/interface of `SubtitleExtensions` for Android, macOS, and Windows to better align with platform-specific needs, alongside shifting event handling focus from window status to full-screen status changes. - Introducing `FullScreenEventArgs` and `GridEventArgs` to replace `WindowsEventArgs`, refining event data specificity for better clarity in event handling. - Updating event handling in `MauiMediaElement` across Android and Windows to utilize the new event argument classes, ensuring consistency with the library's refocused event handling strategy. - Implementing adjustments in `MediaManager` classes for Android and macOS, including clearer CancellationTokenSource variable names and improved resource cleanup patterns, as well as updating macOS event handling to use `FullScreenEventArgs`. These changes collectively enhance the library's approach to handling media and subtitles, focusing on performance optimization, clearer code, and better resource management. --- .../Extensions/SrtParser.cs | 5 ++- .../Extensions/SubtitleCue.cs | 2 +- .../Extensions/SubtitleExtensions.android.cs | 8 ++-- .../Extensions/SubtitleExtensions.macios.cs | 44 ++++++++++++------- .../Extensions/SubtitleExtensions.windows.cs | 15 ++++--- .../Extensions/VttParser.cs | 5 ++- .../Primitives/FullScreenEventArgs.cs | 20 +++++++++ .../Primitives/GridEventArgs.windows.cs | 27 ++++++++++++ .../Primitives/WindowsEventArgs.cs | 20 --------- .../Views/MauiMediaElement.android.cs | 10 ++--- .../Views/MauiMediaElement.windows.cs | 12 ++--- .../Views/MediaManager.android.cs | 35 +++++++-------- .../Views/MediaManager.macios.cs | 43 ++++++++---------- .../Views/MediaManager.windows.cs | 12 ++--- 14 files changed, 144 insertions(+), 114 deletions(-) create mode 100644 src/CommunityToolkit.Maui.MediaElement/Primitives/FullScreenEventArgs.cs create mode 100644 src/CommunityToolkit.Maui.MediaElement/Primitives/GridEventArgs.windows.cs delete mode 100644 src/CommunityToolkit.Maui.MediaElement/Primitives/WindowsEventArgs.cs diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index c86d0aa655..a5db2b4f00 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -6,7 +6,7 @@ namespace CommunityToolkit.Maui.Extensions; static partial class SrtParser { static readonly string[] separator = ["\r\n", "\n"]; - static readonly Regex timecodePattern = new(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})"); + static readonly Regex timecodePattern = MyRegex(); /// /// a method that parses the SRT content and returns a list of SubtitleCue objects. @@ -66,5 +66,8 @@ static TimeSpan ParseTimecode(string timecode) { return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); } + + [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})")] + private static partial Regex MyRegex(); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs index 271f3d9790..6a99919aad 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs @@ -1,6 +1,6 @@ namespace CommunityToolkit.Maui.Extensions; -class SubtitleCue +sealed class SubtitleCue { /// /// The number of the cue. diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index f111696ad4..310953ec37 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -10,7 +10,7 @@ namespace CommunityToolkit.Maui.Extensions; -partial class SubtitleExtensions : IDisposable +class SubtitleExtensions : IDisposable { bool disposedValue; @@ -39,7 +39,7 @@ public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatc subtitleLayout.AddRule(LayoutRules.CenterHorizontal); InitializeTextBlock(); - MauiMediaElement.WindowChanged += OnWindowStatusChanged; + MauiMediaElement.FullScreenChanged += OnFullScreenChanged; } /// @@ -173,7 +173,7 @@ void InitializeTextBlock() return (currentActivity, currentWindow, currentResources, configuration); } - void OnWindowStatusChanged(object? sender, WindowsEventArgs e) + void OnFullScreenChanged(object? sender, FullScreenEventArgs e) { ArgumentNullException.ThrowIfNull(subtitleView); @@ -191,7 +191,7 @@ void OnWindowStatusChanged(object? sender, WindowsEventArgs e) { return; } - switch (e.data) + switch (e.isFullScreen) { case true: viewGroup.RemoveView(subtitleView); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 868a46a767..10e5e35fba 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -9,7 +9,7 @@ namespace CommunityToolkit.Maui.Extensions; -partial class SubtitleExtensions : UIViewController +class SubtitleExtensions : UIViewController { static readonly HttpClient httpClient = new(); readonly PlatformMediaElement player; @@ -41,7 +41,8 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi Lines = 0, AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleTopMargin | UIViewAutoresizing.FlexibleHeight | UIViewAutoresizing.FlexibleBottomMargin }; - MediaManagerDelegate.WindowChanged += OnWindowStatusChanged; + DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); + MediaManagerDelegate.FullScreenChanged += OnFullScreenChanged; } /// @@ -81,15 +82,6 @@ public void StartSubtitleDisplay() playerObserver = player?.AddPeriodicTimeObserver(CMTime.FromSeconds(1, 1), null, (time) => { TimeSpan currentPlaybackTime = TimeSpan.FromSeconds(time.Seconds); - switch(viewController) - { - case null: - DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); - break; - default: - DispatchQueue.MainQueue.DispatchAsync(() => viewController?.View?.AddSubview(subtitleLabel)); - break; - } subtitleLabel.Frame = viewController is not null ? CalculateSubtitleFrame(viewController) : CalculateSubtitleFrame(playerViewController); DispatchQueue.MainQueue.DispatchAsync(() => UpdateSubtitle(currentPlaybackTime)); }); @@ -128,30 +120,48 @@ void UpdateSubtitle(TimeSpan currentPlaybackTime) } static CGRect CalculateSubtitleFrame(UIViewController uIViewController) - { - ArgumentNullException.ThrowIfNull(uIViewController?.View?.Bounds); + { + if(uIViewController is null || uIViewController.View is null) + { + return CGRect.Empty; + } return new CGRect(0, uIViewController.View.Bounds.Height - 60, uIViewController.View.Bounds.Width, 50); } - void OnWindowStatusChanged(object? sender, WindowsEventArgs e) + void OnFullScreenChanged(object? sender, FullScreenEventArgs e) { - if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl) || e.data is null) + if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) { return; } - switch (e.data) + subtitleLabel.Text = string.Empty; + switch (e.isFullScreen) { case true: viewController = WindowStateManager.Default.GetCurrentUIViewController() ?? throw new ArgumentException(nameof(viewController)); ArgumentNullException.ThrowIfNull(viewController.View); subtitleLabel.Frame = CalculateSubtitleFrame(viewController); - viewController.View.AddSubview(subtitleLabel); + DispatchQueue.MainQueue.DispatchAsync(() => { subtitleLabel.RemoveFromSuperview(); viewController?.View?.AddSubview(subtitleLabel); }); break; case false: subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); + DispatchQueue.MainQueue.DispatchAsync(() => { subtitleLabel.RemoveFromSuperview(); playerViewController.View?.AddSubview(subtitleLabel); }); viewController = null; break; } } + ~SubtitleExtensions() + { + MediaManagerDelegate.FullScreenChanged -= OnFullScreenChanged; + subtitleLabel.Text = string.Empty; + subtitleLabel.RemoveFromSuperview(); + if(playerObserver is not null) + { + player.RemoveTimeObserver(playerObserver); + playerObserver.Dispose(); + playerObserver = null; + } + subtitleLabel.Dispose(); + } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index f94c1832ef..f88f28e219 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -5,7 +5,7 @@ namespace CommunityToolkit.Maui.Extensions; -partial class SubtitleExtensions : Grid, IDisposable +class SubtitleExtensions : Grid, IDisposable { bool disposedValue; bool isFullScreen = false; @@ -24,7 +24,7 @@ partial class SubtitleExtensions : Grid, IDisposable public SubtitleExtensions() { cues = []; - MauiMediaElement.WindowChanged += OnWindowStatusChanged; + MauiMediaElement.GridEventsChanged += OnFullScreenChanged; subtitleTextBlock = new() { Text = string.Empty, @@ -47,6 +47,7 @@ public async Task LoadSubtitles(IMediaElement mediaElement, Microsoft.UI.Xaml.Co this.mediaElement = mediaElement; mauiMediaElement = player?.Parent as MauiMediaElement; cues.Clear(); + subtitleTextBlock.Text = string.Empty; subtitleTextBlock.FontSize = mediaElement.SubtitleFontSize; string? vttContent; try @@ -72,8 +73,8 @@ var url when url.EndsWith("vtt") => VttParser.ParseVttContent(vttContent), /// public void StartSubtitleDisplay() { - Dispatcher.Dispatch(() => mauiMediaElement?.Children.Add(subtitleTextBlock)); timer = new System.Timers.Timer(1000); + Dispatcher.Dispatch(() => mauiMediaElement?.Children.Add(subtitleTextBlock)); timer.Elapsed += UpdateSubtitle; timer.Start(); } @@ -93,7 +94,7 @@ public void StopSubtitleDisplay() { return; } - Dispatcher.Dispatch(() => mauiMediaElement.Children.Remove(subtitleTextBlock)); + Dispatcher.Dispatch(() => mauiMediaElement?.Children.Remove(subtitleTextBlock)); } void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) @@ -118,14 +119,14 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) }); } - void OnWindowStatusChanged(object? sender, WindowsEventArgs e) + void OnFullScreenChanged(object? sender, GridEventArgs e) { - if (e.data is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) + if (e.Grid is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) { return; } ArgumentNullException.ThrowIfNull(mauiMediaElement); - + subtitleTextBlock.Text = string.Empty; switch (isFullScreen) { case true: diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index 341f5ecd4f..1576fd77e5 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -6,7 +6,7 @@ namespace CommunityToolkit.Maui.Extensions; static partial class VttParser { static readonly string[] separator = ["\r\n", "\n"]; - static readonly Regex timecodePattern = new((@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})")); + static readonly Regex timecodePattern = MyRegex(); /// /// The ParseVttContent method parses the VTT content and returns a list of SubtitleCue objects. /// @@ -56,4 +56,7 @@ public static List ParseVttContent(string vttContent) return cues; } + + [GeneratedRegex(@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})")] + private static partial Regex MyRegex(); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Primitives/FullScreenEventArgs.cs b/src/CommunityToolkit.Maui.MediaElement/Primitives/FullScreenEventArgs.cs new file mode 100644 index 0000000000..e687a8d21b --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Primitives/FullScreenEventArgs.cs @@ -0,0 +1,20 @@ + +namespace CommunityToolkit.Maui.Primitives; +/// +/// +/// +public class FullScreenEventArgs : EventArgs +{ + /// + /// + /// + public bool isFullScreen { get; } + /// + /// + /// + /// + public FullScreenEventArgs(bool status) + { + this.isFullScreen = status; + } +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Primitives/GridEventArgs.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Primitives/GridEventArgs.windows.cs new file mode 100644 index 0000000000..817f49fd94 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Primitives/GridEventArgs.windows.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CommunityToolkit.Maui.Primitives; + +/// +/// +/// +public class GridEventArgs : EventArgs +{ + /// + /// + /// + public Microsoft.UI.Xaml.Controls.Grid Grid { get; } + + /// + /// + /// + /// + public GridEventArgs(Microsoft.UI.Xaml.Controls.Grid grid) + { + Grid = grid; + } +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Primitives/WindowsEventArgs.cs b/src/CommunityToolkit.Maui.MediaElement/Primitives/WindowsEventArgs.cs deleted file mode 100644 index 92d4e1e031..0000000000 --- a/src/CommunityToolkit.Maui.MediaElement/Primitives/WindowsEventArgs.cs +++ /dev/null @@ -1,20 +0,0 @@ - -namespace CommunityToolkit.Maui.Primitives; -/// -/// -/// -public class WindowsEventArgs : EventArgs -{ - /// - /// - /// - public object? data { get; } - /// - /// - /// - /// - public WindowsEventArgs(object? data) - { - this.data = data; - } -} diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs index eba2673e5f..1b36d07eca 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs @@ -20,7 +20,7 @@ public class MauiMediaElement : CoordinatorLayout /// /// Handles the event when the windows change. /// - public static event EventHandler? WindowChanged; + public static event EventHandler? FullScreenChanged; int defaultSystemUiVisibility; bool isSystemBarVisible; @@ -64,9 +64,9 @@ public MauiMediaElement(Context context, StyledPlayerView playerView) : base(con } /// - /// A method that raises the WindowChanged event. + /// A method that raises the FullScreenChanged event. /// - protected virtual void OnWindowsChanged(WindowsEventArgs e) => WindowChanged?.Invoke(null, e); + protected virtual void OnWindowsChanged(FullScreenEventArgs e) => FullScreenChanged?.Invoke(null, e); public override void OnDetachedFromWindow() { if (isFullScreen) @@ -155,14 +155,14 @@ void OnFullscreenButtonClick(object? sender, StyledPlayerView.FullscreenButtonCl isFullScreen = true; RemoveView(relativeLayout); layout?.AddView(relativeLayout); - OnWindowsChanged(new Maui.Primitives.WindowsEventArgs(isFullScreen)); + OnWindowsChanged(new Maui.Primitives.FullScreenEventArgs(isFullScreen)); } else { isFullScreen = false; layout?.RemoveView(relativeLayout); AddView(relativeLayout); - OnWindowsChanged(new Maui.Primitives.WindowsEventArgs(isFullScreen)); + OnWindowsChanged(new Maui.Primitives.FullScreenEventArgs(isFullScreen)); } // Hide/Show the SystemBars and Status bar SetSystemBarsVisibility(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs index 918f26b596..5d9e88371a 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs @@ -27,7 +27,7 @@ public class MauiMediaElement : Grid, IDisposable /// /// Handles the event when the windows change. /// - public static event EventHandler? WindowChanged; + public static event EventHandler? GridEventsChanged; static readonly AppWindow appWindow = GetAppWindowForCurrentWindow(); readonly Popup popup = new(); readonly Grid fullScreenGrid = new(); @@ -81,11 +81,11 @@ public MauiMediaElement(MediaPlayerElement mediaPlayerElement) ~MauiMediaElement() => Dispose(false); /// - /// A method that raises the WindowChanged event. + /// A method that raises the GridEventsChanged event. /// - protected virtual void OnWindowsChanged(WindowsEventArgs e) + protected virtual void OnGridEventsChanged(GridEventArgs e) { - WindowChanged?.Invoke(null, e); + GridEventsChanged?.Invoke(null, e); } /// @@ -169,7 +169,7 @@ void OnFullScreenButtonClick(object sender, RoutedEventArgs e) appWindow.SetPresenter(AppWindowPresenterKind.Default); Shell.SetNavBarIsVisible(CurrentPage, doesNavigationBarExistBeforeFullScreen); - OnWindowsChanged(new Maui.Primitives.WindowsEventArgs(fullScreenGrid)); + OnGridEventsChanged(new Maui.Primitives.GridEventArgs(fullScreenGrid)); if (popup.IsOpen) { popup.IsOpen = false; @@ -210,7 +210,7 @@ void OnFullScreenButtonClick(object sender, RoutedEventArgs e) { popup.IsOpen = true; } - OnWindowsChanged(new Maui.Primitives.WindowsEventArgs(fullScreenGrid)); + OnGridEventsChanged(new Maui.Primitives.GridEventArgs(fullScreenGrid)); } } } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index ad275e150b..0c1ce6aafb 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -43,7 +43,7 @@ public partial class MediaManager : Java.Lang.Object, IPlayer.IListener TaskCompletionSource? seekToTaskCompletionSource; CancellationTokenSource checkPermissionSourceToken = new(); CancellationTokenSource startServiceSourceToken = new(); - CancellationTokenSource subTitles = new(); + CancellationTokenSource subTitlesSourceToken = new(); Task? startSubtitles; Task? checkPermissionsTask; @@ -454,7 +454,7 @@ protected virtual partial void PlatformUpdateSource() if (hasSetSource && Player.PlayerError is null) { MediaElement.MediaOpened(); - CancellationToken token = subTitles.Token; + CancellationToken token = subTitlesSourceToken.Token; startSubtitles = LoadSubtitles(token); } } @@ -600,33 +600,28 @@ protected override void Dispose(bool disposing) if (disposing) { - StopService(); - subtitleExtensions?.StopSubtitleDisplay(); - subtitleExtensions = null; - subTitles?.Dispose(); - startSubtitles?.Dispose(); - startSubtitles = null; - - mediaSessionConnector?.SetPlayer(null); - mediaSessionConnector?.Dispose(); - mediaSessionConnector = null; - - mediaSession?.Release(); - mediaSession?.Dispose(); - mediaSession = null; - if (uiUpdateReceiver is not null) { LocalBroadcastManager.GetInstance(Platform.AppContext).UnregisterReceiver(uiUpdateReceiver); } - uiUpdateReceiver?.Dispose(); - uiUpdateReceiver = null; + StopService(); + mediaSessionConnector?.SetPlayer(null); + mediaSession?.Release(); + mediaSessionConnector?.Dispose(); + mediaSession?.Dispose(); + uiUpdateReceiver?.Dispose(); checkPermissionSourceToken.Dispose(); startServiceSourceToken.Dispose(); - + subTitlesSourceToken?.Dispose(); httpClient.Dispose(); + startSubtitles?.Dispose(); + + startSubtitles = null; + mediaSessionConnector = null; + mediaSession = null; + uiUpdateReceiver = null; } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index 3cebbc8fa3..bd2912cb67 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -17,7 +17,7 @@ public partial class MediaManager : IDisposable { Metadata? metaData; SubtitleExtensions? subtitleExtensions; - readonly CancellationTokenSource subTitles = new(); + readonly CancellationTokenSource subTitlesCancellationTokenSource = new(); Task? startSubtitles; // Media would still start playing when Speed was set although ShouldAutoPlay=False @@ -302,7 +302,7 @@ protected virtual partial void PlatformUpdateSource() { Player.Play(); } - CancellationToken token = subTitles.Token; + CancellationToken token = subTitlesCancellationTokenSource.Token; startSubtitles = LoadSubtitles(token); } else if (PlayerItem is null) @@ -443,33 +443,28 @@ protected virtual void Dispose(bool disposing) DestroyErrorObservers(); DestroyPlayedToEndObserver(); subtitleExtensions?.StopSubtitleDisplay(); + Player.ReplaceCurrentItemWithPlayerItem(null); + subtitleExtensions?.Dispose(); - subtitleExtensions = null; - subTitles.Dispose(); + subTitlesCancellationTokenSource.Dispose(); RateObserver?.Dispose(); - RateObserver = null; startSubtitles?.Dispose(); - startSubtitles = null; - CurrentItemErrorObserver?.Dispose(); - CurrentItemErrorObserver = null; - - Player.ReplaceCurrentItemWithPlayerItem(null); - MutedObserver?.Dispose(); - MutedObserver = null; - VolumeObserver?.Dispose(); - VolumeObserver = null; - StatusObserver?.Dispose(); - StatusObserver = null; - TimeControlStatusObserver?.Dispose(); - TimeControlStatusObserver = null; - Player.Dispose(); + + startSubtitles = null; + subtitleExtensions = null; Player = null; + CurrentItemErrorObserver = null; + MutedObserver = null; + RateObserver = null; + TimeControlStatusObserver = null; + StatusObserver = null; + VolumeObserver = null; } PlayerViewController?.Dispose(); @@ -654,17 +649,17 @@ sealed class MediaManagerDelegate : AVPlayerViewControllerDelegate /// /// Handles the event when the windows change. /// - public static event EventHandler? WindowChanged; + public static event EventHandler? FullScreenChanged; public override void WillBeginFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) { - OnWindowsChanged(new WindowsEventArgs(true)); + OnWindowsChanged(new FullScreenEventArgs(true)); } public override void WillEndFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) { - OnWindowsChanged(new WindowsEventArgs(false)); + OnWindowsChanged(new FullScreenEventArgs(false)); } /// - /// A method that raises the WindowChanged event. + /// A method that raises the FullScreenChanged event. /// - static void OnWindowsChanged(WindowsEventArgs e) => WindowChanged?.Invoke(null, e); + static void OnWindowsChanged(FullScreenEventArgs e) => FullScreenChanged?.Invoke(null, e); } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs index ae10e02fd9..8e1ff9edab 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs @@ -325,11 +325,6 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - if(subtitleExtensions is not null) - { - subtitleExtensions.Dispose(); - subtitleExtensions = null; - } if (Player?.MediaPlayer is not null) { if (displayActiveRequested) @@ -337,9 +332,7 @@ protected virtual void Dispose(bool disposing) DisplayRequest.RequestRelease(); displayActiveRequested = false; } - subTitles.Dispose(); - startSubtitles?.Dispose(); - startSubtitles = null; + subtitleExtensions?.StopSubtitleDisplay(); Player.MediaPlayer.MediaOpened -= OnMediaElementMediaOpened; Player.MediaPlayer.MediaFailed -= OnMediaElementMediaFailed; Player.MediaPlayer.MediaEnded -= OnMediaElementMediaEnded; @@ -353,6 +346,9 @@ protected virtual void Dispose(bool disposing) Player.MediaPlayer.PlaybackSession.SeekCompleted -= OnPlaybackSessionSeekCompleted; } } + + startSubtitles?.Dispose(); + startSubtitles = null; } } From 8d1749d22e21bc72cf0273d5844bc4a3db2aa121 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 20 Jun 2024 22:53:22 -0700 Subject: [PATCH 33/98] Refactor and centralize parser logic - Simplified namespace declarations in `SrtParser.cs` and `VttParser.cs` by removing unnecessary `using` directives. - Introduced `Parser.shared.cs` to centralize common logic for SRT and VTT parsers, including shared regular expressions, a common timecode parsing method, and a shared line separator. - Refactored `SrtParser.cs` and `VttParser.cs` to utilize centralized logic from `Parser.shared.cs`, enhancing code reuse and maintainability. - Removed duplicate code and definitions from `SrtParser.cs` and `VttParser.cs`, further reducing redundancy. - Adopted the `GeneratedRegex` attribute in `Parser.shared.cs` for defining regular expressions, improving performance and maintainability. --- .../Extensions/Parser.shared.cs | 26 +++++++++++++++++++ .../Extensions/SrtParser.cs | 22 ++++------------ .../Extensions/VttParser.cs | 18 ++++--------- 3 files changed, 36 insertions(+), 30 deletions(-) create mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.shared.cs diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.shared.cs new file mode 100644 index 0000000000..d74ab0f678 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.shared.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Maui.Extensions; +static partial class Parser +{ + public static readonly Regex TimecodePatternSRT = SRTRegex(); + public static readonly Regex TimecodePatternVTT = VTTRegex(); + public static readonly string[] Separator = ["\r\n", "\n"]; + + public static TimeSpan ParseTimecode(string timecode, bool isVtt) + { + if (isVtt) + { + return TimeSpan.Parse(timecode, CultureInfo.InvariantCulture); + } + + return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); + } + + [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})")] + private static partial Regex SRTRegex(); + + [GeneratedRegex(@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})")] + private static partial Regex VTTRegex(); +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index a5db2b4f00..4d00943dfe 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -1,13 +1,7 @@ -using System.Globalization; -using System.Text.RegularExpressions; - -namespace CommunityToolkit.Maui.Extensions; +namespace CommunityToolkit.Maui.Extensions; static partial class SrtParser { - static readonly string[] separator = ["\r\n", "\n"]; - static readonly Regex timecodePattern = MyRegex(); - /// /// a method that parses the SRT content and returns a list of SubtitleCue objects. /// @@ -20,7 +14,7 @@ public static List ParseSrtContent(string srtContent) { return cues; } - string[] lines = srtContent.Split(separator, StringSplitOptions.None); + string[] lines = srtContent.Split(Parser.Separator, StringSplitOptions.None); SubtitleCue? currentCue = null; foreach (var line in lines) @@ -29,7 +23,7 @@ public static List ParseSrtContent(string srtContent) { continue; } - var match = timecodePattern.Match(line); + var match = Parser.TimecodePatternSRT.Match(line); if (match.Success) { if (currentCue is not null) @@ -39,8 +33,8 @@ public static List ParseSrtContent(string srtContent) currentCue = new SubtitleCue { - StartTime = ParseTimecode(match.Groups[1].Value), - EndTime = ParseTimecode(match.Groups[2].Value), + StartTime = Parser.ParseTimecode(match.Groups[1].Value, false), + EndTime = Parser.ParseTimecode(match.Groups[2].Value, false), Text = "" }; } @@ -62,12 +56,6 @@ public static List ParseSrtContent(string srtContent) return cues; } - static TimeSpan ParseTimecode(string timecode) - { - return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); - } - [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})")] - private static partial Regex MyRegex(); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index 1576fd77e5..26f9c2d17c 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -1,12 +1,7 @@ -using System.Globalization; -using System.Text.RegularExpressions; - -namespace CommunityToolkit.Maui.Extensions; +namespace CommunityToolkit.Maui.Extensions; static partial class VttParser { - static readonly string[] separator = ["\r\n", "\n"]; - static readonly Regex timecodePattern = MyRegex(); /// /// The ParseVttContent method parses the VTT content and returns a list of SubtitleCue objects. /// @@ -19,12 +14,12 @@ public static List ParseVttContent(string vttContent) { return cues; } - string[] lines = vttContent.Split(separator, StringSplitOptions.None); + string[] lines = vttContent.Split(Parser.Separator, StringSplitOptions.None); SubtitleCue? currentCue = null; foreach (var line in lines) { - var match = timecodePattern.Match(line); + var match = Parser.TimecodePatternVTT.Match(line); if (match.Success) { if (currentCue is not null) @@ -34,8 +29,8 @@ public static List ParseVttContent(string vttContent) currentCue = new SubtitleCue { - StartTime = TimeSpan.Parse(match.Groups[1].Value, new CultureInfo("en-US")), - EndTime = TimeSpan.Parse(match.Groups[2].Value, new CultureInfo("en-US")), + StartTime = Parser.ParseTimecode(match.Groups[1].Value, true), + EndTime = Parser.ParseTimecode(match.Groups[2].Value, true), Text = "" }; } @@ -56,7 +51,4 @@ public static List ParseVttContent(string vttContent) return cues; } - - [GeneratedRegex(@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})")] - private static partial Regex MyRegex(); } From fb5f22cabe22f3d9d59e1bd09d340bbc24c240ad Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 20 Jun 2024 23:02:27 -0700 Subject: [PATCH 34/98] Make subtitle properties read-only in IMediaElement Changed the `SubtitleFont`, `SubtitleUrl`, and `SubtitleFontSize` properties in the `IMediaElement` interface from read-write to read-only. This update restricts these properties to be set only once, preventing further modifications after their initial assignment. --- .../Interfaces/IMediaElement.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs index a14a5d2254..7f5103f773 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs @@ -86,11 +86,11 @@ public interface IMediaElement : IView, IAsynchronousMediaElementHandler /// /// Gets or sets the font to use for the subtitle text. /// - string SubtitleFont { get; set; } + string SubtitleFont { get; } /// /// Gets or sets the URL of the subtitle file to display. /// - string SubtitleUrl { get; set; } + string SubtitleUrl { get; } /// /// Gets or sets the source of the media to play. @@ -107,7 +107,7 @@ public interface IMediaElement : IView, IAsynchronousMediaElementHandler /// /// Gets or sets the font size of the subtitle text. /// - double SubtitleFontSize { get; set; } + double SubtitleFontSize { get; } /// /// Gets or sets the volume of the audio for the media. /// From c98bf9f4d30ab41b269bd2d858d11918bcdd1129 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 21 Jun 2024 03:21:28 -0700 Subject: [PATCH 35/98] Refactor subtitle parsing and HTTP handling - Centralized HttpClient usage in Parser class, enhancing code reuse and simplifying HTTP request management across subtitle extensions. - Shifted regex usage to SrtParser and VttParser, optimizing where regular expressions are primarily utilized. - Introduced CommunityToolkit.Maui.Core in Parser.shared.cs for improved functionality within the Maui toolkit. - Added async methods for subtitle fetching and parsing in Parser.shared.cs, supporting SRT and VTT formats for better efficiency and application responsiveness. - Implemented GeneratedRegex attributes in SrtParser and VttParser for performance through compile-time optimized code. - Cleaned up unused Regex instances and refined class structures in Parser.shared.cs, SrtParser, and VttParser, focusing on the new parsing strategy and improving codebase clarity. - Enhanced error handling and logging in subtitle content fetching to ensure robust subtitle loading. - Updated UI and lifecycle management across platform-specific subtitle extensions and MediaManager.macios.cs to align with the new parsing and fetching strategy, ensuring consistent handling and resource management. --- .../Extensions/Parser.shared.cs | 43 +++++++++++++++---- .../Extensions/SrtParser.cs | 11 +++-- .../Extensions/SubtitleExtensions.android.cs | 21 +-------- .../Extensions/SubtitleExtensions.macios.cs | 33 ++++---------- .../Extensions/SubtitleExtensions.windows.cs | 20 +-------- .../Extensions/VttParser.cs | 11 ++++- .../Views/MediaManager.macios.cs | 5 ++- 7 files changed, 67 insertions(+), 77 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.shared.cs index d74ab0f678..23fbe5ffc7 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.shared.cs @@ -1,11 +1,10 @@ using System.Globalization; -using System.Text.RegularExpressions; +using CommunityToolkit.Maui.Core; namespace CommunityToolkit.Maui.Extensions; -static partial class Parser +static class Parser { - public static readonly Regex TimecodePatternSRT = SRTRegex(); - public static readonly Regex TimecodePatternVTT = VTTRegex(); + static readonly HttpClient httpClient = new(); public static readonly string[] Separator = ["\r\n", "\n"]; public static TimeSpan ParseTimecode(string timecode, bool isVtt) @@ -18,9 +17,37 @@ public static TimeSpan ParseTimecode(string timecode, bool isVtt) return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); } - [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})")] - private static partial Regex SRTRegex(); + public static async Task> Content(IMediaElement mediaElement) + { + string? vttContent; + var emptyList = new List(); + try + { + if (mediaElement.SubtitleUrl.EndsWith("srt") || mediaElement.SubtitleUrl.EndsWith("vtt")) + { + vttContent = await httpClient.GetStringAsync(mediaElement.SubtitleUrl).ConfigureAwait(false); + } + else + { + System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); + return emptyList; + } + } + catch (Exception ex) + { + System.Diagnostics.Trace.TraceError(ex.Message); + return emptyList; + } - [GeneratedRegex(@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})")] - private static partial Regex VTTRegex(); + switch (mediaElement.SubtitleUrl) + { + case var url when url.EndsWith("srt"): + return SrtParser.ParseSrtContent(vttContent); + case var url when url.EndsWith("vtt"): + return VttParser.ParseVttContent(vttContent); + default: + System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); + return emptyList; + } + } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index 4d00943dfe..ae9ef1d787 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -1,7 +1,11 @@ -namespace CommunityToolkit.Maui.Extensions; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Maui.Extensions; static partial class SrtParser { + static readonly Regex timecodePatternSRT = SRTRegex(); + /// /// a method that parses the SRT content and returns a list of SubtitleCue objects. /// @@ -23,7 +27,7 @@ public static List ParseSrtContent(string srtContent) { continue; } - var match = Parser.TimecodePatternSRT.Match(line); + var match = timecodePatternSRT.Match(line); if (match.Success) { if (currentCue is not null) @@ -56,6 +60,7 @@ public static List ParseSrtContent(string srtContent) return cues; } - + [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})")] + private static partial Regex SRTRegex(); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 310953ec37..59d35e0b1b 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -14,7 +14,6 @@ class SubtitleExtensions : IDisposable { bool disposedValue; - static readonly HttpClient httpClient = new(); readonly IDispatcher dispatcher; readonly RelativeLayout.LayoutParams? subtitleLayout; readonly StyledPlayerView styledPlayerView; @@ -50,24 +49,9 @@ public async Task LoadSubtitles(IMediaElement mediaElement) { this.mediaElement = mediaElement; cues.Clear(); - string? vttContent; - try - { - vttContent = await httpClient.GetStringAsync(mediaElement.SubtitleUrl); - } - catch (Exception ex) - { - System.Diagnostics.Trace.TraceError(ex.Message); - return; - } - cues = mediaElement.SubtitleUrl switch - { - var url when url.EndsWith("srt") => SrtParser.ParseSrtContent(vttContent), - var url when url.EndsWith("vtt") => VttParser.ParseVttContent(vttContent), - _ => throw new NotSupportedException("Unsupported subtitle format"), - }; + cues = await Parser.Content(mediaElement).ConfigureAwait(false); } - + /// /// Starts the subtitle display. /// @@ -218,7 +202,6 @@ protected virtual void Dispose(bool disposing) if (disposing) { - httpClient.Dispose(); timer?.Dispose(); subtitleView?.Dispose(); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 10e5e35fba..9ddc1e133d 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -11,7 +11,6 @@ namespace CommunityToolkit.Maui.Extensions; class SubtitleExtensions : UIViewController { - static readonly HttpClient httpClient = new(); readonly PlatformMediaElement player; readonly UIViewController playerViewController; readonly UILabel subtitleLabel; @@ -41,7 +40,7 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi Lines = 0, AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleTopMargin | UIViewAutoresizing.FlexibleHeight | UIViewAutoresizing.FlexibleBottomMargin }; - DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); + MediaManagerDelegate.FullScreenChanged += OnFullScreenChanged; } @@ -54,23 +53,7 @@ public async Task LoadSubtitles(IMediaElement mediaElement) this.mediaElement = mediaElement; cues.Clear(); subtitleLabel.Font = UIFont.FromName(mediaElement.SubtitleFont, (float)mediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize((float)mediaElement.SubtitleFontSize); - string? vttContent; - try - { - vttContent = await httpClient.GetStringAsync(mediaElement.SubtitleUrl); - } - catch (Exception ex) - { - System.Diagnostics.Trace.TraceError(ex.Message); - return; - } - - cues = mediaElement.SubtitleUrl switch - { - var url when url.EndsWith("srt") => SrtParser.ParseSrtContent(vttContent), - var url when url.EndsWith("vtt") => VttParser.ParseVttContent(vttContent), - _ => throw new NotSupportedException("Unsupported subtitle format"), - }; + cues = await Parser.Content(mediaElement).ConfigureAwait(false); } /// @@ -79,6 +62,7 @@ var url when url.EndsWith("vtt") => VttParser.ParseVttContent(vttContent), public void StartSubtitleDisplay() { ArgumentNullException.ThrowIfNull(subtitleLabel); + DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); playerObserver = player?.AddPeriodicTimeObserver(CMTime.FromSeconds(1, 1), null, (time) => { TimeSpan currentPlaybackTime = TimeSpan.FromSeconds(time.Seconds); @@ -95,10 +79,10 @@ public void StopSubtitleDisplay() if (playerObserver is not null) { player.RemoveTimeObserver(playerObserver); - playerObserver.Dispose(); - playerObserver = null; - subtitleLabel.RemoveFromSuperview(); } + subtitleLabel.Text = string.Empty; + subtitleLabel.BackgroundColor = UIColor.FromRGBA(0, 0, 0, 0); + DispatchQueue.MainQueue.DispatchAsync(() => subtitleLabel.RemoveFromSuperview()); } void UpdateSubtitle(TimeSpan currentPlaybackTime) { @@ -154,8 +138,9 @@ void OnFullScreenChanged(object? sender, FullScreenEventArgs e) { MediaManagerDelegate.FullScreenChanged -= OnFullScreenChanged; subtitleLabel.Text = string.Empty; - subtitleLabel.RemoveFromSuperview(); - if(playerObserver is not null) + subtitleLabel.BackgroundColor = UIColor.FromRGBA(0, 0, 0, 0); + DispatchQueue.MainQueue.DispatchAsync(() => subtitleLabel.RemoveFromSuperview()); + if (playerObserver is not null) { player.RemoveTimeObserver(playerObserver); playerObserver.Dispose(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index f88f28e219..20a19a5799 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -10,7 +10,6 @@ class SubtitleExtensions : Grid, IDisposable bool disposedValue; bool isFullScreen = false; - static readonly HttpClient httpClient = new(); readonly Microsoft.UI.Xaml.Controls.TextBlock subtitleTextBlock; List cues; @@ -49,23 +48,7 @@ public async Task LoadSubtitles(IMediaElement mediaElement, Microsoft.UI.Xaml.Co cues.Clear(); subtitleTextBlock.Text = string.Empty; subtitleTextBlock.FontSize = mediaElement.SubtitleFontSize; - string? vttContent; - try - { - vttContent = await httpClient.GetStringAsync(mediaElement.SubtitleUrl); - } - catch (Exception ex) - { - System.Diagnostics.Trace.TraceError(ex.Message); - return; - } - - cues = mediaElement.SubtitleUrl switch - { - var url when url.EndsWith("srt") => SrtParser.ParseSrtContent(vttContent), - var url when url.EndsWith("vtt") => VttParser.ParseVttContent(vttContent), - _ => throw new NotSupportedException("Unsupported subtitle format"), - }; + cues = await Parser.Content(mediaElement).ConfigureAwait(false); } /// @@ -161,7 +144,6 @@ protected virtual void Dispose(bool disposing) if (disposing) { - httpClient?.Dispose(); timer?.Dispose(); } timer = null; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index 26f9c2d17c..8c5fd9f854 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -1,7 +1,11 @@ -namespace CommunityToolkit.Maui.Extensions; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Maui.Extensions; static partial class VttParser { + static readonly Regex timecodePatternVTT = VTTRegex(); + /// /// The ParseVttContent method parses the VTT content and returns a list of SubtitleCue objects. /// @@ -19,7 +23,7 @@ public static List ParseVttContent(string vttContent) SubtitleCue? currentCue = null; foreach (var line in lines) { - var match = Parser.TimecodePatternVTT.Match(line); + var match = timecodePatternVTT.Match(line); if (match.Success) { if (currentCue is not null) @@ -51,4 +55,7 @@ public static List ParseVttContent(string vttContent) return cues; } + + [GeneratedRegex(@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})")] + private static partial Regex VTTRegex(); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index bd2912cb67..cb3740b2a1 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -223,7 +223,7 @@ protected virtual partial void PlatformUpdateSource() MediaElement.CurrentStateChanged(MediaElementState.Opening); AVAsset? asset = null; - subtitleExtensions?.StopSubtitleDisplay(); + if (Player is null) { return; @@ -231,6 +231,7 @@ protected virtual partial void PlatformUpdateSource() metaData ??= new(Player); Metadata.ClearNowPlaying(); + subtitleExtensions?.StopSubtitleDisplay(); if (MediaElement.Source is UriMediaSource uriMediaSource) { @@ -315,9 +316,9 @@ async Task LoadSubtitles(CancellationToken cancellationToken = default) { if (subtitleExtensions is null || string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { + System.Diagnostics.Trace.TraceError("SubtitleExtensions is null or SubtitleUrl is null or empty."); return; } - subtitleExtensions.StopSubtitleDisplay(); await subtitleExtensions.LoadSubtitles(MediaElement).WaitAsync(cancellationToken).ConfigureAwait(false); subtitleExtensions.StartSubtitleDisplay(); } From 06ad6780013fbdb5b485e383bf18acadae3d76fc Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 21 Jun 2024 04:40:09 -0700 Subject: [PATCH 36/98] Refactor and improve `SubtitleExtensions` Introduced `subtitleBackgroundColor` and `clearBackgroundColor` constants in `SubtitleExtensions` for unified color management. Refactored background color assignments in `StartSubtitleDisplay` and `UpdateSubtitle` methods to use these constants, enhancing code readability and maintainability. Streamlined the removal of the time observer in the `StopSubtitleDisplay` method by checking for nullity before proceeding, improving logic flow. Simplified destructor logic by inverting null checks and removing redundant code, leading to cleaner and more efficient resource management. These changes collectively enhance the clarity, maintainability, and efficiency of the `SubtitleExtensions` class. --- .../Extensions/SubtitleExtensions.macios.cs | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 9ddc1e133d..b4ea8b6844 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -15,6 +15,9 @@ class SubtitleExtensions : UIViewController readonly UIViewController playerViewController; readonly UILabel subtitleLabel; + static readonly UIColor subtitleBackgroundColor = UIColor.FromRGBA(0, 0, 0, 128); + static readonly UIColor clearBackgroundColor = UIColor.FromRGBA(0, 0, 0, 0); + List cues; IMediaElement? mediaElement; NSObject? playerObserver; @@ -76,13 +79,15 @@ public void StartSubtitleDisplay() /// public void StopSubtitleDisplay() { - if (playerObserver is not null) - { - player.RemoveTimeObserver(playerObserver); - } subtitleLabel.Text = string.Empty; - subtitleLabel.BackgroundColor = UIColor.FromRGBA(0, 0, 0, 0); + subtitleLabel.BackgroundColor = clearBackgroundColor; DispatchQueue.MainQueue.DispatchAsync(() => subtitleLabel.RemoveFromSuperview()); + + if (playerObserver is null) + { + return; + } + player.RemoveTimeObserver(playerObserver); } void UpdateSubtitle(TimeSpan currentPlaybackTime) { @@ -92,13 +97,13 @@ void UpdateSubtitle(TimeSpan currentPlaybackTime) if (currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime) { subtitleLabel.Text = cue.Text; - subtitleLabel.BackgroundColor = UIColor.FromRGBA(0, 0, 0, 128); + subtitleLabel.BackgroundColor = subtitleBackgroundColor; break; } else { subtitleLabel.Text = ""; - subtitleLabel.BackgroundColor = UIColor.FromRGBA(0, 0, 0, 0); + subtitleLabel.BackgroundColor = clearBackgroundColor; } } } @@ -137,16 +142,11 @@ void OnFullScreenChanged(object? sender, FullScreenEventArgs e) ~SubtitleExtensions() { MediaManagerDelegate.FullScreenChanged -= OnFullScreenChanged; - subtitleLabel.Text = string.Empty; - subtitleLabel.BackgroundColor = UIColor.FromRGBA(0, 0, 0, 0); - DispatchQueue.MainQueue.DispatchAsync(() => subtitleLabel.RemoveFromSuperview()); - if (playerObserver is not null) + if(playerObserver is null) { - player.RemoveTimeObserver(playerObserver); - playerObserver.Dispose(); - playerObserver = null; + return; } - subtitleLabel.Dispose(); + player.RemoveTimeObserver(playerObserver); } } From 3d2fd49016070cb9850e5818b7b024192f9d2257 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 21 Jun 2024 07:27:22 -0700 Subject: [PATCH 37/98] Refactor SubtitleExtensions class Refactored the SubtitleExtensions class to improve Android context and resource management. Removed unused namespaces and fields, shifting dependency from IDisposable to Java.Lang.Object for better lifecycle management. Introduced a new field and struct for robust activity and view handling. Updated methods for initialization and full-screen handling to utilize the new approach. Removed IDisposable pattern in favor of finalization for resource cleanup. --- .../Extensions/SubtitleExtensions.android.cs | 94 +++++-------------- 1 file changed, 26 insertions(+), 68 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 59d35e0b1b..94b4370f07 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -1,4 +1,4 @@ -using Android.Content.Res; +using System.Diagnostics.CodeAnalysis; using Android.Views; using Android.Widget; using Com.Google.Android.Exoplayer2.UI; @@ -10,14 +10,12 @@ namespace CommunityToolkit.Maui.Extensions; -class SubtitleExtensions : IDisposable +class SubtitleExtensions : Java.Lang.Object { - bool disposedValue; - readonly IDispatcher dispatcher; readonly RelativeLayout.LayoutParams? subtitleLayout; readonly StyledPlayerView styledPlayerView; - + readonly CurrentPlatformActivity platform; List cues; IMediaElement? mediaElement; TextView? subtitleView; @@ -36,11 +34,17 @@ public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatc subtitleLayout = new RelativeLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent); subtitleLayout.AddRule(LayoutRules.AlignParentBottom); subtitleLayout.AddRule(LayoutRules.CenterHorizontal); - InitializeTextBlock(); MauiMediaElement.FullScreenChanged += OnFullScreenChanged; + ArgumentNullException.ThrowIfNull(Platform.CurrentActivity); + if (Platform.CurrentActivity.Window?.DecorView is not ViewGroup decorView) + { + throw new InvalidOperationException("Platform.CurrentActivity.Window.DecorView is not a ViewGroup"); + } + platform = new(Platform.CurrentActivity, decorView); + InitializeTextBlock(); } - + /// /// Loads the subtitles from the provided URL. /// @@ -116,9 +120,7 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) void InitializeTextBlock() { - var (currentActivity, _, _, _) = VerifyAndRetrieveCurrentWindowResources(); - var activity = currentActivity; - subtitleView = new(activity.ApplicationContext) + subtitleView = new(platform.currentActivity.ApplicationContext) { Text = string.Empty, HorizontalScrollBarEnabled = false, @@ -132,29 +134,10 @@ void InitializeTextBlock() subtitleView.SetPaddingRelative(10, 10, 10, 20); } - static (Activity CurrentActivity, Android.Views.Window CurrentWindow, Resources CurrentWindowResources, Configuration CurrentWindowConfiguration) VerifyAndRetrieveCurrentWindowResources() + readonly record struct CurrentPlatformActivity(Activity currentActivity, ViewGroup viewGroup) { - // Ensure current activity and window are available - if (Platform.CurrentActivity is not Activity currentActivity) - { - throw new InvalidOperationException("CurrentActivity cannot be null when the FullScreen button is tapped"); - } - if (currentActivity.Window is not Android.Views.Window currentWindow) - { - throw new InvalidOperationException("CurrentActivity Window cannot be null when the FullScreen button is tapped"); - } - - if (currentActivity.Resources is not Resources currentResources) - { - throw new InvalidOperationException("CurrentActivity Resources cannot be null when the FullScreen button is tapped"); - } - - if (currentResources.Configuration is not Configuration configuration) - { - throw new InvalidOperationException("CurrentActivity Configuration cannot be null when the FullScreen button is tapped"); - } - - return (currentActivity, currentWindow, currentResources, configuration); + public Activity CurrentActivity { get; init; } = currentActivity; + public ViewGroup ViewGroup { get; init; } = viewGroup; } void OnFullScreenChanged(object? sender, FullScreenEventArgs e) @@ -166,60 +149,35 @@ void OnFullScreenChanged(object? sender, FullScreenEventArgs e) { return; } - var (_, currentWindow, _, _) = VerifyAndRetrieveCurrentWindowResources(); - if (currentWindow?.DecorView is not ViewGroup viewGroup) - { - return; - } - if (viewGroup.Parent is not ViewGroup parent) + + if (platform.viewGroup.Parent is not ViewGroup parent) { return; } + switch (e.isFullScreen) { case true: - viewGroup.RemoveView(subtitleView); + platform.viewGroup.RemoveView(subtitleView); InitializeTextBlock(); parent.AddView(subtitleView); break; case false: parent.RemoveView(subtitleView); InitializeTextBlock(); - viewGroup.AddView(subtitleView); + platform.viewGroup.AddView(subtitleView); break; } } - protected virtual void Dispose(bool disposing) + ~SubtitleExtensions() { - if (!disposedValue) + if (timer is not null) { - if (timer is not null) - { - timer.Stop(); - timer.Elapsed -= UpdateSubtitle; - } - - if (disposing) - { - timer?.Dispose(); - subtitleView?.Dispose(); - } - - timer = null; - subtitleView = null; - disposedValue = true; + timer.Stop(); + timer.Elapsed -= UpdateSubtitle; } - } - - ~SubtitleExtensions() - { - Dispose(disposing: false); - } - - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); + timer?.Dispose(); + subtitleView?.Dispose(); } } From 6c2a667b8dc8d8be0c7d78b155b74367e4dae69d Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 21 Jun 2024 07:48:30 -0700 Subject: [PATCH 38/98] Fix method naming inconsistencies - Corrected the method name from `OnWindowsChanged` to `OnFullScreenChanged` in `MauiMediaElement.android.cs` to accurately reflect its functionality related to full screen state changes. - Fixed a typo in `MediaManager.macios.cs`, changing `OnFulScreenChanged` back to the correct `OnFullScreenChanged` to prevent potential compilation errors. --- .../Views/MauiMediaElement.android.cs | 6 +++--- .../Views/MediaManager.macios.cs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs index 1b36d07eca..5adfefc4b4 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs @@ -66,7 +66,7 @@ public MauiMediaElement(Context context, StyledPlayerView playerView) : base(con /// /// A method that raises the FullScreenChanged event. /// - protected virtual void OnWindowsChanged(FullScreenEventArgs e) => FullScreenChanged?.Invoke(null, e); + protected virtual void OnFullScreenChanged(FullScreenEventArgs e) => FullScreenChanged?.Invoke(null, e); public override void OnDetachedFromWindow() { if (isFullScreen) @@ -155,14 +155,14 @@ void OnFullscreenButtonClick(object? sender, StyledPlayerView.FullscreenButtonCl isFullScreen = true; RemoveView(relativeLayout); layout?.AddView(relativeLayout); - OnWindowsChanged(new Maui.Primitives.FullScreenEventArgs(isFullScreen)); + OnFullScreenChanged(new Maui.Primitives.FullScreenEventArgs(isFullScreen)); } else { isFullScreen = false; layout?.RemoveView(relativeLayout); AddView(relativeLayout); - OnWindowsChanged(new Maui.Primitives.FullScreenEventArgs(isFullScreen)); + OnFullScreenChanged(new Maui.Primitives.FullScreenEventArgs(isFullScreen)); } // Hide/Show the SystemBars and Status bar SetSystemBarsVisibility(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index cb3740b2a1..6d5e61b25b 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -653,14 +653,14 @@ sealed class MediaManagerDelegate : AVPlayerViewControllerDelegate public static event EventHandler? FullScreenChanged; public override void WillBeginFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) { - OnWindowsChanged(new FullScreenEventArgs(true)); + OnFulScreenChanged(new FullScreenEventArgs(true)); } public override void WillEndFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) { - OnWindowsChanged(new FullScreenEventArgs(false)); + OnFulScreenChanged(new FullScreenEventArgs(false)); } /// /// A method that raises the FullScreenChanged event. /// - static void OnWindowsChanged(FullScreenEventArgs e) => FullScreenChanged?.Invoke(null, e); + static void OnFulScreenChanged(FullScreenEventArgs e) => FullScreenChanged?.Invoke(null, e); } \ No newline at end of file From 0cbd66f4e24d6bf0987510435a6e5ba15865d37d Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 21 Jun 2024 07:52:35 -0700 Subject: [PATCH 39/98] Renamed OnGridEventsChanged to FullScreenChanged Renamed the method `OnGridEventsChanged` to `FullScreenChanged` to better reflect its purpose related to handling full-screen state changes. Updated calls in `OnFullScreenButtonClick` method to match the new method name, ensuring consistent handling of full-screen toggles and popup management. The destructor `~MauiMediaElement()` and the core functionality of the renamed method remain unchanged. --- .../Views/MauiMediaElement.windows.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs index 5d9e88371a..43ab36d461 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs @@ -83,7 +83,7 @@ public MauiMediaElement(MediaPlayerElement mediaPlayerElement) /// /// A method that raises the GridEventsChanged event. /// - protected virtual void OnGridEventsChanged(GridEventArgs e) + protected virtual void FullScreenChanged(GridEventArgs e) { GridEventsChanged?.Invoke(null, e); } @@ -169,7 +169,7 @@ void OnFullScreenButtonClick(object sender, RoutedEventArgs e) appWindow.SetPresenter(AppWindowPresenterKind.Default); Shell.SetNavBarIsVisible(CurrentPage, doesNavigationBarExistBeforeFullScreen); - OnGridEventsChanged(new Maui.Primitives.GridEventArgs(fullScreenGrid)); + FullScreenChanged(new Maui.Primitives.GridEventArgs(fullScreenGrid)); if (popup.IsOpen) { popup.IsOpen = false; @@ -210,7 +210,7 @@ void OnFullScreenButtonClick(object sender, RoutedEventArgs e) { popup.IsOpen = true; } - OnGridEventsChanged(new Maui.Primitives.GridEventArgs(fullScreenGrid)); + FullScreenChanged(new Maui.Primitives.GridEventArgs(fullScreenGrid)); } } } \ No newline at end of file From 1b854e6895095fe1abe066aa25e5d6ca93dbbc50 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 21 Jun 2024 17:15:14 -0700 Subject: [PATCH 40/98] Refactor and fix subtitle extension bugs - Added null checks and validation in SubtitleExtensions for Android to ensure the current activity and its decor view are correctly initialized, throwing exceptions if not. - Removed redundant code and simplified visibility state changes in SubtitleExtensions for Android, enhancing code readability and maintainability. - Improved UI code readability in SubtitleExtensions for iOS/macOS by adjusting property settings to be multi-line. - Streamlined subtitle loading in MediaManager for Android by optimizing variable usage and method calls. --- .../Extensions/SubtitleExtensions.android.cs | 18 +++++++++--------- .../Extensions/SubtitleExtensions.macios.cs | 8 ++++++-- .../Views/MediaManager.android.cs | 3 +-- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 94b4370f07..5b8c175f7b 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -27,22 +27,22 @@ class SubtitleExtensions : Java.Lang.Object /// public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatcher) { + ArgumentNullException.ThrowIfNull(Platform.CurrentActivity); this.dispatcher = dispatcher; this.styledPlayerView = styledPlayerView; + if (Platform.CurrentActivity.Window?.DecorView is not ViewGroup decorView) + { + throw new InvalidOperationException("Platform.CurrentActivity.Window.DecorView is not a ViewGroup"); + } + platform = new(Platform.CurrentActivity, decorView); cues = []; subtitleLayout = new RelativeLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent); subtitleLayout.AddRule(LayoutRules.AlignParentBottom); subtitleLayout.AddRule(LayoutRules.CenterHorizontal); - MauiMediaElement.FullScreenChanged += OnFullScreenChanged; - ArgumentNullException.ThrowIfNull(Platform.CurrentActivity); - if (Platform.CurrentActivity.Window?.DecorView is not ViewGroup decorView) - { - throw new InvalidOperationException("Platform.CurrentActivity.Window.DecorView is not a ViewGroup"); - } - platform = new(Platform.CurrentActivity, decorView); InitializeTextBlock(); + MauiMediaElement.FullScreenChanged += OnFullScreenChanged; } /// @@ -108,12 +108,12 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) subtitleView.FontFeatureSettings = !string.IsNullOrEmpty(mediaElement.SubtitleFont) ? mediaElement.SubtitleFont : default; subtitleView.Text = cue.Text; subtitleView.TextSize = (float)mediaElement.SubtitleFontSize; - subtitleView.Visibility = Android.Views.ViewStates.Visible; + subtitleView.Visibility = ViewStates.Visible; } else { subtitleView.Text = string.Empty; - subtitleView.Visibility = Android.Views.ViewStates.Gone; + subtitleView.Visibility = ViewStates.Gone; } }); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index b4ea8b6844..2ed30bb278 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -41,7 +41,10 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi Font = UIFont.SystemFontOfSize(16), Text = "", Lines = 0, - AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleTopMargin | UIViewAutoresizing.FlexibleHeight | UIViewAutoresizing.FlexibleBottomMargin + AutoresizingMask = UIViewAutoresizing.FlexibleWidth + | UIViewAutoresizing.FlexibleTopMargin + | UIViewAutoresizing.FlexibleHeight + | UIViewAutoresizing.FlexibleBottomMargin }; MediaManagerDelegate.FullScreenChanged += OnFullScreenChanged; @@ -55,7 +58,8 @@ public async Task LoadSubtitles(IMediaElement mediaElement) { this.mediaElement = mediaElement; cues.Clear(); - subtitleLabel.Font = UIFont.FromName(mediaElement.SubtitleFont, (float)mediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize((float)mediaElement.SubtitleFontSize); + subtitleLabel.Font = UIFont.FromName(mediaElement.SubtitleFont, + (float)mediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize((float)mediaElement.SubtitleFontSize); cues = await Parser.Content(mediaElement).ConfigureAwait(false); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index 0c1ce6aafb..3ed841b2cf 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -454,8 +454,7 @@ protected virtual partial void PlatformUpdateSource() if (hasSetSource && Player.PlayerError is null) { MediaElement.MediaOpened(); - CancellationToken token = subTitlesSourceToken.Token; - startSubtitles = LoadSubtitles(token); + startSubtitles = LoadSubtitles(subTitlesSourceToken.Token); } } async Task LoadSubtitles(CancellationToken cancellationToken = default) From df5d603c7cdcfe8919e7d579ccaa585d270bbf17 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 21 Jun 2024 22:02:07 -0700 Subject: [PATCH 41/98] Refactor subtitle parsers for efficiency - In both SrtParser.cs and VttParser.cs, changed list initialization to `new List()` for clarity and proper instantiation. - Improved readability by adding spaces around `if` conditions and using implicit typing with `var` for line splitting. - Introduced `textBuffer` in both parsers to optimize string handling, reducing memory usage and improving performance. - Adjusted subtitle text addition to use `Environment.NewLine` in SRT and spaces in VTT, enhancing readability and format consistency. - Replaced string concatenations with more efficient methods for building subtitle texts. - Changed `Text = ""` to `Text = string.Empty` in `SubtitleCue` initialization for consistency. --- .../Extensions/SrtParser.cs | 23 +++++++++++-------- .../Extensions/VttParser.cs | 17 +++++++------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index ae9ef1d787..4c13b4c425 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -13,53 +13,56 @@ static partial class SrtParser /// public static List ParseSrtContent(string srtContent) { - List cues = []; - if(string.IsNullOrEmpty(srtContent)) + var cues = new List(); + if (string.IsNullOrEmpty(srtContent)) { return cues; } - string[] lines = srtContent.Split(Parser.Separator, StringSplitOptions.None); + var lines = srtContent.Split(Parser.Separator, StringSplitOptions.None); SubtitleCue? currentCue = null; + var textBuffer = new List(); + foreach (var line in lines) { - if (int.TryParse(line, out _)) // Skip lines that contain only numbers + if (int.TryParse(line, out _)) { continue; } + var match = timecodePatternSRT.Match(line); if (match.Success) { if (currentCue is not null) { + currentCue.Text = string.Join(Environment.NewLine, textBuffer); cues.Add(currentCue); + textBuffer.Clear(); } currentCue = new SubtitleCue { StartTime = Parser.ParseTimecode(match.Groups[1].Value, false), EndTime = Parser.ParseTimecode(match.Groups[2].Value, false), - Text = "" + Text = string.Empty }; } else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) { - if (!string.IsNullOrEmpty(currentCue.Text)) - { - currentCue.Text += Environment.NewLine; - } - currentCue.Text += line.Trim(); // Trim leading/trailing spaces + textBuffer.Add(line.Trim()); } } if (currentCue is not null) { + currentCue.Text = string.Join(Environment.NewLine, textBuffer); cues.Add(currentCue); } return cues; } + [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})")] private static partial Regex SRTRegex(); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index 8c5fd9f854..0f94cab271 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -13,14 +13,16 @@ static partial class VttParser /// public static List ParseVttContent(string vttContent) { - List cues = []; + var cues = new List(); if (string.IsNullOrEmpty(vttContent)) { return cues; } - string[] lines = vttContent.Split(Parser.Separator, StringSplitOptions.None); + var lines = vttContent.Split(Parser.Separator, StringSplitOptions.None); SubtitleCue? currentCue = null; + var textBuffer = new List(); + foreach (var line in lines) { var match = timecodePatternVTT.Match(line); @@ -28,28 +30,27 @@ public static List ParseVttContent(string vttContent) { if (currentCue is not null) { + currentCue.Text = string.Join(" ", textBuffer); cues.Add(currentCue); + textBuffer.Clear(); } currentCue = new SubtitleCue { StartTime = Parser.ParseTimecode(match.Groups[1].Value, true), EndTime = Parser.ParseTimecode(match.Groups[2].Value, true), - Text = "" + Text = string.Empty }; } else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) { - if (!string.IsNullOrEmpty(currentCue.Text)) - { - currentCue.Text += " "; - } - currentCue.Text += line.Trim('-').Trim(); // Trim hyphens and spaces + textBuffer.Add(line.Trim('-').Trim()); } } if (currentCue is not null) { + currentCue.Text = string.Join(" ", textBuffer); cues.Add(currentCue); } From 38aa3e4c38c5208c6c6244fa3750bb6f494f2e6a Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sat, 22 Jun 2024 06:46:04 -0700 Subject: [PATCH 42/98] Created sample parser file and added ability of developer to use a custom parser in media element. --- .../MediaElement/MediaElementPage.xaml.cs | 76 +++++++++++++++++++ .../Extensions/Parser.cs | 55 ++++++++++++++ .../Extensions/Parser.shared.cs | 53 ------------- .../Extensions/SrtParser.cs | 28 ++++--- .../Extensions/SubtitleCue.cs | 7 +- .../Extensions/SubtitleExtensions.android.cs | 37 +++++---- .../Extensions/SubtitleExtensions.macios.cs | 34 ++++++--- .../Extensions/SubtitleExtensions.windows.cs | 24 +++++- .../Extensions/VttParser.cs | 27 ++++--- .../Interfaces/IMediaElement.cs | 5 ++ .../Interfaces/IParser.cs | 14 ++++ .../MediaElement.shared.cs | 15 ++++ 12 files changed, 274 insertions(+), 101 deletions(-) create mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.cs delete mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.shared.cs create mode 100644 src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs 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 fd1de9f657..0452837457 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -6,6 +6,8 @@ using Microsoft.Extensions.Logging; using CommunityToolkit.Maui.Sample.Resources.Fonts; using LayoutAlignment = Microsoft.Maui.Primitives.LayoutAlignment; +using System.Globalization; +using System.Text.RegularExpressions; namespace CommunityToolkit.Maui.Sample.Pages.Views; @@ -209,6 +211,8 @@ async void ChangeSourceClicked(Object sender, EventArgs e) } return; case loadSubTitles: + SrtParser srtParser = new(); + MediaElement.CustomParser = srtParser; MediaElement.SubtitleFont = FontFamilies.PlaywriteSK; MediaElement.SubtitleFontSize = 16; MediaElement.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; @@ -268,4 +272,76 @@ void DisplayPopup(object sender, EventArgs e) popupMediaElement.Handler?.DisconnectHandler(); }; } +} + +/// +/// Sample implementation of an SRT parser. +/// +partial class SrtParser : IParser +{ + static readonly Regex timecodePatternSRT = SRTRegex(); + + /// + /// a method that parses the SRT content and returns a list of SubtitleCue objects. + /// + /// + /// + public List ParseContent(string content) + { + var cues = new List(); + if (string.IsNullOrEmpty(content)) + { + return cues; + } + + var lines = content.Split(Parser.Separator, StringSplitOptions.None); + SubtitleCue? currentCue = null; + var textBuffer = new List(); + + foreach (var line in lines) + { + if (int.TryParse(line, out _)) + { + continue; + } + + var match = timecodePatternSRT.Match(line); + if (match.Success) + { + if (currentCue is not null) + { + currentCue.Text = string.Join(Environment.NewLine, textBuffer); + cues.Add(currentCue); + textBuffer.Clear(); + } + + currentCue = new SubtitleCue + { + StartTime = ParseTimecode(match.Groups[1].Value), + EndTime = ParseTimecode(match.Groups[2].Value), + Text = string.Empty + }; + } + else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) + { + textBuffer.Add(line.Trim()); + } + } + + if (currentCue is not null) + { + currentCue.Text = string.Join(Environment.NewLine, textBuffer); + cues.Add(currentCue); + } + + return cues; + } + + static TimeSpan ParseTimecode(string timecode) + { + return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); + } + + [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})")] + private static partial Regex SRTRegex(); } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.cs new file mode 100644 index 0000000000..a0b88caacd --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.cs @@ -0,0 +1,55 @@ +namespace CommunityToolkit.Maui.Core; + +/// +/// +/// +public partial class Parser +{ + /// + /// + /// + public IParser IParser { get; set; } + static readonly HttpClient httpClient = new(); + + /// + /// + /// + public static readonly string[] Separator = ["\r\n", "\n"]; + + /// + /// + /// + /// + public Parser(IParser parser) + { + this.IParser = parser; + } + + /// + /// + /// + /// + /// + public virtual List ParseContent(string content) + { + return IParser.ParseContent(content); + } + + /// + /// + /// + /// + /// + public static async Task Content(string subtitleUrl) + { + try + { + return await httpClient.GetStringAsync(subtitleUrl).ConfigureAwait(false); + } + catch (Exception ex) + { + System.Diagnostics.Trace.TraceError(ex.Message); + return string.Empty; + } + } +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.shared.cs deleted file mode 100644 index 23fbe5ffc7..0000000000 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.shared.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Globalization; -using CommunityToolkit.Maui.Core; - -namespace CommunityToolkit.Maui.Extensions; -static class Parser -{ - static readonly HttpClient httpClient = new(); - public static readonly string[] Separator = ["\r\n", "\n"]; - - public static TimeSpan ParseTimecode(string timecode, bool isVtt) - { - if (isVtt) - { - return TimeSpan.Parse(timecode, CultureInfo.InvariantCulture); - } - - return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); - } - - public static async Task> Content(IMediaElement mediaElement) - { - string? vttContent; - var emptyList = new List(); - try - { - if (mediaElement.SubtitleUrl.EndsWith("srt") || mediaElement.SubtitleUrl.EndsWith("vtt")) - { - vttContent = await httpClient.GetStringAsync(mediaElement.SubtitleUrl).ConfigureAwait(false); - } - else - { - System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); - return emptyList; - } - } - catch (Exception ex) - { - System.Diagnostics.Trace.TraceError(ex.Message); - return emptyList; - } - - switch (mediaElement.SubtitleUrl) - { - case var url when url.EndsWith("srt"): - return SrtParser.ParseSrtContent(vttContent); - case var url when url.EndsWith("vtt"): - return VttParser.ParseVttContent(vttContent); - default: - System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); - return emptyList; - } - } -} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index 4c13b4c425..58630ef1ec 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -1,25 +1,29 @@ -using System.Text.RegularExpressions; +using System.Globalization; +using System.Text.RegularExpressions; -namespace CommunityToolkit.Maui.Extensions; +namespace CommunityToolkit.Maui.Core; -static partial class SrtParser +/// +/// +/// +partial class SrtParser : IParser { static readonly Regex timecodePatternSRT = SRTRegex(); /// /// a method that parses the SRT content and returns a list of SubtitleCue objects. /// - /// + /// /// - public static List ParseSrtContent(string srtContent) + public List ParseContent(string content) { var cues = new List(); - if (string.IsNullOrEmpty(srtContent)) + if (string.IsNullOrEmpty(content)) { return cues; } - var lines = srtContent.Split(Parser.Separator, StringSplitOptions.None); + var lines = content.Split(Parser.Separator, StringSplitOptions.None); SubtitleCue? currentCue = null; var textBuffer = new List(); @@ -42,8 +46,8 @@ public static List ParseSrtContent(string srtContent) currentCue = new SubtitleCue { - StartTime = Parser.ParseTimecode(match.Groups[1].Value, false), - EndTime = Parser.ParseTimecode(match.Groups[2].Value, false), + StartTime = ParseTimecode(match.Groups[1].Value), + EndTime = ParseTimecode(match.Groups[2].Value), Text = string.Empty }; } @@ -61,7 +65,11 @@ public static List ParseSrtContent(string srtContent) return cues; } - + + static TimeSpan ParseTimecode(string timecode) + { + return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); + } [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})")] private static partial Regex SRTRegex(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs index 6a99919aad..ebd1efc4f2 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs @@ -1,6 +1,9 @@ -namespace CommunityToolkit.Maui.Extensions; +namespace CommunityToolkit.Maui.Core; -sealed class SubtitleCue +/// +/// A class that represents a subtitle cue. +/// +public class SubtitleCue { /// /// The number of the cue. diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 5b8c175f7b..0d7bb47980 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -1,5 +1,4 @@ -using System.Diagnostics.CodeAnalysis; -using Android.Views; +using Android.Views; using Android.Widget; using Com.Google.Android.Exoplayer2.UI; using CommunityToolkit.Maui.Core; @@ -53,7 +52,28 @@ public async Task LoadSubtitles(IMediaElement mediaElement) { this.mediaElement = mediaElement; cues.Clear(); - cues = await Parser.Content(mediaElement).ConfigureAwait(false); + Parser parser; + var content = await Parser.Content(mediaElement.SubtitleUrl); + if (mediaElement.CustomParser is not null) + { + parser = new(mediaElement.CustomParser); + cues = parser.ParseContent(content); + return; + } + switch (mediaElement.SubtitleUrl) + { + case var url when url.EndsWith("srt"): + parser = new(new SrtParser()); + cues = parser.ParseContent(content); + break; + case var url when url.EndsWith("vtt"): + parser = new(new VttParser()); + cues = parser.ParseContent(content); + break; + default: + System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); + return; + } } /// @@ -169,15 +189,4 @@ void OnFullScreenChanged(object? sender, FullScreenEventArgs e) break; } } - - ~SubtitleExtensions() - { - if (timer is not null) - { - timer.Stop(); - timer.Elapsed -= UpdateSubtitle; - } - timer?.Dispose(); - subtitleView?.Dispose(); - } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 2ed30bb278..03e1665e1f 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -58,9 +58,28 @@ public async Task LoadSubtitles(IMediaElement mediaElement) { this.mediaElement = mediaElement; cues.Clear(); - subtitleLabel.Font = UIFont.FromName(mediaElement.SubtitleFont, - (float)mediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize((float)mediaElement.SubtitleFontSize); - cues = await Parser.Content(mediaElement).ConfigureAwait(false); + Parser parser; + var content = await Parser.Content(mediaElement.SubtitleUrl); + if (mediaElement.CustomParser is not null) + { + parser = new(mediaElement.CustomParser); + cues = parser.ParseContent(content); + return; + } + switch (mediaElement.SubtitleUrl) + { + case var url when url.EndsWith("srt"): + parser = new(new SrtParser()); + cues = parser.ParseContent(content); + break; + case var url when url.EndsWith("vtt"): + parser = new(new VttParser()); + cues = parser.ParseContent(content); + break; + default: + System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); + return; + } } /// @@ -143,14 +162,5 @@ void OnFullScreenChanged(object? sender, FullScreenEventArgs e) break; } } - ~SubtitleExtensions() - { - MediaManagerDelegate.FullScreenChanged -= OnFullScreenChanged; - if(playerObserver is null) - { - return; - } - player.RemoveTimeObserver(playerObserver); - } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 20a19a5799..0f0a8a88f7 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -48,7 +48,29 @@ public async Task LoadSubtitles(IMediaElement mediaElement, Microsoft.UI.Xaml.Co cues.Clear(); subtitleTextBlock.Text = string.Empty; subtitleTextBlock.FontSize = mediaElement.SubtitleFontSize; - cues = await Parser.Content(mediaElement).ConfigureAwait(false); + Parser parser; + + var content = await Parser.Content(mediaElement.SubtitleUrl); + if(mediaElement.CustomParser is not null) + { + parser = new(mediaElement.CustomParser); + cues = parser.ParseContent(content); + return; + } + switch (mediaElement.SubtitleUrl) + { + case var url when url.EndsWith("srt"): + parser = new(new SrtParser()); + cues = parser.ParseContent(content); + break; + case var url when url.EndsWith("vtt"): + parser = new(new VttParser()); + cues = parser.ParseContent(content); + break; + default: + System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); + return; + } } /// diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index 0f94cab271..f7f970aa74 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -1,25 +1,29 @@ -using System.Text.RegularExpressions; +using System.Globalization; +using System.Text.RegularExpressions; -namespace CommunityToolkit.Maui.Extensions; +namespace CommunityToolkit.Maui.Core; -static partial class VttParser +/// +/// +/// +partial class VttParser : IParser { static readonly Regex timecodePatternVTT = VTTRegex(); /// /// The ParseVttContent method parses the VTT content and returns a list of SubtitleCue objects. /// - /// + /// /// - public static List ParseVttContent(string vttContent) + public List ParseContent(string content) { var cues = new List(); - if (string.IsNullOrEmpty(vttContent)) + if (string.IsNullOrEmpty(content)) { return cues; } - var lines = vttContent.Split(Parser.Separator, StringSplitOptions.None); + var lines = content.Split(Parser.Separator, StringSplitOptions.None); SubtitleCue? currentCue = null; var textBuffer = new List(); @@ -37,8 +41,8 @@ public static List ParseVttContent(string vttContent) currentCue = new SubtitleCue { - StartTime = Parser.ParseTimecode(match.Groups[1].Value, true), - EndTime = Parser.ParseTimecode(match.Groups[2].Value, true), + StartTime = ParseTimecode(match.Groups[1].Value), + EndTime = ParseTimecode(match.Groups[2].Value), Text = string.Empty }; } @@ -57,6 +61,11 @@ public static List ParseVttContent(string vttContent) return cues; } + static TimeSpan ParseTimecode(string timecode) + { + return TimeSpan.Parse(timecode, CultureInfo.InvariantCulture); + } + [GeneratedRegex(@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})")] private static partial Regex VTTRegex(); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs index 7f5103f773..154de08b34 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs @@ -46,6 +46,11 @@ public interface IMediaElement : IView, IAsynchronousMediaElementHandler /// Not reported for non-visual media. int MediaWidth { get; } + /// + /// Gets the used to parse the subtitle file. + /// + IParser? CustomParser { get; set; } + /// /// The current position of the playing media. /// diff --git a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs new file mode 100644 index 0000000000..af7e434e13 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs @@ -0,0 +1,14 @@ +namespace CommunityToolkit.Maui.Core; + +/// +/// +/// +public interface IParser +{ + /// + /// + /// + /// + /// + public List ParseContent(string content); +} diff --git a/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs b/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs index b706e2fe7a..9c4adfb085 100644 --- a/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs @@ -10,6 +10,12 @@ namespace CommunityToolkit.Maui.Views; /// public class MediaElement : View, IMediaElement, IDisposable { + /// + /// Backing store for the property. + /// + public static readonly BindableProperty ParserProperty = + BindableProperty.Create(nameof(CustomParser), typeof(IParser), typeof(MediaElement), null); + /// /// Backing store for the property. /// @@ -219,6 +225,15 @@ internal event EventHandler StopRequested /// ~MediaElement() => Dispose(false); + /// + /// + /// + public IParser? CustomParser + { + get => (IParser)GetValue(ParserProperty); + set => SetValue(ParserProperty, value); + } + /// /// The current position of the playing media. This is a bindable property. /// From d2ccc9084a078c43698ffdb839c9186031fdfa81 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sat, 22 Jun 2024 07:14:54 -0700 Subject: [PATCH 43/98] Refine codebase and improve documentation - Converted `Parser.cs` from a partial to a regular class and moved `HttpClient` to a static readonly field for better management. - Added meaningful XML documentation across `Parser.cs`, `SrtParser.cs`, and `IParser` interface to enhance code understandability and maintainability. - Streamlined code by removing unnecessary XML comments and refining access modifiers in `SubtitleExtensions` across Android, macOS, and Windows implementations. - General cleanup across subtitle parsing and extension classes, focusing on removing redundant comments and adding valuable documentation where necessary. - Updated `IParser` interface with documented `ParseContent` method to clarify expected behavior for implementers. --- .../Extensions/Parser.cs | 22 +++++++---------- .../Extensions/SrtParser.cs | 8 ------- .../Extensions/SubtitleExtensions.android.cs | 16 +------------ .../Extensions/SubtitleExtensions.macios.cs | 15 ------------ .../Extensions/SubtitleExtensions.windows.cs | 24 ------------------- .../Extensions/VttParser.cs | 8 ------- .../Interfaces/IParser.cs | 2 +- 7 files changed, 11 insertions(+), 84 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.cs index a0b88caacd..ec59eb5b03 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.cs @@ -1,23 +1,24 @@ namespace CommunityToolkit.Maui.Core; /// -/// +/// A class that Represents a parser. /// -public partial class Parser +public class Parser { + static readonly HttpClient httpClient = new(); + /// - /// + /// A property that represents the /// public IParser IParser { get; set; } - static readonly HttpClient httpClient = new(); /// - /// + /// A property that represents the separator. /// public static readonly string[] Separator = ["\r\n", "\n"]; /// - /// + /// A constructor that initializes the /// /// public Parser(IParser parser) @@ -26,7 +27,7 @@ public Parser(IParser parser) } /// - /// + /// A method that parses the content. /// /// /// @@ -35,12 +36,7 @@ public virtual List ParseContent(string content) return IParser.ParseContent(content); } - /// - /// - /// - /// - /// - public static async Task Content(string subtitleUrl) + internal static async Task Content(string subtitleUrl) { try { diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index 58630ef1ec..40ad40df34 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -3,18 +3,10 @@ namespace CommunityToolkit.Maui.Core; -/// -/// -/// partial class SrtParser : IParser { static readonly Regex timecodePatternSRT = SRTRegex(); - /// - /// a method that parses the SRT content and returns a list of SubtitleCue objects. - /// - /// - /// public List ParseContent(string content) { var cues = new List(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 0d7bb47980..432ff9b380 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -20,10 +20,6 @@ class SubtitleExtensions : Java.Lang.Object TextView? subtitleView; System.Timers.Timer? timer; - /// - /// The SubtitleExtensions class provides a way to display subtitles on a video player. - /// - /// public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatcher) { ArgumentNullException.ThrowIfNull(Platform.CurrentActivity); @@ -44,10 +40,6 @@ public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatc MauiMediaElement.FullScreenChanged += OnFullScreenChanged; } - /// - /// Loads the subtitles from the provided URL. - /// - /// public async Task LoadSubtitles(IMediaElement mediaElement) { this.mediaElement = mediaElement; @@ -75,10 +67,7 @@ public async Task LoadSubtitles(IMediaElement mediaElement) return; } } - - /// - /// Starts the subtitle display. - /// + public void StartSubtitleDisplay() { ArgumentNullException.ThrowIfNull(subtitleView); @@ -93,9 +82,6 @@ public void StartSubtitleDisplay() timer.Start(); } - /// - /// Stops the subtitle timer. - /// public void StopSubtitleDisplay() { if (timer is null || subtitleView is null) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 03e1665e1f..5a3994553e 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -23,11 +23,6 @@ class SubtitleExtensions : UIViewController NSObject? playerObserver; UIViewController? viewController; - /// - /// The SubtitleExtensions class provides a way to display subtitles on a video player. - /// - /// - /// public SubtitleExtensions(PlatformMediaElement player, UIViewController playerViewController) { this.playerViewController = playerViewController; @@ -50,10 +45,6 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi MediaManagerDelegate.FullScreenChanged += OnFullScreenChanged; } - /// - /// Loads the subtitles from the provided URL. - /// - /// public async Task LoadSubtitles(IMediaElement mediaElement) { this.mediaElement = mediaElement; @@ -82,9 +73,6 @@ public async Task LoadSubtitles(IMediaElement mediaElement) } } - /// - /// Starts the subtitle display. - /// public void StartSubtitleDisplay() { ArgumentNullException.ThrowIfNull(subtitleLabel); @@ -97,9 +85,6 @@ public void StartSubtitleDisplay() }); } - /// - /// Stops the subtitle display. - /// public void StopSubtitleDisplay() { subtitleLabel.Text = string.Empty; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 0f0a8a88f7..b69417cd48 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -17,9 +17,6 @@ class SubtitleExtensions : Grid, IDisposable MauiMediaElement? mauiMediaElement; System.Timers.Timer? timer; - /// - /// The SubtitleExtensions class provides a way to display subtitles on a video player. - /// public SubtitleExtensions() { cues = []; @@ -36,11 +33,6 @@ public SubtitleExtensions() }; } - /// - /// Loads the subtitles from the provided URL. - /// - /// - /// public async Task LoadSubtitles(IMediaElement mediaElement, Microsoft.UI.Xaml.Controls.MediaPlayerElement player) { this.mediaElement = mediaElement; @@ -73,9 +65,6 @@ public async Task LoadSubtitles(IMediaElement mediaElement, Microsoft.UI.Xaml.Co } } - /// - /// Starts the subtitle display. - /// public void StartSubtitleDisplay() { timer = new System.Timers.Timer(1000); @@ -84,9 +73,6 @@ public void StartSubtitleDisplay() timer.Start(); } - /// - /// Stops the subtitle timer. - /// public void StopSubtitleDisplay() { if (timer is null) @@ -150,10 +136,6 @@ void OnFullScreenChanged(object? sender, GridEventArgs e) } } - /// - /// The Dispose method. For the class."/> - /// - /// protected virtual void Dispose(bool disposing) { if (!disposedValue) @@ -173,17 +155,11 @@ protected virtual void Dispose(bool disposing) } } - /// - /// A finalizer for the . - /// ~SubtitleExtensions() { Dispose(disposing: false); } - /// - /// - /// public void Dispose() { Dispose(disposing: true); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index f7f970aa74..6907593dda 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -3,18 +3,10 @@ namespace CommunityToolkit.Maui.Core; -/// -/// -/// partial class VttParser : IParser { static readonly Regex timecodePatternVTT = VTTRegex(); - /// - /// The ParseVttContent method parses the VTT content and returns a list of SubtitleCue objects. - /// - /// - /// public List ParseContent(string content) { var cues = new List(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs index af7e434e13..33580fcbb3 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs @@ -6,7 +6,7 @@ public interface IParser { /// - /// + /// A method that parses the content. /// /// /// From 8ff994713794d100ec51d9f22d42fc7b64af6bf0 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sat, 22 Jun 2024 09:39:37 -0700 Subject: [PATCH 44/98] Refactor subtitle parsing functionality This commit represents a comprehensive refactor of the subtitle parsing functionality within the application. Key changes include the removal of the `Parser` class and its replacement with the `SubtitleParser` class, designed specifically for subtitle parsing. The `CustomParser` property in the `IMediaElement` interface and `MediaElement` class has been renamed to `CustomSubtitleParser` to more accurately reflect its purpose. All references to the old `Parser` class have been updated to use `SubtitleParser`, ensuring consistency across the codebase. Platform-specific `SubtitleExtensions` files have been updated to utilize the new `SubtitleParser` for loading and parsing subtitle content. The introduction of the `SubtitleParser` class includes methods for parsing subtitle content, fetching content from URLs, and error handling for more robust functionality. Additionally, the static `Separator` property has been maintained for splitting subtitle content into lines, preserving the original parsing logic while transitioning to the new class structure. --- .../Pages/Views/MediaElement/MediaElementPage.xaml.cs | 4 ++-- .../Extensions/SrtParser.cs | 2 +- .../Extensions/SubtitleExtensions.android.cs | 8 ++++---- .../Extensions/SubtitleExtensions.macios.cs | 8 ++++---- .../Extensions/SubtitleExtensions.windows.cs | 8 ++++---- .../Extensions/{Parser.cs => SubtitleParser.cs} | 4 ++-- .../Extensions/VttParser.cs | 2 +- .../Interfaces/IMediaElement.cs | 2 +- .../MediaElement.shared.cs | 8 ++++---- 9 files changed, 23 insertions(+), 23 deletions(-) rename src/CommunityToolkit.Maui.MediaElement/Extensions/{Parser.cs => SubtitleParser.cs} (94%) 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 0452837457..a2a307dce2 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -212,7 +212,7 @@ async void ChangeSourceClicked(Object sender, EventArgs e) return; case loadSubTitles: SrtParser srtParser = new(); - MediaElement.CustomParser = srtParser; + MediaElement.CustomSubtitleParser = srtParser; MediaElement.SubtitleFont = FontFamilies.PlaywriteSK; MediaElement.SubtitleFontSize = 16; MediaElement.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; @@ -294,7 +294,7 @@ public List ParseContent(string content) return cues; } - var lines = content.Split(Parser.Separator, StringSplitOptions.None); + var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.None); SubtitleCue? currentCue = null; var textBuffer = new List(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index 40ad40df34..125647915e 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -15,7 +15,7 @@ public List ParseContent(string content) return cues; } - var lines = content.Split(Parser.Separator, StringSplitOptions.None); + var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.None); SubtitleCue? currentCue = null; var textBuffer = new List(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 432ff9b380..8077342f4b 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -44,11 +44,11 @@ public async Task LoadSubtitles(IMediaElement mediaElement) { this.mediaElement = mediaElement; cues.Clear(); - Parser parser; - var content = await Parser.Content(mediaElement.SubtitleUrl); - if (mediaElement.CustomParser is not null) + SubtitleParser parser; + var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); + if (mediaElement.CustomSubtitleParser is not null) { - parser = new(mediaElement.CustomParser); + parser = new(mediaElement.CustomSubtitleParser); cues = parser.ParseContent(content); return; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 5a3994553e..21031991e2 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -49,11 +49,11 @@ public async Task LoadSubtitles(IMediaElement mediaElement) { this.mediaElement = mediaElement; cues.Clear(); - Parser parser; - var content = await Parser.Content(mediaElement.SubtitleUrl); - if (mediaElement.CustomParser is not null) + SubtitleParser parser; + var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); + if (mediaElement.CustomSubtitleParser is not null) { - parser = new(mediaElement.CustomParser); + parser = new(mediaElement.CustomSubtitleParser); cues = parser.ParseContent(content); return; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index b69417cd48..7a8ba4aeae 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -40,12 +40,12 @@ public async Task LoadSubtitles(IMediaElement mediaElement, Microsoft.UI.Xaml.Co cues.Clear(); subtitleTextBlock.Text = string.Empty; subtitleTextBlock.FontSize = mediaElement.SubtitleFontSize; - Parser parser; + SubtitleParser parser; - var content = await Parser.Content(mediaElement.SubtitleUrl); - if(mediaElement.CustomParser is not null) + var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); + if(mediaElement.CustomSubtitleParser is not null) { - parser = new(mediaElement.CustomParser); + parser = new(mediaElement.CustomSubtitleParser); cues = parser.ParseContent(content); return; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs similarity index 94% rename from src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.cs rename to src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs index ec59eb5b03..9946ce909b 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/Parser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs @@ -3,7 +3,7 @@ /// /// A class that Represents a parser. /// -public class Parser +public class SubtitleParser { static readonly HttpClient httpClient = new(); @@ -21,7 +21,7 @@ public class Parser /// A constructor that initializes the /// /// - public Parser(IParser parser) + public SubtitleParser(IParser parser) { this.IParser = parser; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index 6907593dda..4da3b50982 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -15,7 +15,7 @@ public List ParseContent(string content) return cues; } - var lines = content.Split(Parser.Separator, StringSplitOptions.None); + var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.None); SubtitleCue? currentCue = null; var textBuffer = new List(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs index 154de08b34..2763c239af 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs @@ -49,7 +49,7 @@ public interface IMediaElement : IView, IAsynchronousMediaElementHandler /// /// Gets the used to parse the subtitle file. /// - IParser? CustomParser { get; set; } + IParser? CustomSubtitleParser { get; } /// /// The current position of the playing media. diff --git a/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs b/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs index 9c4adfb085..87e30158da 100644 --- a/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs @@ -11,10 +11,10 @@ namespace CommunityToolkit.Maui.Views; public class MediaElement : View, IMediaElement, IDisposable { /// - /// Backing store for the property. + /// Backing store for the property. /// public static readonly BindableProperty ParserProperty = - BindableProperty.Create(nameof(CustomParser), typeof(IParser), typeof(MediaElement), null); + BindableProperty.Create(nameof(CustomSubtitleParser), typeof(IParser), typeof(MediaElement), null); /// /// Backing store for the property. @@ -226,9 +226,9 @@ internal event EventHandler StopRequested ~MediaElement() => Dispose(false); /// - /// + /// Custom parser for subtitles. /// - public IParser? CustomParser + public IParser? CustomSubtitleParser { get => (IParser)GetValue(ParserProperty); set => SetValue(ParserProperty, value); From b5f232f8552c4b09054687fa0c07daef1d53fbfc Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sat, 22 Jun 2024 19:25:04 -0700 Subject: [PATCH 45/98] Refactor subtitle management and error handling This commit overhauls the subtitle management system, focusing on enhancing robustness, maintainability, and readability. Key changes include: - Refactored `CurrentPlatformActivity` to use static properties, eliminating the need for instantiation when accessing the current activity, window, and view group. This simplification is pivotal for streamlining operations related to subtitle management in media applications. - Modified `subtitleView` initialization to leverage the static `CurrentActivity` property from `CurrentPlatformActivity`, aligning with the refactoring efforts and ensuring context-appropriate initialization. - Introduced comprehensive error handling by adding checks to throw exceptions if the current activity or its window is null, significantly improving the application's resilience by ensuring graceful failure with informative error messages. - Enhanced subtitle loading logic by adding validation to prevent null reference exceptions, implementing a clear operation for `cues`, and adding a conditional early return if the `SubtitleUrl` is null or empty, optimizing the process and ensuring a clean state for subtitle loading. - Improved subtitle display control by adding conditional checks to prevent unnecessary display attempts and ensuring a clean state through clear operations for `cues` when stopping the subtitle display. - Updated `OnFullScreenChanged` method to use static properties of `CurrentPlatformActivity` for accessing the current view group, maintaining consistency with the refactoring and ensuring correct fullscreen mode handling. These changes collectively improve the application's subtitle management functionality, making it more robust, maintainable, and easier to read. --- .../Extensions/SubtitleExtensions.android.cs | 66 +++++++++++++++---- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 8077342f4b..809c445ed2 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -14,7 +14,6 @@ class SubtitleExtensions : Java.Lang.Object readonly IDispatcher dispatcher; readonly RelativeLayout.LayoutParams? subtitleLayout; readonly StyledPlayerView styledPlayerView; - readonly CurrentPlatformActivity platform; List cues; IMediaElement? mediaElement; TextView? subtitleView; @@ -25,11 +24,6 @@ public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatc ArgumentNullException.ThrowIfNull(Platform.CurrentActivity); this.dispatcher = dispatcher; this.styledPlayerView = styledPlayerView; - if (Platform.CurrentActivity.Window?.DecorView is not ViewGroup decorView) - { - throw new InvalidOperationException("Platform.CurrentActivity.Window.DecorView is not a ViewGroup"); - } - platform = new(Platform.CurrentActivity, decorView); cues = []; subtitleLayout = new RelativeLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent); @@ -42,8 +36,16 @@ public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatc public async Task LoadSubtitles(IMediaElement mediaElement) { + ArgumentNullException.ThrowIfNull(subtitleView); this.mediaElement = mediaElement; + cues.Clear(); + subtitleView.Text = string.Empty; + if (string.IsNullOrEmpty(mediaElement.SubtitleUrl)) + { + return; + } + SubtitleParser parser; var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); if (mediaElement.CustomSubtitleParser is not null) @@ -52,6 +54,7 @@ public async Task LoadSubtitles(IMediaElement mediaElement) cues = parser.ParseContent(content); return; } + switch (mediaElement.SubtitleUrl) { case var url when url.EndsWith("srt"): @@ -70,6 +73,11 @@ public async Task LoadSubtitles(IMediaElement mediaElement) public void StartSubtitleDisplay() { + if(cues.Count == 0 || string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) + { + return; + } + ArgumentNullException.ThrowIfNull(subtitleView); if(styledPlayerView.Parent is not ViewGroup parent) { @@ -86,6 +94,7 @@ public void StopSubtitleDisplay() { if (timer is null || subtitleView is null) { + cues.Clear(); return; } if (styledPlayerView.Parent is ViewGroup parent) @@ -126,7 +135,7 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) void InitializeTextBlock() { - subtitleView = new(platform.currentActivity.ApplicationContext) + subtitleView = new(CurrentPlatformActivity.CurrentActivity.ApplicationContext) { Text = string.Empty, HorizontalScrollBarEnabled = false, @@ -140,10 +149,41 @@ void InitializeTextBlock() subtitleView.SetPaddingRelative(10, 10, 10, 20); } - readonly record struct CurrentPlatformActivity(Activity currentActivity, ViewGroup viewGroup) + record struct CurrentPlatformActivity() { - public Activity CurrentActivity { get; init; } = currentActivity; - public ViewGroup ViewGroup { get; init; } = viewGroup; + public static Activity CurrentActivity + { + get + { + if (Platform.CurrentActivity is null) + { + throw new InvalidOperationException("CurrentActivity cannot be null when the FullScreen button is tapped"); + } + return Platform.CurrentActivity; + } + } + public static Android.Views.Window CurrentWindow + { + get + { + if (Platform.CurrentActivity?.Window is null) + { + throw new InvalidOperationException("CurrentActivity cannot be null when the FullScreen button is tapped"); + } + return Platform.CurrentActivity.Window; + } + } + public static Android.Views.ViewGroup CurrentViewGroup + { + get + { + if (CurrentWindow.DecorView is not ViewGroup viewGroup) + { + throw new InvalidOperationException("CurrentActivity Window cannot be null when the FullScreen button is tapped"); + } + return viewGroup; + } + } } void OnFullScreenChanged(object? sender, FullScreenEventArgs e) @@ -156,7 +196,7 @@ void OnFullScreenChanged(object? sender, FullScreenEventArgs e) return; } - if (platform.viewGroup.Parent is not ViewGroup parent) + if (CurrentPlatformActivity.CurrentViewGroup.Parent is not ViewGroup parent) { return; } @@ -164,14 +204,14 @@ void OnFullScreenChanged(object? sender, FullScreenEventArgs e) switch (e.isFullScreen) { case true: - platform.viewGroup.RemoveView(subtitleView); + CurrentPlatformActivity.CurrentViewGroup.RemoveView(subtitleView); InitializeTextBlock(); parent.AddView(subtitleView); break; case false: parent.RemoveView(subtitleView); InitializeTextBlock(); - platform.viewGroup.AddView(subtitleView); + CurrentPlatformActivity.CurrentViewGroup.AddView(subtitleView); break; } } From 7ebee6a6e8494b8defc8a71762a1fa6a614e069a Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sat, 22 Jun 2024 22:26:15 -0700 Subject: [PATCH 46/98] Refactor PageExtensions and update Android refs - Changed `PageExtensions` class from `static` to `static partial` for enhanced flexibility and maintainability. - Removed direct `Android.App.Activity` reference in `SubtitleExtensions.android.cs`, replacing it with `CurrentPlatformActivity` from `CommunityToolkit.Maui.Extensions.PageExtensions` for better modularity. - Deleted `CurrentPlatformActivity` record in `SubtitleExtensions.android.cs` and reintroduced it in `PageExtensions.android.cs` to centralize platform-specific activity management. - Removed unused `Android.Graphics` import in `MediaControlsService.android.cs` to clean up dependencies and improve compile-time efficiency. --- .../Extensions/PageExtensions.android.cs | 43 +++++++++++++++++++ .../Extensions/PageExtensions.cs | 2 +- .../Extensions/SubtitleExtensions.android.cs | 39 +---------------- .../Services/MediaControlsService.android.cs | 1 - 4 files changed, 45 insertions(+), 40 deletions(-) create mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.android.cs diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.android.cs new file mode 100644 index 0000000000..7487154b2e --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.android.cs @@ -0,0 +1,43 @@ +using Android.Views; +using Activity = Android.App.Activity; + +namespace CommunityToolkit.Maui.Extensions; +partial class PageExtensions +{ + public record struct CurrentPlatformActivity() + { + public static Activity CurrentActivity + { + get + { + if (Platform.CurrentActivity is null) + { + throw new InvalidOperationException("CurrentActivity cannot be null when the FullScreen button is tapped"); + } + return Platform.CurrentActivity; + } + } + public static Android.Views.Window CurrentWindow + { + get + { + if (Platform.CurrentActivity?.Window is null) + { + throw new InvalidOperationException("Window cannot be null when the FullScreen button is tapped"); + } + return Platform.CurrentActivity.Window; + } + } + public static Android.Views.ViewGroup CurrentViewGroup + { + get + { + if (Platform.CurrentActivity?.Window?.DecorView is not ViewGroup viewGroup) + { + throw new InvalidOperationException("DecorView cannot be null when the FullScreen button is tapped"); + } + return viewGroup; + } + } + } +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.cs index eca07358d5..e01cbb6803 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.cs @@ -2,7 +2,7 @@ // Since MediaElement can't access .NET MAUI internals we have to copy this code here // https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Platform/PageExtensions.cs -static class PageExtensions +static partial class PageExtensions { internal static Page GetCurrentPage(this Page currentPage) { diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 809c445ed2..720bb3cf83 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -5,7 +5,7 @@ using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; using static Android.Views.ViewGroup; -using Activity = Android.App.Activity; +using CurrentPlatformActivity = CommunityToolkit.Maui.Extensions.PageExtensions.CurrentPlatformActivity; namespace CommunityToolkit.Maui.Extensions; @@ -149,43 +149,6 @@ void InitializeTextBlock() subtitleView.SetPaddingRelative(10, 10, 10, 20); } - record struct CurrentPlatformActivity() - { - public static Activity CurrentActivity - { - get - { - if (Platform.CurrentActivity is null) - { - throw new InvalidOperationException("CurrentActivity cannot be null when the FullScreen button is tapped"); - } - return Platform.CurrentActivity; - } - } - public static Android.Views.Window CurrentWindow - { - get - { - if (Platform.CurrentActivity?.Window is null) - { - throw new InvalidOperationException("CurrentActivity cannot be null when the FullScreen button is tapped"); - } - return Platform.CurrentActivity.Window; - } - } - public static Android.Views.ViewGroup CurrentViewGroup - { - get - { - if (CurrentWindow.DecorView is not ViewGroup viewGroup) - { - throw new InvalidOperationException("CurrentActivity Window cannot be null when the FullScreen button is tapped"); - } - return viewGroup; - } - } - } - void OnFullScreenChanged(object? sender, FullScreenEventArgs e) { ArgumentNullException.ThrowIfNull(subtitleView); diff --git a/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs b/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs index 4070449070..88d9a78d75 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Services/MediaControlsService.android.cs @@ -4,7 +4,6 @@ using Android.App; using Android.Content; using Android.Content.PM; -using Android.Graphics; using Android.Media; using Android.OS; using Android.Support.V4.Media.Session; From 233048c860409293c6ebec844eb7a2518cad73cc Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sat, 22 Jun 2024 23:34:58 -0700 Subject: [PATCH 47/98] Remove redundant null check --- .../Extensions/SubtitleExtensions.android.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 720bb3cf83..1fa4a53b48 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -21,7 +21,6 @@ class SubtitleExtensions : Java.Lang.Object public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatcher) { - ArgumentNullException.ThrowIfNull(Platform.CurrentActivity); this.dispatcher = dispatcher; this.styledPlayerView = styledPlayerView; cues = []; From 799c9678f9bbe604b29def81e0aa521dad7a49b7 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 25 Jun 2024 07:06:28 -0700 Subject: [PATCH 48/98] Fonts working in Android/iOS. Not working in Windows yet. Needs to be refactored. --- .../CommunityToolkit.Maui.Sample.csproj | 10 ++++++++-- samples/CommunityToolkit.Maui.Sample/MauiProgram.cs | 3 ++- .../Pages/Views/MediaElement/MediaElementPage.xaml.cs | 8 +++++++- .../Platforms/iOS/Info.plist | 4 ++++ .../Resources/Fonts/FontFamilies.cs | 1 - .../AppBuilderExtensions.shared.cs | 4 ++++ .../CommunityToolkit.Maui.MediaElement.csproj | 4 ++++ .../Extensions/SubtitleExtensions.android.cs | 8 +++++--- .../Extensions/SubtitleExtensions.macios.cs | 11 +++++++++++ .../Extensions/SubtitleExtensions.windows.cs | 4 ++-- 10 files changed, 47 insertions(+), 10 deletions(-) diff --git a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj index 674e05f90f..76d7ef5f8a 100644 --- a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj +++ b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj @@ -51,8 +51,8 @@ - - + + @@ -78,6 +78,12 @@ + + + Always + + + true android-arm;android-arm64;android-x86;android-x64 diff --git a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs index bc090a2a6b..3f9938f8cc 100644 --- a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs +++ b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs @@ -41,6 +41,7 @@ using Microsoft.UI.Xaml.Media; #endif +[assembly: ExportFont("PlaywriteSK-Regular.ttf", Alias = "PlaywriteSK")] [assembly: XamlCompilation(XamlCompilationOptions.Compile)] namespace CommunityToolkit.Maui.Sample; @@ -72,7 +73,7 @@ public static MauiApp CreateMauiApp() .ConfigureFonts(fonts => { fonts.AddFont("Font Awesome 6 Brands-Regular-400.otf", FontFamilies.FontAwesomeBrands); - fonts.AddFont("PlaywriteSK-Regular.ttf", FontFamilies.PlaywriteSK); + fonts.AddFont("PlaywriteSK-Regular.ttf", "PlaywriteSK"); }); 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 a2a307dce2..4c4ca7644b 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -213,7 +213,13 @@ async void ChangeSourceClicked(Object sender, EventArgs e) case loadSubTitles: SrtParser srtParser = new(); MediaElement.CustomSubtitleParser = srtParser; - MediaElement.SubtitleFont = FontFamilies.PlaywriteSK; +#if IOS + MediaElement.SubtitleFont = "Playwrite SK"; +#elif ANDROID + MediaElement.SubtitleFont = "PlaywriteSK-Regular.ttf"; +#elif WINDOWS + MediaElement.SubtitleFont = "Playwrite SK"; +#endif MediaElement.SubtitleFontSize = 16; MediaElement.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); diff --git a/samples/CommunityToolkit.Maui.Sample/Platforms/iOS/Info.plist b/samples/CommunityToolkit.Maui.Sample/Platforms/iOS/Info.plist index d0245bf261..08b76b572a 100644 --- a/samples/CommunityToolkit.Maui.Sample/Platforms/iOS/Info.plist +++ b/samples/CommunityToolkit.Maui.Sample/Platforms/iOS/Info.plist @@ -40,5 +40,9 @@ audio + UIAppFonts + + Resources/Fonts/PlaywriteSK-Regular.ttf + diff --git a/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/FontFamilies.cs b/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/FontFamilies.cs index 3e05552458..54039a325f 100644 --- a/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/FontFamilies.cs +++ b/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/FontFamilies.cs @@ -3,5 +3,4 @@ public static class FontFamilies { public const string FontAwesomeBrands = nameof(FontAwesomeBrands); - public const string PlaywriteSK = nameof(PlaywriteSK); } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs b/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs index 6d1cd023ac..18af735cfd 100644 --- a/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs @@ -18,6 +18,10 @@ public static MauiAppBuilder UseMauiCommunityToolkitMediaElement(this MauiAppBui builder.ConfigureMauiHandlers(h => { h.AddHandler(); + }) + .ConfigureFonts(fonts => + { + fonts.AddFont("PlaywriteSK-Regular.ttf", "PlaywriteSK"); }); #if ANDROID diff --git a/src/CommunityToolkit.Maui.MediaElement/CommunityToolkit.Maui.MediaElement.csproj b/src/CommunityToolkit.Maui.MediaElement/CommunityToolkit.Maui.MediaElement.csproj index 01cd9a058a..cca4d6fbf3 100644 --- a/src/CommunityToolkit.Maui.MediaElement/CommunityToolkit.Maui.MediaElement.csproj +++ b/src/CommunityToolkit.Maui.MediaElement/CommunityToolkit.Maui.MediaElement.csproj @@ -47,6 +47,10 @@ $(WarningsAsErrors);CS1591 True + + + + diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 1fa4a53b48..e4dbf64022 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -1,4 +1,5 @@ -using Android.Views; +using Android.Graphics; +using Android.Views; using Android.Widget; using Com.Google.Android.Exoplayer2.UI; using CommunityToolkit.Maui.Core; @@ -113,13 +114,14 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { return; } - + var cue = cues.Find(c => c.StartTime <= mediaElement.Position && c.EndTime >= mediaElement.Position); dispatcher.Dispatch(() => { if (cue is not null) { - subtitleView.FontFeatureSettings = !string.IsNullOrEmpty(mediaElement.SubtitleFont) ? mediaElement.SubtitleFont : default; + Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, mediaElement.SubtitleFont); + subtitleView.SetTypeface(typeface, TypefaceStyle.Normal); subtitleView.Text = cue.Text; subtitleView.TextSize = (float)mediaElement.SubtitleFontSize; subtitleView.Visibility = ViewStates.Visible; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 21031991e2..9c0dd77801 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -76,6 +76,15 @@ public async Task LoadSubtitles(IMediaElement mediaElement) public void StartSubtitleDisplay() { ArgumentNullException.ThrowIfNull(subtitleLabel); + var font = UIFont.FromName(name: "Playwrite SK", size: (float)16); + if(font is not null) + { + System.Diagnostics.Trace.TraceError("Font found."); + } + else + { + System.Diagnostics.Trace.TraceError("Font not found."); + } DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); playerObserver = player?.AddPeriodicTimeObserver(CMTime.FromSeconds(1, 1), null, (time) => { @@ -100,11 +109,13 @@ public void StopSubtitleDisplay() void UpdateSubtitle(TimeSpan currentPlaybackTime) { ArgumentNullException.ThrowIfNull(subtitleLabel); + ArgumentNullException.ThrowIfNull(mediaElement); foreach (var cue in cues) { if (currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime) { subtitleLabel.Text = cue.Text; + subtitleLabel.Font = UIFont.FromName(name: "Playwrite SK",size: 16) ?? UIFont.SystemFontOfSize(16); subtitleLabel.BackgroundColor = subtitleBackgroundColor; break; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 7a8ba4aeae..903ecc6cfc 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -40,6 +40,8 @@ public async Task LoadSubtitles(IMediaElement mediaElement, Microsoft.UI.Xaml.Co cues.Clear(); subtitleTextBlock.Text = string.Empty; subtitleTextBlock.FontSize = mediaElement.SubtitleFontSize; + subtitleTextBlock.FontFamily = new FontFamily(mediaElement.SubtitleFont); + subtitleTextBlock.FontStyle = Windows.UI.Text.FontStyle.Normal; SubtitleParser parser; var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); @@ -121,8 +123,6 @@ void OnFullScreenChanged(object? sender, GridEventArgs e) switch (isFullScreen) { case true: - subtitleTextBlock.FontSize = mediaElement.SubtitleFontSize; - subtitleTextBlock.FontFamily = new FontFamily(mediaElement.SubtitleFont); subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20); Dispatcher.Dispatch(() => { gridItem.Children.Remove(subtitleTextBlock); mauiMediaElement.Children.Add(subtitleTextBlock); }); isFullScreen = false; From f8211a66836c4b7f948da371a2a2d6305a41a847 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 25 Jun 2024 07:30:05 -0700 Subject: [PATCH 49/98] Added fonts to mac plist. Removed unneeded test code. --- .../Platforms/MacCatalyst/Info.plist | 4 ++++ .../Extensions/SubtitleExtensions.macios.cs | 9 --------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/samples/CommunityToolkit.Maui.Sample/Platforms/MacCatalyst/Info.plist b/samples/CommunityToolkit.Maui.Sample/Platforms/MacCatalyst/Info.plist index c342a6fa06..c44b5133b5 100644 --- a/samples/CommunityToolkit.Maui.Sample/Platforms/MacCatalyst/Info.plist +++ b/samples/CommunityToolkit.Maui.Sample/Platforms/MacCatalyst/Info.plist @@ -39,5 +39,9 @@ bluetooth-central audio + UIAppFonts + + Resources/Fonts/PlaywriteSK-Regular.ttf + diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 9c0dd77801..3afd8ed500 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -76,15 +76,6 @@ public async Task LoadSubtitles(IMediaElement mediaElement) public void StartSubtitleDisplay() { ArgumentNullException.ThrowIfNull(subtitleLabel); - var font = UIFont.FromName(name: "Playwrite SK", size: (float)16); - if(font is not null) - { - System.Diagnostics.Trace.TraceError("Font found."); - } - else - { - System.Diagnostics.Trace.TraceError("Font not found."); - } DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); playerObserver = player?.AddPeriodicTimeObserver(CMTime.FromSeconds(1, 1), null, (time) => { From 98e1b46449fa60ac325430e4c6173ac1be44dd7c Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 25 Jun 2024 07:32:08 -0700 Subject: [PATCH 50/98] Add subtitleFont string to mac in sample app --- .../Pages/Views/MediaElement/MediaElementPage.xaml.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4c4ca7644b..0519fccdd5 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -213,7 +213,7 @@ async void ChangeSourceClicked(Object sender, EventArgs e) case loadSubTitles: SrtParser srtParser = new(); MediaElement.CustomSubtitleParser = srtParser; -#if IOS +#if IOS || MACCATALYST MediaElement.SubtitleFont = "Playwrite SK"; #elif ANDROID MediaElement.SubtitleFont = "PlaywriteSK-Regular.ttf"; From d5c7a79a76e90be4019624579950448fd2b76acf Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 25 Jun 2024 08:12:44 -0700 Subject: [PATCH 51/98] Windows font support added! --- .../Pages/Views/MediaElement/MediaElementPage.xaml.cs | 2 +- .../Extensions/SubtitleExtensions.windows.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 0519fccdd5..a68d1c5ed2 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -218,7 +218,7 @@ async void ChangeSourceClicked(Object sender, EventArgs e) #elif ANDROID MediaElement.SubtitleFont = "PlaywriteSK-Regular.ttf"; #elif WINDOWS - MediaElement.SubtitleFont = "Playwrite SK"; + MediaElement.SubtitleFont = "ms-appx:///PlaywriteSK-Regular.ttf#Playwrite SK"; #endif MediaElement.SubtitleFontSize = 16; MediaElement.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 903ecc6cfc..fd8ff4c05d 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -102,6 +102,7 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) if (cue is not null) { subtitleTextBlock.Text = cue.Text; + subtitleTextBlock.FontFamily = new FontFamily(mediaElement.SubtitleFont); subtitleTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } else From b6ba3a7faf4168b9e6a596ca40b6eee0d34d9bea Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 25 Jun 2024 10:30:15 -0700 Subject: [PATCH 52/98] Refactor font handling for subtitles Unified subtitle font handling across platforms by simplifying the code in `MediaElementPage.xaml.cs` to use a single `SubtitleFont` property, eliminating the need for platform-specific directives. Integrated `CommunityToolkit.Maui.Core` in `AppBuilderExtensions.shared.cs` for enhanced app functionality and introduced dynamic font loading to add fonts programmatically, increasing flexibility. Updated device-specific font specifications in subtitle extension files (`SubtitleExtensions.*.cs`) to dynamically determine correct fonts, reducing platform-specific code. Added `FontExtensions.cs` for utility methods supporting dynamic font handling. Improved fullscreen subtitle support on Windows in `SubtitleExtensions.windows.cs` by adjusting font size based on the media element's property, enhancing user experience. --- .../MediaElement/MediaElementPage.xaml.cs | 8 +-- .../AppBuilderExtensions.shared.cs | 8 ++- .../Extensions/FontExtensions.cs | 55 +++++++++++++++++++ .../Extensions/SubtitleExtensions.android.cs | 2 +- .../Extensions/SubtitleExtensions.macios.cs | 2 +- .../Extensions/SubtitleExtensions.windows.cs | 3 +- 6 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs 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 a68d1c5ed2..3fdbaf54d3 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -213,13 +213,7 @@ async void ChangeSourceClicked(Object sender, EventArgs e) case loadSubTitles: SrtParser srtParser = new(); MediaElement.CustomSubtitleParser = srtParser; -#if IOS || MACCATALYST - MediaElement.SubtitleFont = "Playwrite SK"; -#elif ANDROID - MediaElement.SubtitleFont = "PlaywriteSK-Regular.ttf"; -#elif WINDOWS - MediaElement.SubtitleFont = "ms-appx:///PlaywriteSK-Regular.ttf#Playwrite SK"; -#endif + MediaElement.SubtitleFont = @"PlaywriteSK-Regular.ttf#Playwrite SK"; MediaElement.SubtitleFontSize = 16; MediaElement.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); diff --git a/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs b/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs index 18af735cfd..597f8e5b95 100644 --- a/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs @@ -1,5 +1,6 @@ using CommunityToolkit.Maui.Core.Handlers; using CommunityToolkit.Maui.Views; +using CommunityToolkit.Maui.Core; namespace CommunityToolkit.Maui; @@ -15,13 +16,18 @@ public static class AppBuilderExtensions /// initialized for . public static MauiAppBuilder UseMauiCommunityToolkitMediaElement(this MauiAppBuilder builder) { + var importedFonts = FontHelper.GetExportedFonts(); + builder.ConfigureMauiHandlers(h => { h.AddHandler(); }) .ConfigureFonts(fonts => { - fonts.AddFont("PlaywriteSK-Regular.ttf", "PlaywriteSK"); + foreach (var (FontFileName, Alias) in importedFonts) + { + fonts.AddFont(FontFileName, Alias); + } }); #if ANDROID diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs new file mode 100644 index 0000000000..d999072eff --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs @@ -0,0 +1,55 @@ +using System.Reflection; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Maui.Core; + +static class DeviceFontSpecs +{ + /// + /// Extracts and returns the font file and font name for Android, Windows, and iOS from the given input string. + /// + /// The input string in the format "fontfile.ttf#fontname". + /// A tuple containing the font specifications for Android, Windows, and iOS. + public static (string androidFont, string windowsFont, string iOSFont) OutputDeviceSpecifications(string input) + { + string pattern = @"(.+\.ttf)#(.+)"; + + var match = Regex.Match(input, pattern); + + if (match.Success) + { + // Extract the font file and font name + var fontFile = match.Groups[1].Value; + var fontName = match.Groups[2].Value; + string windowsFont = $"ms-appx:///{fontFile}#{fontName}"; + return (fontFile, windowsFont, fontName); + } + else + { + System.Diagnostics.Trace.TraceError("The input string is not in the expected format."); + } + return (string.Empty, string.Empty, string.Empty); + } +} + +static class FontHelper +{ + /// + /// Returns the list of exported fonts from the assembly. + /// + /// + public static IEnumerable<(string FontFileName, string Alias)> GetExportedFonts() + { + var assembly = typeof(FontHelper).Assembly; + var exportedFonts = new List<(string FontFileName, string Alias)>(); + + var customAttributes = assembly.GetCustomAttributes(); + + foreach (var attribute in customAttributes) + { + exportedFonts.Add((attribute.FontFileName, attribute.Alias)); + } + + return exportedFonts; + } +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index e4dbf64022..812c90f66c 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -120,7 +120,7 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { if (cue is not null) { - Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, mediaElement.SubtitleFont); + Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, DeviceFontSpecs.OutputDeviceSpecifications(mediaElement.SubtitleFont).androidFont) ?? Typeface.Default; subtitleView.SetTypeface(typeface, TypefaceStyle.Normal); subtitleView.Text = cue.Text; subtitleView.TextSize = (float)mediaElement.SubtitleFontSize; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 3afd8ed500..a8abe397c3 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -106,7 +106,7 @@ void UpdateSubtitle(TimeSpan currentPlaybackTime) if (currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime) { subtitleLabel.Text = cue.Text; - subtitleLabel.Font = UIFont.FromName(name: "Playwrite SK",size: 16) ?? UIFont.SystemFontOfSize(16); + subtitleLabel.Font = UIFont.FromName(name: DeviceFontSpecs.OutputDeviceSpecifications(mediaElement.SubtitleFont).iOSFont,size: (float)mediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize(16); subtitleLabel.BackgroundColor = subtitleBackgroundColor; break; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index fd8ff4c05d..c4b6e7bbd5 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -102,7 +102,7 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) if (cue is not null) { subtitleTextBlock.Text = cue.Text; - subtitleTextBlock.FontFamily = new FontFamily(mediaElement.SubtitleFont); + subtitleTextBlock.FontFamily = new FontFamily(DeviceFontSpecs.OutputDeviceSpecifications(mediaElement.SubtitleFont).windowsFont); subtitleTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } else @@ -125,6 +125,7 @@ void OnFullScreenChanged(object? sender, GridEventArgs e) { case true: subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20); + subtitleTextBlock.FontSize = mediaElement.SubtitleFontSize; Dispatcher.Dispatch(() => { gridItem.Children.Remove(subtitleTextBlock); mauiMediaElement.Children.Add(subtitleTextBlock); }); isFullScreen = false; break; From f0bdc92d1e87e45b14b2b1ac9b0b0fb7363a151c Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 25 Jun 2024 11:43:03 -0700 Subject: [PATCH 53/98] Refactor font spec handling and parsing - Renamed `DeviceFontSpecs` to `FontExtensions` and refactored its functionality. - Introduced `FontFamily` record struct for parsing font specifications with regular expressions, providing platform-specific font paths through properties (`Android`, `WindowsFont`, `MacIOS`). - Made the regex pattern static for performance and added instance `match` field in `FontFamily` for optimized parsing. - Improved error handling by logging mismatches in expected font specification format. - Updated subtitle extension methods across platform-specific files to use `FontFamily` struct for font specifications. - Added a new `FontHelper` class, indicating further refactoring or new functionalities related to font handling. --- .../Extensions/FontExtensions.cs | 65 +++++++++++++------ .../Extensions/SubtitleExtensions.android.cs | 2 +- .../Extensions/SubtitleExtensions.macios.cs | 2 +- .../Extensions/SubtitleExtensions.windows.cs | 2 +- 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs index d999072eff..bfac195bdd 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs @@ -3,35 +3,62 @@ namespace CommunityToolkit.Maui.Core; -static class DeviceFontSpecs +static class FontExtensions { - /// - /// Extracts and returns the font file and font name for Android, Windows, and iOS from the given input string. - /// - /// The input string in the format "fontfile.ttf#fontname". - /// A tuple containing the font specifications for Android, Windows, and iOS. - public static (string androidFont, string windowsFont, string iOSFont) OutputDeviceSpecifications(string input) + public record struct FontFamily(string input) { - string pattern = @"(.+\.ttf)#(.+)"; - - var match = Regex.Match(input, pattern); + static readonly string pattern = @"(.+\.ttf)#(.+)"; - if (match.Success) + readonly Match match = Regex.Match(input, pattern); + public readonly string Android { - // Extract the font file and font name - var fontFile = match.Groups[1].Value; - var fontName = match.Groups[2].Value; - string windowsFont = $"ms-appx:///{fontFile}#{fontName}"; - return (fontFile, windowsFont, fontName); + get + { + if (match.Success) + { + return match.Groups[1].Value; + } + else + { + System.Diagnostics.Trace.TraceError("The input string is not in the expected format."); + return string.Empty; + } + } } - else + public readonly string WindowsFont { - System.Diagnostics.Trace.TraceError("The input string is not in the expected format."); + get + { + if (match.Success) + { + return $"ms-appx:///{match.Groups[1].Value}#{match.Groups[2].Value}"; + } + else + { + System.Diagnostics.Trace.TraceError("The input string is not in the expected format."); + return string.Empty; + } + } + } + public readonly string MacIOS + { + get + { + if (match.Success) + { + return match.Groups[2].Value; + } + else + { + System.Diagnostics.Trace.TraceError("The input string is not in the expected format."); + return string.Empty; + } + } } - return (string.Empty, string.Empty, string.Empty); } } + static class FontHelper { /// diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 812c90f66c..7dddabc7c4 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -120,7 +120,7 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { if (cue is not null) { - Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, DeviceFontSpecs.OutputDeviceSpecifications(mediaElement.SubtitleFont).androidFont) ?? Typeface.Default; + Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).Android) ?? Typeface.Default; subtitleView.SetTypeface(typeface, TypefaceStyle.Normal); subtitleView.Text = cue.Text; subtitleView.TextSize = (float)mediaElement.SubtitleFontSize; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index a8abe397c3..c0fec690db 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -106,7 +106,7 @@ void UpdateSubtitle(TimeSpan currentPlaybackTime) if (currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime) { subtitleLabel.Text = cue.Text; - subtitleLabel.Font = UIFont.FromName(name: DeviceFontSpecs.OutputDeviceSpecifications(mediaElement.SubtitleFont).iOSFont,size: (float)mediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize(16); + subtitleLabel.Font = UIFont.FromName(name: new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).MacIOS, size: (float)mediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize(16); subtitleLabel.BackgroundColor = subtitleBackgroundColor; break; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index c4b6e7bbd5..d34d732e5a 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -102,7 +102,7 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) if (cue is not null) { subtitleTextBlock.Text = cue.Text; - subtitleTextBlock.FontFamily = new FontFamily(DeviceFontSpecs.OutputDeviceSpecifications(mediaElement.SubtitleFont).windowsFont); + subtitleTextBlock.FontFamily = new FontFamily(new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).WindowsFont); subtitleTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } else From 7636777958081df01494c854e746d7835ed60054 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 25 Jun 2024 19:26:18 -0700 Subject: [PATCH 54/98] Add multiple fonts to sample to show how to use them in media element. Add support for OTF and TTF font types --- .../MauiProgram.cs | 1 + .../MediaElement/MediaElementPage.xaml.cs | 10 +++++- .../Resources/Fonts/Poppins-Regular.ttf | Bin 0 -> 158240 bytes .../Extensions/FontExtensions.cs | 34 ++++++++++++------ 4 files changed, 33 insertions(+), 12 deletions(-) create mode 100644 samples/CommunityToolkit.Maui.Sample/Resources/Fonts/Poppins-Regular.ttf diff --git a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs index 3f9938f8cc..0fcdb28b47 100644 --- a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs +++ b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs @@ -41,6 +41,7 @@ using Microsoft.UI.Xaml.Media; #endif +[assembly: ExportFont("Poppins-Regular.ttf", Alias = "Poppins")] [assembly: ExportFont("PlaywriteSK-Regular.ttf", Alias = "PlaywriteSK")] [assembly: XamlCompilation(XamlCompilationOptions.Compile)] 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 3fdbaf54d3..b3ba6a0a70 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -213,7 +213,15 @@ async void ChangeSourceClicked(Object sender, EventArgs e) case loadSubTitles: SrtParser srtParser = new(); MediaElement.CustomSubtitleParser = srtParser; - MediaElement.SubtitleFont = @"PlaywriteSK-Regular.ttf#Playwrite SK"; + if (DevicePlatform.iOS == DeviceInfo.Platform || DevicePlatform.macOS == DeviceInfo.Platform) + { + MediaElement.SubtitleFont = @"PlaywriteSK-Regular.ttf#Playwrite SK"; + } + else + { + MediaElement.SubtitleFont = @"Poppins-Regular.ttf#Poppins"; + } + MediaElement.SubtitleFontSize = 16; MediaElement.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); diff --git a/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/Poppins-Regular.ttf b/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/Poppins-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9f0c71b70a49664ced448c63edc9c4ff2bf8cf4a GIT binary patch literal 158240 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 Date: Wed, 26 Jun 2024 01:17:15 -0700 Subject: [PATCH 55/98] Refactor subtitle parsers for efficiency - Introduced `System.Text` namespace in `MediaElementPage.xaml.cs`, `SrtParser.cs`, and `VttParser.cs` for `StringBuilder` usage. - Refactored subtitle cue text construction to use `StringBuilder` in `SrtParser.cs` and `VttParser.cs`, enhancing string manipulation efficiency. - Modified line splitting logic to ignore empty entries, streamlining parsing. - Added `CreateCue` method in both parsers for centralized `SubtitleCue` creation, improving modularity. - Updated text buffering to use `StringBuilder.AppendLine`, optimizing memory and simplifying code. - Enhanced regex performance in timecode parsing by using `RegexOptions.Compiled`. - Improved error handling in `VttParser.cs` timecode parsing with `TimeSpan.TryParse`, increasing robustness. --- .../MediaElement/MediaElementPage.xaml.cs | 35 ++++++++++--------- .../Extensions/SrtParser.cs | 32 ++++++++++------- .../Extensions/VttParser.cs | 35 ++++++++++++------- 3 files changed, 60 insertions(+), 42 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 b3ba6a0a70..aaf4808c9d 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -8,6 +8,7 @@ using LayoutAlignment = Microsoft.Maui.Primitives.LayoutAlignment; using System.Globalization; using System.Text.RegularExpressions; +using System.Text; namespace CommunityToolkit.Maui.Sample.Pages.Views; @@ -289,11 +290,6 @@ partial class SrtParser : IParser { static readonly Regex timecodePatternSRT = SRTRegex(); - ///

- /// a method that parses the SRT content and returns a list of SubtitleCue objects. - /// - /// - /// public List ParseContent(string content) { var cues = new List(); @@ -302,9 +298,10 @@ public List ParseContent(string content) return cues; } - var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.None); + var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); + SubtitleCue? currentCue = null; - var textBuffer = new List(); + var textBuffer = new StringBuilder(); foreach (var line in lines) { @@ -318,38 +315,42 @@ public List ParseContent(string content) { if (currentCue is not null) { - currentCue.Text = string.Join(Environment.NewLine, textBuffer); + currentCue.Text = textBuffer.ToString(); cues.Add(currentCue); textBuffer.Clear(); } - currentCue = new SubtitleCue - { - StartTime = ParseTimecode(match.Groups[1].Value), - EndTime = ParseTimecode(match.Groups[2].Value), - Text = string.Empty - }; + currentCue = CreateCue(match); } else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) { - textBuffer.Add(line.Trim()); + textBuffer.AppendLine(line.Trim()); } } if (currentCue is not null) { - currentCue.Text = string.Join(Environment.NewLine, textBuffer); + currentCue.Text = textBuffer.ToString(); cues.Add(currentCue); } return cues; } + static SubtitleCue CreateCue(Match match) + { + return new SubtitleCue + { + StartTime = ParseTimecode(match.Groups[1].Value), + EndTime = ParseTimecode(match.Groups[2].Value), + Text = string.Empty + }; + } static TimeSpan ParseTimecode(string timecode) { return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); } - [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})")] + [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})", RegexOptions.Compiled)] private static partial Regex SRTRegex(); } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index 125647915e..2e9e6eb056 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; using System.Text.RegularExpressions; namespace CommunityToolkit.Maui.Core; @@ -15,9 +16,10 @@ public List ParseContent(string content) return cues; } - var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.None); + var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); + SubtitleCue? currentCue = null; - var textBuffer = new List(); + var textBuffer = new StringBuilder(); foreach (var line in lines) { @@ -31,39 +33,43 @@ public List ParseContent(string content) { if (currentCue is not null) { - currentCue.Text = string.Join(Environment.NewLine, textBuffer); + currentCue.Text = textBuffer.ToString(); cues.Add(currentCue); textBuffer.Clear(); } - currentCue = new SubtitleCue - { - StartTime = ParseTimecode(match.Groups[1].Value), - EndTime = ParseTimecode(match.Groups[2].Value), - Text = string.Empty - }; + currentCue = CreateCue(match); } else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) { - textBuffer.Add(line.Trim()); + textBuffer.AppendLine(line.Trim()); } } if (currentCue is not null) { - currentCue.Text = string.Join(Environment.NewLine, textBuffer); + currentCue.Text = textBuffer.ToString(); cues.Add(currentCue); } return cues; } - + + static SubtitleCue CreateCue(Match match) + { + return new SubtitleCue + { + StartTime = ParseTimecode(match.Groups[1].Value), + EndTime = ParseTimecode(match.Groups[2].Value), + Text = string.Empty + }; + } static TimeSpan ParseTimecode(string timecode) { return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); } - [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})")] + [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})", RegexOptions.Compiled)] private static partial Regex SRTRegex(); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index 4da3b50982..67c7dbdbd6 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; using System.Text.RegularExpressions; namespace CommunityToolkit.Maui.Core; @@ -15,9 +16,10 @@ public List ParseContent(string content) return cues; } - var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.None); + var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); + SubtitleCue? currentCue = null; - var textBuffer = new List(); + var textBuffer = new StringBuilder(); foreach (var line in lines) { @@ -26,21 +28,16 @@ public List ParseContent(string content) { if (currentCue is not null) { - currentCue.Text = string.Join(" ", textBuffer); + currentCue.Text = textBuffer.ToString().Trim(); cues.Add(currentCue); textBuffer.Clear(); } - currentCue = new SubtitleCue - { - StartTime = ParseTimecode(match.Groups[1].Value), - EndTime = ParseTimecode(match.Groups[2].Value), - Text = string.Empty - }; + currentCue = CreateCue(match); } else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) { - textBuffer.Add(line.Trim('-').Trim()); + textBuffer.AppendLine(line.Trim('-').Trim()); } } @@ -53,11 +50,25 @@ public List ParseContent(string content) return cues; } + static SubtitleCue CreateCue(Match match) + { + return new SubtitleCue + { + StartTime = ParseTimecode(match.Groups[1].Value), + EndTime = ParseTimecode(match.Groups[2].Value), + Text = string.Empty + }; + } + static TimeSpan ParseTimecode(string timecode) { - return TimeSpan.Parse(timecode, CultureInfo.InvariantCulture); + if (TimeSpan.TryParse(timecode, CultureInfo.InvariantCulture, out var result)) + { + return result; + } + throw new FormatException($"Invalid timecode format: {timecode}"); } - [GeneratedRegex(@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})")] + [GeneratedRegex(@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})", RegexOptions.Compiled)] private static partial Regex VTTRegex(); } From 97702a373f6d4121cdd901c0c94f19765a4ed269 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Wed, 26 Jun 2024 02:59:24 -0700 Subject: [PATCH 56/98] Updated font exports behavior Removed font loading from sample project, it is not needed for device specific font load in nuget. --- .../CommunityToolkit.Maui.Sample.csproj | 6 ------ samples/CommunityToolkit.Maui.Sample/MauiProgram.cs | 1 - 2 files changed, 7 deletions(-) diff --git a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj index 76d7ef5f8a..fb2de7816f 100644 --- a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj +++ b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj @@ -78,12 +78,6 @@ - - - Always - - - true android-arm;android-arm64;android-x86;android-x64 diff --git a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs index 0fcdb28b47..389c324a5f 100644 --- a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs +++ b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs @@ -74,7 +74,6 @@ public static MauiApp CreateMauiApp() .ConfigureFonts(fonts => { fonts.AddFont("Font Awesome 6 Brands-Regular-400.otf", FontFamilies.FontAwesomeBrands); - fonts.AddFont("PlaywriteSK-Regular.ttf", "PlaywriteSK"); }); From b3c7a1e6a5ff314622915d4233ccac246b80bbd3 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Wed, 26 Jun 2024 04:54:10 -0700 Subject: [PATCH 57/98] fix spacing --- .../CommunityToolkit.Maui.Sample.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj index fb2de7816f..674e05f90f 100644 --- a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj +++ b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj @@ -51,8 +51,8 @@ - - + + From 6e79bf0109421705283ee4ee1fd4258fba9f18df Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 27 Jun 2024 13:15:36 -0700 Subject: [PATCH 58/98] Improve subtitle loading and display - Updated the `loadSubTitles` string in `MediaElementPage.xaml.cs` for clarity. - Added platform-specific subtitle font size settings in `ChangeSourceClicked`. - Adjusted font size and line break mode for iOS/macOS in `SubtitleExtensions.macios.cs`. - Modified `StartSubtitleDisplay` to clear cues on stop. - Simplified `StopSubtitleDisplay` by removing redundant code. - Enhanced `UpdateSubtitle` with early return for null or empty `SubtitleUrl`. --- .../Views/MediaElement/MediaElementPage.xaml.cs | 6 ++++-- .../Extensions/SubtitleExtensions.macios.cs | 15 ++++++++------- 2 files changed, 12 insertions(+), 9 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 aaf4808c9d..a3e6c0199f 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -19,7 +19,7 @@ public partial class MediaElementPage : BasePage const string loadHls = "Load HTTP Live Stream (HLS)"; const string loadLocalResource = "Load Local Resource"; const string resetSource = "Reset Source to null"; - const string loadSubTitles = "Load Subtitles"; + const string loadSubTitles = "Load sample with Subtitles"; public MediaElementPage(MediaElementViewModel viewModel, ILogger logger) : base(viewModel) { @@ -217,13 +217,15 @@ async void ChangeSourceClicked(Object sender, EventArgs e) if (DevicePlatform.iOS == DeviceInfo.Platform || DevicePlatform.macOS == DeviceInfo.Platform) { MediaElement.SubtitleFont = @"PlaywriteSK-Regular.ttf#Playwrite SK"; + MediaElement.SubtitleFontSize = 12; } else { MediaElement.SubtitleFont = @"Poppins-Regular.ttf#Poppins"; + MediaElement.SubtitleFontSize = 16; } - MediaElement.SubtitleFontSize = 16; + MediaElement.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); return; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index c0fec690db..b72447e16f 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -33,9 +33,10 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi Frame = CalculateSubtitleFrame(playerViewController), TextColor = UIColor.White, TextAlignment = UITextAlignment.Center, - Font = UIFont.SystemFontOfSize(16), + Font = UIFont.SystemFontOfSize(12), Text = "", Lines = 0, + LineBreakMode = UILineBreakMode.WordWrap, AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleTopMargin | UIViewAutoresizing.FlexibleHeight @@ -88,19 +89,19 @@ public void StartSubtitleDisplay() public void StopSubtitleDisplay() { subtitleLabel.Text = string.Empty; + cues.Clear(); subtitleLabel.BackgroundColor = clearBackgroundColor; DispatchQueue.MainQueue.DispatchAsync(() => subtitleLabel.RemoveFromSuperview()); - - if (playerObserver is null) - { - return; - } - player.RemoveTimeObserver(playerObserver); } void UpdateSubtitle(TimeSpan currentPlaybackTime) { ArgumentNullException.ThrowIfNull(subtitleLabel); ArgumentNullException.ThrowIfNull(mediaElement); + if (string.IsNullOrEmpty(mediaElement.SubtitleUrl)) + { + return; + } + foreach (var cue in cues) { if (currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime) From 8a367bd5b8fad5899ea8373c742cb19e0a41df5d Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 27 Jun 2024 13:48:25 -0700 Subject: [PATCH 59/98] Add destructor to SubtitleExtensions class Added a destructor method `~SubtitleExtensions()` to the `SubtitleExtensions` class within the `SubtitleExtensions.macios.cs` file. This destructor ensures proper cleanup by unsubscribing from the `FullScreenChanged` event of the `MediaManagerDelegate` and removing a time observer from the `player` object, provided `playerObserver` and `player` are not null. This change aims to prevent potential memory leaks or unwanted behavior by ensuring that instances of `SubtitleExtensions` are correctly disposed of during garbage collection. --- .../Extensions/SubtitleExtensions.macios.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index b72447e16f..67099e7f65 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -150,5 +150,14 @@ void OnFullScreenChanged(object? sender, FullScreenEventArgs e) break; } } + + ~SubtitleExtensions() + { + MediaManagerDelegate.FullScreenChanged -= OnFullScreenChanged; + if(playerObserver is not null && player is not null) + { + player.RemoveTimeObserver(playerObserver); + } + } } From b60beeee9daa810833cdfc183d7970ff5c9f51d9 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 27 Jun 2024 17:30:40 -0700 Subject: [PATCH 60/98] Improve subtitle parsing and error handling Enhanced subtitle parsing and error handling across SRT and VTT formats by trimming trailing newlines, adding validation for empty files and cue timing, and wrapping subtitle loading in try-catch blocks. Implemented new unit tests for both parsers and refactored code for better readability and maintainability. These changes aim to ensure more robust and efficient subtitle processing, with improved error logging and validation mechanisms. --- .../MediaElement/MediaElementPage.xaml.cs | 25 ++++-- .../Extensions/SrtParser.cs | 28 ++++--- .../Extensions/SubtitleExtensions.android.cs | 42 ++++++---- .../Extensions/SubtitleExtensions.macios.cs | 42 ++++++---- .../Extensions/SubtitleExtensions.windows.cs | 43 ++++++---- .../Extensions/VttParser.cs | 21 +++-- .../Extensions/SrtParserTests.cs | 75 +++++++++++++++++ .../Extensions/VttParserTests.cs | 81 +++++++++++++++++++ 8 files changed, 286 insertions(+), 71 deletions(-) create mode 100644 src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs create mode 100644 src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs 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 a3e6c0199f..e3e7271079 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -317,7 +317,7 @@ public List ParseContent(string content) { if (currentCue is not null) { - currentCue.Text = textBuffer.ToString(); + currentCue.Text = textBuffer.ToString().TrimEnd('\r', '\n'); cues.Add(currentCue); textBuffer.Clear(); } @@ -326,28 +326,39 @@ public List ParseContent(string content) } else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) { - textBuffer.AppendLine(line.Trim()); + textBuffer.AppendLine(line.Trim().TrimEnd('\r', '\n')); } } if (currentCue is not null) { - currentCue.Text = textBuffer.ToString(); + currentCue.Text = textBuffer.ToString().TrimEnd('\r', '\n'); cues.Add(currentCue); } - + if (cues.Count == 0) + { + throw new FormatException("Invalid SRT format"); + } return cues; } static SubtitleCue CreateCue(Match match) { + var StartTime = ParseTimecode(match.Groups[1].Value); + var EndTime = ParseTimecode(match.Groups[2].Value); + var Text = string.Empty; + if (StartTime > EndTime) + { + throw new FormatException("Start time cannot be greater than end time."); + } return new SubtitleCue { - StartTime = ParseTimecode(match.Groups[1].Value), - EndTime = ParseTimecode(match.Groups[2].Value), - Text = string.Empty + StartTime = StartTime, + EndTime = EndTime, + Text = Text }; } + static TimeSpan ParseTimecode(string timecode) { return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index 2e9e6eb056..ed13e771bc 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -33,7 +33,7 @@ public List ParseContent(string content) { if (currentCue is not null) { - currentCue.Text = textBuffer.ToString(); + currentCue.Text = textBuffer.ToString().TrimEnd('\r', '\n'); cues.Add(currentCue); textBuffer.Clear(); } @@ -42,28 +42,39 @@ public List ParseContent(string content) } else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) { - textBuffer.AppendLine(line.Trim()); + textBuffer.AppendLine(line.Trim().TrimEnd('\r', '\n')); } } if (currentCue is not null) { - currentCue.Text = textBuffer.ToString(); + currentCue.Text = textBuffer.ToString().TrimEnd('\r', '\n'); cues.Add(currentCue); } - + if(cues.Count == 0) + { + throw new FormatException("Invalid SRT format"); + } return cues; } static SubtitleCue CreateCue(Match match) { + var StartTime = ParseTimecode(match.Groups[1].Value); + var EndTime = ParseTimecode(match.Groups[2].Value); + var Text = string.Empty; + if (StartTime > EndTime) + { + throw new FormatException("Start time cannot be greater than end time."); + } return new SubtitleCue { - StartTime = ParseTimecode(match.Groups[1].Value), - EndTime = ParseTimecode(match.Groups[2].Value), - Text = string.Empty + StartTime = StartTime, + EndTime = EndTime, + Text = Text }; } + static TimeSpan ParseTimecode(string timecode) { return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); @@ -71,5 +82,4 @@ static TimeSpan ParseTimecode(string timecode) [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})", RegexOptions.Compiled)] private static partial Regex SRTRegex(); -} - +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 7dddabc7c4..87971ff668 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -48,26 +48,34 @@ public async Task LoadSubtitles(IMediaElement mediaElement) SubtitleParser parser; var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); - if (mediaElement.CustomSubtitleParser is not null) + try { - parser = new(mediaElement.CustomSubtitleParser); - cues = parser.ParseContent(content); - return; - } - - switch (mediaElement.SubtitleUrl) - { - case var url when url.EndsWith("srt"): - parser = new(new SrtParser()); - cues = parser.ParseContent(content); - break; - case var url when url.EndsWith("vtt"): - parser = new(new VttParser()); + if (mediaElement.CustomSubtitleParser is not null) + { + parser = new(mediaElement.CustomSubtitleParser); cues = parser.ParseContent(content); - break; - default: - System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); return; + } + + switch (mediaElement.SubtitleUrl) + { + case var url when url.EndsWith("srt"): + parser = new(new SrtParser()); + cues = parser.ParseContent(content); + break; + case var url when url.EndsWith("vtt"): + parser = new(new VttParser()); + cues = parser.ParseContent(content); + break; + default: + System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); + return; + } + } + catch (Exception ex) + { + System.Diagnostics.Trace.TraceError(ex.Message); + return; } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 67099e7f65..89f2eded52 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -50,27 +50,37 @@ public async Task LoadSubtitles(IMediaElement mediaElement) { this.mediaElement = mediaElement; cues.Clear(); + SubtitleParser parser; var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); - if (mediaElement.CustomSubtitleParser is not null) - { - parser = new(mediaElement.CustomSubtitleParser); - cues = parser.ParseContent(content); - return; - } - switch (mediaElement.SubtitleUrl) + try { - case var url when url.EndsWith("srt"): - parser = new(new SrtParser()); - cues = parser.ParseContent(content); - break; - case var url when url.EndsWith("vtt"): - parser = new(new VttParser()); + if (mediaElement.CustomSubtitleParser is not null) + { + parser = new(mediaElement.CustomSubtitleParser); cues = parser.ParseContent(content); - break; - default: - System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); return; + } + + switch (mediaElement.SubtitleUrl) + { + case var url when url.EndsWith("srt"): + parser = new(new SrtParser()); + cues = parser.ParseContent(content); + break; + case var url when url.EndsWith("vtt"): + parser = new(new VttParser()); + cues = parser.ParseContent(content); + break; + default: + System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); + return; + } + } + catch (Exception ex) + { + System.Diagnostics.Trace.TraceError(ex.Message); + return; } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index d34d732e5a..75f94d22f7 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -42,28 +42,37 @@ public async Task LoadSubtitles(IMediaElement mediaElement, Microsoft.UI.Xaml.Co subtitleTextBlock.FontSize = mediaElement.SubtitleFontSize; subtitleTextBlock.FontFamily = new FontFamily(mediaElement.SubtitleFont); subtitleTextBlock.FontStyle = Windows.UI.Text.FontStyle.Normal; + SubtitleParser parser; - var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); - if(mediaElement.CustomSubtitleParser is not null) - { - parser = new(mediaElement.CustomSubtitleParser); - cues = parser.ParseContent(content); - return; - } - switch (mediaElement.SubtitleUrl) + try { - case var url when url.EndsWith("srt"): - parser = new(new SrtParser()); - cues = parser.ParseContent(content); - break; - case var url when url.EndsWith("vtt"): - parser = new(new VttParser()); + if (mediaElement.CustomSubtitleParser is not null) + { + parser = new(mediaElement.CustomSubtitleParser); cues = parser.ParseContent(content); - break; - default: - System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); return; + } + + switch (mediaElement.SubtitleUrl) + { + case var url when url.EndsWith("srt"): + parser = new(new SrtParser()); + cues = parser.ParseContent(content); + break; + case var url when url.EndsWith("vtt"): + parser = new(new VttParser()); + cues = parser.ParseContent(content); + break; + default: + System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); + return; + } + } + catch (Exception ex) + { + System.Diagnostics.Trace.TraceError(ex.Message); + return; } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index 67c7dbdbd6..90556cce08 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -43,20 +43,31 @@ public List ParseContent(string content) if (currentCue is not null) { - currentCue.Text = string.Join(" ", textBuffer); + currentCue.Text = string.Join(" ", textBuffer).TrimEnd('\r', '\n'); cues.Add(currentCue); } + if(cues.Count == 0) + { + throw new FormatException("Invalid VTT format"); + } return cues; } static SubtitleCue CreateCue(Match match) { + var StartTime = ParseTimecode(match.Groups[1].Value); + var EndTime = ParseTimecode(match.Groups[2].Value); + var Text = string.Empty; + if (StartTime > EndTime) + { + throw new FormatException("Start time cannot be greater than end time."); + } return new SubtitleCue { - StartTime = ParseTimecode(match.Groups[1].Value), - EndTime = ParseTimecode(match.Groups[2].Value), - Text = string.Empty + StartTime = StartTime, + EndTime = EndTime, + Text = Text }; } @@ -71,4 +82,4 @@ static TimeSpan ParseTimecode(string timecode) [GeneratedRegex(@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})", RegexOptions.Compiled)] private static partial Regex VTTRegex(); -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs new file mode 100644 index 0000000000..c5b09ebfbc --- /dev/null +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs @@ -0,0 +1,75 @@ +using CommunityToolkit.Maui.Core; +using Xunit; + +namespace CommunityToolkit.Maui.UnitTests.Extensions; + +public class SrtParserTests : BaseTest +{ + [Fact] + public void ParseSrtFile_ValidInput_ReturnsExpectedResult() + { + // Arrange + var srtContent = @"1 +00:00:10,000 --> 00:00:13,000 +This is the first subtitle. + +2 +00:00:15,000 --> 00:00:18,000 +This is the second subtitle."; + + // Act + SrtParser srtParser = new(); + var cues = srtParser.ParseContent(srtContent); + + // Assert + Assert.Equal(TimeSpan.FromSeconds(10), cues[0].StartTime); + Assert.Equal(TimeSpan.FromSeconds(13), cues[0].EndTime); + Assert.Equal("This is the first subtitle.", cues[0].Text); + Assert.Equal(TimeSpan.FromSeconds(15), cues[1].StartTime); + Assert.Equal(TimeSpan.FromSeconds(18), cues[1].EndTime); + Assert.Equal("This is the second subtitle.", cues[1].Text); + } + + [Fact] + public void ParseSrtFile_EmptyInput_ReturnsEmptyList() + { + // Arrange + var srtContent = string.Empty; + + // Act + SrtParser srtParser = new(); + var result = srtParser.ParseContent(srtContent); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void ParseSrtFile_InvalidFormat_ThrowsException() + { + // Arrange + var srtContent = "Invalid format"; + + // Act & Assert + SrtParser srtParser = new(); + Assert.Throws(() => srtParser.ParseContent(srtContent)); + } + + [Fact] + public void ParseSrtFile_InvalidTimestamps_ThrowsException() + { + // Arrange + var content = @"1 + +00:00:00.000 --> 00:00:05.000 +This is the first subtitle. + +2 +00:00:10.000 --> 00:00:05.000 +This is the second subtitle."; + + // Act & Assert + VttParser vttParser = new(); + Assert.Throws(() => vttParser.ParseContent(content)); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs new file mode 100644 index 0000000000..b72b5f0a68 --- /dev/null +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using CommunityToolkit.Maui.Core; +using Xunit; + +namespace CommunityToolkit.Maui.UnitTests.Extensions; + +public class VttParserTests : BaseTest +{ + [Fact] + public void ParseVttFile_ValidFile_ReturnsCorrectCues() + { + // Arrange + var content = @"WEBVTT + +00:00:00.000 --> 00:00:05.000 +This is the first cue. + +00:00:05.000 --> 00:00:10.000 +This is the second cue."; + + // Act + VttParser vttParser = new(); + var cues = vttParser.ParseContent(content); + + // Assert + Assert.Equal(2, cues.Count); + Assert.Equal(TimeSpan.Zero, cues[0].StartTime); + Assert.Equal(TimeSpan.FromSeconds(5), cues[0].EndTime); + Assert.Equal("This is the first cue.", cues[0].Text); + Assert.Equal(TimeSpan.FromSeconds(5), cues[1].StartTime); + Assert.Equal(TimeSpan.FromSeconds(10), cues[1].EndTime); + Assert.Equal("This is the second cue.", cues[1].Text); + } + + [Fact] + public void ParseVttFile_EmptyFile_ReturnsEmptyList() + { + // Arrange + var content = string.Empty; + + // Act + VttParser vttParser = new(); + var cues = vttParser.ParseContent(content); + + // Assert + Assert.Empty(cues); + } + + [Fact] + public void ParseVttFile_InvalidFormat_ThrowsException() + { + // Arrange + var vttContent = "Invalid format"; + + // Act & Assert + VttParser vttParser = new(); + Assert.Throws(() => vttParser.ParseContent(vttContent)); + } + + [Fact] + public void ParseVttFile_InvalidTimestamps_ThrowsException() + { + // Arrange + var content = @"WEBVTT + +00:00:00.000 --> 00:00:05.000 +This is the first cue. + +00:00:10.000 --> 00:00:05.000 +This is the second cue."; + + // Act & Assert + VttParser vttParser = new(); + Assert.Throws(() => vttParser.ParseContent(content)); + } +} \ No newline at end of file From 4e2348a5e35f301e7f8828b1f7cb6db7f55ace0a Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 27 Jun 2024 18:09:23 -0700 Subject: [PATCH 61/98] Update test for SrtParser invalid timestamps The `ParseSrtFile_InvalidTimestamps_ThrowsException` test in `SrtParserTests.cs` was modified to correctly test the `SrtParser` instead of `VttParser`. Changes include removing the instantiation of `VttParser`, adding a new instantiation of `SrtParser`, and updating the assertion to use the `srtParser` instance for checking a `FormatException` when parsing invalid timestamps. --- .../Extensions/SrtParserTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs index c5b09ebfbc..09c8f7e608 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs @@ -69,7 +69,7 @@ This is the first subtitle. This is the second subtitle."; // Act & Assert - VttParser vttParser = new(); - Assert.Throws(() => vttParser.ParseContent(content)); + SrtParser srtParser = new(); + Assert.Throws(() => srtParser.ParseContent(content)); } } \ No newline at end of file From cd71c3dd9ee21194a4a698ab53347a630257bac1 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 28 Jun 2024 03:46:06 -0700 Subject: [PATCH 62/98] Refactor subtitle handling across platforms Refactored subtitle handling to improve maintainability and code quality across Android, macOS, iOS, and Windows platforms. Key changes include transitioning `SubtitleExtensions` to partial classes for shared code usage, introducing shared properties (`MediaElement`, `Cues`, `Timer`) in `SubtitleExtensions.shared.cs`, and centralizing the `LoadSubtitles` method. Enhanced `SrtParser.cs` and `SubtitleParser.cs` for better line splitting and URL validation. Removed unused directives and variables, and added new test cases for subtitle loading functionality. Minor comment adjustments and code clean-up were also performed to enhance readability and maintainability. --- .../Extensions/SrtParser.cs | 2 +- .../Extensions/SubtitleExtensions.android.cs | 89 +++++------------- .../Extensions/SubtitleExtensions.macios.cs | 62 +++---------- .../Extensions/SubtitleExtensions.shared.cs | 58 ++++++++++++ .../Extensions/SubtitleExtensions.windows.cs | 93 +++++-------------- .../Extensions/SubtitleParser.cs | 27 +++++- .../Views/MediaManager.macios.cs | 2 +- .../Views/MediaManager.windows.cs | 17 +++- .../Extensions/SubtitleExtensionsTests.cs | 83 +++++++++++++++++ .../Extensions/VttParserTests.cs | 6 -- 10 files changed, 239 insertions(+), 200 deletions(-) create mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs create mode 100644 src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index ed13e771bc..91e1faf820 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -15,7 +15,7 @@ public List ParseContent(string content) { return cues; } - + var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); SubtitleCue? currentCue = null; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 87971ff668..bde6660580 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -2,7 +2,6 @@ using Android.Views; using Android.Widget; using Com.Google.Android.Exoplayer2.UI; -using CommunityToolkit.Maui.Core; using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; using static Android.Views.ViewGroup; @@ -10,22 +9,19 @@ namespace CommunityToolkit.Maui.Extensions; -class SubtitleExtensions : Java.Lang.Object +partial class SubtitleExtensions : Java.Lang.Object { readonly IDispatcher dispatcher; readonly RelativeLayout.LayoutParams? subtitleLayout; readonly StyledPlayerView styledPlayerView; - List cues; - IMediaElement? mediaElement; + TextView? subtitleView; - System.Timers.Timer? timer; public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatcher) { this.dispatcher = dispatcher; this.styledPlayerView = styledPlayerView; - cues = []; - + Cues = []; subtitleLayout = new RelativeLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent); subtitleLayout.AddRule(LayoutRules.AlignParentBottom); subtitleLayout.AddRule(LayoutRules.CenterHorizontal); @@ -34,75 +30,32 @@ public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatc MauiMediaElement.FullScreenChanged += OnFullScreenChanged; } - public async Task LoadSubtitles(IMediaElement mediaElement) - { - ArgumentNullException.ThrowIfNull(subtitleView); - this.mediaElement = mediaElement; - - cues.Clear(); - subtitleView.Text = string.Empty; - if (string.IsNullOrEmpty(mediaElement.SubtitleUrl)) - { - return; - } - - SubtitleParser parser; - var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); - try - { - if (mediaElement.CustomSubtitleParser is not null) - { - parser = new(mediaElement.CustomSubtitleParser); - cues = parser.ParseContent(content); - return; - } - - switch (mediaElement.SubtitleUrl) - { - case var url when url.EndsWith("srt"): - parser = new(new SrtParser()); - cues = parser.ParseContent(content); - break; - case var url when url.EndsWith("vtt"): - parser = new(new VttParser()); - cues = parser.ParseContent(content); - break; - default: - System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); - return; - } - } - catch (Exception ex) - { - System.Diagnostics.Trace.TraceError(ex.Message); - return; - } - } - public void StartSubtitleDisplay() { - if(cues.Count == 0 || string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) + ArgumentNullException.ThrowIfNull(subtitleView); + ArgumentNullException.ThrowIfNull(Cues); + if(Cues.Count == 0 || string.IsNullOrEmpty(MediaElement?.SubtitleUrl)) { return; } - ArgumentNullException.ThrowIfNull(subtitleView); if(styledPlayerView.Parent is not ViewGroup parent) { System.Diagnostics.Trace.TraceError("StyledPlayerView parent is not a ViewGroup"); return; } dispatcher.Dispatch(() => parent.AddView(subtitleView)); - timer = new System.Timers.Timer(1000); - timer.Elapsed += UpdateSubtitle; - timer.Start(); + Timer = new System.Timers.Timer(1000); + Timer.Elapsed += UpdateSubtitle; + Timer.Start(); } public void StopSubtitleDisplay() { - if (timer is null || subtitleView is null) + ArgumentNullException.ThrowIfNull(Cues); + if (Timer is null || subtitleView is null) { - cues.Clear(); + Cues.Clear(); return; } if (styledPlayerView.Parent is ViewGroup parent) @@ -110,28 +63,29 @@ public void StopSubtitleDisplay() dispatcher.Dispatch(() => parent.RemoveView(subtitleView)); } subtitleView.Text = string.Empty; - timer.Stop(); - timer.Elapsed -= UpdateSubtitle; + Timer.Stop(); + Timer.Elapsed -= UpdateSubtitle; } void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { ArgumentNullException.ThrowIfNull(subtitleView); - ArgumentNullException.ThrowIfNull(mediaElement); - if (cues.Count == 0) + ArgumentNullException.ThrowIfNull(MediaElement); + ArgumentNullException.ThrowIfNull(Cues); + if (Cues.Count == 0) { return; } - var cue = cues.Find(c => c.StartTime <= mediaElement.Position && c.EndTime >= mediaElement.Position); + var cue = Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position); dispatcher.Dispatch(() => { if (cue is not null) { - Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).Android) ?? Typeface.Default; + Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).Android) ?? Typeface.Default; subtitleView.SetTypeface(typeface, TypefaceStyle.Normal); subtitleView.Text = cue.Text; - subtitleView.TextSize = (float)mediaElement.SubtitleFontSize; + subtitleView.TextSize = (float)MediaElement.SubtitleFontSize; subtitleView.Visibility = ViewStates.Visible; } else @@ -161,9 +115,10 @@ void InitializeTextBlock() void OnFullScreenChanged(object? sender, FullScreenEventArgs e) { ArgumentNullException.ThrowIfNull(subtitleView); + ArgumentNullException.ThrowIfNull(MediaElement); // If the subtitle URL is empty do nothing - if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) + if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { return; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 89f2eded52..98fc71e464 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -1,5 +1,4 @@ -using CommunityToolkit.Maui.Core; -using CommunityToolkit.Maui.Core.Views; +using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; using CoreFoundation; using CoreGraphics; @@ -9,7 +8,7 @@ namespace CommunityToolkit.Maui.Extensions; -class SubtitleExtensions : UIViewController +partial class SubtitleExtensions : UIViewController { readonly PlatformMediaElement player; readonly UIViewController playerViewController; @@ -18,8 +17,6 @@ class SubtitleExtensions : UIViewController static readonly UIColor subtitleBackgroundColor = UIColor.FromRGBA(0, 0, 0, 128); static readonly UIColor clearBackgroundColor = UIColor.FromRGBA(0, 0, 0, 0); - List cues; - IMediaElement? mediaElement; NSObject? playerObserver; UIViewController? viewController; @@ -27,7 +24,7 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi { this.playerViewController = playerViewController; this.player = player; - cues = []; + Cues = []; subtitleLabel = new UILabel { Frame = CalculateSubtitleFrame(playerViewController), @@ -46,44 +43,6 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi MediaManagerDelegate.FullScreenChanged += OnFullScreenChanged; } - public async Task LoadSubtitles(IMediaElement mediaElement) - { - this.mediaElement = mediaElement; - cues.Clear(); - - SubtitleParser parser; - var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); - try - { - if (mediaElement.CustomSubtitleParser is not null) - { - parser = new(mediaElement.CustomSubtitleParser); - cues = parser.ParseContent(content); - return; - } - - switch (mediaElement.SubtitleUrl) - { - case var url when url.EndsWith("srt"): - parser = new(new SrtParser()); - cues = parser.ParseContent(content); - break; - case var url when url.EndsWith("vtt"): - parser = new(new VttParser()); - cues = parser.ParseContent(content); - break; - default: - System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); - return; - } - } - catch (Exception ex) - { - System.Diagnostics.Trace.TraceError(ex.Message); - return; - } - } - public void StartSubtitleDisplay() { ArgumentNullException.ThrowIfNull(subtitleLabel); @@ -98,26 +57,28 @@ public void StartSubtitleDisplay() public void StopSubtitleDisplay() { + ArgumentNullException.ThrowIfNull(Cues); subtitleLabel.Text = string.Empty; - cues.Clear(); + Cues.Clear(); subtitleLabel.BackgroundColor = clearBackgroundColor; DispatchQueue.MainQueue.DispatchAsync(() => subtitleLabel.RemoveFromSuperview()); } void UpdateSubtitle(TimeSpan currentPlaybackTime) { + ArgumentNullException.ThrowIfNull(Cues); ArgumentNullException.ThrowIfNull(subtitleLabel); - ArgumentNullException.ThrowIfNull(mediaElement); - if (string.IsNullOrEmpty(mediaElement.SubtitleUrl)) + ArgumentNullException.ThrowIfNull(MediaElement); + if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { return; } - foreach (var cue in cues) + foreach (var cue in Cues) { if (currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime) { subtitleLabel.Text = cue.Text; - subtitleLabel.Font = UIFont.FromName(name: new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).MacIOS, size: (float)mediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize(16); + subtitleLabel.Font = UIFont.FromName(name: new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).MacIOS, size: (float)MediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize(16); subtitleLabel.BackgroundColor = subtitleBackgroundColor; break; } @@ -140,7 +101,8 @@ static CGRect CalculateSubtitleFrame(UIViewController uIViewController) void OnFullScreenChanged(object? sender, FullScreenEventArgs e) { - if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) + ArgumentNullException.ThrowIfNull(MediaElement); + if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { return; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs new file mode 100644 index 0000000000..e882805537 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs @@ -0,0 +1,58 @@ +using CommunityToolkit.Maui.Core; + +namespace CommunityToolkit.Maui.Extensions; +partial class SubtitleExtensions +{ + public IMediaElement? MediaElement; + public List? Cues; + public System.Timers.Timer? Timer; + public async Task LoadSubtitles(IMediaElement mediaElement) + { + Cues ??= []; + this.MediaElement = mediaElement; + if(MediaElement is null) + { + throw new ArgumentNullException(nameof(mediaElement)); + } + if (!SubtitleParser.ValidateUrlWithRegex(mediaElement.SubtitleUrl)) + { + throw new ArgumentException("Invalid Subtitle URL"); + } + if (string.IsNullOrEmpty(mediaElement.SubtitleUrl)) + { + return; + } + + SubtitleParser parser; + var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); + try + { + if (mediaElement.CustomSubtitleParser is not null) + { + parser = new(mediaElement.CustomSubtitleParser); + Cues = parser.ParseContent(content); + return; + } + + switch (mediaElement.SubtitleUrl) + { + case var url when url.EndsWith("srt"): + parser = new(new SrtParser()); + Cues = parser.ParseContent(content); + break; + case var url when url.EndsWith("vtt"): + parser = new(new VttParser()); + Cues = parser.ParseContent(content); + break; + default: + System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); + return; + } + } + catch (Exception ex) + { + System.Diagnostics.Trace.TraceError(ex.Message); + return; + } + } +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 75f94d22f7..e0e241bf0a 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -1,25 +1,21 @@ -using CommunityToolkit.Maui.Core; -using CommunityToolkit.Maui.Core.Views; +using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; using Microsoft.UI.Xaml.Media; namespace CommunityToolkit.Maui.Extensions; -class SubtitleExtensions : Grid, IDisposable +partial class SubtitleExtensions : Grid, IDisposable { bool disposedValue; bool isFullScreen = false; readonly Microsoft.UI.Xaml.Controls.TextBlock subtitleTextBlock; - List cues; - IMediaElement? mediaElement; MauiMediaElement? mauiMediaElement; - System.Timers.Timer? timer; - public SubtitleExtensions() + public SubtitleExtensions(Microsoft.UI.Xaml.Controls.MediaPlayerElement player) { - cues = []; + mauiMediaElement = player?.Parent as MauiMediaElement; MauiMediaElement.GridEventsChanged += OnFullScreenChanged; subtitleTextBlock = new() { @@ -33,65 +29,22 @@ public SubtitleExtensions() }; } - public async Task LoadSubtitles(IMediaElement mediaElement, Microsoft.UI.Xaml.Controls.MediaPlayerElement player) - { - this.mediaElement = mediaElement; - mauiMediaElement = player?.Parent as MauiMediaElement; - cues.Clear(); - subtitleTextBlock.Text = string.Empty; - subtitleTextBlock.FontSize = mediaElement.SubtitleFontSize; - subtitleTextBlock.FontFamily = new FontFamily(mediaElement.SubtitleFont); - subtitleTextBlock.FontStyle = Windows.UI.Text.FontStyle.Normal; - - SubtitleParser parser; - var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); - try - { - if (mediaElement.CustomSubtitleParser is not null) - { - parser = new(mediaElement.CustomSubtitleParser); - cues = parser.ParseContent(content); - return; - } - - switch (mediaElement.SubtitleUrl) - { - case var url when url.EndsWith("srt"): - parser = new(new SrtParser()); - cues = parser.ParseContent(content); - break; - case var url when url.EndsWith("vtt"): - parser = new(new VttParser()); - cues = parser.ParseContent(content); - break; - default: - System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); - return; - } - } - catch (Exception ex) - { - System.Diagnostics.Trace.TraceError(ex.Message); - return; - } - } - public void StartSubtitleDisplay() { - timer = new System.Timers.Timer(1000); + Timer = new System.Timers.Timer(1000); Dispatcher.Dispatch(() => mauiMediaElement?.Children.Add(subtitleTextBlock)); - timer.Elapsed += UpdateSubtitle; - timer.Start(); + Timer.Elapsed += UpdateSubtitle; + Timer.Start(); } public void StopSubtitleDisplay() { - if (timer is null) + if (Timer is null) { return; } - timer.Stop(); - timer.Elapsed -= UpdateSubtitle; + Timer.Stop(); + Timer.Elapsed -= UpdateSubtitle; if(mauiMediaElement is null) { return; @@ -101,17 +54,18 @@ public void StopSubtitleDisplay() void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { - if (string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) + ArgumentNullException.ThrowIfNull(MediaElement); + if (string.IsNullOrEmpty(MediaElement.SubtitleUrl) || Cues is null) { return; } - var cue = cues.Find(c => c.StartTime <= mediaElement.Position && c.EndTime >= mediaElement.Position); + var cue = Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position); Dispatcher.Dispatch(() => { if (cue is not null) { subtitleTextBlock.Text = cue.Text; - subtitleTextBlock.FontFamily = new FontFamily(new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).WindowsFont); + subtitleTextBlock.FontFamily = new FontFamily(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).WindowsFont); subtitleTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } else @@ -124,22 +78,23 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) void OnFullScreenChanged(object? sender, GridEventArgs e) { - if (e.Grid is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(mediaElement?.SubtitleUrl)) + ArgumentNullException.ThrowIfNull(mauiMediaElement); + ArgumentNullException.ThrowIfNull(MediaElement); + if (e.Grid is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { return; } - ArgumentNullException.ThrowIfNull(mauiMediaElement); subtitleTextBlock.Text = string.Empty; switch (isFullScreen) { case true: subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20); - subtitleTextBlock.FontSize = mediaElement.SubtitleFontSize; + subtitleTextBlock.FontSize = MediaElement.SubtitleFontSize; Dispatcher.Dispatch(() => { gridItem.Children.Remove(subtitleTextBlock); mauiMediaElement.Children.Add(subtitleTextBlock); }); isFullScreen = false; break; case false: - subtitleTextBlock.FontSize = mediaElement.SubtitleFontSize + 8.0; + subtitleTextBlock.FontSize = MediaElement.SubtitleFontSize + 8.0; subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 300); Dispatcher.Dispatch(() => { mauiMediaElement.Children.Remove(subtitleTextBlock); gridItem.Children.Add(subtitleTextBlock); }); isFullScreen = true; @@ -151,17 +106,17 @@ protected virtual void Dispose(bool disposing) { if (!disposedValue) { - if (timer is not null) + if (Timer is not null) { - timer.Stop(); - timer.Elapsed -= UpdateSubtitle; + Timer.Stop(); + Timer.Elapsed -= UpdateSubtitle; } if (disposing) { - timer?.Dispose(); + Timer?.Dispose(); } - timer = null; + Timer = null; disposedValue = true; } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs index 9946ce909b..1dbbe888c8 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs @@ -1,9 +1,11 @@ -namespace CommunityToolkit.Maui.Core; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Maui.Core; /// /// A class that Represents a parser. /// -public class SubtitleParser +public partial class SubtitleParser { static readonly HttpClient httpClient = new(); @@ -48,4 +50,25 @@ internal static async Task Content(string subtitleUrl) return string.Empty; } } + + /// + /// + /// + /// + /// + internal static bool ValidateUrlWithRegex(string url) + { + var urlRegex = ValidateUrl(); + + urlRegex.Matches(url); + if(!urlRegex.IsMatch(url)) + { + throw new ArgumentException("Invalid Subtitle URL"); + } + return true; + } + + [GeneratedRegex(@"^(https?|ftps?):\/\/(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?::(?:0|[1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5]))?(?:\/(?:[-a-zA-Z0-9@%_\+.~#?&=]+\/?)*)?$", RegexOptions.IgnoreCase, "en-CA")] + private static partial Regex ValidateUrl(); } + diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index 6d5e61b25b..929fe20ad5 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -436,7 +436,7 @@ protected virtual void Dispose(bool disposing) { UIApplication.SharedApplication.EndReceivingRemoteControlEvents(); }); - // disable the idle timer so screen turns off when media is not playing + // disable the idle Timer so screen turns off when media is not playing UIApplication.SharedApplication.IdleTimerDisabled = false; var audioSession = AVAudioSession.SharedInstance(); audioSession.SetActive(false); diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs index 8e1ff9edab..4484eec268 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs @@ -51,8 +51,7 @@ public PlatformMediaElement CreatePlatformView() MediaElement.MediaOpened += OnMediaElementMediaOpened; Player.SetMediaPlayer(MediaElement); - subtitleExtensions = []; - + Player.MediaPlayer.PlaybackSession.PlaybackRateChanged += OnPlaybackSessionPlaybackRateChanged; Player.MediaPlayer.PlaybackSession.PlaybackStateChanged += OnPlaybackSessionPlaybackStateChanged; Player.MediaPlayer.PlaybackSession.SeekCompleted += OnPlaybackSessionSeekCompleted; @@ -299,12 +298,22 @@ protected virtual async partial void PlatformUpdateSource() async Task LoadSubtitles(CancellationToken cancellationToken = default) { - if (subtitleExtensions is null || string.IsNullOrEmpty(MediaElement.SubtitleUrl) || Player is null) + if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { System.Diagnostics.Trace.TraceError("SubtitleExtensions is null or SubtitleUrl is null or Player is null"); return; } - await subtitleExtensions.LoadSubtitles(MediaElement, Player).WaitAsync(cancellationToken).ConfigureAwait(false); + + if (Player is null) + { + + System.Diagnostics.Trace.TraceError("Player is null"); + return; + } + + subtitleExtensions = new(Player); + + await subtitleExtensions.LoadSubtitles(MediaElement).WaitAsync(cancellationToken).ConfigureAwait(false); subtitleExtensions.StartSubtitleDisplay(); } protected virtual partial void PlatformUpdateShouldLoopPlayback() diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs new file mode 100644 index 0000000000..4784a39009 --- /dev/null +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs @@ -0,0 +1,83 @@ +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Extensions; +using CommunityToolkit.Maui.Views; +using Xunit; + +namespace CommunityToolkit.Maui.UnitTests.Extensions; + +public class SubtitleExtensionTests : BaseTest +{ + [Fact] + public void LoadSubtitles_Validate() + { + // Arrange + IMediaElement mediaElement = new MediaElement(); + + // Act + SubtitleExtensions subtitleExtensions = new(); + + // Assert + Assert.NotNull(subtitleExtensions.LoadSubtitles(mediaElement)); + } + + [Fact] + public async Task LoadSubtitles_InvalidMediaElement_ThrowsArgumentExceptionAsync() + { + // Arrange + IMediaElement mediaElement = null!; + + // Act + SubtitleExtensions subtitleExtensions = new(); + + // Assert + await Assert.ThrowsAsync(async () => await subtitleExtensions.LoadSubtitles(mediaElement)); + } + + [Fact] + public void SetSubtitleExtensions_ValidSubtitleExtension() + { + // Arrange + IMediaElement mediaElement = new MediaElement(); + SubtitleExtensions subtitleExtensions = new(); + + // Act & Assert + Assert.NotNull(subtitleExtensions); + } + + [Fact] + public void SetSubtitleSource_ValidString_SetsSubtitleSource() + { + // Arrange + MediaElement mediaElement = new(); + var validUri = "https://example.com/subtitles.vtt"; + mediaElement.SubtitleUrl = validUri; + + // Act & Assert + Assert.Equal(validUri, mediaElement.SubtitleUrl); + } + + [Fact] + public void SetSubtitleSource_EmtpyString_SetsSubtitleSource() + { + // Arrange + MediaElement mediaElement = new(); + var emptyUri = string.Empty; + mediaElement.SubtitleUrl = emptyUri; + + // Act & Assert + Assert.Equal(emptyUri, mediaElement.SubtitleUrl); + } + + [Fact] + public async Task SetSubtitleSource_InvalidUri_ThrowsArgumentExceptionAsync() + { + // Arrange + var mediaElement = new MediaElement(); + var invalidSubtitleUrl = "invalid://uri"; + mediaElement.SubtitleUrl = invalidSubtitleUrl; + SubtitleExtensions subtitleExtensions = new(); + + // Act & Assert + await Assert.ThrowsAsync(async () => await subtitleExtensions.LoadSubtitles(mediaElement)); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs index b72b5f0a68..704b12c72b 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; using CommunityToolkit.Maui.Core; using Xunit; From d6fa737e8866bc4468c465126e00b6aac070cd6c Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 28 Jun 2024 04:37:58 -0700 Subject: [PATCH 63/98] Add FontExtensions unit tests Added a comprehensive suite of unit tests in `FontExtensionsTests.cs` for the `CommunityToolkit.Maui` project, focusing on the `FontFamily` property across different platforms (Android, Windows, Mac/iOS). These tests validate correct font file names, URIs, and names for valid inputs and ensure graceful handling of invalid inputs by returning an empty string. Dependencies on `CommunityToolkit.Maui.Core`, `CommunityToolkit.Maui.Views`, and `Xunit` were introduced to support these tests. --- .../Extensions/FontExtensionsTests.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs new file mode 100644 index 0000000000..6d4cd430bf --- /dev/null +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs @@ -0,0 +1,97 @@ +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Views; +using Xunit; + +namespace CommunityToolkit.Maui.UnitTests.Extensions; +public class FontExtensionsTests : BaseTest +{ + [Fact] + public void FontFamily_Android_ReturnsExpectedResult() + { + // Arrange + MediaElement mediaElement = new() + { + SubtitleFont = "Font.ttf#Font" + }; + + // Act + var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).Android; + + // Assert + Assert.Equal("Font.ttf", result); + } + + [Fact] + public void FontFamily_WindowsFont_ReturnsExpectedResult() + { + // Arrange + MediaElement mediaElement = new() + { + SubtitleFont = "Font.ttf#Font" + }; + + // Act + var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).WindowsFont; + + // Assert + Assert.Equal("ms-appx:///Font.ttf#Font", result); + } + + [Fact] + public void FontFamily_MacIOS_ReturnsExpectedResult() + { + // Arrange + MediaElement mediaElement = new() + { + SubtitleFont = "Font.ttf#Font" + }; + + // Act + var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).MacIOS; + + // Assert + Assert.Equal("Font", result); + } + + [Fact] + public void FontFamily_Android_InvalidInput_ReturnsEmptyString() + { + // Arrange + MediaElement mediaElement = new() + { + SubtitleFont = "Invalid input" + }; + var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).Android; + + // Act & Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void FontFamily_WindowsFont_InvalidInput_ReturnsEmptyString() + { + // Arrange + MediaElement mediaElement = new() + { + SubtitleFont = "Invalid input" + }; + var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).WindowsFont; + + // Act & Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void FontFamily_MacIOS_InvalidInput_ReturnsEmptyString() + { + // Arrange + MediaElement mediaElement = new() + { + SubtitleFont = "Invalid input" + }; + var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).MacIOS; + + // Act & Assert + Assert.Equal(string.Empty, result); + } +} From 24b9ad77c442b1cf49eac89625328369fba70a03 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 28 Jun 2024 04:51:25 -0700 Subject: [PATCH 64/98] Refine font family test methods - Removed unused `using` directive for `CommunityToolkit.Maui.Core` and corrected the `using` directive for `CommunityToolkit.Maui.Views` to prevent duplication. - Renamed test methods for TTF font family verification on Android, Windows, and MacOS/iOS to include "TTF" in their names for clarity. - Added new test methods for verifying OTF font family results across Android, Windows, and MacOS/iOS, focusing on `.otf` file extension handling. - Updated test methods for invalid TTF font input to specify "TTF" in method names, enhancing specificity and clarity. --- .../Extensions/FontExtensionsTests.cs | 69 ++++++++++++++++--- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs index 6d4cd430bf..b59f819a16 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs @@ -1,12 +1,11 @@ -using CommunityToolkit.Maui.Core; -using CommunityToolkit.Maui.Views; +using CommunityToolkit.Maui.Views; using Xunit; namespace CommunityToolkit.Maui.UnitTests.Extensions; public class FontExtensionsTests : BaseTest { [Fact] - public void FontFamily_Android_ReturnsExpectedResult() + public void FontFamily_TTF_Android_ReturnsExpectedResult() { // Arrange MediaElement mediaElement = new() @@ -22,7 +21,7 @@ public void FontFamily_Android_ReturnsExpectedResult() } [Fact] - public void FontFamily_WindowsFont_ReturnsExpectedResult() + public void FontFamily_TTF_WindowsFont_ReturnsExpectedResult() { // Arrange MediaElement mediaElement = new() @@ -38,7 +37,7 @@ public void FontFamily_WindowsFont_ReturnsExpectedResult() } [Fact] - public void FontFamily_MacIOS_ReturnsExpectedResult() + public void FontFamily_TTF_MacIOS_ReturnsExpectedResult() { // Arrange MediaElement mediaElement = new() @@ -54,7 +53,55 @@ public void FontFamily_MacIOS_ReturnsExpectedResult() } [Fact] - public void FontFamily_Android_InvalidInput_ReturnsEmptyString() + public void FontFamily_OTF_Android_ReturnsExpectedResult() + { + // Arrange + MediaElement mediaElement = new() + { + SubtitleFont = "Font.otf#Font" + }; + + // Act + var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).Android; + + // Assert + Assert.Equal("Font.otf", result); + } + + [Fact] + public void FontFamily_OTF_WindowsFont_ReturnsExpectedResult() + { + // Arrange + MediaElement mediaElement = new() + { + SubtitleFont = "Font.otf#Font" + }; + + // Act + var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).WindowsFont; + + // Assert + Assert.Equal("ms-appx:///Font.otf#Font", result); + } + + [Fact] + public void FontFamily_OTF_MacIOS_ReturnsExpectedResult() + { + // Arrange + MediaElement mediaElement = new() + { + SubtitleFont = "Font.otf#Font" + }; + + // Act + var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).MacIOS; + + // Assert + Assert.Equal("Font", result); + } + + [Fact] + public void FontFamily_TTF_Android_InvalidInput_ReturnsEmptyString() { // Arrange MediaElement mediaElement = new() @@ -62,13 +109,13 @@ public void FontFamily_Android_InvalidInput_ReturnsEmptyString() SubtitleFont = "Invalid input" }; var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).Android; - + // Act & Assert Assert.Equal(string.Empty, result); } [Fact] - public void FontFamily_WindowsFont_InvalidInput_ReturnsEmptyString() + public void FontFamily_TTF_WindowsFont_InvalidInput_ReturnsEmptyString() { // Arrange MediaElement mediaElement = new() @@ -76,13 +123,13 @@ public void FontFamily_WindowsFont_InvalidInput_ReturnsEmptyString() SubtitleFont = "Invalid input" }; var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).WindowsFont; - + // Act & Assert Assert.Equal(string.Empty, result); } [Fact] - public void FontFamily_MacIOS_InvalidInput_ReturnsEmptyString() + public void FontFamily_TTF_MacIOS_InvalidInput_ReturnsEmptyString() { // Arrange MediaElement mediaElement = new() @@ -90,7 +137,7 @@ public void FontFamily_MacIOS_InvalidInput_ReturnsEmptyString() SubtitleFont = "Invalid input" }; var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).MacIOS; - + // Act & Assert Assert.Equal(string.Empty, result); } From 6144f4b8b596897fc4fdaf59b63a197240ad76df Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 28 Jun 2024 04:54:13 -0700 Subject: [PATCH 65/98] Update comments --- .../Extensions/FontExtensionsTests.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs index b59f819a16..d6cf40144c 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs @@ -108,9 +108,11 @@ public void FontFamily_TTF_Android_InvalidInput_ReturnsEmptyString() { SubtitleFont = "Invalid input" }; + + // Act var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).Android; - // Act & Assert + // Assert Assert.Equal(string.Empty, result); } @@ -122,9 +124,11 @@ public void FontFamily_TTF_WindowsFont_InvalidInput_ReturnsEmptyString() { SubtitleFont = "Invalid input" }; + + // Act var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).WindowsFont; - // Act & Assert + // Assert Assert.Equal(string.Empty, result); } @@ -136,9 +140,11 @@ public void FontFamily_TTF_MacIOS_InvalidInput_ReturnsEmptyString() { SubtitleFont = "Invalid input" }; + + // Act var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).MacIOS; - // Act & Assert + // Assert Assert.Equal(string.Empty, result); } } From 3eabcfb2a4a6dda4b0c325c08ad35f9440b0ee35 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 28 Jun 2024 09:45:58 -0700 Subject: [PATCH 66/98] Refactor and optimize subtitle handling This commit introduces several key changes aimed at refining the subtitle display functionality across different platforms. Notably, the `StopSubtitleDisplay` method in both `SubtitleExtensions.android.cs` and `SubtitleExtensions.windows.cs` files has been refactored for improved efficiency and readability. In the Android version, the method now clears `Cues` unconditionally at the start, adds checks for null `Timer`, and ensures `subtitleView.Text` is only cleared if `subtitleView` is not null. Redundant code has been removed, streamlining the method's execution. In the Windows version, a null-conditional operator is now used for clearing `Cues`, and `subtitleTextBlock.Text` is explicitly set to an empty string when stopping the subtitle display, enhancing safety against null reference exceptions. Additionally, `SubtitleExtensions.windows.cs` sees the introduction of a `using` directive for `Microsoft.Maui.Controls.Grid` and changes the `mauiMediaElement` field to read-only, signaling a design shift towards immutability. The `LoadSubtitles` method in both `MediaManager.android.cs` and `MediaManager.windows.cs` has been updated to optimize subtitle loading. The Android version eliminates a redundant stop-start cycle in subtitle display, potentially improving performance. The Windows version now conditionally instantiates `subtitleExtensions`, avoiding unnecessary object creation and preserving state. These changes collectively aim to make subtitle handling more efficient, readable, and robust across platforms. --- .../Extensions/SubtitleExtensions.android.cs | 16 +++++++++++----- .../Extensions/SubtitleExtensions.windows.cs | 6 ++++-- .../Views/MediaManager.android.cs | 1 - .../Views/MediaManager.windows.cs | 3 +-- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index bde6660580..b22cab4b5b 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -53,18 +53,24 @@ public void StartSubtitleDisplay() public void StopSubtitleDisplay() { ArgumentNullException.ThrowIfNull(Cues); - if (Timer is null || subtitleView is null) + Cues.Clear(); + + if(Timer is not null) + { + Timer.Stop(); + Timer.Elapsed -= UpdateSubtitle; + } + + if (subtitleView is null) { - Cues.Clear(); return; } + subtitleView.Text = string.Empty; + if (styledPlayerView.Parent is ViewGroup parent) { dispatcher.Dispatch(() => parent.RemoveView(subtitleView)); } - subtitleView.Text = string.Empty; - Timer.Stop(); - Timer.Elapsed -= UpdateSubtitle; } void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index e0e241bf0a..0ac0e41434 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -1,6 +1,7 @@ using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; using Microsoft.UI.Xaml.Media; +using Grid = Microsoft.Maui.Controls.Grid; namespace CommunityToolkit.Maui.Extensions; @@ -10,8 +11,7 @@ partial class SubtitleExtensions : Grid, IDisposable bool isFullScreen = false; readonly Microsoft.UI.Xaml.Controls.TextBlock subtitleTextBlock; - - MauiMediaElement? mauiMediaElement; + readonly MauiMediaElement? mauiMediaElement; public SubtitleExtensions(Microsoft.UI.Xaml.Controls.MediaPlayerElement player) { @@ -39,6 +39,8 @@ public void StartSubtitleDisplay() public void StopSubtitleDisplay() { + Cues?.Clear(); + subtitleTextBlock.Text = string.Empty; if (Timer is null) { return; diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index 3ed841b2cf..9e84db77e8 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -463,7 +463,6 @@ async Task LoadSubtitles(CancellationToken cancellationToken = default) { return; } - subtitleExtensions.StopSubtitleDisplay(); await subtitleExtensions.LoadSubtitles(MediaElement).WaitAsync(cancellationToken).ConfigureAwait(false); subtitleExtensions.StartSubtitleDisplay(); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs index 4484eec268..8d3267bf65 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs @@ -311,8 +311,7 @@ async Task LoadSubtitles(CancellationToken cancellationToken = default) return; } - subtitleExtensions = new(Player); - + subtitleExtensions ??= new(Player); await subtitleExtensions.LoadSubtitles(MediaElement).WaitAsync(cancellationToken).ConfigureAwait(false); subtitleExtensions.StartSubtitleDisplay(); } From 22b5fdf43142aa47b80a207a759a3b2d8a09f97f Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 28 Jun 2024 10:13:24 -0700 Subject: [PATCH 67/98] Remove many empty lines --- .../Pages/Views/MediaElement/MediaElementPage.xaml.cs | 1 - .../Extensions/FontExtensions.cs | 4 ---- .../Extensions/SrtParser.cs | 2 -- .../Extensions/SubtitleExtensions.android.cs | 9 --------- .../Extensions/SubtitleExtensions.macios.cs | 3 --- .../Extensions/SubtitleExtensions.shared.cs | 3 +-- .../Extensions/SubtitleExtensions.windows.cs | 2 -- .../Extensions/SubtitleParser.cs | 1 - .../Extensions/VttParser.cs | 4 ---- .../Views/MediaManager.android.cs | 2 +- .../Views/MediaManager.windows.cs | 7 ++----- .../Extensions/SubtitleExtensionsTests.cs | 1 - 12 files changed, 4 insertions(+), 35 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 e3e7271079..e7b2c622e1 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -225,7 +225,6 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.SubtitleFontSize = 16; } - MediaElement.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); return; diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs index 35a3fb0bd4..2c039b1dae 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs @@ -70,7 +70,6 @@ public readonly string MacIOS } } - static class FontHelper { /// @@ -81,14 +80,11 @@ static class FontHelper { var assembly = typeof(FontHelper).Assembly; var exportedFonts = new List<(string FontFileName, string Alias)>(); - var customAttributes = assembly.GetCustomAttributes(); - foreach (var attribute in customAttributes) { exportedFonts.Add((attribute.FontFileName, attribute.Alias)); } - return exportedFonts; } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index 91e1faf820..e7794133f8 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -17,7 +17,6 @@ public List ParseContent(string content) } var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); - SubtitleCue? currentCue = null; var textBuffer = new StringBuilder(); @@ -37,7 +36,6 @@ public List ParseContent(string content) cues.Add(currentCue); textBuffer.Clear(); } - currentCue = CreateCue(match); } else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index b22cab4b5b..516fe7cccd 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -14,7 +14,6 @@ partial class SubtitleExtensions : Java.Lang.Object readonly IDispatcher dispatcher; readonly RelativeLayout.LayoutParams? subtitleLayout; readonly StyledPlayerView styledPlayerView; - TextView? subtitleView; public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatcher) @@ -25,7 +24,6 @@ public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatc subtitleLayout = new RelativeLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent); subtitleLayout.AddRule(LayoutRules.AlignParentBottom); subtitleLayout.AddRule(LayoutRules.CenterHorizontal); - InitializeTextBlock(); MauiMediaElement.FullScreenChanged += OnFullScreenChanged; } @@ -38,7 +36,6 @@ public void StartSubtitleDisplay() { return; } - if(styledPlayerView.Parent is not ViewGroup parent) { System.Diagnostics.Trace.TraceError("StyledPlayerView parent is not a ViewGroup"); @@ -54,19 +51,16 @@ public void StopSubtitleDisplay() { ArgumentNullException.ThrowIfNull(Cues); Cues.Clear(); - if(Timer is not null) { Timer.Stop(); Timer.Elapsed -= UpdateSubtitle; } - if (subtitleView is null) { return; } subtitleView.Text = string.Empty; - if (styledPlayerView.Parent is ViewGroup parent) { dispatcher.Dispatch(() => parent.RemoveView(subtitleView)); @@ -82,7 +76,6 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { return; } - var cue = Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position); dispatcher.Dispatch(() => { @@ -128,12 +121,10 @@ void OnFullScreenChanged(object? sender, FullScreenEventArgs e) { return; } - if (CurrentPlatformActivity.CurrentViewGroup.Parent is not ViewGroup parent) { return; } - switch (e.isFullScreen) { case true: diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 98fc71e464..4c16a4e679 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -13,10 +13,8 @@ partial class SubtitleExtensions : UIViewController readonly PlatformMediaElement player; readonly UIViewController playerViewController; readonly UILabel subtitleLabel; - static readonly UIColor subtitleBackgroundColor = UIColor.FromRGBA(0, 0, 0, 128); static readonly UIColor clearBackgroundColor = UIColor.FromRGBA(0, 0, 0, 0); - NSObject? playerObserver; UIViewController? viewController; @@ -39,7 +37,6 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi | UIViewAutoresizing.FlexibleHeight | UIViewAutoresizing.FlexibleBottomMargin }; - MediaManagerDelegate.FullScreenChanged += OnFullScreenChanged; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs index e882805537..684af71ed2 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs @@ -22,9 +22,9 @@ public async Task LoadSubtitles(IMediaElement mediaElement) { return; } - SubtitleParser parser; var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); + try { if (mediaElement.CustomSubtitleParser is not null) @@ -33,7 +33,6 @@ public async Task LoadSubtitles(IMediaElement mediaElement) Cues = parser.ParseContent(content); return; } - switch (mediaElement.SubtitleUrl) { case var url when url.EndsWith("srt"): diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 0ac0e41434..bd0ef19823 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -9,7 +9,6 @@ partial class SubtitleExtensions : Grid, IDisposable { bool disposedValue; bool isFullScreen = false; - readonly Microsoft.UI.Xaml.Controls.TextBlock subtitleTextBlock; readonly MauiMediaElement? mauiMediaElement; @@ -113,7 +112,6 @@ protected virtual void Dispose(bool disposing) Timer.Stop(); Timer.Elapsed -= UpdateSubtitle; } - if (disposing) { Timer?.Dispose(); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs index 1dbbe888c8..95a939a33c 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs @@ -59,7 +59,6 @@ internal static async Task Content(string subtitleUrl) internal static bool ValidateUrlWithRegex(string url) { var urlRegex = ValidateUrl(); - urlRegex.Matches(url); if(!urlRegex.IsMatch(url)) { diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index 90556cce08..c135ff6578 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -15,9 +15,7 @@ public List ParseContent(string content) { return cues; } - var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); - SubtitleCue? currentCue = null; var textBuffer = new StringBuilder(); @@ -32,7 +30,6 @@ public List ParseContent(string content) cues.Add(currentCue); textBuffer.Clear(); } - currentCue = CreateCue(match); } else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) @@ -46,7 +43,6 @@ public List ParseContent(string content) currentCue.Text = string.Join(" ", textBuffer).TrimEnd('\r', '\n'); cues.Add(currentCue); } - if(cues.Count == 0) { throw new FormatException("Invalid VTT format"); diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index 9e84db77e8..5776112ec6 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -394,8 +394,8 @@ protected virtual partial void PlatformUpdateSource() { return; } - subtitleExtensions?.StopSubtitleDisplay(); + subtitleExtensions?.StopSubtitleDisplay(); StopService(); if (mediaSession is not null) { diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs index 8d3267bf65..af65e12e65 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs @@ -16,10 +16,10 @@ partial class MediaManager : IDisposable { Metadata? metadata; SystemMediaTransportControls? systemMediaControls; - SubtitleExtensions? subtitleExtensions; readonly CancellationTokenSource subTitles = new(); Task? startSubtitles; + // States that allow changing position readonly IReadOnlyList allowUpdatePositionStates = [ @@ -303,14 +303,11 @@ async Task LoadSubtitles(CancellationToken cancellationToken = default) System.Diagnostics.Trace.TraceError("SubtitleExtensions is null or SubtitleUrl is null or Player is null"); return; } - if (Player is null) { - - System.Diagnostics.Trace.TraceError("Player is null"); + System.Diagnostics.Trace.TraceError("Player is null"); return; } - subtitleExtensions ??= new(Player); await subtitleExtensions.LoadSubtitles(MediaElement).WaitAsync(cancellationToken).ConfigureAwait(false); subtitleExtensions.StartSubtitleDisplay(); diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs index 4784a39009..eb1ef6c7a1 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs @@ -37,7 +37,6 @@ public async Task LoadSubtitles_InvalidMediaElement_ThrowsArgumentExceptionAsync public void SetSubtitleExtensions_ValidSubtitleExtension() { // Arrange - IMediaElement mediaElement = new MediaElement(); SubtitleExtensions subtitleExtensions = new(); // Act & Assert From 19875bbfcd963a758b5ae871dcf77f185de293d3 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 2 Jul 2024 08:08:08 -0700 Subject: [PATCH 68/98] Refactor test for NullReferenceException Refactored `LoadSubtitles_InvalidSubtitleExtensions_ThrowsNullReferenceExceptionAsync` in `SubtitleExtensionsTests.cs` to focus on invalid `SubtitleExtensions` rather than an invalid `IMediaElement`. Changed the setup to instantiate a valid `MediaElement` and explicitly set `subtitleExtensions` to `null` to simulate an uninitialized state. Updated the assertion to expect a `NullReferenceException`, aligning with the new test focus. --- .../Extensions/SubtitleExtensionsTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs index eb1ef6c7a1..29267493c5 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs @@ -21,16 +21,16 @@ public void LoadSubtitles_Validate() } [Fact] - public async Task LoadSubtitles_InvalidMediaElement_ThrowsArgumentExceptionAsync() + public async Task LoadSubtitles_InvalidSubtitleExtensions_ThrowsNullReferenceExceptionAsync() { // Arrange - IMediaElement mediaElement = null!; + IMediaElement mediaElement = new MediaElement(); // Act - SubtitleExtensions subtitleExtensions = new(); + SubtitleExtensions subtitleExtensions = null!; // Assert - await Assert.ThrowsAsync(async () => await subtitleExtensions.LoadSubtitles(mediaElement)); + await Assert.ThrowsAsync(async () => await subtitleExtensions.LoadSubtitles(mediaElement)); } [Fact] From b67a00ec75d9420dc9e78984ee15e60764edcb48 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 4 Jul 2024 11:52:44 -0700 Subject: [PATCH 69/98] Fix merge error --- .../Views/MediaManager.macios.cs | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index 48a883ff50..5bf87eae2f 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -651,25 +651,6 @@ void RateChanged(object? sender, NSNotificationEventArgs args) } } } -} -sealed class MediaManagerDelegate : AVPlayerViewControllerDelegate -{ - /// - /// Handles the event when the windows change. - /// - public static event EventHandler? FullScreenChanged; - public override void WillBeginFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) - { - OnFulScreenChanged(new FullScreenEventArgs(true)); - } - public override void WillEndFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) - { - OnFulScreenChanged(new FullScreenEventArgs(false)); - } - /// - /// A method that raises the FullScreenChanged event. - /// - static void OnFulScreenChanged(FullScreenEventArgs e) => FullScreenChanged?.Invoke(null, e); (int Width, int Height) GetVideoDimensions(AVPlayerItem avPlayerItem) { @@ -684,7 +665,7 @@ public override void WillEndFullScreenPresentation(AVPlayerViewController player // Get the natural size of the video var size = videoTrack.NaturalSize; var preferredTransform = videoTrack.PreferredTransform; - + // Apply the preferred transform to get the correct dimensions var transformedSize = CGAffineTransform.CGRectApplyAffineTransform(new CGRect(CGPoint.Empty, size), preferredTransform); var width = Math.Abs(transformedSize.Width); @@ -704,4 +685,23 @@ public override void WillEndFullScreenPresentation(AVPlayerViewController player return (0, 0); } } +} +sealed class MediaManagerDelegate : AVPlayerViewControllerDelegate +{ + /// + /// Handles the event when the windows change. + /// + public static event EventHandler? FullScreenChanged; + public override void WillBeginFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) + { + OnFulScreenChanged(new FullScreenEventArgs(true)); + } + public override void WillEndFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) + { + OnFulScreenChanged(new FullScreenEventArgs(false)); + } + /// + /// A method that raises the FullScreenChanged event. + /// + static void OnFulScreenChanged(FullScreenEventArgs e) => FullScreenChanged?.Invoke(null, e); } \ No newline at end of file From 91f1dbbb693606d54a28528b6f7a7a3f5fc5c1be Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 19 Jul 2024 07:27:29 -0700 Subject: [PATCH 70/98] Refactor full-screen event handling This commit significantly refactors the handling of full-screen state changes across Android, iOS/macOS, and Windows within a media playback context. A new class, `FullScreenStateChangedEventArgs`, and an enum, `MediaElementScreenState`, have been introduced to standardize full-screen state change notifications. Platform-specific full-screen events and their handlers have been removed in favor of a centralized `FullScreenEvents` record in `MediaManager.shared.cs`, which includes a static event `WindowsChanged` and a method `OnWindowsChanged` for raising the event. This change aims to improve cross-platform consistency and maintainability of media playback features with full-screen capabilities. Additionally, code cleanup and refactoring were performed to enhance readability and update documentation. --- .../Extensions/SubtitleExtensions.android.cs | 6 ++-- .../Extensions/SubtitleExtensions.macios.cs | 8 +++--- .../Extensions/SubtitleExtensions.shared.cs | 3 +- .../Extensions/SubtitleExtensions.windows.cs | 8 ++++-- .../Primitives/FullScreenEventArgs.cs | 20 ------------- .../FullScreenStateChangedEventArgs.cs | 28 +++++++++++++++++++ .../Primitives/GridEventArgs.windows.cs | 27 ------------------ .../Primitives/MediaElementScreenState.cs | 17 +++++++++++ .../Views/MauiMediaElement.android.cs | 20 ++++--------- .../Views/MauiMediaElement.windows.cs | 18 +++--------- .../Views/MediaManager.macios.cs | 12 ++------ .../Views/MediaManager.shared.cs | 23 +++++++++++++++ 12 files changed, 93 insertions(+), 97 deletions(-) delete mode 100644 src/CommunityToolkit.Maui.MediaElement/Primitives/FullScreenEventArgs.cs create mode 100644 src/CommunityToolkit.Maui.MediaElement/Primitives/FullScreenStateChangedEventArgs.cs delete mode 100644 src/CommunityToolkit.Maui.MediaElement/Primitives/GridEventArgs.windows.cs create mode 100644 src/CommunityToolkit.Maui.MediaElement/Primitives/MediaElementScreenState.cs diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 516fe7cccd..f5346353af 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -25,7 +25,7 @@ public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatc subtitleLayout.AddRule(LayoutRules.AlignParentBottom); subtitleLayout.AddRule(LayoutRules.CenterHorizontal); InitializeTextBlock(); - MauiMediaElement.FullScreenChanged += OnFullScreenChanged; + MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; } public void StartSubtitleDisplay() @@ -111,7 +111,7 @@ void InitializeTextBlock() subtitleView.SetPaddingRelative(10, 10, 10, 20); } - void OnFullScreenChanged(object? sender, FullScreenEventArgs e) + void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) { ArgumentNullException.ThrowIfNull(subtitleView); ArgumentNullException.ThrowIfNull(MediaElement); @@ -125,7 +125,7 @@ void OnFullScreenChanged(object? sender, FullScreenEventArgs e) { return; } - switch (e.isFullScreen) + switch (e.NewState == MediaElementScreenState.FullScreen) { case true: CurrentPlatformActivity.CurrentViewGroup.RemoveView(subtitleView); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 4c16a4e679..84168b2523 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -37,7 +37,7 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi | UIViewAutoresizing.FlexibleHeight | UIViewAutoresizing.FlexibleBottomMargin }; - MediaManagerDelegate.FullScreenChanged += OnFullScreenChanged; + MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; } public void StartSubtitleDisplay() @@ -96,7 +96,7 @@ static CGRect CalculateSubtitleFrame(UIViewController uIViewController) return new CGRect(0, uIViewController.View.Bounds.Height - 60, uIViewController.View.Bounds.Width, 50); } - void OnFullScreenChanged(object? sender, FullScreenEventArgs e) + void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) { ArgumentNullException.ThrowIfNull(MediaElement); if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) @@ -104,7 +104,7 @@ void OnFullScreenChanged(object? sender, FullScreenEventArgs e) return; } subtitleLabel.Text = string.Empty; - switch (e.isFullScreen) + switch (e.NewState == MediaElementScreenState.FullScreen) { case true: viewController = WindowStateManager.Default.GetCurrentUIViewController() ?? throw new ArgumentException(nameof(viewController)); @@ -122,7 +122,7 @@ void OnFullScreenChanged(object? sender, FullScreenEventArgs e) ~SubtitleExtensions() { - MediaManagerDelegate.FullScreenChanged -= OnFullScreenChanged; + MediaManager.FullScreenEvents.WindowsChanged -= OnFullScreenChanged; if(playerObserver is not null && player is not null) { player.RemoveTimeObserver(playerObserver); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs index 684af71ed2..b0207ecbdc 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs @@ -1,4 +1,5 @@ -using CommunityToolkit.Maui.Core; +// NOTE: PR shares code with #2041 https://github.com/CommunityToolkit/Maui/pull/2041 +using CommunityToolkit.Maui.Core; namespace CommunityToolkit.Maui.Extensions; partial class SubtitleExtensions diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index bd0ef19823..95dbca1d97 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -15,7 +15,7 @@ partial class SubtitleExtensions : Grid, IDisposable public SubtitleExtensions(Microsoft.UI.Xaml.Controls.MediaPlayerElement player) { mauiMediaElement = player?.Parent as MauiMediaElement; - MauiMediaElement.GridEventsChanged += OnFullScreenChanged; + MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; subtitleTextBlock = new() { Text = string.Empty, @@ -77,11 +77,13 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) }); } - void OnFullScreenChanged(object? sender, GridEventArgs e) + void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) { + var gridItem = MediaManager.FullScreenEvents.grid; ArgumentNullException.ThrowIfNull(mauiMediaElement); ArgumentNullException.ThrowIfNull(MediaElement); - if (e.Grid is not Microsoft.UI.Xaml.Controls.Grid gridItem || string.IsNullOrEmpty(MediaElement.SubtitleUrl)) + ArgumentNullException.ThrowIfNull(gridItem); + if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { return; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Primitives/FullScreenEventArgs.cs b/src/CommunityToolkit.Maui.MediaElement/Primitives/FullScreenEventArgs.cs deleted file mode 100644 index e687a8d21b..0000000000 --- a/src/CommunityToolkit.Maui.MediaElement/Primitives/FullScreenEventArgs.cs +++ /dev/null @@ -1,20 +0,0 @@ - -namespace CommunityToolkit.Maui.Primitives; -/// -/// -/// -public class FullScreenEventArgs : EventArgs -{ - /// - /// - /// - public bool isFullScreen { get; } - /// - /// - /// - /// - public FullScreenEventArgs(bool status) - { - this.isFullScreen = status; - } -} diff --git a/src/CommunityToolkit.Maui.MediaElement/Primitives/FullScreenStateChangedEventArgs.cs b/src/CommunityToolkit.Maui.MediaElement/Primitives/FullScreenStateChangedEventArgs.cs new file mode 100644 index 0000000000..2b7fc5b76a --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Primitives/FullScreenStateChangedEventArgs.cs @@ -0,0 +1,28 @@ +namespace CommunityToolkit.Maui.Primitives; + +/// +/// Event data for when the full screen state of the media element has changed. +/// +public sealed class FullScreenStateChangedEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// + public FullScreenStateChangedEventArgs(MediaElementScreenState previousState, MediaElementScreenState newState) + { + PreviousState = previousState; + NewState = newState; + } + + /// + /// Gets the previous state that the instance is transitioning from. + /// + public MediaElementScreenState PreviousState { get; } + + /// + /// Gets the new state that the instance is transitioning to. + /// + public MediaElementScreenState NewState { get; } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Primitives/GridEventArgs.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Primitives/GridEventArgs.windows.cs deleted file mode 100644 index 817f49fd94..0000000000 --- a/src/CommunityToolkit.Maui.MediaElement/Primitives/GridEventArgs.windows.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace CommunityToolkit.Maui.Primitives; - -/// -/// -/// -public class GridEventArgs : EventArgs -{ - /// - /// - /// - public Microsoft.UI.Xaml.Controls.Grid Grid { get; } - - /// - /// - /// - /// - public GridEventArgs(Microsoft.UI.Xaml.Controls.Grid grid) - { - Grid = grid; - } -} diff --git a/src/CommunityToolkit.Maui.MediaElement/Primitives/MediaElementScreenState.cs b/src/CommunityToolkit.Maui.MediaElement/Primitives/MediaElementScreenState.cs new file mode 100644 index 0000000000..bb94b14144 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Primitives/MediaElementScreenState.cs @@ -0,0 +1,17 @@ +namespace CommunityToolkit.Maui.Primitives; + +/// +/// +/// +public enum MediaElementScreenState +{ + /// + /// Full screen. + /// + FullScreen, + + /// + /// The default state. + /// + Default, +} \ 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 e8248b34ea..4df778a042 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs @@ -15,12 +15,7 @@ namespace CommunityToolkit.Maui.Core.Views; /// The user-interface element that represents the on Android. /// public class MauiMediaElement : CoordinatorLayout -{ - /// - /// Handles the event when the windows change. - /// - public static event EventHandler? FullScreenChanged; - +{ int defaultSystemUiVisibility; bool isSystemBarVisible; bool isFullScreen; @@ -62,11 +57,6 @@ public MauiMediaElement(Context context, StyledPlayerView playerView) : base(con AddView(relativeLayout); } - /// - /// A method that raises the FullScreenChanged event. - /// - protected virtual void OnFullScreenChanged(FullScreenEventArgs e) => FullScreenChanged?.Invoke(null, e); - public override void OnDetachedFromWindow() { if (isFullScreen) @@ -128,15 +118,15 @@ void OnFullscreenButtonClick(object? sender, StyledPlayerView.FullscreenButtonCl isFullScreen = true; RemoveView(relativeLayout); layout?.AddView(relativeLayout); - OnFullScreenChanged(new Maui.Primitives.FullScreenEventArgs(isFullScreen)); - } + MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.Default, MediaElementScreenState.FullScreen)); + } else { isFullScreen = false; layout?.RemoveView(relativeLayout); AddView(relativeLayout); - OnFullScreenChanged(new Maui.Primitives.FullScreenEventArgs(isFullScreen)); - } + MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.FullScreen, MediaElementScreenState.Default)); + } // Hide/Show the SystemBars and Status bar SetSystemBarsVisibility(); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs index 43ab36d461..b2be369e76 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs @@ -24,10 +24,6 @@ namespace CommunityToolkit.Maui.Core.Views; ///
public class MauiMediaElement : Grid, IDisposable { - /// - /// Handles the event when the windows change. - /// - public static event EventHandler? GridEventsChanged; static readonly AppWindow appWindow = GetAppWindowForCurrentWindow(); readonly Popup popup = new(); readonly Grid fullScreenGrid = new(); @@ -80,14 +76,6 @@ public MauiMediaElement(MediaPlayerElement mediaPlayerElement) ///
~MauiMediaElement() => Dispose(false); - /// - /// A method that raises the GridEventsChanged event. - /// - protected virtual void FullScreenChanged(GridEventArgs e) - { - GridEventsChanged?.Invoke(null, e); - } - /// /// Releases the managed and unmanaged resources used by the . /// @@ -169,7 +157,6 @@ void OnFullScreenButtonClick(object sender, RoutedEventArgs e) appWindow.SetPresenter(AppWindowPresenterKind.Default); Shell.SetNavBarIsVisible(CurrentPage, doesNavigationBarExistBeforeFullScreen); - FullScreenChanged(new Maui.Primitives.GridEventArgs(fullScreenGrid)); if (popup.IsOpen) { popup.IsOpen = false; @@ -182,6 +169,8 @@ void OnFullScreenButtonClick(object sender, RoutedEventArgs e) var parent = mediaPlayerElement.Parent as FrameworkElement; mediaPlayerElement.Width = parent?.Width ?? mediaPlayerElement.Width; mediaPlayerElement.Height = parent?.Height ?? mediaPlayerElement.Height; + MediaManager.FullScreenEvents.grid = fullScreenGrid; + MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.FullScreen, MediaElementScreenState.Default)); } else { @@ -210,7 +199,8 @@ void OnFullScreenButtonClick(object sender, RoutedEventArgs e) { popup.IsOpen = true; } - FullScreenChanged(new Maui.Primitives.GridEventArgs(fullScreenGrid)); + MediaManager.FullScreenEvents.grid = fullScreenGrid; + MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.Default, MediaElementScreenState.FullScreen)); } } } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index 5bf87eae2f..451bf982a9 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -688,20 +688,12 @@ void RateChanged(object? sender, NSNotificationEventArgs args) } sealed class MediaManagerDelegate : AVPlayerViewControllerDelegate { - /// - /// Handles the event when the windows change. - /// - public static event EventHandler? FullScreenChanged; public override void WillBeginFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) { - OnFulScreenChanged(new FullScreenEventArgs(true)); + MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.FullScreen, MediaElementScreenState.Default)); } public override void WillEndFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) { - OnFulScreenChanged(new FullScreenEventArgs(false)); + MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.Default, MediaElementScreenState.FullScreen)); } - /// - /// A method that raises the FullScreenChanged event. - /// - static void OnFulScreenChanged(FullScreenEventArgs e) => FullScreenChanged?.Invoke(null, e); } \ 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 6fa39a8b66..00f25af0ae 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.shared.cs @@ -10,6 +10,7 @@ global using PlatformMediaElement = CommunityToolkit.Maui.Core.Views.TizenPlayer; #endif +using CommunityToolkit.Maui.Primitives; using Microsoft.Extensions.Logging; namespace CommunityToolkit.Maui.Core.Views; @@ -38,6 +39,28 @@ public MediaManager(IMauiContext context, IMediaElement mediaElement, IDispatche Logger = MauiContext.Services.GetRequiredService().CreateLogger(nameof(MediaManager)); } + /// + /// An event that is raised when the full screen state of the media element has changed. + /// + public record FullScreenEvents() + { + /// + /// An event that is raised when the full screen state of the media element has changed. + /// + public static event EventHandler? WindowsChanged; + /// + /// An event that is raised when the full screen state of the media element has changed. + /// + /// + public static void OnWindowsChanged(FullScreenStateChangedEventArgs e) => WindowsChanged?.Invoke(null, e); +#if WINDOWS + /// + /// Windows specific event that is raised when the full screen state of the media element has changed. + /// + public static Microsoft.UI.Xaml.Controls.Grid? grid { get; set; } +#endif + } + /// /// The instance managed by this manager. /// From dfa0a4a5c8842ad6d5a98bb96bd908f58dfcbc34 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 19 Jul 2024 07:48:35 -0700 Subject: [PATCH 71/98] Refactor Android context access in Maui app This commit overhauls how platform-specific activities, windows, and view groups are managed within our Maui application targeting Android. Key changes include the removal of the `PageExtensions` class, which was previously responsible for providing access to the current Android activity, window, and view group. In its place, we've introduced a new `CurrentPlatformContext` record struct within the `MauiMediaElement` class. This struct is designed to serve a similar purpose but with an improved structure and broader access, as indicated by its `public readonly` visibility modifier. Additionally, the `SubtitleExtensions` class has been updated to utilize the new `CurrentPlatformContext` for managing subtitle functionality, particularly in relation to full-screen mode toggling. This shift away from the now-removed `PageExtensions` class to a more centralized approach within `MauiMediaElement` aims to streamline interactions with the Android platform. It enhances the maintainability and readability of the code, especially for features requiring close integration with Android system capabilities like media playback and full-screen management. --- .../Extensions/PageExtensions.android.cs | 43 ------------------- .../Extensions/SubtitleExtensions.android.cs | 2 +- .../Views/MauiMediaElement.android.cs | 2 +- 3 files changed, 2 insertions(+), 45 deletions(-) delete mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.android.cs diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.android.cs deleted file mode 100644 index 7487154b2e..0000000000 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.android.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Android.Views; -using Activity = Android.App.Activity; - -namespace CommunityToolkit.Maui.Extensions; -partial class PageExtensions -{ - public record struct CurrentPlatformActivity() - { - public static Activity CurrentActivity - { - get - { - if (Platform.CurrentActivity is null) - { - throw new InvalidOperationException("CurrentActivity cannot be null when the FullScreen button is tapped"); - } - return Platform.CurrentActivity; - } - } - public static Android.Views.Window CurrentWindow - { - get - { - if (Platform.CurrentActivity?.Window is null) - { - throw new InvalidOperationException("Window cannot be null when the FullScreen button is tapped"); - } - return Platform.CurrentActivity.Window; - } - } - public static Android.Views.ViewGroup CurrentViewGroup - { - get - { - if (Platform.CurrentActivity?.Window?.DecorView is not ViewGroup viewGroup) - { - throw new InvalidOperationException("DecorView cannot be null when the FullScreen button is tapped"); - } - return viewGroup; - } - } - } -} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index f5346353af..d94dca0751 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -5,7 +5,7 @@ using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; using static Android.Views.ViewGroup; -using CurrentPlatformActivity = CommunityToolkit.Maui.Extensions.PageExtensions.CurrentPlatformActivity; +using CurrentPlatformActivity = CommunityToolkit.Maui.Core.Views.MauiMediaElement.CurrentPlatformContext; namespace CommunityToolkit.Maui.Extensions; diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs index 4df778a042..19dd138e54 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs @@ -193,7 +193,7 @@ void SetSystemBarsVisibility() } } - readonly record struct CurrentPlatformContext + public readonly record struct CurrentPlatformContext { public static Activity CurrentActivity { From 16a7b9faeb7031c303aa8ba955dc66fddf0a25d0 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 19 Jul 2024 08:32:19 -0700 Subject: [PATCH 72/98] Refine visibility and structure of records Changed the visibility of `CurrentPlatformContext` in `MauiMediaElement.android.cs` and `FullScreenEvents` in `MediaManager.shared.cs` from `public` to `internal`, limiting their accessibility to within the assembly. Additionally, transformed `FullScreenEvents` from a `record` to a `readonly record struct`, making it a value type and immutable. --- .../Views/MauiMediaElement.android.cs | 2 +- .../Views/MediaManager.shared.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs index 19dd138e54..b4e7b7d693 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs @@ -193,7 +193,7 @@ void SetSystemBarsVisibility() } } - public readonly record struct CurrentPlatformContext + internal readonly record struct CurrentPlatformContext { public static Activity CurrentActivity { diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.shared.cs index 00f25af0ae..9c33f93499 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.shared.cs @@ -42,7 +42,7 @@ public MediaManager(IMauiContext context, IMediaElement mediaElement, IDispatche /// /// An event that is raised when the full screen state of the media element has changed. /// - public record FullScreenEvents() + internal readonly record struct FullScreenEvents() { /// /// An event that is raised when the full screen state of the media element has changed. From 715f808fcf21f1669a4bc8e2843d4aed81ae6a18 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sat, 20 Jul 2024 03:26:51 -0700 Subject: [PATCH 73/98] Refactor FontExtensions and PageExtensions - Changed `FontExtensions` class from `static` to `sealed`, allowing instantiation and extension but preventing inheritance. - Modified `PageExtensions` class from `partial` to a static class, consolidating its implementation. --- .../Extensions/FontExtensions.cs | 2 +- .../Extensions/PageExtensions.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs index 2c039b1dae..7a01fe6b8b 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs @@ -3,7 +3,7 @@ namespace CommunityToolkit.Maui.Core; -static class FontExtensions +sealed class FontExtensions { public record struct FontFamily(string input) { diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.cs index e01cbb6803..eca07358d5 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.cs @@ -2,7 +2,7 @@ // Since MediaElement can't access .NET MAUI internals we have to copy this code here // https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Platform/PageExtensions.cs -static partial class PageExtensions +static class PageExtensions { internal static Page GetCurrentPage(this Page currentPage) { From 2791bd6ce1c456ed16fc4c4fcfc5378507156380 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Tue, 23 Jul 2024 06:52:22 -0700 Subject: [PATCH 74/98] Enhance subtitle parsing and display capabilities This commit significantly updates the subtitle handling functionality across the application, marking a pivotal enhancement in how subtitles are parsed, represented, and displayed. Key changes include: - Refactored the `SrtParser` and `VttParser` for improved parsing accuracy and error handling, transitioning from returning a list of cues to a more structured `SubtitleDocument`. - Enhanced the `SubtitleCue` class with additional properties for detailed cue settings and replaced the `Text` property with `RawText` and `ParsedCueText` for a richer representation of subtitle cues. - Updated Android `SubtitleExtensions` to support advanced subtitle cue structures, including new methods for displaying cues with applied styles and positioning. - Conducted general code cleanup for better readability and maintainability, including the removal of unused `using` directives. - Adjusted namespace and using directives in response to changes in the CommunityToolkit libraries, reflecting a reorganization of classes and functionalities. - Modified field modifiers and initialization practices, particularly in macOS/iOS implementations, to enhance flexibility in handling subtitle cues. - Improved error handling and validation across the board, throwing more specific exceptions for invalid inputs and formats. - Introduced new classes (`SubtitleDocument`, `SubtitleMetadataCue`, `SubtitleNode`) to support a comprehensive representation of subtitle data. - Implemented a suite of tests for the updated parsers and subtitle handling functionalities, ensuring robustness and reliability. These updates collectively advance the application's capability to handle a wide range of subtitle formats and features, laying the groundwork for future enhancements in multimedia content accessibility and presentation. --- .../MediaElement/MediaElementPage.xaml.cs | 127 +++++---- .../Extensions/SrtParser.cs | 132 ++++++---- .../Extensions/SubtitleCue.cs | 58 ++++- .../Extensions/SubtitleDocument.cs | 22 ++ .../Extensions/SubtitleExtensions.android.cs | 161 ++++++++++-- .../Extensions/SubtitleExtensions.macios.cs | 143 +++++++++-- .../Extensions/SubtitleExtensions.shared.cs | 12 +- .../Extensions/SubtitleExtensions.windows.cs | 162 ++++++++++-- .../Extensions/SubtitleMetadataCue.cs | 12 + .../Extensions/SubtitleNode.cs | 27 ++ .../Extensions/SubtitleParser.cs | 19 +- .../Extensions/VttParser.cs | 240 ++++++++++++++---- .../Interfaces/IParser.cs | 4 +- .../Views/MediaManager.macios.cs | 4 +- .../Views/MediaManager.windows.cs | 4 +- .../Extensions/SrtParserTests.cs | 61 +++-- .../Extensions/SubtitleParserTests.cs | 46 ++++ .../Extensions/VttParserTests.cs | 207 ++++++++++----- 18 files changed, 1141 insertions(+), 300 deletions(-) create mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleDocument.cs create mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleMetadataCue.cs create mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleNode.cs create mode 100644 src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleParserTests.cs 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 e7b2c622e1..17b2e8fbdb 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -285,84 +285,123 @@ void DisplayPopup(object sender, EventArgs e) } /// -/// Sample implementation of an SRT parser. +/// Parser for SubRip (SRT) subtitle files /// -partial class SrtParser : IParser +public partial class SrtParser : IParser { - static readonly Regex timecodePatternSRT = SRTRegex(); - - public List ParseContent(string content) + static readonly Regex timeCodeRegex = TimeCodeRegex(); + static readonly string[] separator = { "\r\n", "\r", "\n" }; + + /// + /// Parses the content of an SRT file and returns a SubtitleDocument + /// + /// The content of the SRT file + /// A SubtitleDocument containing the parsed subtitles + public SubtitleDocument ParseContent(string content) { - var cues = new List(); - if (string.IsNullOrEmpty(content)) + if (string.IsNullOrWhiteSpace(content)) { - return cues; + return new SubtitleDocument(); } - var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); + var document = new SubtitleDocument(); + var lines = content.Split(separator, StringSplitOptions.None); SubtitleCue? currentCue = null; - var textBuffer = new StringBuilder(); + var cueText = new List(); foreach (var line in lines) { - if (int.TryParse(line, out _)) + var trimmedLine = line.Trim(); + + if (string.IsNullOrWhiteSpace(trimmedLine)) { + if (currentCue is not null) + { + FinalizeCue(currentCue, cueText, document); + currentCue = null; + cueText.Clear(); + } continue; } - var match = timecodePatternSRT.Match(line); - if (match.Success) + if (int.TryParse(trimmedLine, out _)) { if (currentCue is not null) { - currentCue.Text = textBuffer.ToString().TrimEnd('\r', '\n'); - cues.Add(currentCue); - textBuffer.Clear(); + FinalizeCue(currentCue, cueText, document); + cueText.Clear(); } - currentCue = CreateCue(match); + currentCue = new SubtitleCue { Id = trimmedLine }; + } + else if (currentCue is not null && timeCodeRegex.IsMatch(trimmedLine)) + { + var match = timeCodeRegex.Match(trimmedLine); + + if (!match.Success) + { + throw new FormatException("Invalid timecode format."); + } + + currentCue.StartTime = ParseTimeCode(match.Groups[1].Value, match.Groups[2].Value, match.Groups[3].Value, match.Groups[4].Value); + currentCue.EndTime = ParseTimeCode(match.Groups[5].Value, match.Groups[6].Value, match.Groups[7].Value, match.Groups[8].Value); + + if (currentCue.EndTime <= currentCue.StartTime) + { + throw new FormatException("End time must be greater than start time."); + } + } + else if (currentCue is not null) + { + cueText.Add(trimmedLine); } - else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) + else { - textBuffer.AppendLine(line.Trim().TrimEnd('\r', '\n')); + throw new FormatException("Invalid subtitle format."); } } - if (currentCue is not null) - { - currentCue.Text = textBuffer.ToString().TrimEnd('\r', '\n'); - cues.Add(currentCue); - } - if (cues.Count == 0) + // Add the last cue if there's any + if (currentCue is not null && cueText.Count > 0) { - throw new FormatException("Invalid SRT format"); + FinalizeCue(currentCue, cueText, document); } - return cues; + + return document; } - static SubtitleCue CreateCue(Match match) + static void FinalizeCue(SubtitleCue cue, List cueText, SubtitleDocument document) { - var StartTime = ParseTimecode(match.Groups[1].Value); - var EndTime = ParseTimecode(match.Groups[2].Value); - var Text = string.Empty; - if (StartTime > EndTime) + cue.RawText = string.Join("\n", cueText); + cue.ParsedCueText = ParseCueText(cue.RawText); + document.Cues.Add(cue); + } + + static SubtitleNode ParseCueText(string rawText) + { + var root = new SubtitleNode { NodeType = "root" }; + var lines = rawText.Split('\n'); + + foreach (var line in lines) { - throw new FormatException("Start time cannot be greater than end time."); + var textNode = new SubtitleNode + { + NodeType = "text", + TextContent = line.TrimStart('-', ' ') + }; + + root.Children.Add(textNode); } - return new SubtitleCue - { - StartTime = StartTime, - EndTime = EndTime, - Text = Text - }; + + return root; } - static TimeSpan ParseTimecode(string timecode) + static TimeSpan ParseTimeCode(string hours, string minutes, string seconds, string milliseconds) { - return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); + return new TimeSpan(0, int.Parse(hours), int.Parse(minutes), int.Parse(seconds), int.Parse(milliseconds)); } - [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})", RegexOptions.Compiled)] - private static partial Regex SRTRegex(); -} \ No newline at end of file + [GeneratedRegex(@"(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})", RegexOptions.Compiled)] + private static partial Regex TimeCodeRegex(); +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index e7794133f8..e0562843ee 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -1,83 +1,125 @@ -using System.Globalization; -using System.Text; -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; namespace CommunityToolkit.Maui.Core; -partial class SrtParser : IParser +/// +/// Parser for SubRip (SRT) subtitle files +/// +public partial class SrtParser : IParser { - static readonly Regex timecodePatternSRT = SRTRegex(); + static readonly Regex timeCodeRegex = TimeCodeRegex(); + static readonly string[] separator = { "\r\n", "\r", "\n" }; - public List ParseContent(string content) + /// + /// Parses the content of an SRT file and returns a SubtitleDocument + /// + /// The content of the SRT file + /// A SubtitleDocument containing the parsed subtitles + public SubtitleDocument ParseContent(string content) { - var cues = new List(); - if (string.IsNullOrEmpty(content)) + if (string.IsNullOrWhiteSpace(content)) { - return cues; + return new SubtitleDocument(); } - - var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); + + var document = new SubtitleDocument(); + var lines = content.Split(separator, StringSplitOptions.None); + SubtitleCue? currentCue = null; - var textBuffer = new StringBuilder(); + var cueText = new List(); foreach (var line in lines) { - if (int.TryParse(line, out _)) + var trimmedLine = line.Trim(); + + if (string.IsNullOrWhiteSpace(trimmedLine)) { + if (currentCue is not null) + { + FinalizeCue(currentCue, cueText, document); + currentCue = null; + cueText.Clear(); + } continue; } - var match = timecodePatternSRT.Match(line); - if (match.Success) + if (int.TryParse(trimmedLine, out _)) { if (currentCue is not null) { - currentCue.Text = textBuffer.ToString().TrimEnd('\r', '\n'); - cues.Add(currentCue); - textBuffer.Clear(); + FinalizeCue(currentCue, cueText, document); + cueText.Clear(); + } + + currentCue = new SubtitleCue { Id = trimmedLine }; + } + else if (currentCue is not null && timeCodeRegex.IsMatch(trimmedLine)) + { + var match = timeCodeRegex.Match(trimmedLine); + + if (!match.Success) + { + throw new FormatException("Invalid timecode format."); + } + + currentCue.StartTime = ParseTimeCode(match.Groups[1].Value, match.Groups[2].Value, match.Groups[3].Value, match.Groups[4].Value); + currentCue.EndTime = ParseTimeCode(match.Groups[5].Value, match.Groups[6].Value, match.Groups[7].Value, match.Groups[8].Value); + + if (currentCue.EndTime <= currentCue.StartTime) + { + throw new FormatException("End time must be greater than start time."); } - currentCue = CreateCue(match); } - else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) + else if (currentCue is not null) + { + cueText.Add(trimmedLine); + } + else { - textBuffer.AppendLine(line.Trim().TrimEnd('\r', '\n')); + throw new FormatException("Invalid subtitle format."); } } - if (currentCue is not null) - { - currentCue.Text = textBuffer.ToString().TrimEnd('\r', '\n'); - cues.Add(currentCue); - } - if(cues.Count == 0) + // Add the last cue if there's any + if (currentCue is not null && cueText.Count > 0) { - throw new FormatException("Invalid SRT format"); + FinalizeCue(currentCue, cueText, document); } - return cues; + + return document; } - static SubtitleCue CreateCue(Match match) + static void FinalizeCue(SubtitleCue cue, List cueText, SubtitleDocument document) { - var StartTime = ParseTimecode(match.Groups[1].Value); - var EndTime = ParseTimecode(match.Groups[2].Value); - var Text = string.Empty; - if (StartTime > EndTime) + cue.RawText = string.Join("\n", cueText); + cue.ParsedCueText = ParseCueText(cue.RawText); + document.Cues.Add(cue); + } + + static SubtitleNode ParseCueText(string rawText) + { + var root = new SubtitleNode { NodeType = "root" }; + var lines = rawText.Split('\n'); + + foreach (var line in lines) { - throw new FormatException("Start time cannot be greater than end time."); + var textNode = new SubtitleNode + { + NodeType = "text", + TextContent = line.TrimStart('-', ' ') + }; + + root.Children.Add(textNode); } - return new SubtitleCue - { - StartTime = StartTime, - EndTime = EndTime, - Text = Text - }; + + return root; } - static TimeSpan ParseTimecode(string timecode) + static TimeSpan ParseTimeCode(string hours, string minutes, string seconds, string milliseconds) { - return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); + return new TimeSpan(0, int.Parse(hours), int.Parse(minutes), int.Parse(seconds), int.Parse(milliseconds)); } - [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})", RegexOptions.Compiled)] - private static partial Regex SRTRegex(); + [GeneratedRegex(@"(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})", RegexOptions.Compiled)] + private static partial Regex TimeCodeRegex(); } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs index ebd1efc4f2..a955a76346 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs @@ -1,26 +1,62 @@ namespace CommunityToolkit.Maui.Core; /// -/// A class that represents a subtitle cue. +/// A subtitle cue /// public class SubtitleCue { /// - /// The number of the cue. + /// The ID of the cue /// - public int Number { get; set; } + public string Id { get; set; } = string.Empty; + /// - /// The start time of the cue. + /// The start time of the cue /// - public TimeSpan? StartTime { get; set; } - + public TimeSpan StartTime { get; set; } + /// - /// The end time of the cue. + /// The end time of the cue /// - public TimeSpan? EndTime { get; set; } - + public TimeSpan EndTime { get; set; } + + /// + /// + /// + public string RegionId { get; set; } = string.Empty; + + /// + /// The Vertical setting of the cue + /// + public string Vertical { get; set; } = string.Empty; + + /// + /// The Line setting of the cue + /// + public string Line { get; set; } = string.Empty; + + /// + /// The Position setting of the cue + /// + public string Position { get; set; } = string.Empty; + + /// + /// The Size setting of the cue + /// + public string Size { get; set; } = "100%"; + + /// + /// The Align setting of the cue + /// + public string Align { get; set; } = "middle"; + + /// + /// The parsed cue text + /// + public SubtitleNode? ParsedCueText { get; set; } + /// - /// The text of the cue. + /// The raw text of the cue /// - public string? Text { get; set; } + public string RawText { get; set; } = string.Empty; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleDocument.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleDocument.cs new file mode 100644 index 0000000000..755a74b468 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleDocument.cs @@ -0,0 +1,22 @@ +namespace CommunityToolkit.Maui.Core; + +/// +/// A subtitle document +/// +public partial class SubtitleDocument +{ + /// + /// The header of the document + /// + public string Header { get; set; } = string.Empty; + + /// + /// The cues in the document + /// + public List Cues { get; set; } = []; + + /// + /// The style block of the document + /// + public string? StyleBlock { get; set; } +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index d94dca0751..b310630cbd 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -1,10 +1,14 @@ using Android.Graphics; +using Android.Text; +using Android.Text.Style; using Android.Views; using Android.Widget; using Com.Google.Android.Exoplayer2.UI; +using CommunityToolkit.Maui.Core; using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; using static Android.Views.ViewGroup; +using Color = Android.Graphics.Color; using CurrentPlatformActivity = CommunityToolkit.Maui.Core.Views.MauiMediaElement.CurrentPlatformContext; namespace CommunityToolkit.Maui.Extensions; @@ -15,6 +19,7 @@ partial class SubtitleExtensions : Java.Lang.Object readonly RelativeLayout.LayoutParams? subtitleLayout; readonly StyledPlayerView styledPlayerView; TextView? subtitleView; + public List? Cues { get; set; } public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatcher) { @@ -32,11 +37,15 @@ public void StartSubtitleDisplay() { ArgumentNullException.ThrowIfNull(subtitleView); ArgumentNullException.ThrowIfNull(Cues); - if(Cues.Count == 0 || string.IsNullOrEmpty(MediaElement?.SubtitleUrl)) + ArgumentNullException.ThrowIfNull(MediaElement); + if (MediaElement.SubtitleUrl is null) { return; } - if(styledPlayerView.Parent is not ViewGroup parent) + + Cues = Document?.Cues; + + if (styledPlayerView.Parent is not ViewGroup parent) { System.Diagnostics.Trace.TraceError("StyledPlayerView parent is not a ViewGroup"); return; @@ -51,7 +60,7 @@ public void StopSubtitleDisplay() { ArgumentNullException.ThrowIfNull(Cues); Cues.Clear(); - if(Timer is not null) + if (Timer is not null) { Timer.Stop(); Timer.Elapsed -= UpdateSubtitle; @@ -81,11 +90,7 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { if (cue is not null) { - Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).Android) ?? Typeface.Default; - subtitleView.SetTypeface(typeface, TypefaceStyle.Normal); - subtitleView.Text = cue.Text; - subtitleView.TextSize = (float)MediaElement.SubtitleFontSize; - subtitleView.Visibility = ViewStates.Visible; + DisplayCue(cue); } else { @@ -95,46 +100,170 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) }); } + void DisplayCue(SubtitleCue cue) + { + ArgumentNullException.ThrowIfNull(MediaElement); + ArgumentNullException.ThrowIfNull(subtitleView); + if (cue.ParsedCueText is null) + { + return; + } + + var spannableString = new SpannableStringBuilder(); + ProcessCueText(spannableString, cue.ParsedCueText); + subtitleView.TextFormatted = spannableString; + + ApplyStyles(cue); + subtitleView.Visibility = ViewStates.Visible; + } + + static void ProcessCueText(SpannableStringBuilder spannableString, SubtitleNode node) + { + foreach (var child in node.Children) + { + if (child.NodeType == "text") + { + string? text = child.TextContent; + if (!string.IsNullOrEmpty(text)) + { + int start = spannableString.Length(); + spannableString.Append(text); + int end = spannableString.Length(); + ApplyStyleToSpan(spannableString, child.NodeType, start, end); + } + } + else if (child.NodeType is not null) + { + int start = spannableString.Length(); + ProcessCueText(spannableString, child); + int end = spannableString.Length(); + ApplyStyleToSpan(spannableString, child.NodeType, start, end); + } + } + } + + static void ApplyStyleToSpan(SpannableStringBuilder spannableString, string nodeType, int start, int end) + { + switch (nodeType.ToLower()) + { + case "b": + spannableString.SetSpan(new StyleSpan(TypefaceStyle.Bold), start, end, SpanTypes.ExclusiveExclusive); + break; + case "i": + spannableString.SetSpan(new StyleSpan(TypefaceStyle.Italic), start, end, SpanTypes.ExclusiveExclusive); + break; + case "u": + spannableString.SetSpan(new UnderlineSpan(), start, end, SpanTypes.ExclusiveExclusive); + break; + case "v": + spannableString.SetSpan(new ForegroundColorSpan(Color.Yellow), start, end, SpanTypes.ExclusiveExclusive); + break; + } + } + + void ApplyStyles(SubtitleCue cue) + { + ArgumentNullException.ThrowIfNull(MediaElement); + ArgumentNullException.ThrowIfNull(subtitleView); + ArgumentNullException.ThrowIfNull(subtitleLayout); + + subtitleView.Gravity = GetTextAlignment(cue.Align); + + Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).Android) ?? Typeface.Default; + subtitleView.SetTypeface(typeface, TypefaceStyle.Normal); + subtitleView.TextSize = (float)MediaElement.SubtitleFontSize; + + if (!string.IsNullOrEmpty(cue.Position)) + { + var parts = cue.Position.Split(','); + if (parts.Length > 0 && float.TryParse(parts[0].TrimEnd('%'), out float horizontalPosition)) + { + subtitleLayout.LeftMargin = (int)(horizontalPosition * styledPlayerView.Width / 100); + } + } + + if (!string.IsNullOrEmpty(cue.Line) && float.TryParse(cue.Line.TrimEnd('%'), out float verticalPosition)) + { + subtitleLayout.BottomMargin = (int)(verticalPosition * styledPlayerView.Height / 100); + } + + subtitleView.LayoutParameters = subtitleLayout; + + if (cue.Vertical is not null) + { + ApplyVerticalWriting(cue.Vertical); + } + } + + static GravityFlags GetTextAlignment(string align) + { + return align?.ToLower() switch + { + "left" => GravityFlags.Left, + "right" => GravityFlags.Right, + "center" => GravityFlags.Center, + _ => GravityFlags.Center, + }; + } + + void ApplyVerticalWriting(string vertical) + { + ArgumentNullException.ThrowIfNull(subtitleView); + if (vertical == "rl" || vertical == "lr") + { + subtitleView.Rotation = vertical == "rl" ? 90 : -90; + } + else + { + subtitleView.Rotation = 0; + } + } + void InitializeTextBlock() { - subtitleView = new(CurrentPlatformActivity.CurrentActivity.ApplicationContext) + subtitleView = new TextView(CurrentPlatformActivity.CurrentActivity.ApplicationContext) { Text = string.Empty, HorizontalScrollBarEnabled = false, VerticalScrollBarEnabled = false, - TextAlignment = Android.Views.TextAlignment.Center, - Visibility = Android.Views.ViewStates.Gone, + Gravity = GravityFlags.Center, + Visibility = ViewStates.Gone, LayoutParameters = subtitleLayout }; - subtitleView.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); - subtitleView.SetTextColor(Android.Graphics.Color.White); - subtitleView.SetPaddingRelative(10, 10, 10, 20); + subtitleView.SetBackgroundColor(Color.Argb(150, 0, 0, 0)); + subtitleView.SetTextColor(Color.White); + subtitleView.SetPadding(10, 10, 10, 20); } void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) { ArgumentNullException.ThrowIfNull(subtitleView); ArgumentNullException.ThrowIfNull(MediaElement); - - // If the subtitle URL is empty do nothing + ArgumentNullException.ThrowIfNull(subtitleLayout); if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { return; } + if (CurrentPlatformActivity.CurrentViewGroup.Parent is not ViewGroup parent) { return; } + switch (e.NewState == MediaElementScreenState.FullScreen) { case true: CurrentPlatformActivity.CurrentViewGroup.RemoveView(subtitleView); InitializeTextBlock(); + subtitleView.TextSize = (float)MediaElement.SubtitleFontSize + 8.0f; + subtitleLayout.BottomMargin = 300; parent.AddView(subtitleView); break; case false: parent.RemoveView(subtitleView); InitializeTextBlock(); + subtitleView.TextSize = (float)MediaElement.SubtitleFontSize; + subtitleLayout.BottomMargin = 20; CurrentPlatformActivity.CurrentViewGroup.AddView(subtitleView); break; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 84168b2523..150c183592 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -1,4 +1,5 @@ -using CommunityToolkit.Maui.Core.Views; +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; using CoreFoundation; using CoreGraphics; @@ -12,24 +13,25 @@ partial class SubtitleExtensions : UIViewController { readonly PlatformMediaElement player; readonly UIViewController playerViewController; - readonly UILabel subtitleLabel; + UILabel subtitleLabel; static readonly UIColor subtitleBackgroundColor = UIColor.FromRGBA(0, 0, 0, 128); static readonly UIColor clearBackgroundColor = UIColor.FromRGBA(0, 0, 0, 0); NSObject? playerObserver; UIViewController? viewController; + List? cues; public SubtitleExtensions(PlatformMediaElement player, UIViewController playerViewController) { this.playerViewController = playerViewController; this.player = player; - Cues = []; + cues = []; subtitleLabel = new UILabel { Frame = CalculateSubtitleFrame(playerViewController), TextColor = UIColor.White, TextAlignment = UITextAlignment.Center, Font = UIFont.SystemFontOfSize(12), - Text = "", + Text = string.Empty, Lines = 0, LineBreakMode = UILineBreakMode.WordWrap, AutoresizingMask = UIViewAutoresizing.FlexibleWidth @@ -42,6 +44,7 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi public void StartSubtitleDisplay() { + cues = Document?.Cues; ArgumentNullException.ThrowIfNull(subtitleLabel); DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); playerObserver = player?.AddPeriodicTimeObserver(CMTime.FromSeconds(1, 1), null, (time) => @@ -54,15 +57,16 @@ public void StartSubtitleDisplay() public void StopSubtitleDisplay() { - ArgumentNullException.ThrowIfNull(Cues); + ArgumentNullException.ThrowIfNull(cues); subtitleLabel.Text = string.Empty; - Cues.Clear(); + cues.Clear(); subtitleLabel.BackgroundColor = clearBackgroundColor; DispatchQueue.MainQueue.DispatchAsync(() => subtitleLabel.RemoveFromSuperview()); } + void UpdateSubtitle(TimeSpan currentPlaybackTime) { - ArgumentNullException.ThrowIfNull(Cues); + ArgumentNullException.ThrowIfNull(cues); ArgumentNullException.ThrowIfNull(subtitleLabel); ArgumentNullException.ThrowIfNull(MediaElement); if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) @@ -70,21 +74,128 @@ void UpdateSubtitle(TimeSpan currentPlaybackTime) return; } - foreach (var cue in Cues) + var currentCue = cues.Find(cue => currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime); + + if (currentCue is not null) + { + DisplayCue(currentCue); + } + else + { + subtitleLabel.Text = string.Empty; + subtitleLabel.BackgroundColor = clearBackgroundColor; + } + } + + void DisplayCue(SubtitleCue cue) + { + if (cue.ParsedCueText is null) + { + return; + } + + var attributedString = new NSMutableAttributedString(); + ProcessCueText(attributedString, cue.ParsedCueText); + subtitleLabel.AttributedText = attributedString; + + ApplyStyles(cue); + subtitleLabel.BackgroundColor = subtitleBackgroundColor; + } + + void ProcessCueText(NSMutableAttributedString attributedString, SubtitleNode node) + { + foreach (var child in node.Children) { - if (currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime) + if (child.NodeType == "text") { - subtitleLabel.Text = cue.Text; - subtitleLabel.Font = UIFont.FromName(name: new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).MacIOS, size: (float)MediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize(16); - subtitleLabel.BackgroundColor = subtitleBackgroundColor; - break; + string? text = child.TextContent; + if (!string.IsNullOrEmpty(text)) + { + var range = new NSRange(attributedString.Length, text.Length); + attributedString.Append(new NSAttributedString(text)); + ApplyStyleToRange(attributedString, child.NodeType, range); + } } - else + else if (child.NodeType is not null) + { + var startLength = attributedString.Length; + ProcessCueText(attributedString, child); + var range = new NSRange(startLength, attributedString.Length - startLength); + ApplyStyleToRange(attributedString, child.NodeType, range); + } + } + } + + void ApplyStyleToRange(NSMutableAttributedString attributedString, string nodeType, NSRange range) + { + ArgumentNullException.ThrowIfNull(MediaElement); + switch (nodeType.ToLower()) + { + case "b": + attributedString.AddAttribute(UIStringAttributeKey.Font, UIFont.BoldSystemFontOfSize((float)MediaElement.SubtitleFontSize), range); + break; + case "i": + attributedString.AddAttribute(UIStringAttributeKey.Font, UIFont.ItalicSystemFontOfSize((float)MediaElement.SubtitleFontSize), range); + break; + case "u": + attributedString.AddAttribute(UIStringAttributeKey.UnderlineStyle, NSNumber.FromInt32((int)NSUnderlineStyle.Single), range); + break; + case "v": + attributedString.AddAttribute(UIStringAttributeKey.ForegroundColor, UIColor.Yellow, range); + break; + } + } + + void ApplyStyles(SubtitleCue cue) + { + ArgumentNullException.ThrowIfNull(MediaElement); + ArgumentNullException.ThrowIfNull(playerViewController.View); + subtitleLabel.TextAlignment = GetTextAlignment(cue.Align); + + var font = UIFont.FromName(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).MacIOS, (float)MediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize((float)MediaElement.SubtitleFontSize); + subtitleLabel.Font = font; + + if (!string.IsNullOrEmpty(cue.Position)) + { + var parts = cue.Position.Split(','); + if (parts.Length > 0 && float.TryParse(parts[0].TrimEnd('%'), out float horizontalPosition)) { - subtitleLabel.Text = ""; - subtitleLabel.BackgroundColor = clearBackgroundColor; + subtitleLabel.Frame = new CGRect(horizontalPosition * playerViewController.View.Bounds.Width / 100, subtitleLabel.Frame.Y, subtitleLabel.Frame.Width, subtitleLabel.Frame.Height); } } + + if (!string.IsNullOrEmpty(cue.Line) && float.TryParse(cue.Line.TrimEnd('%'), out float verticalPosition)) + { + subtitleLabel.Frame = new CGRect(subtitleLabel.Frame.X, verticalPosition * playerViewController.View.Bounds.Height / 100, subtitleLabel.Frame.Width, subtitleLabel.Frame.Height); + } + + if (cue.Vertical is not null) + { + ApplyVerticalWriting(cue.Vertical); + } + } + + static UITextAlignment GetTextAlignment(string align) + { + return align?.ToLower() switch + { + "left" => UITextAlignment.Left, + "right" => UITextAlignment.Right, + "center" => UITextAlignment.Center, + _ => UITextAlignment.Center, + }; + } + + void ApplyVerticalWriting(string vertical) + { + if (vertical == "rl" || vertical == "lr") + { + subtitleLabel.Transform = CGAffineTransform.MakeRotation(vertical == "rl" ? (float)Math.PI / 2 : -(float)Math.PI / 2); + } + else + { + subtitleLabel.Transform = CGAffineTransform.MakeIdentity(); + } } static CGRect CalculateSubtitleFrame(UIViewController uIViewController) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs index b0207ecbdc..5ef1926c41 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs @@ -5,11 +5,11 @@ namespace CommunityToolkit.Maui.Extensions; partial class SubtitleExtensions { public IMediaElement? MediaElement; - public List? Cues; + public SubtitleDocument? Document; public System.Timers.Timer? Timer; public async Task LoadSubtitles(IMediaElement mediaElement) { - Cues ??= []; + Document ??= new(); this.MediaElement = mediaElement; if(MediaElement is null) { @@ -23,26 +23,26 @@ public async Task LoadSubtitles(IMediaElement mediaElement) { return; } + SubtitleParser parser; var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); - try { if (mediaElement.CustomSubtitleParser is not null) { parser = new(mediaElement.CustomSubtitleParser); - Cues = parser.ParseContent(content); + Document = parser.ParseContent(content); return; } switch (mediaElement.SubtitleUrl) { case var url when url.EndsWith("srt"): parser = new(new SrtParser()); - Cues = parser.ParseContent(content); + Document = parser.ParseContent(content); break; case var url when url.EndsWith("vtt"): parser = new(new VttParser()); - Cues = parser.ParseContent(content); + Document = parser.ParseContent(content); break; default: System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 95dbca1d97..aea9e25c08 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -1,7 +1,19 @@ -using CommunityToolkit.Maui.Core.Views; +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Documents; using Microsoft.UI.Xaml.Media; +using Windows.UI.Text; using Grid = Microsoft.Maui.Controls.Grid; +using HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment; +using SolidColorBrush = Microsoft.UI.Xaml.Media.SolidColorBrush; +using Span = Microsoft.UI.Xaml.Documents.Span; +using TextAlignment = Microsoft.UI.Xaml.TextAlignment; +using Thickness = Microsoft.UI.Xaml.Thickness; +using VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment; +using Visibility = Microsoft.UI.Xaml.Visibility; namespace CommunityToolkit.Maui.Extensions; @@ -9,22 +21,24 @@ partial class SubtitleExtensions : Grid, IDisposable { bool disposedValue; bool isFullScreen = false; - readonly Microsoft.UI.Xaml.Controls.TextBlock subtitleTextBlock; + readonly TextBlock subtitleTextBlock; readonly MauiMediaElement? mauiMediaElement; + public List? Cues { get; set; } - public SubtitleExtensions(Microsoft.UI.Xaml.Controls.MediaPlayerElement player) + public SubtitleExtensions(MediaPlayerElement player) { mauiMediaElement = player?.Parent as MauiMediaElement; MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; - subtitleTextBlock = new() + + subtitleTextBlock = new TextBlock { Text = string.Empty, - Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20), - Visibility = Microsoft.UI.Xaml.Visibility.Collapsed, - HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, - VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Bottom, - Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White), - TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap, + Margin = new Thickness(0, 0, 0, 20), + Visibility = Visibility.Collapsed, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Bottom, + Foreground = new SolidColorBrush(Microsoft.UI.Colors.White), + TextWrapping = TextWrapping.Wrap, }; } @@ -34,6 +48,7 @@ public void StartSubtitleDisplay() Dispatcher.Dispatch(() => mauiMediaElement?.Children.Add(subtitleTextBlock)); Timer.Elapsed += UpdateSubtitle; Timer.Start(); + Cues = Document?.Cues; } public void StopSubtitleDisplay() @@ -46,7 +61,7 @@ public void StopSubtitleDisplay() } Timer.Stop(); Timer.Elapsed -= UpdateSubtitle; - if(mauiMediaElement is null) + if (mauiMediaElement is null) { return; } @@ -60,23 +75,134 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { return; } + var cue = Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position); Dispatcher.Dispatch(() => { if (cue is not null) { - subtitleTextBlock.Text = cue.Text; - subtitleTextBlock.FontFamily = new FontFamily(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).WindowsFont); - subtitleTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; + DisplayCue(cue); } else { subtitleTextBlock.Text = string.Empty; - subtitleTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; + subtitleTextBlock.Visibility = Visibility.Collapsed; } }); } + void DisplayCue(SubtitleCue cue) + { + if(cue.ParsedCueText is null) + { + return; + } + subtitleTextBlock.Inlines.Clear(); + ProcessCueText(subtitleTextBlock.Inlines, cue.ParsedCueText); + ApplyStyles(cue); + subtitleTextBlock.Visibility = Visibility.Visible; + + subtitleTextBlock.LineStackingStrategy = LineStackingStrategy.BlockLineHeight; + subtitleTextBlock.LineHeight = subtitleTextBlock.FontSize * 1.2; + } + + static void ProcessCueText(InlineCollection inlines, SubtitleNode node) + { + foreach (var child in node.Children) + { + if (child.NodeType == "text") + { + string? text = child.TextContent; + if (!string.IsNullOrEmpty(text)) + { + inlines.Add(new Run { Text = text }); + } + } + else if(child.NodeType is not null) + { + var span = new Span(); + ApplyStyleToSpan(span, child.NodeType); + ProcessCueText(span.Inlines, child); + inlines.Add(span); + } + } + } + + static void ApplyStyleToSpan(Span span, string nodeType) + { + switch (nodeType.ToLower()) + { + case "b": + span.FontWeight = Microsoft.UI.Text.FontWeights.Bold; + break; + case "i": + span.FontStyle = FontStyle.Italic; + break; + case "u": + span.TextDecorations = Windows.UI.Text.TextDecorations.Underline; + break; + case "v": + span.Foreground = new SolidColorBrush(Microsoft.UI.Colors.Yellow); + break; + } + } + + void ApplyStyles(SubtitleCue cue) + { + if(MediaElement?.SubtitleUrl is null || mauiMediaElement?.Width is null) + { + return; + } + subtitleTextBlock.TextAlignment = GetTextAlignment(cue.Align); + subtitleTextBlock.FontFamily = new FontFamily(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).WindowsFont); + + if (!string.IsNullOrEmpty(cue.Position)) + { + var parts = cue.Position.Split(','); + if (parts.Length > 0 && float.TryParse(parts[0].TrimEnd('%'), out float horizontalPosition)) + { + subtitleTextBlock.Margin = new Thickness(horizontalPosition * mauiMediaElement.Width / 100, 0, 0, subtitleTextBlock.Margin.Bottom); + } + } + + if (!string.IsNullOrEmpty(cue.Line) && float.TryParse(cue.Line.TrimEnd('%'), out float verticalPosition)) + { + subtitleTextBlock.Margin = new Thickness(subtitleTextBlock.Margin.Left, 0, 0, verticalPosition * mauiMediaElement.Height / 100); + } + if (cue.Vertical is null) + { + return; + } + ApplyVerticalWriting(cue.Vertical); + } + + static TextAlignment GetTextAlignment(string align) + { + return align?.ToLower() switch + { + "left" => TextAlignment.Left, + "right" => TextAlignment.Right, + "center" => TextAlignment.Center, + _ => TextAlignment.Center, + }; + } + + void ApplyVerticalWriting(string vertical) + { + if (vertical == "rl" || vertical == "lr") + { + subtitleTextBlock.RenderTransform = new RotateTransform + { + Angle = vertical == "rl" ? 90 : -90 + }; + subtitleTextBlock.RenderTransformOrigin = new Windows.Foundation.Point(0.5, 0.5); + } + else + { + subtitleTextBlock.RenderTransform = null; + } + } + void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) { var gridItem = MediaManager.FullScreenEvents.grid; @@ -91,14 +217,14 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) switch (isFullScreen) { case true: - subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20); + subtitleTextBlock.Margin = new Thickness(0, 0, 0, 20); subtitleTextBlock.FontSize = MediaElement.SubtitleFontSize; Dispatcher.Dispatch(() => { gridItem.Children.Remove(subtitleTextBlock); mauiMediaElement.Children.Add(subtitleTextBlock); }); isFullScreen = false; break; case false: subtitleTextBlock.FontSize = MediaElement.SubtitleFontSize + 8.0; - subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 300); + subtitleTextBlock.Margin = new Thickness(0, 0, 0, 300); Dispatcher.Dispatch(() => { mauiMediaElement.Children.Remove(subtitleTextBlock); gridItem.Children.Add(subtitleTextBlock); }); isFullScreen = true; break; @@ -125,7 +251,7 @@ protected virtual void Dispose(bool disposing) ~SubtitleExtensions() { - Dispose(disposing: false); + Dispose(disposing: false); } public void Dispose() diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleMetadataCue.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleMetadataCue.cs new file mode 100644 index 0000000000..f537c51ad7 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleMetadataCue.cs @@ -0,0 +1,12 @@ +namespace CommunityToolkit.Maui.Core; + +/// +/// The metadata cue +/// +public class SubtitleMetadataCue : SubtitleCue +{ + /// + /// The data of the cue + /// + public string? Data { get; set; } +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleNode.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleNode.cs new file mode 100644 index 0000000000..e9e9776197 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleNode.cs @@ -0,0 +1,27 @@ +namespace CommunityToolkit.Maui.Core; + +/// +/// A subtitle node +/// +public class SubtitleNode +{ + /// + /// The type of the node + /// + public string? NodeType { get; set; } + + /// + /// The text content of the node + /// + public string? TextContent { get; set; } + + /// + /// The attributes of the node + /// + public Dictionary Attributes { get; set; } = []; + + /// + /// The children of the node + /// + public List Children { get; set; } = []; +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs index 95a939a33c..9f0d74f094 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs @@ -33,13 +33,24 @@ public SubtitleParser(IParser parser) /// /// /// - public virtual List ParseContent(string content) + public virtual SubtitleDocument ParseContent(string content) { return IParser.ParseContent(content); } - internal static async Task Content(string subtitleUrl) + internal static async Task Content(string? subtitleUrl) { + ArgumentNullException.ThrowIfNull(subtitleUrl); + if (string.IsNullOrWhiteSpace(subtitleUrl)) + { + throw new ArgumentException("Url is empty."); + } + if (!ValidateUrlWithRegex(subtitleUrl)) + { + throw new UriFormatException("Invalid URL"); + } + + try { return await httpClient.GetStringAsync(subtitleUrl).ConfigureAwait(false); @@ -47,7 +58,7 @@ internal static async Task Content(string subtitleUrl) catch (Exception ex) { System.Diagnostics.Trace.TraceError(ex.Message); - return string.Empty; + throw new FormatException("Invalid URL"); } } @@ -62,7 +73,7 @@ internal static bool ValidateUrlWithRegex(string url) urlRegex.Matches(url); if(!urlRegex.IsMatch(url)) { - throw new ArgumentException("Invalid Subtitle URL"); + return false; } return true; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index c135ff6578..7c96a9b7ae 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -1,81 +1,229 @@ -using System.Globalization; -using System.Text; +using System.Text; using System.Text.RegularExpressions; namespace CommunityToolkit.Maui.Core; -partial class VttParser : IParser +/// +/// Parser for WebVTT (Web Video Text Tracks) format +/// +public partial class VttParser : IParser { - static readonly Regex timecodePatternVTT = VTTRegex(); - - public List ParseContent(string content) + /// + /// Parses the content of a WebVTT file + /// + /// The content of the WebVTT file + /// A SubtitleDocument containing the parsed content + /// Thrown when the file format is invalid + public SubtitleDocument ParseContent(string content) { - var cues = new List(); - if (string.IsNullOrEmpty(content)) + var document = new SubtitleDocument(); + + // Remove UTF-8 BOM if present + if (content.StartsWith('\uFEFF')) + { + content = content[1..]; + } + + var lines = content.Replace("\r\n", "\n").Split('\n'); + if (!lines[0].StartsWith("WEBVTT")) { - return cues; + throw new FormatException("Invalid WebVTT file: Missing WEBVTT header"); } - var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); - SubtitleCue? currentCue = null; - var textBuffer = new StringBuilder(); + document.Header = lines[0]; - foreach (var line in lines) + for (int i = 1; i < lines.Length; i++) { - var match = timecodePatternVTT.Match(line); - if (match.Success) + if (string.IsNullOrWhiteSpace(lines[i])) + { + continue; + } + if (lines[i].StartsWith("STYLE")) { - if (currentCue is not null) + document.StyleBlock = ParseStyleBlock(lines, ref i); + } + else if (TryParseTimestamp(lines[i], out _, out _)) + { + var cue = ParseCue(lines, ref i); + document.Cues.Add(cue); + } + else if (lines[i].StartsWith("NOTE")) + { + // Skip comments + while (i < lines.Length && !string.IsNullOrWhiteSpace(lines[i])) { - currentCue.Text = textBuffer.ToString().Trim(); - cues.Add(currentCue); - textBuffer.Clear(); + i++; } - currentCue = CreateCue(match); } - else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) + else { - textBuffer.AppendLine(line.Trim('-').Trim()); + // Assume it's a metadata cue + var metadataCue = ParseMetadataCue(lines, ref i); + document.Cues.Add(metadataCue); } } + return document; + } - if (currentCue is not null) + static string ParseStyleBlock(string[] lines, ref int i) + { + StringBuilder styleBlock = new(); + i++; // Skip "STYLE" line + while (i < lines.Length && !string.IsNullOrWhiteSpace(lines[i])) { - currentCue.Text = string.Join(" ", textBuffer).TrimEnd('\r', '\n'); - cues.Add(currentCue); + styleBlock.AppendLine(lines[i]); + i++; } - if(cues.Count == 0) + return styleBlock.ToString().Trim(); + } + + static readonly string[] separator = ["-->"]; + + static SubtitleCue ParseCue(string[] lines, ref int i) + { + var cue = new SubtitleCue(); + // Check for cue identifier + if (!TryParseTimestamp(lines[i], out _, out _)) + { + cue.Id = lines[i]; + i++; + } + // Parse timestamp and settings + if (TryParseTimestamp(lines[i], out var startTime, out var endTime)) + { + cue.StartTime = startTime; + cue.EndTime = endTime; + var parts = lines[i].Split(separator, StringSplitOptions.None); + if (parts.Length > 1) + { + ParseCueSettings(parts[1].Trim(), cue); + } + i++; + } + else + { + throw new FormatException($"Invalid cue timing: {lines[i]}"); + } + // Parse cue payload + StringBuilder rawText = new(); + while (i < lines.Length && !string.IsNullOrWhiteSpace(lines[i])) { - throw new FormatException("Invalid VTT format"); + rawText.AppendLine(lines[i]); + i++; } - return cues; + cue.RawText = rawText.ToString().Trim(); + cue.ParsedCueText = ParseCueText(cue.RawText); + return cue; } - static SubtitleCue CreateCue(Match match) + static SubtitleMetadataCue ParseMetadataCue(string[] lines, ref int i) { - var StartTime = ParseTimecode(match.Groups[1].Value); - var EndTime = ParseTimecode(match.Groups[2].Value); - var Text = string.Empty; - if (StartTime > EndTime) + var cue = new SubtitleMetadataCue { - throw new FormatException("Start time cannot be greater than end time."); + Id = lines[i] + }; + i++; + // Check if the next line is a timestamp + if (i < lines.Length && TryParseTimestamp(lines[i], out var startTime, out var endTime)) + { + cue.StartTime = startTime; + cue.EndTime = endTime; + i++; } - return new SubtitleCue + // Parse the metadata content + StringBuilder data = new(); + while (i < lines.Length && !string.IsNullOrWhiteSpace(lines[i])) { - StartTime = StartTime, - EndTime = EndTime, - Text = Text - }; + data.AppendLine(lines[i]); + i++; + } + cue.Data = data.ToString().Trim(); + return cue; } - static TimeSpan ParseTimecode(string timecode) + static void ParseCueSettings(string settingsString, SubtitleCue cue) { - if (TimeSpan.TryParse(timecode, CultureInfo.InvariantCulture, out var result)) + var settings = settingsString.Split(' '); + foreach (var setting in settings) { - return result; + var parts = setting.Split(':'); + if (parts.Length == 2) + { + var key = parts[0].Trim(); + var value = parts[1].Trim(); + switch (key) + { + case "region": cue.RegionId = value; break; + case "vertical": cue.Vertical = value; break; + case "line": cue.Line = value; break; + case "position": cue.Position = value; break; + case "size": cue.Size = value; break; + case "align": cue.Align = value; break; + } + } } - throw new FormatException($"Invalid timecode format: {timecode}"); } - [GeneratedRegex(@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})", RegexOptions.Compiled)] - private static partial Regex VTTRegex(); -} \ No newline at end of file + static SubtitleNode ParseCueText(string text) + { + var root = new SubtitleNode { NodeType = "root" }; + var current = root; + var stack = new Stack(); + stack.Push(root); + + var regex = ParseCueContentRegex(); + var matches = regex.Matches(text); + + for (int i = 0; i < matches.Count; i++) + { + Match match = matches[i]; + if (match.Groups[1].Success) // Opening tag + { + var node = new SubtitleNode { NodeType = match.Groups[1].Value }; + current.Children.Add(node); + stack.Push(node); + current = node; + } + else if (match.Groups[2].Success) // Closing tag + { + if (stack.Count > 1 && stack.Peek().NodeType == match.Groups[2].Value) + { + stack.Pop(); + current = stack.Peek(); + } + } + else if (match.Groups[3].Success) // Text content + { + current.Children.Add(new SubtitleNode { NodeType = "text", TextContent = match.Groups[3].Value }); + } + } + + return root; + } + + static bool TryParseTimestamp(string line, out TimeSpan startTime, out TimeSpan endTime) + { + startTime = TimeSpan.Zero; + endTime = TimeSpan.Zero; + var regex = TryParseTimeStampRegex(); + var match = regex.Match(line); + if (match.Success) + { + startTime = ParseTimeSpan(match.Groups[1].Value, match.Groups[2].Value, match.Groups[3].Value, match.Groups[4].Value); + endTime = ParseTimeSpan(match.Groups[5].Value, match.Groups[6].Value, match.Groups[7].Value, match.Groups[8].Value); + return true; + } + return false; + } + + static TimeSpan ParseTimeSpan(string hours, string minutes, string seconds, string milliseconds) + { + int h = string.IsNullOrEmpty(hours) ? 0 : int.Parse(hours.TrimEnd(':')); + return new TimeSpan(0, h, int.Parse(minutes), int.Parse(seconds), int.Parse(milliseconds)); + } + + [GeneratedRegex(@"(\d{2}:)?(\d{2}):(\d{2})\.(\d{3}) --> (\d{2}:)?(\d{2}):(\d{2})\.(\d{3})")] + private static partial Regex TryParseTimeStampRegex(); + + [GeneratedRegex(@"<([^>/]+)>|]+)>|([^<]+)")] + private static partial Regex ParseCueContentRegex(); +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs index 33580fcbb3..7887971f76 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs @@ -1,7 +1,7 @@ namespace CommunityToolkit.Maui.Core; /// -/// +/// A parser interface /// public interface IParser { @@ -10,5 +10,5 @@ public interface IParser ///
/// /// - public List ParseContent(string content); + public SubtitleDocument ParseContent(string content); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index 451bf982a9..d5bd135a21 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -690,10 +690,10 @@ sealed class MediaManagerDelegate : AVPlayerViewControllerDelegate { public override void WillBeginFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) { - MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.FullScreen, MediaElementScreenState.Default)); + MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.Default, MediaElementScreenState.FullScreen)); } public override void WillEndFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) { - MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.Default, MediaElementScreenState.FullScreen)); + MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.FullScreen, MediaElementScreenState.Default)); } } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs index 5ad688b28c..b62a6d54ee 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs @@ -303,12 +303,12 @@ async Task LoadSubtitles(CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { - System.Diagnostics.Trace.TraceError("SubtitleExtensions is null or SubtitleUrl is null or Player is null"); + System.Diagnostics.Trace.TraceError("SubtitleExtensions is null or SubtitleUrl is null or player is null"); return; } if (Player is null) { - System.Diagnostics.Trace.TraceError("Player is null"); + System.Diagnostics.Trace.TraceError("player is null"); return; } subtitleExtensions ??= new(Player); diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs index 09c8f7e608..108284208a 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs @@ -5,7 +5,7 @@ namespace CommunityToolkit.Maui.UnitTests.Extensions; public class SrtParserTests : BaseTest { - [Fact] + [Fact] public void ParseSrtFile_ValidInput_ReturnsExpectedResult() { // Arrange @@ -22,54 +22,53 @@ This is the first subtitle. var cues = srtParser.ParseContent(srtContent); // Assert - Assert.Equal(TimeSpan.FromSeconds(10), cues[0].StartTime); - Assert.Equal(TimeSpan.FromSeconds(13), cues[0].EndTime); - Assert.Equal("This is the first subtitle.", cues[0].Text); - Assert.Equal(TimeSpan.FromSeconds(15), cues[1].StartTime); - Assert.Equal(TimeSpan.FromSeconds(18), cues[1].EndTime); - Assert.Equal("This is the second subtitle.", cues[1].Text); + Assert.Equal(TimeSpan.FromSeconds(10), cues.Cues[0].StartTime); + Assert.Equal(TimeSpan.FromSeconds(13), cues.Cues[0].EndTime); + Assert.Equal("This is the first subtitle.", cues.Cues[0].RawText); + Assert.Equal(TimeSpan.FromSeconds(15), cues.Cues[1].StartTime); + Assert.Equal(TimeSpan.FromSeconds(18), cues.Cues[1].EndTime); + Assert.Equal("This is the second subtitle.", cues.Cues[1].RawText); } [Fact] - public void ParseSrtFile_EmptyInput_ReturnsEmptyList() - { - // Arrange - var srtContent = string.Empty; + public void ParseSrtFile_EmptyInput_ReturnsEmptyList() + { + // Arrange + var srtContent = string.Empty; - // Act - SrtParser srtParser = new(); - var result = srtParser.ParseContent(srtContent); + // Act + var parser = new SrtParser(); + var result = parser.ParseContent(srtContent); - // Assert - Assert.Empty(result); - } + // Assert + Assert.Empty(result.Cues); + } - [Fact] - public void ParseSrtFile_InvalidFormat_ThrowsException() - { - // Arrange - var srtContent = "Invalid format"; + [Fact] + public void ParseSrtFile_InvalidFormat_ThrowsException() + { + // Arrange + var srtContent = "Invalid format"; - // Act & Assert - SrtParser srtParser = new(); - Assert.Throws(() => srtParser.ParseContent(srtContent)); - } + // Act & Assert + var parser = new SrtParser(); + Assert.Throws(() => parser.ParseContent(srtContent)); + } [Fact] public void ParseSrtFile_InvalidTimestamps_ThrowsException() { // Arrange var content = @"1 - -00:00:00.000 --> 00:00:05.000 +00:00:00,000 --> 00:00:05,000 This is the first subtitle. 2 -00:00:10.000 --> 00:00:05.000 +00:00:10,000 --> 00:00:05,000 This is the second subtitle."; // Act & Assert - SrtParser srtParser = new(); - Assert.Throws(() => srtParser.ParseContent(content)); + var parser = new SrtParser(); + Assert.Throws(() => parser.ParseContent(content)); } } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleParserTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleParserTests.cs new file mode 100644 index 0000000000..5c023b301d --- /dev/null +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleParserTests.cs @@ -0,0 +1,46 @@ +using CommunityToolkit.Maui.Core; +using Xunit; + +namespace CommunityToolkit.Maui.UnitTests.Extensions; +public class SubtitleParserTests +{ + [Fact] + public async Task ParseSubtitles_WithNullUrl_ThrowsArgumentNullException() + { + // Arrange + string? url = null; + + // Act & Assert + await Assert.ThrowsAsync(async () => await SubtitleParser.Content(url)); + } + + [Fact] + public async Task ParseSubtitles_WithEmptyUrl_ThrowsArgumentException() + { + // Arrange + string url = string.Empty; + + // Act & Assert + await Assert.ThrowsAsync(async () => await SubtitleParser.Content(url)); + } + + [Fact] + public async Task ParseSubtitles_WithWhiteSpaceUrl_ThrowsArgumentException() + { + // Arrange + string url = " "; + + // Act & Assert + await Assert.ThrowsAsync(async () => await SubtitleParser.Content(url)); + } + + [Fact] + public async Task ParseSubtitles_WithInvalidUrl_ThrowsUriFormatException() + { + // Arrange + string url = "not a valid url"; + + // Act & Assert + await Assert.ThrowsAsync(async () => await SubtitleParser.Content(url)); + } +} diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs index 704b12c72b..ca762d8534 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using CommunityToolkit.Maui.Core; using Xunit; @@ -5,71 +6,163 @@ namespace CommunityToolkit.Maui.UnitTests.Extensions; public class VttParserTests : BaseTest { - [Fact] - public void ParseVttFile_ValidFile_ReturnsCorrectCues() - { - // Arrange - var content = @"WEBVTT - -00:00:00.000 --> 00:00:05.000 -This is the first cue. - -00:00:05.000 --> 00:00:10.000 -This is the second cue."; - - // Act - VttParser vttParser = new(); - var cues = vttParser.ParseContent(content); - - // Assert - Assert.Equal(2, cues.Count); - Assert.Equal(TimeSpan.Zero, cues[0].StartTime); - Assert.Equal(TimeSpan.FromSeconds(5), cues[0].EndTime); - Assert.Equal("This is the first cue.", cues[0].Text); - Assert.Equal(TimeSpan.FromSeconds(5), cues[1].StartTime); - Assert.Equal(TimeSpan.FromSeconds(10), cues[1].EndTime); - Assert.Equal("This is the second cue.", cues[1].Text); - } - - [Fact] - public void ParseVttFile_EmptyFile_ReturnsEmptyList() - { - // Arrange - var content = string.Empty; - - // Act - VttParser vttParser = new(); - var cues = vttParser.ParseContent(content); - - // Assert - Assert.Empty(cues); - } + [Fact] + public void RegexImportTest() + { + // This test verifies that the Regex namespace is available + // and can be used without throwing exceptions + Assert.NotNull(typeof(Regex)); + } + + [Fact] + public void RegexMatchTest() + { + // Test a simple regex pattern to ensure Regex functionality + string pattern = @"\d+"; + string input = "Test 123"; + var match = Regex.Match(input, pattern); + Assert.True(match.Success); + Assert.Equal("123", match.Value); + } + + [Fact] + public void RegexReplaceTest() + { + // Test Regex.Replace functionality + string pattern = @"\s+"; + string input = "Hello World"; + string result = Regex.Replace(input, pattern, " "); + Assert.Equal("Hello World", result); + } + + [Fact] + public void RegexSplitTest() + { + // Test Regex.Split functionality + string pattern = @",\s*"; + string input = "apple, banana,cherry, date"; + string[] result = Regex.Split(input, pattern); + Assert.Equal(4, result.Length); + Assert.Equal("apple", result[0]); + Assert.Equal("date", result[3]); + } + + readonly VttParser parser = new(); + + [Fact] + public void ParseContent_ValidVTTFile_ReturnsCorrectSubtitleDocument() + { + var content = @"WEBVTT + +00:00:01.000 --> 00:00:04.000 +This is the first subtitle + +00:00:05.000 --> 00:00:08.000 +This is the second subtitle"; + + var result = parser.ParseContent(content); + + Assert.Equal("WEBVTT", result.Header); + Assert.Equal(2, result.Cues.Count); + Assert.Equal(TimeSpan.FromSeconds(1), result.Cues[0].StartTime); + Assert.Equal(TimeSpan.FromSeconds(4), result.Cues[0].EndTime); + Assert.Equal("This is the first subtitle", result.Cues[0].RawText); + } + + [Fact] + public void ParseContent_VTTFileWithCueSettings_ParsesCueSettingsCorrectly() + { + var content = @"WEBVTT + +00:00:01.000 --> 00:00:04.000 vertical:rl line:0 position:50% align:start +This is a subtitle with cue settings"; + + var result = parser.ParseContent(content); + + Assert.Single(result.Cues); + var cue = result.Cues[0]; + Assert.Equal("rl", cue.Vertical); + Assert.Equal("0", cue.Line); + Assert.Equal("50%", cue.Position); + Assert.Equal("start", cue.Align); + } [Fact] - public void ParseVttFile_InvalidFormat_ThrowsException() + public void ParseContent_VTTFileWithStyleAndMetadataCue_ParsesCorrectly() { - // Arrange - var vttContent = "Invalid format"; + var content = @"WEBVTT + +NOTE This is a comment + +STYLE +::cue { + background-color: yellow; + color: black; +} + +00:00:01.000 --> 00:00:04.000 +This is a regular subtitle + +MetadataCue +This is metadata content"; + + var result = parser.ParseContent(content); + + Assert.Equal(2, result.Cues.Count); + Assert.IsType(result.Cues[0]); + Assert.IsType(result.Cues[1]); + + Assert.NotNull(result.StyleBlock); + Assert.Contains("background-color: yellow;", result.StyleBlock); + Assert.Contains("color: black;", result.StyleBlock); - // Act & Assert - VttParser vttParser = new(); - Assert.Throws(() => vttParser.ParseContent(vttContent)); + var metadataCue = result.Cues[1] as SubtitleMetadataCue; + Assert.NotNull(metadataCue); + Assert.Equal("MetadataCue", metadataCue.Id); + Assert.Equal("This is metadata content", metadataCue.Data); } [Fact] - public void ParseVttFile_InvalidTimestamps_ThrowsException() - { - // Arrange - var content = @"WEBVTT + public void ParseContent_VTTFileWithFormattedText_ParsesCueTextCorrectly() + { + var content = @"WEBVTT + +00:00:01.000 --> 00:00:04.000 +This is bold and italic text"; + + var result = parser.ParseContent(content); + + Assert.Single(result.Cues); + var cue = result.Cues[0]; + Assert.NotNull(cue); + var parsedText = cue.ParsedCueText; + Assert.NotNull(parsedText); + Assert.Equal("root", parsedText.NodeType); + Assert.Equal(5, parsedText.Children.Count); + + Assert.Equal("text", parsedText.Children[0].NodeType); + Assert.Equal("This is ", parsedText.Children[0].TextContent); + + Assert.Equal("b", parsedText.Children[1].NodeType); + Assert.Single(parsedText.Children[1].Children); + Assert.Equal("bold", parsedText.Children[1].Children[0].TextContent); -00:00:00.000 --> 00:00:05.000 -This is the first cue. + Assert.Equal("text", parsedText.Children[2].NodeType); + Assert.Equal(" and ", parsedText.Children[2].TextContent); -00:00:10.000 --> 00:00:05.000 -This is the second cue."; + Assert.Equal("i", parsedText.Children[3].NodeType); + Assert.Single(parsedText.Children[3].Children); + Assert.Equal("italic", parsedText.Children[3].Children[0].TextContent); - // Act & Assert - VttParser vttParser = new(); - Assert.Throws(() => vttParser.ParseContent(content)); - } + Assert.Equal("text", parsedText.Children[4].NodeType); + Assert.Equal(" text", parsedText.Children[4].TextContent); + } + + [Fact] + public void ParseContent_InvalidVTTFile_ThrowsFormatException() + { + var content = "This is not a valid VTT file"; + + Assert.Throws(() => parser.ParseContent(content)); + } } \ No newline at end of file From 79ae3c53a41b4a4d4db455369742dc338e544ad4 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Wed, 24 Jul 2024 02:31:55 -0700 Subject: [PATCH 75/98] Revert "Enhance subtitle parsing and display capabilities" This reverts commit 2791bd6ce1c456ed16fc4c4fcfc5378507156380. reverting --- .../MediaElement/MediaElementPage.xaml.cs | 127 ++++----- .../Extensions/SrtParser.cs | 132 ++++------ .../Extensions/SubtitleCue.cs | 58 +---- .../Extensions/SubtitleDocument.cs | 22 -- .../Extensions/SubtitleExtensions.android.cs | 161 ++---------- .../Extensions/SubtitleExtensions.macios.cs | 143 ++--------- .../Extensions/SubtitleExtensions.shared.cs | 12 +- .../Extensions/SubtitleExtensions.windows.cs | 162 ++---------- .../Extensions/SubtitleMetadataCue.cs | 12 - .../Extensions/SubtitleNode.cs | 27 -- .../Extensions/SubtitleParser.cs | 19 +- .../Extensions/VttParser.cs | 240 ++++-------------- .../Interfaces/IParser.cs | 4 +- .../Views/MediaManager.macios.cs | 4 +- .../Views/MediaManager.windows.cs | 4 +- .../Extensions/SrtParserTests.cs | 61 ++--- .../Extensions/SubtitleParserTests.cs | 46 ---- .../Extensions/VttParserTests.cs | 207 +++++---------- 18 files changed, 300 insertions(+), 1141 deletions(-) delete mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleDocument.cs delete mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleMetadataCue.cs delete mode 100644 src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleNode.cs delete mode 100644 src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleParserTests.cs 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 17b2e8fbdb..e7b2c622e1 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -285,123 +285,84 @@ void DisplayPopup(object sender, EventArgs e) } /// -/// Parser for SubRip (SRT) subtitle files +/// Sample implementation of an SRT parser. /// -public partial class SrtParser : IParser +partial class SrtParser : IParser { - static readonly Regex timeCodeRegex = TimeCodeRegex(); - static readonly string[] separator = { "\r\n", "\r", "\n" }; - - /// - /// Parses the content of an SRT file and returns a SubtitleDocument - /// - /// The content of the SRT file - /// A SubtitleDocument containing the parsed subtitles - public SubtitleDocument ParseContent(string content) + static readonly Regex timecodePatternSRT = SRTRegex(); + + public List ParseContent(string content) { - if (string.IsNullOrWhiteSpace(content)) + var cues = new List(); + if (string.IsNullOrEmpty(content)) { - return new SubtitleDocument(); + return cues; } - var document = new SubtitleDocument(); - var lines = content.Split(separator, StringSplitOptions.None); + var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); SubtitleCue? currentCue = null; - var cueText = new List(); + var textBuffer = new StringBuilder(); foreach (var line in lines) { - var trimmedLine = line.Trim(); - - if (string.IsNullOrWhiteSpace(trimmedLine)) + if (int.TryParse(line, out _)) { - if (currentCue is not null) - { - FinalizeCue(currentCue, cueText, document); - currentCue = null; - cueText.Clear(); - } continue; } - if (int.TryParse(trimmedLine, out _)) + var match = timecodePatternSRT.Match(line); + if (match.Success) { if (currentCue is not null) { - FinalizeCue(currentCue, cueText, document); - cueText.Clear(); + currentCue.Text = textBuffer.ToString().TrimEnd('\r', '\n'); + cues.Add(currentCue); + textBuffer.Clear(); } - currentCue = new SubtitleCue { Id = trimmedLine }; - } - else if (currentCue is not null && timeCodeRegex.IsMatch(trimmedLine)) - { - var match = timeCodeRegex.Match(trimmedLine); - - if (!match.Success) - { - throw new FormatException("Invalid timecode format."); - } - - currentCue.StartTime = ParseTimeCode(match.Groups[1].Value, match.Groups[2].Value, match.Groups[3].Value, match.Groups[4].Value); - currentCue.EndTime = ParseTimeCode(match.Groups[5].Value, match.Groups[6].Value, match.Groups[7].Value, match.Groups[8].Value); - - if (currentCue.EndTime <= currentCue.StartTime) - { - throw new FormatException("End time must be greater than start time."); - } - } - else if (currentCue is not null) - { - cueText.Add(trimmedLine); + currentCue = CreateCue(match); } - else + else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) { - throw new FormatException("Invalid subtitle format."); + textBuffer.AppendLine(line.Trim().TrimEnd('\r', '\n')); } } - // Add the last cue if there's any - if (currentCue is not null && cueText.Count > 0) + if (currentCue is not null) { - FinalizeCue(currentCue, cueText, document); + currentCue.Text = textBuffer.ToString().TrimEnd('\r', '\n'); + cues.Add(currentCue); } - - return document; - } - - static void FinalizeCue(SubtitleCue cue, List cueText, SubtitleDocument document) - { - cue.RawText = string.Join("\n", cueText); - cue.ParsedCueText = ParseCueText(cue.RawText); - document.Cues.Add(cue); + if (cues.Count == 0) + { + throw new FormatException("Invalid SRT format"); + } + return cues; } - static SubtitleNode ParseCueText(string rawText) + static SubtitleCue CreateCue(Match match) { - var root = new SubtitleNode { NodeType = "root" }; - var lines = rawText.Split('\n'); - - foreach (var line in lines) + var StartTime = ParseTimecode(match.Groups[1].Value); + var EndTime = ParseTimecode(match.Groups[2].Value); + var Text = string.Empty; + if (StartTime > EndTime) { - var textNode = new SubtitleNode - { - NodeType = "text", - TextContent = line.TrimStart('-', ' ') - }; - - root.Children.Add(textNode); + throw new FormatException("Start time cannot be greater than end time."); } - - return root; + return new SubtitleCue + { + StartTime = StartTime, + EndTime = EndTime, + Text = Text + }; } - static TimeSpan ParseTimeCode(string hours, string minutes, string seconds, string milliseconds) + static TimeSpan ParseTimecode(string timecode) { - return new TimeSpan(0, int.Parse(hours), int.Parse(minutes), int.Parse(seconds), int.Parse(milliseconds)); + return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); } - [GeneratedRegex(@"(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})", RegexOptions.Compiled)] - private static partial Regex TimeCodeRegex(); -} + [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})", RegexOptions.Compiled)] + private static partial Regex SRTRegex(); +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs index e0562843ee..e7794133f8 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -1,125 +1,83 @@ -using System.Text.RegularExpressions; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; namespace CommunityToolkit.Maui.Core; -/// -/// Parser for SubRip (SRT) subtitle files -/// -public partial class SrtParser : IParser +partial class SrtParser : IParser { - static readonly Regex timeCodeRegex = TimeCodeRegex(); - static readonly string[] separator = { "\r\n", "\r", "\n" }; + static readonly Regex timecodePatternSRT = SRTRegex(); - /// - /// Parses the content of an SRT file and returns a SubtitleDocument - /// - /// The content of the SRT file - /// A SubtitleDocument containing the parsed subtitles - public SubtitleDocument ParseContent(string content) + public List ParseContent(string content) { - if (string.IsNullOrWhiteSpace(content)) + var cues = new List(); + if (string.IsNullOrEmpty(content)) { - return new SubtitleDocument(); + return cues; } - - var document = new SubtitleDocument(); - var lines = content.Split(separator, StringSplitOptions.None); - + + var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); SubtitleCue? currentCue = null; - var cueText = new List(); + var textBuffer = new StringBuilder(); foreach (var line in lines) { - var trimmedLine = line.Trim(); - - if (string.IsNullOrWhiteSpace(trimmedLine)) + if (int.TryParse(line, out _)) { - if (currentCue is not null) - { - FinalizeCue(currentCue, cueText, document); - currentCue = null; - cueText.Clear(); - } continue; } - if (int.TryParse(trimmedLine, out _)) + var match = timecodePatternSRT.Match(line); + if (match.Success) { if (currentCue is not null) { - FinalizeCue(currentCue, cueText, document); - cueText.Clear(); - } - - currentCue = new SubtitleCue { Id = trimmedLine }; - } - else if (currentCue is not null && timeCodeRegex.IsMatch(trimmedLine)) - { - var match = timeCodeRegex.Match(trimmedLine); - - if (!match.Success) - { - throw new FormatException("Invalid timecode format."); - } - - currentCue.StartTime = ParseTimeCode(match.Groups[1].Value, match.Groups[2].Value, match.Groups[3].Value, match.Groups[4].Value); - currentCue.EndTime = ParseTimeCode(match.Groups[5].Value, match.Groups[6].Value, match.Groups[7].Value, match.Groups[8].Value); - - if (currentCue.EndTime <= currentCue.StartTime) - { - throw new FormatException("End time must be greater than start time."); + currentCue.Text = textBuffer.ToString().TrimEnd('\r', '\n'); + cues.Add(currentCue); + textBuffer.Clear(); } + currentCue = CreateCue(match); } - else if (currentCue is not null) - { - cueText.Add(trimmedLine); - } - else + else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) { - throw new FormatException("Invalid subtitle format."); + textBuffer.AppendLine(line.Trim().TrimEnd('\r', '\n')); } } - // Add the last cue if there's any - if (currentCue is not null && cueText.Count > 0) + if (currentCue is not null) { - FinalizeCue(currentCue, cueText, document); + currentCue.Text = textBuffer.ToString().TrimEnd('\r', '\n'); + cues.Add(currentCue); } - - return document; - } - - static void FinalizeCue(SubtitleCue cue, List cueText, SubtitleDocument document) - { - cue.RawText = string.Join("\n", cueText); - cue.ParsedCueText = ParseCueText(cue.RawText); - document.Cues.Add(cue); + if(cues.Count == 0) + { + throw new FormatException("Invalid SRT format"); + } + return cues; } - static SubtitleNode ParseCueText(string rawText) + static SubtitleCue CreateCue(Match match) { - var root = new SubtitleNode { NodeType = "root" }; - var lines = rawText.Split('\n'); - - foreach (var line in lines) + var StartTime = ParseTimecode(match.Groups[1].Value); + var EndTime = ParseTimecode(match.Groups[2].Value); + var Text = string.Empty; + if (StartTime > EndTime) { - var textNode = new SubtitleNode - { - NodeType = "text", - TextContent = line.TrimStart('-', ' ') - }; - - root.Children.Add(textNode); + throw new FormatException("Start time cannot be greater than end time."); } - - return root; + return new SubtitleCue + { + StartTime = StartTime, + EndTime = EndTime, + Text = Text + }; } - static TimeSpan ParseTimeCode(string hours, string minutes, string seconds, string milliseconds) + static TimeSpan ParseTimecode(string timecode) { - return new TimeSpan(0, int.Parse(hours), int.Parse(minutes), int.Parse(seconds), int.Parse(milliseconds)); + return TimeSpan.ParseExact(timecode, @"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture); } - [GeneratedRegex(@"(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})", RegexOptions.Compiled)] - private static partial Regex TimeCodeRegex(); + [GeneratedRegex(@"(\d{2}\:\d{2}\:\d{2}\,\d{3}) --> (\d{2}\:\d{2}\:\d{2}\,\d{3})", RegexOptions.Compiled)] + private static partial Regex SRTRegex(); } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs index a955a76346..ebd1efc4f2 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs @@ -1,62 +1,26 @@ namespace CommunityToolkit.Maui.Core; /// -/// A subtitle cue +/// A class that represents a subtitle cue. /// public class SubtitleCue { /// - /// The ID of the cue + /// The number of the cue. /// - public string Id { get; set; } = string.Empty; - + public int Number { get; set; } /// - /// The start time of the cue + /// The start time of the cue. /// - public TimeSpan StartTime { get; set; } - - /// - /// The end time of the cue - /// - public TimeSpan EndTime { get; set; } - - /// - /// - /// - public string RegionId { get; set; } = string.Empty; - - /// - /// The Vertical setting of the cue - /// - public string Vertical { get; set; } = string.Empty; - - /// - /// The Line setting of the cue - /// - public string Line { get; set; } = string.Empty; - - /// - /// The Position setting of the cue - /// - public string Position { get; set; } = string.Empty; - - /// - /// The Size setting of the cue - /// - public string Size { get; set; } = "100%"; - - /// - /// The Align setting of the cue - /// - public string Align { get; set; } = "middle"; - + public TimeSpan? StartTime { get; set; } + /// - /// The parsed cue text + /// The end time of the cue. /// - public SubtitleNode? ParsedCueText { get; set; } - + public TimeSpan? EndTime { get; set; } + /// - /// The raw text of the cue + /// The text of the cue. /// - public string RawText { get; set; } = string.Empty; + public string? Text { get; set; } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleDocument.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleDocument.cs deleted file mode 100644 index 755a74b468..0000000000 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleDocument.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace CommunityToolkit.Maui.Core; - -/// -/// A subtitle document -/// -public partial class SubtitleDocument -{ - /// - /// The header of the document - /// - public string Header { get; set; } = string.Empty; - - /// - /// The cues in the document - /// - public List Cues { get; set; } = []; - - /// - /// The style block of the document - /// - public string? StyleBlock { get; set; } -} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index b310630cbd..d94dca0751 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -1,14 +1,10 @@ using Android.Graphics; -using Android.Text; -using Android.Text.Style; using Android.Views; using Android.Widget; using Com.Google.Android.Exoplayer2.UI; -using CommunityToolkit.Maui.Core; using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; using static Android.Views.ViewGroup; -using Color = Android.Graphics.Color; using CurrentPlatformActivity = CommunityToolkit.Maui.Core.Views.MauiMediaElement.CurrentPlatformContext; namespace CommunityToolkit.Maui.Extensions; @@ -19,7 +15,6 @@ partial class SubtitleExtensions : Java.Lang.Object readonly RelativeLayout.LayoutParams? subtitleLayout; readonly StyledPlayerView styledPlayerView; TextView? subtitleView; - public List? Cues { get; set; } public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatcher) { @@ -37,15 +32,11 @@ public void StartSubtitleDisplay() { ArgumentNullException.ThrowIfNull(subtitleView); ArgumentNullException.ThrowIfNull(Cues); - ArgumentNullException.ThrowIfNull(MediaElement); - if (MediaElement.SubtitleUrl is null) + if(Cues.Count == 0 || string.IsNullOrEmpty(MediaElement?.SubtitleUrl)) { return; } - - Cues = Document?.Cues; - - if (styledPlayerView.Parent is not ViewGroup parent) + if(styledPlayerView.Parent is not ViewGroup parent) { System.Diagnostics.Trace.TraceError("StyledPlayerView parent is not a ViewGroup"); return; @@ -60,7 +51,7 @@ public void StopSubtitleDisplay() { ArgumentNullException.ThrowIfNull(Cues); Cues.Clear(); - if (Timer is not null) + if(Timer is not null) { Timer.Stop(); Timer.Elapsed -= UpdateSubtitle; @@ -90,7 +81,11 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { if (cue is not null) { - DisplayCue(cue); + Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).Android) ?? Typeface.Default; + subtitleView.SetTypeface(typeface, TypefaceStyle.Normal); + subtitleView.Text = cue.Text; + subtitleView.TextSize = (float)MediaElement.SubtitleFontSize; + subtitleView.Visibility = ViewStates.Visible; } else { @@ -100,170 +95,46 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) }); } - void DisplayCue(SubtitleCue cue) - { - ArgumentNullException.ThrowIfNull(MediaElement); - ArgumentNullException.ThrowIfNull(subtitleView); - if (cue.ParsedCueText is null) - { - return; - } - - var spannableString = new SpannableStringBuilder(); - ProcessCueText(spannableString, cue.ParsedCueText); - subtitleView.TextFormatted = spannableString; - - ApplyStyles(cue); - subtitleView.Visibility = ViewStates.Visible; - } - - static void ProcessCueText(SpannableStringBuilder spannableString, SubtitleNode node) - { - foreach (var child in node.Children) - { - if (child.NodeType == "text") - { - string? text = child.TextContent; - if (!string.IsNullOrEmpty(text)) - { - int start = spannableString.Length(); - spannableString.Append(text); - int end = spannableString.Length(); - ApplyStyleToSpan(spannableString, child.NodeType, start, end); - } - } - else if (child.NodeType is not null) - { - int start = spannableString.Length(); - ProcessCueText(spannableString, child); - int end = spannableString.Length(); - ApplyStyleToSpan(spannableString, child.NodeType, start, end); - } - } - } - - static void ApplyStyleToSpan(SpannableStringBuilder spannableString, string nodeType, int start, int end) - { - switch (nodeType.ToLower()) - { - case "b": - spannableString.SetSpan(new StyleSpan(TypefaceStyle.Bold), start, end, SpanTypes.ExclusiveExclusive); - break; - case "i": - spannableString.SetSpan(new StyleSpan(TypefaceStyle.Italic), start, end, SpanTypes.ExclusiveExclusive); - break; - case "u": - spannableString.SetSpan(new UnderlineSpan(), start, end, SpanTypes.ExclusiveExclusive); - break; - case "v": - spannableString.SetSpan(new ForegroundColorSpan(Color.Yellow), start, end, SpanTypes.ExclusiveExclusive); - break; - } - } - - void ApplyStyles(SubtitleCue cue) - { - ArgumentNullException.ThrowIfNull(MediaElement); - ArgumentNullException.ThrowIfNull(subtitleView); - ArgumentNullException.ThrowIfNull(subtitleLayout); - - subtitleView.Gravity = GetTextAlignment(cue.Align); - - Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).Android) ?? Typeface.Default; - subtitleView.SetTypeface(typeface, TypefaceStyle.Normal); - subtitleView.TextSize = (float)MediaElement.SubtitleFontSize; - - if (!string.IsNullOrEmpty(cue.Position)) - { - var parts = cue.Position.Split(','); - if (parts.Length > 0 && float.TryParse(parts[0].TrimEnd('%'), out float horizontalPosition)) - { - subtitleLayout.LeftMargin = (int)(horizontalPosition * styledPlayerView.Width / 100); - } - } - - if (!string.IsNullOrEmpty(cue.Line) && float.TryParse(cue.Line.TrimEnd('%'), out float verticalPosition)) - { - subtitleLayout.BottomMargin = (int)(verticalPosition * styledPlayerView.Height / 100); - } - - subtitleView.LayoutParameters = subtitleLayout; - - if (cue.Vertical is not null) - { - ApplyVerticalWriting(cue.Vertical); - } - } - - static GravityFlags GetTextAlignment(string align) - { - return align?.ToLower() switch - { - "left" => GravityFlags.Left, - "right" => GravityFlags.Right, - "center" => GravityFlags.Center, - _ => GravityFlags.Center, - }; - } - - void ApplyVerticalWriting(string vertical) - { - ArgumentNullException.ThrowIfNull(subtitleView); - if (vertical == "rl" || vertical == "lr") - { - subtitleView.Rotation = vertical == "rl" ? 90 : -90; - } - else - { - subtitleView.Rotation = 0; - } - } - void InitializeTextBlock() { - subtitleView = new TextView(CurrentPlatformActivity.CurrentActivity.ApplicationContext) + subtitleView = new(CurrentPlatformActivity.CurrentActivity.ApplicationContext) { Text = string.Empty, HorizontalScrollBarEnabled = false, VerticalScrollBarEnabled = false, - Gravity = GravityFlags.Center, - Visibility = ViewStates.Gone, + TextAlignment = Android.Views.TextAlignment.Center, + Visibility = Android.Views.ViewStates.Gone, LayoutParameters = subtitleLayout }; - subtitleView.SetBackgroundColor(Color.Argb(150, 0, 0, 0)); - subtitleView.SetTextColor(Color.White); - subtitleView.SetPadding(10, 10, 10, 20); + subtitleView.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); + subtitleView.SetTextColor(Android.Graphics.Color.White); + subtitleView.SetPaddingRelative(10, 10, 10, 20); } void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) { ArgumentNullException.ThrowIfNull(subtitleView); ArgumentNullException.ThrowIfNull(MediaElement); - ArgumentNullException.ThrowIfNull(subtitleLayout); + + // If the subtitle URL is empty do nothing if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { return; } - if (CurrentPlatformActivity.CurrentViewGroup.Parent is not ViewGroup parent) { return; } - switch (e.NewState == MediaElementScreenState.FullScreen) { case true: CurrentPlatformActivity.CurrentViewGroup.RemoveView(subtitleView); InitializeTextBlock(); - subtitleView.TextSize = (float)MediaElement.SubtitleFontSize + 8.0f; - subtitleLayout.BottomMargin = 300; parent.AddView(subtitleView); break; case false: parent.RemoveView(subtitleView); InitializeTextBlock(); - subtitleView.TextSize = (float)MediaElement.SubtitleFontSize; - subtitleLayout.BottomMargin = 20; CurrentPlatformActivity.CurrentViewGroup.AddView(subtitleView); break; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 150c183592..84168b2523 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -1,5 +1,4 @@ -using CommunityToolkit.Maui.Core; -using CommunityToolkit.Maui.Core.Views; +using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; using CoreFoundation; using CoreGraphics; @@ -13,25 +12,24 @@ partial class SubtitleExtensions : UIViewController { readonly PlatformMediaElement player; readonly UIViewController playerViewController; - UILabel subtitleLabel; + readonly UILabel subtitleLabel; static readonly UIColor subtitleBackgroundColor = UIColor.FromRGBA(0, 0, 0, 128); static readonly UIColor clearBackgroundColor = UIColor.FromRGBA(0, 0, 0, 0); NSObject? playerObserver; UIViewController? viewController; - List? cues; public SubtitleExtensions(PlatformMediaElement player, UIViewController playerViewController) { this.playerViewController = playerViewController; this.player = player; - cues = []; + Cues = []; subtitleLabel = new UILabel { Frame = CalculateSubtitleFrame(playerViewController), TextColor = UIColor.White, TextAlignment = UITextAlignment.Center, Font = UIFont.SystemFontOfSize(12), - Text = string.Empty, + Text = "", Lines = 0, LineBreakMode = UILineBreakMode.WordWrap, AutoresizingMask = UIViewAutoresizing.FlexibleWidth @@ -44,7 +42,6 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi public void StartSubtitleDisplay() { - cues = Document?.Cues; ArgumentNullException.ThrowIfNull(subtitleLabel); DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); playerObserver = player?.AddPeriodicTimeObserver(CMTime.FromSeconds(1, 1), null, (time) => @@ -57,16 +54,15 @@ public void StartSubtitleDisplay() public void StopSubtitleDisplay() { - ArgumentNullException.ThrowIfNull(cues); + ArgumentNullException.ThrowIfNull(Cues); subtitleLabel.Text = string.Empty; - cues.Clear(); + Cues.Clear(); subtitleLabel.BackgroundColor = clearBackgroundColor; DispatchQueue.MainQueue.DispatchAsync(() => subtitleLabel.RemoveFromSuperview()); } - void UpdateSubtitle(TimeSpan currentPlaybackTime) { - ArgumentNullException.ThrowIfNull(cues); + ArgumentNullException.ThrowIfNull(Cues); ArgumentNullException.ThrowIfNull(subtitleLabel); ArgumentNullException.ThrowIfNull(MediaElement); if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) @@ -74,128 +70,21 @@ void UpdateSubtitle(TimeSpan currentPlaybackTime) return; } - var currentCue = cues.Find(cue => currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime); - - if (currentCue is not null) - { - DisplayCue(currentCue); - } - else - { - subtitleLabel.Text = string.Empty; - subtitleLabel.BackgroundColor = clearBackgroundColor; - } - } - - void DisplayCue(SubtitleCue cue) - { - if (cue.ParsedCueText is null) - { - return; - } - - var attributedString = new NSMutableAttributedString(); - ProcessCueText(attributedString, cue.ParsedCueText); - subtitleLabel.AttributedText = attributedString; - - ApplyStyles(cue); - subtitleLabel.BackgroundColor = subtitleBackgroundColor; - } - - void ProcessCueText(NSMutableAttributedString attributedString, SubtitleNode node) - { - foreach (var child in node.Children) + foreach (var cue in Cues) { - if (child.NodeType == "text") + if (currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime) { - string? text = child.TextContent; - if (!string.IsNullOrEmpty(text)) - { - var range = new NSRange(attributedString.Length, text.Length); - attributedString.Append(new NSAttributedString(text)); - ApplyStyleToRange(attributedString, child.NodeType, range); - } - } - else if (child.NodeType is not null) - { - var startLength = attributedString.Length; - ProcessCueText(attributedString, child); - var range = new NSRange(startLength, attributedString.Length - startLength); - ApplyStyleToRange(attributedString, child.NodeType, range); - } - } - } - - void ApplyStyleToRange(NSMutableAttributedString attributedString, string nodeType, NSRange range) - { - ArgumentNullException.ThrowIfNull(MediaElement); - switch (nodeType.ToLower()) - { - case "b": - attributedString.AddAttribute(UIStringAttributeKey.Font, UIFont.BoldSystemFontOfSize((float)MediaElement.SubtitleFontSize), range); - break; - case "i": - attributedString.AddAttribute(UIStringAttributeKey.Font, UIFont.ItalicSystemFontOfSize((float)MediaElement.SubtitleFontSize), range); - break; - case "u": - attributedString.AddAttribute(UIStringAttributeKey.UnderlineStyle, NSNumber.FromInt32((int)NSUnderlineStyle.Single), range); + subtitleLabel.Text = cue.Text; + subtitleLabel.Font = UIFont.FromName(name: new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).MacIOS, size: (float)MediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize(16); + subtitleLabel.BackgroundColor = subtitleBackgroundColor; break; - case "v": - attributedString.AddAttribute(UIStringAttributeKey.ForegroundColor, UIColor.Yellow, range); - break; - } - } - - void ApplyStyles(SubtitleCue cue) - { - ArgumentNullException.ThrowIfNull(MediaElement); - ArgumentNullException.ThrowIfNull(playerViewController.View); - subtitleLabel.TextAlignment = GetTextAlignment(cue.Align); - - var font = UIFont.FromName(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).MacIOS, (float)MediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize((float)MediaElement.SubtitleFontSize); - subtitleLabel.Font = font; - - if (!string.IsNullOrEmpty(cue.Position)) - { - var parts = cue.Position.Split(','); - if (parts.Length > 0 && float.TryParse(parts[0].TrimEnd('%'), out float horizontalPosition)) + } + else { - subtitleLabel.Frame = new CGRect(horizontalPosition * playerViewController.View.Bounds.Width / 100, subtitleLabel.Frame.Y, subtitleLabel.Frame.Width, subtitleLabel.Frame.Height); + subtitleLabel.Text = ""; + subtitleLabel.BackgroundColor = clearBackgroundColor; } } - - if (!string.IsNullOrEmpty(cue.Line) && float.TryParse(cue.Line.TrimEnd('%'), out float verticalPosition)) - { - subtitleLabel.Frame = new CGRect(subtitleLabel.Frame.X, verticalPosition * playerViewController.View.Bounds.Height / 100, subtitleLabel.Frame.Width, subtitleLabel.Frame.Height); - } - - if (cue.Vertical is not null) - { - ApplyVerticalWriting(cue.Vertical); - } - } - - static UITextAlignment GetTextAlignment(string align) - { - return align?.ToLower() switch - { - "left" => UITextAlignment.Left, - "right" => UITextAlignment.Right, - "center" => UITextAlignment.Center, - _ => UITextAlignment.Center, - }; - } - - void ApplyVerticalWriting(string vertical) - { - if (vertical == "rl" || vertical == "lr") - { - subtitleLabel.Transform = CGAffineTransform.MakeRotation(vertical == "rl" ? (float)Math.PI / 2 : -(float)Math.PI / 2); - } - else - { - subtitleLabel.Transform = CGAffineTransform.MakeIdentity(); - } } static CGRect CalculateSubtitleFrame(UIViewController uIViewController) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs index 5ef1926c41..b0207ecbdc 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs @@ -5,11 +5,11 @@ namespace CommunityToolkit.Maui.Extensions; partial class SubtitleExtensions { public IMediaElement? MediaElement; - public SubtitleDocument? Document; + public List? Cues; public System.Timers.Timer? Timer; public async Task LoadSubtitles(IMediaElement mediaElement) { - Document ??= new(); + Cues ??= []; this.MediaElement = mediaElement; if(MediaElement is null) { @@ -23,26 +23,26 @@ public async Task LoadSubtitles(IMediaElement mediaElement) { return; } - SubtitleParser parser; var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); + try { if (mediaElement.CustomSubtitleParser is not null) { parser = new(mediaElement.CustomSubtitleParser); - Document = parser.ParseContent(content); + Cues = parser.ParseContent(content); return; } switch (mediaElement.SubtitleUrl) { case var url when url.EndsWith("srt"): parser = new(new SrtParser()); - Document = parser.ParseContent(content); + Cues = parser.ParseContent(content); break; case var url when url.EndsWith("vtt"): parser = new(new VttParser()); - Document = parser.ParseContent(content); + Cues = parser.ParseContent(content); break; default: System.Diagnostics.Trace.TraceError("Unsupported Subtitle file."); diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index aea9e25c08..95dbca1d97 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -1,19 +1,7 @@ -using CommunityToolkit.Maui.Core; -using CommunityToolkit.Maui.Core.Views; +using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Documents; using Microsoft.UI.Xaml.Media; -using Windows.UI.Text; using Grid = Microsoft.Maui.Controls.Grid; -using HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment; -using SolidColorBrush = Microsoft.UI.Xaml.Media.SolidColorBrush; -using Span = Microsoft.UI.Xaml.Documents.Span; -using TextAlignment = Microsoft.UI.Xaml.TextAlignment; -using Thickness = Microsoft.UI.Xaml.Thickness; -using VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment; -using Visibility = Microsoft.UI.Xaml.Visibility; namespace CommunityToolkit.Maui.Extensions; @@ -21,24 +9,22 @@ partial class SubtitleExtensions : Grid, IDisposable { bool disposedValue; bool isFullScreen = false; - readonly TextBlock subtitleTextBlock; + readonly Microsoft.UI.Xaml.Controls.TextBlock subtitleTextBlock; readonly MauiMediaElement? mauiMediaElement; - public List? Cues { get; set; } - public SubtitleExtensions(MediaPlayerElement player) + public SubtitleExtensions(Microsoft.UI.Xaml.Controls.MediaPlayerElement player) { mauiMediaElement = player?.Parent as MauiMediaElement; MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; - - subtitleTextBlock = new TextBlock + subtitleTextBlock = new() { Text = string.Empty, - Margin = new Thickness(0, 0, 0, 20), - Visibility = Visibility.Collapsed, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Bottom, - Foreground = new SolidColorBrush(Microsoft.UI.Colors.White), - TextWrapping = TextWrapping.Wrap, + Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20), + Visibility = Microsoft.UI.Xaml.Visibility.Collapsed, + HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, + VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Bottom, + Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White), + TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap, }; } @@ -48,7 +34,6 @@ public void StartSubtitleDisplay() Dispatcher.Dispatch(() => mauiMediaElement?.Children.Add(subtitleTextBlock)); Timer.Elapsed += UpdateSubtitle; Timer.Start(); - Cues = Document?.Cues; } public void StopSubtitleDisplay() @@ -61,7 +46,7 @@ public void StopSubtitleDisplay() } Timer.Stop(); Timer.Elapsed -= UpdateSubtitle; - if (mauiMediaElement is null) + if(mauiMediaElement is null) { return; } @@ -75,134 +60,23 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { return; } - var cue = Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position); Dispatcher.Dispatch(() => { if (cue is not null) { - DisplayCue(cue); + subtitleTextBlock.Text = cue.Text; + subtitleTextBlock.FontFamily = new FontFamily(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).WindowsFont); + subtitleTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } else { subtitleTextBlock.Text = string.Empty; - subtitleTextBlock.Visibility = Visibility.Collapsed; + subtitleTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; } }); } - void DisplayCue(SubtitleCue cue) - { - if(cue.ParsedCueText is null) - { - return; - } - subtitleTextBlock.Inlines.Clear(); - ProcessCueText(subtitleTextBlock.Inlines, cue.ParsedCueText); - ApplyStyles(cue); - subtitleTextBlock.Visibility = Visibility.Visible; - - subtitleTextBlock.LineStackingStrategy = LineStackingStrategy.BlockLineHeight; - subtitleTextBlock.LineHeight = subtitleTextBlock.FontSize * 1.2; - } - - static void ProcessCueText(InlineCollection inlines, SubtitleNode node) - { - foreach (var child in node.Children) - { - if (child.NodeType == "text") - { - string? text = child.TextContent; - if (!string.IsNullOrEmpty(text)) - { - inlines.Add(new Run { Text = text }); - } - } - else if(child.NodeType is not null) - { - var span = new Span(); - ApplyStyleToSpan(span, child.NodeType); - ProcessCueText(span.Inlines, child); - inlines.Add(span); - } - } - } - - static void ApplyStyleToSpan(Span span, string nodeType) - { - switch (nodeType.ToLower()) - { - case "b": - span.FontWeight = Microsoft.UI.Text.FontWeights.Bold; - break; - case "i": - span.FontStyle = FontStyle.Italic; - break; - case "u": - span.TextDecorations = Windows.UI.Text.TextDecorations.Underline; - break; - case "v": - span.Foreground = new SolidColorBrush(Microsoft.UI.Colors.Yellow); - break; - } - } - - void ApplyStyles(SubtitleCue cue) - { - if(MediaElement?.SubtitleUrl is null || mauiMediaElement?.Width is null) - { - return; - } - subtitleTextBlock.TextAlignment = GetTextAlignment(cue.Align); - subtitleTextBlock.FontFamily = new FontFamily(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).WindowsFont); - - if (!string.IsNullOrEmpty(cue.Position)) - { - var parts = cue.Position.Split(','); - if (parts.Length > 0 && float.TryParse(parts[0].TrimEnd('%'), out float horizontalPosition)) - { - subtitleTextBlock.Margin = new Thickness(horizontalPosition * mauiMediaElement.Width / 100, 0, 0, subtitleTextBlock.Margin.Bottom); - } - } - - if (!string.IsNullOrEmpty(cue.Line) && float.TryParse(cue.Line.TrimEnd('%'), out float verticalPosition)) - { - subtitleTextBlock.Margin = new Thickness(subtitleTextBlock.Margin.Left, 0, 0, verticalPosition * mauiMediaElement.Height / 100); - } - if (cue.Vertical is null) - { - return; - } - ApplyVerticalWriting(cue.Vertical); - } - - static TextAlignment GetTextAlignment(string align) - { - return align?.ToLower() switch - { - "left" => TextAlignment.Left, - "right" => TextAlignment.Right, - "center" => TextAlignment.Center, - _ => TextAlignment.Center, - }; - } - - void ApplyVerticalWriting(string vertical) - { - if (vertical == "rl" || vertical == "lr") - { - subtitleTextBlock.RenderTransform = new RotateTransform - { - Angle = vertical == "rl" ? 90 : -90 - }; - subtitleTextBlock.RenderTransformOrigin = new Windows.Foundation.Point(0.5, 0.5); - } - else - { - subtitleTextBlock.RenderTransform = null; - } - } - void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) { var gridItem = MediaManager.FullScreenEvents.grid; @@ -217,14 +91,14 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) switch (isFullScreen) { case true: - subtitleTextBlock.Margin = new Thickness(0, 0, 0, 20); + subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20); subtitleTextBlock.FontSize = MediaElement.SubtitleFontSize; Dispatcher.Dispatch(() => { gridItem.Children.Remove(subtitleTextBlock); mauiMediaElement.Children.Add(subtitleTextBlock); }); isFullScreen = false; break; case false: subtitleTextBlock.FontSize = MediaElement.SubtitleFontSize + 8.0; - subtitleTextBlock.Margin = new Thickness(0, 0, 0, 300); + subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 300); Dispatcher.Dispatch(() => { mauiMediaElement.Children.Remove(subtitleTextBlock); gridItem.Children.Add(subtitleTextBlock); }); isFullScreen = true; break; @@ -251,7 +125,7 @@ protected virtual void Dispose(bool disposing) ~SubtitleExtensions() { - Dispose(disposing: false); + Dispose(disposing: false); } public void Dispose() diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleMetadataCue.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleMetadataCue.cs deleted file mode 100644 index f537c51ad7..0000000000 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleMetadataCue.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CommunityToolkit.Maui.Core; - -/// -/// The metadata cue -/// -public class SubtitleMetadataCue : SubtitleCue -{ - /// - /// The data of the cue - /// - public string? Data { get; set; } -} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleNode.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleNode.cs deleted file mode 100644 index e9e9776197..0000000000 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleNode.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace CommunityToolkit.Maui.Core; - -/// -/// A subtitle node -/// -public class SubtitleNode -{ - /// - /// The type of the node - /// - public string? NodeType { get; set; } - - /// - /// The text content of the node - /// - public string? TextContent { get; set; } - - /// - /// The attributes of the node - /// - public Dictionary Attributes { get; set; } = []; - - /// - /// The children of the node - /// - public List Children { get; set; } = []; -} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs index 9f0d74f094..95a939a33c 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs @@ -33,24 +33,13 @@ public SubtitleParser(IParser parser) ///
/// /// - public virtual SubtitleDocument ParseContent(string content) + public virtual List ParseContent(string content) { return IParser.ParseContent(content); } - internal static async Task Content(string? subtitleUrl) + internal static async Task Content(string subtitleUrl) { - ArgumentNullException.ThrowIfNull(subtitleUrl); - if (string.IsNullOrWhiteSpace(subtitleUrl)) - { - throw new ArgumentException("Url is empty."); - } - if (!ValidateUrlWithRegex(subtitleUrl)) - { - throw new UriFormatException("Invalid URL"); - } - - try { return await httpClient.GetStringAsync(subtitleUrl).ConfigureAwait(false); @@ -58,7 +47,7 @@ internal static async Task Content(string? subtitleUrl) catch (Exception ex) { System.Diagnostics.Trace.TraceError(ex.Message); - throw new FormatException("Invalid URL"); + return string.Empty; } } @@ -73,7 +62,7 @@ internal static bool ValidateUrlWithRegex(string url) urlRegex.Matches(url); if(!urlRegex.IsMatch(url)) { - return false; + throw new ArgumentException("Invalid Subtitle URL"); } return true; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs index 7c96a9b7ae..c135ff6578 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -1,229 +1,81 @@ -using System.Text; +using System.Globalization; +using System.Text; using System.Text.RegularExpressions; namespace CommunityToolkit.Maui.Core; -/// -/// Parser for WebVTT (Web Video Text Tracks) format -/// -public partial class VttParser : IParser +partial class VttParser : IParser { - /// - /// Parses the content of a WebVTT file - /// - /// The content of the WebVTT file - /// A SubtitleDocument containing the parsed content - /// Thrown when the file format is invalid - public SubtitleDocument ParseContent(string content) - { - var document = new SubtitleDocument(); - - // Remove UTF-8 BOM if present - if (content.StartsWith('\uFEFF')) - { - content = content[1..]; - } + static readonly Regex timecodePatternVTT = VTTRegex(); - var lines = content.Replace("\r\n", "\n").Split('\n'); - if (!lines[0].StartsWith("WEBVTT")) + public List ParseContent(string content) + { + var cues = new List(); + if (string.IsNullOrEmpty(content)) { - throw new FormatException("Invalid WebVTT file: Missing WEBVTT header"); + return cues; } - document.Header = lines[0]; + var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); + SubtitleCue? currentCue = null; + var textBuffer = new StringBuilder(); - for (int i = 1; i < lines.Length; i++) + foreach (var line in lines) { - if (string.IsNullOrWhiteSpace(lines[i])) - { - continue; - } - if (lines[i].StartsWith("STYLE")) + var match = timecodePatternVTT.Match(line); + if (match.Success) { - document.StyleBlock = ParseStyleBlock(lines, ref i); - } - else if (TryParseTimestamp(lines[i], out _, out _)) - { - var cue = ParseCue(lines, ref i); - document.Cues.Add(cue); - } - else if (lines[i].StartsWith("NOTE")) - { - // Skip comments - while (i < lines.Length && !string.IsNullOrWhiteSpace(lines[i])) + if (currentCue is not null) { - i++; + currentCue.Text = textBuffer.ToString().Trim(); + cues.Add(currentCue); + textBuffer.Clear(); } + currentCue = CreateCue(match); } - else + else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) { - // Assume it's a metadata cue - var metadataCue = ParseMetadataCue(lines, ref i); - document.Cues.Add(metadataCue); + textBuffer.AppendLine(line.Trim('-').Trim()); } } - return document; - } - - static string ParseStyleBlock(string[] lines, ref int i) - { - StringBuilder styleBlock = new(); - i++; // Skip "STYLE" line - while (i < lines.Length && !string.IsNullOrWhiteSpace(lines[i])) - { - styleBlock.AppendLine(lines[i]); - i++; - } - return styleBlock.ToString().Trim(); - } - - static readonly string[] separator = ["-->"]; - - static SubtitleCue ParseCue(string[] lines, ref int i) - { - var cue = new SubtitleCue(); - // Check for cue identifier - if (!TryParseTimestamp(lines[i], out _, out _)) - { - cue.Id = lines[i]; - i++; - } - // Parse timestamp and settings - if (TryParseTimestamp(lines[i], out var startTime, out var endTime)) - { - cue.StartTime = startTime; - cue.EndTime = endTime; - var parts = lines[i].Split(separator, StringSplitOptions.None); - if (parts.Length > 1) - { - ParseCueSettings(parts[1].Trim(), cue); - } - i++; - } - else - { - throw new FormatException($"Invalid cue timing: {lines[i]}"); - } - // Parse cue payload - StringBuilder rawText = new(); - while (i < lines.Length && !string.IsNullOrWhiteSpace(lines[i])) - { - rawText.AppendLine(lines[i]); - i++; - } - cue.RawText = rawText.ToString().Trim(); - cue.ParsedCueText = ParseCueText(cue.RawText); - return cue; - } - static SubtitleMetadataCue ParseMetadataCue(string[] lines, ref int i) - { - var cue = new SubtitleMetadataCue - { - Id = lines[i] - }; - i++; - // Check if the next line is a timestamp - if (i < lines.Length && TryParseTimestamp(lines[i], out var startTime, out var endTime)) + if (currentCue is not null) { - cue.StartTime = startTime; - cue.EndTime = endTime; - i++; + currentCue.Text = string.Join(" ", textBuffer).TrimEnd('\r', '\n'); + cues.Add(currentCue); } - // Parse the metadata content - StringBuilder data = new(); - while (i < lines.Length && !string.IsNullOrWhiteSpace(lines[i])) + if(cues.Count == 0) { - data.AppendLine(lines[i]); - i++; + throw new FormatException("Invalid VTT format"); } - cue.Data = data.ToString().Trim(); - return cue; + return cues; } - static void ParseCueSettings(string settingsString, SubtitleCue cue) + static SubtitleCue CreateCue(Match match) { - var settings = settingsString.Split(' '); - foreach (var setting in settings) + var StartTime = ParseTimecode(match.Groups[1].Value); + var EndTime = ParseTimecode(match.Groups[2].Value); + var Text = string.Empty; + if (StartTime > EndTime) { - var parts = setting.Split(':'); - if (parts.Length == 2) - { - var key = parts[0].Trim(); - var value = parts[1].Trim(); - switch (key) - { - case "region": cue.RegionId = value; break; - case "vertical": cue.Vertical = value; break; - case "line": cue.Line = value; break; - case "position": cue.Position = value; break; - case "size": cue.Size = value; break; - case "align": cue.Align = value; break; - } - } + throw new FormatException("Start time cannot be greater than end time."); } - } - - static SubtitleNode ParseCueText(string text) - { - var root = new SubtitleNode { NodeType = "root" }; - var current = root; - var stack = new Stack(); - stack.Push(root); - - var regex = ParseCueContentRegex(); - var matches = regex.Matches(text); - - for (int i = 0; i < matches.Count; i++) + return new SubtitleCue { - Match match = matches[i]; - if (match.Groups[1].Success) // Opening tag - { - var node = new SubtitleNode { NodeType = match.Groups[1].Value }; - current.Children.Add(node); - stack.Push(node); - current = node; - } - else if (match.Groups[2].Success) // Closing tag - { - if (stack.Count > 1 && stack.Peek().NodeType == match.Groups[2].Value) - { - stack.Pop(); - current = stack.Peek(); - } - } - else if (match.Groups[3].Success) // Text content - { - current.Children.Add(new SubtitleNode { NodeType = "text", TextContent = match.Groups[3].Value }); - } - } - - return root; + StartTime = StartTime, + EndTime = EndTime, + Text = Text + }; } - static bool TryParseTimestamp(string line, out TimeSpan startTime, out TimeSpan endTime) + static TimeSpan ParseTimecode(string timecode) { - startTime = TimeSpan.Zero; - endTime = TimeSpan.Zero; - var regex = TryParseTimeStampRegex(); - var match = regex.Match(line); - if (match.Success) + if (TimeSpan.TryParse(timecode, CultureInfo.InvariantCulture, out var result)) { - startTime = ParseTimeSpan(match.Groups[1].Value, match.Groups[2].Value, match.Groups[3].Value, match.Groups[4].Value); - endTime = ParseTimeSpan(match.Groups[5].Value, match.Groups[6].Value, match.Groups[7].Value, match.Groups[8].Value); - return true; + return result; } - return false; + throw new FormatException($"Invalid timecode format: {timecode}"); } - static TimeSpan ParseTimeSpan(string hours, string minutes, string seconds, string milliseconds) - { - int h = string.IsNullOrEmpty(hours) ? 0 : int.Parse(hours.TrimEnd(':')); - return new TimeSpan(0, h, int.Parse(minutes), int.Parse(seconds), int.Parse(milliseconds)); - } - - [GeneratedRegex(@"(\d{2}:)?(\d{2}):(\d{2})\.(\d{3}) --> (\d{2}:)?(\d{2}):(\d{2})\.(\d{3})")] - private static partial Regex TryParseTimeStampRegex(); - - [GeneratedRegex(@"<([^>/]+)>|]+)>|([^<]+)")] - private static partial Regex ParseCueContentRegex(); -} + [GeneratedRegex(@"(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})", RegexOptions.Compiled)] + private static partial Regex VTTRegex(); +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs index 7887971f76..33580fcbb3 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs @@ -1,7 +1,7 @@ namespace CommunityToolkit.Maui.Core; /// -/// A parser interface +/// /// public interface IParser { @@ -10,5 +10,5 @@ public interface IParser ///
/// /// - public SubtitleDocument ParseContent(string content); + public List ParseContent(string content); } diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index d5bd135a21..451bf982a9 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -690,10 +690,10 @@ sealed class MediaManagerDelegate : AVPlayerViewControllerDelegate { public override void WillBeginFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) { - MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.Default, MediaElementScreenState.FullScreen)); + MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.FullScreen, MediaElementScreenState.Default)); } public override void WillEndFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) { - MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.FullScreen, MediaElementScreenState.Default)); + MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.Default, MediaElementScreenState.FullScreen)); } } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs index b62a6d54ee..5ad688b28c 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs @@ -303,12 +303,12 @@ async Task LoadSubtitles(CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { - System.Diagnostics.Trace.TraceError("SubtitleExtensions is null or SubtitleUrl is null or player is null"); + System.Diagnostics.Trace.TraceError("SubtitleExtensions is null or SubtitleUrl is null or Player is null"); return; } if (Player is null) { - System.Diagnostics.Trace.TraceError("player is null"); + System.Diagnostics.Trace.TraceError("Player is null"); return; } subtitleExtensions ??= new(Player); diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs index 108284208a..09c8f7e608 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs @@ -5,7 +5,7 @@ namespace CommunityToolkit.Maui.UnitTests.Extensions; public class SrtParserTests : BaseTest { - [Fact] + [Fact] public void ParseSrtFile_ValidInput_ReturnsExpectedResult() { // Arrange @@ -22,53 +22,54 @@ This is the first subtitle. var cues = srtParser.ParseContent(srtContent); // Assert - Assert.Equal(TimeSpan.FromSeconds(10), cues.Cues[0].StartTime); - Assert.Equal(TimeSpan.FromSeconds(13), cues.Cues[0].EndTime); - Assert.Equal("This is the first subtitle.", cues.Cues[0].RawText); - Assert.Equal(TimeSpan.FromSeconds(15), cues.Cues[1].StartTime); - Assert.Equal(TimeSpan.FromSeconds(18), cues.Cues[1].EndTime); - Assert.Equal("This is the second subtitle.", cues.Cues[1].RawText); + Assert.Equal(TimeSpan.FromSeconds(10), cues[0].StartTime); + Assert.Equal(TimeSpan.FromSeconds(13), cues[0].EndTime); + Assert.Equal("This is the first subtitle.", cues[0].Text); + Assert.Equal(TimeSpan.FromSeconds(15), cues[1].StartTime); + Assert.Equal(TimeSpan.FromSeconds(18), cues[1].EndTime); + Assert.Equal("This is the second subtitle.", cues[1].Text); } [Fact] - public void ParseSrtFile_EmptyInput_ReturnsEmptyList() - { - // Arrange - var srtContent = string.Empty; + public void ParseSrtFile_EmptyInput_ReturnsEmptyList() + { + // Arrange + var srtContent = string.Empty; - // Act - var parser = new SrtParser(); - var result = parser.ParseContent(srtContent); + // Act + SrtParser srtParser = new(); + var result = srtParser.ParseContent(srtContent); - // Assert - Assert.Empty(result.Cues); - } + // Assert + Assert.Empty(result); + } - [Fact] - public void ParseSrtFile_InvalidFormat_ThrowsException() - { - // Arrange - var srtContent = "Invalid format"; + [Fact] + public void ParseSrtFile_InvalidFormat_ThrowsException() + { + // Arrange + var srtContent = "Invalid format"; - // Act & Assert - var parser = new SrtParser(); - Assert.Throws(() => parser.ParseContent(srtContent)); - } + // Act & Assert + SrtParser srtParser = new(); + Assert.Throws(() => srtParser.ParseContent(srtContent)); + } [Fact] public void ParseSrtFile_InvalidTimestamps_ThrowsException() { // Arrange var content = @"1 -00:00:00,000 --> 00:00:05,000 + +00:00:00.000 --> 00:00:05.000 This is the first subtitle. 2 -00:00:10,000 --> 00:00:05,000 +00:00:10.000 --> 00:00:05.000 This is the second subtitle."; // Act & Assert - var parser = new SrtParser(); - Assert.Throws(() => parser.ParseContent(content)); + SrtParser srtParser = new(); + Assert.Throws(() => srtParser.ParseContent(content)); } } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleParserTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleParserTests.cs deleted file mode 100644 index 5c023b301d..0000000000 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleParserTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using CommunityToolkit.Maui.Core; -using Xunit; - -namespace CommunityToolkit.Maui.UnitTests.Extensions; -public class SubtitleParserTests -{ - [Fact] - public async Task ParseSubtitles_WithNullUrl_ThrowsArgumentNullException() - { - // Arrange - string? url = null; - - // Act & Assert - await Assert.ThrowsAsync(async () => await SubtitleParser.Content(url)); - } - - [Fact] - public async Task ParseSubtitles_WithEmptyUrl_ThrowsArgumentException() - { - // Arrange - string url = string.Empty; - - // Act & Assert - await Assert.ThrowsAsync(async () => await SubtitleParser.Content(url)); - } - - [Fact] - public async Task ParseSubtitles_WithWhiteSpaceUrl_ThrowsArgumentException() - { - // Arrange - string url = " "; - - // Act & Assert - await Assert.ThrowsAsync(async () => await SubtitleParser.Content(url)); - } - - [Fact] - public async Task ParseSubtitles_WithInvalidUrl_ThrowsUriFormatException() - { - // Arrange - string url = "not a valid url"; - - // Act & Assert - await Assert.ThrowsAsync(async () => await SubtitleParser.Content(url)); - } -} diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs index ca762d8534..704b12c72b 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using CommunityToolkit.Maui.Core; using Xunit; @@ -6,163 +5,71 @@ namespace CommunityToolkit.Maui.UnitTests.Extensions; public class VttParserTests : BaseTest { - [Fact] - public void RegexImportTest() - { - // This test verifies that the Regex namespace is available - // and can be used without throwing exceptions - Assert.NotNull(typeof(Regex)); - } - - [Fact] - public void RegexMatchTest() - { - // Test a simple regex pattern to ensure Regex functionality - string pattern = @"\d+"; - string input = "Test 123"; - var match = Regex.Match(input, pattern); - Assert.True(match.Success); - Assert.Equal("123", match.Value); - } - - [Fact] - public void RegexReplaceTest() - { - // Test Regex.Replace functionality - string pattern = @"\s+"; - string input = "Hello World"; - string result = Regex.Replace(input, pattern, " "); - Assert.Equal("Hello World", result); - } - - [Fact] - public void RegexSplitTest() - { - // Test Regex.Split functionality - string pattern = @",\s*"; - string input = "apple, banana,cherry, date"; - string[] result = Regex.Split(input, pattern); - Assert.Equal(4, result.Length); - Assert.Equal("apple", result[0]); - Assert.Equal("date", result[3]); - } - - readonly VttParser parser = new(); - - [Fact] - public void ParseContent_ValidVTTFile_ReturnsCorrectSubtitleDocument() - { - var content = @"WEBVTT - -00:00:01.000 --> 00:00:04.000 -This is the first subtitle - -00:00:05.000 --> 00:00:08.000 -This is the second subtitle"; - - var result = parser.ParseContent(content); - - Assert.Equal("WEBVTT", result.Header); - Assert.Equal(2, result.Cues.Count); - Assert.Equal(TimeSpan.FromSeconds(1), result.Cues[0].StartTime); - Assert.Equal(TimeSpan.FromSeconds(4), result.Cues[0].EndTime); - Assert.Equal("This is the first subtitle", result.Cues[0].RawText); - } - - [Fact] - public void ParseContent_VTTFileWithCueSettings_ParsesCueSettingsCorrectly() - { - var content = @"WEBVTT - -00:00:01.000 --> 00:00:04.000 vertical:rl line:0 position:50% align:start -This is a subtitle with cue settings"; - - var result = parser.ParseContent(content); - - Assert.Single(result.Cues); - var cue = result.Cues[0]; - Assert.Equal("rl", cue.Vertical); - Assert.Equal("0", cue.Line); - Assert.Equal("50%", cue.Position); - Assert.Equal("start", cue.Align); - } + [Fact] + public void ParseVttFile_ValidFile_ReturnsCorrectCues() + { + // Arrange + var content = @"WEBVTT + +00:00:00.000 --> 00:00:05.000 +This is the first cue. + +00:00:05.000 --> 00:00:10.000 +This is the second cue."; + + // Act + VttParser vttParser = new(); + var cues = vttParser.ParseContent(content); + + // Assert + Assert.Equal(2, cues.Count); + Assert.Equal(TimeSpan.Zero, cues[0].StartTime); + Assert.Equal(TimeSpan.FromSeconds(5), cues[0].EndTime); + Assert.Equal("This is the first cue.", cues[0].Text); + Assert.Equal(TimeSpan.FromSeconds(5), cues[1].StartTime); + Assert.Equal(TimeSpan.FromSeconds(10), cues[1].EndTime); + Assert.Equal("This is the second cue.", cues[1].Text); + } + + [Fact] + public void ParseVttFile_EmptyFile_ReturnsEmptyList() + { + // Arrange + var content = string.Empty; + + // Act + VttParser vttParser = new(); + var cues = vttParser.ParseContent(content); + + // Assert + Assert.Empty(cues); + } [Fact] - public void ParseContent_VTTFileWithStyleAndMetadataCue_ParsesCorrectly() + public void ParseVttFile_InvalidFormat_ThrowsException() { - var content = @"WEBVTT - -NOTE This is a comment - -STYLE -::cue { - background-color: yellow; - color: black; -} - -00:00:01.000 --> 00:00:04.000 -This is a regular subtitle - -MetadataCue -This is metadata content"; - - var result = parser.ParseContent(content); - - Assert.Equal(2, result.Cues.Count); - Assert.IsType(result.Cues[0]); - Assert.IsType(result.Cues[1]); - - Assert.NotNull(result.StyleBlock); - Assert.Contains("background-color: yellow;", result.StyleBlock); - Assert.Contains("color: black;", result.StyleBlock); + // Arrange + var vttContent = "Invalid format"; - var metadataCue = result.Cues[1] as SubtitleMetadataCue; - Assert.NotNull(metadataCue); - Assert.Equal("MetadataCue", metadataCue.Id); - Assert.Equal("This is metadata content", metadataCue.Data); + // Act & Assert + VttParser vttParser = new(); + Assert.Throws(() => vttParser.ParseContent(vttContent)); } [Fact] - public void ParseContent_VTTFileWithFormattedText_ParsesCueTextCorrectly() - { - var content = @"WEBVTT - -00:00:01.000 --> 00:00:04.000 -This is bold and italic text"; - - var result = parser.ParseContent(content); - - Assert.Single(result.Cues); - var cue = result.Cues[0]; - Assert.NotNull(cue); - var parsedText = cue.ParsedCueText; - Assert.NotNull(parsedText); - Assert.Equal("root", parsedText.NodeType); - Assert.Equal(5, parsedText.Children.Count); - - Assert.Equal("text", parsedText.Children[0].NodeType); - Assert.Equal("This is ", parsedText.Children[0].TextContent); - - Assert.Equal("b", parsedText.Children[1].NodeType); - Assert.Single(parsedText.Children[1].Children); - Assert.Equal("bold", parsedText.Children[1].Children[0].TextContent); + public void ParseVttFile_InvalidTimestamps_ThrowsException() + { + // Arrange + var content = @"WEBVTT - Assert.Equal("text", parsedText.Children[2].NodeType); - Assert.Equal(" and ", parsedText.Children[2].TextContent); +00:00:00.000 --> 00:00:05.000 +This is the first cue. - Assert.Equal("i", parsedText.Children[3].NodeType); - Assert.Single(parsedText.Children[3].Children); - Assert.Equal("italic", parsedText.Children[3].Children[0].TextContent); +00:00:10.000 --> 00:00:05.000 +This is the second cue."; - Assert.Equal("text", parsedText.Children[4].NodeType); - Assert.Equal(" text", parsedText.Children[4].TextContent); - } - - [Fact] - public void ParseContent_InvalidVTTFile_ThrowsFormatException() - { - var content = "This is not a valid VTT file"; - - Assert.Throws(() => parser.ParseContent(content)); - } + // Act & Assert + VttParser vttParser = new(); + Assert.Throws(() => vttParser.ParseContent(content)); + } } \ No newline at end of file From 2c636651350b73d6b85965dca57e27f59f541ec1 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Wed, 24 Jul 2024 02:57:04 -0700 Subject: [PATCH 76/98] Reorder SubtitleUrl assignment in ChangeSourceClicked method Reordered the assignment of the `SubtitleUrl` property in the `ChangeSourceClicked` method within `MediaElementPage.xaml.cs`. Set `SubtitleUrl` to an empty string before setting the `Source` property for `loadOnlineMp4` and `loadHls` cases. Moved `SubtitleUrl` assignment after setting `Source` to `null` in the `resetSource` case. --- .../Pages/Views/MediaElement/MediaElementPage.xaml.cs | 6 +++--- 1 file changed, 3 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 e7b2c622e1..275c909e55 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -168,28 +168,28 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.MetadataTitle = "Big Buck Bunny"; MediaElement.MetadataArtworkUrl = "https://lh3.googleusercontent.com/pw/AP1GczNRrebWCJvfdIau1EbsyyYiwAfwHS0JXjbioXvHqEwYIIdCzuLodQCZmA57GADIo5iB3yMMx3t_vsefbfoHwSg0jfUjIXaI83xpiih6d-oT7qD_slR0VgNtfAwJhDBU09kS5V2T5ZML-WWZn8IrjD4J-g=w1792-h1024-s-no-gm"; MediaElement.MetadataArtist = "Big Buck Bunny Album"; + MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = MediaSource.FromUri( "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"); - MediaElement.SubtitleUrl = string.Empty; return; case loadHls: MediaElement.MetadataArtist = "HLS Album"; MediaElement.MetadataArtworkUrl = "https://lh3.googleusercontent.com/pw/AP1GczNRrebWCJvfdIau1EbsyyYiwAfwHS0JXjbioXvHqEwYIIdCzuLodQCZmA57GADIo5iB3yMMx3t_vsefbfoHwSg0jfUjIXaI83xpiih6d-oT7qD_slR0VgNtfAwJhDBU09kS5V2T5ZML-WWZn8IrjD4J-g=w1792-h1024-s-no-gm"; MediaElement.MetadataTitle = "HLS Title"; + MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = MediaSource.FromUri( "https://mtoczko.github.io/hls-test-streams/test-gap/playlist.m3u8"); - MediaElement.SubtitleUrl = string.Empty; return; case resetSource: MediaElement.MetadataArtworkUrl = string.Empty; MediaElement.MetadataTitle = string.Empty; MediaElement.MetadataArtist = string.Empty; - MediaElement.Source = null; MediaElement.SubtitleUrl = string.Empty; + MediaElement.Source = null; return; case loadLocalResource: From fb5b6af2031c6ada6f9407b055003d8c9710dda2 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Wed, 24 Jul 2024 06:56:07 -0700 Subject: [PATCH 77/98] In SubtitleExtensions.windows.cs: - Changed subtitleTextBlock from TextBlock to TextBox for advanced text handling. - Set properties for subtitleTextBlock: FontSize (16), Width (one-third of player's width), TextAlignment (Center), Foreground (white), Background (black with 0.7 opacity), BackgroundSizing (InnerBorderEdge), TextWrapping (Wrap), and initialized Text to an empty string. - Modified StopSubtitleDisplay to clear TextProperty using ClearValue. - Set HorizontalTextAlignment to Center in UpdateSubtitle method. - Adjusted OnFullScreenChanged method to handle subtitleTextBlock width and margin changes based on full-screen mode. --- .../CommunityToolkit.Maui.Sample.csproj | 1 + .../Extensions/SubtitleExtensions.windows.cs | 25 ++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj index 86825f114a..904d2bf994 100644 --- a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj +++ b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj @@ -5,6 +5,7 @@ $(TargetFrameworks);$(NetVersion)-windows10.0.19041.0 $(TargetFrameworks);$(NetVersion)-tizen Exe + true true CommunityToolkit.Maui.Sample diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 95dbca1d97..e85204373b 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -9,16 +9,20 @@ partial class SubtitleExtensions : Grid, IDisposable { bool disposedValue; bool isFullScreen = false; - readonly Microsoft.UI.Xaml.Controls.TextBlock subtitleTextBlock; + readonly Microsoft.UI.Xaml.Controls.TextBox subtitleTextBlock; readonly MauiMediaElement? mauiMediaElement; + readonly int width; public SubtitleExtensions(Microsoft.UI.Xaml.Controls.MediaPlayerElement player) { - mauiMediaElement = player?.Parent as MauiMediaElement; + width = (int)player.ActualWidth / 3; + mauiMediaElement = player.Parent as MauiMediaElement; MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; subtitleTextBlock = new() { - Text = string.Empty, + FontSize = 16, + Width = width, + TextAlignment = Microsoft.UI.Xaml.TextAlignment.Center, Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20), Visibility = Microsoft.UI.Xaml.Visibility.Collapsed, HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, @@ -26,6 +30,12 @@ public SubtitleExtensions(Microsoft.UI.Xaml.Controls.MediaPlayerElement player) Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White), TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap, }; + subtitleTextBlock.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White); + subtitleTextBlock.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Black); + subtitleTextBlock.BackgroundSizing = Microsoft.UI.Xaml.Controls.BackgroundSizing.InnerBorderEdge; + subtitleTextBlock.Opacity = 0.7; + subtitleTextBlock.TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap; + subtitleTextBlock.Text = string.Empty; } public void StartSubtitleDisplay() @@ -39,7 +49,7 @@ public void StartSubtitleDisplay() public void StopSubtitleDisplay() { Cues?.Clear(); - subtitleTextBlock.Text = string.Empty; + subtitleTextBlock.ClearValue(Microsoft.UI.Xaml.Controls.TextBox.TextProperty); if (Timer is null) { return; @@ -66,6 +76,7 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) if (cue is not null) { subtitleTextBlock.Text = cue.Text; + subtitleTextBlock.HorizontalTextAlignment = Microsoft.UI.Xaml.TextAlignment.Center; subtitleTextBlock.FontFamily = new FontFamily(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).WindowsFont); subtitleTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } @@ -87,18 +98,20 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) { return; } - subtitleTextBlock.Text = string.Empty; + switch (isFullScreen) { case true: subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20); subtitleTextBlock.FontSize = MediaElement.SubtitleFontSize; + subtitleTextBlock.Width = width; Dispatcher.Dispatch(() => { gridItem.Children.Remove(subtitleTextBlock); mauiMediaElement.Children.Add(subtitleTextBlock); }); isFullScreen = false; break; case false: subtitleTextBlock.FontSize = MediaElement.SubtitleFontSize + 8.0; - subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 300); + subtitleTextBlock.Width = DeviceDisplay.Current.MainDisplayInfo.Width / 4; + subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 100); Dispatcher.Dispatch(() => { mauiMediaElement.Children.Remove(subtitleTextBlock); gridItem.Children.Add(subtitleTextBlock); }); isFullScreen = true; break; From b0bc69b92ab1b7f349e1fbc19b02bbba16fecc83 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Thu, 25 Jul 2024 16:57:46 -0700 Subject: [PATCH 78/98] Refactor SubtitleExtensions for better layout management Updated SubtitleExtensions to improve subtitle handling and layout: - Replaced RelativeLayout.LayoutParams with FrameLayout.LayoutParams. - Added screenState field to manage screen state. - Initialized screenState in constructor and called InitializeLayout. - Added destructor to clean up timer and unsubscribe from events. - Updated StartSubtitleDisplay and StopSubtitleDisplay methods. - Refactored UpdateSubtitle to call SetHeight and InitializeText. - Added OnFullScreenChanged to handle full-screen state changes. - Added SetHeight to adjust subtitle view height. - Added InitializeText to set typeface and text size. - Added InitializeLayout to set up subtitleLayout. - Moved InitializeTextBlock for better code organization. --- .../Extensions/SubtitleExtensions.android.cs | 116 +++++++++++------- 1 file changed, 73 insertions(+), 43 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index d94dca0751..b4ff982440 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -5,6 +5,7 @@ using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; using static Android.Views.ViewGroup; +using static CommunityToolkit.Maui.Core.Views.MauiMediaElement; using CurrentPlatformActivity = CommunityToolkit.Maui.Core.Views.MauiMediaElement.CurrentPlatformContext; namespace CommunityToolkit.Maui.Extensions; @@ -12,22 +13,31 @@ namespace CommunityToolkit.Maui.Extensions; partial class SubtitleExtensions : Java.Lang.Object { readonly IDispatcher dispatcher; - readonly RelativeLayout.LayoutParams? subtitleLayout; + FrameLayout.LayoutParams? subtitleLayout; readonly StyledPlayerView styledPlayerView; TextView? subtitleView; + MediaElementScreenState screenState; public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatcher) { + screenState = MediaElementScreenState.Default; this.dispatcher = dispatcher; this.styledPlayerView = styledPlayerView; Cues = []; - subtitleLayout = new RelativeLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent); - subtitleLayout.AddRule(LayoutRules.AlignParentBottom); - subtitleLayout.AddRule(LayoutRules.CenterHorizontal); + InitializeLayout(); InitializeTextBlock(); MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; } + ~SubtitleExtensions() + { + if (Timer is not null) + { + Timer.Stop(); + Timer.Elapsed -= UpdateSubtitle; + } + MediaManager.FullScreenEvents.WindowsChanged -= OnFullScreenChanged; + } public void StartSubtitleDisplay() { ArgumentNullException.ThrowIfNull(subtitleView); @@ -36,12 +46,10 @@ public void StartSubtitleDisplay() { return; } - if(styledPlayerView.Parent is not ViewGroup parent) - { - System.Diagnostics.Trace.TraceError("StyledPlayerView parent is not a ViewGroup"); - return; - } - dispatcher.Dispatch(() => parent.AddView(subtitleView)); + + InitializeText(); + dispatcher.Dispatch(() => styledPlayerView.AddView(subtitleView)); + Timer = new System.Timers.Timer(1000); Timer.Elapsed += UpdateSubtitle; Timer.Start(); @@ -61,10 +69,7 @@ public void StopSubtitleDisplay() return; } subtitleView.Text = string.Empty; - if (styledPlayerView.Parent is ViewGroup parent) - { - dispatcher.Dispatch(() => parent.RemoveView(subtitleView)); - } + dispatcher.Dispatch(() => styledPlayerView.RemoveView(subtitleView)); } void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) @@ -72,19 +77,20 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) ArgumentNullException.ThrowIfNull(subtitleView); ArgumentNullException.ThrowIfNull(MediaElement); ArgumentNullException.ThrowIfNull(Cues); + if (Cues.Count == 0) { return; } + var cue = Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position); dispatcher.Dispatch(() => { + + SetHeight(); if (cue is not null) { - Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).Android) ?? Typeface.Default; - subtitleView.SetTypeface(typeface, TypefaceStyle.Normal); subtitleView.Text = cue.Text; - subtitleView.TextSize = (float)MediaElement.SubtitleFontSize; subtitleView.Visibility = ViewStates.Visible; } else @@ -95,6 +101,52 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) }); } + void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) + { + var layout = CurrentPlatformContext.CurrentWindow.DecorView as ViewGroup; + ArgumentNullException.ThrowIfNull(layout); + if (e.NewState == MediaElementScreenState.FullScreen) + { + screenState = MediaElementScreenState.FullScreen; + styledPlayerView.RemoveView(subtitleView); + InitializeLayout(); + InitializeTextBlock(); + InitializeText(); + layout.AddView(subtitleView); + + } + else + { + screenState = MediaElementScreenState.Default; + layout.RemoveView(subtitleView); + InitializeLayout(); + InitializeTextBlock(); + InitializeText(); + styledPlayerView.AddView(subtitleView); + } + } + void SetHeight() + { + int height = styledPlayerView.Height; + switch (screenState) + { + case MediaElementScreenState.Default: + height = (int)(height * 0.1); + break; + case MediaElementScreenState.FullScreen: + height = (int)(height * 0.2); + break; + } + dispatcher.Dispatch(() => subtitleLayout?.SetMargins(20, 0, 20, height)); + } + void InitializeText() + { + ArgumentNullException.ThrowIfNull(subtitleView); + ArgumentNullException.ThrowIfNull(MediaElement); + Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).Android) ?? Typeface.Default; + subtitleView.TextSize = (float)MediaElement.SubtitleFontSize; + subtitleView.SetTypeface(typeface, TypefaceStyle.Normal); + } void InitializeTextBlock() { subtitleView = new(CurrentPlatformActivity.CurrentActivity.ApplicationContext) @@ -110,33 +162,11 @@ void InitializeTextBlock() subtitleView.SetTextColor(Android.Graphics.Color.White); subtitleView.SetPaddingRelative(10, 10, 10, 20); } - - void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) + void InitializeLayout() { - ArgumentNullException.ThrowIfNull(subtitleView); - ArgumentNullException.ThrowIfNull(MediaElement); - - // If the subtitle URL is empty do nothing - if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) - { - return; - } - if (CurrentPlatformActivity.CurrentViewGroup.Parent is not ViewGroup parent) + subtitleLayout = new FrameLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent) { - return; - } - switch (e.NewState == MediaElementScreenState.FullScreen) - { - case true: - CurrentPlatformActivity.CurrentViewGroup.RemoveView(subtitleView); - InitializeTextBlock(); - parent.AddView(subtitleView); - break; - case false: - parent.RemoveView(subtitleView); - InitializeTextBlock(); - CurrentPlatformActivity.CurrentViewGroup.AddView(subtitleView); - break; - } + Gravity = GravityFlags.Center | GravityFlags.Bottom, + }; } } From f0efafc3e6fa06639e6faa7a93eb91dff269f85f Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 26 Jul 2024 03:03:51 -0700 Subject: [PATCH 79/98] Refactor subtitleTextBlock initialization and usage Refactored the `SubtitleExtensions` class to improve code readability and maintainability: - Changed `subtitleTextBlock` to a nullable type (`TextBox?`). - Moved `subtitleTextBlock` initialization to a new method `InitializeTextBlock`. - Updated the constructor to call `InitializeTextBlock`. - Added null checks for `subtitleTextBlock` using `ArgumentNullException.ThrowIfNull`. - Introduced `InitializeText` to handle text-related properties of `subtitleTextBlock`. - Removed redundant initialization code from the constructor and `UpdateSubtitle`, moving it to `InitializeText`. --- .../Extensions/SubtitleExtensions.windows.cs | 55 ++++++++++++------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index e85204373b..73718094c6 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -9,7 +9,7 @@ partial class SubtitleExtensions : Grid, IDisposable { bool disposedValue; bool isFullScreen = false; - readonly Microsoft.UI.Xaml.Controls.TextBox subtitleTextBlock; + Microsoft.UI.Xaml.Controls.TextBox? subtitleTextBlock; readonly MauiMediaElement? mauiMediaElement; readonly int width; @@ -18,24 +18,7 @@ public SubtitleExtensions(Microsoft.UI.Xaml.Controls.MediaPlayerElement player) width = (int)player.ActualWidth / 3; mauiMediaElement = player.Parent as MauiMediaElement; MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; - subtitleTextBlock = new() - { - FontSize = 16, - Width = width, - TextAlignment = Microsoft.UI.Xaml.TextAlignment.Center, - Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20), - Visibility = Microsoft.UI.Xaml.Visibility.Collapsed, - HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, - VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Bottom, - Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White), - TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap, - }; - subtitleTextBlock.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White); - subtitleTextBlock.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Black); - subtitleTextBlock.BackgroundSizing = Microsoft.UI.Xaml.Controls.BackgroundSizing.InnerBorderEdge; - subtitleTextBlock.Opacity = 0.7; - subtitleTextBlock.TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap; - subtitleTextBlock.Text = string.Empty; + InitializeTextBlock(); } public void StartSubtitleDisplay() @@ -48,6 +31,7 @@ public void StartSubtitleDisplay() public void StopSubtitleDisplay() { + ArgumentNullException.ThrowIfNull(subtitleTextBlock); Cues?.Clear(); subtitleTextBlock.ClearValue(Microsoft.UI.Xaml.Controls.TextBox.TextProperty); if (Timer is null) @@ -66,6 +50,7 @@ public void StopSubtitleDisplay() void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { ArgumentNullException.ThrowIfNull(MediaElement); + ArgumentNullException.ThrowIfNull(subtitleTextBlock); if (string.IsNullOrEmpty(MediaElement.SubtitleUrl) || Cues is null) { return; @@ -75,9 +60,8 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { if (cue is not null) { + InitializeText(); subtitleTextBlock.Text = cue.Text; - subtitleTextBlock.HorizontalTextAlignment = Microsoft.UI.Xaml.TextAlignment.Center; - subtitleTextBlock.FontFamily = new FontFamily(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).WindowsFont); subtitleTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } else @@ -94,6 +78,7 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) ArgumentNullException.ThrowIfNull(mauiMediaElement); ArgumentNullException.ThrowIfNull(MediaElement); ArgumentNullException.ThrowIfNull(gridItem); + ArgumentNullException.ThrowIfNull(subtitleTextBlock); if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { return; @@ -117,7 +102,35 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) break; } } + void InitializeTextBlock() + { + subtitleTextBlock = new() + { + FontSize = 16, + Width = width, + TextAlignment = Microsoft.UI.Xaml.TextAlignment.Center, + Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20), + Visibility = Microsoft.UI.Xaml.Visibility.Collapsed, + HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, + VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Bottom, + Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White), + TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap, + Text = string.Empty + }; + } + void InitializeText() + { + ArgumentNullException.ThrowIfNull(MediaElement); + ArgumentNullException.ThrowIfNull(subtitleTextBlock); + subtitleTextBlock.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White); + subtitleTextBlock.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Black); + subtitleTextBlock.BackgroundSizing = Microsoft.UI.Xaml.Controls.BackgroundSizing.InnerBorderEdge; + subtitleTextBlock.Opacity = 0.7; + subtitleTextBlock.TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap; + subtitleTextBlock.HorizontalTextAlignment = Microsoft.UI.Xaml.TextAlignment.Center; + subtitleTextBlock.FontFamily = new FontFamily(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).WindowsFont); + } protected virtual void Dispose(bool disposing) { if (!disposedValue) From ee297d8601382b67ee22cdd06d3d7ae3697fd786 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 26 Jul 2024 13:15:24 -0700 Subject: [PATCH 80/98] Refactor SubtitleExtensions and update MediaManager Updated `SubtitleExtensions` to replace `viewController` with `screenState`, refactored methods to use `screenState`, and added new methods for text wrapping and regex matching. Adjusted `CalculateSubtitleFrame` and `OnFullScreenChanged` methods. Updated `MediaManager` to call `StopSubtitleDisplay`. --- .../Extensions/SubtitleExtensions.macios.cs | 113 +++++++++++++----- .../Views/MediaManager.macios.cs | 10 +- 2 files changed, 87 insertions(+), 36 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 84168b2523..73965086e2 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -1,4 +1,6 @@ -using CommunityToolkit.Maui.Core.Views; +using System.Text; +using System.Text.RegularExpressions; +using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; using CoreFoundation; using CoreGraphics; @@ -16,26 +18,25 @@ partial class SubtitleExtensions : UIViewController static readonly UIColor subtitleBackgroundColor = UIColor.FromRGBA(0, 0, 0, 128); static readonly UIColor clearBackgroundColor = UIColor.FromRGBA(0, 0, 0, 0); NSObject? playerObserver; - UIViewController? viewController; + MediaElementScreenState screenState; public SubtitleExtensions(PlatformMediaElement player, UIViewController playerViewController) { this.playerViewController = playerViewController; this.player = player; + screenState = MediaElementScreenState.Default; Cues = []; + subtitleLabel = new UILabel { Frame = CalculateSubtitleFrame(playerViewController), TextColor = UIColor.White, TextAlignment = UITextAlignment.Center, Font = UIFont.SystemFontOfSize(12), - Text = "", + Text = string.Empty, + BackgroundColor = clearBackgroundColor, Lines = 0, LineBreakMode = UILineBreakMode.WordWrap, - AutoresizingMask = UIViewAutoresizing.FlexibleWidth - | UIViewAutoresizing.FlexibleTopMargin - | UIViewAutoresizing.FlexibleHeight - | UIViewAutoresizing.FlexibleBottomMargin }; MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; } @@ -44,11 +45,10 @@ public void StartSubtitleDisplay() { ArgumentNullException.ThrowIfNull(subtitleLabel); DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); + playerObserver = player?.AddPeriodicTimeObserver(CMTime.FromSeconds(1, 1), null, (time) => { - TimeSpan currentPlaybackTime = TimeSpan.FromSeconds(time.Seconds); - subtitleLabel.Frame = viewController is not null ? CalculateSubtitleFrame(viewController) : CalculateSubtitleFrame(playerViewController); - DispatchQueue.MainQueue.DispatchAsync(() => UpdateSubtitle(currentPlaybackTime)); + DispatchQueue.MainQueue.DispatchAsync(() => UpdateSubtitle()); }); } @@ -60,40 +60,86 @@ public void StopSubtitleDisplay() subtitleLabel.BackgroundColor = clearBackgroundColor; DispatchQueue.MainQueue.DispatchAsync(() => subtitleLabel.RemoveFromSuperview()); } - void UpdateSubtitle(TimeSpan currentPlaybackTime) + + void UpdateSubtitle() { ArgumentNullException.ThrowIfNull(Cues); ArgumentNullException.ThrowIfNull(subtitleLabel); ArgumentNullException.ThrowIfNull(MediaElement); + if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) { return; } - foreach (var cue in Cues) + switch (screenState) { - if (currentPlaybackTime >= cue.StartTime && currentPlaybackTime <= cue.EndTime) - { - subtitleLabel.Text = cue.Text; - subtitleLabel.Font = UIFont.FromName(name: new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).MacIOS, size: (float)MediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize(16); - subtitleLabel.BackgroundColor = subtitleBackgroundColor; + case MediaElementScreenState.FullScreen: + var viewController = WindowStateManager.Default.GetCurrentUIViewController(); + ArgumentNullException.ThrowIfNull(viewController); + subtitleLabel.Frame = CalculateSubtitleFrame(viewController); + break; + case MediaElementScreenState.Default: + subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); + ArgumentNullException.ThrowIfNull(playerViewController.View); break; + } + + var cue = Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position); + if (cue is not null) + { + subtitleLabel.Text = TextWrapper(cue.Text ?? string.Empty); + subtitleLabel.Font = UIFont.FromName(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).MacIOS, (float)MediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize(16); + subtitleLabel.BackgroundColor = subtitleBackgroundColor; + } + else + { + subtitleLabel.Text = string.Empty; + subtitleLabel.BackgroundColor = clearBackgroundColor; + } + } + + static string TextWrapper(string input) + { + Regex wordRegex = MatchWorksRegex(); + MatchCollection words = wordRegex.Matches(input); + + StringBuilder wrappedTextBuilder = new(); + int currentLineLength = 0; + int lineNumber = 1; + + foreach (var matchValue in + from Match match in words + let matchValue = match.Value + select matchValue) + { + if (currentLineLength + matchValue.Length > 60) + { + wrappedTextBuilder.AppendLine(); + lineNumber++; + currentLineLength = 0; } - else + + if (currentLineLength > 0) { - subtitleLabel.Text = ""; - subtitleLabel.BackgroundColor = clearBackgroundColor; + wrappedTextBuilder.Append(' '); } + + wrappedTextBuilder.Append(matchValue); + currentLineLength += matchValue.Length + 1; } - } + return wrappedTextBuilder.ToString(); + } static CGRect CalculateSubtitleFrame(UIViewController uIViewController) - { - if(uIViewController is null || uIViewController.View is null) + { + if (uIViewController is null || uIViewController.View is null) { return CGRect.Empty; } - return new CGRect(0, uIViewController.View.Bounds.Height - 60, uIViewController.View.Bounds.Width, 50); + var viewHeight = uIViewController.View.Bounds.Height; + var viewWidth = uIViewController.View.Bounds.Width; + return new CGRect(0, viewHeight - 80, viewWidth, 50); } void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) @@ -103,19 +149,21 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) { return; } - subtitleLabel.Text = string.Empty; + switch (e.NewState == MediaElementScreenState.FullScreen) { case true: - viewController = WindowStateManager.Default.GetCurrentUIViewController() ?? throw new ArgumentException(nameof(viewController)); + var viewController = WindowStateManager.Default.GetCurrentUIViewController(); + screenState = MediaElementScreenState.FullScreen; + ArgumentNullException.ThrowIfNull(viewController); ArgumentNullException.ThrowIfNull(viewController.View); subtitleLabel.Frame = CalculateSubtitleFrame(viewController); - DispatchQueue.MainQueue.DispatchAsync(() => { subtitleLabel.RemoveFromSuperview(); viewController?.View?.AddSubview(subtitleLabel); }); + DispatchQueue.MainQueue.DispatchAsync(() => { viewController?.View?.Add(subtitleLabel); }); break; case false: + screenState = MediaElementScreenState.Default; subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); - DispatchQueue.MainQueue.DispatchAsync(() => { subtitleLabel.RemoveFromSuperview(); playerViewController.View?.AddSubview(subtitleLabel); }); - viewController = null; + DispatchQueue.MainQueue.DispatchAsync(() => { playerViewController.View?.AddSubview(subtitleLabel); }); break; } } @@ -123,10 +171,13 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) ~SubtitleExtensions() { MediaManager.FullScreenEvents.WindowsChanged -= OnFullScreenChanged; - if(playerObserver is not null && player is not null) + + if (playerObserver is not null && player is not null) { player.RemoveTimeObserver(playerObserver); } } -} + [GeneratedRegex(@"\b\w+\b")] + private static partial Regex MatchWorksRegex(); +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index 451bf982a9..11c31f83da 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -224,7 +224,8 @@ protected virtual partial void PlatformUpdateSource() MediaElement.CurrentStateChanged(MediaElementState.Opening); AVAsset? asset = null; - + subtitleExtensions?.StopSubtitleDisplay(); + if (Player is null) { return; @@ -232,8 +233,7 @@ protected virtual partial void PlatformUpdateSource() metaData ??= new(Player); Metadata.ClearNowPlaying(); - subtitleExtensions?.StopSubtitleDisplay(); - + if (MediaElement.Source is UriMediaSource uriMediaSource) { var uri = uriMediaSource.Uri; @@ -690,10 +690,10 @@ sealed class MediaManagerDelegate : AVPlayerViewControllerDelegate { public override void WillBeginFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) { - MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.FullScreen, MediaElementScreenState.Default)); + MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.Default, MediaElementScreenState.FullScreen)); } public override void WillEndFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) { - MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.Default, MediaElementScreenState.FullScreen)); + MediaManager.FullScreenEvents.OnWindowsChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.FullScreen, MediaElementScreenState.Default)); } } \ No newline at end of file From 206e650973e4d8cad6ed12100ff1b0f2f993b318 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 26 Jul 2024 15:36:34 -0700 Subject: [PATCH 81/98] Refactor SubtitleExtensions for better resource management Refactored SubtitleExtensions to improve resource management and ensure proper disposal of resources. Replaced destructor with Dispose method, which now calls StopTimer, clears Cues, and disposes of subtitleView. Updated StartSubtitleDisplay to subscribe to WindowsChanged event and start timer using new StartTimer method. Updated StopSubtitleDisplay to call StopTimer, clear Cues, and unsubscribe from WindowsChanged event. Added null checks in UpdateSubtitle and OnFullScreenChanged methods. In MediaManager.android.cs, Dispose method now calls subtitleExtensions?.StopSubtitleDisplay() and subtitleExtensions?.Dispose() for proper cleanup. --- .../Extensions/SubtitleExtensions.android.cs | 49 ++++++++++++------- .../Extensions/SubtitleExtensions.macios.cs | 13 ++--- .../Extensions/SubtitleExtensions.windows.cs | 32 ++++++++---- .../Views/MediaManager.android.cs | 3 +- 4 files changed, 62 insertions(+), 35 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index b4ff982440..3d61f34605 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -26,16 +26,14 @@ public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatc Cues = []; InitializeLayout(); InitializeTextBlock(); - MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; } - ~SubtitleExtensions() + protected override void Dispose(bool disposing) { - if (Timer is not null) - { - Timer.Stop(); - Timer.Elapsed -= UpdateSubtitle; - } + base.Dispose(disposing); + Cues?.Clear(); + StopTimer(); + subtitleView?.Dispose(); MediaManager.FullScreenEvents.WindowsChanged -= OnFullScreenChanged; } public void StartSubtitleDisplay() @@ -47,29 +45,38 @@ public void StartSubtitleDisplay() return; } + MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; InitializeText(); dispatcher.Dispatch(() => styledPlayerView.AddView(subtitleView)); + StartTimer(); + } + void StartTimer() + { + if(Timer is not null) + { + Timer.Stop(); + Timer.Dispose(); + } Timer = new System.Timers.Timer(1000); Timer.Elapsed += UpdateSubtitle; Timer.Start(); } - public void StopSubtitleDisplay() + void StopTimer() { - ArgumentNullException.ThrowIfNull(Cues); - Cues.Clear(); - if(Timer is not null) + if (Timer is not null) { - Timer.Stop(); Timer.Elapsed -= UpdateSubtitle; + Timer.Stop(); + Timer.Dispose(); } - if (subtitleView is null) - { - return; - } - subtitleView.Text = string.Empty; - dispatcher.Dispatch(() => styledPlayerView.RemoveView(subtitleView)); + } + public void StopSubtitleDisplay() + { + Cues?.Clear(); + StopTimer(); + MediaManager.FullScreenEvents.WindowsChanged -= OnFullScreenChanged; } void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) @@ -78,7 +85,7 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) ArgumentNullException.ThrowIfNull(MediaElement); ArgumentNullException.ThrowIfNull(Cues); - if (Cues.Count == 0) + if (Cues.Count == 0 || styledPlayerView is null) { return; } @@ -127,6 +134,10 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) } void SetHeight() { + if (styledPlayerView is null || subtitleLayout is null || subtitleView is null) + { + return; + } int height = styledPlayerView.Height; switch (screenState) { diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 73965086e2..ff2b872b8b 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -59,10 +59,16 @@ public void StopSubtitleDisplay() Cues.Clear(); subtitleLabel.BackgroundColor = clearBackgroundColor; DispatchQueue.MainQueue.DispatchAsync(() => subtitleLabel.RemoveFromSuperview()); + playerObserver?.Dispose(); } void UpdateSubtitle() { + if (playerViewController is null) + { + return; + } + ArgumentNullException.ThrowIfNull(Cues); ArgumentNullException.ThrowIfNull(subtitleLabel); ArgumentNullException.ThrowIfNull(MediaElement); @@ -81,7 +87,6 @@ void UpdateSubtitle() break; case MediaElementScreenState.Default: subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); - ArgumentNullException.ThrowIfNull(playerViewController.View); break; } @@ -171,11 +176,7 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) ~SubtitleExtensions() { MediaManager.FullScreenEvents.WindowsChanged -= OnFullScreenChanged; - - if (playerObserver is not null && player is not null) - { - player.RemoveTimeObserver(playerObserver); - } + playerObserver?.Dispose(); } [GeneratedRegex(@"\b\w+\b")] diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 73718094c6..889d42192b 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -23,10 +23,8 @@ public SubtitleExtensions(Microsoft.UI.Xaml.Controls.MediaPlayerElement player) public void StartSubtitleDisplay() { - Timer = new System.Timers.Timer(1000); Dispatcher.Dispatch(() => mauiMediaElement?.Children.Add(subtitleTextBlock)); - Timer.Elapsed += UpdateSubtitle; - Timer.Start(); + StartTimer(); } public void StopSubtitleDisplay() @@ -34,12 +32,7 @@ public void StopSubtitleDisplay() ArgumentNullException.ThrowIfNull(subtitleTextBlock); Cues?.Clear(); subtitleTextBlock.ClearValue(Microsoft.UI.Xaml.Controls.TextBox.TextProperty); - if (Timer is null) - { - return; - } - Timer.Stop(); - Timer.Elapsed -= UpdateSubtitle; + StopTimer(); if(mauiMediaElement is null) { return; @@ -47,6 +40,27 @@ public void StopSubtitleDisplay() Dispatcher.Dispatch(() => mauiMediaElement?.Children.Remove(subtitleTextBlock)); } + void StartTimer() + { + if (Timer is not null) + { + Timer.Stop(); + Timer.Dispose(); + } + Timer = new System.Timers.Timer(1000); + Timer.Elapsed += UpdateSubtitle; + Timer.Start(); + } + + void StopTimer() + { + if (Timer is not null) + { + Timer.Elapsed -= UpdateSubtitle; + Timer.Stop(); + Timer.Dispose(); + } + } void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { ArgumentNullException.ThrowIfNull(MediaElement); diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index e487eb4721..446744090b 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -602,11 +602,12 @@ protected override void Dispose(bool disposing) { LocalBroadcastManager.GetInstance(Platform.AppContext).UnregisterReceiver(uiUpdateReceiver); } - + subtitleExtensions?.StopSubtitleDisplay(); StopService(); mediaSessionConnector?.SetPlayer(null); mediaSession?.Release(); + subtitleExtensions?.Dispose(); mediaSessionConnector?.Dispose(); mediaSession?.Dispose(); uiUpdateReceiver?.Dispose(); From fd173c22be26eb4f2e5e48484081a24cc40518f3 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 26 Jul 2024 17:34:50 -0700 Subject: [PATCH 82/98] Refactor SubtitleExtensions and MediaManager Updated SubtitleExtensions.android.cs: - Added using directives for CommunityToolkit.Maui namespaces. - Removed Dispose method; added finalizer to call StopSubtitleDisplay. - Corrected formatting in StartSubtitleDisplay method. - Used null-coalescing assignment in StartTimer method. - Set Timer to null in StopTimer method after disposal. - Unsubscribed from WindowsChanged event, cleared subtitle text, and removed subtitleView in StopSubtitleDisplay method. - Refactored UpdateSubtitle method with pattern matching and early return. - Refactored OnFullScreenChanged method with switch statement and thread-safe UI updates. Updated MediaManager.android.cs: - Used null-coalescing assignment for subtitleExtensions initialization. - Updated Dispose method to call subtitleExtensions?.Dispose(). --- .../Extensions/SubtitleExtensions.android.cs | 76 ++++++++++--------- .../Views/MediaManager.android.cs | 6 +- 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 3d61f34605..b2f6c04d34 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -2,6 +2,7 @@ using Android.Views; using Android.Widget; using Com.Google.Android.Exoplayer2.UI; +using CommunityToolkit.Maui.Core; using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; using static Android.Views.ViewGroup; @@ -27,20 +28,12 @@ public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatc InitializeLayout(); InitializeTextBlock(); } - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - Cues?.Clear(); - StopTimer(); - subtitleView?.Dispose(); - MediaManager.FullScreenEvents.WindowsChanged -= OnFullScreenChanged; - } public void StartSubtitleDisplay() { ArgumentNullException.ThrowIfNull(subtitleView); ArgumentNullException.ThrowIfNull(Cues); - if(Cues.Count == 0 || string.IsNullOrEmpty(MediaElement?.SubtitleUrl)) + + if (Cues.Count == 0 || string.IsNullOrEmpty(MediaElement?.SubtitleUrl)) { return; } @@ -51,6 +44,11 @@ public void StartSubtitleDisplay() StartTimer(); } + ~SubtitleExtensions() + { + StopSubtitleDisplay(); + } + void StartTimer() { if(Timer is not null) @@ -58,7 +56,7 @@ void StartTimer() Timer.Stop(); Timer.Dispose(); } - Timer = new System.Timers.Timer(1000); + Timer ??= new System.Timers.Timer(1000); Timer.Elapsed += UpdateSubtitle; Timer.Start(); } @@ -70,13 +68,17 @@ void StopTimer() Timer.Elapsed -= UpdateSubtitle; Timer.Stop(); Timer.Dispose(); + Timer = null; } } public void StopSubtitleDisplay() { + MediaManager.FullScreenEvents.WindowsChanged -= OnFullScreenChanged; + ArgumentNullException.ThrowIfNull(subtitleView); + subtitleView.Text = string.Empty; Cues?.Clear(); StopTimer(); - MediaManager.FullScreenEvents.WindowsChanged -= OnFullScreenChanged; + dispatcher.Dispatch(() => styledPlayerView?.RemoveView(subtitleView)); } void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) @@ -89,11 +91,14 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { return; } - - var cue = Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position); - dispatcher.Dispatch(() => + + if (Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position) is not SubtitleCue cue) { + return; + } + dispatcher.Dispatch(() => + { SetHeight(); if (cue is not null) { @@ -112,25 +117,28 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) { var layout = CurrentPlatformContext.CurrentWindow.DecorView as ViewGroup; ArgumentNullException.ThrowIfNull(layout); - if (e.NewState == MediaElementScreenState.FullScreen) - { - screenState = MediaElementScreenState.FullScreen; - styledPlayerView.RemoveView(subtitleView); - InitializeLayout(); - InitializeTextBlock(); - InitializeText(); - layout.AddView(subtitleView); - - } - else - { - screenState = MediaElementScreenState.Default; - layout.RemoveView(subtitleView); - InitializeLayout(); - InitializeTextBlock(); - InitializeText(); - styledPlayerView.AddView(subtitleView); - } + dispatcher.Dispatch(() => + { + switch(e.NewState) + { + case MediaElementScreenState.FullScreen: + screenState = MediaElementScreenState.FullScreen; + styledPlayerView.RemoveView(subtitleView); + InitializeLayout(); + InitializeTextBlock(); + InitializeText(); + layout.AddView(subtitleView); + break; + default: + screenState = MediaElementScreenState.Default; + layout.RemoveView(subtitleView); + InitializeLayout(); + InitializeTextBlock(); + InitializeText(); + styledPlayerView.AddView(subtitleView); + break; + } + }); } void SetHeight() { diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index 446744090b..233c2e018e 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -206,7 +206,7 @@ or PlaybackStateCompat.StateSkippingToQueueItem LayoutParameters = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent) }; - subtitleExtensions = new(PlayerView, Dispatcher); + subtitleExtensions ??= new(PlayerView, Dispatcher); checkPermissionsTask = CheckAndRequestForegroundPermission(checkPermissionSourceToken.Token); return (Player, PlayerView); } @@ -602,12 +602,11 @@ protected override void Dispose(bool disposing) { LocalBroadcastManager.GetInstance(Platform.AppContext).UnregisterReceiver(uiUpdateReceiver); } - subtitleExtensions?.StopSubtitleDisplay(); + subtitleExtensions?.Dispose(); StopService(); mediaSessionConnector?.SetPlayer(null); mediaSession?.Release(); - subtitleExtensions?.Dispose(); mediaSessionConnector?.Dispose(); mediaSession?.Dispose(); uiUpdateReceiver?.Dispose(); @@ -617,7 +616,6 @@ protected override void Dispose(bool disposing) httpClient.Dispose(); startSubtitles?.Dispose(); - startSubtitles = null; mediaSessionConnector = null; mediaSession = null; uiUpdateReceiver = null; From 4262cd501300677a2c2e03f812d9a9e24cd4ca62 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 26 Jul 2024 18:06:21 -0700 Subject: [PATCH 83/98] Refactor subtitle view management in SubtitleExtensions.macios.cs --- .../Extensions/SubtitleExtensions.macios.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index ff2b872b8b..64c5f151fe 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -163,12 +163,20 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) ArgumentNullException.ThrowIfNull(viewController); ArgumentNullException.ThrowIfNull(viewController.View); subtitleLabel.Frame = CalculateSubtitleFrame(viewController); - DispatchQueue.MainQueue.DispatchAsync(() => { viewController?.View?.Add(subtitleLabel); }); + DispatchQueue.MainQueue.DispatchAsync(() => + { + subtitleLabel.RemoveFromSuperview(); + viewController?.View?.Add(subtitleLabel); + }); break; case false: screenState = MediaElementScreenState.Default; subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); - DispatchQueue.MainQueue.DispatchAsync(() => { playerViewController.View?.AddSubview(subtitleLabel); }); + DispatchQueue.MainQueue.DispatchAsync(() => + { + subtitleLabel.RemoveFromSuperview(); + playerViewController.View?.AddSubview(subtitleLabel); + }); break; } } From ca21bbd2c0a1dda2dd14495a5cf22446c960f09d Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sat, 27 Jul 2024 10:25:03 -0700 Subject: [PATCH 84/98] Refactor subtitle view management in SubtitleExtensions.macios.cs --- .../Extensions/SubtitleExtensions.macios.cs | 87 ++++++++++++------- 1 file changed, 54 insertions(+), 33 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 64c5f151fe..675f076074 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -29,7 +29,7 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi subtitleLabel = new UILabel { - Frame = CalculateSubtitleFrame(playerViewController), + Frame = CalculateSubtitleFrame(playerViewController, 100), TextColor = UIColor.White, TextAlignment = UITextAlignment.Center, Font = UIFont.SystemFontOfSize(12), @@ -78,24 +78,10 @@ void UpdateSubtitle() return; } - switch (screenState) - { - case MediaElementScreenState.FullScreen: - var viewController = WindowStateManager.Default.GetCurrentUIViewController(); - ArgumentNullException.ThrowIfNull(viewController); - subtitleLabel.Frame = CalculateSubtitleFrame(viewController); - break; - case MediaElementScreenState.Default: - subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); - break; - } - var cue = Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position); if (cue is not null) { - subtitleLabel.Text = TextWrapper(cue.Text ?? string.Empty); - subtitleLabel.Font = UIFont.FromName(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).MacIOS, (float)MediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize(16); - subtitleLabel.BackgroundColor = subtitleBackgroundColor; + SetText(cue.Text); } else { @@ -103,6 +89,48 @@ void UpdateSubtitle() subtitleLabel.BackgroundColor = clearBackgroundColor; } } + void SetText(string? text) + { + ArgumentNullException.ThrowIfNull(text); + ArgumentNullException.ThrowIfNull(subtitleLabel); + ArgumentNullException.ThrowIfNull(MediaElement); + subtitleLabel.Text = TextWrapper(text ?? string.Empty); + subtitleLabel.Font = UIFont.FromName(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).MacIOS, (float)MediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize(16); + subtitleLabel.BackgroundColor = subtitleBackgroundColor; + + var nsString = new NSString(subtitleLabel.Text); + var attributes = new UIStringAttributes { Font = subtitleLabel.Font }; + var textSize = nsString.GetSizeUsingAttributes(attributes); + var labelWidth = textSize.Width + 5; + + switch (screenState) + { + case MediaElementScreenState.FullScreen: + var viewController = GetCurrentUIViewController(); + ArgumentNullException.ThrowIfNull(viewController); + subtitleLabel.Frame = CalculateSubtitleFrame(viewController, labelWidth); + break; + case MediaElementScreenState.Default: + subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController, labelWidth); + break; + } + } + + UIViewController GetCurrentUIViewController() + { + UIViewController? viewController = null; + + // Must use KeyWindow as it is the only one that will be available when the app is in full screen mode on macOS. + // It is deprecated for use in MacOS apps, but is still available and the only choice for this scenario. + #if MACCATALYST + viewController = UIApplication.SharedApplication.KeyWindow?.RootViewController ?? Platform.GetCurrentUIViewController(); + #endif + #if IOS + viewController = WindowStateManager.Default.GetCurrentUIViewController(); + #endif + ArgumentNullException.ThrowIfNull(viewController); + return viewController; + } static string TextWrapper(string input) { @@ -136,15 +164,17 @@ from Match match in words return wrappedTextBuilder.ToString(); } - static CGRect CalculateSubtitleFrame(UIViewController uIViewController) + + static CGRect CalculateSubtitleFrame(UIViewController uIViewController, nfloat labelWidth) { if (uIViewController is null || uIViewController.View is null) { return CGRect.Empty; } - var viewHeight = uIViewController.View.Bounds.Height; var viewWidth = uIViewController.View.Bounds.Width; - return new CGRect(0, viewHeight - 80, viewWidth, 50); + var viewHeight = uIViewController.View.Bounds.Height; + var x = (viewWidth - labelWidth) / 2; + return new CGRect(x, viewHeight - 80, labelWidth, 50); } void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) @@ -155,30 +185,21 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) return; } + DispatchQueue.MainQueue.DispatchAsync(subtitleLabel.RemoveFromSuperview); switch (e.NewState == MediaElementScreenState.FullScreen) { case true: - var viewController = WindowStateManager.Default.GetCurrentUIViewController(); + var viewController = GetCurrentUIViewController(); screenState = MediaElementScreenState.FullScreen; - ArgumentNullException.ThrowIfNull(viewController); ArgumentNullException.ThrowIfNull(viewController.View); - subtitleLabel.Frame = CalculateSubtitleFrame(viewController); - DispatchQueue.MainQueue.DispatchAsync(() => - { - subtitleLabel.RemoveFromSuperview(); - viewController?.View?.Add(subtitleLabel); - }); + DispatchQueue.MainQueue.DispatchAsync(() => viewController?.View?.Add(subtitleLabel)); break; case false: screenState = MediaElementScreenState.Default; - subtitleLabel.Frame = CalculateSubtitleFrame(playerViewController); - DispatchQueue.MainQueue.DispatchAsync(() => - { - subtitleLabel.RemoveFromSuperview(); - playerViewController.View?.AddSubview(subtitleLabel); - }); + DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); break; } + DispatchQueue.MainQueue.DispatchAsync(UpdateSubtitle); } ~SubtitleExtensions() From 2dfedae7b130a9de5cf22df7866d7d0a6d9d44be Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sat, 27 Jul 2024 10:28:07 -0700 Subject: [PATCH 85/98] Refactor SubtitleExtensions methods Modified SetText to pass text directly to TextWrapper, allowing null values. Changed GetCurrentUIViewController to a static method for class-level access. --- .../Extensions/SubtitleExtensions.macios.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 675f076074..fab1744892 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -94,7 +94,7 @@ void SetText(string? text) ArgumentNullException.ThrowIfNull(text); ArgumentNullException.ThrowIfNull(subtitleLabel); ArgumentNullException.ThrowIfNull(MediaElement); - subtitleLabel.Text = TextWrapper(text ?? string.Empty); + subtitleLabel.Text = TextWrapper(text); subtitleLabel.Font = UIFont.FromName(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).MacIOS, (float)MediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize(16); subtitleLabel.BackgroundColor = subtitleBackgroundColor; @@ -116,7 +116,7 @@ void SetText(string? text) } } - UIViewController GetCurrentUIViewController() + static UIViewController GetCurrentUIViewController() { UIViewController? viewController = null; From 6869d5a3a84922ed85a2271c2ec85fd18d4d05e9 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sat, 27 Jul 2024 10:34:55 -0700 Subject: [PATCH 86/98] Refactor UpdateSubtitle method for clarity and efficiency Streamlined null checks in `UpdateSubtitle` method by combining checks for `playerViewController` and `MediaElement.SubtitleUrl` into a single conditional statement. Removed redundant `ArgumentNullException.ThrowIfNull` calls for `Cues`, `subtitleLabel`, and `MediaElement`. --- .../Extensions/SubtitleExtensions.macios.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index fab1744892..5ef12253ce 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -64,16 +64,9 @@ public void StopSubtitleDisplay() void UpdateSubtitle() { - if (playerViewController is null) - { - return; - } - ArgumentNullException.ThrowIfNull(Cues); ArgumentNullException.ThrowIfNull(subtitleLabel); - ArgumentNullException.ThrowIfNull(MediaElement); - - if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) + if (playerViewController is null || string.IsNullOrEmpty(MediaElement?.SubtitleUrl)) { return; } From 6daff2d91a0ce50e18ff31fc47da14239c56d83a Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sat, 27 Jul 2024 11:36:30 -0700 Subject: [PATCH 87/98] Updated Spacing --- .../Extensions/SubtitleExtensions.macios.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 5ef12253ce..ee6727cdbb 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -115,12 +115,12 @@ static UIViewController GetCurrentUIViewController() // Must use KeyWindow as it is the only one that will be available when the app is in full screen mode on macOS. // It is deprecated for use in MacOS apps, but is still available and the only choice for this scenario. - #if MACCATALYST - viewController = UIApplication.SharedApplication.KeyWindow?.RootViewController ?? Platform.GetCurrentUIViewController(); - #endif - #if IOS - viewController = WindowStateManager.Default.GetCurrentUIViewController(); - #endif +#if MACCATALYST + viewController = UIApplication.SharedApplication.KeyWindow?.RootViewController ?? Platform.GetCurrentUIViewController(); +#endif +#if IOS + viewController = WindowStateManager.Default.GetCurrentUIViewController(); +#endif ArgumentNullException.ThrowIfNull(viewController); return viewController; } From a942033f29efd32a926dd9dd8d9a814841e1d15f Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sat, 27 Jul 2024 12:17:35 -0700 Subject: [PATCH 88/98] Refactor SubtitleExtensions.macios.cs for improved subtitle width calculation --- .../Extensions/SubtitleExtensions.macios.cs | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index ee6727cdbb..783278c30f 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -87,15 +87,12 @@ void SetText(string? text) ArgumentNullException.ThrowIfNull(text); ArgumentNullException.ThrowIfNull(subtitleLabel); ArgumentNullException.ThrowIfNull(MediaElement); + subtitleLabel.Text = TextWrapper(text); - subtitleLabel.Font = UIFont.FromName(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).MacIOS, (float)MediaElement.SubtitleFontSize) ?? UIFont.SystemFontOfSize(16); + subtitleLabel.Font = GetFontFamily(MediaElement.SubtitleFont, (float)MediaElement.SubtitleFontSize); subtitleLabel.BackgroundColor = subtitleBackgroundColor; - var nsString = new NSString(subtitleLabel.Text); - var attributes = new UIStringAttributes { Font = subtitleLabel.Font }; - var textSize = nsString.GetSizeUsingAttributes(attributes); - var labelWidth = textSize.Width + 5; - + var labelWidth = GetSubtileWidth(text); switch (screenState) { case MediaElementScreenState.FullScreen: @@ -109,6 +106,16 @@ void SetText(string? text) } } + nfloat GetSubtileWidth(string? text) + { + var nsString = new NSString(subtitleLabel.Text); + var attributes = new UIStringAttributes { Font = subtitleLabel.Font }; + var textSize = nsString.GetSizeUsingAttributes(attributes); + return textSize.Width + 5; + } + + static UIFont GetFontFamily(string fontFamily, float fontSize) => UIFont.FromName(new Core.FontExtensions.FontFamily(fontFamily).MacIOS, fontSize); + static UIViewController GetCurrentUIViewController() { UIViewController? viewController = null; @@ -116,7 +123,7 @@ static UIViewController GetCurrentUIViewController() // Must use KeyWindow as it is the only one that will be available when the app is in full screen mode on macOS. // It is deprecated for use in MacOS apps, but is still available and the only choice for this scenario. #if MACCATALYST - viewController = UIApplication.SharedApplication.KeyWindow?.RootViewController ?? Platform.GetCurrentUIViewController(); + viewController = UIApplication.SharedApplication.KeyWindow?.RootViewController; #endif #if IOS viewController = WindowStateManager.Default.GetCurrentUIViewController(); From e7669bbbf217081a813c15885becf22c38efc174 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sat, 27 Jul 2024 13:18:19 -0700 Subject: [PATCH 89/98] Refactor SubtitleExtensions.macios.cs for improved subtitle Size in Full Screen. --- .../Extensions/SubtitleExtensions.macios.cs | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 783278c30f..18892d37b3 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -82,17 +82,19 @@ void UpdateSubtitle() subtitleLabel.BackgroundColor = clearBackgroundColor; } } + void SetText(string? text) { ArgumentNullException.ThrowIfNull(text); ArgumentNullException.ThrowIfNull(subtitleLabel); ArgumentNullException.ThrowIfNull(MediaElement); + var fontSize = GetFontSize((float)MediaElement.SubtitleFontSize); subtitleLabel.Text = TextWrapper(text); - subtitleLabel.Font = GetFontFamily(MediaElement.SubtitleFont, (float)MediaElement.SubtitleFontSize); + subtitleLabel.Font = GetFontFamily(MediaElement.SubtitleFont, fontSize); subtitleLabel.BackgroundColor = subtitleBackgroundColor; - var labelWidth = GetSubtileWidth(text); + switch (screenState) { case MediaElementScreenState.FullScreen: @@ -114,19 +116,27 @@ nfloat GetSubtileWidth(string? text) return textSize.Width + 5; } + float GetFontSize(float fontSize) + { + ArgumentNullException.ThrowIfNull(MediaElement); + #if IOS + return fontSize; + #else + return screenState == MediaElementScreenState.FullScreen? (float)MediaElement.SubtitleFontSize * 1.5f : (float)MediaElement.SubtitleFontSize; + #endif + } + static UIFont GetFontFamily(string fontFamily, float fontSize) => UIFont.FromName(new Core.FontExtensions.FontFamily(fontFamily).MacIOS, fontSize); static UIViewController GetCurrentUIViewController() { UIViewController? viewController = null; - +#if IOS + viewController = WindowStateManager.Default.GetCurrentUIViewController(); +#else // Must use KeyWindow as it is the only one that will be available when the app is in full screen mode on macOS. // It is deprecated for use in MacOS apps, but is still available and the only choice for this scenario. -#if MACCATALYST viewController = UIApplication.SharedApplication.KeyWindow?.RootViewController; -#endif -#if IOS - viewController = WindowStateManager.Default.GetCurrentUIViewController(); #endif ArgumentNullException.ThrowIfNull(viewController); return viewController; From ff4f6a6ceba64f60763387c1e83a57e8ba3f59c2 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sat, 27 Jul 2024 13:34:22 -0700 Subject: [PATCH 90/98] Refactor subtitle width and font size calculations Modified `GetSubtileWidth` to use the `text` parameter directly, ensuring accurate width calculation based on provided text. Simplified `GetFontSize` by removing `MediaElement` dependency and applying a 1.5x multiplier for full screen, focusing on the `fontSize` parameter. --- .../Extensions/SubtitleExtensions.macios.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 18892d37b3..28dabb1886 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -110,7 +110,7 @@ void SetText(string? text) nfloat GetSubtileWidth(string? text) { - var nsString = new NSString(subtitleLabel.Text); + var nsString = new NSString(text); var attributes = new UIStringAttributes { Font = subtitleLabel.Font }; var textSize = nsString.GetSizeUsingAttributes(attributes); return textSize.Width + 5; @@ -118,11 +118,10 @@ nfloat GetSubtileWidth(string? text) float GetFontSize(float fontSize) { - ArgumentNullException.ThrowIfNull(MediaElement); #if IOS return fontSize; #else - return screenState == MediaElementScreenState.FullScreen? (float)MediaElement.SubtitleFontSize * 1.5f : (float)MediaElement.SubtitleFontSize; + return screenState == MediaElementScreenState.FullScreen? fontSize * 1.5f : fontSize; #endif } From 4414ae631dc591ead07a6ee1925cdeddf09e8340 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sun, 28 Jul 2024 05:33:19 -0700 Subject: [PATCH 91/98] Add Dispose method to SubtitleExtensions for resource cleanup Updated SubtitleExtensions in android and macios to include a Dispose method for proper resource cleanup. Removed finalizers as Dispose now handles cleanup. This prevents potential memory leaks by ensuring resources are properly disposed of. --- .../Extensions/SubtitleExtensions.android.cs | 14 +++++++++----- .../Extensions/SubtitleExtensions.macios.cs | 13 +++++++------ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index b2f6c04d34..53156f2ef2 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -28,6 +28,15 @@ public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatc InitializeLayout(); InitializeTextBlock(); } + + protected override void Dispose(bool disposing) + { + StopSubtitleDisplay(); + subtitleLayout?.Dispose(); + subtitleView?.Dispose(); + base.Dispose(disposing); + } + public void StartSubtitleDisplay() { ArgumentNullException.ThrowIfNull(subtitleView); @@ -44,11 +53,6 @@ public void StartSubtitleDisplay() StartTimer(); } - ~SubtitleExtensions() - { - StopSubtitleDisplay(); - } - void StartTimer() { if(Timer is not null) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs index 28dabb1886..8544ccf2f2 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -41,6 +41,13 @@ public SubtitleExtensions(PlatformMediaElement player, UIViewController playerVi MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; } + protected override void Dispose(bool disposing) + { + MediaManager.FullScreenEvents.WindowsChanged -= OnFullScreenChanged; + playerObserver?.Dispose(); + base.Dispose(disposing); + } + public void StartSubtitleDisplay() { ArgumentNullException.ThrowIfNull(subtitleLabel); @@ -211,12 +218,6 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) DispatchQueue.MainQueue.DispatchAsync(UpdateSubtitle); } - ~SubtitleExtensions() - { - MediaManager.FullScreenEvents.WindowsChanged -= OnFullScreenChanged; - playerObserver?.Dispose(); - } - [GeneratedRegex(@"\b\w+\b")] private static partial Regex MatchWorksRegex(); } From 7dab55e54397f196eb705a25a13c3622c6fda45d Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sun, 28 Jul 2024 20:21:12 -0700 Subject: [PATCH 92/98] Refactor SubtitleExtensions and add CancellationToken support - Refactored SubtitleExtensions to inherit from SubtitleTimer and implement IDisposable for resource management. - Renamed subtitleView to subtitleTextBlock for clarity. - Added CancellationToken support to LoadSubtitles and SubtitleParser.Content methods. - Updated MediaManager classes to pass CancellationToken. - Enhanced OnFullScreenChanged method and updated related methods to use subtitleTextBlock. - Included debug logging in UpdateSubtitle. - Improved code style and added comments for better readability. --- .../Extensions/SubtitleExtensions.android.cs | 111 ++++++++---------- .../Extensions/SubtitleExtensions.shared.cs | 45 ++++++- .../Extensions/SubtitleExtensions.windows.cs | 82 +++---------- .../Extensions/SubtitleParser.cs | 4 +- .../Views/MediaManager.android.cs | 2 +- .../Views/MediaManager.macios.cs | 4 +- .../Views/MediaManager.windows.cs | 4 +- .../Extensions/SubtitleExtensionsTests.cs | 9 +- 8 files changed, 121 insertions(+), 140 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 53156f2ef2..e5749a1b4e 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -11,13 +11,12 @@ namespace CommunityToolkit.Maui.Extensions; -partial class SubtitleExtensions : Java.Lang.Object +partial class SubtitleExtensions : SubtitleTimer, IDisposable { - readonly IDispatcher dispatcher; FrameLayout.LayoutParams? subtitleLayout; readonly StyledPlayerView styledPlayerView; - TextView? subtitleView; MediaElementScreenState screenState; + bool disposedValue; public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatcher) { @@ -28,18 +27,10 @@ public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatc InitializeLayout(); InitializeTextBlock(); } - - protected override void Dispose(bool disposing) - { - StopSubtitleDisplay(); - subtitleLayout?.Dispose(); - subtitleView?.Dispose(); - base.Dispose(disposing); - } - + public void StartSubtitleDisplay() { - ArgumentNullException.ThrowIfNull(subtitleView); + ArgumentNullException.ThrowIfNull(subtitleTextBlock); ArgumentNullException.ThrowIfNull(Cues); if (Cues.Count == 0 || string.IsNullOrEmpty(MediaElement?.SubtitleUrl)) @@ -49,45 +40,24 @@ public void StartSubtitleDisplay() MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; InitializeText(); - dispatcher.Dispatch(() => styledPlayerView.AddView(subtitleView)); + dispatcher.Dispatch(() => styledPlayerView.AddView(subtitleTextBlock)); StartTimer(); } - - void StartTimer() - { - if(Timer is not null) - { - Timer.Stop(); - Timer.Dispose(); - } - Timer ??= new System.Timers.Timer(1000); - Timer.Elapsed += UpdateSubtitle; - Timer.Start(); - } - - void StopTimer() - { - if (Timer is not null) - { - Timer.Elapsed -= UpdateSubtitle; - Timer.Stop(); - Timer.Dispose(); - Timer = null; - } - } public void StopSubtitleDisplay() { + StopTimer(); MediaManager.FullScreenEvents.WindowsChanged -= OnFullScreenChanged; - ArgumentNullException.ThrowIfNull(subtitleView); - subtitleView.Text = string.Empty; + ArgumentNullException.ThrowIfNull(subtitleTextBlock); + subtitleTextBlock.Text = string.Empty; Cues?.Clear(); - StopTimer(); - dispatcher.Dispatch(() => styledPlayerView?.RemoveView(subtitleView)); + + dispatcher.Dispatch(() => styledPlayerView?.RemoveView(subtitleTextBlock)); } - void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) + public override void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { - ArgumentNullException.ThrowIfNull(subtitleView); + System.Diagnostics.Debug.WriteLine("UpdateSubtitle"); + ArgumentNullException.ThrowIfNull(subtitleTextBlock); ArgumentNullException.ThrowIfNull(MediaElement); ArgumentNullException.ThrowIfNull(Cues); @@ -106,13 +76,13 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) SetHeight(); if (cue is not null) { - subtitleView.Text = cue.Text; - subtitleView.Visibility = ViewStates.Visible; + subtitleTextBlock.Text = cue.Text; + subtitleTextBlock.Visibility = ViewStates.Visible; } else { - subtitleView.Text = string.Empty; - subtitleView.Visibility = ViewStates.Gone; + subtitleTextBlock.Text = string.Empty; + subtitleTextBlock.Visibility = ViewStates.Gone; } }); } @@ -127,26 +97,26 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) { case MediaElementScreenState.FullScreen: screenState = MediaElementScreenState.FullScreen; - styledPlayerView.RemoveView(subtitleView); + styledPlayerView.RemoveView(subtitleTextBlock); InitializeLayout(); InitializeTextBlock(); InitializeText(); - layout.AddView(subtitleView); + layout.AddView(subtitleTextBlock); break; default: screenState = MediaElementScreenState.Default; - layout.RemoveView(subtitleView); + layout.RemoveView(subtitleTextBlock); InitializeLayout(); InitializeTextBlock(); InitializeText(); - styledPlayerView.AddView(subtitleView); + styledPlayerView.AddView(subtitleTextBlock); break; } }); } void SetHeight() { - if (styledPlayerView is null || subtitleLayout is null || subtitleView is null) + if (styledPlayerView is null || subtitleLayout is null || subtitleTextBlock is null) { return; } @@ -164,15 +134,15 @@ void SetHeight() } void InitializeText() { - ArgumentNullException.ThrowIfNull(subtitleView); + ArgumentNullException.ThrowIfNull(subtitleTextBlock); ArgumentNullException.ThrowIfNull(MediaElement); Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).Android) ?? Typeface.Default; - subtitleView.TextSize = (float)MediaElement.SubtitleFontSize; - subtitleView.SetTypeface(typeface, TypefaceStyle.Normal); + subtitleTextBlock.TextSize = (float)MediaElement.SubtitleFontSize; + subtitleTextBlock.SetTypeface(typeface, TypefaceStyle.Normal); } void InitializeTextBlock() { - subtitleView = new(CurrentPlatformActivity.CurrentActivity.ApplicationContext) + subtitleTextBlock = new(CurrentPlatformActivity.CurrentActivity.ApplicationContext) { Text = string.Empty, HorizontalScrollBarEnabled = false, @@ -181,9 +151,9 @@ void InitializeTextBlock() Visibility = Android.Views.ViewStates.Gone, LayoutParameters = subtitleLayout }; - subtitleView.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); - subtitleView.SetTextColor(Android.Graphics.Color.White); - subtitleView.SetPaddingRelative(10, 10, 10, 20); + subtitleTextBlock.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); + subtitleTextBlock.SetTextColor(Android.Graphics.Color.White); + subtitleTextBlock.SetPaddingRelative(10, 10, 10, 20); } void InitializeLayout() { @@ -192,4 +162,27 @@ void InitializeLayout() Gravity = GravityFlags.Center | GravityFlags.Bottom, }; } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + + if (disposing) + { + MediaManager.FullScreenEvents.WindowsChanged -= OnFullScreenChanged; + StopTimer(); + subtitleLayout?.Dispose(); + subtitleTextBlock?.Dispose(); + } + + disposedValue = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs index b0207ecbdc..724890005b 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs @@ -1,4 +1,4 @@ -// NOTE: PR shares code with #2041 https://github.com/CommunityToolkit/Maui/pull/2041 +using System.ComponentModel.DataAnnotations; using CommunityToolkit.Maui.Core; namespace CommunityToolkit.Maui.Extensions; @@ -6,8 +6,7 @@ partial class SubtitleExtensions { public IMediaElement? MediaElement; public List? Cues; - public System.Timers.Timer? Timer; - public async Task LoadSubtitles(IMediaElement mediaElement) + public async Task LoadSubtitles(IMediaElement mediaElement, CancellationToken token) { Cues ??= []; this.MediaElement = mediaElement; @@ -24,7 +23,7 @@ public async Task LoadSubtitles(IMediaElement mediaElement) return; } SubtitleParser parser; - var content = await SubtitleParser.Content(mediaElement.SubtitleUrl); + var content = await SubtitleParser.Content(mediaElement.SubtitleUrl, token); try { @@ -56,3 +55,41 @@ public async Task LoadSubtitles(IMediaElement mediaElement) } } } +interface ITimer where T : class +{ + public abstract System.Timers.Timer? timer { get; set; } + public abstract T? subtitleTextBlock { get; set; } + public abstract void StartTimer(); + public abstract void StopTimer(); + public abstract void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e); +} + +abstract class SubtitleTimer : ITimer where T : class +{ + + [Required] + public IDispatcher dispatcher { get; set; } = null!; + public System.Timers.Timer? timer { get; set; } + public T? subtitleTextBlock { get; set; } + public void StartTimer() + { + if (timer is not null) + { + timer.Stop(); + timer.Dispose(); + } + timer = new System.Timers.Timer(1000); + timer.Elapsed += UpdateSubtitle; + timer.Start(); + } + public void StopTimer() + { + if (timer is not null) + { + timer.Elapsed -= UpdateSubtitle; + timer.Stop(); + timer.Dispose(); + } + } + public abstract void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e); +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 889d42192b..511a9dded3 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -1,29 +1,28 @@ using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; +using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; using Grid = Microsoft.Maui.Controls.Grid; namespace CommunityToolkit.Maui.Extensions; -partial class SubtitleExtensions : Grid, IDisposable + +partial class SubtitleExtensions : SubtitleTimer { - bool disposedValue; - bool isFullScreen = false; - Microsoft.UI.Xaml.Controls.TextBox? subtitleTextBlock; readonly MauiMediaElement? mauiMediaElement; readonly int width; - public SubtitleExtensions(Microsoft.UI.Xaml.Controls.MediaPlayerElement player) - { + public SubtitleExtensions(Microsoft.UI.Xaml.Controls.MediaPlayerElement player, IDispatcher dispatcher) + { + this.dispatcher = dispatcher; width = (int)player.ActualWidth / 3; mauiMediaElement = player.Parent as MauiMediaElement; MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; InitializeTextBlock(); } - public void StartSubtitleDisplay() { - Dispatcher.Dispatch(() => mauiMediaElement?.Children.Add(subtitleTextBlock)); + dispatcher.Dispatch(() => mauiMediaElement?.Children.Add(subtitleTextBlock)); StartTimer(); } @@ -37,31 +36,10 @@ public void StopSubtitleDisplay() { return; } - Dispatcher.Dispatch(() => mauiMediaElement?.Children.Remove(subtitleTextBlock)); + dispatcher.Dispatch(() => mauiMediaElement?.Children.Remove(subtitleTextBlock)); } - void StartTimer() - { - if (Timer is not null) - { - Timer.Stop(); - Timer.Dispose(); - } - Timer = new System.Timers.Timer(1000); - Timer.Elapsed += UpdateSubtitle; - Timer.Start(); - } - - void StopTimer() - { - if (Timer is not null) - { - Timer.Elapsed -= UpdateSubtitle; - Timer.Stop(); - Timer.Dispose(); - } - } - void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) + public override void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { ArgumentNullException.ThrowIfNull(MediaElement); ArgumentNullException.ThrowIfNull(subtitleTextBlock); @@ -70,7 +48,7 @@ void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) return; } var cue = Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position); - Dispatcher.Dispatch(() => + dispatcher.Dispatch(() => { if (cue is not null) { @@ -98,21 +76,19 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) return; } - switch (isFullScreen) + switch (e.NewState) { - case true: + case MediaElementScreenState.Default: subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 20); subtitleTextBlock.FontSize = MediaElement.SubtitleFontSize; subtitleTextBlock.Width = width; - Dispatcher.Dispatch(() => { gridItem.Children.Remove(subtitleTextBlock); mauiMediaElement.Children.Add(subtitleTextBlock); }); - isFullScreen = false; + dispatcher.Dispatch(() => { gridItem.Children.Remove(subtitleTextBlock); mauiMediaElement.Children.Add(subtitleTextBlock); }); break; - case false: + case MediaElementScreenState.FullScreen: subtitleTextBlock.FontSize = MediaElement.SubtitleFontSize + 8.0; subtitleTextBlock.Width = DeviceDisplay.Current.MainDisplayInfo.Width / 4; subtitleTextBlock.Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 100); - Dispatcher.Dispatch(() => { mauiMediaElement.Children.Remove(subtitleTextBlock); gridItem.Children.Add(subtitleTextBlock); }); - isFullScreen = true; + dispatcher.Dispatch(() => { mauiMediaElement.Children.Remove(subtitleTextBlock); gridItem.Children.Add(subtitleTextBlock); }); break; } } @@ -145,32 +121,4 @@ void InitializeText() subtitleTextBlock.HorizontalTextAlignment = Microsoft.UI.Xaml.TextAlignment.Center; subtitleTextBlock.FontFamily = new FontFamily(new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).WindowsFont); } - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) - { - if (Timer is not null) - { - Timer.Stop(); - Timer.Elapsed -= UpdateSubtitle; - } - if (disposing) - { - Timer?.Dispose(); - } - Timer = null; - disposedValue = true; - } - } - - ~SubtitleExtensions() - { - Dispose(disposing: false); - } - - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); - } } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs index 95a939a33c..cc03d4e9ab 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs @@ -38,11 +38,11 @@ public virtual List ParseContent(string content) return IParser.ParseContent(content); } - internal static async Task Content(string subtitleUrl) + internal static async Task Content(string subtitleUrl, CancellationToken token = default) { try { - return await httpClient.GetStringAsync(subtitleUrl).ConfigureAwait(false); + return await httpClient.GetStringAsync(subtitleUrl, token).ConfigureAwait(false); } catch (Exception ex) { diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index 233c2e018e..a3ebe6dec3 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -463,7 +463,7 @@ async Task LoadSubtitles(CancellationToken cancellationToken = default) { return; } - await subtitleExtensions.LoadSubtitles(MediaElement).WaitAsync(cancellationToken).ConfigureAwait(false); + await subtitleExtensions.LoadSubtitles(MediaElement, cancellationToken).ConfigureAwait(false); subtitleExtensions.StartSubtitleDisplay(); } protected virtual partial void PlatformUpdateAspect() diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index 11c31f83da..5cbf6341c3 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -326,7 +326,7 @@ async Task LoadSubtitles(CancellationToken cancellationToken = default) System.Diagnostics.Trace.TraceError("SubtitleExtensions is null or SubtitleUrl is null or empty."); return; } - await subtitleExtensions.LoadSubtitles(MediaElement).WaitAsync(cancellationToken).ConfigureAwait(false); + await subtitleExtensions.LoadSubtitles(MediaElement, cancellationToken).ConfigureAwait(false); subtitleExtensions.StartSubtitleDisplay(); } @@ -443,7 +443,7 @@ protected virtual void Dispose(bool disposing) { UIApplication.SharedApplication.EndReceivingRemoteControlEvents(); }); - // disable the idle Timer so screen turns off when media is not playing + // disable the idle timer so screen turns off when media is not playing UIApplication.SharedApplication.IdleTimerDisabled = false; var audioSession = AVAudioSession.SharedInstance(); audioSession.SetActive(false); diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs index 5ad688b28c..7fa9b53294 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs @@ -311,8 +311,8 @@ async Task LoadSubtitles(CancellationToken cancellationToken = default) System.Diagnostics.Trace.TraceError("Player is null"); return; } - subtitleExtensions ??= new(Player); - await subtitleExtensions.LoadSubtitles(MediaElement).WaitAsync(cancellationToken).ConfigureAwait(false); + subtitleExtensions ??= new(Player, Dispatcher); + await subtitleExtensions.LoadSubtitles(MediaElement, cancellationToken).ConfigureAwait(false); subtitleExtensions.StartSubtitleDisplay(); } protected virtual partial void PlatformUpdateShouldLoopPlayback() diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs index 29267493c5..4bc90f1dcb 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs @@ -12,12 +12,13 @@ public void LoadSubtitles_Validate() { // Arrange IMediaElement mediaElement = new MediaElement(); + CancellationToken token = new(); // Act SubtitleExtensions subtitleExtensions = new(); // Assert - Assert.NotNull(subtitleExtensions.LoadSubtitles(mediaElement)); + Assert.NotNull(subtitleExtensions.LoadSubtitles(mediaElement, token)); } [Fact] @@ -25,12 +26,13 @@ public async Task LoadSubtitles_InvalidSubtitleExtensions_ThrowsNullReferenceExc { // Arrange IMediaElement mediaElement = new MediaElement(); + CancellationToken token = new(); // Act SubtitleExtensions subtitleExtensions = null!; // Assert - await Assert.ThrowsAsync(async () => await subtitleExtensions.LoadSubtitles(mediaElement)); + await Assert.ThrowsAsync(async () => await subtitleExtensions.LoadSubtitles(mediaElement, token)); } [Fact] @@ -75,8 +77,9 @@ public async Task SetSubtitleSource_InvalidUri_ThrowsArgumentExceptionAsync() var invalidSubtitleUrl = "invalid://uri"; mediaElement.SubtitleUrl = invalidSubtitleUrl; SubtitleExtensions subtitleExtensions = new(); + CancellationToken token = new(); // Act & Assert - await Assert.ThrowsAsync(async () => await subtitleExtensions.LoadSubtitles(mediaElement)); + await Assert.ThrowsAsync(async () => await subtitleExtensions.LoadSubtitles(mediaElement, token)); } } \ No newline at end of file From 6ae8dead46fae339df271f0922d7658b1720902a Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sun, 28 Jul 2024 20:38:18 -0700 Subject: [PATCH 93/98] Fix spacing issues --- .../Extensions/SubtitleExtensions.android.cs | 6 +++++- .../Extensions/SubtitleExtensions.shared.cs | 2 +- .../Extensions/SubtitleExtensions.windows.cs | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index e5749a1b4e..520c4dced9 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -43,6 +43,7 @@ public void StartSubtitleDisplay() dispatcher.Dispatch(() => styledPlayerView.AddView(subtitleTextBlock)); StartTimer(); } + public void StopSubtitleDisplay() { StopTimer(); @@ -56,7 +57,6 @@ public void StopSubtitleDisplay() public override void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) { - System.Diagnostics.Debug.WriteLine("UpdateSubtitle"); ArgumentNullException.ThrowIfNull(subtitleTextBlock); ArgumentNullException.ThrowIfNull(MediaElement); ArgumentNullException.ThrowIfNull(Cues); @@ -114,6 +114,7 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) } }); } + void SetHeight() { if (styledPlayerView is null || subtitleLayout is null || subtitleTextBlock is null) @@ -132,6 +133,7 @@ void SetHeight() } dispatcher.Dispatch(() => subtitleLayout?.SetMargins(20, 0, 20, height)); } + void InitializeText() { ArgumentNullException.ThrowIfNull(subtitleTextBlock); @@ -140,6 +142,7 @@ void InitializeText() subtitleTextBlock.TextSize = (float)MediaElement.SubtitleFontSize; subtitleTextBlock.SetTypeface(typeface, TypefaceStyle.Normal); } + void InitializeTextBlock() { subtitleTextBlock = new(CurrentPlatformActivity.CurrentActivity.ApplicationContext) @@ -155,6 +158,7 @@ void InitializeTextBlock() subtitleTextBlock.SetTextColor(Android.Graphics.Color.White); subtitleTextBlock.SetPaddingRelative(10, 10, 10, 20); } + void InitializeLayout() { subtitleLayout = new FrameLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs index 724890005b..0fca30505d 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs @@ -55,6 +55,7 @@ public async Task LoadSubtitles(IMediaElement mediaElement, CancellationToken to } } } + interface ITimer where T : class { public abstract System.Timers.Timer? timer { get; set; } @@ -66,7 +67,6 @@ interface ITimer where T : class abstract class SubtitleTimer : ITimer where T : class { - [Required] public IDispatcher dispatcher { get; set; } = null!; public System.Timers.Timer? timer { get; set; } diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs index 511a9dded3..4f003d2d3a 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -6,7 +6,6 @@ namespace CommunityToolkit.Maui.Extensions; - partial class SubtitleExtensions : SubtitleTimer { readonly MauiMediaElement? mauiMediaElement; @@ -20,6 +19,7 @@ public SubtitleExtensions(Microsoft.UI.Xaml.Controls.MediaPlayerElement player, MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; InitializeTextBlock(); } + public void StartSubtitleDisplay() { dispatcher.Dispatch(() => mauiMediaElement?.Children.Add(subtitleTextBlock)); @@ -92,6 +92,7 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) break; } } + void InitializeTextBlock() { subtitleTextBlock = new() From 22a964eab6e297fafacf1a83b2e796f096d9ff55 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Sun, 28 Jul 2024 21:03:18 -0700 Subject: [PATCH 94/98] Refactor to remove early return in subtitle cue logic Refactored the code to eliminate the early return pattern when no matching `SubtitleCue` is found. The `cue` variable is now assigned directly, and subsequent logic is wrapped in a conditional check to handle the case when `cue` is not null. This change simplifies the code flow by reducing the number of return statements and consolidating the logic within a single block. --- .../Extensions/SubtitleExtensions.android.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 520c4dced9..3520d67927 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -66,11 +66,7 @@ public override void UpdateSubtitle(object? sender, System.Timers.ElapsedEventAr return; } - if (Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position) is not SubtitleCue cue) - { - return; - } - + var cue = Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position); dispatcher.Dispatch(() => { SetHeight(); From a5d7383ccedba89669e34f6e6ea3ee30a494f972 Mon Sep 17 00:00:00 2001 From: ne0rrmatrix Date: Fri, 9 Aug 2024 03:44:41 -0700 Subject: [PATCH 95/98] Fix merge errors --- .../Pages/Views/MediaElement/MediaElementPage.xaml.cs | 1 + .../Views/MediaManager.windows.cs | 1 + 2 files changed, 2 insertions(+) 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 a04986660b..27fd9b1fd9 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -230,6 +230,7 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); + return; case loadMusic: MediaElement.MetadataTitle = "HAL 9000"; diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs index e29928f018..445d93ad22 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Maui.Core.Primitives; +using CommunityToolkit.Maui.Extensions; using CommunityToolkit.Maui.Views; using Microsoft.Extensions.Logging; using Microsoft.UI.Xaml.Controls; From 581dcddfd06c09941460c3cd5fde67c1c0192233 Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Tue, 10 Sep 2024 07:59:46 -0700 Subject: [PATCH 96/98] Fix merge bug --- .../Pages/Views/MediaElement/MediaElementPage.xaml.cs | 1 + 1 file changed, 1 insertion(+) 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 e058975ac2..7ab67610bd 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -8,6 +8,7 @@ using System.Globalization; using System.Text.RegularExpressions; using System.Text; +using CommunityToolkit.Maui.Core; namespace CommunityToolkit.Maui.Sample.Pages.Views; From bddca10fad6fec2ed937e871e8aaf5b9e3c459d9 Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Sun, 20 Oct 2024 23:59:10 -0700 Subject: [PATCH 97/98] Fix MediaElement source assignment in ChangeSourceClicked Removed an incomplete line that set `MediaElement.Source` without a value. Now, `MediaElement.Source` is correctly assigned to a `MediaSource` created from the `hlsStreamTestUrl` URI, ensuring proper source update in the `loadHls` case. --- .../Pages/Views/MediaElement/MediaElementPage.xaml.cs | 1 - 1 file changed, 1 deletion(-) 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 177d56a9f4..546fe5c774 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml.cs @@ -185,7 +185,6 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.MetadataArtworkUrl = botImageUrl; MediaElement.MetadataTitle = "HLS Title"; MediaElement.SubtitleUrl = string.Empty; - MediaElement.Source MediaElement.Source = MediaSource.FromUri(hlsStreamTestUrl); return; From 08a6d76dab19ffeae8df6cb6200a09db91dc0086 Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Mon, 20 Jan 2025 01:35:09 -0800 Subject: [PATCH 98/98] Fix merge issues --- .../Extensions/SubtitleExtensions.android.cs | 29 ++++---- .../Views/MauiMediaElement.android.cs | 3 +- .../Views/MauiMediaElement.windows.cs | 2 +- .../Views/MediaManager.android.cs | 71 +++++++++---------- 4 files changed, 48 insertions(+), 57 deletions(-) diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs index 3520d67927..c02a982993 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -1,28 +1,25 @@ using Android.Graphics; using Android.Views; using Android.Widget; -using Com.Google.Android.Exoplayer2.UI; -using CommunityToolkit.Maui.Core; +using AndroidX.Media3.UI; using CommunityToolkit.Maui.Core.Views; using CommunityToolkit.Maui.Primitives; using static Android.Views.ViewGroup; -using static CommunityToolkit.Maui.Core.Views.MauiMediaElement; -using CurrentPlatformActivity = CommunityToolkit.Maui.Core.Views.MauiMediaElement.CurrentPlatformContext; namespace CommunityToolkit.Maui.Extensions; partial class SubtitleExtensions : SubtitleTimer, IDisposable { FrameLayout.LayoutParams? subtitleLayout; - readonly StyledPlayerView styledPlayerView; + readonly PlayerView playerView; MediaElementScreenState screenState; bool disposedValue; - public SubtitleExtensions(StyledPlayerView styledPlayerView, IDispatcher dispatcher) + public SubtitleExtensions(PlayerView styledPlayerView, IDispatcher dispatcher) { screenState = MediaElementScreenState.Default; this.dispatcher = dispatcher; - this.styledPlayerView = styledPlayerView; + this.playerView = styledPlayerView; Cues = []; InitializeLayout(); InitializeTextBlock(); @@ -40,7 +37,7 @@ public void StartSubtitleDisplay() MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; InitializeText(); - dispatcher.Dispatch(() => styledPlayerView.AddView(subtitleTextBlock)); + dispatcher.Dispatch(() => playerView.AddView(subtitleTextBlock)); StartTimer(); } @@ -52,7 +49,7 @@ public void StopSubtitleDisplay() subtitleTextBlock.Text = string.Empty; Cues?.Clear(); - dispatcher.Dispatch(() => styledPlayerView?.RemoveView(subtitleTextBlock)); + dispatcher.Dispatch(() => playerView?.RemoveView(subtitleTextBlock)); } public override void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) @@ -61,7 +58,7 @@ public override void UpdateSubtitle(object? sender, System.Timers.ElapsedEventAr ArgumentNullException.ThrowIfNull(MediaElement); ArgumentNullException.ThrowIfNull(Cues); - if (Cues.Count == 0 || styledPlayerView is null) + if (Cues.Count == 0 || playerView is null) { return; } @@ -85,7 +82,7 @@ public override void UpdateSubtitle(object? sender, System.Timers.ElapsedEventAr void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) { - var layout = CurrentPlatformContext.CurrentWindow.DecorView as ViewGroup; + var layout = Platform.CurrentActivity?.Window?.DecorView as ViewGroup; ArgumentNullException.ThrowIfNull(layout); dispatcher.Dispatch(() => { @@ -93,7 +90,7 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) { case MediaElementScreenState.FullScreen: screenState = MediaElementScreenState.FullScreen; - styledPlayerView.RemoveView(subtitleTextBlock); + playerView.RemoveView(subtitleTextBlock); InitializeLayout(); InitializeTextBlock(); InitializeText(); @@ -105,7 +102,7 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) InitializeLayout(); InitializeTextBlock(); InitializeText(); - styledPlayerView.AddView(subtitleTextBlock); + playerView.AddView(subtitleTextBlock); break; } }); @@ -113,11 +110,11 @@ void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) void SetHeight() { - if (styledPlayerView is null || subtitleLayout is null || subtitleTextBlock is null) + if (playerView is null || subtitleLayout is null || subtitleTextBlock is null) { return; } - int height = styledPlayerView.Height; + int height = playerView.Height; switch (screenState) { case MediaElementScreenState.Default: @@ -141,7 +138,7 @@ void InitializeText() void InitializeTextBlock() { - subtitleTextBlock = new(CurrentPlatformActivity.CurrentActivity.ApplicationContext) + subtitleTextBlock = new(Platform.CurrentActivity?.ApplicationContext) { Text = string.Empty, HorizontalScrollBarEnabled = false, diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs index 29bb8f4f87..15e653c5a3 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.android.cs @@ -6,6 +6,7 @@ using AndroidX.CoordinatorLayout.Widget; using AndroidX.Core.View; using AndroidX.Media3.UI; +using CommunityToolkit.Maui.Primitives; using CommunityToolkit.Maui.Views; namespace CommunityToolkit.Maui.Core.Views; @@ -22,8 +23,6 @@ public class MauiMediaElement : CoordinatorLayout bool isSystemBarVisible; bool isFullScreen; - readonly RelativeLayout relativeLayout; - #pragma warning disable CS8618 // Non-nullable field is uninitialized. Consider declaring as nullable. #pragma warning disable IDE0060 // Remove unused parameter public MauiMediaElement(nint ptr, JniHandleOwnership jni) : base(Platform.AppContext) diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs index 10ba8a78f4..a47185f7b1 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs @@ -186,7 +186,7 @@ void OnFullScreenButtonClick(object sender, RoutedEventArgs e) fullScreenButton.Content = exitFullScreenIcon; fullScreenGrid.Children.Add(mediaPlayerElement); fullScreenGrid.Children.Add(buttonContainer); - + popup.XamlRoot = mediaPlayerElement.XamlRoot; popup.HorizontalOffset = 0; popup.VerticalOffset = 0; diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs index a9da0d8cff..e0078a2315 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -3,7 +3,6 @@ using Android.Views; using Android.Widget; using AndroidX.Media3.Common; -using AndroidX.Media3.Common.Text; using AndroidX.Media3.Common.Util; using AndroidX.Media3.ExoPlayer; using AndroidX.Media3.Session; @@ -14,8 +13,6 @@ using CommunityToolkit.Maui.Services; using CommunityToolkit.Maui.Views; using Microsoft.Extensions.Logging; -using AudioAttributes = AndroidX.Media3.Common.AudioAttributes; -using DeviceInfo = AndroidX.Media3.Common.DeviceInfo; using MediaMetadata = AndroidX.Media3.Common.MediaMetadata; namespace CommunityToolkit.Maui.Core.Views; @@ -23,7 +20,6 @@ namespace CommunityToolkit.Maui.Core.Views; public partial class MediaManager : Java.Lang.Object, IPlayerListener { SubtitleExtensions? subtitleExtensions; - static readonly HttpClient httpClient = new(); const int bufferState = 2; const int readyState = 3; const int endedState = 4; @@ -31,8 +27,8 @@ public partial class MediaManager : Java.Lang.Object, IPlayerListener static readonly HttpClient client = new(); readonly SemaphoreSlim seekToSemaphoreSlim = new(1, 1); - CancellationTokenSource checkPermissionSourceToken = new(); - CancellationTokenSource startServiceSourceToken = new(); + double? previousSpeed; + float volumeBeforeMute = 1; TaskCompletionSource? seekToTaskCompletionSource; CancellationTokenSource? cancellationTokenSource; @@ -40,12 +36,10 @@ public partial class MediaManager : Java.Lang.Object, IPlayerListener MediaItem.Builder? mediaItem; BoundServiceConnection? connection; - MediaElementState currentState; - /// + /// The platform native counterpart of . + /// protected PlayerView? PlayerView { get; set; } - [Obsolete] - protected StyledPlayerView? PlayerView { get; set; } /// /// Occurs when ExoPlayer changes the playback parameters. @@ -86,9 +80,9 @@ public void UpdateNotifications() /// The state that the player has transitioned to. /// /// This is part of the implementation. + /// While this method does not seem to have any references, it's invoked at runtime. + /// public void OnPlayerStateChanged(bool playWhenReady, int playbackState) - [Obsolete] - public async void OnPlayerStateChanged(bool playWhenReady, int playbackState) { if (Player is null || MediaElement.Source is null) { @@ -131,11 +125,11 @@ or PlaybackState.StateSkippingToQueueItem /// /// Creates the corresponding platform view of on Android. + /// + /// 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() - [Obsolete] - public (PlatformMediaElement platformView, StyledPlayerView PlayerView) CreatePlatformView() { Player = new ExoPlayerBuilder(MauiContext.Context).Build() ?? throw new InvalidOperationException("Player cannot be null"); Player.AddListener(this); @@ -150,10 +144,9 @@ or PlaybackState.StateSkippingToQueueItem var mediaSessionWRandomId = new MediaSession.Builder(Platform.AppContext, Player); mediaSessionWRandomId.SetId(randomId); session ??= mediaSessionWRandomId.Build() ?? throw new InvalidOperationException("Session cannot be null"); + ArgumentNullException.ThrowIfNull(session.Id); subtitleExtensions ??= new(PlayerView, Dispatcher); - checkPermissionsTask = CheckAndRequestForegroundPermission(checkPermissionSourceToken.Token); - checkPermissionsTask = CheckAndRequestForegroundPermission(checkPermissionSourceToken.Token); return (Player, PlayerView); } @@ -280,9 +273,9 @@ protected virtual partial void PlatformPause() } Player.Pause(); - [MemberNotNull(nameof(Player))] + } - [Obsolete] + [MemberNotNull(nameof(Player))] protected virtual async partial Task PlatformSeek(TimeSpan position, CancellationToken token) { if (Player is null) @@ -319,18 +312,19 @@ protected virtual partial void PlatformStop() Player.SeekTo(0); Player.Stop(); MediaElement.Position = TimeSpan.Zero; + } + protected virtual async partial ValueTask PlatformUpdateSource() - [Obsolete] - protected virtual partial void PlatformUpdateSource() { var hasSetSource = false; if (Player is null) { return; - if (connection is null) + } - if (mediaSession is not null) + subtitleExtensions?.StopSubtitleDisplay(); + if (connection is null) { StartService(); } @@ -359,9 +353,12 @@ protected virtual partial void PlatformUpdateSource() } if (hasSetSource && Player.PlayerError is null) - startSubtitles = LoadSubtitles(subTitlesSourceToken.Token); { + await LoadSubtitles(); MediaElement.MediaOpened(); + UpdateNotifications(); + } + } async Task LoadSubtitles(CancellationToken cancellationToken = default) { if (subtitleExtensions is null || string.IsNullOrEmpty(MediaElement.SubtitleUrl)) @@ -371,8 +368,6 @@ async Task LoadSubtitles(CancellationToken cancellationToken = default) await subtitleExtensions.LoadSubtitles(MediaElement, cancellationToken).ConfigureAwait(false); subtitleExtensions.StartSubtitleDisplay(); } - - [Obsolete] protected virtual partial void PlatformUpdateAspect() { if (PlayerView is null) @@ -502,6 +497,8 @@ protected override void Dispose(bool disposing) { base.Dispose(disposing); + if (disposing) + { session?.Release(); session?.Dispose(); session = null; @@ -510,13 +507,11 @@ protected override void Dispose(bool disposing) cancellationTokenSource = null; if (connection is not null) - - if (uiUpdateReceiver is not null) { StopService(connection); connection.Dispose(); - mediaSessionConnector?.SetPlayer(null); - client.Dispose(); + connection = null; + } client.Dispose(); } @@ -544,10 +539,10 @@ static async Task GetBytesFromMetadataArtworkUrl(string? url, Cancellati { return artworkData; } + } + [MemberNotNull(nameof(connection))] void StartService() - [Obsolete] - void InitializeMediaSession() { var intent = new Intent(Android.App.Application.Context, typeof(MediaControlsService)); connection = new BoundServiceConnection(this); @@ -573,6 +568,8 @@ void StopService(in BoundServiceConnection boundServiceConnection) if (MediaElement.Source is null) { return null; + } + switch (MediaElement.Source) { case UriMediaSource uriMediaSource: @@ -582,8 +579,6 @@ void StopService(in BoundServiceConnection boundServiceConnection) { return await CreateMediaItem(uri.AbsoluteUri, cancellationToken).ConfigureAwait(false); } - await checkPermissionsTask.WaitAsync(cancellationToken); - } break; } @@ -605,15 +600,17 @@ void StopService(in BoundServiceConnection boundServiceConnection) { var assetFilePath = $"asset://{package}{Path.PathSeparator}{path}"; return await CreateMediaItem(assetFilePath, cancellationToken).ConfigureAwait(false); + } + break; } default: throw new NotSupportedException($"{MediaElement.Source.GetType().FullName} is not yet supported for {nameof(MediaElement.Source)}"); } - LocalBroadcastManager.GetInstance(Platform.AppContext).SendBroadcast(intent); + return mediaItem; - MediaElement.MediaWidth = videoSize?.Width ?? 0; - MediaElement.MediaHeight = videoSize?.Height ?? 0; + } + async Task CreateMediaItem(string url, CancellationToken cancellationToken = default) { MediaMetadata.Builder mediaMetaData = new(); @@ -621,8 +618,6 @@ void StopService(in BoundServiceConnection boundServiceConnection) mediaMetaData.SetTitle(MediaElement.MetadataTitle); var data = await GetBytesFromMetadataArtworkUrl(MediaElement.MetadataArtworkUrl, cancellationToken).ConfigureAwait(true); mediaMetaData.SetArtworkData(data, (Java.Lang.Integer)MediaMetadata.PictureTypeFrontCover); - [Obsolete] - public void OnTrackSelectionParametersChanged(TrackSelectionParameters? trackSelectionParameters) { } mediaItem = new MediaItem.Builder(); mediaItem.SetUri(url);