Added artwork spriting support. Artwork is now displayed using a gigantic CSS sprite instead of hundreds of little images. GD required.
authorDan
Mon, 01 Sep 2008 13:06:50 -0400
changeset 40 bd3372a2afc1
parent 39 38dbcda3cf20
child 41 6e0beb10566d
Added artwork spriting support. Artwork is now displayed using a gigantic CSS sprite instead of hundreds of little images. GD required.
greyhound.php
imagetools.php
playlist.php
themes/iphone/playlist.tpl
webserver.php
--- a/greyhound.php	Mon Sep 01 13:05:52 2008 -0400
+++ b/greyhound.php	Mon Sep 01 13:06:50 2008 -0400
@@ -126,6 +126,7 @@
   $httpd->add_handler('scripts',              'dir',      GREY_ROOT . '/scripts');
   $httpd->add_handler('favicon.ico',          'file',     GREY_ROOT . '/amarok_icon.ico');
   $httpd->add_handler('apple-touch-icon.png', 'file',     GREY_ROOT . '/apple-touch-icon.png');
+  $httpd->add_handler('spacer.gif',           'file',     GREY_ROOT . '/spacer.gif');
   // load all themes if forking is enabled
   // Themes are loaded when the playlist is requested. This is fine for
   // single-threaded operation, but if the playlist handler is only loaded
@@ -155,7 +156,7 @@
 }
 catch( Exception $e )
 {
-  if ( strstr(strval($e), "Could not bind") )
+  if ( strstr(strval($e), "Could not bind") || strstr(strval($e), "Address already in use") )
   {
     burnout("Could not bind to the port $ip:$port. Is Greyhound already running? Sometimes browsers don't close off their connections until Greyhound has been dead for about a minute, so try starting Greyhound again in roughly 60 seconds. If that doesn't work, type \"killall -9 php\" at a terminal and try starting Greyhound again in 60 seconds.");
   }
--- a/imagetools.php	Mon Sep 01 13:05:52 2008 -0400
+++ b/imagetools.php	Mon Sep 01 13:06:50 2008 -0400
@@ -38,7 +38,7 @@
 /**
  * Scales an image to the specified width and height, and writes the output to the specified
  * file. Will use ImageMagick if present, but if not will attempt to scale with GD. This will
- * always scale images proportionally.
+ * always scale images proportionally, unless $preserve_ratio is set to false.
  * 
  * Ported from Enano CMS (which is also my project)
  * 
@@ -47,10 +47,11 @@
  * @param int Image width, in pixels
  * @param int Image height, in pixels
  * @param bool If true, the output file will be deleted if it exists before it is written
+ * @param bool If true, preserves the image's aspect ratio (default)
  * @return bool True on success, false on failure
  */
 
-function scale_image($in_file, $out_file, $width = 225, $height = 225, $unlink = false)
+function scale_image($in_file, $out_file, $width = 225, $height = 225, $unlink = false, $preserve_ratio = true)
 {
   global $db, $session, $paths, $template, $plugins; // Common objects
   
@@ -117,8 +118,9 @@
       // ImageMagick path seems screwy
       return false;
     }
-    $cmdline = "\"$magick_path\" \"$in_file\" -resize \"{$width}x{$height}>\" \"$out_file\"";
-    system($cmdline, $return);
+    $op = ( $preserve_ratio ) ? '>' : '!';
+    $cmdline = "\"$magick_path\" \"$in_file\" -resize '{$width}x{$height}$op' \"$out_file\"";
+    exec($cmdline, $output, $return);
     if ( !file_exists($out_file) )
       return false;
     return true;
@@ -130,29 +132,37 @@
       return false;
     // calculate new width and height
     
-    $ratio = $width_orig / $height_orig;
-    if ( $ratio > 1 )
+    if ( $preserve_ratio )
     {
-      // orig. width is greater that height
-      $new_width = $width;
-      $new_height = round( $width / $ratio );
+      $ratio = $width_orig / $height_orig;
+      if ( $ratio > 1 )
+      {
+        // orig. width is greater that height
+        $new_width = $width;
+        $new_height = round( $width / $ratio );
+      }
+      else if ( $ratio < 1 )
+      {
+        // orig. height is greater than width
+        $new_width = round( $height / $ratio );
+        $new_height = $height;
+      }
+      else if ( $ratio == 1 )
+      {
+        $new_width = $width;
+        $new_height = $width;
+      }
+      if ( $new_width > $width_orig || $new_height > $height_orig )
+      {
+        // Too big for our britches here; set it to only convert the file
+        $new_width = $width_orig;
+        $new_height = $height_orig;
+      }
     }
-    else if ( $ratio < 1 )
-    {
-      // orig. height is greater than width
-      $new_width = round( $height / $ratio );
-      $new_height = $height;
-    }
-    else if ( $ratio == 1 )
+    else
     {
       $new_width = $width;
-      $new_height = $width;
-    }
-    if ( $new_width > $width_orig || $new_height > $height_orig )
-    {
-      // Too big for our britches here; set it to only convert the file
-      $new_width = $width_orig;
-      $new_height = $height_orig;
+      $new_height = $height;
     }
     
     $newimage = @imagecreatetruecolor($new_width, $new_height);
--- a/playlist.php	Mon Sep 01 13:05:52 2008 -0400
+++ b/playlist.php	Mon Sep 01 13:06:50 2008 -0400
@@ -61,6 +61,7 @@
       'position.js'
     ));
   $smarty->assign('allow_control', $allowcontrol);
