torsdag 11 juli 2013

Tutorial on how to use the Flickr API


Inspired by one of Apple TV's screen savers, which fetches pictures from a Flickr account and moves them elegantly across the screen, I decided some time ago to see if I could do a similar app using web technologies. I named the app Flickrfeed, and here I thought I'd share how I made it.

Let's think about what we need to do to make such an app.

1. We need some HTML skeleton code so that we have a page to load and somewhere to put the Javascript code that will handle the logic.

2. We'll need to connect to Flickr to ask for photos from an account, and download those photos to our app.

3. We'll need a graphics surface to draw the photos on.

4. We'll need an animation loop for moving the photos across the screen.


I created an empty file called index.html, and I put some HTML skeleton code there, as well as a Canvas element:


<!DOCTYPE HTML>
<html>
        <head>
                <meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
                <style type="text/css">
                        body {
                                margin-top: 0px;
                                margin-right: 0px;
                                margin-bottom: 0px;
                                margin-left: 0px;
                                padding: 0px;
                        }
                        canvas {
                                display: block;
                        }
                </style>
        </head>
        <body bgcolor="#000000">
                <canvas id="myCanvas" width="600" height="400"></canvas>
        </body>
</html>

You can see some CSS styling code there as well, to ensure that there will be no additional border space between the window and the canvas element.

There are many different devices out there, and consequently a lot of different screen sizes to support. Independently of screen size, I wanted the canvas to always take up as much space as possible, even if the user resizes the window, so I added some code for listening to the window resize event and automatically setting the canvas size accordingly.

This script code can be put in the head section:


<script>
        function pageLoaded() {
                window.onresize = function() {
                        var canvas = document.getElementById("myCanvas");
                        if (canvas.width != (window.innerWidth)) {
                                canvas.width = window.innerWidth;
                        }
                        if (canvas.height != (window.innerHeight)) {
                                canvas.height = window.innerHeight;
                        }
                }
                window.onresize();
        }
</script>

The explicit call to window.onresize() on the last line is to ensure that the canvas size is set at least once, i.e. even if the user does not resize anything.

Now to make sure the pageLoaded function is indeed called when the page is loaded, lets hook it up using the onload event:


<body bgcolor="#000000" onload="pageLoaded()">

Great! Now the canvas will scale automatically to whichever screen size or browser window size is being used.

Next, I turned to Flickr and started reading their instructions for developers on www.flickr.com/services/api/.
By registering as a developer on Flickr you get an API key, which is basically a long string that uniquely identifies you as a user of the Flickr APIs. When you call Flickr APIs from your app you need to use this key so that Flickr recognizes you.

It actually turned out to be quite simple to use Flickr's API to download photos from an account.

First of all, we need to download a script from Flickr to our web app and call their search API.
This can be added to the body of the HTML code:


<script src='http://api.flickr.com/services/rest/?method=flickr.photos.search&api_key=<YOUR_API_KEY>&user_id=<YOUR_USER_ID>&content_type=1&per_page=1000&format=json'></script>

(You need to exchange YOUR_API_KEY and YOUR_USER_ID to your own values that you get when you register as a Flickr developer.)

When this line is executed, the Flickr search API will be called. Next, Flickr will call a predetermined function in your app to let you know the search result. So for this to work, you need to have such a function in your code. The function must be named jsonFlickrApi.


function jsonFlickrApi(object) {
        console.log("Hey, I got a reply from Flickr!");
        // We'll add more code here later...
}

Actually, by the time this function is called, no photos have been downloaded yet. The received object contains only references to the photos and information about where you can download them from. You can find this information in an array of object: "object.photos" and thus you can check how many references you received by checking "object.photos.length".

Next we want to add code to download some photos. We may not want to download all the photos, because there could be tons of them and it could take a long time. It's probably better to download just a few photos, render those, and then download a few more photos later when we have finished showing the first ones.

For downloading photos I added a helper function like this:


