Channels ▼
RSS

Web Development

Your Own MP3 Duration Calculator


The MainWindow Class

The MainWindow class is responsible for displaying the main window and also functions as the controller. In a larger system I would probably split it into multiple files and maybe use some application framework, but in such a small utility I felt okay with just managing everything in a single class. The UI is defined declaratively using XAML in MainWindow.xaml and the controller code is in the code-behind class MainWindow.xaml.cs.

Let's start with the UI. The root element is naturally the <Window>. It contains the usual XML namespaces for XAML and the local namespace for the MP3DurationCalculator. It also contains the title of the window and its initial dimensions. There is also a <Window.Resources> sub-element that contains resources that can be shared by all elements in the window. In this case it's the DurationConverter class. I'll talk more about it later. Here is the XAML for <Window> element:


<Window x:Class="MP3DurationCalculator.MainWindow"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:local="clr-namespace:MP3DurationCalculator"
  Title="Duration Calculator" Height="293" Width="376">
  <Window.Resources>
    <local:DurationConverter x:Key="DurationConverter"></local:DurationConverter>
  </Window.Resources>
  ...
  </Window>

The Window element corresponds to the WPF Window class, which is a content container. That means it can hold just one item. That sounds pretty limited, but in practice this single item can be a layout container that itself can hold many items. This is how WPF layout works. You nest containers within containers to get the exact desired layout. The most common and flexible layout container is the grid. WPF Layout is a big topic that warrants its own article (or more). In this article, I try to get away with the minimal amount of explanations necessary to understand the layout of the MP3 Duration Converter. The grid element contains two elements: a media element and a dock panel. The media element is not displayed actually because we use it only to play audio. The dock panel contains the rest of the UI. Here is a collapsed view of the grid:


  <Grid>
    <MediaElement 
        Height="0" 
        Width="0" 
        Name="mediaElement" 
        LoadedBehavior="Manual" />
    <DockPanel LastChildFill="True">
      ...
    </DockPanel>
  </Grid>

The dock panel is another layout container, which contains three stripes arranged vertically. The top stripe contains a browse button for selecting a folder and a text box to display the selected folder name (see Figure 3).

Figure 3

The middle stripe contains a list view that displays the MP3 files in the selected folder (see Figure 4).

Figure 4

The bottom stripe contains a couple of buttons for selecting all or none of the files and a text box to display the total duration of the selected songs (see Figure 5).

Figure 5

The top stripe is a dock panel that has several attributes. The DockPanel.Dock attribute is actually an attached attribute and it applies to outer dock panel (the one that contains all three stripes). It has the value Top, which means that the top strip will be docked to the top part of outer dock panel. The height is defined to be 30 and the vertical alignment is set to Stretch, which means that the top stripe will stretch to fit the entire width of the outer dock panel. The LastChildFill attribute is set to True, which means that that last child laid out will fill the remaining area. The top stripe contains two controls a button and a text box. Both has attached DockPanel.Dock properties that apply to the top stripe itself. The button is docked to the left and the text box is docked to the right. In addition, since the top stripe has a LastChildFill ="True" the text box will stretch to fill the remaining area of the top stripe after the button is laid out. The margin attribute of the button and text box ensures that there will be some spacing between them, so they are not squished together. The button has an event handler called Click that calls the btnSelectDir_Click method when the button is clicked. This method is defined in the code-behind class. Here is XAML for the top stripe:


  <DockPanel 
	DockPanel.Dock="Top" 
	LastChildFill="True"
	Height="30" 
	Margin="0" VerticalAlignment="Stretch"
  >
	<Button DockPanel.Dock="Left"  Height="22" Name="btnSelectDir" Width="84" Margin="3" Click="btnSelectDir_Click">Browse...</Button>
	<TextBox Height="22" Name="tbTargetFolder" MinWidth="258" Margin="3" TextChanged="tbTargetFolder_TextChanged"></TextBox>
  </DockPanel>

