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));
+ }
+}