Linking Zune media items with LinQ, Part 1 (Matt Gertz)
Way back in October 2007, I wrote up a few posts (here and here) on my experiments with the Windows Media Player object model. The problem I was trying to solve was that, when I had a playlist set to “shuffle,” WMP would break up songs that should always play together (for example, Jackson Browne’s “The Load-Out” and “Stay”, or Pink Floyd’s “Brain Damage” and “Total Eclipse”). In those posts, I worked around this by building my own shuffler which would emit a new randomized playlist which preserved those links, and then playing that playlist. A few months later, after I bought my Zune 80, I updated the program to copy the playlist to a ZPL format, since the format was essentially identical to WPL.
Although this was a good investigation into the WMP object model, I’ve never been happy with the program. First of all, for the program to work, I had to add metadata to the actual music tracks, and anything could come along and blow that metadata away. Second, the tracks had to already be adjacent in the parent playlist for it to work at all. Third, I had to bring up the WMP just to shuffle a playlist, which is pretty heavyweight. Finally, since I mostly care about Zune playlists these days and not WMP at all, it seemed the height of hackery to bring up the WMP to generate something for Zune (since the Zune object model is not public).
So, last week, after seeing my metadata blown away again when I carelessly updated my tracks after upgrading to Windows 7, I decided to work on a more permanent solution. Why bring up the WMP at all (or Zune, for that matter), when the playlists themselves are just XML, and the track just entries into those?
After some experiments with parsing the XML by following the parent/child chain, which seemed kludge to me, I came across a post by Avner in which he discusses porting iTunes playlists to Zune, leveraging LinQ to help with the data parsing. I realized right away that I could do something very similar in this case, and came up with what I think is a pretty elegant solution.
So here’s the plan: the application will:
(1) Load in a playlist that the user chooses
(2) Allow the user to rearrange the playlist
(3) Allow the user to link certain tracks together (and unlink them)
(4) Shuffle the playlist on command, preserving the linkages
(5) Allow the user to change the title of the playlist
(6) Allow the user to cave the playlist (or a copy of it)
(7) And, most important, persist the linkage data between sessions in such a way that the information won’t get blown away.
Let’s get started!
The form
Create a Windows Form Application (about 430 x 300) containing the following:
(1) A ListBox, about 290 x 180 and positioned on the left side, with the SelectionMode property set to “MultiExtended” and the DrawMode set to “OwnerDrawFixed.”
(2) Above that, a Label and EditBox, with the Label text set to “&Title”.
(3) To the right of those, five Buttons going down the right side, labeled “Link”, “Unlink”, “Move Up”, “Move Down”, and “Shuffle”.
(4) A MenuStrip. Select it, and on the right side of the strip, click the tiny right-pointing triangle icon and choose “Insert Standard Items” from the resulting popup. Now, go back and remove all of the items except File (Open, Close, Save, Save As, Exit) and Help (About). The Edit and Tools menu can be completely removed; we won’t be using those.
(5) An OpenFileDialog and a SaveFileDialog. The Filter property of both should be set to “Zune playlists|*.zpl” (without the quotes).
In my example, I also disabled the Maximize box in the form, added an icon and title to the form and application, etc., but those aren’t strictly necessary for the exercise.
The Zune playlist
The format of a Zune playlist (version 2.0) looks like this (memorize this, it will be useful later):
<smil>
<head>
<guid> (some guid)</guid>
<meta name="creatorId" content="(some guid)" />
<meta name="Subtitle" />
<meta name="ContentPartnerName" />
<meta name="ContentPartnerNameType" />
<meta name="ContentPartnerListID" />
<meta name="ItemCount" content="(number of tracks)" />
<meta name="TotalDuration" content=" (total duration) " />
<meta name="AverageRating" content="(whatever the average rating is)" />
<meta name="Generator" content="Zune -- 4.0.740.0" />
<title> (some title) </title>
</head>
<body>
<seq>
<media src="(file path of the track on disk)" serviceId="{BF0A0E00-0100-11DB-89CA-0019B92A3933}" albumTitle="(album title)" albumArtist="(artist)" trackTitle="(name of the track)" trackArtist="(track artist)" duration="(track duration)" />
<media … /> (etc… one for each track)
</seq>
</body>
</smil>
Code for the controls
The rest of this post will be organized based on the controls, starting from the top.
The Form
The only code associated with the form itself is the event handler for Load, a few member variables, and a helper class. Let’s take those in reverse order:
The helper class, xmlMediaEntry
We’ll be reading the Zune playlist using the XDocument class, and then we’ll be using LinQ to retrieve individual <media>…</media> tag pairs representing each track. These will be handed to us as XElements, and as we will want to add each of the tracks to the listbox, we’ll want to wrap these in an object that will expose the proper information to the ListBox – namely, a human-readable string. We will also want to have a way to link a track to another track, and we’ll be using a doubly-linked list to do this. So:
Class xmlMediaEntry
Public xmlElem As XElement
Public PrevLinkedElement As xmlMediaEntry = Nothing
Public NextLinkedElement As xmlMediaEntry = Nothing
That code allows us to cache the XElement and also point to other elements of this class backwards and forward. The header node for a chain of linked songs will have PrevLinkedElement remaining as Nothing; the tail node will have NextLinkedElement remain as Nothing; songs in the middle of the chain will have both of those set to some other node.
The following constructor allows us to initialize the object with the XElement to be cached.
Sub New(ByVal xelem As XElement)
xmlElem = xelem
End Sub
This code, exposed publically as a shared method, allows us to create a canonical (and readable) way to refer to a track in an XElement. To use one of my previous examples, the resulting value might be “The Load-Out (Jackson Browne)”:
Public Shared Function CreateMediaString(ByVal elem As XElement) As String
Return elem.Attribute("trackTitle").Value & _
" (" & elem.Attribute("trackArtist").Value & ")"
End Function
I can then leverage that function to override the ToString for the xmlMediaEntry:
Public Overrides Function ToString() As String
Return CreateMediaString(xmlElem)
End Function
End Class
And that’s all I need in that class. Once I add an instance of it to the ListBox, it will show up with nice readable text, and I’ll be able to navigate between it and any other linked songs (if any).
Member Variables
I’ll need a few variables to keep track of the state of the file, and these should all be self-explanatory:
Private FileLoaded As Boolean = False ' Do we have a file loaded into the listbox?
Private FileChanged As Boolean = False ' Has the playlist changed since it was last loaded/saved?
Private FilePath As String ' Where was the file last loaded from/saved to?
Private FileDirectory As String ' What directory was the file last loaded from/saved to?
We’ll also need to keep track of some of the playlist’s metadata. Each playlist has a GUID associated with it (if you don’t know what a GUID is, don’t worry about it – think of it as a unique identifier); if we do a “Save As” to a different file, we’ll want a different GUID. The user might also change the title of the playlist, and we should cache our creatorId format since it might be unique to the user’s situation:
Private playlistGuid As String ' What is the GUID of this playlist?
Private playlistTitle As String ' What is the title of this playlist?
Private playlistCreatorId As XElement ' What is the creator information of this playlist?
Events
The Form only has two events to handle that we care about – the Load event, and the FormClosing event. For the Load event, we’ll want to make sure that our menu items and buttons are enabled appropriately, and that we appropriately generate random numbers later on:
Private Sub VBShuffleForm_Load(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles MyBase.Load
Microsoft.VisualBasic.Randomize()
ResetButtons()
ResetMenus()
End Sub
We will be using those helper functions quite a lot, so it’s worth going over them. For ResetMenus, the “Open” commands will always be available, so we’ll apply a little overkill and ensure that. “Save As” and “Close” will be available whenever a file is loaded (otherwise, we’d have nothing to save or close!), and “Save” will be available if a file is loaded and has been changed since the last time that it was saved or loaded:
Private Sub ResetMenus()
OpenToolStripMenuItem.Enabled = True
CloseToolStripMenuItem.Enabled = FileLoaded
SaveAsToolStripMenuItem.Enabled = FileLoaded
SaveToolStripMenuItem.Enabled = FileLoaded AndAlso FileChanged
edtTitle.Enabled = FileLoaded
End Sub
Somewhat more complicated is ResetButtons. Each button reacts to one of three situations – nothing is selected in the listbox, exactly one thing is selected in the listbox, or multiple things are selected in the listbox. The breakdown is as follows:
· “Link” is only available if multiple things are selected. You can’t link if nothing is selected, and you can’t link a file to itself. It’s also OK to link files that are already linked, since you might be adding more files to the chain of songs.
· “Unlink” is available if at least one item in the selection has a link. You don’t have to select an entire link set to unlink it; it’s sufficient just to select one of the members of the link chain. If the selection contains elements from multiple link chains, then all of the associated chains will be unlinked. We can examine the “PrevLinkedElement” and “NextLinkedElement” values to determine if an item has a link.
· “Move Up” or “Move Down” moves one song, so they are only available if exactly one song is selected.
· “Shuffle” is available whenever a file is loaded.
So:
Private Sub ResetButtons()
If ShuffleListBox.SelectedIndices.Count = 0 Then
LinkBtn.Enabled = False
UnlinkBtn.Enabled = False
MoveDownBtn.Enabled = False
MoveUpBtn.Enabled = False
ElseIf ShuffleListBox.SelectedIndices.Count = 1 Then
LinkBtn.Enabled = False
MoveDownBtn.Enabled = (ShuffleListBox.SelectedIndices(0) <> _
ShuffleListBox.Items.Count() - 1)
MoveUpBtn.Enabled = (ShuffleListBox.SelectedIndices(0) <> 0)
UnlinkBtn.Enabled = (CType(ShuffleListBox.SelectedItems(0), _
xmlMediaEntry).PrevLinkedElement IsNot Nothing OrElse _
CType(ShuffleListBox.SelectedItems(0), xmlMediaEntry).NextLinkedElement _
IsNot Nothing)
Else
LinkBtn.Enabled = True
MoveDownBtn.Enabled = False
MoveUpBtn.Enabled = False
Dim enableBn As Boolean = True
For Each item In ShuffleListBox.SelectedItems
If CType(item, xmlMediaEntry).PrevLinkedElement Is Nothing AndAlso _
CType(item, xmlMediaEntry).NextLinkedElement Is Nothing Then
enableBn = False
Exit For
End If
Next
UnlinkBtn.Enabled = enableBn
End If
ShuffleBtn.Enabled = FileLoaded
End Sub
The FormClosing event handler needs to prompt the user if there are unsaved changes before closing the application. (Note that this is the event generated from the clicking the “X” on the form’s title bar – not the “Close” menu item! We’ll implement that other one later.) The user will have an opportunity to save his/her work before closing (if applicable), or else canceling the Close operation altogether.
First, we’ll define the code which determines if it’s OK to close:
Private Function OKToUnload() As Boolean
If Not FileLoaded OrElse Not FileChanged Then
Return True
End If
Dim result As MsgBoxResult = _
MsgBox("Do you want to save your changes?", _
MsgBoxStyle.YesNoCancel, "Save changes?")
If result = MsgBoxResult.Cancel Then
Return False
Else
If result = MsgBoxResult.Yes Then
Return SaveAs()
End If
End If
Return True
End Function
If there is no file loaded, or if the file is loaded but hasn’t changed, then it’s of course OK to proceed, and we return True. Otherwise, we pop up a MessageBox prompting the user if they want to save their changes. If the user chooses “Cancel,” then we simply return False to indicate that it is not OK to proceed. If the user chooses “Yes,” then we call SaveAs() (which we’ll define later) and return whatever the results are from that call. That just leaves “No,” which means the user doesn’t want to save changes, so we return True indicating that it’s OK to proceed. (The strings for the message box should be stored in the resources so that they can be localized, of course, but that makes the blog less readable. J)
Now, we can use that helper function in the event handler for Close:
Private Sub VBShuffleForm_FormClosing(ByVal sender As Object, _
ByVal e As System.Windows.Forms.FormClosingEventArgs) Handles Me.FormClosing
If Not OKToUnload() Then
e.Cancel = True
End If
End Sub
In other words, if it’s not OK to close the application, then we set e.Cancel to True indicating that the event should be canceled.
The Menu Strip
Now, we start getting into the meat of the code. Let’s go through these one by one:
FileàOpen
Open is pretty trivial to implement, as you can see:
Private Sub OpenToolStripMenuItem_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles OpenToolStripMenuItem.Click
Open()
End Sub
OK, I’m kidding. I moved all of the handling for this into a helper function, in case I ever wanted to open the file in some other way than the menu click. Here’s the actual implementation:
Private Function Open() As Boolean
Try
If Not OKToUnload() Then
Return False ' User canceled; just return.
End If
As you can see, we first leverage the “OKToUnload” helper function to make sure that the user doesn’t lose any work in progress. We’ll also wrap the functionality in a Try construct in case something goes wrong with the opening of the file.
Next, we’ll get the name of the playlist to be opened. If we’ve recently loaded from (or saved to) a directory, we’ll start out there; otherwise, we’ll default to the Music directory as our initial location with no particular file in mind to load:
If String.IsNullOrEmpty(FileDirectory) Then
ShuffleOpenFileDialog.InitialDirectory = My.Computer.FileSystem.SpecialDirectories.MyMusic
Else
ShuffleOpenFileDialog.InitialDirectory = FileDirectory
End If
ShuffleOpenFileDialog.FileName = ""
Dim result As DialogResult = ShuffleOpenFileDialog.ShowDialog
If result = Windows.Forms.DialogResult.Cancel Then
Return False ' User canceled; just return
End If
And we’ll need to cache that information for later usage. (I cache the directory separately, so that I can easily use it to initialize the Save dialog’s initial location later on):
FilePath = ShuffleOpenFileDialog.FileName
Dim fileData As FileInfo = My.Computer.FileSystem.GetFileInfo(FilePath)
FileDirectory = fileData.DirectoryName
Next, we’ll close any open playlist. We’ll use a helper function to do this:
ClosePlaylist()
Where ClosePlaylist() is defined to just flush out our caches, and the listbox:
Private Sub ClosePlaylist()
' Get rid of all items and clear the member variables.
ShuffleListBox.Items.Clear()
FileLoaded = False
FileChanged = False
edtTitle.Text = ""
playlistGuid = ""
playlistTitle = ""
playlistCreatorId = Nothing
End Sub
Finally, we get to the part where we load the file! As I mentioned before, we’ll be using XElements to store the items, and reading in the file in such a case is really easy:
Dim xmlElements As XElement = XElement.Load(FilePath)
If you’re debugging this, take a moment to look at the result in the watch window. You’ll see that this is a fairly deep structure that is difficult to plumb, and so digging though it to find the media items would be quite a chore. Fortunately, LinQ to XML is here to help us, and we can use it liberally. First, let’s get and cache the GUID, so that we can spit it out again if we save back to the same file:
Dim guids = From guidEntry In xmlElements...<guid> _
Select guidEntry
playlistGuid = guids(0).Value
Looks trivial, doesn’t it? But we just saved ourselves about twenty lines of code that we would have had to write to navigate the XElement structure. Basically, I told link to search for the GUID element, nested somewhere under the root of the document (hence the “…” before the <guid> tag) and return me a list (actually an IEnumeration) of all such tags. I happen to know that there’s only one in this schema, so I’ll cache the first (and only) one returned to me.
I can do something similar for the title:
Dim titles = From titleEntry In xmlElements...<title> _
Select titleEntry.Value
playlistTitle = titles(0)
edtTitle.Text = playlistTitle
But creatorId is tricker – it’s not a tag itself, but an attribute on another tag called “<meta>.” There are several other <meta> tags which could be in any order, so I can’t simply grad the first and be done with it. Fortunately, I can use “Where” and the “@” qualifiers to narrow down the field to just the one that I want:
' Get the creatorId metatag
Dim cIds = From metaEntry In xmlElements...<meta> _
Where metaEntry.@name = "creatorId" _
Select metaEntry
playlistCreatorId = cIds(0)
But it’s the media entries that we’re most interested in. We can retrieve those similar to the way we got the title and guid information:, but this time we’ll save the results to a list that we can iterate over. Once we have that list, we will create an xmlMediaEntry for each XElement in that list, and throw it into the ListBox:
Dim mediaEntries = From media In xmlElements...<media> _
Select media
Dim mediaList As List(Of XElement) = mediaEntries.ToList
For Each m In mediaList
ShuffleListBox.Items.Add(New xmlMediaEntry(m))
Next
The files are loaded, so we can update our state appropriately and return a “success:”
FileChanged = False
FileLoaded = True
ResetButtons()
ResetMenus()
Return True
Of course, if something went wrong, we need to tell the user, and return a failure code:
Catch ex As Exception
MsgBox("Error opening file: " & ex.Message, MsgBoxStyle.OkOnly)
Return False ' Oops, problems loading the playlist file.
End Try
End Function
FileàSave and FileàSaveAs
Here’s the other half of the file work. As with Open, we’ll do all of the work in a helper function, which we’ll then be able to leverage in other conditions:
Private Sub SaveToolStripMenuItem_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles SaveToolStripMenuItem.Click
Save()
End Sub
This, in turn, will call another function to emit the XML, after which it will clear the “FileChanged” flag and reset the menus, catching any errors that happen:
Private Function Save() As Boolean
Try
EmitXML()
FileChanged = False
ResetMenus()
Return True
Catch ex As Exception
MsgBox("Error saving file: " & ex.Message, MsgBoxStyle.OkOnly)
Return False
End Try
End Function
EmitXML, as you might guess, is where all of the work happens. But the work itself is pretty simple. Rather than opening a file and spitting in XML tags line by line, we’ll leverage an XML literal to build up the document and then we’ll save it wholesale to the file:
Private Sub EmitXML()
Dim playlist = <?xml version="1.0"?>
<?zpl version="2.0"?><smil>
<head>
<author/>
<guid><%= playlistGuid %></guid>
<%= playlistCreatorId %>
<meta name="Subtitle"/>
<meta name="ContentPartnerName"/>
<meta name="ContentPartnerNameType"/>
<meta name="ContentPartnerListID"/>
<meta name="ItemCount" content="754"/>
<meta name="TotalDuration" content="191685"/>
<meta name="AverageRating" content="7"/>
<meta name="Generator" content="Zune -- 4.0.740.0"/>
<title><%= playlistTitle %></title>
</head>
<body>
<seq>
<%= GetMediaElements() %>
</seq>
</body>
</smil>
playlist.Save(FilePath)
End Sub
How cool is that? The playlistGuid, playlistCreatorId, and playlistTitle will automatically be replaced by whatever we cached, and the media will be generated within the context of the XML by a helper function called GetMediaElements(), the form of which is very simple:
Private Function GetMediaElements() As IEnumerable(Of XElement)
Dim media As New List(Of XElement)
For Each m As xmlMediaEntry In ShuffleListBox.Items
media.Add(m.xmlElem)
Next
Return media
End Function
In other words, we just iterate through the listbox, pull the XElement out of each item, and add it to a list which gets passed back to the XML. That’s it; that’s all there is to it.
Now, we can leverage this for the Save As command; first the event handler:
Private Sub SaveAsToolStripMenuItem_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles SaveAsToolStripMenuItem.Click
SaveAs()
End Sub
And then the helper function. First, we get the name of the file to save, setting the initial directory to what we cached earlier, and defaulting the filename to the name of the title (instead of the original filename, reasoning that, if the user was saving to the same file, they would have used “Save” and not “SaveAs”):
Private Function SaveAs() As Boolean
ShuffleSaveFileDialog.FileName = playlistTitle
ShuffleSaveFileDialog.InitialDirectory = FileDirectory
Dim result As DialogResult = ShuffleSaveFileDialog.ShowDialog()
If result = Windows.Forms.DialogResult.Cancel Then
Return False
End If
We should supply a new GUID if the filename is going to change:
If FilePath <> ShuffleSaveFileDialog.FileName Then
playlistGuid = "{" & System.Guid.NewGuid.ToString() & "}"
End If
Then we cache the name and directory:
FilePath = ShuffleSaveFileDialog.FileName
Dim fileData As FileInfo = My.Computer.FileSystem.GetFileInfo(FilePath)
FileDirectory = fileData.DirectoryName
And having done that, we can just call Save() to do the rest of the work:
Return Save()
End Function
That’s it for the file work – for now. We’ll revisit Open and Save later when we persist the linkage info.
FileàClose and File->Exit
In the case of Close, we’re closing the playlist, not the application; for Exit, we close the application as well. The code for this is pretty trivial, as we have already written both of the helper functions involved:
Private Sub CloseToolStripMenuItem_Click(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles CloseToolStripMenuItem.Click
If OKToUnload() Then
ClosePlaylist()
End If
End Sub
Private Sub ExitToolStripMenuItem_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles ExitToolStripMenuItem.Click
If OKToUnload() Then
Close()' This is Form.Close, of course.
End If
End Sub
HelpàAbout
This is pretty trivial to handle, and I’ve described it before in earlier posts, but here goes: right-click on the project, choose “Add New Item,” and add an About Dialog. Then, right-click the project again and choose “Properties.” In the resulting properties document, select the Application tab (if it isn’t already selected) and click the “Assembly Information…” button. Fill in the information however you prefer. Then, we can hook up the dialog to the command pretty easily as follows:
Private Sub AboutToolStripMenuItem_Click(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles AboutToolStripMenuItem.Click
Dim dlg As New VBSmartShuffleAbout
dlg.ShowDialog()
End Sub
That’s all I’m going to cover in this post… it’s getting too long, and I’ve got nine more pages to go! I’ll post the next set in a separate post, and we’ll wrap this up then.
‘ Til next time,
--Matt--*