Creating A Downloader For YouTube with Flex/Air
by 12 June, 2008 11:58 am26
YouTube’s mixed easy movie access with community uploads to create a startling new service. The online problem is that you can only access YouTube when you are online. How can you access those movies when you are offline? Let’s solve that problem by building a downloader with Flex and AIR.
In this article we will build a cross platform application that searches for YouTube videos and then provides a mechanism to download those videos and view them locally. You will be able to take your favorite YouTube videos with you wherever you go.
Requirements
Sample files:
YouTube.zip
Let’s start by building the search user interface.
Searching YouTube
YouTube provides a set of RSS feeds that keep you up to date with the latest videos. These feeds take lots of parameters to refine what you are looking for, one of those is an arbitrary keyword search.
The Flex code below uses the YouTube search feed to request a set of videos based on the user’s search criteria. The code then uses the e4x language extensions in ActionScript 3 to parse the feed and present the video thumbnails in a TileList.
<?xml version="1.0" encoding="utf-8"?> <mx:WindowedApplication xmlns:mx="http://www.adobe.com/2006/mxml" layout="vertical" title="YouTube Search"> <mx:Script> <![CDATA[ import mx.rpc.events.ResultEvent; import mx.rpc.http.HTTPService; namespace atom = "http://www.w3.org/2005/Atom" ; namespace media = "http://search.yahoo.com/mrss/" ; private function onSearch() : void { var search:HTTPService = new HTTPService(); search.url = "http://gdata.youtube.com/feeds/api/videos/?vq=" +escape(txtSearch.text)+"&orderby=updated" ; search.resultFormat = 'e4x' ; search.addEventListener(ResultEvent.RESULT,onSearchResult); search.send(); } private function onSearchResult( event:ResultEvent ) : void { use namespace atom; use namespace media; var movies:Array = []; for each ( var entry:XML in event.result..entry ) { var group:XML = entry.group[0]; movies.push( { id:[email protected](), description:entry.content.toString(), thumbnail:group.thumbnail[0][email protected]() } ); } thumbList.dataProvider = movies; } ]]> </mx:Script> <mx:HBox width="100%"> <mx:TextInput width="100%" id="txtSearch" /> <mx:Button label="Search" click="onSearch()" /> </mx:HBox> <mx:TileList id="thumbList" width="100%" height="100%"> <mx:itemRenderer> <mx:Component> <mx:HBox paddingBottom="5" paddingLeft="5" paddingRight="5" paddingTop="5"> <mx:Image source ="{data.thumbnail}" height="100" width="150" horizontalAlign="center" verticalAlign="middle" toolTip="{data.description}"> </mx:Image> </mx:HBox> </mx:Component> </mx:itemRenderer> </mx:TileList> </mx:WindowedApplication>
The onSearch method is called from the search button. It creates a new HTTPService on the fly with the URL of the YouTube feed for the search. It then registers onSearchResult as an event handler for the result.
The onSearchResult method uses e4x to parse through each ‘entry’ tag in the RSS feed. For each movie it builds an object with three fields. The ‘id’ field holds the URL of the HTML page for the video. The ‘description’ field holds the textual description of the video. And the ‘thumbnail’ field holds the URL of the thumbnail.
When I launch this MXML in an AIR project within Flex Builder 3 I see something like Figure 1.
Figure 1-1. Just the YouTube search
In this case I’ve typed in ‘super mario’ and pressed ‘search’ to get a list of the movies that matched that criteria.
From here we need to add the ability to download the flash video, as well as play it back.
Downloading From YouTube
The user interface for the download version of the project is going to be a lot more complex than the search interface. We need to show the search results, allow for playback, and add a Save button to save the movie locally. The finished product is shown in Figure 2.
Figure 1-2. The search and the downloader
The interface is separated into two pieces, defined by panels. One panel is for searching and the other panel is shows the downloaded movies. The vertical divider allows you to scale the size of each of these panels to your preferred size.
The search section is largely the same though we will add a progress indicator (invisible in the figure) next to the search button. That progress indicator will be used during the downloads of the movies since those tend to be fairly big files.
The downloaded movies panel has the list of downloaded movies on the left, and the movie player on the right. As well as a Save button that is only visible if a movie is selected. The Save button allows the user to copy the downloaded movie out of the temporary directory onto the desktop (or wherever they choose).
The user interface portion of the MXML code for this example is shown below:
<?xml version="1.0" encoding="utf-8"?> <mx:WindowedApplication xmlns:mx="http://www.adobe.com/2006/mxml" layout="vertical" title="YouTube Downloader" height="700" width="600" paddingBottom="5" paddingLeft="5" paddingRight="5" paddingTop="5" creationComplete="updateLocalVideos()"> <mx:VDividedBox width="100%" height="100%"> <mx:Panel title="Search" paddingBottom="5" paddingLeft="5" paddingRight="5" paddingTop="5" width="100%" height="50%"> <mx:HBox width="100%"> <mx:TextInput width="100%" id="txtSearch" /> <mx:Button label="Search" click="onSearch()" /> <mx:ProgressBar width="100" id="downloadProgress" visible="false" /> </mx:HBox> <mx:TileList id="thumbList" width="100%" height="100%" doubleClickEnabled="true" doubleClick="onThumbClick()"> ... </mx:TileList> </mx:Panel> <mx:Panel title="Downloaded Movies" paddingBottom="5" paddingLeft="5" paddingRight="5" paddingTop="5" width="100%" height="50%"> <mx:HBox height="100%"> <mx:List id="localList" width="170" height="100%" doubleClickEnabled="true" doubleClick="onMovieClick()" > <mx:itemRenderer> <mx:Component> <mx:HBox paddingBottom="2" paddingLeft="2" paddingRight="2" paddingTop="2"> <mx:Image source="{data.thumbnail.url}" height="100" width="140" horizontalAlign="center" verticalAlign="middle"> </mx:Image> </mx:HBox> </mx:Component> </mx:itemRenderer> </mx:List> <mx:VBox verticalAlign="middle" horizontalAlign="center" width="100%" height="100%"> <mx:VideoDisplay id="player" width="300" height="200" backgroundColor="black" /> <mx:Button id="btnSave" label="Save" visible="false" click="onSave()" /> </mx:VBox> </mx:HBox> </mx:Panel> </mx:VDividedBox>
To keep the code sample short I’ve omit a bit of the original code from the display of the movies found during the search.
Don’t be put off by the quantity of tags. The MXML is quite straightforward. Most of the tags define the ‘itemRenderer’s used by the list control to show the video thumbnails. The other elements, the panels, the video display, the save button and so on, are just single elements with a few attributes to refine them.
With the interface all laid out it’s time to dig into the code. The search code remains exactly the same, but we’ve now added a thumbClick event handler which is called when the user double clicks on a thumbnail in the search panel. The thumbClick handler starts the request for the HTML page associated with the YouTube video. The onHTMLComplete method receives the HTML code for the page as text. It then calls getFLVURL to get the URL for the FLV data for the movie.
The code for this is shown below:
<mx:Script> <![CDATA[ import mx.rpc.events.ResultEvent; import mx.rpc.http.HTTPService; import com.adobe.serialization.json.JSONDecoder; namespace atom = "http://www.w3.org/2005/Atom" ; namespace media = "http://search.yahoo.com/mrss/" ; private function onSearch() : void { ...} private function onSearchResult( event:ResultEvent ) : void { ... } public function getFLVURL( sHTML:String ) : String { var swfArgsFound:Array = sHTML.match( /var swfArgs =(.*?);/ ); var swfArgsJS:JSONDecoder = new JSONDecoder( swfArgsFound[1] ); var swfArgs:Object = swfArgsJS.getValue(); var url:String = 'http://youtube.com/get_video.php' ; var first:Boolean = true ; for ( var k:String in swfArgs ) { if ( swfArgs[k] != null && swfArgs[k].toString().length > 0 ) { url += first ? '?' : '&' ; first = false ; url += k+'=' +escape(swfArgs[k]); } } return url; } private function onHTMLComplete( movie:Object, event:Event ) : void { var loader:URLLoader = event.target as URLLoader; var movieID:String = movie.id.split( /=/ )[1]; var flvStream:URLStream = startRequest( movieID+'.flv' , getFLVURL( loader.data ) ); startRequest( movieID+'.jpg' , movie.thumbnail ); flvStream.addEventListener(Event.COMPLETE,function (event:Event):void { downloadProgress.visible = false ; updateLocalVideos(); } ); downloadProgress.source = flvStream; downloadProgress.visible = true ; } private function onThumbClick() : void { var htmlLoader:URLLoader = new URLLoader(); htmlLoader.addEventListener(Event.COMPLETE, function ( event:Event ) : void { onHTMLComplete( thumbList.selectedItem, event ); } ); htmlLoader.load(new URLRequest(thumbList.selectedItem.id)); }
Let me step back for a second and talk briefly about Flash Video. YouTube, like any other site that uses a Flash player to show videos, uses FLV as the movie format. What YouTube does is provide their Flash video application with enough data for it to construct a ‘source’ URL for it’s video display object. That ‘source’ URL is get_video.php along with a set of parameters. Those parameters are stored in a Javascript variable called ‘swfVars’ which is embedded in the page.
The GetFLVURL takes the Javascript from the page and extracts the ‘swfVars’. It then uses the JSONDecode class provided by the as3corelib (http://code.google.com/p/as3corelib/) to decode the Javascript into an ActionScript object. From there it construct the FLV URL from the parameters in the ActionScript object.
The weakness in this example application is that it uses this ‘screen scraping’ technique to get to the FLV URL. Unfortunately there is no easier way to do it. If the format of the YouTube HTML changes, then this code might need to be rewritten to compensate for the changes.
Once the FLV URL is constructed the onHTMLComplete method calls startRequest on both the thumbnail and the FLV to download the data and store it locally. The code for this is shown below:
private function onReqComplete( fileName:String, event:Event ) : void { var stream:URLStream = event.target as URLStream; var byteLength:int = stream.bytesAvailable; var bytes:ByteArray = new ByteArray(); stream.readBytes( bytes, 0, byteLength ); stream.close(); if ( File.applicationStorageDirectory.exists == false ) File.applicationStorageDirectory.createDirectory(); var f:File = new File( File.applicationStorageDirectory.nativePath + File.separator + fileName ); var fs:FileStream = new FileStream(); fs.open( f, FileMode.WRITE ); fs.writeBytes( bytes, 0, byteLength ); fs.close(); } private function startRequest( fileName:String, url:String ) : URLStream { var req:URLStream = new URLStream(); req.addEventListener(Event.COMPLETE, function ( event:Event ) : void { onReqComplete( fileName, event ); } ); req.load( new URLRequest( url ) ); return req; }
The startRequest builds a URLStream object to get the data for the given URL. It then sets up onReqComplete as an event handler for when the download is complete. The onReqComplete uses the AIR File API to store the data, which is read directly into an AIR ByteArray class, into a file stored in the application storage directory. The application storage directory is maintained by AIR automatically for you.
Once the files are downloaded the updateLocalVideos method is called. This method, shown below, updates the list of local videos in the downloaded videos panel.
private function updateLocalVideos() : void { var fileNames:Object = new Object(); for each ( var file:File in File.applicationStorageDirectory.getDirectoryListing() ) { var fName:String = file.name.split( /[.]/ )[0]; fileNames[ fName ] = true ; } var movieList:Array = []; for ( var fileKey:String in fileNames ) { var thumb:File = new File( File.applicationStorageDirectory.nativePath + File.separator + fileKey + '.jpg' ); var movie:File = new File( File.applicationStorageDirectory.nativePath + File.separator + fileKey + '.flv' ); if ( thumb.exists && movie.exists ) movieList.push( { thumbnail: thumb, movie: movie } ); } localList.dataProvider = movieList; }
To do that the method uses the getDirectoryListing method, provided by the AIR File API, to get all of the files in the application storage directory. It then chops off the extensions and creates a list of just the file names. From there builds the list of local movies by iterating through the file names and checking to make sure that both the ‘.flv’ file for the video, and the ‘.jpg’ file for the thumbnail, are available.
With the list of local videos in hand the method sets the dataProvider of the local list to the movie list that it generated.
From there the final few functions handle the playback and the saving of the FLV to the desktop. These methods are shown below:
private function onMovieClick() : void { player.source = localList.selectedItem.movie.url; btnSave.visible = true ; } private function onSave() : void { var f:File = File.desktopDirectory; f.addEventListener(Event.SELECT,onSaveSelect); f.browseForSave("Save FLV" ); } private function onSaveSelect( event:Event ) : void { var f:File = event.target as File; var lf:File = localList.selectedItem.movie as File; lf.copyTo( f, true ); } ]]> </mx:Script> </mx:WindowedApplication>
The onMovieClick method is called when the user double clicks on a movie in the local list. It just sets the source of the VideoDisplay to the url of the local ‘.flv’ file.
The onSave method is called when the user clicks the Save button. It pops up a Save dialog using the AIR File API. If the user hits Save then the onSaveSelect method is called which copies the ‘.flv’ file from the local storage directory to the location they specify. You can see the interface for this in action in Figure 3.
Figure 1-3. The save window
On it’s face it seems like a lot of code, but it’s really not all that daunting when you break it down into it’s component pieces.
Your Next Steps
I hope you can leverage the code that I have presented in this article in your own work. There are some good reusable fragments including the file request and storage code in on ReqComplete. The JSON parsing in GetFLVURL can also come in handy when dealing with websites that lack a web services interface. Or in this case, have a web service interface that lacks the information you require.
If you do make use of this code, be sure to let me know. You can contact me directly through my website; http://jackherrington.com.