Build a YouTube Queue with Next.js: Part 2

Khalea B.

Khalea B. / June 17, 2021

10 min read

3D rendered YouTube play button. Photo by Alexander Shatov on Unsplash.

Intro

If you've completed part 1, you're ready to add some new features! In this tutorial, we'll be adding search using the Axios HTTP client and YouTube's API, as well as the ability to add search results to the queue.

Prerequisites

Search UI

Let's think about how to structure the search feature. We want to use a search bar to submit a query, and results should appear beneath the bar with each title, channel name, and thumbnail. Each result should have a button that can add the video to the queue. This tells us several things about the search component's structure:

  • The queue and search results need to share data → We should pass a queuing function down to the search results from their highest common ancestor, the Home component in index.js.

  • The search bar initiates a query that should serve data to a results component → We should hold the search bar and search results within one container. The container should hold the state of the results data. Lastly, the container should pass down a search handler that updates results to the search bar.

Let's start by adding a file called searchContainer.js to the components folder. Import useState from react, and create a new functional component called SearchContainer that accepts props. Implement a state hook for an array of search results that has an empty array as a default value:

import { useState } from 'react'
export default function SearchContainer(props) {
    const [searchResults, updateSearchResults] = useState([])
}

Add a file called searchbar.js to the components folder. Create a new functional component called SearchBar that accepts props. We will return a simple search bar with a magnifying glass SVG button.

export default function SearchBar(props) {
    return (
        <div className="relative w-1/2 mb-4">
          <input
            type="text"
            placeholder="Search"
            className="px-4 py-2 border border-gray-300 focus:ring-blue-500 block w-full rounded-md bg-white text-gray-900"
          />
          <button>
            <svg
                className="absolute right-3 top-3 h-5 w-5 text-gray-400 dark:text-gray-300"
                xmlns="http://www.w3.org/2000/svg"
                fill="none"
                viewBox="0 0 24 24"
                stroke="currentColor"
            >
                <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
                />
            </svg>
          </button>      
        </div>
    )
}

Import and add it into the SearchContainer:

import SearchBar from './searchbar'
export default function SearchContainer(props) {
    const [searchResults, updateSearchResults] = useState([])
    return (
        <div>        
        <SearchBar />
        </div>
    )
}

Import the SearchContainer intoindex.js. Wrap the section with the Player and Queue components in a div that has flex flex-col styling:

import SearchContainer from '../components/searchContainer'
<main>
    <div className="flex flex-col">
        <div className="flex flex-row">
            <Player videoId={currentVideoId} onEnd={playNext}/>
            <Queue data={videoData}/>
        </div>
        <SearchContainer />
    </div>
</main>

Refresh http://localhost:3000/, and you should now see the SearchBar beneath the Player.

Search API

Before we add the results component, let's set up search with the YouTube API. We'll be making use of the Axios HTTP library in order to simplify the API request process. Using the CLI, run the following in your project directory:

npm install axios

In the pages > api folder, add a file called search.js. Import the axios library at the top. Declare variables youtubeAPI and apiKey containing the YouTube search endpoint and your API key:

import axios from 'axios'

const youtubeAPI = 'https://youtube.googleapis.com/youtube/v3/search?part=snippet&q='
const apiKey = 'YOUR_API_KEY'

Note: When deploying an app onto the internet, you'll want to conceal your API key. You can use environment variables with Next.js apps deployed with Vercel as an alternative to hard-coding keys. Learn more about environment variables with Next.js here.

Now create the GET request with Axios:

export const searchVideos = (query) => {
    const url = youtubeAPI + query + '&type=video&key=' + apiKey

    return axios.get(url)
        .catch(function (error) {
            console.log(error)
        })
}

In searchContainer.js, import searchVideos from the pages > api folder. Create a function variable called handleSearch that takes a query, searches for videos, and uses updateSearchResults. Add a handleSearch prop to the SearchBar component in order to pass down the ability to update results:

import { searchVideos } from '../pages/api/search'
const handleSearch = async (query) => {
        const response = await searchVideos(query)
        updateSearchResults(response.data.items)
  console.log(response.data.items) // See results in browser console
    }
<SearchBar handleSearch={handleSearch}/>

In searchbar.js, add a state hook for the value of input field so it can be shared with the search function:

import {useState} from 'react'
const [input, setInput] = useState('')
<input type="text" onChange={(e) => setInput(e.target.value)} ... />

Add an onClick prop to the magnifying glass button that initiates the handleSearch function:

<button onClick={() => props.handleSearch(input)}>

Now in http://localhost:3000/, search for a music video. Check the browser console and you should see a JSON object containing metadata about the top 5 results. Look through the data, and see what information is available. Here is a shortened JSON response from searching 'Hozier':

{
  "kind": "youtube#searchListResponse",
  "etag": "4zACJes6LjRa7-qKJPB6DR_0si8",
  "nextPageToken": "CAUQAA",
  "regionCode": "US",
  "pageInfo": { "totalResults": 16515, "resultsPerPage": 5 },
  "items": [
    {
      "kind": "youtube#searchResult",
      "etag": "bAEkzy_M-jxlV6eogJ73TfGEPok",
      "id": { "kind": "youtube#video", "videoId": "HlLx7oE7q3I" },
      "snippet": {
        "publishedAt": "2019-03-06T14:58:15Z",
        "channelId": "UCdOcBpu5O2V0JhFFs9k-Ouw",
        "title": "Hozier - Dinner &amp; Diatribes (Official Video)",
        "description": "Featuring Anya Taylor-Joy & Andrew Hozier-Byrne Directed by Anthony Byrne Executive Producer - Jess Wylie Producer - Fred Bonham Carter Director of ...",
        "thumbnails": {
          "default": {
            "url": "https://i.ytimg.com/vi/HlLx7oE7q3I/default.jpg",
            "width": 120,
            "height": 90
          },
          "medium": {
            "url": "https://i.ytimg.com/vi/HlLx7oE7q3I/mqdefault.jpg",
            "width": 320,
            "height": 180
          },
          "high": {
            "url": "https://i.ytimg.com/vi/HlLx7oE7q3I/hqdefault.jpg",
            "width": 480,
            "height": 360
          }
        },
        "channelTitle": "HozierVEVO",
        "liveBroadcastContent": "none",
        "publishTime": "2019-03-06T14:58:15Z"
      }
    }
  ]
}

Search results

We can fetch results, now we just need to present them to the user! We'll create a reusable card component for each result, and map them all into a list. Each card should display the title, channel name, and a thumbnail image of a video. We'll be making use of the next/image component in order to optimize thumbnail images.

Start by adding a file called searchlist.js to the components folder. Import Image from next/image and create a functional component called SearchList that accepts props. Declare a variable called results that holds a prop called data:

import Image from 'next/image'
export default function SearchList(props) {
    const results = props.data
}

Now we'll use JSX and Array.map to list the search results. The layout should have the thumbnail on the left, with a vertical stack containing the title and channel name to the right:

return(
    <div>

        {
            results.map(item => {
                return(
                    <div key={item.id.videoId} className="flex flex-row py-1 space-x-2">
                        <Image 
                            src={item.snippet.thumbnails.default.url}
                            alt="Music video thumbnail"
                            width={120}
                            height={90}
                        />

                        <div className="flex flex-col">
                            <h3 className="text-md font-semibold">{item.snippet.title}</h3>
                            <p className="text-sm font-light">{item.snippet.channelTitle}</p>
                        </div>
                    </div>
                )
            })
        }
        
    </div>
)

Note: We need to ensure each item has a unique key so that React can track changes for individual items. Since the API returns unique results, we use the video ID.

Import the SearchList component into the searchContainer.js file. Add SearchList into the container, and give it a prop called data that takes searchResults:

import SearchList from './searchlist'
<div>
    <SearchBar handleSearch={handleSearch} />
    <SearchList data={searchResults} />