+  $smarty->register_function('sprite', 'smarty_function_sprite');
   $smarty->display('playlist.tpl');
 }
 
@@ -68,6 +69,48 @@
 {
   global $amarok_home;
   
+  // get PATH_INFO
+  $pathinfo = @substr(@substr($_SERVER['REQUEST_URI'], 1), @strpos(@substr($_SERVER['REQUEST_URI'], 1), '/')+1);
+  
+  // should we do a collage (for CSS sprites instead of sending hundreds of individual images)?
+  if ( preg_match('/^collage(?:\/([0-9]+))?$/', $pathinfo, $match) )
+  {
+    // default size is 50px per image
+    $collage_size = ( isset($match[1]) ) ? intval($match[1]) : 50;
+    
+    $artwork_dir = "$amarok_home/albumcovers";
+    if ( !file_exists("$artwork_dir/collage_{$collage_size}.png") )
+    {
+      if ( !generate_artwork_collage("$artwork_dir/collage_{$collage_size}.png", $collage_size) )
+      {
+        echo 'Error: generate_artwork_collage() failed';
+        return;
+      }
+    }
+    
+    $target_file = "$artwork_dir/collage_{$collage_size}.png";
+    // we have it now, send the image through
+    $fh = @fopen($target_file, 'r');
+    if ( !$fh )
+      return false;
+    
+    $httpd->header('Content-type: image/png');
+    $httpd->header('Content-length: ' . filesize($target_file));
+    $httpd->header('Expires: Wed, 1 Jan 2020 01:00:00 GMT');
+    
+    // kinda sorta a hack.
+    $headers = implode("\r\n", $httpd->response_headers);
+    $httpd->send_client_headers($socket, $httpd->response_code, $httpd->content_type, $headers);
+    
+    while ( $d = fread($fh, 10240) )
+    {
+      $socket->write($d);
+    }
+    fclose($fh);
+    
+    return;
+  }
+  
   if ( !isset($_GET['artist']) || !isset($_GET['album']) )
   {
     echo 'Please specify artist and album.';
@@ -94,6 +137,7 @@
       $artwork_filetype = get_image_filetype("$artwork_dir/large/$artwork_hash");
       if ( !$artwork_filetype )
       {
+        // image is not supported (PNG, GIF, or JPG required)
         return false;
       }
       // we'll need to copy the existing artwork file to our thumbnail dir to let scale_image() detect the type properly (it doesn't use magic bytes)
@@ -117,12 +161,18 @@
     $fh = @fopen($target_file, 'r');
     if ( !$fh )
       return false;
+    
     $httpd->header('Content-type: image/png');
     $httpd->header('Content-length: ' . filesize($target_file));
     $httpd->header('Expires: Wed, 1 Jan 2020 01:00:00 GMT');
+    
+    // kinda sorta a hack.
+    $headers = implode("\r\n", $httpd->response_headers);
+    $httpd->send_client_headers($socket, $httpd->response_code, $httpd->content_type, $headers);
+    
     while ( !feof($fh) )
     {
-      socket_write($socket, fread($fh, 51200));
+      $socket->write(fread($fh, 51200));
     }
     fclose($fh);
   }
@@ -135,4 +185,300 @@
   }
 }
 
