diff --git a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs index 1dba8b3525..5fd48dc982 100644 --- a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs +++ b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs @@ -41,6 +41,8 @@ using Microsoft.Maui.Platform; #endif +[assembly: ExportFont("Poppins-Regular.ttf", Alias = "Poppins")] +[assembly: ExportFont("PlaywriteSK-Regular.ttf", Alias = "PlaywriteSK")] [assembly: XamlCompilation(XamlCompilationOptions.Compile)] namespace CommunityToolkit.Maui.Sample; diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml index 609ce11a3e..b9743d67b1 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/MediaElement/MediaElementPage.xaml @@ -29,6 +29,7 @@ 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 sample with Subtitles"; const string loadMusic = "Load Music"; const string buckBunnyMp4Url = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"; @@ -161,7 +167,7 @@ void Button_Clicked(object? sender, EventArgs e) async void ChangeSourceClicked(Object sender, EventArgs e) { var result = await DisplayActionSheet("Choose a source", "Cancel", null, - loadOnlineMp4, loadHls, loadLocalResource, resetSource, loadMusic); + loadOnlineMp4, loadHls, loadLocalResource, resetSource, loadSubTitles, loadMusic); switch (result) { @@ -169,6 +175,7 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.MetadataTitle = "Big Buck Bunny"; MediaElement.MetadataArtworkUrl = botImageUrl; MediaElement.MetadataArtist = "Big Buck Bunny Album"; + MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = MediaSource.FromUri(buckBunnyMp4Url); return; @@ -177,6 +184,7 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.MetadataArtist = "HLS Album"; MediaElement.MetadataArtworkUrl = botImageUrl; MediaElement.MetadataTitle = "HLS Title"; + MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = MediaSource.FromUri(hlsStreamTestUrl); return; @@ -184,6 +192,7 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.MetadataArtworkUrl = string.Empty; MediaElement.MetadataTitle = string.Empty; MediaElement.MetadataArtist = string.Empty; + MediaElement.SubtitleUrl = string.Empty; MediaElement.Source = null; return; @@ -191,7 +200,7 @@ async void ChangeSourceClicked(Object sender, EventArgs e) MediaElement.MetadataArtworkUrl = botImageUrl; MediaElement.MetadataTitle = "Local Resource Title"; MediaElement.MetadataArtist = "Local Resource Album"; - + MediaElement.SubtitleUrl = string.Empty; if (DeviceInfo.Platform == DevicePlatform.MacCatalyst || DeviceInfo.Platform == DevicePlatform.iOS) { @@ -207,6 +216,24 @@ async void ChangeSourceClicked(Object sender, EventArgs e) } return; + case loadSubTitles: + SrtParser srtParser = new(); + MediaElement.CustomSubtitleParser = srtParser; + 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.SubtitleUrl = "https://raw.githubusercontent.com/ne0rrmatrix/SampleVideo/main/SRT/WindowsVideo.srt"; + MediaElement.Source = MediaSource.FromResource("WindowsVideo.mp4"); + return; + case loadMusic: MediaElement.MetadataTitle = "HAL 9000"; MediaElement.MetadataArtist = "HAL 9000 Album"; @@ -242,7 +269,6 @@ async void ChangeAspectClicked(object? sender, EventArgs e) MediaElement.Aspect = (Aspect)aspectEnum; } - void DisplayPopup(object sender, EventArgs e) { MediaElement.Pause(); @@ -274,4 +300,87 @@ void DisplayPopup(object sender, EventArgs e) popupMediaElement.Handler?.DisconnectHandler(); }; } +} + +/// +/// Sample implementation of an SRT parser. +/// +partial class SrtParser : IParser +{ + static readonly Regex timecodePatternSRT = SRTRegex(); + + public List ParseContent(string content) + { + var cues = new List(); + if (string.IsNullOrEmpty(content)) + { + return cues; + } + + var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); + + SubtitleCue? currentCue = null; + var textBuffer = new StringBuilder(); + + 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 = textBuffer.ToString().TrimEnd('\r', '\n'); + cues.Add(currentCue); + textBuffer.Clear(); + } + + currentCue = CreateCue(match); + } + else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) + { + textBuffer.AppendLine(line.Trim().TrimEnd('\r', '\n')); + } + } + + if (currentCue is not null) + { + 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 = StartTime, + EndTime = EndTime, + Text = Text + }; + } + + 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})", RegexOptions.Compiled)] + private static partial Regex SRTRegex(); } \ No newline at end of file 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/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/PlaywriteSK-Regular.ttf b/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/PlaywriteSK-Regular.ttf new file mode 100644 index 0000000000..0e6b229d45 Binary files /dev/null and b/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/PlaywriteSK-Regular.ttf differ 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 0000000000..9f0c71b70a Binary files /dev/null and b/samples/CommunityToolkit.Maui.Sample/Resources/Fonts/Poppins-Regular.ttf differ diff --git a/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs b/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs index f2ee5d594b..694a8d867f 100644 --- a/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/AppBuilderExtensions.shared.cs @@ -1,6 +1,7 @@ using System.Runtime.Versioning; using CommunityToolkit.Maui.Core.Handlers; using CommunityToolkit.Maui.Views; +using CommunityToolkit.Maui.Core; namespace CommunityToolkit.Maui; @@ -21,9 +22,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 => + { + foreach (var (FontFileName, Alias) in importedFonts) + { + fonts.AddFont(FontFileName, Alias); + } }); #if ANDROID diff --git a/src/CommunityToolkit.Maui.MediaElement/CommunityToolkit.Maui.MediaElement.csproj b/src/CommunityToolkit.Maui.MediaElement/CommunityToolkit.Maui.MediaElement.csproj index d770a5273c..32bc580f6d 100644 --- a/src/CommunityToolkit.Maui.MediaElement/CommunityToolkit.Maui.MediaElement.csproj +++ b/src/CommunityToolkit.Maui.MediaElement/CommunityToolkit.Maui.MediaElement.csproj @@ -5,6 +5,7 @@ $(TargetFrameworks);$(NetVersion)-windows10.0.19041.0 $(TargetFrameworks);$(NetVersion)-tizen true + true true true @@ -45,6 +46,10 @@ $(WarningsAsErrors);CS1591 True + + + + diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs new file mode 100644 index 0000000000..7a01fe6b8b --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/FontExtensions.cs @@ -0,0 +1,90 @@ +using System.Reflection; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Maui.Core; + +sealed class FontExtensions +{ + public record struct FontFamily(string input) + { + static readonly string ttfPattern = @"(.+\.ttf)#(.+)"; + static readonly string otfPattern = @"(.+\.otf)#(.+)"; + readonly Match ttfMatch = Regex.Match(input, ttfPattern); + readonly Match otfMatch = Regex.Match(input, otfPattern); + public readonly string Android + { + get + { + if (ttfMatch.Success) + { + return ttfMatch.Groups[1].Value; + } + if (otfMatch.Success) + { + return otfMatch.Groups[1].Value; + } + else + { + System.Diagnostics.Trace.TraceError("The input string is not in the expected format."); + return string.Empty; + } + } + } + public readonly string WindowsFont + { + get + { + if (ttfMatch.Success) + { + return $"ms-appx:///{ttfMatch.Groups[1].Value}#{ttfMatch.Groups[2].Value}"; + } + if (otfMatch.Success) + { + return $"ms-appx:///{otfMatch.Groups[1].Value}#{otfMatch.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 (ttfMatch.Success) + { + return ttfMatch.Groups[2].Value; + } + if (otfMatch.Success) + { + return otfMatch.Groups[2].Value; + } + else + { + System.Diagnostics.Trace.TraceError("The input string is not in the expected format."); + return 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/SrtParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs new file mode 100644 index 0000000000..ebd5fdaa73 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SrtParser.cs @@ -0,0 +1,83 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Maui.Core; + +partial class SrtParser : IParser +{ + static readonly Regex timecodePatternSRT = SRTRegex(); + + public List ParseContent(string content) + { + var cues = new List(); + if (string.IsNullOrEmpty(content)) + { + return cues; + } + + var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); + SubtitleCue? currentCue = null; + var textBuffer = new StringBuilder(); + + 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 = textBuffer.ToString().TrimEnd('\r', '\n'); + cues.Add(currentCue); + textBuffer.Clear(); + } + currentCue = CreateCue(match); + } + else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) + { + textBuffer.AppendLine(line.Trim().TrimEnd('\r', '\n')); + } + } + + if (currentCue is not null) + { + 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 = StartTime, + EndTime = EndTime, + Text = Text + }; + } + + 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})", RegexOptions.Compiled, 2000)] + 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 new file mode 100644 index 0000000000..ebd1efc4f2 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleCue.cs @@ -0,0 +1,26 @@ +namespace CommunityToolkit.Maui.Core; + +/// +/// A class that represents a subtitle cue. +/// +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..c02a982993 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.android.cs @@ -0,0 +1,185 @@ +using Android.Graphics; +using Android.Views; +using Android.Widget; +using AndroidX.Media3.UI; +using CommunityToolkit.Maui.Core.Views; +using CommunityToolkit.Maui.Primitives; +using static Android.Views.ViewGroup; + +namespace CommunityToolkit.Maui.Extensions; + +partial class SubtitleExtensions : SubtitleTimer, IDisposable +{ + FrameLayout.LayoutParams? subtitleLayout; + readonly PlayerView playerView; + MediaElementScreenState screenState; + bool disposedValue; + + public SubtitleExtensions(PlayerView styledPlayerView, IDispatcher dispatcher) + { + screenState = MediaElementScreenState.Default; + this.dispatcher = dispatcher; + this.playerView = styledPlayerView; + Cues = []; + InitializeLayout(); + InitializeTextBlock(); + } + + public void StartSubtitleDisplay() + { + ArgumentNullException.ThrowIfNull(subtitleTextBlock); + ArgumentNullException.ThrowIfNull(Cues); + + if (Cues.Count == 0 || string.IsNullOrEmpty(MediaElement?.SubtitleUrl)) + { + return; + } + + MediaManager.FullScreenEvents.WindowsChanged += OnFullScreenChanged; + InitializeText(); + dispatcher.Dispatch(() => playerView.AddView(subtitleTextBlock)); + StartTimer(); + } + + public void StopSubtitleDisplay() + { + StopTimer(); + MediaManager.FullScreenEvents.WindowsChanged -= OnFullScreenChanged; + ArgumentNullException.ThrowIfNull(subtitleTextBlock); + subtitleTextBlock.Text = string.Empty; + Cues?.Clear(); + + dispatcher.Dispatch(() => playerView?.RemoveView(subtitleTextBlock)); + } + + public override void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) + { + ArgumentNullException.ThrowIfNull(subtitleTextBlock); + ArgumentNullException.ThrowIfNull(MediaElement); + ArgumentNullException.ThrowIfNull(Cues); + + if (Cues.Count == 0 || playerView is null) + { + return; + } + + var cue = Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position); + dispatcher.Dispatch(() => + { + SetHeight(); + if (cue is not null) + { + subtitleTextBlock.Text = cue.Text; + subtitleTextBlock.Visibility = ViewStates.Visible; + } + else + { + subtitleTextBlock.Text = string.Empty; + subtitleTextBlock.Visibility = ViewStates.Gone; + } + }); + } + + void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) + { + var layout = Platform.CurrentActivity?.Window?.DecorView as ViewGroup; + ArgumentNullException.ThrowIfNull(layout); + dispatcher.Dispatch(() => + { + switch(e.NewState) + { + case MediaElementScreenState.FullScreen: + screenState = MediaElementScreenState.FullScreen; + playerView.RemoveView(subtitleTextBlock); + InitializeLayout(); + InitializeTextBlock(); + InitializeText(); + layout.AddView(subtitleTextBlock); + break; + default: + screenState = MediaElementScreenState.Default; + layout.RemoveView(subtitleTextBlock); + InitializeLayout(); + InitializeTextBlock(); + InitializeText(); + playerView.AddView(subtitleTextBlock); + break; + } + }); + } + + void SetHeight() + { + if (playerView is null || subtitleLayout is null || subtitleTextBlock is null) + { + return; + } + int height = playerView.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(subtitleTextBlock); + ArgumentNullException.ThrowIfNull(MediaElement); + Typeface? typeface = Typeface.CreateFromAsset(Platform.AppContext.ApplicationContext?.Assets, new Core.FontExtensions.FontFamily(MediaElement.SubtitleFont).Android) ?? Typeface.Default; + subtitleTextBlock.TextSize = (float)MediaElement.SubtitleFontSize; + subtitleTextBlock.SetTypeface(typeface, TypefaceStyle.Normal); + } + + void InitializeTextBlock() + { + subtitleTextBlock = new(Platform.CurrentActivity?.ApplicationContext) + { + Text = string.Empty, + HorizontalScrollBarEnabled = false, + VerticalScrollBarEnabled = false, + TextAlignment = Android.Views.TextAlignment.Center, + Visibility = Android.Views.ViewStates.Gone, + LayoutParameters = subtitleLayout + }; + subtitleTextBlock.SetBackgroundColor(Android.Graphics.Color.Argb(150, 0, 0, 0)); + subtitleTextBlock.SetTextColor(Android.Graphics.Color.White); + subtitleTextBlock.SetPaddingRelative(10, 10, 10, 20); + } + + void InitializeLayout() + { + subtitleLayout = new FrameLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.WrapContent) + { + 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.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs new file mode 100644 index 0000000000..8544ccf2f2 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.macios.cs @@ -0,0 +1,223 @@ +using System.Text; +using System.Text.RegularExpressions; +using CommunityToolkit.Maui.Core.Views; +using CommunityToolkit.Maui.Primitives; +using CoreFoundation; +using CoreGraphics; +using CoreMedia; +using Foundation; +using UIKit; + +namespace CommunityToolkit.Maui.Extensions; + +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; + MediaElementScreenState screenState; + + public SubtitleExtensions(PlatformMediaElement player, UIViewController playerViewController) + { + this.playerViewController = playerViewController; + this.player = player; + screenState = MediaElementScreenState.Default; + Cues = []; + + subtitleLabel = new UILabel + { + Frame = CalculateSubtitleFrame(playerViewController, 100), + TextColor = UIColor.White, + TextAlignment = UITextAlignment.Center, + Font = UIFont.SystemFontOfSize(12), + Text = string.Empty, + BackgroundColor = clearBackgroundColor, + Lines = 0, + LineBreakMode = UILineBreakMode.WordWrap, + }; + 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); + DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); + + playerObserver = player?.AddPeriodicTimeObserver(CMTime.FromSeconds(1, 1), null, (time) => + { + DispatchQueue.MainQueue.DispatchAsync(() => UpdateSubtitle()); + }); + } + + public void StopSubtitleDisplay() + { + ArgumentNullException.ThrowIfNull(Cues); + subtitleLabel.Text = string.Empty; + Cues.Clear(); + subtitleLabel.BackgroundColor = clearBackgroundColor; + DispatchQueue.MainQueue.DispatchAsync(() => subtitleLabel.RemoveFromSuperview()); + playerObserver?.Dispose(); + } + + void UpdateSubtitle() + { + ArgumentNullException.ThrowIfNull(Cues); + ArgumentNullException.ThrowIfNull(subtitleLabel); + if (playerViewController is null || string.IsNullOrEmpty(MediaElement?.SubtitleUrl)) + { + return; + } + + var cue = Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position); + if (cue is not null) + { + SetText(cue.Text); + } + else + { + subtitleLabel.Text = string.Empty; + 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, fontSize); + subtitleLabel.BackgroundColor = subtitleBackgroundColor; + var labelWidth = GetSubtileWidth(text); + + 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; + } + } + + nfloat GetSubtileWidth(string? text) + { + var nsString = new NSString(text); + var attributes = new UIStringAttributes { Font = subtitleLabel.Font }; + var textSize = nsString.GetSizeUsingAttributes(attributes); + return textSize.Width + 5; + } + + float GetFontSize(float fontSize) + { + #if IOS + return fontSize; + #else + return screenState == MediaElementScreenState.FullScreen? fontSize * 1.5f : fontSize; + #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. + viewController = UIApplication.SharedApplication.KeyWindow?.RootViewController; +#endif + ArgumentNullException.ThrowIfNull(viewController); + return viewController; + } + + 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; + } + + if (currentLineLength > 0) + { + wrappedTextBuilder.Append(' '); + } + + wrappedTextBuilder.Append(matchValue); + currentLineLength += matchValue.Length + 1; + } + + return wrappedTextBuilder.ToString(); + } + + static CGRect CalculateSubtitleFrame(UIViewController uIViewController, nfloat labelWidth) + { + if (uIViewController is null || uIViewController.View is null) + { + return CGRect.Empty; + } + var viewWidth = uIViewController.View.Bounds.Width; + 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) + { + ArgumentNullException.ThrowIfNull(MediaElement); + if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) + { + return; + } + + DispatchQueue.MainQueue.DispatchAsync(subtitleLabel.RemoveFromSuperview); + switch (e.NewState == MediaElementScreenState.FullScreen) + { + case true: + var viewController = GetCurrentUIViewController(); + screenState = MediaElementScreenState.FullScreen; + ArgumentNullException.ThrowIfNull(viewController.View); + DispatchQueue.MainQueue.DispatchAsync(() => viewController?.View?.Add(subtitleLabel)); + break; + case false: + screenState = MediaElementScreenState.Default; + DispatchQueue.MainQueue.DispatchAsync(() => playerViewController.View?.AddSubview(subtitleLabel)); + break; + } + DispatchQueue.MainQueue.DispatchAsync(UpdateSubtitle); + } + + [GeneratedRegex(@"\b\w+\b")] + private static partial Regex MatchWorksRegex(); +} 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..0fca30505d --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.shared.cs @@ -0,0 +1,95 @@ +using System.ComponentModel.DataAnnotations; +using CommunityToolkit.Maui.Core; + +namespace CommunityToolkit.Maui.Extensions; +partial class SubtitleExtensions +{ + public IMediaElement? MediaElement; + public List? Cues; + public async Task LoadSubtitles(IMediaElement mediaElement, CancellationToken token) + { + 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, token); + + 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; + } + } +} + +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 new file mode 100644 index 0000000000..4f003d2d3a --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleExtensions.windows.cs @@ -0,0 +1,125 @@ +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 : SubtitleTimer +{ + readonly MauiMediaElement? mauiMediaElement; + readonly int width; + + 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)); + StartTimer(); + } + + public void StopSubtitleDisplay() + { + ArgumentNullException.ThrowIfNull(subtitleTextBlock); + Cues?.Clear(); + subtitleTextBlock.ClearValue(Microsoft.UI.Xaml.Controls.TextBox.TextProperty); + StopTimer(); + if(mauiMediaElement is null) + { + return; + } + dispatcher.Dispatch(() => mauiMediaElement?.Children.Remove(subtitleTextBlock)); + } + + public override void UpdateSubtitle(object? sender, System.Timers.ElapsedEventArgs e) + { + ArgumentNullException.ThrowIfNull(MediaElement); + ArgumentNullException.ThrowIfNull(subtitleTextBlock); + if (string.IsNullOrEmpty(MediaElement.SubtitleUrl) || Cues is null) + { + return; + } + var cue = Cues.Find(c => c.StartTime <= MediaElement.Position && c.EndTime >= MediaElement.Position); + dispatcher.Dispatch(() => + { + if (cue is not null) + { + InitializeText(); + subtitleTextBlock.Text = cue.Text; + subtitleTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Visible; + } + else + { + subtitleTextBlock.Text = string.Empty; + subtitleTextBlock.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; + } + }); + } + + void OnFullScreenChanged(object? sender, FullScreenStateChangedEventArgs e) + { + var gridItem = MediaManager.FullScreenEvents.grid; + ArgumentNullException.ThrowIfNull(mauiMediaElement); + ArgumentNullException.ThrowIfNull(MediaElement); + ArgumentNullException.ThrowIfNull(gridItem); + ArgumentNullException.ThrowIfNull(subtitleTextBlock); + if (string.IsNullOrEmpty(MediaElement.SubtitleUrl)) + { + return; + } + + switch (e.NewState) + { + 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); }); + break; + 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); }); + 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); + } +} diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs new file mode 100644 index 0000000000..cc03d4e9ab --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/SubtitleParser.cs @@ -0,0 +1,73 @@ +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Maui.Core; + +/// +/// A class that Represents a parser. +/// +public partial class SubtitleParser +{ + static readonly HttpClient httpClient = new(); + + /// + /// A property that represents the + /// + public IParser IParser { get; set; } + + /// + /// A property that represents the separator. + /// + public static readonly string[] Separator = ["\r\n", "\n"]; + + /// + /// A constructor that initializes the + /// + /// + public SubtitleParser(IParser parser) + { + this.IParser = parser; + } + + /// + /// A method that parses the content. + /// + /// + /// + public virtual List ParseContent(string content) + { + return IParser.ParseContent(content); + } + + internal static async Task Content(string subtitleUrl, CancellationToken token = default) + { + try + { + return await httpClient.GetStringAsync(subtitleUrl, token).ConfigureAwait(false); + } + catch (Exception ex) + { + System.Diagnostics.Trace.TraceError(ex.Message); + 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/Extensions/VttParser.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs new file mode 100644 index 0000000000..05226ab31f --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/VttParser.cs @@ -0,0 +1,81 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Maui.Core; + +partial class VttParser : IParser +{ + static readonly Regex timecodePatternVTT = VTTRegex(); + + public List ParseContent(string content) + { + var cues = new List(); + if (string.IsNullOrEmpty(content)) + { + return cues; + } + var lines = content.Split(SubtitleParser.Separator, StringSplitOptions.RemoveEmptyEntries); + SubtitleCue? currentCue = null; + var textBuffer = new StringBuilder(); + + foreach (var line in lines) + { + var match = timecodePatternVTT.Match(line); + if (match.Success) + { + if (currentCue is not null) + { + currentCue.Text = textBuffer.ToString().Trim(); + cues.Add(currentCue); + textBuffer.Clear(); + } + currentCue = CreateCue(match); + } + else if (currentCue is not null && !string.IsNullOrWhiteSpace(line)) + { + textBuffer.AppendLine(line.Trim('-').Trim()); + } + } + + if (currentCue is not null) + { + 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 = StartTime, + EndTime = EndTime, + Text = Text + }; + } + + static TimeSpan ParseTimecode(string timecode) + { + 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})", RegexOptions.Compiled, 2000)] + private static partial Regex VTTRegex(); +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.android.cs b/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.android.cs index c204ef847b..6a68aebed5 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.android.cs @@ -17,7 +17,6 @@ public static void ShouldLoopPlayback(MediaElementHandler handler, MediaElement handler.mediaManager?.UpdateShouldLoopPlayback(); } - protected override MauiMediaElement CreatePlatformView() { mediaManager ??= new(MauiContext ?? throw new InvalidOperationException($"{nameof(MauiContext)} cannot be null"), diff --git a/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IMediaElement.cs index 05e3ce7f51..5ef3a3cf8a 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; internal set; } + /// + /// Gets the used to parse the subtitle file. + /// + IParser? CustomSubtitleParser { get; } + /// /// The current position of the playing media. /// @@ -83,6 +88,15 @@ public interface IMediaElement : IView, IAsynchronousMediaElementHandler /// bool ShouldShowPlaybackControls { get; set; } + /// + /// Gets or sets the font to use for the subtitle text. + /// + string SubtitleFont { get; } + /// + /// Gets or sets the URL of the subtitle file to display. + /// + string SubtitleUrl { get; } + /// /// Gets or sets the source of the media to play. /// @@ -95,6 +109,10 @@ 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; } /// /// Gets or sets the volume of the audio for the 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..33580fcbb3 --- /dev/null +++ b/src/CommunityToolkit.Maui.MediaElement/Interfaces/IParser.cs @@ -0,0 +1,14 @@ +namespace CommunityToolkit.Maui.Core; + +/// +/// +/// +public interface IParser +{ + /// + /// A method that parses the content. + /// + /// + /// + public List ParseContent(string content); +} diff --git a/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs b/src/CommunityToolkit.Maui.MediaElement/MediaElement.shared.cs index b23d291711..8497a6a950 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 partial class MediaElement : View, IMediaElement, IDisposable { + /// + /// Backing store for the property. + /// + public static readonly BindableProperty ParserProperty = + BindableProperty.Create(nameof(CustomSubtitleParser), typeof(IParser), typeof(MediaElement), null); + /// /// Backing store for the property. /// @@ -74,6 +80,21 @@ public partial 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. + /// + public static readonly BindableProperty SubtitleProperty = BindableProperty.Create(nameof(SubtitleUrl), typeof(string), typeof(MediaElement), string.Empty); + /// /// Backing store for the property. /// @@ -210,6 +231,15 @@ internal event EventHandler StopRequested /// ~MediaElement() => Dispose(false); + /// + /// Custom parser for subtitles. + /// + public IParser? CustomSubtitleParser + { + get => (IParser)GetValue(ParserProperty); + set => SetValue(ParserProperty, value); + } + /// /// The current position of the playing media. This is a bindable property. /// @@ -287,6 +317,33 @@ 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 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. /// 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/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 50c06b756a..0ed0524630 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; @@ -120,12 +121,14 @@ void OnFullscreenButtonClick(object? sender, PlayerView.FullscreenButtonClickEve isFullScreen = true; RemoveView(relativeLayout); layout?.AddView(relativeLayout); + MediaManager.FullScreenEvents.OnFullScreenStateChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.Default, MediaElementScreenState.FullScreen)); } else { isFullScreen = false; layout?.RemoveView(relativeLayout); AddView(relativeLayout); + MediaManager.FullScreenEvents.OnFullScreenStateChanged(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 9a6d50ef52..2383d3aaf7 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.windows.cs @@ -181,6 +181,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.OnFullScreenStateChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.FullScreen, MediaElementScreenState.Default)); } else { @@ -207,6 +209,8 @@ void OnFullScreenButtonClick(object sender, RoutedEventArgs e) { popup.IsOpen = true; } + MediaManager.FullScreenEvents.grid = fullScreenGrid; + MediaManager.FullScreenEvents.OnFullScreenStateChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.Default, MediaElementScreenState.FullScreen)); } } } \ 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 e34ef498cb..e0078a2315 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.android.cs @@ -3,24 +3,23 @@ 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; using AndroidX.Media3.UI; using CommunityToolkit.Maui.Core.Primitives; +using CommunityToolkit.Maui.Extensions; using CommunityToolkit.Maui.Media.Services; 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; public partial class MediaManager : Java.Lang.Object, IPlayerListener { + SubtitleExtensions? subtitleExtensions; const int bufferState = 2; const int readyState = 3; const int endedState = 4; @@ -146,6 +145,7 @@ or PlaybackState.StateSkippingToQueueItem mediaSessionWRandomId.SetId(randomId); session ??= mediaSessionWRandomId.Build() ?? throw new InvalidOperationException("Session cannot be null"); ArgumentNullException.ThrowIfNull(session.Id); + subtitleExtensions ??= new(PlayerView, Dispatcher); return (Player, PlayerView); } @@ -323,6 +323,7 @@ protected virtual async partial ValueTask PlatformUpdateSource() return; } + subtitleExtensions?.StopSubtitleDisplay(); if (connection is null) { StartService(); @@ -353,11 +354,20 @@ protected virtual async partial ValueTask PlatformUpdateSource() if (hasSetSource && Player.PlayerError is null) { + await LoadSubtitles(); MediaElement.MediaOpened(); UpdateNotifications(); } } - + async Task LoadSubtitles(CancellationToken cancellationToken = default) + { + if (subtitleExtensions is null || string.IsNullOrEmpty(MediaElement.SubtitleUrl)) + { + return; + } + await subtitleExtensions.LoadSubtitles(MediaElement, 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 3354e500b1..2d3328f913 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -1,6 +1,8 @@ using AVFoundation; using AVKit; using CommunityToolkit.Maui.Core.Primitives; +using CommunityToolkit.Maui.Extensions; +using CommunityToolkit.Maui.Primitives; using CommunityToolkit.Maui.Views; using CoreFoundation; using CoreGraphics; @@ -15,6 +17,9 @@ namespace CommunityToolkit.Maui.Core.Views; public partial class MediaManager : IDisposable { Metadata? metaData; + SubtitleExtensions? subtitleExtensions; + readonly CancellationTokenSource subTitlesCancellationTokenSource = new(); + Task? startSubtitles; // Media would still start playing when Speed was set although ShouldAutoPlay=False // This field was added to overcome that. @@ -95,7 +100,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 Player.Muted = MediaElement.ShouldMute; @@ -119,7 +125,7 @@ public partial class MediaManager : IDisposable AddStatusObservers(); AddPlayedToEndObserver(); AddErrorObservers(); - + subtitleExtensions = new(Player, PlayerViewController); return (Player, PlayerViewController); } @@ -214,8 +220,10 @@ protected virtual partial void PlatformUpdateAspect() protected virtual partial ValueTask PlatformUpdateSource() { MediaElement.CurrentStateChanged(MediaElementState.Opening); - + AVAsset? asset = null; + subtitleExtensions?.StopSubtitleDisplay(); + if (Player is null) { return ValueTask.CompletedTask; @@ -270,7 +278,6 @@ protected virtual partial ValueTask PlatformUpdateSource() CurrentItemErrorObserver?.Dispose(); Player.ReplaceCurrentItemWithPlayerItem(PlayerItem); - CurrentItemErrorObserver = PlayerItem?.AddObserver("error", valueObserverOptions, (NSObservedChange change) => { @@ -298,6 +305,9 @@ protected virtual partial ValueTask PlatformUpdateSource() { Player.Play(); } + + CancellationToken token = subTitlesCancellationTokenSource.Token; + startSubtitles = LoadSubtitles(token); SetPoster(); } else if (PlayerItem is null) @@ -352,6 +362,17 @@ void SetPoster() } } + 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; + } + await subtitleExtensions.LoadSubtitles(MediaElement, cancellationToken).ConfigureAwait(false); + subtitleExtensions.StartSubtitleDisplay(); + } + protected virtual partial void PlatformUpdateSpeed() { if (PlayerViewController?.Player is null) @@ -475,29 +496,29 @@ protected virtual void Dispose(bool disposing) DestroyErrorObservers(); DestroyPlayedToEndObserver(); + subtitleExtensions?.StopSubtitleDisplay(); + Player.ReplaceCurrentItemWithPlayerItem(null); + subtitleExtensions?.Dispose(); + subTitlesCancellationTokenSource.Dispose(); RateObserver?.Dispose(); - RateObserver = null; - + startSubtitles?.Dispose(); 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(); @@ -718,4 +739,16 @@ void RateChanged(object? sender, NSNotificationEventArgs args) } } } -} \ No newline at end of file +} + +sealed class MediaManagerDelegate : AVPlayerViewControllerDelegate +{ + public override void WillBeginFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) + { + MediaManager.FullScreenEvents.OnFullScreenStateChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.Default, MediaElementScreenState.FullScreen)); + } + public override void WillEndFullScreenPresentation(AVPlayerViewController playerViewController, IUIViewControllerTransitionCoordinator coordinator) + { + MediaManager.FullScreenEvents.OnFullScreenStateChanged(new FullScreenStateChangedEventArgs(MediaElementScreenState.FullScreen, MediaElementScreenState.Default)); + } + } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.shared.cs index 195537fb62..a2c2763a23 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. + /// + internal readonly record struct 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 OnFullScreenStateChanged(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. /// diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs index a62a32b9ad..d29b8ccf75 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.windows.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Numerics; using CommunityToolkit.Maui.Core.Primitives; +using CommunityToolkit.Maui.Extensions; using CommunityToolkit.Maui.Views; using Microsoft.Extensions.Logging; using Microsoft.UI.Xaml.Controls; @@ -19,6 +20,9 @@ partial class MediaManager : IDisposable { Metadata? metadata; SystemMediaTransportControls? systemMediaControls; + SubtitleExtensions? subtitleExtensions; + readonly CancellationTokenSource subTitles = new(); + Task? startSubtitles; // States that allow changing position readonly IReadOnlyList allowUpdatePositionStates = @@ -266,8 +270,9 @@ protected virtual async partial ValueTask PlatformUpdateSource() return; } - await Dispatcher.DispatchAsync(() => Player.PosterSource = new BitmapImage()); - + subtitleExtensions?.StopSubtitleDisplay(); + Dispatcher.Dispatch(() => Player.PosterSource = new BitmapImage()); + if (MediaElement.Source is null) { Player.Source = null; @@ -307,8 +312,27 @@ protected virtual async partial ValueTask PlatformUpdateSource() Player.Source = WinMediaSource.CreateFromUri(new Uri(path)); } } + + CancellationToken token = subTitles.Token; + startSubtitles = LoadSubtitles(token); } + 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"); + return; + } + if (Player is null) + { + System.Diagnostics.Trace.TraceError("Player is null"); + return; + } + subtitleExtensions ??= new(Player, Dispatcher); + await subtitleExtensions.LoadSubtitles(MediaElement, cancellationToken).ConfigureAwait(false); + subtitleExtensions.StartSubtitleDisplay(); + } protected virtual partial void PlatformUpdateShouldLoopPlayback() { if (Player is null) @@ -334,7 +358,7 @@ protected virtual void Dispose(bool disposing) DisplayRequest.RequestRelease(); displayActiveRequested = false; } - + subtitleExtensions?.StopSubtitleDisplay(); Player.MediaPlayer.MediaOpened -= OnMediaElementMediaOpened; Player.MediaPlayer.MediaFailed -= OnMediaElementMediaFailed; Player.MediaPlayer.MediaEnded -= OnMediaElementMediaEnded; @@ -349,6 +373,9 @@ protected virtual void Dispose(bool disposing) Player.MediaPlayer.PlaybackSession.SeekCompleted -= OnPlaybackSessionSeekCompleted; } } + + startSubtitles?.Dispose(); + startSubtitles = null; } } diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs new file mode 100644 index 0000000000..d6cf40144c --- /dev/null +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/FontExtensionsTests.cs @@ -0,0 +1,150 @@ +using CommunityToolkit.Maui.Views; +using Xunit; + +namespace CommunityToolkit.Maui.UnitTests.Extensions; +public class FontExtensionsTests : BaseTest +{ + [Fact] + public void FontFamily_TTF_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_TTF_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_TTF_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_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() + { + SubtitleFont = "Invalid input" + }; + + // Act + var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).Android; + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void FontFamily_TTF_WindowsFont_InvalidInput_ReturnsEmptyString() + { + // Arrange + MediaElement mediaElement = new() + { + SubtitleFont = "Invalid input" + }; + + // Act + var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).WindowsFont; + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void FontFamily_TTF_MacIOS_InvalidInput_ReturnsEmptyString() + { + // Arrange + MediaElement mediaElement = new() + { + SubtitleFont = "Invalid input" + }; + + // Act + var result = new Core.FontExtensions.FontFamily(mediaElement.SubtitleFont).MacIOS; + + // Assert + Assert.Equal(string.Empty, result); + } +} diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/SrtParserTests.cs new file mode 100644 index 0000000000..09c8f7e608 --- /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 + SrtParser srtParser = new(); + Assert.Throws(() => srtParser.ParseContent(content)); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs new file mode 100644 index 0000000000..4bc90f1dcb --- /dev/null +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/SubtitleExtensionsTests.cs @@ -0,0 +1,85 @@ +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(); + CancellationToken token = new(); + + // Act + SubtitleExtensions subtitleExtensions = new(); + + // Assert + Assert.NotNull(subtitleExtensions.LoadSubtitles(mediaElement, token)); + } + + [Fact] + public async Task LoadSubtitles_InvalidSubtitleExtensions_ThrowsNullReferenceExceptionAsync() + { + // Arrange + IMediaElement mediaElement = new MediaElement(); + CancellationToken token = new(); + + // Act + SubtitleExtensions subtitleExtensions = null!; + + // Assert + await Assert.ThrowsAsync(async () => await subtitleExtensions.LoadSubtitles(mediaElement, token)); + } + + [Fact] + public void SetSubtitleExtensions_ValidSubtitleExtension() + { + // Arrange + 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(); + CancellationToken token = new(); + + // Act & Assert + await Assert.ThrowsAsync(async () => await subtitleExtensions.LoadSubtitles(mediaElement, token)); + } +} \ 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..704b12c72b --- /dev/null +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/VttParserTests.cs @@ -0,0 +1,75 @@ +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