Click here to Skip to main content
Click here to Skip to main content

Sonic: A WPF (hybrid smart client) searchable media library

, 21 Feb 2009 CPOL
Rate this:
Please Sign up or sign in to vote.
A queryable working MP3 player, using some cool LINQ stuff.

Contents

Introduction

This article has been in the making a while, and has sort of become a labour of love. When I first started, the sole purpose of this article was to gain a deeper understanding of some of LINQ internals; I just wanted to gain a better understanding of IQueryProvider and its role in the scheme of things. I needed an arena in which to perform this self study, so what I decided to do was construct a workable/searchable MP3 player. The MP3 player would work of MP3 ID3 tag metadata which would be queryable using IQueryProvider, and would allow the user's MP3s to be persisted to a database (SQL Server).

In essence, this is what this article is all about.

Before you proceed to read the rest of this article, I would just ask you to read the Voting Games section at the bottom of this article.

Before You Start

Before you try and run Sonic at home/work, you will need to install the SQL Server database and change the connection string in the Settings file, and also create your own musicLocationPath (see App.Config).

This article uses some items from .NET 3.5 SP1, so that is a pre-requisite. Sorry.

Overview

Essentially, Sonic (the codename, get the music link) looks like this. It is made up of a number of different constituent Views, and each View is driven by a dedicated ViewModel, which allows the View to bind seamlessly to the ViewModel data. What this also means is that there is very little code-behind in each of the Views, as all the heavy lifting is done by the ViewModel associated with a given View.

We will study each of the Views/ViewModels in more detail later in the article, but for now, here is a brief run down:

  • MainWindow: Hosts a MediaView View and a top control banner, and also has some common buttons for minimise/maximise/close.
  • MediaView View: Hosts an ItemsControl which has a number of AlbumView Views which are driven from their own ViewModels. It also hosts another ItemsControl which has a number of MP3FileView items (again, driven by their own ViewModels), and lastly, it hosts a 3D album art view, which is called AlbumView3D.

We will get into how all this hangs together a bit later on, but for now, just be aware that there are a number of Views that form the whole app, and each View has a ViewModel to deal with its logic.

How it is Intended to Work

I have tried to write this application in a logical manner, and I hope it works as most people would expect it to work. So without further ado, let me delve into how I intended Sonic to work.

When Sonic is first run, it will examine the Settings associated with the Sonic application and look at the "ReReadAllFiles" setting which is initially set to true. If it finds that this flag is set to true, Sonic will scan all the available and valid music (basically only MP3s) that is available using the locations that you specify within the App.Config using the custom Sonic configuration section.

When valid MP3s are found, the ID3 metadata associated with each valid file is stored within a SQL Server table (see the top of this article for the scripts to create that database).

Important note

Once all of the musicLocationPath(s) have been scanned (as configured within the App.Config), the Sonic setting "ReReadAllFiles" will be set to false, such that future runs of Sonic will not cause an entire scan process to occur. So you will need to configure the App.Config to point to your music paths before running Sonic the first time.

So assuming you have successfully scanned in all your music, you will be able to search it using the associated ID3 available metadata. To facilitate this, I have allowed searches to be performed on the following metadata:

  • Artist start letter
  • Genre
  • Song name
  • Artist name

When a search yields results, an ItemsControl within the MediaView will be populated to show the matching albums (AlbumView). From where you can click one of the albums (AlbumView) and have it show a list of the tracks (a number of MP3FileViews) in that album; the album art will be shown in a larger view using a 3D type view (AlbumView3D).

When the list of tracks (a number of MP3FileViews) is shown, you will be able to click on a track and it will play using a WPF MediaPlayer element.

In essence, that is it. Obviously, there is slightly more to it than that; otherwise, it would not have taken me so long to write.

Configuration

OK, now that you know what I was aiming towards, you should know that there is some user configuration required in order to make Sonic work correctly. In order to do this, I have created a custom configuration section which allows you (the user) to specify your own MP3 directory locations.

For me, I store all my music in one or two top level folders, so my personal Sonic configuration section only has one or two entries. Yours may be different.

Anyway that is by the by, let's have a look at the App.Config part that deals with the custom Sonic configuration section; well, for me, it is configured like this:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <configSections>
    <section name="MusicLocationLookup" 
             type="Sonic.MusicLocationLookupConfigSection, Sonic" />
   </configSections>

  <MusicLocationLookup>
    <MusicRepository>
      <!-- Add your MP3 directories here, my uses 
              1 or 2 top levels, your may be different-->
      <add musicLocationPath="E:\MP3's" />
    </MusicRepository>
  </MusicLocationLookup>

So that is all well and good; we can put Sonic related data into its own Config section, but how does that all work? Well, it works by using two dedicated configuration classes. Let's see each of them, shall we?

MusicLocationLookupConfigSection: which is the actual custom Config section class:

/// <summary>
/// The Class that will have the XML config file data 
/// loaded into it via the configuration Manager.
/// </summary>
public class MusicLocationLookupConfigSection : ConfigurationSection
{
    #region Public Properties
    /// <summary>
    /// The value of the property here "SkinNameToTheme" 
    /// needs to match that of the config file section
    /// </summary>
    [ConfigurationProperty("MusicRepository")]
    public MusicLocationElementCollection MusicLocations
    {
        get { return 
            ((MusicLocationElementCollection)
                (base["MusicRepository"])); }
    }
    #endregion
}

MusicLocationElementCollection: which allows for a collection of MusicLocationElement(s) to be added to the App.Config:

/// <summary>
/// A MusicLocation collection class that will store the 
/// list of each MusicLocationElement item that is 
/// returned back from the configuration manager.
/// </summary>
[ConfigurationCollection(typeof(MusicLocationElement))]
public class MusicLocationElementCollection 
    : ConfigurationElementCollection
{
    #region Overrides
    protected override ConfigurationElement 
        CreateNewElement()
    {
        return new MusicLocationElement();
    }


    protected override object GetElementKey(
        ConfigurationElement element)
    {
        return ((MusicLocationElement)(element)).musicPath;
    }
    #endregion

    #region Public Properties
    /// <summary>
    /// Gets ThemeElement at the index provided
    /// </summary>
    public MusicLocationElement this[int idx]
    {
        get
        {
            return (MusicLocationElement)BaseGet(idx);
        }
    }
    #endregion
}

MusicLocationElement: which is an actual directory name of where your music files are stored:

/// <summary>
/// The class that holds information for a single 
/// element returned by the configuration manager.
/// </summary>
public class MusicLocationElement 
    : ConfigurationElement
{
    #region Public Properties
    [ConfigurationProperty("musicLocationPath", 
        DefaultValue = "", IsKey = true, 
        IsRequired = true)]
    public string musicPath
    {
        get { return ((string)(base["musicLocationPath"])); }
        set { base["musicLocationPath"] = value; }
    }
    #endregion
}

I really like the fact that Microsoft opened up Configuration for extension, it makes it quite nice to work with.

Settings

