diff --git a/InkkSlinger.Tests/MediaElementTests.cs b/InkkSlinger.Tests/MediaElementTests.cs new file mode 100644 index 0000000..fa89786 --- /dev/null +++ b/InkkSlinger.Tests/MediaElementTests.cs @@ -0,0 +1,61 @@ +using System; +using Xunit; + +namespace InkkSlinger.Tests; + +public sealed class MediaElementTests +{ + [Fact] + public void Play_WithNullSource_RaisesMediaFailed() + { + var media = new MediaElement(); + MediaFailedEventArgs? failed = null; + media.MediaFailed += (_, args) => failed = args; + + media.Play(); + + Assert.NotNull(failed); + Assert.Contains("Source is null", failed!.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Equal(MediaElementState.Closed, media.CurrentState); + } + + [Fact] + public void SourceAndPlayback_StateTransitions_AreDeterministic() + { + var media = new MediaElement + { + Source = new Uri("https://example.com/video.mp4") + }; + + Assert.Equal(MediaElementState.Opening, media.CurrentState); + + media.Play(); + Assert.Equal(MediaElementState.Playing, media.CurrentState); + + media.Pause(); + Assert.Equal(MediaElementState.Paused, media.CurrentState); + + media.Stop(); + Assert.Equal(MediaElementState.Stopped, media.CurrentState); + Assert.Equal(TimeSpan.Zero, media.Position); + + media.Close(); + Assert.Equal(MediaElementState.Closed, media.CurrentState); + } + + [Fact] + public void XamlLoader_CanInstantiate_MediaElement() + { + const string xaml = """ + + """; + + var root = (MediaElement)XamlLoader.LoadFromString(xaml); + + Assert.NotNull(root); + Assert.Equal(MediaState.Manual, root.LoadedBehavior); + Assert.Equal(new Uri("https://example.com/demo.mp4"), root.Source); + } +} diff --git a/UI/Automation/AutomationPeerFactory.cs b/UI/Automation/AutomationPeerFactory.cs index e6b2a52..e53efb1 100644 --- a/UI/Automation/AutomationPeerFactory.cs +++ b/UI/Automation/AutomationPeerFactory.cs @@ -60,6 +60,7 @@ private static AutomationControlType MapControlType(UIElement element) ToolTip => AutomationControlType.ToolTip, TextBlock => AutomationControlType.Text, Image => AutomationControlType.Image, + MediaElement => AutomationControlType.Pane, Border => AutomationControlType.Pane, Panel => AutomationControlType.Pane, _ => AutomationControlType.Custom diff --git a/UI/Controls/Media/MediaElement.cs b/UI/Controls/Media/MediaElement.cs new file mode 100644 index 0000000..869433f --- /dev/null +++ b/UI/Controls/Media/MediaElement.cs @@ -0,0 +1,293 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace InkkSlinger; + +public enum MediaElementState +{ + Closed, + Opening, + Stopped, + Playing, + Paused +} + +public enum MediaState +{ + Manual, + Play, + Close, + Stop, + Pause +} + +public sealed class MediaFailedEventArgs : EventArgs +{ + public MediaFailedEventArgs(string message, Exception? errorException = null) + { + ErrorMessage = message; + ErrorException = errorException; + } + + public string ErrorMessage { get; } + + public Exception? ErrorException { get; } +} + +public class MediaElement : FrameworkElement +{ + public static readonly DependencyProperty SourceProperty = + DependencyProperty.Register( + nameof(Source), + typeof(Uri), + typeof(MediaElement), + new FrameworkPropertyMetadata( + null, + FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender, + static (d, e) => ((MediaElement)d).OnSourceChanged((Uri?)e.OldValue, (Uri?)e.NewValue))); + + public static readonly DependencyProperty LoadedBehaviorProperty = + DependencyProperty.Register( + nameof(LoadedBehavior), + typeof(MediaState), + typeof(MediaElement), + new FrameworkPropertyMetadata(MediaState.Play)); + + public static readonly DependencyProperty UnloadedBehaviorProperty = + DependencyProperty.Register( + nameof(UnloadedBehavior), + typeof(MediaState), + typeof(MediaElement), + new FrameworkPropertyMetadata(MediaState.Close)); + + public static readonly DependencyProperty VolumeProperty = + DependencyProperty.Register( + nameof(Volume), + typeof(float), + typeof(MediaElement), + new FrameworkPropertyMetadata(0.5f)); + + public static readonly DependencyProperty IsMutedProperty = + DependencyProperty.Register( + nameof(IsMuted), + typeof(bool), + typeof(MediaElement), + new FrameworkPropertyMetadata(false)); + + public static readonly DependencyProperty PositionProperty = + DependencyProperty.Register( + nameof(Position), + typeof(TimeSpan), + typeof(MediaElement), + new FrameworkPropertyMetadata(TimeSpan.Zero)); + + public static readonly DependencyProperty StretchProperty = + DependencyProperty.Register( + nameof(Stretch), + typeof(Stretch), + typeof(MediaElement), + new FrameworkPropertyMetadata(Stretch.Uniform, FrameworkPropertyMetadataOptions.AffectsRender)); + + private static Texture2D? _fallbackTexture; + private MediaElementState _state = MediaElementState.Closed; + private TimeSpan _naturalDuration = TimeSpan.Zero; + + public Uri? Source + { + get => GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } + + public MediaState LoadedBehavior + { + get => GetValue(LoadedBehaviorProperty); + set => SetValue(LoadedBehaviorProperty, value); + } + + public MediaState UnloadedBehavior + { + get => GetValue(UnloadedBehaviorProperty); + set => SetValue(UnloadedBehaviorProperty, value); + } + + public float Volume + { + get => GetValue(VolumeProperty); + set => SetValue(VolumeProperty, value); + } + + public bool IsMuted + { + get => GetValue(IsMutedProperty); + set => SetValue(IsMutedProperty, value); + } + + public TimeSpan Position + { + get => GetValue(PositionProperty); + set => SetValue(PositionProperty, value); + } + + public Stretch Stretch + { + get => GetValue(StretchProperty); + set => SetValue(StretchProperty, value); + } + + public bool ScrubbingEnabled { get; set; } + + public float Balance { get; set; } + + public float SpeedRatio { get; set; } = 1f; + + public bool CanPause => _state == MediaElementState.Playing || _state == MediaElementState.Paused; + + public bool HasAudio => false; + + public bool HasVideo => Source != null; + + public TimeSpan NaturalDuration => _naturalDuration; + + public MediaElementState CurrentState => _state; + + public event EventHandler? MediaOpened; + + public event EventHandler? MediaFailed; + + public event EventHandler? MediaEnded; + + public void Play() + { + if (Source == null) + { + RaiseMediaFailed("Cannot play when Source is null."); + return; + } + + if (_state == MediaElementState.Closed || _state == MediaElementState.Opening) + { + _state = MediaElementState.Stopped; + MediaOpened?.Invoke(this, EventArgs.Empty); + } + + _state = MediaElementState.Playing; + InvalidateVisual(); + } + + public void Pause() + { + if (_state == MediaElementState.Playing) + { + _state = MediaElementState.Paused; + InvalidateVisual(); + } + } + + public void Stop() + { + if (_state == MediaElementState.Closed) + { + return; + } + + Position = TimeSpan.Zero; + _state = MediaElementState.Stopped; + InvalidateVisual(); + } + + public void Close() + { + Position = TimeSpan.Zero; + _naturalDuration = TimeSpan.Zero; + _state = MediaElementState.Closed; + InvalidateVisual(); + } + + protected override Size MeasureOverride(Size availableSize) + { + return new Size(MathF.Max(0f, availableSize.X), MathF.Max(0f, availableSize.Y)); + } + + protected override void OnRender(SpriteBatch spriteBatch) + { + var slot = LayoutSlot; + if (slot.Width <= 0f || slot.Height <= 0f) + { + return; + } + + var texture = GetFallbackTexture(spriteBatch.GraphicsDevice); + if (texture == null) + { + return; + } + + var color = _state == MediaElementState.Playing ? new Color(28, 28, 28) : new Color(20, 20, 20); + spriteBatch.Draw(texture, new Rectangle((int)slot.X, (int)slot.Y, (int)slot.Width, (int)slot.Height), color * Opacity); + } + + protected override void OnVisualParentChanged(UIElement? oldParent, UIElement? newParent) + { + base.OnVisualParentChanged(oldParent, newParent); + + if (newParent == null) + { + ApplyBehavior(UnloadedBehavior); + return; + } + + ApplyBehavior(LoadedBehavior); + } + + private void OnSourceChanged(Uri? oldValue, Uri? newValue) + { + if (Equals(oldValue, newValue)) + { + return; + } + + Position = TimeSpan.Zero; + _naturalDuration = TimeSpan.Zero; + _state = newValue == null ? MediaElementState.Closed : MediaElementState.Opening; + InvalidateVisual(); + } + + private void ApplyBehavior(MediaState behavior) + { + switch (behavior) + { + case MediaState.Play: + Play(); + break; + case MediaState.Pause: + Pause(); + break; + case MediaState.Stop: + Stop(); + break; + case MediaState.Close: + Close(); + break; + default: + break; + } + } + + private static Texture2D? GetFallbackTexture(GraphicsDevice graphicsDevice) + { + if (_fallbackTexture != null && !_fallbackTexture.IsDisposed) + { + return _fallbackTexture; + } + + _fallbackTexture = new Texture2D(graphicsDevice, 1, 1); + _fallbackTexture.SetData(new[] { Color.White }); + return _fallbackTexture; + } + + private void RaiseMediaFailed(string message, Exception? ex = null) + { + MediaFailed?.Invoke(this, new MediaFailedEventArgs(message, ex)); + } +}