+/**
+ * Generates a collage of all album art for use as a CSS sprite. Also generates a textual .map file in the format of "hash xpos ypos\n"
+ * to allow retrieving positions of images. Requires GD.
+ * @param string Name of the collage file. Map file will be the same filename except with the extension ".map"
+ * @param int Size of each image, in pixels. Artwork images will be stretched to a 1:1 aspect ratio. Optional, defaults to 50.
+ * @return bool True on success, false on failure.
+ */
 
+function generate_artwork_collage($target_file, $size = 50)
+{
+  // check for required GD functionality
+  if ( !function_exists('imagecopyresampled') || !function_exists('imagepng') )
+    return false;
+  
+  status("generating size $size collage");
+  $stderr = fopen('php://stderr', 'w');
+  if ( !$stderr )
+    // this should really never fail.
+    return false;
+  
+  // import amarok globals
+  global $amarok_home;
+  $artwork_dir = "$amarok_home/albumcovers";
+  
+  // map file path
+  $mapfile = preg_replace('/\.[a-z]+$/', '', $target_file) . '.map';
+  
+  // open map file
+  $maphandle = @fopen($mapfile, 'w');
+  if ( !$maphandle )
+    return false;
+  
+  $mapheader = <<<EOF
+# this artwork collage map gives the locations of various artwork images within the collage
+# format is:
+# hash                           x y
+# x and y are indices, not pixel values (obviously), and hash is the name of the artwork file in large/
+
+EOF;
+  fwrite($maphandle, $mapheader);
+  
+  // build a list of existing artwork files
+  $artwork_list = array();
+  if ( $dh = @opendir("$artwork_dir/large") )
+  {
+    while ( $fp = @readdir($dh) )
+    {
+      if ( preg_match('/^[a-f0-9]{32}$/', $fp) )
+      {
+        $artwork_list[] = $fp;
+      }
+    }
+    closedir($dh);
+  }
+  else
+  {
+    return false;
+  }
+  
+  // at least one image?
+  if ( empty($artwork_list) )
+    return false;
+  
+  // asort it to make sure map is predictable
+  asort($artwork_list);
+  
+  // number of columns
+  $cols = 20;
+  // number of rows
+  $rows = ceil( count($artwork_list) / $cols );
+  
+  // image dimensions
+  $image_width  = $cols * $size;
+  $image_height = $rows * $size;
+  
+  // create image
+  $collage = imagecreatetruecolor($image_width, $image_height);
+  
+  // generator loop
+  // start at row 0, column 0
+  $col = -1;
+  $row = 0;
+  $srow = $row + 1;
+  fwrite($stderr, "  -> row $srow of $rows\r");
+  $time_map = microtime(true);
+  foreach ( $artwork_list as $artwork_file )
+  {
+    // calculate where we are
+    $col++;
+    if ( $col == $cols )
+    {
+      // reached column limit, reset $cols and increment row
+      $col = 0;
+      $row++;
+      $srow = $row + 1;
+      fwrite($stderr, "  -> row $srow of $rows\r");
+    }
+    // x and y offset of scaled image
+    $xoff = $col * $size;
+    $yoff = $row * $size;
+    // set offset
+    fwrite($maphandle, "$artwork_file $col $row\n");
+    // load image
+    $createfunc = ( get_image_filetype("$artwork_dir/large/$artwork_file") == 'jpg' ) ? 'imagecreatefromjpeg' : 'imagecreatefrompng';
+    $aw = @$createfunc("$artwork_dir/large/$artwork_file");
+    if ( !$aw )
+    {
+      $aw = @imagecreatefromwbmp("$artwork_dir/large/$artwork_file");
+      if ( !$aw )
+      {
+        // couldn't load image, silently continue
+        continue;
+      }
+    }
+    list($aw_width, $aw_height) = array(imagesx($aw), imagesy($aw));
+    // scale and position image
+    $result = imagecopyresampled($collage, $aw, $xoff, $yoff, 0, 0, $size, $size, $aw_width, $aw_height);
+    if ( !$result )
+    {
+      // couldn't scale image, silently continue
+      continue;
+    }
+    // free the temp image
+    imagedestroy($aw);
+  }
+  $time_map = round(1000 * (microtime(true) - $time_map));
+  $time_write = microtime(true);
+  fclose($maphandle);
+  fwrite($stderr, "  -> saving image\r");
+  if ( !imagepng($collage, $target_file) )
+    return false;
+  imagedestroy($collage);
+  $time_write = round(1000 * (microtime(true) - $time_write));
+  
+  $avg = round($time_map / count($artwork_list));
+  
+  status("collage generation complete, returning success; time (ms): map/avg/write $time_map/$avg/$time_write");
+  return true;
+}
+
+/**
+ * Returns an img tag showing artwork from the specified size collage sprite.
+ * @param string Artist
+ * @param string Album
+ * @param int Collage size
+ * @return string
+ */
+
+function get_artwork_sprite($artist, $album, $size = 50)
+{
+  // import amarok globals
+  global $amarok_home;
+  $artwork_dir = "$amarok_home/albumcovers";
+  
+  if ( !is_int($size) )
+    return '';
+  
+  // hash of cover
+  $coverid = md5(strtolower(trim($artist)) . strtolower(trim($album)));
+  
+  $tag = '<img alt=" " src="/spacer.gif" width="' . $size . '" height="' . $size . '" ';
+  if ( file_exists("$artwork_dir/collage_{$size}.map") )
+  {
+    $mapdata = parse_collage_map("$artwork_dir/collage_{$size}.map");
+    if ( isset($mapdata[$coverid]) )
+    {
+      $css_x = -1 * $size * $mapdata[$coverid][0];
+      $css_y = -1 * $size * $mapdata[$coverid][1];
+      $tag .= "style=\"background-image: url(/artwork/collage/$size); background-repeat: no-repeat; background-position: {$css_x}px {$css_y}px;\" ";
+    }
+  }
+  $tag .= '/>';
+  
+  return $tag;
+}
+
+/**
+ * Parses the specified artwork map file. Return an associative array, keys being the artwork file hashes and values being array(x, y).
+ * @param string Map file
+ * @return array
+ */
+
+function parse_collage_map($mapfile)
+{
+  if ( !file_exists($mapfile) )
+    return array();
+  
+  $fp = @fopen($mapfile, 'r');
+  if ( !$fp )
+    return false;
+  
+  $map = array();
+  while ( $line = fgets($fp) )
+  {
+    // parse out comments
+    $line = trim(preg_replace('/#(.+)$/', '', $line));
+    if ( empty($line) )
+      continue;
+    list($hash, $x, $y) = explode(' ', $line);
+    if ( !preg_match('/^[a-f0-9]{32}$/', $hash) || !preg_match('/^[0-9]+$/', $x) || !preg_match('/^[0-9]+$/', $y) )
+      // invalid line
+      continue;
+      
+    // valid line, append map array
+    $map[$hash] = array(
+        intval($x),
+        intval($y)
+      );
+  }
+  fclose($fp);
+  return $map;
+}
+
+/**
+ * Finds out if a collage file is outdated (e.g. missing artwork images)
+ * @param int Size of collage
+ * @return bool true if outdated
+ */
+
+function collage_is_outdated($size = 50)
+{
+  global $amarok_home;
+  $artwork_dir = "$amarok_home/albumcovers";
+  $mapfile = "$artwork_dir/collage_{$size}.map";
+  if ( !file_exists($mapfile) )
+  {
+    // consider it outdated if it doesn't exist
+    return true;
+  }
+  
+  // load existing image map
+  $map = parse_collage_map($mapfile);
+  
+  // build a list of existing artwork files
+  $artwork_list = array();
+  if ( $dh = @opendir("$artwork_dir/large") )
+  {
+    while ( $fp = @readdir($dh) )
+    {
+      if ( preg_match('/^[a-f0-9]{32}$/', $fp) )
+      {
+        // found an artwork file
+        if ( !isset($map[$fp]) )
+        {
+          // this artwork isn't in the map file, return outdated
+          closedir($dh);
+          status("size $size collage is outdated");
+          return true;
+        }
+      }
+    }
+    closedir($dh);
+  }
+  
+  // if we reach here, we haven't found anything missing.
+  return false;
+}
+
+/**
+ * Smarty function for sprite generation.
+ * @access private
+ */
+
+function smarty_function_sprite($params, &$smarty)
+{
+  // don't perform the exhaustive check more than once per execution
+  static $checks_done = array();
+  
+  if ( empty($params['artist']) )
+    return 'Error: missing "artist" parameter';
+  if ( empty($params['album']) )
+    return 'Error: missing "album" parameter';
+  if ( empty($params['size']) )
+    $params['size'] = 50;
+  
+  $params['size'] = intval($params['size']);
+  $size =& $params['size'];
+  
+  // if the collage file doesn't exist or is missing artwork, renew it
+  // but only perform this check once per execution per size
+  if ( !isset($checks_done[$size]) )
+  {
+    global $amarok_home;
+    $artwork_dir = "$amarok_home/albumcovers";
+    
+    $collage_file = "$artwork_dir/collage_{$size}";
+    $collage_is_good = file_exists("$collage_file.png") && file_exists("$collage_file.map") && !collage_is_outdated($size);
+    if ( !$collage_is_good )
+    {
+      generate_artwork_collage("$collage_file.png", $size);
+    }
+    $checks_done[$size] = true;
+  }
+  
+  return get_artwork_sprite($params['artist'], $params['album'], $params['size']);
+}
+
--- a/themes/iphone/playlist.tpl	Mon Sep 01 13:05:52 2008 -0400
+++ b/themes/iphone/playlist.tpl	Mon Sep 01 13:06:50 2008 -0400
@@ -67,7 +67,7 @@
           <th>Track</th>
         </tr>
         {foreach key=tid item=track from=$playlist}