</div>

Before we are able to view the results, we'll need to create a next.config.js file. External URLs are so when we use images from an external entity, we need to whitelist the domain. In the root of your project directory, create a file called next.config.js and add the following:

module.exports = {
    images: {
        domains: ['i.ytimg.com']
    }
}

Restart the development server with npm run dev. Search for a video, and now you should see your results!

List of top 5 Hozier videos on YouTube.

Adding to the Queue

All that's left is to select videos from the search results and add them to the queue. Since data will need to be shared between SearchContainer (where search results are stored) and Queue, we will add a queuing function to their highest common ancestor (Home) and pass it down to the SearchList.

Open index.js and create a function variable called addToQueue that takes a video as an argument. video is the JSON metadata for a YouTube video returned from the API. We will simply spread the videoData array and append the new video to it. Then, if the queue was previously empty and no song was playing, we'll set the currentVideoId to the newly added video:

const addToQueue = (video) => {
    console.log(video)
    updateVideoData(videoData => [...videoData, video])

    if (currentVideoId == null) {
        updateCurrentVideoId(video.id.videoId)
    }

}

Pass this prop down to SearchContainer, and then down to SearchList:

// index.js
<SearchContainer queueFunc={addToQueue} />
// searchContainer.js
<SearchList data={searchResults} queueFunc={props.queueFunc} />

In searchlist.js, add a button to the vertical stack containing the video title and channel title. Give the button an onClick prop that uses the queueFunc that was passed down:

<div className="flex flex-row space-x-2 pt-2">
    <button onClick={() => props.queueFunc(item)} className="w-auto h-auto">
        <svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" strokeWidth="0.5" fill="currentColor" fillRule="evenodd" clipRule="evenodd"><path d="M11.5 0c6.347 0 11.5 5.153 11.5 11.5s-5.153 11.5-11.5 11.5-11.5-5.153-11.5-11.5 5.153-11.5 11.5-11.5zm0 1c5.795 0 10.5 4.705 10.5 10.5s-4.705 10.5-10.5 10.5-10.5-4.705-10.5-10.5 4.705-10.5 10.5-10.5zm.5 10h6v1h-6v6h-1v-6h-6v-1h6v-6h1v6z"/></svg>
    </button>
    <h2 className="m-0">Add to Queue</h2>
</div>

Now you can add new videos to the queue!

Search results for 'Hozier' with queue add button.

Conditional Rendering

As of now, when the last video in the queue plays, it will remain listed in the queue and any songs added after it ends will not autoplay (though they'll be reflected in the queue).

This is because the currentVideoId stays the same since we only update the ID if the size of videoData is greater than zero. Furthermore, shifting (removing the first element) videoData directly will not trigger a UI update, however modifying the data with updateVideoData will.

Set the initial state of videoData to be an empty array, the initial state of currentVideoId to null, and remove the TestQueue import from index.js:

const [videoData, updateVideoData] = useState([])
const [currentVideoId, updateCurrentVideoId] = useState(null)

Alter the Home component to conditionally render the Player when the videoData array is not empty. Otherwise, we'll render an empty div with a gray background:

{videoData.length > 0 &&
    <Player videoId={currentVideoId} onEnd={playNext} />
}

{videoData.length === 0 &&
    <div className="block px-64 py-48 bg-gray-300 rounded" style={{width: '640px', height: '360px'}}>
        <h2>Empty Queue</h2>
    </div>
}

Update the playNext function to use updateVideoData with slice instead of shift. Using the useState setter function will trigger a UI update where, in the case of an empty videoData array, the Player embed is replaced (as there is no video ID given) and the Queue is cleared:

const playNext = () => {
    updateVideoData(videoData.slice(1))

    if (videoData.length > 0) {
        updateCurrentVideoId(videoData[0].id.videoId)
    }
}

Conclusion

Nice! You've now integrated search with the YouTube API and added the ability to add search results to your queue! In part 3, we'll see how we can leverage websockets for real-time video sync. This code is also available on Github.