The second stripe is actually the bottom stripe and not the middle stripe. The reason for this supposed inconsistency is that I want the middle stripe to fill the remaining area of the outer stripe after the top and bottom have been laid out (according to the LastChildFill attribute), so it must be laid out last. I actually find it a little disorienting. I would prefer if you could simply set one of the DockPanel.Dock of one of the children to Fill instead of making sure it appears last in the XAML file. Anyway, that's the the choice the WPF designers made, so the second stripe is the bottom stripe. It is very similar to the top stripe. It contains two buttons for selecting all files or unseelcting all files and two text boxes. The total text box contains the total duration of all the selected files and the status text box shows various status messages. Named elements like total and status can be accessed using their name in the code-behind class. The text of the "total" text box is data bound to the total.Text property of the MainWindow object. More on that later. Here is the XAML for the bottom stripe:

  

<DockPanel 
	DockPanel.Dock="Bottom" 
	LastChildFill="True"
	Height="30" 
	Margin="0" VerticalAlignment="Stretch"
  >
	<Button DockPanel.Dock="Left" Margin="3" Click="SelectAll_Click">Select All</Button>
	<Button DockPanel.Dock="Left" Margin="3" Click="UnselectAll_Click">Unselect All</Button>
	<TextBox DockPanel.Dock="Left" Height="22" Name="total" Width="60" Margin="3" 
	Text="{Binding Path=Self, Converter={StaticResource DurationConverter}}"></TextBox>
	<TextBox Height="22" Name="status" MinWidth="100" Margin="3" />
  </DockPanel>

The middle stripe is a list view. It has no DockPanel.Dock attached property because it is the last child and thus just fills the area left between the top stripe and the bottom stripe. When the Window is resized the top and bottom stripe keep their fixed height and the list view is resize to accommodate the new height of the window. The list view has a name ("files") because it is accessed programmatically from the MainWindow class. It also has an event handler for the SelectionChanged event. The list view also has a view property, which is a grid view in this case (the list view supports several view types including custom views). The grid view has two columns, which are bound to the Name and Duration properties of its item object. The items that populate the list must be object that have a Name and Duration properties. The Duration property is converted using the DurationConverter to a more display-friendly format. Here is the XAML for the list view:


  <ListView MinHeight="223" Name="files" MinWidth="355" Background="White" SelectionChanged="files_SelectionChanged">
	<ListView.View>
	  <GridView>
		<GridView.Columns>
		  <GridViewColumn 
			Header="Filename"
			DisplayMemberBinding="{Binding Path=Name}"
			Width="Auto"
		  />
		  <GridViewColumn 
			Header="Duration"
                                 DisplayMemberBinding="{Binding Path=Duration, Converter={StaticResource DurationConverter}}"
			Width="Auto"
		  />
		</GridView.Columns>
	  </GridView>
	</ListView.View>
  </ListView>

Let's move on to the MainWindow code-behind class. This class oversees the following actions: browse for a new folder that contains MP3 files, collect track info about every MP3 file, select files from the current folder, calculate and display the total duration of all the selected files.

The state the class works with the following member variables:

_folderBrowserDialog . This is a System.Windows.Forms component used to display a dialog for browsing the file system and selecting a folder. WPF doesn't have its own component, but using the Windows.Forms component is just as easy.

_trackDurations. This is an instance of our very own TrackDurations class described earlier

_results. This is an observable collection of TrackInfo objects received from the TrackDurations class whenever the selection of MP3 files is changed.

_fileCount. Just an integer that says how many files are in the current selected directory.

_maxLength. Just an integer that measures the length of the longest track name. It is used to properly resize the list view columns to make sure track names are not truncated.


FolderBrowserDialog _folderBrowserDialog;
TrackDurations _trackDurations;
ObservableCollection<TrackInfo> _results;
int _fileCount = 0;
double _maxLength = 0;

In addition to these member variables defined in code the MainWindow class also has additional member variables, which are all the named elements in the XAMNL file like the files list view, the total and status text boxes.