-        {strip}
+        {* strip *}
         <tr class="{cycle values="row1,row2"}{if $active == $tid} current{/if}" id="track_{$tid}" amarok:length_sec="{$track.length_int}">
           <td>
             <a class="tracklink" href="#action:jump;tid:{$tid}" onclick="expand_track({$tid}); return false;">
@@ -75,7 +75,7 @@
             </a>
             <div id="track_inner_{$tid}" class="track_inner">
               <div style="float: left; margin-right: 5px;">
-                <img alt=" " src="/artwork?artist={$track.artist|urlencode}&album={$track.album|urlencode}" />
+                {sprite artist=$track.artist album=$track.album size=50}
               </div>
               <small>
               <b>Artist:</b> {$track.artist|escape}<br />
@@ -86,7 +86,7 @@
             </div>
           </td>
         </tr>
-        {/strip}
+        {* /strip *}
         {/foreach}
       </table>
     </div>
--- a/webserver.php	Mon Sep 01 13:05:52 2008 -0400
+++ b/webserver.php	Mon Sep 01 13:06:50 2008 -0400
@@ -93,7 +93,7 @@
    */
   
   var $response_code = 0;
-  
+    
   /**
    * Content type set by the current handler function
    * @var string
@@ -1010,20 +1010,23 @@
           return true;
         }
         
-        // $this->header('Transfer-encoding: chunked');
-        $this->header("Content-length: " . strlen($output));
-        $headers = implode("\r\n", $this->response_headers);
-        
-        // write headers
-        $this->send_client_headers($socket, $this->response_code, $this->content_type, $headers);
-        
-        // chunk output
-        // $output = dechex(strlen($output)) . "\r\n$output";
-        
-        // write body
-        $socket->write($output);
-        
-        $this->headers_sent = false;
+        if ( !$this->headers_sent )
+        {
+          // $this->header('Transfer-encoding: chunked');
+          $this->header("Content-length: " . strlen($output));
+          $headers = implode("\r\n", $this->response_headers);
+          
+          // write headers
+          $this->send_client_headers($socket, $this->response_code, $this->content_type, $headers);
+          
+          // chunk output
+          // $output = dechex(strlen($output)) . "\r\n$output";
+          
+          // write body
+          $socket->write($output);
+          
+          $this->headers_sent = false;
+        }
         
         break;
       case 'sysuuid':