function generateURL(farmID, serverID, id, secret) {
        // Thumbnail size, 100 pixels on longest side:
        // return "http://farm" + farmID + ".static.flickr.com/" + serverID + "/" + id + "_" + secret + "_t.jpg";
                      
        // medium size, 500 pixels on longest side:
        return "http://farm" + farmID + ".static.flickr.com/" + serverID + "/" + id + "_" + secret + ".jpg";

        // large size, 1024 pixels on longest side, not 100% supported...:
        // return "http://farm" + farmID + ".static.flickr.com/" + serverID + "/" + id + "_" + secret + "_b.jpg";
}

As you see, most lines are actually commented out. Flickr allows for different sizes of images and here I went for the medium size, 500 pixels on longest side. Choosing the larger size would obviously mean longer download times.

Now before adding code to actually load the photos, let's first add a few variables to the global scope:


var photoArray = null;
var myMovingImages = new Array();
var MAX_NUMBER_OF_PHOTOS_ON_SCREEN = 3;

  • photoArray will be used to refer to the array of photo references that we receive from Flickr.
  • myMovingImages will be an array of images that are moving across the screen.
  • MAX_NUMBER_OF_PHOTOS_ON_SCREEN defines how many images we want to be shown at a time. We don't want to flood the screen with hundreds of images.

Also, let's add a class for a moving image.

Which properties does a moving image need? Basically, it needs:

  • a reference to a displayable Image,
  • a position for where to draw the image
  • a velocity at which the image moves.
  • we'll also add an index so that we know which element in the photoArray it is associated with.


function MovingImage(image, photoArrayIndex) {
        this.image = image;
        this.photoArrayIndex = photoArrayIndex;

        this.x = 0;
        this.y = 0;

        this.velocityX = 0;     // in pixels per second
        this.velocityY = 0;     // in pixels per second

        this.initialized = false;
}

We will get back to explain the initialized variable later.

Now we can modify the jsonFlickrApi function so that it actually loads some photos using the generateURL helper function.

Instead of always drawing the photos in order, it might be fun to randomize the order.
So let's add a function that pulls a random index from our photo array:


function getRandomPhotoNumber() {
        return Math.floor(Math.random() * photoArray.length);
}

And here's the modified jsonFlickrApi function:


function jsonFlickrApi(object){
        console.log("Hey, I got a reply from Flickr!");
        if (object.stat == 'ok') {
                photoArray = object.photos.photo;
                console.log("Number of photos: " + photoArray.length);
                // Download the first few photos
                for (var i=0; i<MAX_NUMBER_OF_PHOTOS_ON_SCREEN; i++) {
                        var myImage = new Image();
                        var randomPhotoNumber = getRandomPhotoNumber();
                        myMovingImages[i] = new MovingImage(myImage, randomPhotoNumber);
                        myImage.src = generateURL(object.photos.photo[randomPhotoNumber].farm,
                                                                                object.photos.photo[randomPhotoNumber].server,
                                                                                object.photos.photo[randomPhotoNumber].id,
                                                                                object.photos.photo[randomPhotoNumber].secret);                                               
                }
  }
  else {
         alert('Flickr search failed');
        }
}

So, what did we do here? We generated URLs for some photos we want to show, and we created some instances of our MovingImage class. Also we created Image objects with src set to the corresponding URL. Doing so will trigger the browser to download the image from the URL.

Hey, this means we have gotten quite far! We have sent a request to Flickr, we have retrieved a list of images, and we have started downloading a few of those images. It's time to add code for drawing some photos!

Let's add code in pageLoaded() for setting up a drawing loop that we wish to be run 60 times a second:


        window.requestAnimFrame = (function(callback){
                return window.requestAnimationFrame ||
                window.webkitRequestAnimationFrame ||
                window.mozRequestAnimationFrame ||
                window.oRequestAnimationFrame ||
                window.msRequestAnimationFrame ||
                function(callback){
                        window.setTimeout(callback, 1000 / 60);
                };
        })();

...and let's add a function where we will do our actual drawing. This function will take one parameter
that tells at which time the function was called last time. Inside the function we can get the current time and compare that with the last time, to know how much time has elapsed since the last animation call. Later we will use this time to determine how long animation step we need to take. (I.e. how far should we move the images that are moving across the screen.)