The constructor calls the mandatory InitializeComponent() method that reads the XAML and builds all the UI and then instantiates the folder browser dialog and initializes its properties so it starts browsing from the Downloads directory (folder) under the current user's desktop directory. It also instantiates _results to an empty collection of TrackInfo objects. The most important action is binding the 'files' list view to _results object by assigning _results to files.ItemsSource. This ensures that the files list view will always display the contents of the _results object.


    public MainWindow()
    {
      InitializeComponent();
      _folderBrowserDialog = new FolderBrowserDialog();
      _folderBrowserDialog.Description =
        "Select the directory that containd the MP3 files.";
      // Do not allow the user to create new files via the FolderBrowserDialog.
      _folderBrowserDialog.ShowNewFolderButton = false;
      _folderBrowserDialog.RootFolder = Environment.SpecialFolder.DesktopDirectory;
      var dt = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
      var start = System.IO.Path.Combine(dt, "Downloads");
      _folderBrowserDialog.SelectedPath = start;
      _results = new ObservableCollection<TrackInfo>();
      files.ItemsSource = _results;
    }

The action is usually triggered by selecting a folder to browse. When you click the 'Browse...' button the BrowseFolderDialog pops up and lets you select a target directory. If the user selected a folder and clicked 'OK' the text of the tbTargetFolder text box is set to the selected path. If the user clicked 'Cancel' nothing happens.


void btnSelectDir_Click(object sender, RoutedEventArgs e)
{
  DialogResult r = _folderBrowserDialog.ShowDialog();
  if (r == System.Windows.Forms.DialogResult.OK)
  {
	tbTargetFolder.Text = this._folderBrowserDialog.SelectedPath;
  }
}

Another way to initiate the action is to directly type a folder path into the tbTargetFolder text box. In both cases the TextChanged event of the text box will fire and corresponding event handler will be called. It will check if the text constitutes a valid folder path and if so calls the collectTrackInfo() method.


    private void tbTargetFolder_TextChanged(object sender, 
    										TextChangedEventArgs e)
    {
      if (Directory.Exists(tbTargetFolder.Text))
        collectTrackInfo(tbTargetFolder.Text);
    } 