As previously stated, there are a number of settings that Sonic uses to do various things. These settings are as follows:

  • ReReadAllFiles: Which when set to true will force Sonic to re-read all the music as scanned by using the MusicLocationElement(s) that you specified within the App.Config. This setting will be set to false by Sonic after its initial scan. Settings files are strange, and a local copy is actually stored when the Save() method is called on the setting based class. Which is something that Sonic does. Basically, after an initial scan, the "ReReadAllFiles" setting is set to false and the settings are saved, which means even if you set the "ReReadAllFiles" setting back to true in the App.Config, Sonic will use the previously saved version. So if you forget to configure valid MusicLocationElement(s) or would like Sonic to re-scan all your music after the initial run, you will need to locate and delete the saved settings file. For me, this is within a directory named C:\Users\sacha\AppData\Local\Sonic\ Sonic.vshost.exe_Url_hgefzieuxosag5swhmgx4xzo5rtoslkn\0.0.0.0 (yours will be different, but will be roughly in the same place in your profile).
  • AttemptToGainWebAlbumArt: Instructs Sonic that you would like to search Google (yes, that's right, Sonic is capable of doing Google searches) for album art images, instead of looking locally for hard drive stored album art. It is important to note that there is a large impact on doing this. For example, if Sonic is returning 20 albums worth of MP3s for a query, these would load almost instantly when not trying to obtain web based artwork, but take about a minute when trying to grab album art images from Google. It takes a while to do. But I think the effect of having all the correct artwork is worth the wait. But if this delay annoys you, simply toggle this flag to false, and Sonic will try and use local artwork, and if there is none available, a default image will be used for the album art.

Custom Controls

Whilst constructing Sonic, I had to create a number of custom controls that did various things, I will now describe these.

CircularProgressBar

I wanted a marquee style progress bar that was like the web based circular ones that have become popular these days. So I had a look at this and came up with a simple idea; just arrange some ellipses in XAML and do a never ending rotate on the entire control. It's quite effective, and looks like this:

The only thing worth mentioning here is that I changed the default animation frame rate by overriding the metadata associated with StoryBoard types within the constructor of the CircularProgressBar control.

static CircularProgressBar()
{
    //Use a default Animation Framerate of 30, which uses less CPU time
    //than the standard 50 which you get out of the box
    Timeline.DesiredFrameRateProperty.OverrideMetadata(
        typeof(Timeline),
            new FrameworkPropertyMetadata { DefaultValue = 30 });
}

And here is the XAML. Easy, right?

<UserControl x:Class="Sonic.CircularProgressBar"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      Height="120" Width="120" Background="Transparent">
    <Grid x:Name="LayoutRoot" Background="Transparent" 
          HorizontalAlignment="Center" VerticalAlignment="Center">
        <Grid.RenderTransform>
            <ScaleTransform x:Name="SpinnerScale" 
                   ScaleX="1.0" ScaleY="1.0" />
        </Grid.RenderTransform>
        <Canvas RenderTransformOrigin="0.5,0.5" 
                HorizontalAlignment="Center" 
                VerticalAlignment="Center" 
                Width="120" Height="120" >
            <Ellipse Width="21.835" Height="21.862" 
                     Canvas.Left="20.1696" 
                     Canvas.Top="9.76358" Stretch="Fill" 
                     Fill="Red" Opacity="1.0"/>
            <Ellipse Width="21.835" Height="21.862" 
                     Canvas.Left="2.86816" 
                     Canvas.Top="29.9581" Stretch="Fill" 
                     Fill="Orange" Opacity="0.9"/>
            <Ellipse Width="21.835" Height="21.862" 
                     Canvas.Left="5.03758e-006" 
                     Canvas.Top="57.9341" Stretch="Fill" 
                     Fill="Orange" Opacity="0.8"/>
            <Ellipse Width="21.835" Height="21.862" 
                     Canvas.Left="12.1203" 
                     Canvas.Top="83.3163" Stretch="Fill" 
                     Fill="Orange" Opacity="0.7"/>
            <Ellipse Width="21.835" Height="21.862" 
                     Canvas.Left="36.5459" 
                     Canvas.Top="98.138" Stretch="Fill" 
                     Fill="Orange" Opacity="0.6"/>
            <Ellipse Width="21.835" Height="21.862" 
                     Canvas.Left="64.6723" 
                     Canvas.Top="96.8411" Stretch="Fill" 
                     Fill="Orange" Opacity="0.5"/>
            <Ellipse Width="21.835" Height="21.862" 
                     Canvas.Left="87.6176" 
                     Canvas.Top="81.2783" Stretch="Fill" 
                     Fill="Orange" Opacity="0.4"/>
            <Ellipse Width="21.835" Height="21.862" 
                     Canvas.Left="98.165" 
                     Canvas.Top="54.414" Stretch="Fill" 
                     Fill="Orange" Opacity="0.3"/>
            <Ellipse Width="21.835" Height="21.862" 
                     Canvas.Left="92.9838" 
                     Canvas.Top="26.9938" Stretch="Fill" 
                     Fill="Orange" Opacity="0.2"/>
            <Ellipse Width="21.835" Height="21.862" 
                     Canvas.Left="47.2783" 
                     Canvas.Top="0.5" Stretch="Fill" 
                     Fill="Orange" Opacity="0.1"/>
            <Canvas.RenderTransform>
                <RotateTransform x:Name="SpinnerRotate" Angle="0" />
            </Canvas.RenderTransform>
            <Canvas.Triggers>
                <EventTrigger RoutedEvent="ContentControl.Loaded">
                    <BeginStoryboard>
                        <Storyboard>
                            <DoubleAnimation Storyboard.TargetName="SpinnerRotate" 
                                 Storyboard.TargetProperty="(RotateTransform.Angle)" 
                                 From="0" To="360" Duration="0:0:01" 
                                 RepeatBehavior="Forever" />
                        </Storyboard>
                    </BeginStoryboard>
                </EventTrigger>
            </Canvas.Triggers>
        </Canvas>
    </Grid>
</UserControl>

FrictionScrollViewer

I wanted the list of albums that matched a query to be contained in a special friction enabled ScrollViewer; to that end, I created this class which did the trick:

/// <summary>
/// Provides a scrollable ScrollViewer which
/// allows user to apply friction, which in turn
/// animates the ScrollViewer position, giving it
/// the appearance of sliding into position
/// </summary>
public class FrictionScrollViewer : ScrollViewer
{
    #region Data

    // Used when manually scrolling.
    private DispatcherTimer animationTimer = new DispatcherTimer();
    private Point previousPoint;
    private Point scrollStartOffset;
    private Point scrollStartPoint;
    private Point scrollTarget;
    private Vector velocity;
    private Point autoScrollTarget;
    private bool shouldAutoScroll = false;
    #endregion

    #region Ctor
    /// <summary>
    /// Overrides metadata
    /// </summary>
    static FrictionScrollViewer()
    {
        DefaultStyleKeyProperty.OverrideMetadata(
        typeof(FrictionScrollViewer),
        new FrameworkPropertyMetadata(typeof(FrictionScrollViewer)));
    }

    /// <summary>
    /// Initialises all friction related variables
    /// </summary>
    public FrictionScrollViewer()
    {
        Friction = 0.95;
        animationTimer.Interval = new TimeSpan(0, 0, 0, 0, 20);
        animationTimer.Tick += HandleWorldTimerTick;
        animationTimer.Start();
    }
    #endregion

    #region DPs
    /// <summary>
    /// The ammount of friction to use. Use the Friction property to set a 
    /// value between 0 and 1, 0 being no friction 1 is full friction 
    /// meaning the panel won’t "auto-scroll".
    /// </summary>
    public double Friction
    {
        get { return (double)GetValue(FrictionProperty); }
        set { SetValue(FrictionProperty, value); }
    }

    // Using a DependencyProperty as the backing store for Friction.
    public static readonly DependencyProperty FrictionProperty =
        DependencyProperty.Register("Friction", typeof(double),
        typeof(FrictionScrollViewer), new UIPropertyMetadata(0.0));
    #endregion

    #region overrides
    /// <summary>
    /// Get position and CaptureMouse
    /// </summary>
    /// <param name="e"></param>
    protected override void OnMouseDown(MouseButtonEventArgs e)
    {
        if (IsMouseOver)
        {
            shouldAutoScroll = false;
            // Save starting point, used later when determining how much to scroll.
            scrollStartPoint = e.GetPosition(this);
            scrollStartOffset.X = HorizontalOffset;
            scrollStartOffset.Y = VerticalOffset;
            // Update the cursor if can scroll or not. 
            Cursor = (ExtentWidth > ViewportWidth) ||
                (ExtentHeight > ViewportHeight) ?
                Cursors.ScrollAll : Cursors.Arrow;
            CaptureMouse();
        }
        base.OnMouseDown(e);
    }


    /// <summary>
    /// If IsMouseCaptured scroll to correct position. 
    /// Where position is updated by animation timer
    /// </summary>
    protected override void OnMouseMove(MouseEventArgs e)
    {
        if (IsMouseCaptured)
        {
            shouldAutoScroll = false;
            Point currentPoint = e.GetPosition(this);
            // Determine the new amount to scroll.
            Point delta = new Point(scrollStartPoint.X -
                currentPoint.X, scrollStartPoint.Y - currentPoint.Y);
            scrollTarget.X = scrollStartOffset.X + delta.X;
            scrollTarget.Y = scrollStartOffset.Y + delta.Y;
            // Scroll to the new position.
            ScrollToHorizontalOffset(scrollTarget.X);
            ScrollToVerticalOffset(scrollTarget.Y);
        }
        base.OnMouseMove(e);
    }


    /// <summary>
    /// Release MouseCapture if its captured
    /// </summary>
    /// <param name="e"></param>
    protected override void OnMouseUp(MouseButtonEventArgs e)
    {
        if (IsMouseCaptured)
        {
            Cursor = Cursors.Arrow;
            ReleaseMouseCapture();
        }
        base.OnMouseUp(e);
    }
    #endregion

    #region Animation timer Tick
    /// <summary>
    /// Animation timer tick, used to move the scrollviewer incrementally
    /// to the desired position. This also uses the friction setting
    /// when determining how much to move the scrollviewer
    /// </summary>
    private void HandleWorldTimerTick(object sender, EventArgs e)
    {
        if (IsMouseCaptured)
        {
            Point currentPoint = Mouse.GetPosition(this);
            velocity = previousPoint - currentPoint;
            previousPoint = currentPoint;
        }
        else
        {
            if (shouldAutoScroll)
            {
                Point currentScroll = new Point(ScrollInfo.HorizontalOffset + 
            ScrollInfo.ViewportWidth / 2.0, 
            ScrollInfo.VerticalOffset + ScrollInfo.ViewportHeight / 2.0);
                Vector offset = autoScrollTarget - currentScroll;
                shouldAutoScroll = offset.Length > 2.0;

                // FIXME: 10.0 here is the scroll speed factor, a higher value 
        //means slower auto-scroll, 1 means no animation
                ScrollToHorizontalOffset(HorizontalOffset + offset.X / 10.0);
                ScrollToVerticalOffset(VerticalOffset + offset.Y / 10.0);
            }
            else
            {
                if (velocity.Length > 1)
                {
                    ScrollToHorizontalOffset(scrollTarget.X);
                    ScrollToVerticalOffset(scrollTarget.Y);
                    scrollTarget.X += velocity.X;
                    scrollTarget.Y += velocity.Y;
                    velocity *= Friction;
                    System.Diagnostics.Debug.WriteLine("Scroll @ " + 
                                       ScrollInfo.HorizontalOffset + ", " + 
                                       ScrollInfo.VerticalOffset);
                }
            }

            InvalidateScrollInfo();
            InvalidateVisual();
        }
    }
    #endregion

    #region Public Methods/Properties
    public Point AutoScrollTarget
    {
        set
        {
            autoScrollTarget = value;
            shouldAutoScroll = true;
        }
    }


    public void ScrollToCenterTarget(Point target)
    {
        ScrollToHorizontalOffset(target.X - ScrollInfo.ViewportWidth / 2.0);
        ScrollToVerticalOffset(target.Y - ScrollInfo.ViewportHeight / 2.0);
    }
    #endregion
}

You can see this in use within this area of Sonic; simple place your mouse down and drag left or right quickly and let go, and to stop it, click the mouse again (it only drags when not directly over an album):

FancyButton

The only other control I have is a fancy (very fancy button, which I stole from the Blend samples) button. As I stole this, I will not go into the code, but it looks like this, and it has lots of cool StoryBoard animations to make it work. I like it anyway.

Custom Window

I do not really like the way the standard WPF Window looks, so I decided to tackle re-styling it. Luckily, Microsoft has made this pretty easy to do with some standard XAML control templating techniques. There is actually a very good MSDN link that shows the default control templates that the standard controls (of which, Window is one) use.

So armed with this knowledge, it was simply a case of restyling the window the way I wanted. So instead of this Window style:

I would get the following, which is pretty much a blank resizable (with Grip) window. Which allows me to place other controls on for things like minimize/maximize/close.

You do need to specify a few Window level properties to make this happen. Here is an example:

<Window x:Class="Sonic.Views.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="Sonic : Music Library"
    Background="{x:Null}" 
    Topmost="False" 
    WindowStartupLocation="CenterScreen" 
    WindowState="Normal"
    MinHeight="620" 
    MinWidth="950"
    WindowStyle="None" 
    Template="{StaticResource WindowTemplateKey}"
    ResizeMode="CanResizeWithGrip" AllowsTransparency="True">
    <Grid Background="WhiteSmoke">
        
    </Grid>
</Window>

Where the actual Window control template is defined as follows. In the attached demo app, all styles are declared within the ResourceDictionary AppStyles.xaml.

<!-- Custom Window : to allow repositioning of ResizeGrip-->
<ControlTemplate x:Key="WindowTemplateKey" TargetType="{x:Type Window}">
    <Border Background="{TemplateBinding Background}" 
            BorderBrush="{TemplateBinding BorderBrush}" 
            BorderThickness="{TemplateBinding BorderThickness}">
        <Grid>
            <AdornerDecorator>
                <ContentPresenter/>
            </AdornerDecorator>
            <ResizeGrip Visibility="Collapsed" 
                        HorizontalAlignment="Right" x:Name="WindowResizeGrip" 
                        Style="{DynamicResource ResizeGripStyle1}" 
                        VerticalAlignment="Bottom" IsTabStop="false"/>
        </Grid>
    </Border>
    <ControlTemplate.Triggers>
        <MultiTrigger>
            <MultiTrigger.Conditions>
                <Condition Property="ResizeMode" Value="CanResizeWithGrip"/>
                <Condition Property="WindowState" Value="Normal"/>
            </MultiTrigger.Conditions>
            <Setter Property="Visibility" 
                 TargetName="WindowResizeGrip" Value="Visible"/>
        </MultiTrigger>
    </ControlTemplate.Triggers>
</ControlTemplate>

MP3s / ID3

Sonic relies heavily on ID3 tag metadata that may or may not be available within scanned files. Now there are two major versions of the ID3 specification ID3v1, which is fairly simple and actually looks like this:

Which is pretty easy to byte strip; in fact, I have done this myself on a previous project,;however, ID3v2 is a different beast altogether. And to be frank, I couldn't be bothered doing this, so I had a hunt about and found an excellent free ID3 library for .NET which is called UltraID3Lib, which reads both ID3v1 and ID3v2 tags. It is very easy to use, and is obviously included in Sonic.

Here is what it looks like to read an ID3 tag for a given file, where I am returning a specialised MP3 object which has extra functions that I needed within Sonic:

/// <summary>
/// Obtain the ID3 information for the given filename
/// </summary>
public static MP3 ProcessSingleMP3File(String fileName)
{
    MP3 mp3File = null;

    Boolean hasTag = false;

    String album = String.Empty;
    String artist = String.Empty;
    String genreName = String.Empty;
    String title = String.Empty;

    //Use the ID3 Library (and why not)
    UltraID3 readMP3File = new UltraID3();
    readMP3File.Read(fileName);

    //check for ID3 v2 Tag 1st
    if (readMP3File.ID3v2Tag.ExistsInFile)
    {
        hasTag = true;
        album = readMP3File.ID3v2Tag.Album;
        artist = readMP3File.ID3v2Tag.Artist;
        genreName = readMP3File.ID3v2Tag.Genre;
        title = readMP3File.ID3v2Tag.Title;
    }

    //check for ID3 v1 Tag
    if (readMP3File.ID3v1Tag.ExistsInFile && !hasTag)
    {
        hasTag = true;
        album = readMP3File.ID3v1Tag.Album ?? "Uknown";
        artist = readMP3File.ID3v1Tag.Artist ?? "Uknown";
        genreName = readMP3File.ID3v1Tag.GenreName ?? "Uknown";
        title = readMP3File.ID3v1Tag.Title ?? "Uknown";
    }

    //Only create an actual MP3File if we actually found
    //a ID3 Tag
    if (hasTag)
    {
        mp3File = new MP3();
        mp3File.FileName = fileName;
        mp3File.Album = album;
        mp3File.Artist = artist;
        mp3File.GenreName = genreName;
        mp3File.Title = title;
    }

    return mp3File;

}

LINQ Provider

As I stated at the start of this article, one of the main reasons I wanted to write this article was to get a bit more familiar with LINQ and IQueryProvider. I should point out right now that Sonic does not and will never create an entire IQueryProvider implementation, that is an insane amount of work. Basically, what Sonic does is cheat. As Sonic is using SQL Server behind the scenes (LINQ to SQL is an implementation of IQueryProvider, wouldn't you know?), I merely intercept the original query which is an Expression tree, which is what IQueryProvider implementations must work with, and delegate that off to some logic which does the work using my SQL Server DataContext which deals with parsing the Expression tree into a SQL command.

You may ask yourself, why the hell I did that, and you would be right to ask that. Well, to be honest, you would bypass the IQueryProvider implementation within Sonic altogether and go straight to the LINQ to SQL database DataContext, but where is the fun in that? We want to develop a better understanding, don't we? Basically, that is why I did this extra step.

Trying to write an entire IQueryProvider implementation is not for the faint hearted. If you want to know more about this subject, you can read more at Matt Warren's site: LINQ: Building an IQueryable provider series.

OK, so now, all that is said, how does it all work in terms of Sonic?

Well, quite simply, it works like this:

  1. The MediaViewModel has a number of pre-built parameter driven Expressions that are used to feed into my own MP3Provider, which implements QueryProvider (which is a base class that I stole from Matt Warren's site).
  2. What the QueryProvider does is use the incoming Expression tree, by overriding the public override object Execute(Expression expression) method of QueryProvider.
  3. Basically, when working with IQueryProvider, we must work with Expression trees, and not delegates (Func<T,TResult>); the reason being that IQueryProvider is intended to work with things like SQL which use other storage mechanisms/grammar and would not understand what to do with a delegate (Func<T,TResult>), so Expression trees must be used. Typically, the entire Expression tree would be examined using the Visitor pattern, which allows the correct query to be dynamically built for the visited Expression tree. So you can see, by having a specific IQueryProvider implementation, you could use the same Expression tree with numerous IQueryProvider implementation(s). Each IQueryProvider implementation would essentially form its own specific query based on the correct grammar/syntax for the object that it is providing values for. In the case of LINQ to SQL (which is an IQueryProvider implementation), this would be to create a SQL query.

  4. The Expression tree that is passed to the public override object Execute(Expression expression) method of QueryProvider is then used to pass to a helper class that does the real work of using the Expression tree and compiling it into a delegate (Func<T,TResult>), which can be used against the Sonic database DataContext (which as I stated is a IQueryProvider implementation).

This all sounds fairly mental, but perhaps it will become clearer when we work though an example.

OK, so let's start with the source of the query, which is via the search button on the MainWindow, which when pressed will instruct the embedded MediaView (which uses its ViewModel to do the work) to perform a certain type of query. It does this by using Commands, but we will cover that later. For now, let's concentrate on understanding this query mechanism.

We have an Expression within MediaViewViewModel which looks like this, which is an expression that uses a Func<MP3,Boolean> as a selector (or predicate) to filter a collection of MP3 types:

private Expression<Func<MP3, Boolean>> queryExpression = null;

And when we do some sort of search, such as try and search via "Artist Letter":

This will set the current Expression within the MediaViewViewModel to be something like:

public String CurrentArtistLetter
{
    get { return currentArtistLetter; }
    set
    {
        currentArtistLetter = value;
        NotifyPropertyChanged("CurrentArtistLetter");

        //create the correct Query type and Expression for the query
        QueryToPerform = OverallQueryType.ByArtistLetter;
        queryExpression = 
            mp3 => mp3.Artist.ToLower().
                StartsWith(currentArtistLetter.ToLower());
    }
}

We now have an Expression within the MediaViewViewModel that can be used to search for MP3 types. Next, we need to look at how this query is used against the Sonic QueryProvider implementation.

When the query is run within the MediaViewViewModel, the RunQuery() method is run, which looks like this:

/// <summary>
/// Runs the Query using the Expression tree for the Query
/// and then does some LINQ Grouping into Albums, such that
/// the Album cover image can be searched for and all related
/// MP3s that go with the album are kept together
/// </summary>
/// <param name="expr">The Expression tree for the Query</param>
private void RunQuery(Expression<Func<MP3, Boolean>> expr)
{
    try
    {
        //use a Threadpool thread to run the query
        ThreadPool.QueueUserWorkItem(x =>
        {
            IsBusy = true;
            
            MP3s MP3 = new MP3s();
            IQueryable<MP3> query = MP3.Files.Where<MP3>(expr);

            //Create a concrete list
            var mp3sMatched = query.ToList();

            //group the result of the matched MP3s into albums
            var albumsOfMP3s =
              from mp3 in mp3sMatched
              group mp3 by mp3.Album;

            //Now create a ObservableCollection of the grouped results
            //just because an ObservableCollection is easier to bind to
            //then a Dictionary which isnt even Observable

            //This grouping is the grouping of tracks to Albums
            ObservableCollection<AlbumOfMP3ViewModel> albums = 
                new ObservableCollection<AlbumOfMP3ViewModel>();

            double animationOffset = 100;
            double currentAnimationTime = 0;

            //Didn't want to use for loop here, as its grouped,
            //foreach is better, for grouped LINQ objects

            //Allocate Albums with tracks
            foreach (var album in albumsOfMP3s)
            {
                List<MP3> albumFiles = album.ToList();
                AlbumOfMP3ViewModel albumOfMP3s = new AlbumOfMP3ViewModel
                        {
                            Album = albumFiles.First().Album,
                            Artist = albumFiles.First().Artist,
                            Files = albumFiles
                        };
                albumOfMP3s.ObtainImageForAlbum();
                albumOfMP3s.AnimationDelayMs = 
            currentAnimationTime += animationOffset;
                albums.Add(albumOfMP3s);
            }
            //Store the Albums
            AlbumsReturned = albums;
            IsBusy = false;
        });
    }
    catch (Exception ex)
    {
        Console.WriteLine("Ooops, its busted " + ex.Message);
    }
    finally
    {
        IsBusy = false;
    }
}

The most important part of this, by far, is these two lines:

MP3s MP3 = new MP3s();
IQueryable<MP3> query = MP3.Files.Where<MP3>(expr);

What is happening there is a new MP3 object which contains an internal Sonic QueryProvider implementation.

public class MP3s
{
    private IQueryProvider provider;

    public IQueryable<MP3> Files;

    public MP3s() 
    {
        this.provider = new MP3Provider();
        this.Files = new Query<MP3>(this.provider);
    }


    public IQueryProvider Provider
    {
        get { return this.provider; }
    }
}

Now that we can see what that is all about, we can turn our attention to the Sonic QueryProvider implementation.

This works as follows:

public class MP3Provider : QueryProvider
{
    /// <summary>
    /// Returns objects that match the Expression
    /// input
    /// </summary>
    public override object Execute(Expression expression)
    {
        //Get MethodCallExpression where original 
        //Expression would have been something
        //like :
        //
        // MP3.Files.Where<MP3>(mp3 => mp3.FileName.ToLower().Contains("prison"));
        MethodCallExpression mex = expression as MethodCallExpression;

        //get out the lambdaExpression
        Expression<Func<MP3,Boolean>> lambdaExpression =
            (Expression<Func<MP3, Boolean>>)
                (mex.Arguments[1] as UnaryExpression).Operand;

        //get out the Func
        Func<MP3, Boolean> filter = lambdaExpression.Compile();

        //And now query the actual database using this filter

        //NOTE : To be honest we could have gone straight to the
        //QueryXML.GetMatchingMP3Files() method without this
        //QueryProvider....But I wanted to write it, to see
        //if I could understand QueryProviders a bit more.
        //So there.
        return XMLAndSQLQueryOperations.GetMatchingMP3Files(filter);
    }
}

Where this inherits from (from Matt Warren's site) the QueryProvider base class.

/// <summary>
/// A basic abstract LINQ query provider
/// </summary>
public abstract class QueryProvider : IQueryProvider
{
    #region Ctor
    protected QueryProvider()
    {
    }
    #endregion

    #region IQueryProvider Members

    IQueryable<T> IQueryProvider.CreateQuery<T>(Expression expression)
    {
        return new Query<T>(this, expression);
    }

    public IQueryable CreateQuery(Expression expression)
    {
        throw new NotImplementedException();
    }

    public T Execute<T>(Expression expression)
    {
        return (T)this.Execute(expression);
    }

    public abstract object Execute(Expression expression);

    #endregion
}

This may look completely nuts, but all that is happening is the original Expression which was something like:

queryExpression = 
            mp3 => mp3.Artist.ToLower().
                StartsWith(currentArtistLetter.ToLower());

is compiled into a Func<MP3,Boolean> selector (predicate, if you prefer) which can be used against a IEnumerable<MP3> or Query<MP3>.

If we examine the XMLAndSQLQueryOperations.GetMatchingMP3Files(filter) method which uses this Func<MP3,Boolean> predicate, it will hopefully become clearer.

public static IQueryable<MP3> GetMatchingMP3Files(Func<MP3, Boolean> filter)
{
    SQLMP3sDataContext datacontext = new SQLMP3sDataContext();
        return datacontext.MP3s.Where(filter).AsQueryable();
}

See, behind the scenes, it is just using standard LINQ to SQL IEnumerable<MP3> stuff which works with the standard LINQ to SQL DataContext; the only thing it does is cast it to ensure the results are using IQueryable<MP3>. Basically, if a type supports IQueryable<T>, it will use IQueryable<T>, and if it doesn't support being queried, it will use IEnumerable<T>, which uses delegates instead of Expression trees.

Recall that we used a line like this:

MP3s MP3 = new MP3s();
IQueryable<MP3> query = MP3.Files.Where<MP3>(expr);

So we are returning IQueryable<MP3>, but how's that LINQ to SQL doesn't do that out of the box? There is one final step which is to make the types you want to query, queryable. Here is how (again, borrowed from Matt Warren's site):

/// <summary>
/// A default implementation of IQueryable for use with QueryProvider
/// </summary>
public class Query<T> : IQueryable<T>, IQueryable, 
    IEnumerable<T>, IEnumerable, 
    IOrderedQueryable<T>, IOrderedQueryable
{
    IQueryProvider provider;
    Expression expression;

    public Query(IQueryProvider provider)
    {
        if (provider == null)
        {
            throw new ArgumentNullException("provider");
        }
        this.provider = provider;
        this.expression = Expression.Constant(this);
    }

    public Query(QueryProvider provider, Expression expression)
    {
        if (provider == null)
        {
            throw new ArgumentNullException("provider");
        }
        if (expression == null)
        {
            throw new ArgumentNullException("expression");
        }
        if (!typeof(IQueryable<T>).IsAssignableFrom(expression.Type))
        {
            throw new ArgumentOutOfRangeException("expression");
        }
        this.provider = provider;
        this.expression = expression;
    }

    Expression IQueryable.Expression
    {
        get { return this.expression; }
    }

    Type IQueryable.ElementType
    {
        get { return typeof(T); }
    }

    IQueryProvider IQueryable.Provider
    {
        get { return this.provider; }
    }

    public IEnumerator<T> GetEnumerator()
    {
        return ((IEnumerable<T>)
            this.provider.Execute(this.expression))
                .GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return ((IEnumerable)
            this.provider.Execute(this.expression))
                .GetEnumerator();
    }

    public override string ToString()
    {
        if (this.expression.NodeType == ExpressionType.Constant &&
            ((ConstantExpression)this.expression).Value == this)
        {
            return "Query(" + typeof(T) + ")";
        }
        else
        {
            return this.expression.ToString();
        }
    }
}

And breath, you are there. I know this looks insane (well, for me, it is anyhow), but remember you could bypass all this, and simply declare the searches as a Func<MP3,Boolean> predicate instead of Expression trees, and skip all this and use the Func<MP3,Boolean> predicates against LINQ to SQL directly, and work with IEnumerable<MP3> instead.

I just wanted to delve a little deeper is all.

ModelViewViewModel Pattern

If you are trying to get to grips with WPF development, you will want to use the MVVM pattern.

There are a number of great sources regarding this pattern; here are some links:

Sonic actually uses a number of different Views, each of which has its own ViewModel.

Recall this image:

Each of these Views has a dedicated ViewModel. The basic idea is that the View is able to delegate its actions to a ViewModel, and that it is able to bind to its ViewModel using WPF databinding techniques.

The delegation of commands basically means not doing stuff in code-behind, but rather get the ViewModel to do the work and have it update its properties which the Views sees and can adjust its visual representation to reflect.

In order to make this work, there are a number of key things.

Commands

WPF comes with RoutedCommand(s) which allow ViewModels to hold Commands (ICommand implementations) which can hold methods that are run when the command is run from the item that is using the command. The standard RoutedCommand(s) idea is cool, but requires a little bit of XAML/code-behind work in the View, and some bright people have put some time into coming up with alternatives. One such person was Marlon Grech, who wrote a nice delegate style command, which means you can do away with having to do stuff in the View and do it in the ViewModel.

Here is an example:

The ICommand implementation looks like:

/// <summary>
/// Implements the ICommand and wraps up all the verbose 
/// stuff so that you can just pass 2 delegates 1 for the 
/// CanExecute and one for the Execute
/// </summary>
public class SimpleCommand : ICommand
{
    /// <summary>
    /// Gets or sets the Predicate to execute when the 
    /// CanExecute of the command gets called
    /// </summary>
    public Predicate<object> CanExecuteDelegate { get; set; }

    /// <summary>
    /// Gets or sets the action to be called when the 
    /// Execute method of the command gets called
    /// </summary>
    public Action<object> ExecuteDelegate { get; set; }

    #region ICommand Members

    /// <summary>
    /// Checks if the command Execute method can run
    /// </summary>
    /// <param name="parameter">THe command parameter to 
    /// be passed</param>
    /// <returns>Returns true if the command can execute. 
    /// By default true is returned so that if the user of 
    /// SimpleCommand does not specify a CanExecuteCommand 
    /// delegate the command still executes.</returns>
    public bool CanExecute(object parameter)
    {
        if (CanExecuteDelegate != null)
            return CanExecuteDelegate(parameter);
        return true;// if there is no can execute default to true
    }

    public event EventHandler CanExecuteChanged
    {
        add { CommandManager.RequerySuggested += value; }
        remove { CommandManager.RequerySuggested -= value; }
    }

    /// <summary>
    /// Executes the actual command
    /// </summary>
    /// <param name="parameter">THe command parameter to be passed</param>
    public void Execute(object parameter)
    {
        if (ExecuteDelegate != null)
            ExecuteDelegate(parameter);
    }

    #endregion
}

Which allows us to simply have ICommand properties on our ViewModel which the View can bind to.

public class MediaViewViewModel : ViewModelBase
{
    //Commands
    private ICommand runQueryCommand = null;

    public MediaViewViewModel()
    {
        //wire up command
        runQueryCommand = new SimpleCommand
        {
            CanExecuteDelegate = x => !IsBusy && queryExpression != null,
            ExecuteDelegate = x => RunQuery(queryExpression)
        };

        private void RunQuery(Expression<Func<MP3, Boolean>> expr)
        {
            ....
            ....
            ....
        }
    }
}

Which allows the View to simply use this command like this:

<local:FancyButton ButtonToolTip="Search For Music Using This Query" 
    ButtonCommand="{Binding Path=MediaViewVM.RunQueryCommand}"/>

Can you see from this, we can wire up the View to the ViewModel logic. No problems.

INotifyPropertyChanged

The other holy grail is INPC, which simply allows for change notification to be seen by Bindings.

I typically make a base class that I inherit from that deals with this. Here is an example:

/// <summary>
/// Provides a bindable ViewModel base class
/// </summary>
public abstract class ViewModelBase : INotifyPropertyChanged
{
    #region INotifyPropertyChanged implementation
    public event PropertyChangedEventHandler PropertyChanged;

    protected void NotifyPropertyChanged(String info)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(info));
        }
    }
    #endregion
}

OK, so now that we have the basics covered, let's look at a few of these ViewModels, shall we? I will not cover all of them, but I would like to spend a little bit of time talking about maybe one or two of them.

One thing that I have personally noticed is that there are simply no examples that deal with, well, a more complex problem than just showing a list of Xs and updating a certain X. I understand why this is the case; it is because people understand that problem domain. However, real life is not that simple, so I decided to make Sonic involve things like animations etc.

So without further ado, let's consider a ViewModel or two.

AlbumOfMP3ViewModel

The MediaViewViewModel actually has a property on it which is an ObservableCollection<AlbumOfMP3ViewModel> AlbumsReturned that is used to represent the grouped albums of MP3s that matches a particular search.

//This grouping is the grouping of tracks to Albums
ObservableCollection<AlbumOfMP3ViewModel> albums = 
    new ObservableCollection<AlbumOfMP3ViewModel>();

double animationOffset = 100;
double currentAnimationTime = 0;

//Didn't want to use for loop here, as its grouped,
//foreach is better, for grouped LINQ objects

//Allocate Albums with tracks
foreach (var album in albumsOfMP3s)
{
    List<MP3> albumFiles = album.ToList();
    AlbumOfMP3ViewModel albumOfMP3s = new AlbumOfMP3ViewModel
            {
                Album = albumFiles.First().Album,
                Artist = albumFiles.First().Artist,
                Files = albumFiles
            };
    albumOfMP3s.ObtainImageForAlbum();
    albumOfMP3s.AnimationDelayMs = currentAnimationTime += animationOffset;
    albums.Add(albumOfMP3s);
}
//Store the Albums
AlbumsReturned = albums;

Now, what Sonic does with these is within the MediaViewView, there is an ItemsControl that binds to this property of the AlbumsReturned property of the MediaViewViewModel; here is the XAML:

<ItemsControl x:Name="albumItems" 
  VerticalAlignment="Center" Height="130"
  HorizontalAlignment="Stretch" Margin="0"
  ItemsSource="{Binding MediaViewVM.AlbumsReturned}"
  ItemTemplate="{StaticResource albumItemsTemplate}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <StackPanel Orientation="Horizontal"/>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
</ItemsControl>

It can be seen that the ItemsControl binds to the AlbumsReturned property of the MediaViewViewModel. That's cool. What does one of these AlbumOfMP3ViewModel objects look like? Well, it is a ViewModel, so it is just a class. Here is the code. I should just mention that this ViewModel does some cool stuff to try and obtain the album art work. It basically examines the settings to see if it should search the hard drive for album art or do a Google search.

This is discussed in the Settings section in more detail.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Text;
using System.ComponentModel;

//Google .NET API, see GAPI.dll
using System.Timers;
using Gapi.Search;
using System.IO;

namespace Sonic
{
    [DebuggerDisplay("{ToString()}")]
    public class AlbumOfMP3ViewModel : ViewModelBase
    {
        #region Data
        private String album = String.Empty;
        private String artist = String.Empty;
        private List<MP3> files = new List<MP3>();
        private String albumCoverArtUrl = String.Empty;
        private Boolean isAnimatable = false;
        private Double animationDelayMs = 500;
        private Timer delayStartAnimationTimer = new Timer();
        public event EventHandler<EventArgs> 
        AnimationStartTimerExpiredEvent;
        private List<String> 
        allowableLocalImageFormats = new List<String>();

        #endregion

        public AlbumOfMP3ViewModel()
        {
            delayStartAnimationTimer.Enabled = true;
            delayStartAnimationTimer.Elapsed += DelayStartAnimationTimer_Elapsed;

            //add allowable local image formats
            allowableLocalImageFormats.Add("*.jpg");
            allowableLocalImageFormats.Add("*.png");
            allowableLocalImageFormats.Add("*.gif");
        }
        #region Public Methods


        public void OnAnimationStartTimerExpiredEvent()
        {
            // Copy to a temporary variable to be thread-safe.
            EventHandler<EventArgs> temp = AnimationStartTimerExpiredEvent;
            if (temp != null)
                temp(this, new EventArgs());
        }

        public void StartDelayedAnimationTimer()
        {
            delayStartAnimationTimer.Start();      
        }

        /// <summary>
        /// If the AttemptToGainWebAlbumArt setting is on will 
        /// create a google image search for the current Album name
        /// and will attempt to obtain the web page as a string that the
        /// search results url to see if the image is truly available.
        /// 
        /// If it is not available we will get a "404 File Not Found" html
        /// error code. In this case or in the case where we get a WebException,
        /// simply use a defulat application stored image
        /// 
        /// It should be noted that doing a google search takes time, and does
        /// mean there is a lag in getting the search results
        /// </summary>
        /// <returns></returns>
        public Boolean ObtainImageForAlbum()
        {

            Boolean attemptToGainWebAlbumArt = false;
            
            if (Boolean.TryParse(Sonic.Properties.Settings.
                     Default.AttemptToGainWebAlbumArt, 
                    out attemptToGainWebAlbumArt));

            //if the setting is on, we should we use the google .NET api
            //to do a search on google for an image for the album, otherwise 
            //try and find a hard drive stored album image, and if that fails
            //finally use a default image for the album
            if (attemptToGainWebAlbumArt)
            {

                Boolean foundValidImage = false;
                String tempImageUrl = String.Empty;
                WebClient webClient = new WebClient();
                String downloadedContent = String.Empty;

                try
                {
                    SearchResults searchResults =
                        Searcher.Search(SearchType.Image,
                            String.Format("{0}", Album));

                    if (searchResults.Items.Count() > 0)
                    {
                        for (int i = 0; i < 1; i++)
                        {
                            downloadedContent =
                                webClient.DownloadString(searchResults.Items[i].Url);

                            if (!(downloadedContent.Contains("404") ||
                                downloadedContent.ToLower().
                                      Contains("file not found")))
                            {
                                tempImageUrl = searchResults.Items[i].Url;
                                foundValidImage = true;
                                break;
                            }
                            else
                            {
                                foundValidImage = false;
                                break;
                            }
                        }
                    }
                }
                catch (WebException)
                {
                    foundValidImage = false;
                }

                if (foundValidImage)
                    albumCoverArtUrl = tempImageUrl;
                else
                    albumCoverArtUrl = "../Images/NoImage.png";
            }
            //not doing web search so look locally for an image                    
            else
            {
                if (!FoundHardDiskImage())
                {
                    albumCoverArtUrl = "../Images/NoImage.png";
                }
            }
            return true;
        }
        #endregion

        #region Private Methods

        /// <summary>
        /// Signal that the animation start delay has 
          /// occurred so tell View to start its 
        /// loading animation via the AnimationStartTimerExpiredEvent
        /// </summary>
        private void DelayStartAnimationTimer_Elapsed(
              object sender, ElapsedEventArgs e)
        {
            delayStartAnimationTimer.Enabled = false;
            delayStartAnimationTimer.Stop();
            OnAnimationStartTimerExpiredEvent();
        }

        /// <summary>
        /// finds a hard disk stoerd album image if one is available
        /// </summary>
        /// <returns></returns>
        private Boolean FoundHardDiskImage()
        {
            try
            {
                FileInfo f = new FileInfo(files[0].FileName);

                foreach (String allowableLocalImageFormat in 
            allowableLocalImageFormats)
                {

                    String[] imageFiles = 
            Directory.GetFiles(f.Directory.FullName, 
                allowableLocalImageFormat);
                    if (imageFiles.Length > 0)
                    {
                        albumCoverArtUrl = imageFiles[0];
                        return true;
                    }
                }
                return false;
            }
            catch
            {
                albumCoverArtUrl = "../Images/NoImage.png";
                return false;
            }
        }

        #endregion

        #region Public Properties

        public Double AnimationDelayMs
        {
            private get { return animationDelayMs; }
            set
            {
                animationDelayMs = value;
                delayStartAnimationTimer.Interval = animationDelayMs;
            }
        }


        public Boolean IsAnimatable
        {
            get { return isAnimatable; }
            set
            {
                isAnimatable = value;
                NotifyPropertyChanged("IsAnimatable");
            }
        }

        public String Album
        {
            get { return album; }
            set
            {
                album = value;
                NotifyPropertyChanged("Album");
            }
        }

        public String Artist
        {
            get { return artist; }
            set
            {
                artist = value;
                NotifyPropertyChanged("Artist");
            }
        }

        public List<MP3> Files
        {
            get { return files; }
            set
            {
                files = value;
                NotifyPropertyChanged("Files");
            }
        }

        public String AlbumCoverArtUrl
        {
            get { return albumCoverArtUrl; }
            set
            {
                albumCoverArtUrl = value;
                NotifyPropertyChanged("AlbumCoverArtUrl");
            }
        }

        public String ToolTipDisplay
        {
            get { return ToString(); }
        }

        

        #endregion

        #region Overrides
        public override string ToString()
        {
            return String.Format(
                "Album : {0}, Artist : {1}",
                Album, Artist);
        }
        #endregion

    }
}

Well, that's great. So how do we actually get to see some UI for this ViewModel? Well, if we re-examine the ItemsControl XAML again.

<ItemsControl x:Name="albumItems" 
  VerticalAlignment="Center" Height="130"
  HorizontalAlignment="Stretch" Margin="0"
  ItemsSource="{Binding MediaViewVM.AlbumsReturned}"
  ItemTemplate="{StaticResource albumItemsTemplate}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <StackPanel Orientation="Horizontal"/>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
</ItemsControl>

We can see that there is an albumItemsTemplate ItemTemplate template involved for each item. Let us have a look at one of those.

<DataTemplate x:Key="albumItemsTemplate">
    <local:AlbumView DataContext="{Binding}"/>
</DataTemplate>

It can be seen for each item within the ItemsControl (which is really an AlbumOfMP3ViewModel) which allows us to show some UI for the bound item AlbumOfMP3ViewModel.

This is a very powerful technique that allows us to basically slice and dice the UI into smaller parts that are all controlled by ViewModels.

I mentioned earlier that I wanted to support things like animation. Well, if we stick with this example AlbumOfMP3ViewModel ViewModel and look at it in runtime, we will notice that each Item in the ItemsControl (which are really showing individual AlbumView Views) is animated into position based on the associated ViewModel properties.

The AlbumOfMP3ViewModel knows nothing about the View, but rather starts a timer off which, after it elapses, sets an AlbumOfMP3ViewModel property which the AlbumView knows about and so starts its own animation. Do you see, the ViewModel controls the View without even having to know about it? It may become clearer if we look at the code for the AlbumView View.

Here is the XAML:

<UserControl x:Class="Sonic.AlbumView"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      HorizontalAlignment="Left"
      Height="100" Width="100" 
      x:Name="userControl">
    <UserControl.Resources>
        <Storyboard x:Key="OnLoaded1">
            <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" 
                    Storyboard.TargetName="btn" 
                    Storyboard.TargetProperty="(UIElement.RenderTransform).
                       (TransformGroup.Children)[0].(ScaleTransform.ScaleX)">
                <SplineDoubleKeyFrame KeyTime="00:00:00" Value="0"/>
                <SplineDoubleKeyFrame KeyTime="00:00:00.1500000" Value="1.5"/>
                <SplineDoubleKeyFrame KeyTime="00:00:00.2000000" Value="1.25"/>
                <SplineDoubleKeyFrame KeyTime="00:00:00.50" Value="1.0"/>
            </DoubleAnimationUsingKeyFrames>
            <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" 
                    Storyboard.TargetName="btn" 
                    Storyboard.TargetProperty="(UIElement.RenderTransform).
                       (TransformGroup.Children)[0].(ScaleTransform.ScaleY)">
                <SplineDoubleKeyFrame KeyTime="00:00:00" Value="0"/>
                <SplineDoubleKeyFrame KeyTime="00:00:00.1500000" Value="1.5"/>
                <SplineDoubleKeyFrame KeyTime="00:00:00.2000000" Value="1.25"/>
                <SplineDoubleKeyFrame KeyTime="00:00:00.50" Value="1.0"/>
            </DoubleAnimationUsingKeyFrames>
            <ObjectAnimationUsingKeyFrames BeginTime="00:00:00" 
                    Storyboard.TargetName="btn" 
                    Storyboard.TargetProperty="(UIElement.Visibility)">
                <DiscreteObjectKeyFrame KeyTime="00:00:00.05" 
                    Value="{x:Static Visibility.Visible}"/>
            </ObjectAnimationUsingKeyFrames>
        </Storyboard>
    </UserControl.Resources>

    <Button x:Name="btn" Margin="5" 
                        HorizontalAlignment="Center" 
                        VerticalAlignment="Center" 
                        Width="Auto"                     
                        ToolTip="{Binding ToolTipDisplay}" 
                        Click="btn_Click"
                        Template="{StaticResource GlassButton}"
                        RenderTransformOrigin="0.5,0.5">

        <Button.RenderTransform>
            <TransformGroup>
                <ScaleTransform ScaleX="1" ScaleY="1"/>
                <SkewTransform AngleX="0" AngleY="0"/>
                <RotateTransform Angle="0"/>
                <TranslateTransform X="0" Y="0"/>
            </TransformGroup>
        </Button.RenderTransform>

        <Image Margin="4" Source="{Binding AlbumCoverArtUrl}" 
               Stretch="UniformToFill"/>
    </Button>

</UserControl>

And here is the code-behind for this View:

public delegate void AlbumClickedEventHandler(object sender, 
                     AlbumClickedEventArgs e);

/// <summary>
/// Interaction logic for AlbumView.xaml
/// </summary>
public partial class AlbumView : UserControl
{
    public AlbumView()
    {
        InitializeComponent();
        this.DataContextChanged+=AlbumView_DataContextChanged;
    }

    #region Events
    /// <summary>
    /// Raised when Album item clicked
    /// </summary>
    public static readonly RoutedEvent AlbumClickedEvent =
            EventManager.RegisterRoutedEvent(
            "AlbumClicked", RoutingStrategy.Bubble,
            typeof(AlbumClickedEventHandler),
            typeof(AlbumView));

    public event AlbumClickedEventHandler AlbumClicked
    {
        add { AddHandler(AlbumClickedEvent, value); }
        remove { RemoveHandler(AlbumClickedEvent, value); }
    }
    #endregion

    #region Private methods
    /// <summary>
    /// Hook up the associated AlbumOfMP3ViewModel
    /// AnimationStartTimerExpiredEvent event
    /// </summary>
    private void  AlbumView_DataContextChanged(object sender, 
        DependencyPropertyChangedEventArgs e)
    {
        AlbumOfMP3ViewModel viewModel = e.NewValue as AlbumOfMP3ViewModel;
        if (viewModel != null)
        {
            viewModel.StartDelayedAnimationTimer();
            viewModel.AnimationStartTimerExpiredEvent += 
                ViewModel_AnimationStartTimerExpiredEvent;
        }
    }

    /// <summary>
    /// Start the animation Storyboard after the associated AlbumOfMP3ViewModel
    /// timer expires and raises its AnimationStartTimerExpiredEvent event
    /// </summary>
    private void ViewModel_AnimationStartTimerExpiredEvent(object sender, EventArgs e)
    {
        //As the call that populated this control was on a different thread, 
        //we need to do some threading trickery
        this.Dispatcher.InvokeIfRequired(() =>
        {
            Storyboard sb = this.TryFindResource("OnLoaded1") as Storyboard;
            if (sb != null)
            {
                sb.Begin(this.btn);
            }
        }, DispatcherPriority.Normal);
    }

    private void btn_Click(object sender, RoutedEventArgs e)
    {
        //raise our custom AlbumClickedEvent event
        AlbumClickedEventArgs args = new
            AlbumClickedEventArgs(AlbumClickedEvent, 
                this.DataContext as AlbumOfMP3ViewModel);
        RaiseEvent(args);
    }
    #endregion
}

AlbumView3D View

Simply shows 3D album art animation:

MainWindow View/ViewModel

Is the container for all other Views. Its ViewModel sets up the Genres/Artist letters, and has an IsBusy state.

MediaView View/ViewModel

Is the main area within the MainWindow and hosts n-many AlbumView(s) and n-many MP3FileView(s).

MP3FileView View/ViewModel

Represents a single MP3 track.

All these additional View/ViewModels work in the way outlined above.

Drag and Drop Support

Sonic actually allows more music to be added even if the initial scan has been done. This is done via drag and drop, where the user is able to drag an entire directory or just single file(s). This is done by dragging items to the drag area of Sonic.

Now that you have a better understanding of the whole View/ViewModel pattern, I feel it's OK to assume you know that each View has a ViewModel that governs the View. The MainWindow is no exception; it uses the MainWindowViewModel, which has an IsBusy/IsNotBusy property.

What happens is that the MainWindow examines the IsNotBusy property of the MainWindowViewModel, and if it is false, will delegate the drag and drop functions to a DragAndDropHelper helper class that actually does the drag/drop operations.

private void StackPanel_DragOver(object sender, DragEventArgs e)
{
    if (mainWindowViewModel.IsNotBusy)
        dragAndDropHelper.DragOver(e);
}

private void StackPanel_Drop(object sender, DragEventArgs e)
{
    if (mainWindowViewModel.IsNotBusy)
        dragAndDropHelper.Drop(e);
}

The basic idea is this; if the thing being dragged is a directory, all its files are scanned, and if they are not in the Sonic database, they are added to the database, but only if they are valid audio (MP3 only) files.

If the items are files, the process is as outlined above.

/// <summary>
/// File types for Drag operation
/// </summary>
public enum FileType { Audio, NotSupported }

/// <summary>
/// Drag and drop helper
/// </summary>
public class DragAndDropHelper
{
    #region Public Methods
    /// <summary>
    /// Do Drop, which will stored the items in the database
    /// </summary>
    public void Drop(DragEventArgs e)
    {
        try
        {
            e.Effects = DragDropEffects.None;

            string[] fileNames = 
                e.Data.GetData(DataFormats.FileDrop, true) 
                    as string[];

            //is it a directory, get the files and check them
            if (Directory.Exists(fileNames[0]))
            {
                string[] files = Directory.GetFiles(fileNames[0]);
                AddFilesToDatabase(files);
            }
            //not a directory so assume they are individual files
            else
            {
                AddFilesToDatabase(fileNames);
            }
        }
        catch
        {
            e.Effects = DragDropEffects.None;
        }
        finally
        {
            // Mark the event as handled, so control's native 
            //DragOver handler is not called.
            e.Handled = true;
        }
    }

    /// <summary>
    /// Show the Copy DragDropEffect if files are supported
    /// </summary>
    public void DragOver(DragEventArgs e)
    {
        try
        {
            e.Effects = DragDropEffects.None;

            string[] fileNames = 
                e.Data.GetData(DataFormats.FileDrop, true) 
                    as string[];

            //is it a directory, get the files and check them
            if (Directory.Exists(fileNames[0]))
            {
                string[] files = Directory.GetFiles(fileNames[0]);
                CheckFiles(files, e);
            }
            //not a directory so assume they are individual files
            else
            {
                CheckFiles(fileNames, e);
            }
        }
        catch
        {
            e.Effects = DragDropEffects.None;
        }
        finally
        {
            // Mark the event as handled, so control's native 
            //DragOver handler is not called.
            e.Handled = true;
        }
    }

    /// <summary>Returns the FileType </summary>
    /// <param name="fileName">Path of a file.</param>
    public FileType GetFileType(string fileName)
    {
        string extension = System.IO.Path.GetExtension(fileName).ToLower();

        if (extension == ".mp3")
            return FileType.Audio;

        return FileType.NotSupported;
    }
    #endregion


    #region Private Methods
    /// <summary>
    /// Checks that the files being dragged are valid
    /// </summary>
    private void CheckFiles(string[] files, DragEventArgs e)
    {
        foreach (string fileName in files)
        {
            FileType type = GetFileType(fileName);

            // Only Image files are supported
            if (type == FileType.Audio)
                e.Effects = DragDropEffects.Copy;
        }
    }

    /// <summary>
    /// Adds dragged files to database
    /// </summary>
    /// <param name="files"></param>
    private void AddFilesToDatabase(String[] files)
    {
        SQLMP3sDataContext datacontext = new SQLMP3sDataContext();

        try
        {
            foreach (string fileName in files)
            {
                FileType type = GetFileType(fileName);

                // Handles image files
                if (type == FileType.Audio)
                {
                    MP3 mp3File = XMLAndSQLQueryOperations.
                        ProcessSingleMP3File(fileName);
                    if (mp3File != null)
                    {
                        datacontext = new SQLMP3sDataContext();

                        //does it already exist in the database, if it does return
                        if (datacontext.MP3s.Where(mp3 =>
                            mp3.Album == mp3File.Album).Count() > 0)
                            return;

                        //Doesn't exist so add it in to DB
                        datacontext.MP3s.InsertOnSubmit(mp3File);
                        datacontext.SubmitChanges();
                    }
                }
            }
        }
        catch (Exception ex)
        {
            //Oooops, something went wrong reading file
            //not much we can do about it, just skip it
        }
    }
    #endregion
}

Voting Games

I have spent a lot of time and effort on this article, so if anyone out there votes less than 5, could they at least just tell me why? Was the technical content not right? Did you not like the article's layout etc.?

So What Do You Think ?

Anyway, that's it, comments are welcome.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Sacha Barber
Software Developer (Senior)
United Kingdom United Kingdom
I currently hold the following qualifications (amongst others, I also studied Music Technology and Electronics, for my sins)
 
- MSc (Passed with distinctions), in Information Technology for E-Commerce
- BSc Hons (1st class) in Computer Science & Artificial Intelligence
 
Both of these at Sussex University UK.
 
Award(s)

I am lucky enough to have won a few awards for Zany Crazy code articles over the years

  • Microsoft C# MVP 2014
  • Codeproject MVP 2014
  • Microsoft C# MVP 2013
  • Codeproject MVP 2013
  • Microsoft C# MVP 2012
  • Codeproject MVP 2012
  • Microsoft C# MVP 2011
  • Codeproject MVP 2011
  • Microsoft C# MVP 2010
  • Codeproject MVP 2010
  • Microsoft C# MVP 2009
  • Codeproject MVP 2009
  • Microsoft C# MVP 2008
  • Codeproject MVP 2008
  • And numerous codeproject awards which you can see over at my blog

Comments and Discussions

 
GeneralRe: Impressive! PinmvpSacha Barber20-Dec-09 22:18 
GeneralInstalling SQL database... PinmemberJumpin' Jeff20-Jul-09 13:43 
GeneralRe: Installing SQL database... PinmvpSacha Barber20-Jul-09 21:43 
Actually installing SQL is a mare, search google, I found a trick somewhere, you have to select advanced options or something.
 
Sacha Barber
  • Microsoft Visual C# MVP 2008/2009
  • Codeproject MVP 2008/2009
Your best friend is you.
I'm my best friend too. We share the same views, and hardly ever argue
 
My Blog : sachabarber.net

GeneralRe: Installing SQL database... PinmemberJumpin' Jeff21-Jul-09 1:47 
GeneralRe: Installing SQL database... PinmvpSacha Barber21-Jul-09 2:42 
GeneralRe: Installing SQL database... PinmemberJumpin' Jeff21-Jul-09 6:14 
GeneralRe: Installing SQL database... PinmvpSacha Barber21-Jul-09 6:27 
GeneralExcellent work - thanks very much Pinmemberscott.leckie14-Jul-09 10:32 
GeneralRe: Excellent work - thanks very much PinmvpSacha Barber14-Jul-09 21:37 
GeneralWowsers Sacha! Great Stuff! Pinmembermtonsager8-May-09 9:18 
GeneralRe: Wowsers Sacha! Great Stuff! Pinmembermtonsager9-May-09 6:45 
GeneralRe: Wowsers Sacha! Great Stuff! PinmvpSacha Barber12-May-09 23:03 
GeneralRe: Wowsers Sacha! Great Stuff! Pinmembermtonsager14-May-09 10:08 
GeneralReally like the way you find the images from web PinmemberWahab Hussain27-Apr-09 23:24 
GeneralRe: Really like the way you find the images from web PinmvpSacha Barber28-Apr-09 0:23 
Generalvery good Pingroupzhujinlong1984091316-Apr-09 20:50 
GeneralRe: very good PinmvpSacha Barber16-Apr-09 21:40 
GeneralGreat Programming Effort Pinmemberkanu@adatapost7-Apr-09 16:02 
GeneralRe: Great Programming Effort PinmvpSacha Barber7-Apr-09 21:31 
GeneralYou did it again! PinmemberVincenzo Rossi27-Mar-09 5:43 
GeneralRe: You did it again! PinmvpSacha Barber27-Mar-09 6:33 
GeneralPlease be my mentor........ PinmemberNajmul Hoda24-Mar-09 8:45 
GeneralRe: Please be my mentor........ PinmvpSacha Barber24-Mar-09 10:00 
GeneralBest article (very impressive) PinmemberIFFI24-Mar-09 5:14 
GeneralRe: Best article (very impressive) PinmvpSacha Barber24-Mar-09 5:40 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web01 | 2.8.141022.2 | Last Updated 21 Feb 2009
Article Copyright 2009 by Sacha Barber
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid