Linking Zune media items with LinQ, Part 2 (Matt Gertz)
In this post, I’ll continue on with coding the new playlist shuffler. If you haven’t read part 1 yet, I highly recommend it so that this post will make more sense. J
Code for the controls (continued)
The Title TextBox
When the title changes, we’ll want to indicate that the playlist has changed, and we’ll want to cache the change and update the menus. This is pretty simple:
Private Sub edtTitle_TextChanged(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles edtTitle.TextChanged
playlistTitle = edtTitle.Text
FileChanged = True
ResetMenus()
End Sub
The ListBox
The listbox has only two events that we need to handle. The first is just handling whenever the selection changes – we’ll have to update the buttons in such a case:
Private Sub ListBox1_SelectedIndexChanged(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles ShuffleListBox.SelectedIndexChanged
ResetButtons()
End Sub
The other event is the DrawItem event, which is called whenever a listbox element needs to be draw. Normally, this event is handled by the base class, but for this application, we’ll handle it so that we can decorate items which are linked. (We set this up by setting the DrawMode property of the listbox to OwnerDrawFixed.) Now, it even gets called when the listbox is empty, in order to draw the focus rectangle or whatever else should go into an empty list. Drawing isn’t too difficult. First, I’ll let it draw the background as usual:
Private Sub ShuffleListBox_DrawItem(ByVal sender As Object, _
ByVal e As System.Windows.Forms.DrawItemEventArgs) _
Handles ShuffleListBox.DrawItem
e.DrawBackground()
Then, assuming that the index is non-zero and we actually have some text, we’ll check to see if the item is linked. Anything linked with be written in bold, with the header node written in green, the tail node written in red, and anything in-between written in blue. The default, however, will be black text:
If e.Index >= 0 Then
Dim myBrush As Brush = Brushes.Black
which we will use if there are no links involved:
If elem.NextLinkedElement Is Nothing AndAlso elem.PrevLinkedElement Is Nothing Then
e.Graphics.DrawString(ShuffleListBox.Items(e.Index).ToString(), _
e.Font, myBrush, e.Bounds, StringFormat.GenericDefault)
but if there are links, we’ll have to use a bold font:
Else
Dim linkFont As Font = New Font(e.Font.Name, e.Font.Size, _
FontStyle.Bold, e.Font.Unit)
and set the colors appropriately:
If elem.NextLinkedElement IsNot Nothing _
AndAlso elem.PrevLinkedElement Is Nothing Then
myBrush = Brushes.Green ' Starting items are green
ElseIf elem.NextLinkedElement IsNot Nothing _
AndAlso elem.PrevLinkedElement IsNot Nothing Then
myBrush = Brushes.Blue ' Middle items are blue
ElseIf elem.NextLinkedElement Is Nothing _
AndAlso elem.PrevLinkedElement IsNot Nothing Then
myBrush = Brushes.Red ' End items are red
End If
e.Graphics.DrawString(ShuffleListBox.Items(e.Index).ToString(), _
linkFont, myBrush, e.Bounds, StringFormat.GenericDefault)
End If
End If
Finally, regardless of what we drew, we’ll need to draw the focus rectangle (and dotted line around any selections:
e.DrawFocusRectangle()
End Sub
The Link and Unlink buttons
I’ve been chattering a lot about linked files, but without walking through the process of actually linking them, it may be hard to see how they work in practice. When Link is chosen, we’ll take all of the selected files, move them together, and point them at each other as a doubly-linked list. We can then use the existence of these links to change the behavior the program when drawing the items (as we have already done), shuffling them, or even saving them (more on that later).
To link the files, first we need to know how many:
Private Sub LinkBtn_Click(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles LinkBtn.Click
Dim numToLink As Integer = ShuffleListBox.SelectedItems.Count
Then, we need to iterate through each of those and link them.:
For index As Integer = 0 To numToLink - 1
Dim elem As xmlMediaEntry = ShuffleListBox.SelectedItems(index)
elem.NextLinkedElement = If(index <> numToLink - 1,_
CType(ShuffleListBox.SelectedItems(index + 1), xmlMediaEntry), _
Nothing)
elem.PrevLinkedElement = If(index <> 0, _
CType(ShuffleListBox.SelectedItems(index - 1), _
xmlMediaEntry), Nothing)
Note that I am using the If() ternary function above, which takes three arguments. The first is the condition to test, the second is code to run if the condition is true, and the third is code to run if the condition is false. In this case, I’m checking to see if we are at the first or last of the selected items, and linking accordingly. Since header nodes shouldn’t have a “previous” and tail nodes shouldn’t have a “next”, I set these to Nothing under those conditions – otherwise, they are set to reference the adjacent node in the appropriate direction.
Now, I want to physically move the linked tracks together so that they will actually play together. I’ll get the first one where it is, and move the others up right behind it. I do this by removing them from the listbox and adding them back in at the right spot, and then remind the listbox that they should still be selected:
If index <> 0 Then
ShuffleListBox.Items.RemoveAt(ShuffleListBox.SelectedIndices(index))
ShuffleListBox.Items.Insert(ShuffleListBox.SelectedIndices(index - 1) + 1, elem)
ShuffleListBox.SetSelected(ShuffleListBox.SelectedIndices(index - 1) + 1, True)
End If
Next
Be careful here; we are making two assumptions in all of this. First, we will be assuming that the list of selected indices is ordered from lowest to highest, and also that the list of selected objects is in the same order as the selected indices (although we could work around the latter point). These are safe assumptions to make, as near as I can tell. Second, we need to remember that the contents of SelectedIndices are indices into the Items() collection – that is, the contents of the 0th index of the SelectedIndices might refer to the 4th entry of Items() – that can get confusing.
Anyway, once we’ve moved them together and linked them, we need to tell the listbox to repaint so that it redraws the linked items appropriately. To do this, we invalidate the control, and then tell it to update the invalidated regions:
ShuffleListBox.Invalidate()
ShuffleListBox.Update()
Finally, we need to update the buttons and menus, because we have changed the playlist and we are in a condition where we could unlink now:
FileChanged = True
ResetMenus()
ResetButtons()
End Sub
Unlink is sort of the opposite of Link, as you might expect, but there is a catch – we might not have the entire chain in the selection. This means that we’ll have to navigate back to the head node for a chain before navigating through and unlinking. We’ll use a helper function to do this; the unlink handler simply checks each selected item to see if it is part of a chain and calls the helper function if so, then invalidates/repaints/updates:
Private Sub UnlinkBtn_Click(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles UnlinkBtn.Click
For Each item As xmlMediaEntry In ShuffleListBox.SelectedItems
If item.PrevLinkedElement IsNot Nothing _
OrElse item.NextLinkedElement IsNot Nothing Then
FlushLinkSetFromBeginningToEnd(item)
End If
Next
ShuffleListBox.Invalidate()
ShuffleListBox.Update()
FileChanged = True
ResetMenus()
ResetButtons()
End Sub
Note that FlushLinkSetFromBeginning to End will clean out the whole linked chain for that item, so even if other members of that chain were part of the selection, it won’t be called a second time when those members are hit in the loop – they’ll already have had their links cleaned out. To prove that, here’s the code, which is identical to the link-crawling code we all learned in college (if we’re that old yet J):
Private Sub FlushLinkSetFromBeginningToEnd(ByVal elem As xmlMediaEntry)
Dim current As xmlMediaEntry = elem
' Rewind to the head of the linkage
While current.PrevLinkedElement IsNot Nothing
current = current.PrevLinkedElement
End While
' Now destroy the links in this chain, front to back:
Do While current.NextLinkedElement IsNot Nothing
current.PrevLinkedElement = Nothing
current = current.NextLinkedElement
current.PrevLinkedElement.NextLinkedElement = Nothing
Loop
current.PrevLinkedElement = Nothing
End Sub
Move Up and Move Down buttons
After moving the items during the Link, this code will look pretty tame. We’ll create a helper function to do the removal in either direction. We’ll verify that the helper function is told to move up or down exactly one, and return without doing anything otherwise:
Private Sub MoveEntry(ByVal direction As Integer)
If direction <> 1 AndAlso direction <> -1 Then Return
Now we’ll get the item and see if it’s linked. Moving a linked item will put the links out-of-sync with the playlist order, so we will flush the links out, since the assumption is that it was a bad chain to begin with:
Dim index As Integer = ShuffleListBox.SelectedIndices(0)
Dim item As xmlMediaEntry = ShuffleListBox.Items(index)
If item.PrevLinkedElement IsNot Nothing OrElse item.NextLinkedElement IsNot Nothing Then
FlushLinkSetFromBeginningToEnd(item)
End If
Then we remove and insert the item appropriately, keep it selected, and do the usual updating:
ShuffleListBox.Items.RemoveAt(index)
ShuffleListBox.Items.Insert(index + direction, item)
ShuffleListBox.SetSelected(index + direction, True)
ShuffleListBox.Invalidate()
ShuffleListBox.Update()
FileChanged = True
ResetMenus()
ResetButtons()
End Sub
We can then call this helper function from the event handlers, specifying -1 for “up” and +1 for “down”:
Private Sub MoveUpBtn_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MoveUpBtn.Click
MoveEntry(-1) ' Move in the negative direction
End Sub
Private Sub MoveDownBtn_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MoveDownBtn.Click
MoveEntry(1) ' Move in the positive direction
End Sub
The Shuffle Button
If you’ve read the posts that I did on my earlier attempts to shuffle a playlist, then this should be pretty familiar territory – we’ll create a new list, do an insertion from the first list to a random place in the second list (bringing along linked items), and then point the playlist to the new list. The big difference in this case is that we won’t be removing items from the first list until we’re all done. Why? Because the listbox (i.e., the original list) is an active control, and we don’t want it to have it flashing at the user during the shuffle.
First,we’ll create the storage list into which we will insert, and get the number of songs to move:
Private Sub Shuffle_Click(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles ShuffleBtn.Click
Dim newList As New List(Of xmlMediaEntry)
Dim numberOfSongs As Integer = ShuffleListBox.Items.Count
Next, we walk through the original list and start moving things to random locations in the new list. There are two cases: the item is a header node for a linked set, or it is not. If it is a header node, we’ll move all of the associated files (middle and tail nodes) together with it, and increment the loop counter appropriately past all of those:
For index As Integer = 0 To numberOfSongs - 1
Dim mediaItem As xmlMediaEntry = ShuffleListBox.Items(index)
If mediaItem.NextLinkedElement IsNot Nothing Then
Dim placeToInsert As Integer = FindInsertionLocation(newList)
Dim count = 0
Do
Insert(placeToInsert + count, mediaItem, newList)
count += 1
mediaItem = mediaItem.NextLinkedElement
Loop While mediaItem IsNot Nothing
index += count
(FindInsertionLocation() and Insert() are helper functions; we’ll get to those in a second.) In the normal unlinked case, we just insert the file to a random location in the new list:
Else
Insert(mediaItem, newList)
End If
Next
(Again, bear with me – I’ll describe Insert() in a bit.) The new list is ready to go now, so we’ll flush all of the old items all at once using Clear(), and then reinsert the items in the new order.
ShuffleListBox.Items.Clear()
For Each m In newList
ShuffleListBox.Items.Add(m)
Next
' This is a playlist change, so react accordingly:
FileChanged = True
ResetMenus()
ResetButtons()
End Sub
(You could also simply remove and insert items to random location in the listbox without creating a second list – that would save some memory, but not enough to counter the elegance of operating beginning-to-end with minimal flickering in the listbox. If you are interested in that sort of randomization, check out the shuffle routine in my Euchre game series of posts.)
Now, to our helper functions: FindInsertionPoint needs to find a location in the new list that isn’t in the middle of a set of linked songs. We’ll do this in a simple way: generatea number, and if it’s in the middle of a list, then just rewind and use the location before the chain of songs. Of course, if the list is empty, we’d insert at 0. This is fundamentally identical to the version of this function from my earlier posts, so I won’t go into too much detail:
Private Function FindInsertionLocation(ByVal newList As List(Of xmlMediaEntry)) As Integer
If newList.Count = 0 Then
Return 0
Else
Dim placeToInsert As Integer = _
Math.Truncate(Microsoft.VisualBasic.Rnd() * (newList.Count + 1))
If placeToInsert = newList.Count Then Return placeToInsert ' Insert at end
Dim mediaItem As xmlMediaEntry = newList.Item(placeToInsert)
Dim itemEntry As xmlMediaEntry = mediaItem.PrevLinkedElement
If itemEntry IsNot Nothing Then
While itemEntry IsNot Nothing
itemEntry = itemEntry.PrevLinkedElement
placeToInsert -= 1
End While
Return If(placeToInsert >= 0, placeToInsert, 0)
End If
Return placeToInsert
End If
End Function
Insert() just wraps a normal insertion by checking for the condition where the index is at the end of the list, so that Add is used instead in that case:
Private Sub Insert(ByVal place As Integer, ByVal mediaItem As xmlMediaEntry, _
ByVal newList As List(Of xmlMediaEntry))
If place = newList.Count Then
newList.Add(mediaItem)
Else
newList.Insert(place, mediaItem)
End If
End Sub
And its overload (used in the simple file case) just calls FindInsertionLocation to get an index, then calls into its sibling:
Private Sub Insert(ByVal mediaItem As xmlMediaEntry, ByVal newList As List(Of xmlMediaEntry))
Dim place As Integer = FindInsertionLocation(newList)
Insert(place, mediaItem, newList)
End Sub
Persisting Linkage Information from Session to Session
We’re almost done now. Using what we’ve got, we can load in a playlist, link music items, shuffle it, and save it out. The linkage information, however, won’t persist – the next time we load in the playlist, it will be gone. In order to preserve this information so that the PC or Zune won’t stomp on it, we’ll avoid using metadata and instead we’ll persist a second hidden file which keeps this information handy. The information will be stored as XML in this format:
<body>
<mlink>
<media …/>
<media…/>
<media…/>
</mlink>
<mlink>
<media …/>
<media…/>
<media…/>
</mlink>
</body>
So, each <mlink> tag set will contains an arc of linked songs in sequential order.
We’ll save this information to a hidden file with a name identical to that of the playlist, but with the suffix “.links” added to it. After loading the link file after the playlist file, we’ll use this information to relink the files in the listbox. If Zune has changed the corresponding playlist (i.e., if the user has removed, added, or rearranged songs from within Zune), that’s OK; we’ll ignore any songs that we can’t identify.
First, let’s concentrate on saving the information. We’ll create a trivial helper function to create the hidden file’s name:
Private Function GetHiddenFileName() As String
Return FilePath & ".links"
End Function
Now, in EmitXML, we’ll add the following code at the end of the method:
Dim hiddenLinkInfo = <?xml version="1.0"?>
<body>
<%= GetLinkInfo() %>
</body>
This is similar to the other emit code – I create a schema for my XML document, but defer to a helper function to fill in the actual XML elements. Next, I need to determine if a previous version of the hidden file already exists. If it does, I’ll need to un-hide and delete it:
Dim hiddenPath As String = GetHiddenFileName()
If My.Computer.FileSystem.FileExists(hiddenPath) Then
File.SetAttributes(hiddenPath, FileAttributes.Normal)
My.Computer.FileSystem.DeleteFile(hiddenPath, FileIO.UIOption.OnlyErrorDialogs, FileIO.RecycleOption.DeletePermanently)
End If
Finally, I’ll save it and hide it:
' Save it and hide it.
hiddenLinkInfo.Save(hiddenPath)
File.SetAttributes(hiddenPath, File.GetAttributes(hiddenPath) Or FileAttributes.Hidden)
Next, I’ll create the helper function. To show how nicely this all nests, I’ll use one helper function for the <mlink> tags, and then one for the media items within those tags. Here’s GetLinkInfo:
Private Function GetLinkInfo() As IEnumerable(Of XElement)
Dim linkInfos As New List(Of XElement)
For Each m As xmlMediaEntry In ShuffleListBox.Items
If m.NextLinkedElement IsNot Nothing And m.PrevLinkedElement Is Nothing Then
Dim linkInfo = <mlink><%= GetLinkSet(m) %></mlink>
linkInfos.Add(linkInfo)
End If
Next
Return linkInfos
End Function
As you can see, the code iterates through the listbox and finds the next header node for a linked arc of songs. It generates a <mlink> tag for the set, and then uses yet another helper function to get eh media items, called GetLinkSet, which does a standard link crawl and returns the XELements in a list:
Private Function GetLinkSet(ByVal m As xmlMediaEntry) As IEnumerable(Of XElement)
Dim linkSet As New List(Of XElement)
Do
linkSet.Add(m.xmlElem)
m = m.NextLinkedElement
Loop While m IsNot Nothing
Return linkSet
End Function
I really like this solution; it is extremely elegant! But now we have to load it all back in when opening a playlist, so we add the following code to Open() right before the FileChanged = False line:
Try
Dim hiddenPath As String = GetHiddenFileName()
Dim xmlLinks As XElement = XElement.Load(hiddenPath)
Dim linkSets = From links In xmlLinks...<mlink> _
Select links
That opens the hidden file and gets the all of the <mlink> elemets. Next, for each of the <mlink> elements, we’ll retrieve the media elements and see if they exist in the listbox that we just populated earlier in Open(). If they do, we can throw them into a list to link up again as if the user had selected them. If we can’t find a particular song, we just skip it and work with whatever we’ve got.
For Each linkSet In linkSets
Dim linkedFiles = From media In linkSet.<media> _
Select media
Dim listOfElements As New List(Of Integer)
For Each m In linkedFiles
Dim entry As Integer = ShuffleListBox.FindStringExact(xmlMediaEntry.CreateMediaString(m))
If entry <> -1 Then
listOfElements.Add(entry)
End If
Next
Link(listOfElements)
Next
(Link() is a helper function which I’ll describe in a second.) Note that we are using the publically shared method of getting a file’s identifier from its title and artist. That should hopefully be enough to identify the song uniquely – if not, we can change CreateMediaString to return some other value, bearing in mind that whatever we choose will show up in the listbox. (Or, we can slowly step through the listbox and look for an exact match on all of a song’s attributes, but that’s not much fun…)
If we can’t open the hidden file, it might not exist, so we’ll just ignore that condition:
Catch ex As Exception
End Try
The final thing we need to do is define the Link() helper function. It’s very similar to the code we wrote to link selected items, except of course the items are not selected, and forcing a temporary multi-selection just for the purposes of linking would be an ugly hack. The biggest difference is that the items are drawn from a list of indices that we created in Open, we don’t have a SelectedItems list to help us, and we don’t need to set any file state – that’s done by the “Open” command. Otherwise, it’s very similar, and if we worked hard enough at it, we could probably combine the two functions (I’m feeling lazy):
Private Sub Link(ByVal linkList As List(Of Integer))
Dim numToLink As Integer = linkList.Count
For index As Integer = 0 To numToLink - 1
Dim elem As xmlMediaEntry = ShuffleListBox.Items(linkList(index))
elem.NextLinkedElement = If(index <> numToLink - 1, _
CType(ShuffleListBox.Items(linkList(index + 1)), xmlMediaEntry), _
Nothing)
elem.PrevLinkedElement = If(index <> 0, _
CType(ShuffleListBox.Items(linkList(index - 1)), xmlMediaEntry), _
Nothing)
If index <> 0 Then
ShuffleListBox.Items.RemoveAt(linkList(index))
ShuffleListBox.Items.Insert(linkList(index - 1) + 1, elem)
End If
Next
ShuffleListBox.Invalidate()
ShuffleListBox.Update()
End Sub
And that’s it! As usual, the full code can be found on my Temple of VB site.
‘Til next time,
--Matt--*