Thursday, April 23, 2009

jQuery Cycle: Adding player controls to a slide show

I'm working on a web site for a local student organization, Dance Marathon, to help them track people who participant or donate to their fundraising activities. On the front page of the web site I have a slideshow that rotates through a series of photos from previous events. I'm using the jQuery Cycle plugin for this. I wanted to add some player controls so that the end user can control the operation of the slideshow.

The site uses the FamFamFam Silk icon set by Mark James, so I decided to use the control icons for my player controls. These icons are released and used under a Creative Commons Attribution 3.0 license. I highly recommend these icons.

I wanted to support the full range of player controls: Goto First Slide, Previous Slide, Stop Show, Play Show, Next Slide, and Goto Last Slide. Fortunately, the Cycle plugin allows me to pause and resume the slide show as well as choose a specific slide to show. Turns out that this is really all that is necessary to support those features. I also appreciate that the Silk icons include both blue and gray versions of the control icons. This allows me to give the user some visual feedback on which control is currently selected. By default the play control will be the active control on page load.

The Cycle plugin is very easy to set up. Simply create a DIV and assign it a class (or id). I used:


<div class="pics"><div>


Next generate the set of images that you want to include in your slideshow. I'm using ASP.NET MVC so I pass down an IEnumerable<ImageDescriptor>, where ImageDescriptor is a class containing Url and AltText properties for the images to include. This enumeration is produced in my controller by reading a specific subdirectory of my images directory.

public ActionResult Index()
{
ViewData["Title"] = "Home Page";
ViewData["Message"] = this.LocalStrings.WelcomeMessage;

List images = new List();
string homeImagePath = Server.MapPath( "~/Content/images/home-images" );
foreach (string imageFile in Directory.GetFiles( homeImagePath ))
{
string fileName = Path.GetFileName( imageFile );
string url = Url.Content( "~/Content/images/home-images/" + fileName );
images.Add( new ImageDescriptor { Url = url } );
}
return View( images );
}


Because my images are of different sizes, I decided to place the controls above the slideshow. The cycle plugin, by default, reserves enough space, by setting fixed width/height on the DIV, to show all of the slides. Since some of my images are oriented vertically and others horizontally, placing the controls below the slideshow would make it appear too far below the horizontal images. I'd prefer it below, so I'm still working on making this work for both orientations. If you have any ideas, let me know.

First, the view includes the player controls DIV so that they appear above the slideshow.

<div id="controls" class="hidden">
<img id="startButton"
src='<%= Url.Content( "~/Content/images/icons/control_start.png" ) %>'
alt="Beginning"
title="Beginning" />
<img id="prevButton"
src='<%= Url.Content( "~/Content/images/icons/control_rewind.png" ) %>'
alt="Previous"
title="Previous" />
<img id="stopButton"
src='<%= Url.Content( "~/Content/images/icons/control_stop.png" ) %>'
alt="Stop"
title="Stop" />
<img id="playButton" src='<%= Url.Content( "~/Content/images/icons/control_play_blue.png" ) %>'
alt="Play"
title="Play" />
<img id="nextButton"
src='<%= Url.Content( "~/Content/images/icons/control_fastforward.png" ) %>'
alt="Next"
title="Next" />
<img id="endButton"
src='<%= Url.Content( "~/Content/images/icons/control_end.png" ) %>'
alt="End"
title="End" />
</div>


Next I include the code to generate the gallery. This has been simplified from the actual code. Note that I make all but the first image hidden, as well as the player controls, when the page first loads in case Javascript is not enabled. This helps the page to downgrade gracefully if the user won't be able to use the gallery. Note that Image is my own HtmlHelper extension, although, I think that there is a similar one in MvcFutures. ParameterDictionary is also my own class, but it functions similar to RouteValueDictionary so you could use that instead.

<div class='pics'>
<% var klass = "";
foreach (ImageDescriptor image in Model)
{
var htmlOptions = image.HtmlOptions ?? new ParameterDictionary();
var url = Url.Content( image.Url );
%>
<%= Html.Image( url,
image.AltText,
htmlOptions.Merge( new { @class = klass } ))%>
<%
klass = "hidden";
}
%>
</div>


Finally, we come to the magic that makes it all work -- the Javascript code. We need to include jQuery and the Cycle plugin. These go in the header for the view. Note That I'm also using an HtmlHelper extension here to generate the script tags.

<%= Html.Javascript( Url.Content( "~/Scripts/jquery-1.3.2.min.js" ) ) %>
<%= Html.Javascript( Url.Content( "~/Scripts/cycle/jquery.cycle.all.min.js" ) ) %>


Remember that we want the controls positioned over the center of whatever image is displayed. The gallery div will be sized to fit the largest image, but the images may be of different sizes. I played with a number of different options, but finally decided to simply set the left margin of the player controls based on the image size and the size of the controls themselves. This is handled dynamically using the following function. This function will be set as the before callback on the Cycle plugin.

function positionControls( curr, next, options, forward )
{
if (controlWidth == 0)
{
$('#controls > img').each( function() {
controlWidth = controlWidth + $(this).width();
});
}
var nextWidth = $(next).width();
var leftMargin = (nextWidth - controlWidth) / 2;
if (leftMargin < 0) leftMargin = 0;
$('#controls').css( 'marginLeft', leftMargin + 'px' );
}


Also, remember that we want to have the clicked control highlighted as a visual indicator to the user. The easiest way to do this is to have the click handler for each of the controls set the image urls based on which control is clicked using the following function.

function iconSelected(img)
{
$('#controls > img').each( function() {
this.src = this.src.replace( /_blue/, '' );
});
img.src = img.src.replace( /.png$/,'_blue.png' );
}


Now to tie it all together, we remove the hidden class from all the elements, set up the Cycle plugin, and install the click handlers for the controls. Note that the Cycle plugin has native support for Previous and Next slide options, though it doesn't pause the show after advancing. We'll need to set up the other behaviors, however, and add some additional behavior to the Previous and Next controls.

I'm going to use the default fade effect for the Cycle plugin and set it up to use random display of the images. I'll precalculate the position of the last image in the set to use for the Goto Last Slide control. All controls, except, Play, will pause the slideshow after performing their respective behavior. Play simply resumes the show when clicked.

    var controlWidth = 0;
var lastImage = 0;
$(document).ready( function() {
$('#controls').removeClass('hidden');

var gallery = $('.pics');
gallery.cycle( { fx: 'fade',
random: true,
prev: '#prevButton',
next: '#nextButton',
before: positionControls } );

gallery.children('img').removeClass('hidden');

lastImage = gallery.children('img').size() - 1;
if (lastImage < 0) lastImage = 0;

$('#stopButton').click( function() {
gallery.cycle('pause');
iconSelected(this);
});
$('#playButton').click( function() {
gallery.cycle('resume',true);
iconSelected(this);
});
$('#prevButton,#nextButton').click( function() {
gallery.cycle('pause');
iconSelected(this);
});
$('#startButton').click( function() {
gallery.cycle('pause');
gallery.cycle(0);
iconSelected(this);
});
$('#endButton').click( function() {
gallery.cycle('pause');
gallery.cycle(lastImage);
iconSelected(this);
});
});


I'm pretty pleased with how this turned out, though I want to keep exploring options for position the player controls. It is disconcerting when they jump around as the image sizes change. Floating them left, though, looks odd to me. I'd love to hear your idea.