DoneJS StealJS jQuery++ FuncUnit DocumentJS
3.14.1
5.0.0 4.3.0 2.3.35
  • About
  • Guides
  • API Docs
  • Community
  • Contributing
  • Bitovi
    • Bitovi.com
    • Blog
    • Design
    • Development
    • Training
    • Open Source
    • About
    • Contact Us
  • About
  • Guides
    • experiment
      • Chat Guide
      • TodoMVC Guide
      • ATM Guide
    • getting started
      • Setting Up CanJS
      • Reading the Docs (API Guide)
    • recipes
      • Credit Card Guide (Advanced)
      • Credit Card Guide (Simple)
      • CTA Bus Map (Medium)
      • File Navigator Guide (Advanced)
      • File Navigator Guide (Simple)
      • Playlist Editor (Advanced)
      • Signup and Login (Simple)
      • TodoMVC with StealJS
    • upgrade
      • Migrating to CanJS 3
      • Using Codemods
  • API Docs
  • Community
  • Contributing
  • GitHub
  • Twitter
  • Chat
  • Forum
  • News
Bitovi

Playlist Editor (Advanced)

  • Edit on GitHub

Learn how to use YouTube's API to search for videos and make a playlist. This makes authenticated requests with OAuth2. It uses jQuery++ for drag/drop events. It shows using custom attributes and custom events. This guide takes an hour to complete.

This recipe uses YouTube API Services and follows YouTube Terms of Service and Google Privacy Policy

The final widget looks like:

JS Bin on jsbin.com

To use the widget:

  1. Click Sign In to give access to the app to create playlists on your behalf.
  2. Type search terms in Search for videos and hit enter.
  3. Drag and drop those videos into the playlist area (Drag video here).
  4. Click Create Playlist.
  5. Enter a name in the popup.
  6. Navigate to your YouTube channel to verify the playlist was created.

Start this tutorial by cloning the following JS Bin:

JS Bin on jsbin.com

This JS Bin has initial prototype HTML and CSS which is useful for getting the application to look right.

The following sections are broken down into:

  • Problem — A description of what the section is trying to accomplish.
  • Things to know — Information about CanJS that is useful for solving the problem.
  • Solution — The solution to the problem.

The following video goes through this recipe:

Setup CanJS and Load Google API

The problem

In this section, we will:

  1. Load Google's JS API client, gapi, and initialize it to make requests on behalf of the registered "CanJS Playlist" app.
  2. Setup a basic CanJS application.
  3. Use the basic CanJS application to show when Google's JS API has finished loading.