function animate(lastTime) {
        var date = new Date();
        var time = date.getTime(); // milliseconds
        var timeDiff = time - lastTime;

        // TODO: We will add our actual drawing here later...

        // Finally, request a new animation frame
        requestAnimFrame(function() {
                animate(time);
        });
}

Let's also add one explicit call to our animate function so that it is called once when our page is loaded.
Add this code to the end of pageLoaded():


var date = new Date();
var time = date.getTime();
animate(time);

Now we have set up an animation function, but so far we are not doing any actual drawing. So let's add some more code to "animate", to handle the drawing of one animation frame.

To draw graphics, we need access to the context of the canvas:


var canvas = document.getElementById("myCanvas");
var context = canvas.getContext("2d");

And before drawing, we need to clear the canvas of any previously drawn graphics. If we don't do this, each frame will simply add graphics on top of what we drew in the last frame, causing the photos to smear all over the canvas.


context.clearRect(0, 0, canvas.width, canvas.height);

It's finally time to actually draw some photos!


// Draw all moving images
for (var i=0; i<myMovingImages.length; i++) {
        if (myMovingImages[i].image && myMovingImages[i].image.width > 0) {
                if (!myMovingImages[i].initialized) {
                        initializeMovingImage(myMovingImages[i], canvas.width, canvas.height);
                }

                context.fillStyle = "rgba(0, 0, 0, 0.5)";
                context.fillRect(myMovingImages[i].x-20, myMovingImages[i].y-20, myMovingImages[i].image.width+40, myMovingImages[i].image.height+40);
                context.drawImage(myMovingImages[i].image, myMovingImages[i].x, myMovingImages[i].y);
        }
}

So what does this drawing code do?

We loop over all the moving images. For each photo we initialize its MovingImage instance if that hasn't been done already. We then make two drawing calls. The first one draws a rectangle slightly larger than the photo itself, with a alpha value of 0.5. This will create something of a shadow behind the photo. The second call is for drawing the actual photo on top of the shadow.

There is one function call that we have not implemented yet: initializeMovingImage. In that one we need to add code to set the initial position and velocity of the moving image.

  • To have images moving across the screen from bottom to top, the image should initially be drawn just below the canvas, so that in the next few animation frames when it moves upwards we will start seing just the top of the image at the bottom part of the canvas.
  • As for the x position, let's randomize it a bit so that each image has a different x position.
  • Let's randomize the y velocity as well so that images move at slightly different speeds. Note that the y velocity needs to be negative since we are moving images upwards not downwards. (The HTML canvas uses a coordinate system where the top left pixel is pixel x=0, y=0.)


function initializeMovingImage(movingImage, canvasWidth, canvasHeight) {
        movingImage.x = Math.floor(Math.random() * (canvasWidth-movingImage.image.width));
        movingImage.y = canvasHeight;
        // Set velocity between -20 and -50
        movingImage.velocityY = -20 - Math.floor(Math.random() * 30);
        movingImage.initialized = true;
}

Now we are able to initialize and draw photos on their initial positions. But so far we are not actually moving the photos. To do that we need to update the photos' positions each animation frame. Let's add that in the animation function after drawing the photos, so that in each animation frame all photos are drawn on their current position, and then a new position is calculated. When calling the animation function repeatedly, this will make it look like the photos are moving upwards.

Let's also consider what should happen when a photo moves out from the screen. As a photo moves upwards acroess the screen, eventually only the lower portion of the photo will be visible in the upper part of the canvas, and shortly thereafter the photo will be completely above the canvas and thus not visible anymore. At that time we are done animating that photo, and if we don't do anything else we will simply run out of photos: the bunch of photos that we initially loaded will move across the screen and that's it. That's not very fun, so let's add code that loads a new photo each time one photo disappears from the screen. And have the new photo start at the initial position just below the canvas. Then it will look like there is a constant flow of photos, each popping up at the bottom and sliding upwards.

So, add this loop after the drawing loop:


// Update positions of all images
for (var i=0; i<myMovingImages.length; i++) {
        if (myMovingImages[i].image && myMovingImages[i].image.width > 0) {
                myMovingImages[i].x += myMovingImages[i].velocityX * (timeDiff/1000);
                myMovingImages[i].y += myMovingImages[i].velocityY * (timeDiff/1000);

                // Has the image scrolled out from the screen?
                if (myMovingImages[i].y < -(myMovingImages[i].image.height)) {
                        // Load a new image and put it on the initial position
                        myMovingImages[i].initialized = false;
                        var myImage = new Image();
                        randomPhotoNumber = getRandomPhotoNumber();
                        myMovingImages[i].image = myImage;
                        myMovingImages[i].photoIndex = randomPhotoNumber;
                        myImage.src = generateURL(photoArray[randomPhotoNumber].farm, photoArray[randomPhotoNumber].server, photoArray[randomPhotoNumber].id, photoArray[randomPhotoNumber].secret);
                }
        }
}

To recap what we just wrote, we loop through all the moving images and for each one we update the position. An image's new position is determined by its old position plus its velocity multiplied by the time passed, according to the formula distance = velocity * time.

Instead of using a constant time value, we use the timeDiff parameter, i.e. the measured amount of time passed between this and the last call to the animation function. Depending on the system's performance and workload, the animation function may not be called in a perfect framerate, and by taking timeDiff into account like this we can calculate the position correctly even if the animation framerate varies.

The second if-switch checks if the image has moved out from the screen. An image has moved out from the screen if it is drawn completely above the canvas. The canvas top pixel line is at y position 0, so an image that has moved out completely above the canvas would have its y position on the same value as its negative height, so that's the condition we check. If the image is indeed outside the canvas, we pick a new image from the photoArray and set it on an initial position.

And we're (almost!) done. Now we have automatic loading of new images as existing images move out from the screen, causing a seamlessly endless flow of photos moving from bottom to top.

Let's add just one more thing: When the user clicks on a photo, let's change the current URL to the page on Flickr that holds that photo.

To do so, we'll add this code to the end of pageLoaded:


var canvas = document.getElementById("myCanvas");
canvas.onclick = function(event) {
        var x = event.pageX - canvas.offsetLeft;
        var y = event.pageY - canvas.offsetTop;
        for (var i=myMovingImages.length-1; i>=0; i--) {
                if (myMovingImages[i].image) {
                        if (x >= myMovingImages[i].x &&
                                y >= myMovingImages[i].y &&
                                x < myMovingImages[i].x + myMovingImages[i].image.width &&
                                y < myMovingImages[i].y + myMovingImages[i].image.height) {
                                        location.href = "http://www.flickr.com/photo.gne?id=" + myMovingImages[i].flickrId;

                                        break;
                        }
                }
        }
}

You may wonder why we are looping backwards here. Let's think about the case where two or more photos are drawn on top of each other. If the user clicks such a place, we want to jump to the image that is "on top". Remember that we are drawing the images in the same order as they occur in the myMovingImages array, meaning if there are any images overlapping each other, the one seen "on top" will be the element closest to the end of the array. Thus we'll go through the list from end to beginning and as soon as we find an image covering the clicked area, we'll pick that image.

Flickr has a service where we can give the unique photo ID as argument and Flickr will handle the loading of that page, so we just have to set location.href to "http://www.flickr.com/photo.gne?id=" and add the photo id as argument, then the browser and Flickr will take care of the rest.

And that's it!

Some things that could be improved from here are:

  • Currently we don't take the screen size into account when deciding how many photos to show at the same time. We could add a bit of code in the resize function to recalculate this number dynamically, given the screen width (or height) as input.
  • Similarly, we don't take the screen size into account when deciding which resolution of photos to load and draw. To make the drawing a bit more dynamic, instead of always drawing medium size pictures we could sometimes (perhaps based on a random value, or the screen size) draw small or large images as well. This could be done either by downloading thumbnails and large images via the Flickr API, or we could change the draw function so that it draws the images with some scaling. There is a special prototype of drawImage that handles clipping and scaling, see for example http://www.w3schools.com/tags/canvas_drawimage.asp
  • To make sure that photos are not rendered too much on top of each other, we could spread them out by setting the initial positions more intelligently.
  • We could make the loading mechanism a bit more efficient so that instead of loading a new image for each disappeared image, a set of images could be loaded in advance so that new images are always instantly available.
  • To improve the drawing performance, the canvas and context references could be stored globally so that we don't have to retrieve them every time animate is called.
  • Currently the code that pulls random numbers does not care if the same number is pulled several times. Thus if we are unlucky we might see the same photo in several instances on the screen. To avoid this we could improve the random number pulling code so that it picks numbers more intelligently, for example by shuffling the indexes and then pulling numbers from the shuffled list.
  • We could add some effects, such as the 3D rotation effect used in Apple TV.