The collectTrackInfo() method is pretty central so I'll explain it in detail. First of all, it disables the browse button and the target folder text box to ensure that the user doesn't try to go to a different folder while the collection is in progress. This prevents a whole class of race condition and synchronization issues.


    void collectTrackInfo(string targetFolder)
    {
      btnSelectDir.IsEnabled = false;
      tbTargetFolder.IsEnabled = false;

The next part is getting all the MP3 files in the target folder. I used a LINQ expression that reads almost like English: "From the files in the target folder select all the files whose extension is ".mp3":

      
var mp3_files = from f in Directory.GetFiles(targetFolder)
                      where System.IO.Path.GetExtension(f) == ".mp3"
                      select f;

The collection of mp3 files is returned in the reverse order for some reason, so I reverse them back.


      mp3_files = mp3_files.Reverse();

Now, the _fileCount member variable is updated and the _results collection is cleared:


      _fileCount = mp3_files.Count();
      _results.Clear();

If _fileCount is 0 it means no mp3 files were found and there is no need to collect any track information. The status text box is updated and the browse button and the target folder text box are enabled.


      if (_fileCount == 0)
      {
        status.Text = "No MP3 files in this folder.";
        btnSelectDir.IsEnabled = true;
        tbTargetFolder.IsEnabled = true;
      }

If _fileCount is greater than 0, then a new instance of _trackDurations is created and receives the media element, the collection of mp3 files in the target folder and the onTrackInfo() callback.


      else
        _trackDurations = new TrackDurations(mediaElement, 
                                             mp3_files, 
                                             onTrackInfo);

The onTrackInfo() callback is called by TrackDurations every time the information about one of the tracks is collected and once more in the end (with a null TrackInfo). If ti (the TrackInfo object) is null it means we are done with the current directory. The _maxLength variable is reset to 0, the _trackDurations object is disposed of, the status text box displays "Ready." and the selection controls are enabled again.


    void onTrackInfo(TrackInfo ti)
    {
      if (ti == null)
      {
        _maxLength = 0;
        _trackDurations.Dispose();
        status.Text = "Ready.";
        btnSelectDir.IsEnabled = true;
        tbTargetFolder.IsEnabled = true;
      }

If ti is not null it means a new TrackInfo object was received asynchronously. First of all the TrackInfo object is added to the _results collection. As you recall (or not) the _results collection is data-bound to the list view, so just adding it to _results make the new track info show up in the list view.


      else
      {
        _results.Add(ti);

The next step is to make sure the new filename fits in the first list view column. This is a little cumbersome and involves first creating a FormattedText object with the proper font of the list view and then checking if the size of this formatted text object is greater than the current _maxWidth. If it is greater than it becomes the new _maxWidth and the width of first column of the list view is set to the new _maxWidth.

   
        // Make sure the new filename fits in the column
        var ft = new FormattedText(
          ti.Name,
          CultureInfo.GetCultureInfo("en-us"),
          System.Windows.FlowDirection.LeftToRight,
          new Typeface(files.FontFamily,
                       files.FontStyle,
                       files.FontWeight,
                       files.FontStretch),
          files.FontSize,
          Brushes.Black);

        if (ft.Width > _maxLength)
        {
          _maxLength = ft.Width;
          var gv = (GridView)files.View;
          var gvc = gv.Columns[0];
          var curWidth = gvc.Width;

          // Reset to a specific width before auto-sizing
          gvc.Width = _maxLength;
          // This causes auto-sizing
          gvc.Width = Double.NaN;
          
        }   

The last part of the onTrackInfo() method is updating the status line with current count of track info object out of the total number of MP3 files.


        // Update the status line
        var st = String.Format("Collecting track info {0}/{1} ...",
                                _results.Count,
                                _fileCount);
        status.Text = st;
      }
    }

After all the information has been collected the user may select files in the list view using the standard Windows selection conventions (click/space to select, ctrl+click/space to toggle selection and shift+click/space to extend selection). Whenever the selected files change the SelectionChanged event is fired and the event handler calculates the total duration of all the currently selected files and update 'total' text box.

    private void files_SelectionChanged(object sender, 
                                        SelectionChangedEventArgs e)
    {
      var tp = new TimeSpan();
      foreach (var f in files.SelectedItems)
      {
        tp += ((TrackInfo)f).Duration;      
      }

      var d = new DateTime(tp.Ticks);
      string format = "mm:ss";
      if (tp.Hours > 0)
        format = "hh:mm:ss";

      total.Text = d.ToString(format);
    }

There are two buttons called 'Select All' and 'Unselect All' that are hooked to corresponding event handlers and simply select or unselect all the files in list view when clicked. This results of course in a SelectionChanged event handled by the files _SelectionChanged event handler described above.


    private void SelectAll_Click(object sender, RoutedEventArgs e)
    {
      files.SelectAll();
    }
    private void UnselectAll_Click(object sender, RoutedEventArgs e)
    {
      files.UnselectAll();
    }


Related Reading


More Insights






Currently we allow the following HTML tags in comments:

Single tags

These tags can be used alone and don't need an ending tag.

<br> Defines a single line break

<hr> Defines a horizontal line

Matching tags

These require an ending tag - e.g. <i>italic text</i>

<a> Defines an anchor

<b> Defines bold text

<big> Defines big text

<blockquote> Defines a long quotation

<caption> Defines a table caption

<cite> Defines a citation

<code> Defines computer code text

<em> Defines emphasized text

<fieldset> Defines a border around elements in a form

<h1> This is heading 1

<h2> This is heading 2

<h3> This is heading 3

<h4> This is heading 4

<h5> This is heading 5

<h6> This is heading 6

<i> Defines italic text

<p> Defines a paragraph

<pre> Defines preformatted text

<q> Defines a short quotation

<samp> Defines sample computer code text

<small> Defines small text

<span> Defines a section in a document

<s> Defines strikethrough text

<strike> Defines strikethrough text

<strong> Defines strong text

<sub> Defines subscripted text

<sup> Defines superscripted text

<u> Defines underlined text

Dr. Dobb's encourages readers to engage in spirited, healthy debate, including taking us to task. However, Dr. Dobb's moderates all comments posted to our site, and reserves the right to modify or remove any content that it determines to be derogatory, offensive, inflammatory, vulgar, irrelevant/off-topic, racist or obvious marketing or spam. Dr. Dobb's further reserves the right to disable the profile of any commenter participating in said activities.

 
Disqus Tips To upload an avatar photo, first complete your Disqus profile. | View the list of supported HTML tags you can use to style comments. | Please read our commenting policy.
 
Dr. Dobb's TV