What you need to know

  • The preferred way of loading Google's JS API is with an async script tag like:

    <script async defer src="https://apis.google.com/js/api.js"
      onload="this.onload=function(){}; googleScriptLoaded();"
      onreadystatechange="if (this.readyState === 'complete') this.onload();">
    </script>
    

    The async attribute allows other JS to execute while the api.js file is loading. Once complete, this will call a googleScriptLoaded function.

  • Once api.js is loaded, it adds the gapi object to the window. This is Google's JS API. It can be used to load other APIs that extend the gapi library.

    The following can be used to load the OAuth2 GAPI libraries:

    gapi.load('client:auth2', completeCallback);
    

    Once this functionality is loaded, we can tell gapi to make requests on behalf of a registered application. In this case, the following keys enable this client to make requests on behalf of the "CanJS Playlist" application:

    gapi.client.init({
        'apiKey': 'AIzaSyAbHbOuFtJRvTX731PQXGSTy59eh5rEiE0',
        'clientId': '764983721035-85cbj35n0kmkmrba10f4jtte8fhpst84.apps.googleusercontent.com',
        'discoveryDocs': [ 'https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest' ],
        'scope': 'https://www.googleapis.com/auth/youtube'
    }).then( completeCallback )
    

    To use your own key, you can follow the instructions here. This is not required to complete this guide.

  • Instead of callbacks, CanJS favors Promises to manage asynchronous behavior. A promise can be created like:

    var messagePromise = new Promise(function(resolve, reject){
      setTimeout(function(){
          resolve("Hello There")
      },1000)
    });
    

    resolve should be called once the promise has a value. reject should be called if something goes wrong (like an error). We say the messagePromise resolves with "Hello There" after one second.

    Anyone can listen to when messagePromise resolves with a value like:

    messagePromise.then(function(messageValue){
        messageValue //-> "Hello There"
    });
    

    CanJS can use promises in its can-stache templates. More on that below.

  • A basic CanJS application is a live-bound template (or view) rendered with a ViewModel.

  • A can-stache template is used to render data into a document fragment:

    var template = can.stache("<h1>{{message}}</h1>");
    var frag = template({message: "Hello World"});
    frag //-> <h1>Hello World</h1>
    
  • Load a template from a <script> tag with [can-stach.from can.stache.from] like:

    var template = can.stache.from(SCRIPT_ID);
    
  • Use {{#if(value)}} to do if/else branching in can-stache.

  • Promises are observable in can-stache. Given a promise somePromise, you can:

    • Check if the promise is loading like: {{#if(somePromise.isPending)}}.
    • Loop through the resolved value of the promise like: {{#each(somePromise.value)}}.
  • can.DefineMap can be used to define the behavior of observable objects like:

    var Type = can.DefineMap.extend({
        message: "string"
    });
    
  • Instances of these can.DefineMap types are often used as a ViewModel that controls the behavior of a can-stache template (or can-component).

    var PlaylistVM = can.DefineMap.extend({
        message: "string"
    });
    
    var messageVM = new PlaylistVM();
    var frag = template(messageVM)
    
  • can.DefineMap can specify a default value and a type:

    var PlaylistVM = can.DefineMap.extend({
      count: {value: 33}
    });
    new PlaylistVM().count //-> 33
    

The solution

Update the HTML tab to:

Note: Please use your own clientId if you use this code outside this guide.

<!DOCTYPE html>
<html>
<head>
<meta name="description" content="CanJS 3.7 - Playlist Editor Final">
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>JS Bin</title>
</head>
<body>

<script id="app-template" type="text/stache">
  {{#if(googleApiLoadedPromise.isPending)}}
    <div>Loading Google API ...</div>
  {{else}}
    <div>Loaded Google API</div>
  {{/if}}
</script>

<script src="https://code.jquery.com/jquery-2.2.4.js"></script>
<script src="https://unpkg.com/jquerypp@2/dist/global/jquerypp.js"></script>
<script src="https://unpkg.com/can@3/dist/global/can.all.js"></script>

<script>
var googleScriptLoaded;
var googleApiLoadedPromise = new Promise(function(resolve){
  googleScriptLoaded = function(){
    gapi.load('client:auth2', function() {
        gapi.client.init({
            'apiKey': 'AIzaSyBcnGGOryOnmjUC09T78VCFEqRQRgvPnAc',
            'clientId': '764983721035-85cbj35n0kmkmrba10f4jtte8fhpst84.apps.googleusercontent.com',
            'discoveryDocs': [ 'https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest' ],
            'scope': 'https://www.googleapis.com/auth/youtube'
        }).then(resolve);
    });
  }
})
</script>

<script async defer src="https://apis.google.com/js/api.js"
    onload="this.onload=function(){}; googleScriptLoaded();"
    onreadystatechange="if (this.readyState === 'complete') this.onload();">
</script>

</body>
</html>

Update the JavaScript tab to:

var PlaylistVM = can.DefineMap.extend("PlaylistVM", {
  googleApiLoadedPromise: {
    value: googleApiLoadedPromise
  }
});

var vm = new PlaylistVM();
var template = can.stache.from("app-template");
var frag = template(vm);
document.body.appendChild(frag);

Sign in and out

The problem

In this section, we will:

  1. Show a Sign In button that signs a person into their google account.
  2. Show a Sign Out button that signs a person out of their google account.
  3. Automatically know via google's API when the user signs in and out, and update the page accordingly.
  4. Show a welcome message with the user's given name.

What you need to know

  • Once the Google API has been fully loaded, information about the currently authenticated user can be found in the googleAuth object. This can be retrieved like:

    googleApiLoadedPromise.then(function(){
        var googleAuth = gapi.auth2.getAuthInstance()
    });
    

    With googleAuth, you can:

    • Know if someone is signed in: googleAuth.isSignedIn.get()
    • Sign someone in: googleAuth.signIn()
    • Sign someone out: googleAuth.signOut()
    • Listen to when someone's signedIn status changes: googleAuth.isSignedIn.listen(callback)
    • Get the user's name: googleAuth.currentUser.get().getBasicProfile().getGivenName()
  • ES5 Getter Syntax can be used to define a DefineMap property that changes when another property changes. For example, the following defines an signedOut property that is the opposite of the signedIn property:

    DefineMap.extend({
      signedIn: "boolean",
      get signedOut(){
        return !this.signedIn;
      }
    });
    
  • Use asynchronous getters to get data from asynchronous sources. For example:

    var PlaylistVM = can.DefineMap.extend({
      property: {
        get: function(lastSet, resolve) {
          apiLoadedPromise.then(function(){
              resolve( api.getValue() );
          })
        }
      }
    });
    
  • DefineMap's init method can be used to perform initialization behavior. For example, the following might initialize googleApiLoadedPromise:

    DefineMap.extend({
        init: function(){
            this.googleApiLoadedPromise = googleApiLoadedPromise;
        },
        googleApiLoadedPromise: "any"
    })
    
  • DefineMap's on on lets you listen on changes in a DefineMap. This can be used to change values when other values change. The following will increment nameChange everytime the name property changes:

    DefineMap.extend({
        init: function(){
            var self = this;
            self.on("name", function(){
                self.nameChange++;      
            })
        },
        name: "string",
        nameChange: "number"
    })
    

    NOTE: EventStreams provide a much better way of doing this. Check out can-define-stream-kefir.

  • Use on:EVENT to listen to an event on an element and call a method in can-stache. For example, the following calls sayHi() when the <div> is clicked.

    <div on:click="sayHi()"> … </div>
    

The solution

Update the template in the HTML tab to:

<script id="app-template" type="text/stache">
  {{#if(googleApiLoadedPromise.isPending)}}
    <div>Loading Google API ...</div>
  {{else}}
    {{#if(signedIn)}}
      Welcome {{givenName}}! <button on:click="googleAuth.signOut()">Sign Out</button>
    {{else}}
      <button on:click="googleAuth.signIn()">Sign In</button>
    {{/if}}
  {{/if}}
</script>

Update the JavaScript tab to:

var PlaylistVM = can.DefineMap.extend("PlaylistVM", {
  init: function() {
    var self = this;

    self.on("googleAuth", function(ev, googleAuth) {
      self.signedIn = googleAuth.isSignedIn.get();
      googleAuth.isSignedIn.listen(function(isSignedIn) {
        self.signedIn = isSignedIn;
      });
    });
  },
  googleApiLoadedPromise: {
    value: googleApiLoadedPromise
  },
  googleAuth: {
    get: function(lastSet, resolve) {
      this.googleApiLoadedPromise.then(function() {
        resolve(gapi.auth2.getAuthInstance());
      });
    }
  },
  signedIn: "boolean",
  get givenName() {
    return this.googleAuth &&
      this.googleAuth.currentUser.get().getBasicProfile().getGivenName();
  }
});

var vm = new PlaylistVM();
var template = can.stache.from("app-template");
var frag = template(vm);
document.body.appendChild(frag);

Search for videos

The problem

In this section, we will:

  1. Create a search <input> where a user can type a search query.
  2. When the user types more than 2 characters, get a list of video search results and display them to the user.

What you need to know

  • Use value:bind to setup a two-way binding in can-stache. For example, the following keeps searchQuery and the input's value in sync:

    <input value:bind="searchQuery"/>
    
  • Use gapi.client.youtube.search.list to search YouTube like:

    var googlePromise = gapi.client.youtube.search.list({
      q: "dogs",
      part: 'snippet',
      type: 'video'
    }).then(function(response){
      response //-> {
      // result: {
      //   items: [
      //     {
      //       id: {videoId: "ajsadfa"},
      //       snippet: {
      //         title: "dogs",
      //         thumbnails: {default: {url: "https://example.com/dog.png"}}
      //       }
      //     }
      //   ]
      // }     
      //}
    });
    
  • To convert a googlePromise to a native Promise use:

    new Promise(function(resolve, reject){
      googlePromise.then(resolve, reject);      
    })
    

The solution

Update the template in the HTML tab to:

<script id="app-template" type="text/stache">
  {{#if(googleApiLoadedPromise.isPending)}}
    <div>Loading Google API ...</div>
  {{else}}
    {{#if(signedIn)}}
      Welcome {{givenName}}! <button on:click="googleAuth.signOut()">Sign Out</button>
    {{else}}
      <button on:click="googleAuth.signIn()">Sign In</button>
    {{/if}}

    <div>
      <input value:bind="searchQuery" placeholder="Search for videos"/>
    </div>

    {{#if(searchResultsPromise.isPending)}}
      <div class="loading">Loading videos...</div>
    {{/if}}

    {{#if(searchResultsPromise.isResolved)}}
      <ul class='source'>
      {{#each(searchResultsPromise.value)}}
        <li>
          <a href="https://www.youtube.com/watch?v={{./id.videoId}}" target='_blank'>
            <img src="{{./snippet.thumbnails.default.url}}" width="50px"/>
          </a>
          {{./snippet.title}}
        </li>
      {{/each}}
      </ul>

    {{/if}}

  {{/if}}
</script>

Update the JavaScript tab to:

var PlaylistVM = can.DefineMap.extend("PlaylistVM", {
  init: function() {
    var self = this;

    self.on("googleAuth", function(ev, googleAuth) {
      self.signedIn = googleAuth.isSignedIn.get();
      googleAuth.isSignedIn.listen(function(isSignedIn) {
        self.signedIn = isSignedIn;
      });
    });
  },
  googleApiLoadedPromise: {
    value: googleApiLoadedPromise
  },
  googleAuth: {
    get: function(lastSet, resolve) {
      this.googleApiLoadedPromise.then(function() {
        resolve(gapi.auth2.getAuthInstance());
      });
    }
  },
  signedIn: "boolean",
  get givenName() {
    return this.googleAuth &&
      this.googleAuth.currentUser.get().getBasicProfile().getGivenName();
  },
  searchQuery: {
    type: "string",
    value: ""
  },
  get searchResultsPromise() {
    if (this.searchQuery.length > 2) {

      var results = gapi.client.youtube.search.list({
          q: this.searchQuery,
          part: 'snippet',
          type: 'video'
        }).then(function(response){
        console.log(response.result.items);
        return response.result.items;
      });
      return new Promise(function(resolve, reject){
        results.then(resolve, reject);
      });
    }
  }
});

var vm = new PlaylistVM();
var template = can.stache.from("app-template");
var frag = template(vm);
document.body.appendChild(frag);

Drag videos

The problem

In this section, we will:

  1. Let a user drag around a cloned representation of the searched videos.

What you need to know

  • The jQuery++ library (which is already included on the page), supports the following drag events:

    • dragdown - the mouse cursor is pressed down
    • draginit - the drag motion is started
    • dragmove - the drag is moved
    • dragend - the drag has ended
    • dragover - the drag is over a drop point
    • dragout - the drag moved out of a drop point

    You can bind on them manually with jQuery like:

    $(element).on('draginit', function(ev, drag) {
      drag.limit($(this).parent());
      drag.horizontal();
    });
    

    Notice that drag is the 2nd argument to the event. You can listen to drag events in can-stache and pass the drag argument to a function like:

    on:draginit="startedDrag(%arguments[1])"
    
  • The drag.ghost() method copies the elements being dragged and drags that instead. The .ghost() method returns the copied elements wrapped with jQuery. Add the ghost className to style the ghost elements, like:

    drag.ghost().addClass("ghost");
    
  • To add a method to a DefineMap, just add a function to one of the properties passed to extend:

    PlaylistVM = DefineMap.extend({
      startedDrag: function(){
        console.log("you did it!")
      }
    });
    new PlaylistVM().startedDrag();
    
  • Certain browsers have default drag behaviors for certain elements like <a> and <img> that can be prevented with the draggable="false" attribute.

The solution

Update the template in the HTML tab to:

<script id="app-template" type="text/stache">
  {{#if(googleApiLoadedPromise.isPending)}}
    <div>Loading Google API ...</div>
  {{else}}
    {{#if(signedIn)}}
      Welcome {{givenName}}! <button on:click="googleAuth.signOut()">Sign Out</button>
    {{else}}
      <button on:click="googleAuth.signIn()">Sign In</button>
    {{/if}}

    <div>
      <input value:bind="searchQuery" placeholder="Search for videos"/>
    </div>

    {{#if(searchResultsPromise.isPending)}}
      <div class="loading">Loading videos...</div>
    {{/if}}

    {{#if(searchResultsPromise.isResolved)}}
      <ul class='source'>
      {{#each(searchResultsPromise.value)}}
        <li on:draginit="videoDrag(%arguments[1])">
          <a draggable="false" href="https://www.youtube.com/watch?v={{./id.videoId}}" target='_blank'>
            <img draggable="false" src="{{./snippet.thumbnails.default.url}}" width="50px"/>
          </a>
          {{./snippet.title}}
        </li>
      {{/each}}
      </ul>

    {{/if}}

  {{/if}}
</script>

Update the JavaScript tab to:

var PlaylistVM = can.DefineMap.extend("PlaylistVM", {
  init: function() {
    var self = this;

    self.on("googleAuth", function(ev, googleAuth) {
      self.signedIn = googleAuth.isSignedIn.get();
      googleAuth.isSignedIn.listen(function(isSignedIn) {
        self.signedIn = isSignedIn;
      });
    });
  },
  googleApiLoadedPromise: {
    value: googleApiLoadedPromise
  },
  googleAuth: {
    get: function(lastSet, resolve) {
      this.googleApiLoadedPromise.then(function() {
        resolve(gapi.auth2.getAuthInstance());
      });
    }
  },
  signedIn: "boolean",
  get givenName() {
    return this.googleAuth &&
      this.googleAuth.currentUser.get().getBasicProfile().getGivenName();
  },
  searchQuery: {
    type: "string",
    value: ""
  },
  get searchResultsPromise() {
    if (this.searchQuery.length > 2) {

      var results = gapi.client.youtube.search.list({
          q: this.searchQuery,
          part: 'snippet',
          type: 'video'
        }).then(function(response){
        console.log(response.result.items);
        return response.result.items;
      });
      return new Promise(function(resolve, reject){
        results.then(resolve, reject);
      });
    }
  },
  videoDrag: function(drag) {
    drag.ghost().addClass("ghost");
  }
});

var vm = new PlaylistVM();
var template = can.stache.from("app-template");
var frag = template(vm);
document.body.appendChild(frag);

Drop videos

The problem

In this section, we will:

  1. Allow a user to drop videos on a playlist element.
  2. When the user drags a video over the playlist element, a placeholder of the video will appear in the first position of the playlist.
  3. If the video is dragged out of the playlist element, the placeholder will be removed.
  4. If the video is dropped on the playlist element, it will be added to the playlist's list of videos.
  5. Prepare for inserting the placeholder or video in any position in the list.

What you need to know

  • The PlaylistVM should maintain a list of playlist videos (playlistVideos) and the placeholder video (dropPlaceholderData) separately. It can combine these two values into a single value (videosWithDropPlaceholder) of the videos to display to the user. On a high-level, this might look like:

    PlaylistVM = DefineMap.extend({
        ...
        // {video: video, index: 0}
        dropPlaceholderData: "any",
        // [video1, video2, ...]
        playlistVideos: {
           Type: ["any"],
           Value: can.DefineList
        },
        get videosWithDropPlaceholder() {
           var copyOfPlaylistVideos = this.placeListVideos.map(...);
    
           // insert this.dropPlaceholderData into copyOfPlaylistVideos
    
           return copyOfPlaylistVideos;
        }
    })
    
  • The methods that add a placeholder (addDropPlaceholder) and add video to the playlist (addVideo) should take an index like:

    addDropPlaceholder: function(index, video) { ... }
    addVideo: function(index, video) { ... }
    

    These functions will be called with 0 as the index for this section.

  • jQuery++ supports the following drop events:

    • dropinit - the drag motion is started, drop positions are calculated
    • dropover - a drag moves over a drop element, called once as the drop is dragged over the element
    • dropout - a drag moves out of the drop element
    • dropmove - a drag is moved over a drop element, called repeatedly as the element is moved
    • dropon - a drag is released over a drop element
    • dropend - the drag motion has completed

    You can bind on them manually with jQuery like:

    $(element).on('dropon', function(ev, drop, drag) {...});
    

    Notice that drop is now the 2nd argument to the event. You can listen to drop events in can-stache, and pass the drag argument to a function, like:

    on:dropon="addVideo(%arguments[2])"
    
  • You will need to associate the drag objects with the video being dragged so you know which video is being dropped when a drop happens. The following utilities help create that association:

    • The drag.element is the jQuery-wrapped element that the user initiated the drag motion upon.

    • CanJS's {{data DATANAME}} helper lets you associate custom data with an element. The following saves the current context of the <li> as "dragData" on the <li>:

      <li ($draginit)="videoDrag(%arguments[1])"
                {{data "dragData"}}>
      
    • can.data.get can access this data like:

      can.data.get.call(drag.element[0], "dragData");
      

The solution

Update the template in the HTML tab to:

<script id="app-template" type="text/stache">
  {{#if(googleApiLoadedPromise.isPending)}}
    <div>Loading Google API ...</div>
  {{else}}
    {{#if(signedIn)}}
      Welcome {{givenName}}! <button on:click="googleAuth.signOut()">Sign Out</button>
    {{else}}
      <button on:click="googleAuth.signIn()">Sign In</button>
    {{/if}}

    <div>
      <input value:bind="searchQuery" placeholder="Search for videos"/>
    </div>

    {{#if(searchResultsPromise.isPending)}}
      <div class="loading">Loading videos...</div>
    {{/if}}

    {{#if(searchResultsPromise.isResolved)}}
      <ul class='source'>
      {{#each(searchResultsPromise.value)}}
        <li on:draginit="videoDrag(%arguments[1])"
            {{data "dragData"}}>
          <a draggable="false" href="https://www.youtube.com/watch?v={{./id.videoId}}" target='_blank'>
            <img draggable="false" src="{{./snippet.thumbnails.default.url}}" width="50px"/>
          </a>
          {{./snippet.title}}
        </li>
      {{/each}}
      </ul>

      {{#if(searchResultsPromise.value.length)}}
        <div class='new-playlist'>
          <ul
            on:dropover="addDropPlaceholder(0,getDragData(%arguments[2]))"
            on:dropout="clearDropPlaceholder()"
            on:dropon="addVideo(0,getDragData(%arguments[2]))">

            {{#each(videosWithDropPlaceholder)}}
              <li class="{{#if(isPlaceholder)}}placeholder{{/if}}">
                <a href="https://www.youtube.com/watch?v={{./video.id.videoId}}" target='_blank'>
                  <img src="{{./video.snippet.thumbnails.default.url}}" width="50px"/>
                </a>

                {{./video.snippet.title}}
              </li>
            {{else}}
              <div class="content">Drag video here</div>
            {{/each}}
          </ul>
        </div>
      {{/if}}

    {{/if}}

  {{/if}}
</script>

Update the JavaScript tab to:

var PlaylistVM = can.DefineMap.extend("PlaylistVM", {
  init: function() {
    var self = this;

    self.on("googleAuth", function(ev, googleAuth) {
      self.signedIn = googleAuth.isSignedIn.get();
      googleAuth.isSignedIn.listen(function(isSignedIn) {
        self.signedIn = isSignedIn;
      });
    });
  },
  googleApiLoadedPromise: {
    value: googleApiLoadedPromise
  },
  googleAuth: {
    get: function(lastSet, resolve) {
      this.googleApiLoadedPromise.then(function() {
        resolve(gapi.auth2.getAuthInstance());
      });
    }
  },
  signedIn: "boolean",
  get givenName() {
    return this.googleAuth &&
      this.googleAuth.currentUser.get().getBasicProfile().getGivenName();
  },
  searchQuery: {
    type: "string",
    value: ""
  },
  get searchResultsPromise() {
    if (this.searchQuery.length > 2) {

      var results = gapi.client.youtube.search.list({
          q: this.searchQuery,
          part: 'snippet',
          type: 'video'
        }).then(function(response){
        console.log(response.result.items);
        return response.result.items;
      });
      return new Promise(function(resolve, reject){
        results.then(resolve, reject);
      });
    }
  },
  videoDrag: function(drag) {
    drag.ghost().addClass("ghost");
  },
  getDragData: function(drag){
    return can.data.get.call(drag.element[0], "dragData");
  },
  dropPlaceholderData: "any",
  playlistVideos: {
    Type: ["any"],
    Value: can.DefineList
  },
  addDropPlaceholder: function(index, video) {
    this.dropPlaceholderData = {
      index: index,
      video: video
    };
  },
  clearDropPlaceholder: function() {
    this.dropPlaceholderData = null;
  },
  addVideo: function(index, video) {
    this.dropPlaceholderData = null;
    if (index >= this.playlistVideos.length) {
      this.playlistVideos.push(video);
    } else {
      this.playlistVideos.splice(index, 0, video);
    }
  },
  get videosWithDropPlaceholder() {
    var copy = this.playlistVideos.map(function(video) {
      return {
        video: video,
        isPlaceholder: false
      };
    });
    if (this.dropPlaceholderData) {
      copy.splice(this.dropPlaceholderData.index, 0, {
        video: this.dropPlaceholderData.video,
        isPlaceholder: true
      });
    }
    return copy;
  }
});

var vm = new PlaylistVM();
var template = can.stache.from("app-template");
var frag = template(vm);
document.body.appendChild(frag);

Drop videos in order

The problem

In this section, we will:

  1. Allow a user to drop videos in order they prefer.

What you need to know

  • ViewModels are best left knowing very little about the DOM. This makes them more easily unit-testable. To make this interaction, we need to know where the mouse is in relation to the playlist's videos. This requires a lot of DOM interaction and is best done outside the ViewModel.

    Specifically, we'd like to translate the dropmove and dropon events into other events that let people know where the dropmove and dropon events are happening in relationship to the drop target's child elements.

    Our goal is to:

    • Translate dropmove into sortableplaceholderat events that dispatch events with the index where a placeholder should be inserted and the dragData of what is being dragged.

    • Translate dropon into sortableinsertat events that dispatch events with the index where the dragged item should be inserted and the dragData of what is being dragged.

  • can.Control is useful for listening to events on an element in a memory-safe way. Use extend to define a can.Control type, as follows:

    var Sortable = can.Control.extend({
        ... event handlers and methods ...
    });
    

    To listen to events (like dragmove) on a control, use an event handler with {element} EVENTNAME, as follows:

    var Sortable = can.Control.extend({
      "{element} dropmove": function(el, ev, drop, drag) {
        // do stuff on dropmove like call method:
        this.method();
      },
      method: function(){
        // do something
      }
    });
    

    Use new Control(element) to create a control on an element. The following would setup the dropmove binding on el:

    new Sortable(el);
    
  • can.view.callbacks.attr can listen to when a custom attribute is found in a can-stache template like:

    can.view.callbacks.attr("sortable", function(el, attrData) {});
    

    This can be useful to create controls on an element with that attribute. For example, if a user has:

    <ul sortable>...</ul>
    

    The following will create the Sortable control on that <ul>:

    can.view.callbacks.attr("sortable", function(el) {
      new Sortable(el);
    });
    
  • Use $.trigger to fire custom events with jQuery:

    $(element).trigger({
      type: "sortableinsertat",
      index: 0,
      dragData: dragData
    });
    
  • Access the event object in a on:event with %event, like:

    on:sortableinsertat="addVideo(%event.index, %event.dragData)"
    
  • Mouse events like click and dropmove and dropon have a pageY property that tells how many pixels down the page a user's mouse is.

  • jQuery.offset returns an element's position on the page.

  • jQuery.height returns an element's height.

  • If the mouse position is below an element's center, the placeholder should be inserted after the element. If the mouse position is above an element's center, it should be inserted before the element.

The solution

Update the template in the HTML tab to:

<script id="app-template" type="text/stache">
  {{#if(googleApiLoadedPromise.isPending)}}
    <div>Loading Google API ...</div>
  {{else}}
    {{#if(signedIn)}}
      Welcome {{givenName}}! <button on:click="googleAuth.signOut()">Sign Out</button>
    {{else}}
      <button on:click="googleAuth.signIn()">Sign In</button>
    {{/if}}

    <div>
      <input value:bind="searchQuery" placeholder="Search for videos"/>
    </div>

    {{#if(searchResultsPromise.isPending)}}
      <div class="loading">Loading videos...</div>
    {{/if}}

    {{#if(searchResultsPromise.isResolved)}}
      <ul class='source'>
      {{#each(searchResultsPromise.value)}}
        <li on:draginit="videoDrag(%arguments[1])"
            {{data "dragData"}}>
          <a draggable="false" href="https://www.youtube.com/watch?v={{./id.videoId}}" target='_blank'>
            <img draggable="false" src="{{./snippet.thumbnails.default.url}}" width="50px"/>
          </a>
          {{./snippet.title}}
        </li>
      {{/each}}
      </ul>

      {{#if(searchResultsPromise.value.length)}}
        <div class='new-playlist'>
          <ul sortable
            on:sortableplaceholderat="addDropPlaceholder(%event.index, %event.dragData)"
            on:sortableinsertat="addVideo(%event.index, %event.dragData)"
            on:dropout="clearDropPlaceholder()">

            {{#each(videosWithDropPlaceholder)}}
              <li class="{{#if(isPlaceholder)}}placeholder{{/if}}">
                <a href="https://www.youtube.com/watch?v={{./video.id.videoId}}" target='_blank'>
                  <img src="{{./video.snippet.thumbnails.default.url}}" width="50px"/>
                </a>

                {{./video.snippet.title}}
              </li>
            {{else}}
              <div class="content">Drag video here</div>
            {{/each}}
          </ul>
        </div>
      {{/if}}

    {{/if}}

  {{/if}}
</script>

Update the JavaScript tab to:

var PlaylistVM = can.DefineMap.extend("PlaylistVM", {
  init: function() {
    var self = this;

    self.on("googleAuth", function(ev, googleAuth) {
      self.signedIn = googleAuth.isSignedIn.get();
      googleAuth.isSignedIn.listen(function(isSignedIn) {
        self.signedIn = isSignedIn;
      });
    });
  },
  googleApiLoadedPromise: {
    value: googleApiLoadedPromise
  },
  googleAuth: {
    get: function(lastSet, resolve) {
      this.googleApiLoadedPromise.then(function() {
        resolve(gapi.auth2.getAuthInstance());
      });
    }
  },
  signedIn: "boolean",
  get givenName() {
    return this.googleAuth &&
      this.googleAuth.currentUser.get().getBasicProfile().getGivenName();
  },
  searchQuery: {
    type: "string",
    value: ""
  },
  get searchResultsPromise() {
    if (this.searchQuery.length > 2) {

      var results = gapi.client.youtube.search.list({
          q: this.searchQuery,
          part: 'snippet',
          type: 'video'
        }).then(function(response){
        console.log(response.result.items);
        return response.result.items;
      });
      return new Promise(function(resolve, reject){
        results.then(resolve, reject);
      });
    }
  },
  videoDrag: function(drag) {
    drag.ghost().addClass("ghost");
  },
  getDragData: function(drag){
    return can.data.get.call(drag.element[0], "dragData");
  },
  dropPlaceholderData: "any",
  playlistVideos: {
    Type: ["any"],
    Value: can.DefineList
  },
  addDropPlaceholder: function(index, video) {
    this.dropPlaceholderData = {
      index: index,
      video: video
    };
  },
  clearDropPlaceholder: function() {
    this.dropPlaceholderData = null;
  },
  addVideo: function(index, video) {
    this.dropPlaceholderData = null;
    if (index >= this.playlistVideos.length) {
      this.playlistVideos.push(video);
    } else {
      this.playlistVideos.splice(index, 0, video);
    }
  },
  get videosWithDropPlaceholder() {
    var copy = this.playlistVideos.map(function(video) {
      return {
        video: video,
        isPlaceholder: false
      };
    });
    if (this.dropPlaceholderData) {
      copy.splice(this.dropPlaceholderData.index, 0, {
        video: this.dropPlaceholderData.video,
        isPlaceholder: true
      });
    }
    return copy;
  }
});

var Sortable = can.Control.extend({
  "{element} dropmove": function(el, ev, drop, drag) {
    this.fireEventForDropPosition(ev, drop, drag, "sortableplaceholderat");
  },
  "{element} dropon": function(el, ev, drop, drag) {
    this.fireEventForDropPosition(ev, drop, drag, "sortableinsertat");
  },
  fireEventForDropPosition: function(ev, drop, drag, eventName) {
    var dragData = can.data.get.call(drag.element[0], "dragData");

    var sortables = $(this.element).children();

    for (var i = 0; i < sortables.length; i++) {
      //check if cursor is past 1/2 way
      var sortable = $(sortables[i]);
      if (ev.pageY < Math.floor(sortable.offset().top + sortable.height() / 2)) {
        // index at which it needs to be inserted before
        $(this.element).trigger({
          type: eventName,
          index: i,
          dragData: dragData
        });
        return;
      }
    }
    if (!sortables.length) {
      $(this.element).trigger({
        type: eventName,
        index: 0,
        dragData: dragData
      });
    } else {
      $(this.element).trigger({
        type: eventName,
        index: i,
        dragData: dragData
      });
    }
  }
});

can.view.callbacks.attr("sortable", function(el) {
  new Sortable(el);
});

var vm = new PlaylistVM();
var template = can.stache.from("app-template");
var frag = template(vm);
document.body.appendChild(frag);

Revert videos not dropped on playlist

The problem

In this section, we will:

  1. Revert videos not dropped on the playlist. If a user drags a video, but does not drop it on the playlist, show an animation returning the video to its original place.

What you need to know

  • If you call drag.revert(), the drag element will animate back to its original position.

The solution

Update the JavaScript tab to:

var PlaylistVM = can.DefineMap.extend("PlaylistVM", {
  init: function() {
    var self = this;

    self.on("googleAuth", function(ev, googleAuth) {
      self.signedIn = googleAuth.isSignedIn.get();
      googleAuth.isSignedIn.listen(function(isSignedIn) {
        self.signedIn = isSignedIn;
      });
    });
  },
  googleApiLoadedPromise: {
    value: googleApiLoadedPromise
  },
  googleAuth: {
    get: function(lastSet, resolve) {
      this.googleApiLoadedPromise.then(function() {
        resolve(gapi.auth2.getAuthInstance());
      });
    }
  },
  signedIn: "boolean",
  get givenName() {
    return this.googleAuth &&
      this.googleAuth.currentUser.get().getBasicProfile().getGivenName();
  },
  searchQuery: {
    type: "string",
    value: ""
  },
  get searchResultsPromise() {
    if (this.searchQuery.length > 2) {

      var results = gapi.client.youtube.search.list({
          q: this.searchQuery,
          part: 'snippet',
          type: 'video'
        }).then(function(response){
        console.log(response.result.items);
        return response.result.items;
      });
      return new Promise(function(resolve, reject){
        results.then(resolve, reject);
      });
    }
  },
  videoDrag: function(drag) {
    drag.ghost().addClass("ghost");
  },
  getDragData: function(drag){
    return can.data.get.call(drag.element[0], "dragData");
  },
  dropPlaceholderData: "any",
  playlistVideos: {
    Type: ["any"],
    Value: can.DefineList
  },
  addDropPlaceholder: function(index, video) {
    this.dropPlaceholderData = {
      index: index,
      video: video
    };
  },
  clearDropPlaceholder: function() {
    this.dropPlaceholderData = null;
  },
  addVideo: function(index, video) {
    this.dropPlaceholderData = null;
    if (index >= this.playlistVideos.length) {
      this.playlistVideos.push(video);
    } else {
      this.playlistVideos.splice(index, 0, video);
    }
  },
  get videosWithDropPlaceholder() {
    var copy = this.playlistVideos.map(function(video) {
      return {
        video: video,
        isPlaceholder: false
      };
    });
    if (this.dropPlaceholderData) {
      copy.splice(this.dropPlaceholderData.index, 0, {
        video: this.dropPlaceholderData.video,
        isPlaceholder: true
      });
    }
    return copy;
  }
});

var Sortable = can.Control.extend({
  "{element} dropinit": function() {
    this.droppedOn = false;
  },
  "{element} dropmove": function(el, ev, drop, drag) {
    this.fireEventForDropPosition(ev, drop, drag, "sortableplaceholderat");
  },
  "{element} dropon": function(el, ev, drop, drag) {
    this.droppedOn = true;
    this.fireEventForDropPosition(ev, drop, drag, "sortableinsertat");
  },
  "{element} dropend": function(el, ev, drop, drag) {
    if (!this.droppedOn) {
      drag.revert();
    }
  },
  fireEventForDropPosition: function(ev, drop, drag, eventName) {
    var dragData = can.data.get.call(drag.element[0], "dragData");

    var sortables = $(this.element).children();

    for (var i = 0; i < sortables.length; i++) {
      //check if cursor is past 1/2 way
      var sortable = $(sortables[i]);
      if (ev.pageY < Math.floor(sortable.offset().top + sortable.height() / 2)) {
        // index at which it needs to be inserted before
        $(this.element).trigger({
          type: eventName,
          index: i,
          dragData: dragData
        });
        return;
      }
    }
    if (!sortables.length) {
      $(this.element).trigger({
        type: eventName,
        index: 0,
        dragData: dragData
      });
    } else {
      $(this.element).trigger({
        type: eventName,
        index: i,
        dragData: dragData
      });
    }
  }
});

can.view.callbacks.attr("sortable", function(el) {
  new Sortable(el);
});

var vm = new PlaylistVM();
var template = can.stache.from("app-template");
var frag = template(vm);
document.body.appendChild(frag);

Create a playlist

The problem

In this section, we will:

  1. Add a Create Playlist button that prompts the user for the playlist name.
  2. After the user enters the name, the playlist is saved.
  3. Disable the button while the playlist is being created.
  4. Empty the playlist after it is created.

What you need to know

  • Use https://developer.mozilla.org/en-US/docs/Web/API/Window/prompt to prompt a user for a simple string value.

  • YouTube only allows you to create a playlist and add items to it.

    To create a playlist:

    var lastPromise = gapi.client.youtube.playlists.insert({
      part: 'snippet,status',
      resource: {
        snippet: {
          title: PLAYLIST_NAME,
          description: 'A private playlist created with the YouTube API and CanJS'
        },
        status: {
          privacyStatus: 'private'
        }
      }
    }).then(function(response) {
      response //->{} response.result.id
      // result: {
      //   id: "lk2asf8o"
      // }
    });
    

    To insert something onto the end of it:

    gapi.client.youtube.playlistItems.insert({
      part: 'snippet',
      resource: {
        snippet: {
          playlistId: playlistId,
          resourceId: video.id
        }
      }
    }).then();
    
  • These requests must run in order. You can make one request run after another, like:

    lastPromise = makeRequest(1);
    
    lastPromise = lastPromise.then(function(){
      return makeRequest(2);    
    })
    
    lastPromise = lastPromise.then(function(){
      return makeRequest(3);    
    })
    

    When a callback to .then returns a promise, .then returns a promise that resolves after the inner promise has been resolved.

  • Use {$disabled} to make an input disabled, like:

    <button disabled:from="createPlaylistPromise.isPending()">...
    
  • When the promise has finished, set the playlistVideos property back to an empty list. This can be done by listening to createPlaylistPromise:

    this.on("createPlaylistPromise", function(ev, promise) { ... })
    

The solution

Update the template in the HTML tab to:

<script id="app-template" type="text/stache">
  {{#if(googleApiLoadedPromise.isPending)}}
    <div>Loading Google API ...</div>
  {{else}}
    {{#if(signedIn)}}
      Welcome {{givenName}}! <button on:click="googleAuth.signOut()">Sign Out</button>
    {{else}}
      <button on:click="googleAuth.signIn()">Sign In</button>
    {{/if}}

    <div>
      <input value:bind="searchQuery" placeholder="Search for videos"/>
    </div>

    {{#if(searchResultsPromise.isPending)}}
      <div class="loading">Loading videos...</div>
    {{/if}}

    {{#if(searchResultsPromise.isResolved)}}
      <ul class='source'>
      {{#each(searchResultsPromise.value)}}
        <li on:draginit="videoDrag(%arguments[1])"
            {{data "dragData"}}>
          <a draggable="false" href="https://www.youtube.com/watch?v={{./id.videoId}}" target='_blank'>
            <img draggable="false" src="{{./snippet.thumbnails.default.url}}" width="50px"/>
          </a>
          {{./snippet.title}}
        </li>
      {{/each}}
      </ul>

      {{#if(searchResultsPromise.value.length)}}
        <div class='new-playlist'>
          <ul sortable
            on:sortableplaceholderat="addDropPlaceholder(%event.index, %event.dragData)"
            on:sortableinsertat="addVideo(%event.index, %event.dragData)"
            on:dropout="clearDropPlaceholder()">

            {{#each(videosWithDropPlaceholder)}}
              <li class="{{#if(isPlaceholder)}}placeholder{{/if}}">
                <a href="https://www.youtube.com/watch?v={{./video.id.videoId}}" target='_blank'>
                  <img src="{{./video.snippet.thumbnails.default.url}}" width="50px"/>
                </a>

                {{./video.snippet.title}}
              </li>
            {{else}}
              <div class="content">Drag video here</div>
            {{/each}}
          </ul>
          {{#if(playlistVideos.length)}}
            <button on:click="createPlaylist()"
              disabled:from="createPlaylistPromise.isPending()">
                Create Playlist
            </button>
          {{/if}}
        </div>
      {{/if}}

    {{/if}}

  {{/if}}
</script>

Update the JavaScript tab to:

var PlaylistVM = can.DefineMap.extend("PlaylistVM", {
  init: function() {
    var self = this;

    self.on("googleAuth", function(ev, googleAuth) {
      self.signedIn = googleAuth.isSignedIn.get();
      googleAuth.isSignedIn.listen(function(isSignedIn) {
        self.signedIn = isSignedIn;
      });
    });

    self.on("createPlaylistPromise", function(ev, promise) {
      if (promise) {
        promise.then(function() {
          self.playlistVideos = [];
          self.createPlaylistPromise = null;
        });
      }
    });
  },
  googleApiLoadedPromise: {
    value: googleApiLoadedPromise
  },
  googleAuth: {
    get: function(lastSet, resolve) {
      this.googleApiLoadedPromise.then(function() {
        resolve(gapi.auth2.getAuthInstance());
      });
    }
  },
  signedIn: "boolean",
  get givenName() {
    return this.googleAuth &&
      this.googleAuth.currentUser.get().getBasicProfile().getGivenName();
  },
  searchQuery: {
    type: "string",
    value: ""
  },
  get searchResultsPromise() {
    if (this.searchQuery.length > 2) {

      var results = gapi.client.youtube.search.list({
          q: this.searchQuery,
          part: 'snippet',
          type: 'video'
        }).then(function(response){
        console.log(response.result.items);
        return response.result.items;
      });
      return new Promise(function(resolve, reject){
        results.then(resolve, reject);
      });
    }
  },
  videoDrag: function(drag) {
    drag.ghost().addClass("ghost");
  },
  getDragData: function(drag){
    return can.data.get.call(drag.element[0], "dragData");
  },
  dropPlaceholderData: "any",
  playlistVideos: {
    Type: ["any"],
    Value: can.DefineList
  },
  addDropPlaceholder: function(index, video) {
    this.dropPlaceholderData = {
      index: index,
      video: video
    };
  },
  clearDropPlaceholder: function() {
    this.dropPlaceholderData = null;
  },
  addVideo: function(index, video) {
    this.dropPlaceholderData = null;
    if (index >= this.playlistVideos.length) {
      this.playlistVideos.push(video);
    } else {
      this.playlistVideos.splice(index, 0, video);
    }
  },
  get videosWithDropPlaceholder() {
    var copy = this.playlistVideos.map(function(video) {
      return {
        video: video,
        isPlaceholder: false
      };
    });
    if (this.dropPlaceholderData) {
      copy.splice(this.dropPlaceholderData.index, 0, {
        video: this.dropPlaceholderData.video,
        isPlaceholder: true
      });
    }
    return copy;
  },
  createPlaylistPromise: "any",
  createPlaylist: function() {
    var playlistName = prompt("What would you like to name your playlist?");
    if (!playlistName) {
      return;
    }

    var playlistId;
    var lastPromise = gapi.client.youtube.playlists.insert({
      part: 'snippet,status',
      resource: {
        snippet: {
          title: playlistName,
          description: 'A private playlist created with the YouTube API and CanJS'
        },
        status: {
          privacyStatus: 'private'
        }
      }
    }).then(function(response) {
      playlistId = response.result.id;
    });


    var playlistVideos = this.playlistVideos.slice();
    playlistVideos.forEach(function(video) {
      lastPromise = lastPromise.then(function() {
        return gapi.client.youtube.playlistItems.insert({
          part: 'snippet',
          resource: {
            snippet: {
              playlistId: playlistId,
              resourceId: video.id
            }
          }
        }).then();
      });
    });

    this.createPlaylistPromise = new Promise(function(resolve, reject) {
      lastPromise.then(resolve, reject);
    });
  }
});

var Sortable = can.Control.extend({
  "{element} dropinit": function() {
    this.droppedOn = false;
  },
  "{element} dropmove": function(el, ev, drop, drag) {
    this.fireEventForDropPosition(ev, drop, drag, "sortableplaceholderat");
  },
  "{element} dropon": function(el, ev, drop, drag) {
    this.droppedOn = true;
    this.fireEventForDropPosition(ev, drop, drag, "sortableinsertat");
  },
  "{element} dropend": function(el, ev, drop, drag) {
    if (!this.droppedOn) {
      drag.revert();
    }
  },
  fireEventForDropPosition: function(ev, drop, drag, eventName) {
    var dragData = can.data.get.call(drag.element[0], "dragData");

    var sortables = $(this.element).children();

    for (var i = 0; i < sortables.length; i++) {
      //check if cursor is past 1/2 way
      var sortable = $(sortables[i]);
      if (ev.pageY < Math.floor(sortable.offset().top + sortable.height() / 2)) {
        // index at which it needs to be inserted before
        $(this.element).trigger({
          type: eventName,
          index: i,
          dragData: dragData
        });
        return;
      }
    }
    if (!sortables.length) {
      $(this.element).trigger({
        type: eventName,
        index: 0,
        dragData: dragData
      });
    } else {
      $(this.element).trigger({
        type: eventName,
        index: i,
        dragData: dragData
      });
    }
  }
});

can.view.callbacks.attr("sortable", function(el) {
  new Sortable(el);
});

var vm = new PlaylistVM();
var template = can.stache.from("app-template");
var frag = template(vm);
document.body.appendChild(frag);

Congrats! You now have your very own YouTube Playlist Editor.

CanJS is part of DoneJS. Created and maintained by the core DoneJS team and Bitovi. Currently 3.14.1.

On this page

Get help

  • Chat with us
  • File an issue
  • Ask questions
  • Read latest news