Today we’ll look at organizing the MusicApp interface.
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.
Quick note before we start: The project uses .NET’s standard DI container, and we’ve opted against an MVVM framework entirely.
While we’re not using a full MVVM framework, we’ll create some basic classes to maintain MVVM patterns in our code.
To support INotifyPropertyChanged
, we use the ObservableObject
class:
// Base class implementing INotifyPropertyChanged for MVVM data binding
public abstract class ObservableObject : INotifyPropertyChanged
{
// Event to notify UI when a property changes
public event PropertyChangedEventHandler? PropertyChanged;
// Manually trigger property notifications (e.g., for calculated properties)
public void Invalidate(string? propertyName = null)
{
OnPropertyChanged(propertyName);
}
// Helper method to set property values and auto-raise PropertyChanged
protected bool Set<T>(ref T property, T value, [CallerMemberName] string? propertyName = null)
{
// Skip updates if the value hasn't changed (optimization)
if (property is Enum && property.Equals(value)) return false;
else if (property is IEquatable<T> equatable && equatable.Equals(value)) return false;
else if ((property is null && value is null) || object.ReferenceEquals(property, value)) return false;
// Update property and notify UI
property = value;
OnPropertyChanged(propertyName);
return true;
}
// Default implementation of property change notification
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
The Set
method assigns property values and raises the PropertyChanged
event. Here’s how it looks in code:
public int Volume
{
get => volume;
private set => Set(ref volume, value);
}
To bind UI elements to view models methods, we use the RelayCommand
class:
// Implements ICommand to bridge UI actions (like button clicks) to ViewModel logic
public class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Func<object?, bool>? _canExecute;
// Constructor:
// - `execute`: Method to call when the command runs (required).
// - `canExecute`: Optional condition to enable/disable the command (e.g., gray out a button).
public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
{
ArgumentNullException.ThrowIfNull(execute); // Fail fast if execute is null
_execute = execute;
_canExecute = canExecute;
}
// Event raised when CanExecute state changes (e.g., UI updates button's enabled state)
public event EventHandler? CanExecuteChanged;
// Returns true if the command can run (e.g., "Delete" button only enabled if an item is selected)
public bool CanExecute(object? parameter)
{
return _canExecute == null || _canExecute(parameter);
}
// Executes the command's logic (e.g., saves data or deletes an item)
public void Execute(object? parameter)
{
_execute(parameter);
}
}
All view models classes inherit from a base ViewModel
class… which is currently empty (I haven’t figured out its purpose yet).
That’s all we need from an MVVM framework (at least at this stage of the app’s development). Essentially, this is why we avoided adding another dependency to the project…
Now, about the interface. The main (and currently only) window of the system contains all the controls. No additional views are used.
The logic for this window is handled by two view models: PlayerViewModel
and PlaylistViewModel
. The first manages playback, while the second handles playlist operations. Window interaction is implemented via RelayCommand
directly in the Window class (at least for now).
Part of the functionality has been extracted into dedicated controls like DragTargetControl
. Additionally, since I found the standard slider implementation lacking, I developed a custom solution - details will be covered in the upcoming post.
The view models follow a similar pattern: they subscribe to observable events from services and propagate them to properties (on the UI thread). User input events are translated into calls to corresponding service methods.
For lists, we use a slightly modified ObservableCollection
. The model maintains a mutable collection that gets updated by subscribing to service events:
private void UpdatePlaylist(ItemCollectionAction<MediaItem> action)
{
switch (action.Type)
{
case ItemCollectionActionType.Reset:
// Completely replace all items in the playlist with new ones
items.Set(action.Items.Select(i => new PlaylistItemViewModel(this, i)));
break;
case ItemCollectionActionType.Add:
// Add new items to the playlist (insert at null position = append)
items.Insert(action.Items.Select(i => new PlaylistItemViewModel(this, i)), null);
break;
case ItemCollectionActionType.Remove:
// Find and remove specific item from playlist
var itemToRemove = items.FirstOrDefault(x => x.MediaItem.Equals(action.Items[0]));
if (itemToRemove != null)
{
items.Remove(itemToRemove);
}
break;
}
// Notify UI that IsEmpty property might have changed
Invalidate(nameof(IsEmpty));
}
The Set
and Insert
methods are the previously mentioned modifications to ObservableCollection
. They allow replacing the entire collection at once or adding multiple elements with a single collection change notification. Here’s what this collection looks like:
/// <summary>
/// Enhanced ObservableCollection with bulk operation support for better performance in MVVM scenarios
/// </summary>
public class ItemObservableCollection<T> : ObservableCollection<T>
{
/// <summary>
/// Replaces all items in the collection with a single notification
/// </summary>
/// <param name="items">New items (null or empty clears the collection)</param>
public void Set(IEnumerable<T>? items)
{
// Clear existing items silently (without notifications)
Items.Clear();
// Add new items to the internal storage
foreach (var i in items ?? [])
{
Items.Add(i);
}
// Raise a single Reset notification for the entire operation
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
/// <summary>
/// Bulk-inserts items with a single notification
/// </summary>
/// <param name="items">Items to add</param>
/// <param name="startingIndex">
/// null: append to end
/// [number]: insert at specified position
/// </param>
public void Insert(IEnumerable<T>? items, int? startingIndex = 0)
{
// Early exit for empty inputs
if (items == null || !items.Any())
{
return;
}
// Prepare event args with all new items and correct position
var args = new NotifyCollectionChangedEventArgs(
NotifyCollectionChangedAction.Add,
items!.ToArray(), // Convert to array for event args
startingIndex == null ? Count : startingIndex.Value);
// Actual item insertion
if (startingIndex == null)
{
// Append mode
foreach (var i in items)
{
Items.Add(i);
}
}
else
{
// Insert-at-position mode
var index = startingIndex.Value;
foreach (var i in items)
{
Items.Insert(index++, i);
}
}
// Raise a single notification for all additions
OnCollectionChanged(args);
}
}
The album cover display is implemented in an interesting way. Since all our view models reside in the .Core
project, their properties can’t reference Windows App SDK types. Therefore, we use a converter function that transforms ImageData
into a BitmapImage
during binding:
/// <summary>
/// Converts raw image data into a display-ready BitmapImage
/// </summary>
/// <param name="mediaFileCover">Raw image bytes</param>
/// <returns>Windows-compatible ImageSource or null</returns>
public static ImageSource? LoadCover(ImageData mediaFileCover)
{
// Handle null or empty cover art
if (mediaFileCover == null || mediaFileCover.Size == 0)
{
return null;
}
// Convert byte stream to WinUI-compatible format
using var stream = mediaFileCover.GetStream().AsRandomAccessStream();
var bitmapImage = new BitmapImage();
bitmapImage.SetSource(stream); // Decode image asynchronously
return bitmapImage;
}
<Image
Source="{x:Bind h:Converters.LoadCover(PlayerViewModel.MediaItemCover), Mode=OneWay}"
Stretch="UniformToFill"/>
The MainWindow
, which serves as the view, receives all required view models (and other services) through its constructor. Both the window itself and the view models are created via Dependency Injection.
I know this was a bit all over the place, but hopefully some ideas got through. The project’s source on GitHub might explain things better than I can!