Creating the ZuneCard Control for Windows Phone
Join the DZone community and get the full member experience.
Join For FreeZune has a pretty cool sub-service called Zune Card - it displays a user's most played tracks, as well as the total number of plays. If you've ever used Xbox Live, it carries the same concept as the Xbox Gamercard, the only difference being that it is applied to musical content rather than games. As people like to showcase their Xbox Achievements, Zune users might want to showcase their musical preferences. That's why I decided to create the ZuneCard control, that can be easily embedded in a Windows Phone application to represent the exact copy of a Zune Card in the Zune desktop client.
I am still working in the context of my Windows Phone Control Kit, so I made the ZuneCard control a part of it. First things first, let's look at the XAML layout that I created in the generic.xaml file.
<!--ZuneCard--> <Style TargetType="local:ZuneCard"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="local:ZuneCard"> <Border Width="420" Height="260" BorderBrush="Black" BorderThickness="2"> <Grid Background="White"> <Grid.RowDefinitions> <RowDefinition Height="84" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> <Grid Grid.Row="0"> <Grid.Resources> <cvs:BadgeCountToVisibility x:Key="BadgeCountToVisibility"></cvs:BadgeCountToVisibility> </Grid.Resources> <Image HorizontalAlignment="Left" Margin="10" Source="{TemplateBinding AvatarImageSource}" Height="64" Width="64"></Image> <StackPanel Margin="84,10,10,10"> <TextBlock Foreground="Black" FontWeight="Bold" Text="{TemplateBinding UserID}"></TextBlock> <StackPanel Orientation="Horizontal"> <TextBlock Foreground="Black" Text="{TemplateBinding PlayCount}"></TextBlock> <TextBlock Foreground="Black" Text=" plays"></TextBlock> </StackPanel> </StackPanel> <Grid HorizontalAlignment="Right" Height="60" Width="60" Margin="10" VerticalAlignment="Center" DataContext="{TemplateBinding BadgeCount}" Visibility="{Binding Converter={StaticResource BadgeCountToVisibility}}"> <Image Source="Graphics/badge.png" Height="60" Width="60" Stretch="UniformToFill"></Image> <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="White" Text="{TemplateBinding BadgeCount}"></TextBlock> </Grid> </Grid> <Grid Grid.Row="1" Margin="10,0,10,10"> <Grid.ColumnDefinitions> <ColumnDefinition Width="10"></ColumnDefinition> <ColumnDefinition Width="*"></ColumnDefinition> </Grid.ColumnDefinitions> <Grid.Background> <ImageBrush ImageSource="Graphics/default_bg.png"></ImageBrush> </Grid.Background> <Image Stretch="UniformToFill" Source="{TemplateBinding BackgroundImageSource}" Grid.ColumnSpan="2"></Image> <Image Height="86" Stretch="UniformToFill" Width="10" Grid.Column="0" Source="Graphics/side.png" VerticalAlignment="Center"></Image> <ListBox ScrollViewer.HorizontalScrollBarVisibility="Disabled" ScrollViewer.VerticalScrollBarVisibility="Disabled" Margin="10,0,0,0" Grid.Column="1" Height="86" ItemsSource="{TemplateBinding RecentTracks}"> <ListBox.ItemsPanel> <ItemsPanelTemplate> <StackPanel Orientation="Horizontal"/> </ItemsPanelTemplate> </ListBox.ItemsPanel> <ListBox.ItemTemplate> <DataTemplate> <Image Margin="0,0,5,0" Source="{Binding}" Height="86" Width="86"></Image> </DataTemplate> </ListBox.ItemTemplate> </ListBox> </Grid> </Grid> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style>
It might seem quite complex, but once dissected, you will notice that it is, in fact, very easy to understand and modify.
The main container is surrounded by a border - this decision was made because the card itself is white (just like you see in the screenshot above), therefore to highlight its limits, the border does the job pretty well. It looks good without one with the dark theme enabled, but with a light one it might happen that the card blends in with the background.
The container grid is split in two parts. The top row is presenting the general user information, such as the Zune tag, the number of play counts, the avatar and the number of badges earned. This data is directly obtained from properties that are bound to TextBlock and Image controls. You might also notice that I have a resource declared - this is the converter that determines the visibility of the badget graphic. If the user has no badges - there is no need to display it.
It is as simple as this:
using System; using System.Windows; using System.Windows.Data; using System.Diagnostics; namespace ControlKit.Converters { public class BadgeCountToVisibility : IValueConverter { public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { string countStr = value.ToString(); if (countStr == "0") return Visibility.Collapsed; else return Visibility.Visible; } public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { return string.Empty; } } }
If the badge string is zero, this means that there are no badges, and therefore the badge image is set to Collapsed.
I have a couple of image overlays in the second row. By default, the user background is fetched directly from the server. There are situations, however, when the background image that is returned is in GIF format. As you probably know, Windows Phone doesn't really like working with GIFs, so I get an empty BitmapImage instance and that leaves me with empty white space as the background. To avoid this, there is a placeholder background image that is shown only when no valid background is used. In all other situations the top image covers the generic one.
Last but not least, I have a ListBox with a custom data template to display recent tracks. It will only contain four of them at a time, so I disabled both horizontal and vertical scrolling.
The code-behind is a little bit more interesting. I have declared seven Zune API endpoints that are used inside the control:
private const string SourceURL = "http://socialapi.zune.net/en-US/members/{0}"; private const string AvatarURL = "http://cache-tiles.zune.net/tiles/user/{0}"; private const string BackgroundURL = "http://cache-tiles.zune.net/tiles/background/{0}"; private const string BadgeURL = "http://socialapi.zune.net/en-US/members/{0}/badges"; private const string RecentTracksURL = "http://socialapi.zune.net/en-US/members/{0}/playlists/BuiltIn-RecentTracks"; private const string AlbumURL = "http://catalog.zune.net/v3.2/en-US/music/album/{0}/"; private const string PictureURL = "http://image.catalog.zune.net/v3.2/en-US/image/{0}?width=64&height=64";
All these are already documented as a part of my Zune Data Viewer project.
The control starts downloading information once it is loaded - using the Loaded event handler, obiously.
public ZuneCard() { DefaultStyleKey = typeof(ZuneCard); this.Loaded += new RoutedEventHandler(ZuneCard_Loaded); RecentTracks = new ObservableCollection<ImageSource>(); } void ZuneCard_Loaded(object sender, RoutedEventArgs e) { if (!string.IsNullOrWhiteSpace(UserID)) { DownloadData(DownloadType.BasicUserInfo); BitmapImage bSource = new BitmapImage(new Uri(string.Format(AvatarURL, UserID))); AvatarImageSource = bSource ?? new BitmapImage(new Uri("Graphics/64x64_tile.jpg", UriKind.Relative)); bSource = new BitmapImage(new Uri(string.Format(BackgroundURL, UserID))); BackgroundImageSource = bSource ?? new BitmapImage(new Uri("Graphics/default_bg.jpg", UriKind.Relative)); } }
UserID is the key property, that sets the current user, for whom the data should be downloaded. I am initiating the process only if that property is not empty or null. Notice the verification I am subjecting the avatar and background images too. Remember the GIF scenario? There are also cases when the result is null, so I have to make sure that there is at lease a placeholder image.
DownloadData is a single method that handles data acquisition. Depending on the passed download type, it accesses a different URL. Here are the possible download types, shown under a single enum:
public enum DownloadType { BasicUserInfo, Badges, RecentTracks, AlbumImage }
Here is what the actual DownloadData method looks like:
private void DownloadData(DownloadType downloadType, string parameter = "") { var client = new WebClient(); XDocument document; if (downloadType == DownloadType.BasicUserInfo) { client.DownloadStringAsync(new Uri(string.Format(SourceURL, UserID))); client.DownloadStringCompleted += (s, e) => { try { document = XDocument.Parse(e.Result); PlayCount = document.Root.Element("{http://schemas.zune.net/profiles/2008/01}playcount").Value.ToString(); DownloadData(DownloadType.Badges); } catch { PlayCount = "0"; } }; } else if (downloadType == DownloadType.Badges) { client.DownloadStringAsync(new Uri(string.Format(BadgeURL, UserID))); client.DownloadStringCompleted += (s, e) => { try { document = XDocument.Parse(e.Result); BadgeCount = (from c in document.Root.Elements() where c.Name == "{http://www.w3.org/2005/Atom}entry" select c).Count().ToString(); DownloadData(DownloadType.RecentTracks); } catch { BadgeCount = "0"; } }; } else if (downloadType == DownloadType.RecentTracks) { client.DownloadStringAsync(new Uri(string.Format(RecentTracksURL, UserID))); client.DownloadStringCompleted += (s, e) => { try { document = XDocument.Parse(e.Result); var selection = (from c in document.Root.Elements() where c.Name == "{http://www.w3.org/2005/Atom}entry" select foreach (XElement element in selection) { string albumID = element.Element("{http://schemas.zune.net/catalog/music/2007/10}track") .Element("{http://schemas.zune.net/catalog/music/2007/10}album") .Element("{http://schemas.zune.net/catalog/music/2007/10}id").Value.Replace("urn:uuid:",""); DownloadData(DownloadType.AlbumImage, albumID); } } catch { } }; } else if (downloadType == DownloadType.AlbumImage) { client.DownloadStringAsync(new Uri(string.Format(AlbumURL, parameter))); client.DownloadStringCompleted += (s, e) => { try { document = XDocument.Parse(e.Result); string imageID = document.Root.Element("{http://schemas.zune.net/catalog/music/2007/10}image") .Value.Replace("urn:uuid:", ""); RecentTracks.Add(new BitmapImage(new Uri(string.Format(PictureURL, imageID)))); } catch { } }; } }
Every returned feed is in XML format - as you can see, I am using LINQ-to-XML to read it. Instead of working on the serialization layer, I decided to simply use node-specific requests because I am working with small amounts of data from every feed. Notice that DownloadData also has a string parameter = "". It is used when I need to retrieve the album-specific feed based on its ID. Look at how the download is handled for RecentTracks and AlbumImage.
When I get the list of tracks, I also get the album ID
string albumID = element.Element("{http://schemas.zune.net/catalog/music/2007/10}track") .Element("{http://schemas.zune.net/catalog/music/2007/10}album") .Element("{http://schemas.zune.net/catalog/music/2007/10}id").Value.Replace("urn:uuid:","");
I now need to pass this ID to the next cascading call - for the album image. Here is where the parameter becomes useful, and I am able to do this:
DownloadData(DownloadType.AlbumImage, albumID);
When downloading the album metadata, I can simply pass the ID to the regualr URL formatter:
client.DownloadStringAsync(new Uri(string.Format(AlbumURL, parameter)));
So where is all this data stored? In dependency properties, of course:
public static readonly DependencyProperty RecentTracksProperty = DependencyProperty.Register("RecentTracks", typeof(ObservableCollection<ImageSource>), typeof(ZuneCard), new PropertyMetadata(n public ObservableCollection<ImageSource> RecentTracks { get { return GetValue(RecentTracksProperty) as ObservableCollection<ImageSource>; } set { SetValue(RecentTracksProperty, value); } } public static readonly DependencyProperty UserIDProperty = DependencyProperty.Register("UserID", typeof(string), typeof(ZuneCard), new PropertyMetadata(string.Empty)); public string UserID { get { return GetValue(UserIDProperty) as string; } set { SetValue(UserIDProperty, value); } } public static readonly DependencyProperty BadgeCountProperty = DependencyProperty.Register("BadgeCount", typeof(string), typeof(ZuneCard), new PropertyMetadata("0")); public string BadgeCount { get { return (string)GetValue(BadgeCountProperty); } set { SetValue(BadgeCountProperty, value); } } public static readonly DependencyProperty PlayCountProperty = DependencyProperty.Register("PlayCount", typeof(string), typeof(ZuneCard), new PropertyMetadata("0")); public string PlayCount { get { return (string)GetValue(PlayCountProperty); } set { SetValue(PlayCountProperty, value); } } public static readonly DependencyProperty AvatarImageSourceProperty = DependencyProperty.Register("AvatarImageSource", typeof(ImageSource), typeof(ZuneCard), new PropertyMetadata(null)); public ImageSource AvatarImageSource { get { return (ImageSource)GetValue(AvatarImageSourceProperty); } set { SetValue(AvatarImageSourceProperty, value); } } public static readonly DependencyProperty BackgroundImageSourceProperty = DependencyProperty.Register("BackgroundImageSource", typeof(ImageSource), typeof(ZuneCard), new PropertyMetadata(null)); public ImageSource BackgroundImageSource { get { return (ImageSource)GetValue(BackgroundImageSourceProperty); } set { SetValue(BackgroundImageSourceProperty, value); } }
Pretty much everything that is going on behind the scenes relies on UserID, starting with the first web call. The rest are potential metadata holders. Potential, because in some scenarios the user will not have that specific chunk of information - for example, the number of badges or the list of recent tracks (remember that there are privacy settings enforced that might be blocking access).
To experiment with the control, simply add the reference to the library, add the XML namespace reference declaration and add it to your page like this:
<ckit:ZuneCard UserID="ZeBond"></ckit:ZuneCard>
You should get this at the end:
This is my first attempt at this control. It can be improved in the future, and here is what I plan on adding:
- Ability to view badges if the user taps on the badge sign.
- Open the Marketplace once the user taps on an album image
- Show more information related to the artists the user listens to
- Add an explicit Refresh() method that will allow control manipulation from the code-behind
- The possibility to choose between the dark and light styles.
- Show more than 4 recent tracks
- Remove tracks that belong to the same album from the RecentTracks collection.
As always, you can download all this awesomeness on GitHub.
Opinions expressed by DZone contributors are their own.
Comments