Playlist Editor (Advanced)
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:
- Click Sign In to give access to the app to create playlists on your behalf.
- Type search terms in Search for videos and hit enter.
- Drag and drop those videos into the playlist area (Drag video here).
- Click Create Playlist.
- Enter a name in the popup.
- 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:
- Load Google’s JS API client,
gapi
, and initialize it to make requests on behalf of the registered "CanJS Playlist" app. - Set up a basic CanJS application.
- 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 theapi.js
file is loading. Once complete, this will call agoogleScriptLoaded
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 thegapi
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 themessagePromise
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 incan-stache
.Promise
s are observable incan-stache
. Given a promisesomePromise
, you can:- Check if the promise is loading like:
{{#if(somePromise.isPending)}}
. - Loop through the resolved value of the promise like:
{{#each(somePromise.value)}}
.
- Check if the promise is loading like:
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:
- Show a
Sign In
button that signs a person into their google account. - Show a
Sign Out
button that signs a person out of their google account. - Automatically know via google’s API when the user signs in and out, and update the page accordingly.
- 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()
- Know if someone is signed in:
ES5 Getter Syntax can be used to define a
DefineMap
property that changes when another property changes. For example, the following defines ansignedOut
property that is the opposite of thesignedIn
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 initializegoogleApiLoadedPromise
: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 incrementnameChange
everytime thename
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 callssayHi()
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:
- Create a search
<input>
where a user can type a search query. - 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 keepssearchQuery
and the input’svalue
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 nativePromise
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:
- 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 downdraginit
- the drag motion is starteddragmove
- the drag is moveddragend
- the drag has endeddragover
- the drag is over a drop pointdragout
- 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 todrag
events in can-stache and pass thedrag
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 theghost
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 thedraggable="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:
- Allow a user to drop videos on a playlist element.
- When the user drags a video over the playlist element, a placeholder of the video will appear in the first position of the playlist.
- If the video is dragged out of the playlist element, the placeholder will be removed.
- If the video is dropped on the playlist element, it will be added to the playlist’s list of videos.
- 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 todrop
events in can-stache, and pass thedrag
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:
- 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
anddropon
events into other events that let people know where thedropmove
anddropon
events are happening in relationship to the drop target’s child elements.Our goal is to:
Translate
dropmove
intosortableplaceholderat
events that dispatch events with theindex
where a placeholder should be inserted and thedragData
of what is being dragged.Translate
dropon
intosortableinsertat
events that dispatch events with theindex
where the dragged item should be inserted and thedragData
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 thedropmove
binding onel
: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
anddropmove
anddropon
have apageY
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:
- 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:
- Add a
Create Playlist
button that prompts the user for the playlist name. - After the user enters the name, the playlist is saved.
- Disable the button while the playlist is being created.
- 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 tocreatePlaylistPromise
: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: