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.

UI

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!