Most of us use an online streaming service (e.g. Netflix) to watch our favourite shows/movies. This post will highlight how to build a similarly styled movie streaming interface, with Vue.js 2.0 (see image above).
The final product: https://codepen.io/itslit/full/MvvjZr.
Though Bulma was used as the CSS framework for my application, this post will focus primarily on the usage of Vue.js and will glance over any styling/CSS. If you wish to tag along, I’ve set up a starting pen that has all the custom SCSS, the initial data object and the necessary external CDN libraries (vue-router, etc.). Starting pen: https://codepen.io/itslit/pen/QMzRev
Let’s note down the requirements for the application.
We’ll create the application to have the footer present at all times while the Home, Movie and Movie Trailer screens will share the same real estate.
For the sake of simplicity we’ll start with a simple/reliable data object (of objects) that would act as the main store for all of our components. The store will have all the movie information we need and would be focused around Christopher Nolan’s awesome films. Here’s a portion of the data object:
const movies = {"dunkirk": {"id": 'dunkirk'"title": 'Dunkirk',"subtitle": 'Dunkirk',"description": 'Miraculous evacuation of Allied soldiers from Belgium, Britain, Canada, and France, who were cut off and surrounded by the German army from the beaches and harbor of Dunkirk, France, during the Battle of France in World War II.',"largeImgSrc": `url('https://image.tmdb.org/t/p/w780/fudEG1VUWuOqleXv6NwCExK0VLy.jpg')`,"smallImgSrc': 'https://image.tmdb.org/t/p/w185/fudEG1VUWuOqleXv6NwCExK0VLy.jpg',"releaseDate": 'July 21 2017',"duration": '1hr 46min',"genre": 'Action, Drama, History',"trailerPath": 'https://www.youtube.com/embed/F-eMt3SrfFU',"favorite": false},"interstellar": {...},"the-dark-knight-rises": {...},"inception": {...},"the-prestige": {...}}
Now that we have the main store object created and an understanding of how all our components are laid out, we can start building the interface.
Let’s first create the Vue instance. We’ll mount/attach our instance to the DOM element app
and return the global store movies
as part of the instance’s data object to be accessed in our HTML.
const rootApp = new Vue({el: '#app',data() {return {movieChoices: movies}}})
We can now start to work on each of the separate components.
Let’s begin with the fixed Footer section that lists all the movies present in the data store.
<div id="app"><section class="hero is-primary is-medium"><div class="hero-foot"><div class="columns is-mobile"><div v-for="movieChoice in movieChoices" class="column"><li class="movie-choice"><img :src="`${movieChoice.smallImgSrc}`" class="desktop"/><p class="mobile">{{ movieChoice.subtitle }}</p></li></div></div></div></section></div>
Addressing the fields that have been bolded in the snippet of code above:
id="app"
, where our Vue instance will be mounted upon.v-for
directive to render a list from the data source movieChoices
.
Within every listed movieChoice
: - We bind an image src
to the smallImgSrc
url within our movie object (for desktop browsers). - We use the Mustache syntax for data binding to display the movieChoice.subtitle
as text for mobile screens.
With all the styles/CSS magic applied, our app should currently look like this:
Desktop Display
Mobile Display
So we’ve created the footer and now aim to create an Intro component that has our app title and description.
We’ve mentioned the Intro component will be sharing the same real estate as the upcoming Movie and MovieTrailer components (i.e. the user would be able to direct himself from Intro -> Movie -> MovieComponent by clicking the appropriate links in our app).
This is a perfect use case to add the vue-router
library. vue-router
is the official router for Vue.js and deeply integrates to allow for component-based router configuration, nested/view mapping, etc.
We’ll set up the basics of routing in the JS file:
const Intro = {template:`<div class="hero-body" style="background: #1e1d1d"><div class="container has-text-centered"><div class="columns"><div class="column is-half is-offset-one-quarter vertical-align"><h1 class="home-intro">VueFlix</h1><p class="home-subintro">Select a movie below from the list of critically acclaimed Christopher Nolan films.</p></div></div></div></div>`}
const routes = [{ path: '/', component: Intro },]
const router = new VueRouter({routes})
Up above you can see we’ve defined our first route component Intro
, our route for that component { path: '/', component: Intro }
and instantiated our router new VueRouter({ routes })
.
Note: There are multiple ways as to how component templates can be defined with Vue. The Intro and subsequent components use ES6’s template literals to define the template across multiple lines. Here’s a great post by Anthony Gore on highlighting the different ways -> 7 Ways To Define A Component Template in Vue.js
We now need to inject our router
to the Vue instance to make our whole app router aware and render our route component in our DOM with <router-view></router-view>
.
Injecting our router
to the Vue instance:
const rootApp = new Vue({el: '#app',router: router,data() {return {movieChoices: movies}}})
Rendering our route component in the DOM:
<div id="app"><section class="hero is-primary is-medium"><router-view></router-view>
<div class="hero-foot"><div class="columns"><div v-for="movieChoice in movieChoices" class="column"><li class="movie-choice"><img :src="`${movieChoice.smallImgSrc}`" class="desktop"/><p class="mobile">{{ movieChoice.subtitle }}</p></li></div></div></div></section></div>
So we’ve successfully created our first root route path: '/'
to display our IntroComponent
. With all the styles we’ve added, our app should look like this:
Looking good 🎉
We now have our main route specified and our footer section laid out. Let’s work on extending the route to a Movie component that displays all the information about a particular movie.
First, let’s get our navigation set up properly. As mentioned before, we want our footer to be the section that allows the user to navigate between movies. We’ll use vue-router
's router-link
component to enable navigation and provide the appropriate target location.
Going back to our HTML and making a small edit to the footer section:
<div id="app"><section class="hero is-primary is-medium"><router-view></router-view>
<div class="hero-foot"><div class="columns"><div v-for="movieChoice in movieChoices" class="column"><router-link :to="`/${movieChoice.id}`"tag="li"class="movie-choice"><img :src="`${movieChoice.smallImgSrc}`" class="desktop"/><p class="mobile">{{ movieChoice.subtitle }}</p></router-link></div></div></div></section></div>
We’ve established a target location of `/${movieChoice.id}`
which uses ES6 template literals to label the target url as the id’s of each respective movie (e.g. the path for The Dark Knight Rises would be /the-dark-knight-rises
). The tag
argument dictates that we want our router-link
to render as a li
while still listening for click events.
To complement our new navigation paths, we’ll need to set a dynamic route for our Movie component. Going back to where we’ve set up our routes:
const routes = [{ path: '/', component: Intro },{ path: '/:id', component: Movie }]
We’ve denoted a dynamic segment :id
with every route to the same component Movie
. We’ll now be able to read the different dynamic segments within our component by using $route.params.id
.
Now that we have our routes set up for the Movie component, let’s quickly draft the component and make sure our routes work appropriately.
The first draft of the Movie Component:
const Movie = {template:`<div><div class="hero-body"><div class="container has-text-centered"><div class="columns"><div class="column is-half is-offset-one-quarter vertical-align"><h1 class="home-intro">{{ selectedMovie.title }}</h1></div></div></div></div></div>`,data () {return {selectedMovie: movies[this.$route.params.id]}},watch: {$route () {this.selectMovie()}},methods: {selectMovie () {this.selectedMovie = movies[this.$route.params.id]}}}
There’s a few things to note here.
data () {return {selectedMovie: movies[this.$route.params.id]}}
The data
function sets the selectedMovie
property within the component to be an object from the global movies
store, based on the $route.params.id
. So assuming the movie choice was The Dark Knight Rises, the selectedMovie
property would be movies[the-dark-knight-rises]
.
watch**:** {$route () {this.selectMovie()}},methods**:** {selectMovie () {this.selectedMovie = movies[this.$route.params.id]}}
We use the watch
property to watch for any changes in the route which will then call the component’s selectMovie
method. The selectMovie
method simply updates the selectedMovie
parameter again with the new movie selected. This is necessary to handle as the user changes from one Movie component to another (i.e. switches movies).
Testing everything out we should be able to see the routes working:
It routes! 🎉
Now that we know our routes work appropriately, we’ll update the template in our Movie component to display all the desired information about a movie. (I’ve only bolded the lines that I’ll further address)
const Movie = {template:`<div class="hero-body":style="{ 'background-image': selectedMovie.largeImgSrc }"><header class="nav"><div class="container"><div class="nav-left"><a class="nav-item"><i class="fa fa-bars" aria-hidden="true"></i></a><router-link to="/" class="nav-item is-active">Home</router-link><a class="nav-item is-active"><span class="tag is-rounded">Films</span></a><a class="nav-item is-active">Shows</a><a class="nav-item is-active">Music</a></div><div class="nav-right desktop"><span class="nav-item"><a class="title">VueFlix</a></span></div></div></header>
<div class="container description-container">
<div class="columns">
<div class="column is-three-quarters">
<h1 class="title">{{ selectedMovie.title }}</h1>
<h4 class="subtitle">
<p class="subtitle-tag">{{ selectedMovie.duration }}</p>
<p class="subtitle-tag">{{ selectedMovie.genre }}</p>
<p class="subtitle-tag">{{ selectedMovie.releaseDate }}</p>
</h4>
<p class="description">{{ selectedMovie.description }}</p>
<div class="links">
**<router-link
:to="{path: '/' + $route.params.id + '/trailer'}"
class="button play-button">
Play <i class="fa fa-play"></i>
</router-link>**
</div>
</div>
</div>
</div>
</div>`,}
We’ve established a router-link
component in the Home link within the navbar to direct the user back to the root path /
(Intro component).
We’ve introduced another router-link
, within a movie’s Play button, that creates a target location of '/' + $route.params.id + '/trailer'
. This basically extends the current route of the movie id with /trailer
and is the navigation to our final Movie Trailer component.
As of now, the Movie component within our app should look like this:
🎥
Awesome. Since we’ve established the appropriate router-link
to direct a user from Movie to Movie Trailer, we now need to create the Movie Trailer component and the accompanying dynamic route.
The Movie Trailer component:
const MovieTrailer = {template: `<div class="trailer-body" style="background: #1e1d1d"><div class="has-text-centered"><div class="columns"><div class="column vertical-align"><iframeallowFullScreenframeborder="0"height="376":src="trailerUrlPath"style="width: 100%; min-width: 536px"/></div></div></div></div>`,data () {return {trailerUrlPath: movies[this.$route.params.id].trailerPath}}}
We’re using a simple iframe to display the trailers from YouTube. We’re binding the iframe src
to the component property trailerUrlPath
set in the data function. The trailerUrlPath
simply accesses the global movies
store and obtains the appropriate trailer url based on the the $route.params.id
.
The accompanying dynamic route:
const routes = [{ path: '/', component: Intro },{ path: '/:id', component: Movie },{ path: '/:id/trailer', component: MovieTrailer }]
Our app at this moment:
It plays! 🎉
We’re almost done! We’ll just address a simple visual indicator of adding movies to favorites and VueFlix would then be complete.
Every movie object within the main movies
store has a favorite
boolean. We’ll be using this as the trigger to represent whether a movie has been added to favorites.
With regards to visual display, we’ll have two visual cues: - A yellow box-shadow around the Movie component - A yellow checkmark within a list item in the Footer component
We have the favorite-shadow
and favourite-check
classes set up in our SCSS already to help us with this.
.favorite-shadow {box-shadow: 0 0 50px 15px rgba(251, 255, 15, 0.25);}
.favorite-check {position: absolute;right: 5px;top: 5px;z-index: 1;color: #fcff4c;
@media(max-width: $medium) {position: initial;display: block;}}
Now, we need to apply conditional class bindings within our Movie component template and our Footer section. We’ll also need to create the event handler for the add to favorites button in our Movie component.
For our Movie component:
const Movie = {template:`<div :class="[{ 'favorite-shadow': selectedMovie.favorite }, 'hero-body']":style="{ 'background-image': selectedMovie.largeImgSrc }"><header class="nav">...</header>
<div class="container description-container">
...
...
...
<div class="links">
<router-link
:to="{path: '/' + $route.params.id + '/trailer'}"
class="button play-button">
Play <i class="fa fa-play"></i>
</router-link>
**<a
class="button is-link favorites-button"
@click="addToFavorites">
<span
:class="\[{ 'hide': selectedMovie.favorite }\]">
Add to
</span>
<span
:class="\[{ 'hide': !selectedMovie.favorite }\]">
Remove from
</span>
favorites
<i class="fa fa-plus-square-o"></i>
</a>** </div>
</div>
</div>`,data() {...},watch: {},methods: {selectMovie() {...},addToFavorites() {movies[this.$route.params.id].favorite =!movies[this.$route.params.id].favorite}}
The class binding specified above dictates that the favorite-shadow
class is determined by the selectedMovie.favorite
boolean while the hero-body
class should always be present.
We’ve also introduced an ‘Add to/Remove from’ favorites button right after the original Play button. The ‘Add to/Remove from’ favorites button listens to the addToFavorites()
method handler which simply toggles the favorite
boolean for a particular movie on click. The text toggles between ‘Add to’ and ‘Remove from’ based on whether the movie has been added or removed from favorites (the hide
class is a created class with a display:none
property).
Similarly, we need to introduce the conditional class binding for a check-mark in the footer as well:
<div id="app"><section class="hero is-primary is-medium"><router-view></router-view>
<div class="hero-foot"><div class="columns is-mobile"><div v-for="movieChoice in movieChoices" class="column"><router-link :to="`/${movieChoice.id}`"tag="li"class="movie-choice"><i :class="[{ 'fa fa-check-circle favorite-check': movieChoice.favorite }]"></i><img :src="`${movieChoice.smallImgSrc}`" class="desktop"/><p class="mobile movie-title">{{ movieChoice.subtitle }}</p></router-link></div></div></div></section></div>
Now we should be able to add movies to our favourites list!
It favorites! 🎉
Thanks for taking the time to go through this. I hope this was informative and you learned something.
If you have any questions/comments/opinions, I’ll be happy to hear them!
— — — — — — — — — — — — — — — ♥ — — — — — — — — — — — — — — —If you enjoyed my style of writing and are interested in learning how to build Vue applications, I’ve just helped release Fullstack Vue! Fullstack Vue is a project driven approach to learning Vue.js since everything is explained within the context of building a larger application. Fullstack Vue is currently available and you can download the first chapter for free from the main website: https://www.fullstack.io/vue