DoneJS StealJS jQuery++ FuncUnit DocumentJS
4.3.0
5.0.0 3.13.1 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
      • Technology Overview
      • Reading the Docs (API Guide)
      • Experimental ES Module Usage
    • recipes
      • Canvas Clock (Simple)
      • 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)
      • Text Editor (Medium)
      • Tinder Carousel (Medium)
      • TodoMVC with StealJS
      • Video Player (Simple)
    • topics
      • Debugging
      • Forms
    • upgrade
      • Migrating to CanJS 3
      • Migrating to CanJS 4
      • 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:

Finished CanJS Playlist Editor 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:

Starting CanJS Playlist Editor 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:

Set up 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. Set up 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:

    const 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:

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

    const 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:

    const 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).

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

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

The solution

Update the HTML tab by deleting everything inside the <body> and replacing it with:

Note: use your own clientId if you use this code outside this guide and JS Bin.

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>CanJS Playlist Editor</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@4/dist/global/can.all.js"></script>

<script>
window.googleApiLoadedPromise = new Promise(function(resolve) {
  window.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:

const PlaylistVM = can.DefineMap.extend("PlaylistVM", {
  googleApiLoadedPromise: {
    default: googleApiLoadedPromise
  }
});

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

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() {
        const 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:

    const 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 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() {
            const 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:

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

    self.on("googleAuth", function(ev, googleAuth) {
      self.signedIn = googleAuth.isSignedIn.get();
      googleAuth.isSignedIn.listen(function(isSignedIn) {
        self.signedIn = isSignedIn;
      });
    });
  },
  googleApiLoadedPromise: {
    default: 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();
  }
});

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

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:

    const 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:

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

    self.on("googleAuth", function(ev, googleAuth) {
      self.signedIn = googleAuth.isSignedIn.get();
      googleAuth.isSignedIn.listen(function(isSignedIn) {
        self.signedIn = isSignedIn;
      });
    });
  },
  googleApiLoadedPromise: {
    default: 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",
    default: ""
  },
  get searchResultsPromise() {
    if (this.searchQuery.length > 2) {

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

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

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(scope.arguments[1])"
    
  • You can use can.addJQueryEvents() to listen to custom jQuery events (such as jQuery++’s draginit above):

    can.addJQueryEvents(jQuery);
    
  • 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(scope.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:

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

    self.on("googleAuth", function(ev, googleAuth) {
      self.signedIn = googleAuth.isSignedIn.get();
      googleAuth.isSignedIn.listen(function(isSignedIn) {
        self.signedIn = isSignedIn;
      });
    });
  },
  googleApiLoadedPromise: {
    default: 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",
    default: ""
  },
  get searchResultsPromise() {
    if (this.searchQuery.length > 2) {

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

can.addJQueryEvents(jQuery);

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

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"],
           Default: can.DefineList
        },
        get videosWithDropPlaceholder() {
        const 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(scope.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 {{domData("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 on:draginit="../videoDrag(scope.arguments[1])"
                {{domData("dragData")}}>
      
    • can.domData.get can access this data like:

      can.domData.get(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(scope.arguments[1])"
            {{domData("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(scope.arguments[2]))"
            on:dropout="clearDropPlaceholder()"
            on:dropon="addVideo(0,getDragData(scope.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:

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

    self.on("googleAuth", function(ev, googleAuth) {
      self.signedIn = googleAuth.isSignedIn.get();
      googleAuth.isSignedIn.listen(function(isSignedIn) {
        self.signedIn = isSignedIn;
      });
    });
  },
  googleApiLoadedPromise: {
    default: 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",
    default: ""
  },
  get searchResultsPromise() {
    if (this.searchQuery.length > 2) {

      const results = gapi.client.youtube.search.list({
        q: this.searchQuery,
        part: "snippet",
        type: "video"
      }).then(function(response) {
        console.info("Search results:", 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.domData.get(drag.element[0], "dragData");
  },
  dropPlaceholderData: "any",
  playlistVideos: {
    Type: ["any"],
    Default: 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() {
    const 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;
  }
});

can.addJQueryEvents(jQuery);

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

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:

    const 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:

    const 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 can.domEvents.dispatch to fire custom events:

    can.domEvents.dispatch(element, {
      type: "sortableinsertat",
      index: 0,
      dragData: dragData
    });
    
  • Access the event object in a on:event with scope.event, like:

    on:sortableinsertat="addVideo(scope.event.index, scope.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(scope.arguments[1])"
            {{domData("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(scope.event.index, scope.event.dragData)"
            on:sortableinsertat="addVideo(scope.event.index, scope.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:

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

    self.on("googleAuth", function(ev, googleAuth) {
      self.signedIn = googleAuth.isSignedIn.get();
      googleAuth.isSignedIn.listen(function(isSignedIn) {
        self.signedIn = isSignedIn;
      });
    });
  },
  googleApiLoadedPromise: {
    default: 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",
    default: ""
  },
  get searchResultsPromise() {
    if (this.searchQuery.length > 2) {

      const results = gapi.client.youtube.search.list({
        q: this.searchQuery,
        part: "snippet",
        type: "video"
      }).then(function(response) {
        console.info("Search results:", 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.domData.get(drag.element[0], "dragData");
  },
  dropPlaceholderData: "any",
  playlistVideos: {
    Type: ["any"],
    Default: 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() {
    const 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;
  }
});

can.addJQueryEvents(jQuery);

const 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) {
    const dragData = can.domData.get(drag.element[0], "dragData");

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

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

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

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

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:

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

    self.on("googleAuth", function(ev, googleAuth) {
      self.signedIn = googleAuth.isSignedIn.get();
      googleAuth.isSignedIn.listen(function(isSignedIn) {
        self.signedIn = isSignedIn;
      });
    });
  },
  googleApiLoadedPromise: {
    default: 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",
    default: ""
  },
  get searchResultsPromise() {
    if (this.searchQuery.length > 2) {

      const results = gapi.client.youtube.search.list({
        q: this.searchQuery,
        part: "snippet",
        type: "video"
      }).then(function(response) {
        console.info("Search results:", 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.domData.get(drag.element[0], "dragData");
  },
  dropPlaceholderData: "any",
  playlistVideos: {
    Type: ["any"],
    Default: 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() {
    const 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;
  }
});

can.addJQueryEvents(jQuery);

const 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) {
    const dragData = can.domData.get(drag.element[0], "dragData");

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

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

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

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

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:

    let 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(scope.arguments[1])"
            {{domData("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(scope.event.index, scope.event.dragData)"
            on:sortableinsertat="addVideo(scope.event.index, scope.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:

const PlaylistVM = can.DefineMap.extend("PlaylistVM", {
  init: function() {
    const 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: {
    default: 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",
    default: ""
  },
  get searchResultsPromise() {
    if (this.searchQuery.length > 2) {

      const results = gapi.client.youtube.search.list({
        q: this.searchQuery,
        part: "snippet",
        type: "video"
      }).then(function(response) {
        console.info("Search results:", 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.domData.get(drag.element[0], "dragData");
  },
  dropPlaceholderData: "any",
  playlistVideos: {
    Type: ["any"],
    Default: 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() {
    const 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() {
    const playlistName = prompt("What would you like to name your playlist?");
    if (!playlistName) {
      return;
    }

    let playlistId;
    let 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;
    });


    const 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);
    });
  }
});

can.addJQueryEvents(jQuery);

const 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) {
    const dragData = can.domData.get(drag.element[0], "dragData");

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

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

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

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

Result

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

When finished, you should see something like the following JS Bin:

Finished CanJS Playlist Editor on jsbin.com

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

On this page

Get help

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