Table of Contents
Introduction
After playing with my first Smartphone for a couple of months so far, I finally realised how much potential there is in it regarding reading content from the web in a fast and
easy way. Today, many websites provide mobile-friendly features (for example, you can read The Code Project articles with the "mobile" switch on). As a result, you end up with a cleaner,
faster version of the article, much more appropriate for the Smartphone form factor, as an alternative to that chunky, confusing full web page that was designed for 19" PC screens.
But even with that option for mobile version, I'm not always that satisfied with the results, depending on the kind of content I'm reading. I'll give an example: I love watching TV shows
and as a non-native English speaker I often find myself lost in many of the jokes. So I like reading the TV show transcripts, specially the
transcripts of this particular blog. But if while watching the TV show I lose the jokes, reading the TV transcripts in plain
text should be more fun. Why not format the dialogues as a dialogue thread, with pictures of characters and speech balloons? This idea gave birth to a free app I published
on the Windows Phone Marketplace and, finally, this article on The Code Project.
System Requirements
To use The Big Bang Transcripts provided with this article, you must have installed the Windows Phone SDK 7.1 that you can download 100% free directly from Microsoft:
Disclaimer
For those who may have some concerns regarding the copyright issues: what I'm doing here is simply reading and formatting
the contents already provided by this particular blog. As happens with any web browser app, this app is just a reader, and
no text content is provided with the source project uploaded with this article.
The WebClient Component: Downloading Content From the Web
The WebClient
Silverlight component stands as the bare bones of the web content consuming technique in the Windows Phone platform. In our project, we are using the
DownloadStringAsync
function to retrieve the raw content from the blog page/pages.
The function name speaks for itself: DownloadStringAsync
downloads string content in an async manner:
WebClient.DownloadStringAsync Method (Uri) |
Downloads the resource at the specified URI as a string.
- Namespace:
System.Net
- Assembly: System.Net (in System.Net.dll)
|
Syntax |
public void DownloadStringAsync(
Uri address
) |
Parameters
address : Type: System.Uri . The location of the resource to be downloaded.
|
Also, it is worth mentioning that the DownloadStringAsync
method doesn't work in a traditional sequence of instructions. That is, the instruction
following DownloadStringAsync
will be executed immediately after the web request is submitted: It will not wait for the content to be downloaded,
so don't expect the processing to occur in the same order of the code instructions.
The right way to work with DownloadStringAsync
functions is the same you should apply to any other asynchronous request: you must subscribe to the events provided by
the WebClient
class and so you have control over the order the things may occur. The DownloadStringCompleted
event subscription is particularly important,
since it gives you the opportunity to tell the program what to do immediately after the content has been completely downloaded. Another useful event is DownloadProgressChanged
,
which is often used to give users a visual feedback regarding the progress of the download.
private void DownloadString(string url,
Action<string> onDownloadCompleted,
Action onConnectionFailed,
Action<int> onProgressChanged)
{
try
{
var webClient = new WebClient();
webClient.DownloadStringCompleted += (sender, e) =>
{
try
{
onDownloadCompleted(e.Result);
}
catch (Exception exc)
{
MessageBox.Show(@"We're sorry, but an unexpected
connection error occurred. Please try again later.",
"Oops!", MessageBoxButton.OK);
onConnectionFailed();
}
};
webClient.DownloadProgressChanged += (sender, e) =>
{
onProgressChanged(e.ProgressPercentage);
};
webClient.AllowReadStreamBuffering = true;
webClient.DownloadStringAsync(new Uri(url), webClient);
}
catch (Exception exc)
{
MessageBox.Show(@"We're sorry, but an unexpected
connection error occurred. Please try again later.",
"Oops!", MessageBoxButton.OK);
onConnectionFailed();
}
}
Downloading the Episode List
The first relevant action in our application is to download the episodes list. You may think that right after that we will start downloading all the episodes, but that's not
correct: once the episode list is downloaded, the program will wait for the user to open each particular episode to start downloading its transcript. This is so because many users (including me)
may be under carrier contract restrictions, and downloading all episodes at one time would unnecessarily increase the carrier usage. I'm sure many (including myself) will prefer to download
the episodes while under a WiFi network.
The episode list is embedded in the main blog page's HTML, in such a way that we must extract the relevant data from it by using Regular Expressions. Remember that we work with asynchronous
download methods, so once the episode list download is complete, we execute the onContentReady
callback. Likewise, we have two more callbacks, one for reporting the download
progress and one for the failed connection event.
public void DownloadEpisodeList(Action<List<Episode>> onContentReady,
Action onConnectionFailed, Action<int> onProgressChanged)
{
if (!InternetIsAvailable())
onConnectionFailed();
DownloadString("http://bigbangtrans.wordpress.com/",
(content) =>
{
var episodes = new List<Episode>();
var linkRegex = new Regex("<a[^>]+
href\\s*=\\s*[\"\\'](?!(?:#|javascript\\s*:))([^\"\\']+)
[^>]*>(.*?)<\\/a>");
MatchCollection matches = linkRegex.Matches(content);
var episodeId = 1;
for (var i = 0; i < matches.Count; i++)
{
var match = matches[i];
var linkValue = match.Groups[1].Value;
var nameValue = match.Groups[2].Value.Replace(" ", " ");
var nameRegex = new Regex("^Series ([0-9]) Episode ([0-9][0-9]) – (.*)");
if (nameRegex.IsMatch(nameValue))
{
var season = int.Parse(nameRegex.Matches(nameValue)[0].Groups[1].Value);
var number = int.Parse(nameRegex.Matches(nameValue)[0].Groups[2].Value);
var name = nameRegex.Matches(nameValue)[0].Groups[3].Value;
if (nameRegex.IsMatch(nameValue))
{
episodes.Add(new Episode()
{
Id = episodeId,
Number = number,
Description = "",
Link = linkValue,
Name = name,
Season = season,
CreatedOn = DateTime.Now
});
}
}
episodeId++;
}
onContentReady(episodes);
},
() =>
{
onConnectionFailed();
},
(percentage) => {
onProgressChanged(percentage);
});
}
Saving the Episode List Locally
The application relies on the good old technique of serializing/deserializing objects for persisting/retrieving the XML data related to the episode list, episodes, and so on. There are indeed
other interesting approaches that could do the job pretty well (such as a local SQL database) but given the simplicity of this application's requirements,
the XML paradigm worked well so far, and I don't have plans to change it, at least for a short time.
If you are a seasoned Silverlight developer, needless to say that we are using here the Isolated Storage. If you are not, then Isolated Storage is a closed storage system,
or sandboxed system, in which the application is granted access only to the files/folders it has created.
Another relevant aspect of our approach is that we don't generate XML from XML documents. Instead, we instantiate POCOs (Plain Old C# Objects) defined in our model and then serialize them into
XML files. I found this approach more flexible and less cumbersome than dealing with XML documents directly. Also, it enables us to do a further refactoring and class decoupling, which is always
a good design practice.
Whenever the application is started, it tries to download a new, refreshed version of the episode list, and saves it locally in case of success. But if the download fails,
we can go offline and work with the last downloaded episode list. It would be nice if we only downloaded the episode list once and for all, but the blog author is still updating the
episode list (and hopefully will keep updating it for many years to come!) so we must keep checking for updates.
public static void SaveEpisodeList(List<Episode> episodes)
{
StreamResourceInfo streamResourceInfo =
Application.GetResourceStream(new Uri(EPISODELIST_FILE_PATH, UriKind.Relative));
using (IsolatedStorageFile isolatedStorage =
IsolatedStorageFile.GetUserStoreForApplication())
{
string directoryName = System.IO.Path.GetDirectoryName(EPISODELIST_FILE_PATH);
if (!string.IsNullOrEmpty(directoryName) &&
!isolatedStorage.DirectoryExists(directoryName))
{
isolatedStorage.CreateDirectory(directoryName);
}
isolatedStorage.DeleteFile(EPISODELIST_FILE_PATH);
Serialize(isolatedStorage, EPISODELIST_FILE_PATH, episodes, typeof(List<Episode>));
}
}
Downloading an Episode Transcript
Each episode has its own page inside the blog. Remember that the URLs for the episode pages are discovered and stored locally during the last step,
while the episode list was being retrieved.
Similarly to what happens with the episode lists, the episode transcripts are found mixed with the rest of the page's HTML, and once again it's up to us to separate
the wheat from the chaff. We apply some more Regular Expressions in order to extract the relevant data and then populate the EpisodeTranscript
instance with the episode data.
public void DownloadEpisodeTranscript(Episode episode,
Action<EpisodeTranscript> onContentReady,
Action onConnectionFailed, Action<int> onProgressChanged)
{
if (!InternetIsAvailable())
onConnectionFailed();
DownloadString(episode.Link,
(content) =>
{
var episodeTranscript = new EpisodeTranscript()
{
Number = episode.Number,
Description = episode.Description,
Link = episode.Link,
Name = episode.Name,
Season = episode.Season,
};
episodeTranscript.Quotes = new List<Quote>();
var linkRegex = new Regex("(<p>|<span
style=\"font-size:small;font-family:Calibri;\">|<span
style=\"font-family:Calibri;\">)(.*?)(<\\/p>|<\\/span>)");
MatchCollection matches = linkRegex.Matches(content);
for (var i = 0; i < matches.Count; i++)
{
var match = matches[i];
var quoteValue = match.Groups[2].Value;
quoteValue = quoteValue.Replace("<span>", "");
quoteValue = quoteValue.Replace("</span>", "");
quoteValue = quoteValue.Replace("<em>", "");
quoteValue = quoteValue.Replace("</em>", "");
quoteValue = quoteValue.Replace("…", "...");
quoteValue = quoteValue.Replace("’", "'");
quoteValue = quoteValue.Replace("&", "&");
var quoteRegex = new Regex("(.*):(.*)");
MatchCollection matches2 = quoteRegex.Matches(quoteValue);
var character = "";
var speech = "";
if (matches2.Count == 0)
{
speech = quoteValue;
}
else
{
var quoteMatch = matches2[0];
if (quoteMatch.Groups[1].Value.Contains("<img") ||
quoteMatch.Groups[1].Value.Contains("<a href"))
break;
character = (quoteMatch.Groups[1].Value + "(").Split('(')[0].Trim();
speech = quoteMatch.Groups[2].Value;
}
episodeTranscript.Quotes.Add(new Quote()
{
Id = episodeTranscript.Quotes.Count() + 1,
Season = episode.Season,
Number = episode.Number,
Image = string.Format(@"/Images/{0}.png", character),
Character = character,
Speech = speech,
CreatedOn = DateTime.Now
});
}
onContentReady(episodeTranscript);
},
() =>
{
onConnectionFailed();
},
(percentage) =>
{
onProgressChanged(percentage);
});
}
Saving the Episode Transcript Locally
We save the episode transcript much like we do with episode lists. The difference is that we persist an EpisodeTranscript
instance. Another difference is that
once an episode transcript is downloaded and persisted locally, it doesn't need to be downloaded again. This is why each episode has its own separated XML file.
public static void SaveEpisodeTranscript(EpisodeTranscript episodeTranscript)
{
var episodeTranscriptFilePath = string.Format(EPISODETRANSCRIPT_FILE_PATH,
episodeTranscript.Season, episodeTranscript.Number);
StreamResourceInfo streamResourceInfo =
Application.GetResourceStream(new Uri(episodeTranscriptFilePath, UriKind.Relative));
using (IsolatedStorageFile isolatedStorage = IsolatedStorageFile.GetUserStoreForApplication())
{
string directoryName = System.IO.Path.GetDirectoryName(episodeTranscriptFilePath);
if (!string.IsNullOrEmpty(directoryName) &&
!isolatedStorage.DirectoryExists(directoryName))
{
isolatedStorage.CreateDirectory(directoryName);
}
isolatedStorage.DeleteFile(episodeTranscriptFilePath);
Serialize(isolatedStorage, episodeTranscriptFilePath,
episodeTranscript, typeof(EpisodeTranscript));
}
}
Searching the Episodes
This TV show is very popular in Brazil, and me and my friends love to discuss the situations, jokes, dialogues of it. Sometimes it is difficult to remember the season, the episode, and
and who said what. Then it appeared obvious to me, that it would be nice for the app to have a search feature. It's obviously no big deal, just searching each episode for a particular text
is relatively easy compared to what we have been doing so far. But indeed, it's a nice and fun feature.
Since the episodes are individually persisted in Isolated Storage, and since their contents are not indexed in any way, we don't have an easy way other than retrieving episode-by-episode
and searching its contents. So, the more episodes we download, the longer it takes for the entire searching operation to complete. This is why we create a search function that
reports its progress and remains responsive during the search process. If you're looking for a particular text and it's found in the third episode of the first season, it will quickly
appear on the search result list, but the searching operation will keep going. Nevertheless, you'll be able to click that already found result and navigate to the respective episode
before the search function finishes its job.
public static void SearchQuotes(string searchText,
Action<int> onProgressChanged,
Action<Quote> onNewQuotesFound,
Action onSearchCompleted)
{
searchText = searchText.ToLower();
List<Quote> quotes = new List<Quote>();
var episodes = EpisodeRepository.RetrieveEpisodeList();
var episodeCount = episodes.Count();
var currentEpisodePosition = 0;
foreach (var episode in episodes)
{
currentEpisodePosition++;
onProgressChanged((100 * currentEpisodePosition) / episodeCount);
if (EpisodeRepository.EpisodeTranscriptExists(episode.Season,
episode.Number))
{
var episodeTranscript = EpisodeRepository
.RetrieveEpisodeTranscript(episode.Season, episode.Number);
var matches = episodeTranscript.Quotes.Where(x =>
x.Character.ToLower().Contains(searchText.ToLower()) ||
x.Speech.ToLower().Contains(searchText.ToLower()));
foreach (var match in matches)
{
onNewQuotesFound(match);
}
}
}
onProgressChanged(100);
onSearchCompleted();
}
Publishing to Facebook and Twitter
Reading the transcripts of your favorite TV show in your Windows Phone may be cool, but unless your family, friends, and workmates download the app, you'll have to share the bits with them
by conversation. Fortunately, there is way to share it more effectively via social networks such as Facebook and Twitter, and we are going to make use of the native sharing tool
that is provided with Windows Phone out of the box.
In order to share a specific quote, you must select and double-tap the speech balloon or group of balloons, and then you are redirected to the sharing page. Keep in mind that there may
be restrictions to the maximum number of characters due to the nature of the social network to which you are posting.
Once you share, the selected quote will be posted along with a link to the app in the Windows Phone Marketplace.
private void EpisodeTranscriptListBox_DoubleTap(object sender,
System.Windows.Input.GestureEventArgs e)
{
var selectedItems = EpisodeTranscriptListBox.SelectedItems;
if (selectedItems != null)
{
ShareLinkTask shareLinkTask = new ShareLinkTask();
shareLinkTask.LinkUri =
new Uri("http://windowsphone.com/s?appid=e28e5cab-a604-4913-af5c-5694923433b6",
UriKind.Absolute);
shareLinkTask.Title = "The Big Bang Transcripts Viewer";
StringBuilder sb = new StringBuilder();
foreach (var selectedItem in selectedItems)
{
var quote = (Quote)selectedItem;
sb.AppendFormat("{0}: {1}\r\n", quote.Character, quote.Speech.Trim());
}
shareLinkTask.Message = string.Format("{0}\r\n({1})", sb.ToString(), txtEpisodeName.Text);
shareLinkTask.Show();
}
}
Showing the Dialogues
The dialogues are the most important part of the application. They're the very reason for the app's existence. With that in mind, I tried to show them in a clean and
fun way. The idea is that each quote has a separate colorized speech balloon, along with the photo of the character. Also, I thought it would be nice to have
the dialogues in an alternate fashion, that is, alternating the speech direction from left to right, and then to left again, so that it would be similar to the experience
in SMS and other mobile messaging applications.
That being said, the display of the dialogues is something that deserves to be discussed in some detail. First of all, we have the native and very powerful ListBox
Silverlight control, which allows us to create templates for the items we want to list.
The template we have for the Listbox
control has two basic behaviors: it can either show the series "comments" (like episode intro, credits sequence section, and so on)
or the dialogues themselves. The comments are shown in a banner, while the dialogues are balloons accompanied by the character's photo. This differentiation makes the reading
more fun and less confusing.
Image |
|
Description |
The IsComment property is converted to Visibility , so that if the line is a comment line, then the comment template will become visible.
The comments content will be showed in a stylized, flag-like rectangle, in italics. |
XAML |
<Grid Visibility="{Binding IsComment, Converter={StaticResource
booleanToVisibilityConverter}}" Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40"/>
<ColumnDefinition Width="360"/>
<ColumnDefinition Width="40"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="2"/>
<RowDefinition/>
<RowDefinition Height="10"/>
</Grid.RowDefinitions>
<Border Grid.Row="1" Grid.RowSpan="2" Grid.Column="0"
Background="{Binding Id, Converter={StaticResource
quoteIdToBaloonBrushConverter}}"
CornerRadius="0,0,5,0" Margin="0,0,1,0"/>
<Border Grid.Row="1" Grid.RowSpan="2" Grid.Column="2"
Background="{Binding Id, Converter={StaticResource
quoteIdToBaloonBrushConverter}}"
CornerRadius="0,0,0,5" Margin="1,0,0,0"/>
<Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
Grid.ColumnSpan="3" BorderBrush="{StaticResource
PhoneBackgroundBrush}"
BorderThickness="1" Background="{Binding Id,
Converter={StaticResource
quoteIdToBaloonBrushConverter}}"
CornerRadius="10,10,0,0" Margin="10">
<TextBlock Text="{Binding Speech}" FontSize="30"
FontStyle="Italic" TextWrapping="Wrap" Margin="4"
Foreground="{Binding Id, Converter={StaticResource
quoteIdToFontBrushConverter}}" ></TextBlock>
</Border>
<Path Width="40" Height="10" Grid.Column="0"
Grid.ColumnSpan="3" Grid.Row="0"
Grid.RowSpan="3" Fill="{StaticResource PhoneBackgroundBrush}"
Data="M0,0 L1,0 L1,1 L0,0" Stretch="Fill"
HorizontalAlignment="Left" VerticalAlignment="Bottom"
Margin="10,0,0,0"/>
<Path Width="40" Height="10" Grid.Column="0"
Grid.ColumnSpan="3" Grid.Row="0"
Grid.RowSpan="3" Fill="{StaticResource PhoneBackgroundBrush}"
Data="M1,0 L0,0 L0,1 L1,0" Stretch="Fill"
HorizontalAlignment="Right" VerticalAlignment="Bottom"
Margin="0,0,10,0"/>
</Grid> |
Image |
|
Description |
The IsSpeech property is converted to Visibility , so that if the line is a speech line, then the speech template will become visible.
The speech content will be shown in a speech balloon, next to the character's picture.
The quoteIdToPictureColumnConverter and quoteIdToBalloonColumnConverter converters take the quote's ID property and based on the parity
value (that is, whether the ID is even or odd) they return the correct grid column value for the balloon speech rectangle and the character picture.
Long story short, when the speech line is even, the balloon appears on the left side, and the character's picture on the right side. |
XAML |
<Grid HorizontalAlignment="Stretch"
Visibility="{Binding IsSpeech, Converter={StaticResource
booleanToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="100"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="{Binding Id,
Converter={StaticResource quoteIdToPictureColumnConverter}}"
VerticalAlignment="Center">
<Image Source="{Binding Image}" Height="100"
Width="100"></Image>
<TextBlock Text="{Binding Character}"/>
</StackPanel>
<Grid Grid.Column="{Binding Id, Converter={StaticResource quoteIdToBalloonColumnConverter}}"
Grid.ColumnSpan="2" Margin="5,15,5,15">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="20"/>
<ColumnDefinition/>
<ColumnDefinition Width="20"/>
</Grid.ColumnDefinitions>
<Path Grid.Column="0" Visibility="{Binding Id,
Converter={StaticResource evenQuoteIdToVisibilityConverter}}"
Fill="{Binding Id, Converter={StaticResource
quoteIdToBaloonBrushConverter}}"
Data="M0,1 L1,0 L1,2 L0,1" Height="20"
Width="20" Stretch="Fill"/>
<Rectangle Grid.Column="1" Fill="{Binding Id,
Converter={StaticResource quoteIdToBaloonBrushConverter}}"
RadiusX="4" RadiusY="4"/>
<TextBlock Grid.Column="1" Text="{Binding Speech}"
FontSize="26"
TextWrapping="Wrap" Margin="4"
Foreground="{Binding Id,
Converter={StaticResource
quoteIdToFontBrushConverter}}"></TextBlock>
<Path Grid.Column="2" Visibility="{Binding Id,
Converter={StaticResource oddQuoteIdToVisibilityConverter}}"
Fill="{Binding Id, Converter={StaticResource
quoteIdToBaloonBrushConverter}}"
Data="M1,1 L0,0 L0,2 L1,1" Height="20" Width="20"
Stretch="Fill"/>
</Grid>
</Grid> |
Image |
|
Description |
In this case, the quoteIdToPictureColumnConverter and quoteIdToBalloonColumnConverter converters take the quote's ID property and based on the parity
value (that is, whether the ID is even or odd) they return the correct grid column value for the balloon speech rectangle and the character picture.
Based on this sample image, the balloon appears on the right side, and the character's picture on the left side. |
XAML |
<Grid HorizontalAlignment="Stretch"
Visibility="{Binding IsSpeech, Converter={StaticResource
booleanToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="100"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="{Binding Id,
Converter={StaticResource quoteIdToPictureColumnConverter}}"
VerticalAlignment="Center">
<Image Source="{Binding Image}" Height="100"
Width="100"></Image>
<TextBlock Text="{Binding Character}"/>
</StackPanel>
<Grid Grid.Column="{Binding Id, Converter={StaticResource
quoteIdToBalloonColumnConverter}}"
Grid.ColumnSpan="2" Margin="5,15,5,15">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="20"/>
<ColumnDefinition/>
<ColumnDefinition Width="20"/>
</Grid.ColumnDefinitions>
<Path Grid.Column="0" Visibility="{Binding Id,
Converter={StaticResource
evenQuoteIdToVisibilityConverter}}"
Fill="{Binding Id, Converter={StaticResource
quoteIdToBaloonBrushConverter}}"
Data="M0,1 L1,0 L1,2 L0,1" Height="20"
Width="20" Stretch="Fill"/>
<Rectangle Grid.Column="1" Fill="{Binding Id,
Converter={StaticResource quoteIdToBaloonBrushConverter}}"
RadiusX="4" RadiusY="4"/>
<TextBlock Grid.Column="1" Text="{Binding Speech}"
FontSize="26"
TextWrapping="Wrap" Margin="4" Foreground="{Binding Id,
Converter={StaticResource
quoteIdToFontBrushConverter}}"></TextBlock>
<Path Grid.Column="2" Visibility="{Binding Id,
Converter={StaticResource oddQuoteIdToVisibilityConverter}}"
Fill="{Binding Id, Converter={StaticResource
quoteIdToBaloonBrushConverter}}"
Data="M1,1 L0,0 L0,2 L1,1" Height="20" Width="20" Stretch="Fill"/>
</Grid>
</Grid> |
Final Considerations
Thank you so much for your patience. I hope you have enjoyed the article and also hope you liked the concepts I presented here. If you would like to share complaints,
questions, ideas, or suggestions, please feel free to fill in a post in the comments section below.
History
- 2011-12-29: Initial version.