Today, we’ll figure out how to use MediaPlayer
to play music in a WinUI app.
This article is part of a series about creating a simple media player using WinUI 3 framework. Other articles in this series can be found under the MusicApp tag.
The easiest way to use MediaPlayer
is to place UI controls in the window markup and load/play media files in event handlers.
You could also move all the logic to view models.
But we’ll go further—all playback logic will live in services, accessed through abstract interfaces. This way, the app won’t depend on implementation details, and MediaPlayer
could be replaced later without rewriting the rest of the code.
The playback API
IPlaybackService
While prototyping MusicApp, I realized the app only needs one interface for music playback. It’ll handle both playing tracks and managing the playlist. It’s called IPlaybackService
:
/// <summary>
/// Provides core music playback functionality and playlist management.
/// All properties are observable for real-time UI updates.
/// </summary>
public interface IPlaybackService
{
/// <summary>
/// Observable stream of the currently playing media item.
/// </summary>
IObservable<MediaItem> MediaItem { get; }
/// <summary>
/// Observable stream of the current media item's cover art.
/// </summary>
IObservable<ImageData> MediaItemCover { get; }
/// <summary>
/// Collection of media items in the current playlist.
/// </summary>
ItemCollection<MediaItem> Items { get; }
/// <summary>
/// Observable stream of current playback position (in seconds).
/// </summary>
IObservable<int> Position { get; }
/// <summary>
/// Observable stream of current track duration (in seconds).
/// </summary>
IObservable<int> Duration { get; }
/// <summary>
/// Observable stream of current volume level (0-100 scale).
/// </summary>
IObservable<int> Volume { get; }
/// <summary>
/// Observable stream of current playback state (Playing/Paused/Stopped etc.)
/// </summary>
IObservable<PlaybackState> State { get; }
/// <summary>
/// Indicates whether previous track navigation is available.
/// </summary>
IObservable<bool> CanGoPrevious { get; }
/// <summary>
/// Indicates whether next track navigation is available.
/// </summary>
IObservable<bool> CanGoNext { get; }
/// <summary>
/// Observable stream of shuffle mode state.
/// </summary>
IObservable<bool> ShuffleMode { get; }
/// <summary>
/// Observable stream of repeat mode state.
/// </summary>
IObservable<bool> RepeatMode { get; }
/// <summary>
/// Starts playback of specified media item.
/// </summary>
/// <param name="mediaItem">Item to play.</param>
void Play(MediaItem? mediaItem);
/// <summary>
/// Resumes playback of current item or starts first item in playlist.
/// </summary>
void Play();
/// <summary>
/// Pauses current playback.
/// </summary>
void Pause();
/// <summary>
/// Toggles between Play and Pause states.
/// </summary>
void TogglePlayback();
/// <summary>
/// Skips to previous track in playlist.
/// </summary>
void GoPrevious();
/// <summary>
/// Skips to next track in playlist.
/// </summary>
void GoNext();
/// <summary>
/// Seeks to specific position in current track.
/// </summary>
/// <param name="position">Position in seconds.</param>
void SetPosition(int position);
/// <summary>
/// Adjusts playback volume.
/// </summary>
/// <param name="volume">Volume level (0-100).</param>
void SetVolume(int volume);
/// <summary>
/// Enables/disables shuffle mode.
/// </summary>
/// <param name="isShuffleMode">True to enable shuffle.</param>
void SetShuffleMode(bool isShuffleMode);
/// <summary>
/// Enables/disables repeat mode.
/// </summary>
/// <param name="isRepeatMode">True to enable repeat.</param>
void SetRepeatMode(bool isRepeatMode);
}
I’ve settled on using the IObservable
pattern for all services. This approach means the UI simply subscribes to events and automatically reacts to state changes. All modifications flow through dedicated SetXXX
methods, keeping everything clean and maintainable. This approach makes thread-safe UI updates effortless.
It’s worth noting that I’m writing this post while the product is still unfinished. What you’re seeing here is just a draft interface that may change as development progresses.
I’m still weighing several design decisions - like whether to measure track duration in simple seconds, more precise milliseconds, or perhaps use TimeSpan
for cleaner time representation. These implementation details might evolve as the project takes shape.
Playlist
In an early implementation, I used a separate interface for the playlist. But then I realized - all it did was store a list of media items and notify about changes. That’s how the ItemCollection
class was born.
/// <summary>
/// Thread-safe observable collection that broadcasts changes to subscribers.
/// Implements efficient change tracking with granular add/remove/reset notifications.
/// </summary>
/// <typeparam name="T">Item type (must be equatable)</typeparam>
public class ItemCollection<T> : IObservable<ItemCollection<T>.CollectionAction> where T : class, IEquatable<T>
{
private readonly List<T> items = [];
private readonly Subject<CollectionAction> subject = new();
/// <summary>
/// Adds new unique items to the collection.
/// </summary>
/// <param name="newItems">Items to add (skips duplicates)</param>
/// <exception cref="ArgumentNullException">Thrown if input is null</exception>
public void Add(IEnumerable<T> newItems);
/// <summary>
/// Replaces all items in the collection (full reset).
/// </summary>
/// <param name="newItems">New item set (skips duplicates)</param>
/// <exception cref="ArgumentNullException">Thrown if input is null</exception>
public void Set(IEnumerable<T> newItems);
/// <summary>
/// Removes specific item from the collection.
/// </summary>
/// <param name="item">Item to remove</param>
/// <returns>True if item was found and removed</returns>
public bool Remove(T? item);
/// <summary>
/// Clears all items from the collection.
/// </summary>
public void RemoveAll();
/// <summary>
/// Checks if collection contains specific item.
/// </summary>
/// <param name="item">Item to locate</param>
/// <returns>True if item exists in collection</returns>
public bool Contains(T item);
/// <summary>
/// Subscribes to collection change notifications.
/// Immediately pushes current state snapshot to observer.
/// </summary>
/// <param name="observer">Change notification receiver</param>
/// <returns>Disposable subscription token</returns>
public IDisposable Subscribe(IObserver<CollectionAction> observer);
/// <summary>
/// Describes a collection modification event.
/// </summary>
public sealed class CollectionAction
{
/// <summary>Type of change operation</summary>
public CollectionActionType Type { get; init; }
/// <summary>Index where change occurred (for add/remove)</summary>
public int StartingIndex { get; init; }
/// <summary>Affected items (added/removed/full set)</summary>
public required IImmutableList<T> Items { get; init; }
}
/// <summary>
/// Types of collection modifications
/// </summary>
public enum CollectionActionType
{
/// <summary>Complete collection replacement</summary>
Reset,
/// <summary>Items were added</summary>
Add,
/// <summary>Items were removed</summary>
Remove,
}
}
Cover Art
The ImageData
class represents another lesson from trial and error. At its core, it’s just a wrapper around a byte buffer that converts to MemoryStream
on demand. This design ensures cover art loads only once per track, then efficiently serves streams to multiple consumers - whether for the main window, taskbar thumbnails, or system media controls. The IPlaybackService
automatically disposes instances when tracks change, making it a lightweight solution for shared image resources.
/// <summary>
/// Efficient disposable container for track cover art, using array pooling for memory optimization.
/// </summary>
/// <remarks>
/// Key features:
/// - Zero-allocation streaming via on-demand MemoryStream creation
/// - ArrayPool-backed buffer for reduced GC pressure
/// - Thread-safe read operations (immutable after construction)
/// - Empty state support for missing covers
/// </remarks>
public class ImageData : IDisposable
{
/// <summary>
/// Shared instance representing missing/empty cover art.
/// </summary>
public readonly static ImageData Empty = new(null);
private readonly byte[] buffer;
/// <summary>
/// Initializes new ImageData from stream source.
/// </summary>
/// <param name="sourceStream">
/// Input stream (null creates empty instance).
/// Max 2GB size supported.
/// </param>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown if source exceeds int.MaxValue bytes.
/// </exception>
public ImageData(Stream? sourceStream)
{
if (sourceStream == null)
{
Size = 0;
buffer = [];
}
else
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(sourceStream.Length, int.MaxValue);
Size = (int)sourceStream.Length;
buffer = ArrayPool<byte>.Shared.Rent(Size);
sourceStream.ReadExactly(buffer, 0, Size);
}
}
/// <summary>
/// Indicates whether this instance contains no image data.
/// </summary>
public bool IsEmpty => Size == 0;
/// <summary>
/// Size of contained image data in bytes (0 for empty instances).
/// </summary>
public int Size { get; }
/// <summary>
/// Returns rented buffer to ArrayPool (no-op for empty instances).
/// </summary>
public void Dispose()
{
if (!IsEmpty)
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
/// <summary>
/// Creates read-only MemoryStream wrapping the image data.
/// </summary>
/// <returns>
/// Non-resizable stream with image content.
/// Safe for multiple concurrent consumers.
/// </returns>
public Stream GetStream()
{
return new MemoryStream(buffer, 0, Size, false, false);
}
}
Metadata
The MediaItem
class represents the core element of our music player - a single track or media file. This lightweight yet powerful class serves as the fundamental data carrier throughout the application, containing both the file reference and all associated metadata. It’s designed to be immutable after creation, ensuring thread-safe operations across the player components.
/// <summary>
/// Represents a media file with its metadata, serving as the primary data transfer object
/// for playback operations. Implements equality comparison by file name.
/// </summary>
public class MediaItem : IEquatable<MediaItem>, IEquatable<string>
{
/// <summary>
/// Represents an empty/null media item (singleton instance).
/// </summary>
public static readonly MediaItem Empty = new(null);
/// <summary>
/// Initializes a new MediaItem with specified file name.
/// </summary>
/// <param name="fileName">Path to the media file (null creates empty item)</param>
public MediaItem(string? fileName)
{
FileName = fileName ?? string.Empty;
}
/// <summary>
/// Indicates whether this instance represents an empty/non-existent media item.
/// </summary>
public bool IsEmpty => string.IsNullOrEmpty(FileName);
/// <summary>
/// Gets the media file path (empty string for empty items).
/// </summary>
public string FileName { get; }
/// <summary>
/// Optional track number in album.
/// </summary>
public uint? TrackNumber { get; init; }
/// <summary>
/// Optional track title.
/// </summary>
public string? Title { get; init; }
/// <summary>
/// Optional album name.
/// </summary>
public string? Album { get; init; }
/// <summary>
/// Optional artist name.
/// </summary>
public string? Artist { get; init; }
/// <summary>
/// Optional release year.
/// </summary>
public uint? Year { get; init; }
/// <summary>
/// Optional bitrate (in kbps).
/// </summary>
public uint? Bitrate { get; init; }
/// <summary>
/// Optional duration of the media content.
/// </summary>
public TimeSpan? Duration { get; init; }
/// <summary>
/// Collection of genres associated with the media (empty if unspecified).
/// </summary>
public ImmutableArray<string> Genre { get; init; }
/// <summary>
/// Determines whether this media item refers to the same file as the specified path.
/// </summary>
/// <param name="other">File path to compare with</param>
/// <returns>True if paths match (case-insensitive)</returns>
public bool Equals(string? other)
{
return other == null ? false : FileName.Equals(other, StringComparison.CurrentCultureIgnoreCase);
}
/// <summary>
/// Determines whether this media item refers to the same file as another instance.
/// </summary>
/// <param name="other">MediaItem to compare with</param>
/// <returns>True if file paths match (case-insensitive)</returns>
public bool Equals(MediaItem? other)
{
return other == null ? false : Equals(other.FileName);
}
/// <summary>
/// Determines equality with another object (supports MediaItem and string types).
/// </summary>
public override bool Equals(object? obj)
{
return Equals(obj as MediaItem);
}
/// <summary>
/// Gets hash code based on file path (case-insensitive).
/// </summary>
public override int GetHashCode()
{
return FileName.GetHashCode(StringComparison.CurrentCultureIgnoreCase);
}
}
Now that we’ve examined all the supporting classes, let’s return to our core question: how to actually play music using the MediaPlayer
class in WinUI 3.
To play a single music file, we need the MediaPlayer
class. To play multiple tracks, we’ll also need the MediaPlaybackList
class.
The Playback Service implementation
Let’s create the PlaybackService
class to handle music playback. This class will implement the IPlaybackService
interface while hiding all implementation details from the rest of the application.
Main Implementation Idea: we subscribe to MediaPlayer
and MediaPlaybackList
events and forward them to all interested components through observable subscriptions:
// Initializes all media player event subscriptions
private void InitSubscriptions()
{
// 1. Synchronization context validation (must be UI context)
if (SynchronizationContext.Current == null)
{
throw new InvalidOperationException("UI thread synchronization context required");
}
// 2. Playlist items observer
Items
.ObserveOn(SynchronizationContext.Current) // Ensures UI thread execution
.Subscribe(async x => await UpdatePlaylist(x)) // Async playlist update
.DisposeWith(disposable); // Automatic disposal
// 3. Volume change handler
Observable
.FromEventPattern<object>(mediaPlayer, nameof(MediaPlayer.VolumeChanged))
.Select(x => Convert.ToInt32(mediaPlayer.Volume * 100)) // Normalize to 0-100
.Where(x => x >= 0) // Filter invalid values
.ObserveOn(SynchronizationContext.Current) // UI thread safety
.Subscribe(x => volumeSubject.OnNext(x)) // Push to volume stream
.DisposeWith(disposable);
// 4. Playback state tracker
Observable
.FromEventPattern<object>(mediaPlayer, nameof(MediaPlayer.CurrentStateChanged))
.ObserveOn(SynchronizationContext.Current)
.Subscribe(_ => playbackStateSubject.OnNext(GetPlaybackState())) // State mapping
.DisposeWith(disposable);
// 5. Track change processor
Observable
.FromEventPattern<CurrentMediaPlaybackItemChangedEventArgs>(playbackList,
nameof(MediaPlaybackList.CurrentItemChanged))
.Select(x => x.EventArgs.NewItem) // Extract new media item
.Where(x => x != null) // Skip null items
.ObserveOn(SynchronizationContext.Current)
.Subscribe(async x => await SetCurrentItem(x)) // Async item processing
.DisposeWith(disposable);
}
Since the public playlist broadcasts changes via subscription, we need to subscribe to these updates and properly update the native playlist:
/// <summary>
/// Manages a media playlist by handling dynamic updates (reset/add/remove) and synchronizing playback state.
/// </summary>
private async Task UpdatePlaylist(ItemCollection<MediaItem>.CollectionAction action)
{
switch (action.Type)
{
case ItemCollection<MediaItem>.CollectionActionType.Reset:
// Dispose all playback sources and clear the playlist
playbackList.Items
.Select(x => x.Source as IDisposable)
.Where(x => x != null)
.ForEach(x => x.Dispose());
playbackList.Items.Clear();
// Reload all items from the action
foreach (var i in action.Items)
{
playbackList.Items.Add(await LoadPlaybackItem(i));
}
break;
case ItemCollection<MediaItem>.CollectionActionType.Add:
// Add new items to the playlist
foreach (var i in action.Items)
{
playbackList.Items.Add(await LoadPlaybackItem(i));
}
break;
case ItemCollection<MediaItem>.CollectionActionType.Remove:
// Remove a specific item if found
var itemToRemove = FindPlaybackItem(action.Items[0]);
if (itemToRemove != null)
{
playbackList.Items.Remove(itemToRemove);
}
break;
}
// Auto-play if playlist has items but no source is set
if (mediaPlayer.Source == null && playbackList.Items.Count > 0)
{
mediaPlayer.Source = playbackList;
}
// Stop playback if playlist is empty
if (mediaPlayer.Source != null && playbackList.Items.Count == 0)
{
mediaPlayer.Source = null;
await SetCurrentItem(null);
}
}
/// <summary>
/// Finds a MediaPlaybackItem in the playlist matching the specified MediaItem.
/// </summary>
private MediaPlaybackItem? FindPlaybackItem(MediaItem? mediaItem)
{
return mediaItem == null
? null
: playbackList.Items.FirstOrDefault(x =>
x.Source.GetProperty<MediaItem>()?.Equals(mediaItem) == true);
}
/// <summary>
/// Creates a MediaPlaybackItem from a MediaItem, loading the file and setting metadata.
/// </summary>
private static async Task<MediaPlaybackItem> LoadPlaybackItem(MediaItem mediaItem)
{
var file = await StorageFile.GetFileFromPathAsync(mediaItem.FileName);
var mediaSource = MediaSource.CreateFromStorageFile(file);
mediaSource.SetProperty(mediaItem);
var item = new MediaPlaybackItem(mediaSource);
var properties = item.GetDisplayProperties();
properties.Type = MediaPlaybackType.Music;
properties.MusicProperties.Title = mediaItem?.Title;
properties.MusicProperties.AlbumTitle = mediaItem?.Album;
properties.MusicProperties.AlbumArtist = mediaItem?.Artist;
item.ApplyDisplayProperties(properties);
return item;
}
When the current track changes, we need to update:
-
The media item
-
The cover art
-
The track duration
-
Navigation controls (previous/next track availability).
/// <summary>
/// Updates the current track's metadata (cover, duration, position) and navigation state.
/// </summary>
private async Task SetCurrentItem(MediaPlaybackItem? playbackItem)
{
// 1. Extract MediaItem from playback source or use empty fallback
var mediaItem = playbackItem?.Source.GetProperty<MediaItem>() ?? Core.Models.MediaItem.Empty;
mediaItemSubject.OnNext(mediaItem); // Notify subscribers about track change
// 2. Update UI navigation buttons (previous/next)
UpdateNavigationState();
// 3. Clean up previous cover art if disposable
if (mediaItemCoverSubject.Value is IDisposable currentCover)
{
currentCover.Dispose();
}
// 4. Load new cover art asynchronously
var cover = await mediaItem.LoadCover();
mediaItemCoverSubject.OnNext(cover); // Push new cover to subscribers
// 5. Update track duration (if source is open)
var duration = playbackItem?.Source?.IsOpen == true
? Convert.ToInt32(playbackItem.Source.Duration?.TotalSeconds ?? 0)
: 0;
durationSubject.OnNext(duration); // Notify duration change
}
/// <summary>
/// Determines whether previous/next navigation is possible based on current playlist position.
/// Handles both normal and shuffle modes.
/// </summary>
private void UpdateNavigationState()
{
// Get items in current order (shuffled or normal)
var items = shuffleModeSubject.Value
? playbackList.ShuffledItems.ToArray()
: playbackList.Items.ToArray();
// Find current item index
var index = Array.IndexOf(items, playbackList.CurrentItem);
// Navigation logic:
// - Can go previous if not at first item
// - Can go next if not at last item
canGoPreviousSubject.OnNext(index > 0);
canGoNextSubject.OnNext(index > -1 && index < items.Length - 1);
}
The playback position broadcasting implementation has an important nuance. Since we subscribe to position change events, there’s a potential race condition: when manually setting a new position, the system might broadcast an intermediate (unwanted) position before reaching our target. This happens because:
-
Native playback continues progressing during seek operations
-
Position change events fire continuously during playback
-
The seek completion event arrives slightly delayed
The PlaybackPosition
class solves this by:
-
Introducing a seek flag (_isSeeking) that temporarily suppresses events
-
Only allowing natural playback positions (non-seeking) to propagate
-
Automatically resuming updates after seek completion
-
Maintaining thread safety through volatile variables
This creates a “safe zone” during manual positioning while preserving real-time updates during normal playback. The reactive design ensures efficient event handling without unnecessary position jumps in the UI.
/// <summary>
/// Manages playback position updates with seek operation protection.
/// </summary>
private sealed class PlaybackPosition : IObservable<TimeSpan>, IDisposable
{
// Reference to parent service for media player access
private readonly PlaybackService owner;
// Composite disposable for clean resource management
private readonly CompositeDisposable disposable = [];
// The actual position change observable stream
private readonly IObservable<TimeSpan> positionChanged;
// Flag indicating active seek operation (volatile for thread safety)
private volatile bool _isSeeking;
public PlaybackPosition(PlaybackService owner)
{
ArgumentNullException.ThrowIfNull(owner);
this.owner = owner;
// 1. Create filtered position observable:
positionChanged = Observable
// Convert PositionChanged event to observable
.FromEventPattern<object>(owner.mediaPlayer.PlaybackSession, nameof(MediaPlaybackSession.PositionChanged))
// Filter out events during seeking
.Where(_ => !_isSeeking)
// Extract current position
.Select(x => owner.mediaPlayer.PlaybackSession.Position)
// Make the observable hot (shared subscription)
.Publish()
.RefCount();
// 2. Handle seek completion:
Observable.FromEventPattern<object>(owner.mediaPlayer.PlaybackSession, nameof(MediaPlaybackSession.SeekCompleted))
.Subscribe(_ => _isSeeking = false) // Reset flag on seek completion
.DisposeWith(disposable); // Automatic cleanup
}
/// <summary>
/// Safely disposes all subscriptions
/// </summary>
public void Dispose()
{
if (!disposable.IsDisposed)
{
disposable.Dispose();
}
}
/// <summary>
/// Subscribes to position changes (excluding seek operations)
/// </summary>
public IDisposable Subscribe(IObserver<TimeSpan> observer)
{
ArgumentNullException.ThrowIfNull(observer);
return positionChanged.Subscribe(observer);
}
/// <summary>
/// Sets new playback position with seek protection
/// </summary>
public void SetPosition(TimeSpan position)
{
_isSeeking = true; // Activate seek lock
owner.mediaPlayer.PlaybackSession.Position = position; // Update position
}
}
The remaining functionality of the PlaybackService
class was relatively straightforward to implement, so we didn’t examine it in detail here. The complete source code was made available on GitHub.
Next time, we’ll examine the project structure.