Finally, for reference I've written the complete code below (although without the improvements discussed above) to show how everything fits together. Remember to exchange the Flickr API key and user id if you want to make your own application referencing your own photos instead of mine.

If you want to see how it looks in motion, welcome to my site: http://mattiaserlo.com/flickrfeed/index.html


<!DOCTYPE HTML>
<html>
        <head>
                <meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
                <style type="text/css">
                        body {
                                margin-top: 0px;
                                margin-right: 0px;
                                margin-bottom: 0px;
                                margin-left: 0px;
                                padding: 0px;
                        }
                        canvas {
                                display: block;
                        }
                </style>
        </head>
        <script>
      
                var photoArray = null;
                var myMovingImages = new Array();
                var maxNumberOfPhotosOnScreen = 3;
              
                var shadowBorder = 14;
              
                function MovingImage(image) {
                        this.image = image;
                  this.flickrId = 0;
                
                        this.x = 0;
                        this.y = 0;
              
                        this.velocityX = 0;     // in pixels per second
                        this.velocityY = 0;     // in pixels per second
              
                        this.initialized = false;
                }
      
                function initializeMovingImage(movingImage, canvasWidth, canvasHeight) {
                        movingImage.x = Math.floor(Math.random() * (canvasWidth-movingImage.image.width));
                        movingImage.y = canvasHeight;
                        // Set velocity between -20 and -50
                        movingImage.velocityY = -20 - Math.floor(Math.random() * 30);
                        movingImage.initialized = true;
                }
              
                function animate(lastTime) {
                        var date = new Date();
                        var time = date.getTime(); // milliseconds
                        var timeDiff = time - lastTime;

                        var canvas = document.getElementById("myCanvas");
                        var context = canvas.getContext("2d");
                      
                        context.clearRect(0, 0, canvas.width, canvas.height);
                      
                        // Draw all moving images
                        for (var i=0; i<myMovingImages.length; i++) {
                                if (myMovingImages[i].image && myMovingImages[i].image.width > 0) {
                                        if (!myMovingImages[i].initialized) {
                                                initializeMovingImage(myMovingImages[i], canvas.width, canvas.height);
                                        }

                                        context.fillStyle = "rgba(0, 0, 0, 0.5)";
                                        context.fillRect(myMovingImages[i].x-shadowBorder, myMovingImages[i].y-shadowBorder, myMovingImages[i].image.width+(2*shadowBorder), myMovingImages[i].image.height+(2*shadowBorder));
                                        context.drawImage(myMovingImages[i].image, myMovingImages[i].x, myMovingImages[i].y);
                                }
                        }
                      
                        // Update positions of all images
                        for (var i=0; i<myMovingImages.length; i++) {
                                if (myMovingImages[i].image && myMovingImages[i].image.width > 0) {
                                        myMovingImages[i].x += myMovingImages[i].velocityX * (timeDiff/1000);
                                        myMovingImages[i].y += myMovingImages[i].velocityY * (timeDiff/1000);
                      
                                        // Has the image scrolled out from the screen?
                                        if (myMovingImages[i].y < -(myMovingImages[i].image.height + shadowBorder)) {
                                                // Load a new image and put it on the initial position
                                                myMovingImages[i].initialized = false;
                                                var myImage = new Image();
                                                randomPhotoNumber = getRandomPhotoNumber();
                                                myMovingImages[i].image = myImage;
                                                myImage.src = generateURL(photoArray[randomPhotoNumber].farm, photoArray[randomPhotoNumber].server, photoArray[randomPhotoNumber].id, photoArray[randomPhotoNumber].secret);                                 
                                                myMovingImages[i].flickrId = photoArray[randomPhotoNumber].id;
                                        }
                                }
                        }
                      
                        // Finally, request a new animation frame
                        requestAnimFrame(function() {
                                animate(time);
                        });
                }
      
                function pageLoaded() {
                        window.onresize = function() {
                                var canvas = document.getElementById("myCanvas");
                                if (canvas.width != (window.innerWidth)) {
                                        canvas.width = window.innerWidth;
                                }
                                if (canvas.height != (window.innerHeight)) {
                                        canvas.height = window.innerHeight;
                                }
                        }
                        window.onresize();
                      
                        window.requestAnimFrame = (function(callback){
                                return window.requestAnimationFrame ||
                                window.webkitRequestAnimationFrame ||
                                window.mozRequestAnimationFrame ||
                                window.oRequestAnimationFrame ||
                                window.msRequestAnimationFrame ||
                                function(callback){
                                 window.setTimeout(callback, 1000 / 60);
                 };
                        })();           // Betyder det att den exekveras en g蚣g automatiskt? Behs det?
                      
                        var date = new Date();
                        var time = date.getTime();
                        animate(time);                          // Behs detta?
                      
                        var canvas = document.getElementById("myCanvas");
                        canvas.onclick = function(event) {
                                var x = event.pageX - canvas.offsetLeft;
                                var y = event.pageY - canvas.offsetTop;
                                for (var i=myMovingImages.length-1; i>=0; i--) {
                                        if (myMovingImages[i].image) {
                                                if (x >= myMovingImages[i].x &&
                                                                y >= myMovingImages[i].y &&
                                                                x < myMovingImages[i].x + myMovingImages[i].image.width &&
                                                                y < myMovingImages[i].y + myMovingImages[i].image.height) {
                                                                        location.href = "http://www.flickr.com/photo.gne?id=" + myMovingImages[i].flickrId;
                                                                        break;
                                                }
                                        }
                                }
                        }
                }
              
                function getRandomPhotoNumber() {
                        return Math.floor(Math.random() * photoArray.length);
                }
              
                function jsonFlickrApi(object){
                        console.log("Hey, I got a reply from Flickr!");
                        if (object.stat == 'ok') {
                                photoArray = object.photos.photo;
                                console.log("Number of photos: " + photoArray.length);
                              
                                // Download the first few photos
                                for (var i=0; i<maxNumberOfPhotosOnScreen; i++) {
                                        var myImage = new Image();
                                        var randomPhotoNumber = getRandomPhotoNumber();
                                        myMovingImages[i] = new MovingImage(myImage);
                                        myMovingImages[i].flickrId = object.photos.photo[randomPhotoNumber].id;
                                        myImage.src = generateURL(object.photos.photo[randomPhotoNumber].farm,
                                                                                                object.photos.photo[randomPhotoNumber].server,
                                                                                                object.photos.photo[randomPhotoNumber].id,
                                                                                                object.photos.photo[randomPhotoNumber].secret);
                                }
                 }
                 else {
                         alert('Flickr search failed');
                        }
                }

                function generateURL(farmID, serverID, id, secret) {

                        // Thumbnail size, 100 pixels on longest side:
                        // return "http://farm" + farmID + ".static.flickr.com/" + serverID + "/" + id + "_" + secret + "_t.jpg";
                      
                        // medium size, 500 pixels on longest side:
                        return "http://farm" + farmID + ".static.flickr.com/" + serverID + "/" + id + "_" + secret + ".jpg";

                        // large size, 1024 pixels on longest side, not 100% supported...:
                        // return "http://farm" + farmID + ".static.flickr.com/" + serverID + "/" + id + "_" + secret + "_b.jpg";
                }
              
        </script>
        <body bgcolor="#000000" onload="pageLoaded()">
                <canvas id="myCanvas" width="600" height="400"></canvas>
        <script src='http://api.flickr.com/services/rest/?method=flickr.photos.search&api_key=9d7128ec941d553156644efb41a7dc7e&user_id=73384519@N00&content_type=1&per_page=1000&format=json'></script>
        </body>
</html>

Inga kommentarer: