Movie Soundtrack Explorer

Fetch real movie and music data from the iTunes API with async/await.

Introduction

30 Second Summary

Think about the last time you heard a great song in a movie and wanted to explore the full soundtrack. You probably bounced between apps and search results trying to find every track.

In this project, you will build a Movie Soundtrack Explorer that connects to Apple's iTunes Search API. You'll search for movies, browse poster artwork, and play 30-second audio previews of soundtrack songs directly in your browser.

What You'll Build

A browser-based web app that fetches real movie and music data from the iTunes Search API, renders interactive movie cards, and plays soundtrack previews.

By the end of this project, you'll have:

  • A working search feature that fetches movie results from the iTunes Search API and displays them as visual cards with poster artwork.
  • Dynamic soundtrack loading that retrieves and displays song data complete with album art, artist names, and durations when you click a movie card.
  • Built-in audio playback controls that let you play and pause 30-second preview clips directly in the browser.
  • Secret Mission: Add a persistent search history feature using localStorage.

Are there any prerequisites?

This project assumes basic familiarity with HTML, CSS, and JavaScript. You should be comfortable with DOM manipulation, event listeners, and arrays.

Before We Start

Every web app starts from the same foundation: a set of files that handle structure, style, and behavior. For your Movie Soundtrack Explorer, you need three files working together.

In this step, you'll create your workspace in VS Code and set up the project files you'll build on throughout the rest of this project.

In this step, get ready to:
  • Open VS Code.
  • Create a project folder on your Desktop.
  • Create three project files: index.html, style.css, and script.js.
Open VS Code
  • Press Cmd+Space to open Spotlight Search.
  • Type Visual Studio Code and press Enter to open it.
Create your project folder

You'll use VS Code's built-in terminal to create a folder that holds all your project files in one place.

  • Open the terminal in VS Code by pressing Ctrl+` (the backtick key, above Tab on your keyboard).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir movie-soundtrack-explorer
cd movie-soundtrack-explorer
Create the project files

Your project needs three files. index.html handles structure, style.css handles appearance, and script.js handles behavior.

  • Create all three files by running this command:
touch index.html style.css script.js
  • Confirm the files exist by running:
ls

You should see index.html, style.css, and script.js listed in the output.

  • Open the project folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the movie-soundtrack-explorer folder, then click Open.

Your VS Code Explorer sidebar (on the left) should now show all three files listed under the movie-soundtrack-explorer folder.

Don't see your files in the sidebar?

Make sure you opened the movie-soundtrack-explorer folder itself, not the Desktop folder. The folder name should appear at the top of the Explorer panel.

If you don't see the Explorer panel at all, click View in the top menu, then click Explorer.

Help me find my project files in VS Code.

Your workspace is ready. Next up, you'll build the HTML structure and add styling to create your app's search interface and card layouts.

Set Up the Project Files

Every web app starts with structure and style. Before you can fetch data from an API or wire up interactivity, you need a solid HTML skeleton and polished CSS that gives your app a professional look from the start.

In this step, you will build the full page structure for the Movie Soundtrack Explorer and style it with a dark theme, responsive grid layout, and card-based design.

In this step, get ready to:
  • Build the HTML skeleton with a search form, movie results section, and soundtrack section.
  • Add all CSS styles including layout, card grid, track list, and responsive breakpoints.
  • Open the page in your browser and confirm the styled search bar is visible.
Build the HTML skeleton

The HTML file defines every section of the app that JavaScript will later populate with data. You need a search form for user input, containers for movie cards, and a section for soundtrack tracks.

  • In VS Code, click on index.html in the sidebar to open it.
  • Copy the following HTML into the file:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Movie Soundtrack Explorer</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <header>
            <h1>Movie Soundtrack Explorer</h1>
            <p class="subtitle">Search for a movie and discover its soundtrack</p>
        </header>

        <form id="search-form">
            <div class="search-bar">
                <input
                    type="text"
                    id="search-input"
                    placeholder="Search for a movie..."
                    required
                >
                <button type="submit" id="search-button">Search</button>
            </div>
        </form>

        <div id="loading" class="loading hidden">Searching...</div>
        <div id="error-message" class="error hidden"></div>

        <section id="movies-section" class="hidden">
            <h2>Movies</h2>
            <div id="movies-grid" class="card-grid"></div>
        </section>

        <section id="soundtrack-section" class="hidden">
            <h2 id="soundtrack-heading">Soundtrack</h2>
            <div id="tracks-list" class="tracks-list"></div>
        </section>
    </div>

    <script src="script.js"></script>
</body>
</html>

What does this HTML do?

  • The <form id="search-form"> wraps the search input and button so you can listen for form submissions later in JavaScript.
  • The loading and error-message divs start hidden and will appear only when the app is fetching data or encounters a problem.
  • The movies-section and soundtrack-section are empty containers that JavaScript will fill with cards once the API returns data.
  • The <script> tag at the bottom of <body> ensures all HTML elements exist before the script tries to reference them.
  • Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Open the file in your browser by right-clicking index.html in the VS Code sidebar and selecting Reveal in Finder (macOS) or Reveal in File Explorer (Windows), then double-clicking the file.
  • You should see unstyled text with a search input, a Search button, and section headings on a white background.

See a blank page?

  • Make sure you saved the file. Unsaved changes won't appear in the browser.
  • Confirm you double-clicked index.html (not style.css or script.js).

Why is my page blank when I open index.html?

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on to the CSS.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Movie Soundtrack Explorer</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <header>
            <h1>Movie Soundtrack Explorer</h1>
            <p class="subtitle">Search for a movie and discover its soundtrack</p>
        </header>

        <form id="search-form">
            <div class="search-bar">
                <input
                    type="text"
                    id="search-input"
                    placeholder="Search for a movie..."
                    required
                >
                <button type="submit" id="search-button">Search</button>
            </div>
        </form>

        <div id="loading" class="loading hidden">Searching...</div>
        <div id="error-message" class="error hidden"></div>

        <section id="movies-section" class="hidden">
            <h2>Movies</h2>
            <div id="movies-grid" class="card-grid"></div>
        </section>

        <section id="soundtrack-section" class="hidden">
            <h2 id="soundtrack-heading">Soundtrack</h2>
            <div id="tracks-list" class="tracks-list"></div>
        </section>
    </div>

    <script src="script.js"></script>
</body>
</html>
Add the layout and search form styles

The CSS gives the app a dark cinematic theme and centers the search bar in a clean layout. You will add the styles in three chunks, starting with the page foundation.

  • Switch to style.css in VS Code by clicking it in the sidebar.
  • Copy the following CSS into the file:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background-color: #1a1a2e;
    color: #eaeaea;
    min-height: 100vh;
    padding: 2rem 1rem;
}

.container {
    max-width: 900px;
    margin: 0 auto;
}

header {
    text-align: center;
    margin-bottom: 2rem;
}

header h1 {
    font-size: 2rem;
    color: #ffffff;
    margin-bottom: 0.5rem;
}

.subtitle {
    color: #a0a0b0;
    font-size: 1rem;
}

#search-form {
    margin-bottom: 1.5rem;
}

.search-bar {
    display: flex;
    gap: 0.5rem;
    max-width: 500px;
    margin: 0 auto;
}

#search-input {
    flex: 1;
    padding: 0.75rem 1rem;
    border: 2px solid #333355;
    border-radius: 8px;
    background-color: #16213e;
    color: #eaeaea;
    font-size: 1rem;
    outline: none;
    transition: border-color 0.2s;
}

#search-input:focus {
    border-color: #e94560;
}

#search-button {
    padding: 0.75rem 1.5rem;
    border: none;
    border-radius: 8px;
    background-color: #e94560;
    color: #ffffff;
    font-size: 1rem;
    font-weight: 600;
    cursor: pointer;
    transition: background-color 0.2s;
}

#search-button:hover {
    background-color: #d63851;
}

What do these styles do?

  • The universal reset (*) removes default browser margins and padding so spacing is consistent.
  • The body gets a dark navy background (#1a1a2e) with light text, setting a cinematic mood.
  • The .container limits content width to 900px and centers it horizontally.
  • The .search-bar uses flexbox to place the input and button side by side with a small gap.
  • The search input highlights with a red border on focus, giving clear visual feedback.
  • Save style.css and refresh your browser.
  • You should see a dark navy page with the title centered at the top and a styled search bar (dark input field with a red Search button).

Still seeing unstyled white page?

  • Check that index.html has <link rel="stylesheet" href="style.css"> in the <head> section.
  • Make sure both files are in the same folder. The link assumes style.css is next to index.html.
  • Try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows) to bypass cached styles.

Why aren't my CSS styles showing up in the browser?

Add utility classes and movie card styles

Next you will add styles for loading indicators, error messages, the CSS Grid layout that displays movie cards, and the cards themselves.

  • In style.css, add the following code below the #search-button:hover rule:
.loading {
    text-align: center;
    color: #a0a0b0;
    padding: 2rem;
    font-size: 1.1rem;
}

.error {
    text-align: center;
    color: #e94560;
    padding: 1rem;
    background-color: #2a1a2e;
    border-radius: 8px;
    margin-bottom: 1rem;
}

.hidden {
    display: none;
}

section {
    margin-bottom: 2rem;
}

section h2 {
    font-size: 1.4rem;
    margin-bottom: 1rem;
    color: #ffffff;
}

.card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
    gap: 1rem;
}

.movie-card {
    background-color: #16213e;
    border-radius: 12px;
    overflow: hidden;
    cursor: pointer;
    transition: transform 0.2s, box-shadow 0.2s;
    border: 2px solid transparent;
}

.movie-card:hover {
    transform: translateY(-4px);
    box-shadow: 0 8px 24px rgba(233, 69, 96, 0.2);
}

.movie-card.selected {
    border-color: #e94560;
}

.movie-card img {
    width: 100%;
    aspect-ratio: 2 / 3;
    object-fit: cover;
    display: block;
}

.movie-card-info {
    padding: 0.75rem;
}

.movie-card-info h3 {
    font-size: 0.9rem;
    margin-bottom: 0.25rem;
    color: #ffffff;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.movie-card-info p {
    font-size: 0.8rem;
    color: #a0a0b0;
}

What do these styles do?

  • The .hidden class uses display: none to toggle elements on and off from JavaScript without removing them from the page.
  • The .card-grid creates a responsive grid that auto-fills columns at a minimum of 180px wide.
  • Movie cards lift up on hover with translateY(-4px) and gain a subtle red shadow for an interactive feel.
  • The .selected class adds a red border to the card the user clicked, showing which movie is active.
  • Save style.css.
  • You won't see these card styles in action yet. The movie cards will appear once JavaScript creates them from API data in the next step.
  • Now add the final block of CSS below the .movie-card-info p rule:
.tracks-list {
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
}

.track-card {
    display: flex;
    align-items: center;
    gap: 1rem;
    background-color: #16213e;
    border-radius: 10px;
    padding: 0.75rem;
    transition: background-color 0.2s;
}

.track-card:hover {
    background-color: #1a2744;
}

.track-card img {
    width: 56px;
    height: 56px;
    border-radius: 6px;
    object-fit: cover;
}

.track-info {
    flex: 1;
    min-width: 0;
}

.track-info h4 {
    font-size: 0.95rem;
    color: #ffffff;
    margin-bottom: 0.2rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.track-info p {
    font-size: 0.8rem;
    color: #a0a0b0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.track-duration {
    font-size: 0.8rem;
    color: #a0a0b0;
    min-width: 40px;
    text-align: right;
}

.play-button {
    width: 36px;
    height: 36px;
    border: none;
    border-radius: 50%;
    background-color: #e94560;
    color: #ffffff;
    font-size: 1rem;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: background-color 0.2s;
    flex-shrink: 0;
}

.play-button:hover {
    background-color: #d63851;
}

@media (max-width: 600px) {
    .card-grid {
        grid-template-columns: repeat(2, 1fr);
    }

    .search-bar {
        flex-direction: column;
    }

    #search-button {
        width: 100%;
    }
}

What do these styles do?

  • The .track-card uses flexbox to lay out album art, track info, duration, and a play button in a horizontal row.
  • The min-width: 0 on .track-info allows text to truncate with ellipsis instead of overflowing the card.
  • The .play-button is a circular red button sized at 36px for easy tapping on mobile.
  • The @media query at 600px switches the card grid to 2 columns and stacks the search bar vertically on small screens.
  • Save style.css and refresh your browser.
  • The page should still show the dark background with the styled search bar. The track and card styles will become visible once JavaScript populates those sections with data.

Seeing a CSS syntax error?

  • Check that every opening curly brace { has a matching closing brace }. A missing brace can break all styles below it.
  • Look for red squiggly underlines in VS Code. They indicate a syntax problem on that line.
  • Confirm you pasted each chunk directly below the previous one with no overlap or duplicate selectors.

Why are my CSS styles broken after adding the second or third chunk?

✔️ Awesome, I've got everything!

Great. Double check you have saved both index.html and style.css before moving on.

ⓧ I'd like to double check the full code

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background-color: #1a1a2e;
    color: #eaeaea;
    min-height: 100vh;
    padding: 2rem 1rem;
}

.container {
    max-width: 900px;
    margin: 0 auto;
}

header {
    text-align: center;
    margin-bottom: 2rem;
}

header h1 {
    font-size: 2rem;
    color: #ffffff;
    margin-bottom: 0.5rem;
}

.subtitle {
    color: #a0a0b0;
    font-size: 1rem;
}

#search-form {
    margin-bottom: 1.5rem;
}

.search-bar {
    display: flex;
    gap: 0.5rem;
    max-width: 500px;
    margin: 0 auto;
}

#search-input {
    flex: 1;
    padding: 0.75rem 1rem;
    border: 2px solid #333355;
    border-radius: 8px;
    background-color: #16213e;
    color: #eaeaea;
    font-size: 1rem;
    outline: none;
    transition: border-color 0.2s;
}

#search-input:focus {
    border-color: #e94560;
}

#search-button {
    padding: 0.75rem 1.5rem;
    border: none;
    border-radius: 8px;
    background-color: #e94560;
    color: #ffffff;
    font-size: 1rem;
    font-weight: 600;
    cursor: pointer;
    transition: background-color 0.2s;
}

#search-button:hover {
    background-color: #d63851;
}

.loading {
    text-align: center;
    color: #a0a0b0;
    padding: 2rem;
    font-size: 1.1rem;
}

.error {
    text-align: center;
    color: #e94560;
    padding: 1rem;
    background-color: #2a1a2e;
    border-radius: 8px;
    margin-bottom: 1rem;
}

.hidden {
    display: none;
}

section {
    margin-bottom: 2rem;
}

section h2 {
    font-size: 1.4rem;
    margin-bottom: 1rem;
    color: #ffffff;
}

.card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
    gap: 1rem;
}

.movie-card {
    background-color: #16213e;
    border-radius: 12px;
    overflow: hidden;
    cursor: pointer;
    transition: transform 0.2s, box-shadow 0.2s;
    border: 2px solid transparent;
}

.movie-card:hover {
    transform: translateY(-4px);
    box-shadow: 0 8px 24px rgba(233, 69, 96, 0.2);
}

.movie-card.selected {
    border-color: #e94560;
}

.movie-card img {
    width: 100%;
    aspect-ratio: 2 / 3;
    object-fit: cover;
    display: block;
}

.movie-card-info {
    padding: 0.75rem;
}

.movie-card-info h3 {
    font-size: 0.9rem;
    margin-bottom: 0.25rem;
    color: #ffffff;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.movie-card-info p {
    font-size: 0.8rem;
    color: #a0a0b0;
}

.tracks-list {
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
}

.track-card {
    display: flex;
    align-items: center;
    gap: 1rem;
    background-color: #16213e;
    border-radius: 10px;
    padding: 0.75rem;
    transition: background-color 0.2s;
}

.track-card:hover {
    background-color: #1a2744;
}

.track-card img {
    width: 56px;
    height: 56px;
    border-radius: 6px;
    object-fit: cover;
}

.track-info {
    flex: 1;
    min-width: 0;
}

.track-info h4 {
    font-size: 0.95rem;
    color: #ffffff;
    margin-bottom: 0.2rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.track-info p {
    font-size: 0.8rem;
    color: #a0a0b0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.track-duration {
    font-size: 0.8rem;
    color: #a0a0b0;
    min-width: 40px;
    text-align: right;
}

.play-button {
    width: 36px;
    height: 36px;
    border: none;
    border-radius: 50%;
    background-color: #e94560;
    color: #ffffff;
    font-size: 1rem;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: background-color 0.2s;
    flex-shrink: 0;
}

.play-button:hover {
    background-color: #d63851;
}

@media (max-width: 600px) {
    .card-grid {
        grid-template-columns: repeat(2, 1fr);
    }

    .search-bar {
        flex-direction: column;
    }

    #search-button {
        width: 100%;
    }
}

Your page structure and styling are locked in. Next up, you will write JavaScript that takes the search input and fetches real movie data from Apple's iTunes Search API.

Search Movies from the API

Your HTML structure and styles are ready. Now it's time to make the app actually do something.

In this step, you'll connect your search form to Apple's iTunes Search API. This is a free, public API that returns movie and music data as JSON. You'll write JavaScript that fetches data from this API and logs it to the console so you can see the shape of real API responses.

In this step, get ready to:
  • Write an async function that fetches movie data from the iTunes Search API.
  • Wire up the search form to trigger API calls when submitted.
  • Handle loading states and errors gracefully.
Write the searchMovies function

Before you can search for anything, your JavaScript needs references to the HTML elements it will interact with. Then you need a function that knows how to talk to the iTunes API.

  • In VS Code, click on script.js in the Explorer sidebar to open it.
  • Add the following code to grab DOM references and define the searchMovies function:
const searchForm = document.getElementById("search-form");
const searchInput = document.getElementById("search-input");
const loadingEl = document.getElementById("loading");
const errorEl = document.getElementById("error-message");
const moviesSection = document.getElementById("movies-section");
const moviesGrid = document.getElementById("movies-grid");
const soundtrackSection = document.getElementById("soundtrack-section");
const soundtrackHeading = document.getElementById("soundtrack-heading");
const tracksList = document.getElementById("tracks-list");

let currentAudio = null;
let currentPlayButton = null;

async function searchMovies(query) {
    // Build the API URL with search parameters
    const url = `https://itunes.apple.com/search?term=${encodeURIComponent(query)}&media=movie&entity=movie&country=US&limit=8`;
    const response = await fetch(url);

    // Throw an error if the response status is not OK
    if (!response.ok) {
        throw new Error("Failed to fetch movies");
    }

    // Parse the JSON and return just the results array
    const data = await response.json();
    return data.results;
}

What does this code do?

The first block stores references to every HTML element your script will interact with. Caching these up front avoids repeated lookups later.

The searchMovies function is marked async so you can use await inside it. It builds a URL with query parameters telling the iTunes API to search for movies, then uses fetch() to make the HTTP request. The response comes back as JSON, and you return just the results array from it.

What is async/await?

Network requests take time. The async keyword tells JavaScript this function will wait for something. The await keyword pauses execution until the network response arrives, without freezing the rest of the page.

  • Save script.js.
  • You won't see a change in the browser yet. The next chunk wires the search form to this function.
Add the form submit handler

The searchMovies function exists, but nothing calls it yet. You need an event listener that fires when the user submits the search form.

  • In script.js, add this code between the let currentPlayButton = null; line and the searchMovies function:
searchForm.addEventListener("submit", async function (event) {
    // Stop the form from reloading the page
    event.preventDefault();
    const query = searchInput.value.trim();
    if (!query) return;

    // Show loading state and clear previous results
    showLoading();
    hideError();
    hideSoundtrack();

    try {
        const movies = await searchMovies(query);
        displayMovies(movies);
    } catch (error) {
        // Show a friendly message if the API call fails
        showError("Something went wrong. Please try again.");
    } finally {
        // Always hide the loading indicator when done
        hideLoading();
    }
});

What does this code do?

This event listener fires every time the user clicks **Search** or presses Enter. event.preventDefault() stops the browser from refreshing the page (the default behavior for forms).

The try/catch/finally block is your error handling safety net. If searchMovies fails (network down, API error), the catch block shows a message to the user. The finally block hides the loading indicator whether the request succeeded or failed.

Why use try/catch/finally for API calls?

Network requests can fail for many reasons: no internet connection, the server is down, or the URL is wrong. Without error handling, your app would silently break and leave the loading spinner spinning forever.

The finally block guarantees cleanup code runs no matter what. This pattern is standard for any async operation that shows a loading state.

  • Save script.js.
  • You won't see a change yet. The event listener calls helper functions (showLoading, hideError, etc.) that you'll build in the next chunk.
Build the helper functions

Your event listener calls several functions that don't exist yet. These helpers toggle the visibility of the loading indicator, error message, and soundtrack section.

  • Add the following functions at the bottom of script.js, after the searchMovies function:
// Placeholder - logs results to console for now
function displayMovies(movies) {
    console.log(movies);
}

function showLoading() {
    loadingEl.classList.remove("hidden");
}

function hideLoading() {
    loadingEl.classList.add("hidden");
}

function showError(message) {
    errorEl.textContent = message;
    errorEl.classList.remove("hidden");
}

function hideError() {
    errorEl.classList.add("hidden");
}

function hideSoundtrack() {
    soundtrackSection.classList.add("hidden");
}

What do these helpers do?

Each function adds or removes the hidden CSS class to toggle elements on and off. Your stylesheet already defines .hidden { display: none; }, so adding that class hides an element instantly.

The displayMovies function is a temporary placeholder. For now it just logs the API response to your browser console so you can inspect the data shape. You'll build the real version in the next step.

  • Save script.js.
  • Refresh your browser.
  • Type Inception in the search box and click Search.
  • Open your browser's Developer Tools by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
  • In the Console tab, you should see an array of movie objects. Expand one to see fields like trackName, artworkUrl100, primaryGenreName, and releaseDate.

Not seeing anything in the console?

  • Check the Console tab for red error messages. A typo in a function name or missing quote will show up here.
  • Make sure you saved script.js and refreshed the browser page.
  • Verify the <script src="script.js"></script> tag is at the bottom of your index.html body.

Help me debug why my console is not showing movie results.

What is the iTunes Search API?

The iTunes Search API is a free, public API from Apple that lets you search for movies, music, podcasts, and more. It requires no API key or sign-up. You just send a request to the URL with your search parameters, and it returns JSON data.

The API has a rate limit of approximately 20 calls per minute. Normal usage won't hit this, but if you spam the search button rapidly you might see temporary failures.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete script.js so far:

const searchForm = document.getElementById("search-form");
const searchInput = document.getElementById("search-input");
const loadingEl = document.getElementById("loading");
const errorEl = document.getElementById("error-message");
const moviesSection = document.getElementById("movies-section");
const moviesGrid = document.getElementById("movies-grid");
const soundtrackSection = document.getElementById("soundtrack-section");
const soundtrackHeading = document.getElementById("soundtrack-heading");
const tracksList = document.getElementById("tracks-list");

let currentAudio = null;
let currentPlayButton = null;

searchForm.addEventListener("submit", async function (event) {
    event.preventDefault();
    const query = searchInput.value.trim();
    if (!query) return;

    showLoading();
    hideError();
    hideSoundtrack();

    try {
        const movies = await searchMovies(query);
        displayMovies(movies);
    } catch (error) {
        showError("Something went wrong. Please try again.");
    } finally {
        hideLoading();
    }
});

async function searchMovies(query) {
    const url = `https://itunes.apple.com/search?term=${encodeURIComponent(query)}&media=movie&entity=movie&country=US&limit=8`;
    const response = await fetch(url);

    if (!response.ok) {
        throw new Error("Failed to fetch movies");
    }

    const data = await response.json();
    return data.results;
}

function displayMovies(movies) {
    console.log(movies);
}

function showLoading() {
    loadingEl.classList.remove("hidden");
}

function hideLoading() {
    loadingEl.classList.add("hidden");
}

function showError(message) {
    errorEl.textContent = message;
    errorEl.classList.remove("hidden");
}

function hideError() {
    errorEl.classList.add("hidden");
}

function hideSoundtrack() {
    soundtrackSection.classList.add("hidden");
}

You've got real movie data flowing from Apple's servers into your browser. Next up, you'll turn that raw JSON into visual movie cards the user can actually click on.

Display Movie Results as Cards

Your app can fetch movie data from the iTunes API. The problem is, those results are sitting in the console where no user will ever see them.

In this step, you'll transform that raw JSON data into visual movie cards. Each card will display poster artwork, the title, genre, and release year. Clicking a card will eventually trigger the soundtrack search.

In this step, get ready to:
  • Build the displayMovies function that creates card elements from API data.
  • Add click handling so each card highlights and triggers a soundtrack search.
  • Verify the card grid renders with the CSS styles already in place.
Replace the displayMovies placeholder

The current displayMovies function just logs data to the console. You need it to create actual HTML elements from each movie object in the array.

  • In script.js, find the existing displayMovies function (it currently contains console.log(movies)).
  • Replace the entire function with this code:
function displayMovies(movies) {
    // Clear any previous results
    moviesGrid.innerHTML = "";

    // Handle empty results
    if (movies.length === 0) {
        moviesGrid.innerHTML = "<p>No movies found. Try a different search.</p>";
        moviesSection.classList.remove("hidden");
        return;
    }

    // Create a card for each movie
    movies.forEach(function (movie) {
        const card = document.createElement("div");
        card.className = "movie-card";

        // Replace 100x100 with 600x600 for higher resolution artwork
        const artwork = movie.artworkUrl100
            ? movie.artworkUrl100.replace("100x100bb", "600x600bb")
            : "";
        // Parse the release year from the full date string
        const year = movie.releaseDate
            ? new Date(movie.releaseDate).getFullYear()
            : "N/A";

        card.innerHTML = `
            <img src="${artwork}" alt="${movie.trackName}">
            <div class="movie-card-info">
                <h3>${movie.trackName}</h3>
                <p>${movie.primaryGenreName} &middot; ${year}</p>
            </div>
        `;

        // Highlight the clicked card and trigger soundtrack search
        card.addEventListener("click", function () {
            document.querySelectorAll(".movie-card").forEach(function (c) {
                c.classList.remove("selected");
            });
            card.classList.add("selected");
            searchSoundtrack(movie.trackName);
        });

        moviesGrid.appendChild(card);
    });

    // Show the movies section
    moviesSection.classList.remove("hidden");
}

What does this code do?

This function transforms raw API data into visible movie cards on the page.

  • moviesGrid.innerHTML = "" clears any previous search results before rendering new ones.
  • The artwork URL trick replaces 100x100bb with 600x600bb to get a higher resolution image from the same URL pattern.
  • new Date(movie.releaseDate).getFullYear() extracts just the year from the full ISO date string the API returns.
  • The click listener removes selected from ALL cards first, then adds it to the clicked one. This ensures only one card is highlighted at a time.
Add the searchSoundtrack placeholder

The click listener calls a searchSoundtrack function that doesn't exist yet. You need a placeholder so the app doesn't crash when you click a card.

  • Below the displayMovies function, add this temporary placeholder:
function searchSoundtrack(movieTitle) {
    console.log(movieTitle);
}

Why add a placeholder?

If you click a card without this function defined, the browser throws a ReferenceError. The placeholder lets you test card clicking now. You'll replace it with real API logic in the next step.

  • Save script.js.
  • Refresh your browser and type Interstellar in the search box, then press **Enter**.
  • You should see a grid of movie cards with poster artwork, titles, genres, and release years.
  • Click any card. It should highlight with a red border, and the movie title should appear in the console.

Cards not showing up?

  • Open the browser console (right-click the page, then click **Inspect**, then the **Console** tab). Look for red error messages. A common cause is a missing closing brace or parenthesis in the forEach loop.
  • Make sure you replaced the entire old displayMovies function (including its opening and closing braces), not just the body inside it.
  • If cards appear but have no images, check that the template literal uses backticks (`,) not regular quotes.

Help me debug why my movie cards aren't rendering.

Where do the card styles come from?

You already wrote all the CSS for .movie-card in Step 1. The hover effects, rounded corners, shadows, and responsive grid layout are already active.

The .card-grid uses CSS Grid with repeat(auto-fill, minmax(180px, 1fr)) to automatically fit 2-3 columns on desktop and collapse to fewer on mobile.

✔️ Awesome, I've got everything!

Great. Double check you've saved script.js before moving on.

ⓧ I'd like to double check the full code

Here is the complete script.js at this point:

const searchForm = document.getElementById("search-form");
const searchInput = document.getElementById("search-input");
const loadingEl = document.getElementById("loading");
const errorEl = document.getElementById("error-message");
const moviesSection = document.getElementById("movies-section");
const moviesGrid = document.getElementById("movies-grid");
const soundtrackSection = document.getElementById("soundtrack-section");
const soundtrackHeading = document.getElementById("soundtrack-heading");
const tracksList = document.getElementById("tracks-list");

let currentAudio = null;
let currentPlayButton = null;

searchForm.addEventListener("submit", async function (event) {
    event.preventDefault();
    const query = searchInput.value.trim();
    if (!query) return;

    showLoading();
    hideError();
    hideSoundtrack();

    try {
        const movies = await searchMovies(query);
        displayMovies(movies);
    } catch (error) {
        showError("Something went wrong. Please try again.");
    } finally {
        hideLoading();
    }
});

async function searchMovies(query) {
    const url = `https://itunes.apple.com/search?term=${encodeURIComponent(query)}&media=movie&entity=movie&country=US&limit=8`;
    const response = await fetch(url);

    if (!response.ok) {
        throw new Error("Failed to fetch movies");
    }

    const data = await response.json();
    return data.results;
}

function displayMovies(movies) {
    moviesGrid.innerHTML = "";

    if (movies.length === 0) {
        moviesGrid.innerHTML = "<p>No movies found. Try a different search.</p>";
        moviesSection.classList.remove("hidden");
        return;
    }

    movies.forEach(function (movie) {
        const card = document.createElement("div");
        card.className = "movie-card";

        const artwork = movie.artworkUrl100
            ? movie.artworkUrl100.replace("100x100bb", "600x600bb")
            : "";
        const year = movie.releaseDate
            ? new Date(movie.releaseDate).getFullYear()
            : "N/A";

        card.innerHTML = `
            <img src="${artwork}" alt="${movie.trackName}">
            <div class="movie-card-info">
                <h3>${movie.trackName}</h3>
                <p>${movie.primaryGenreName} &middot; ${year}</p>
            </div>
        `;

        card.addEventListener("click", function () {
            document.querySelectorAll(".movie-card").forEach(function (c) {
                c.classList.remove("selected");
            });
            card.classList.add("selected");
            searchSoundtrack(movie.trackName);
        });

        moviesGrid.appendChild(card);
    });

    moviesSection.classList.remove("hidden");
}

function searchSoundtrack(movieTitle) {
    console.log(movieTitle);
}

function showLoading() {
    loadingEl.classList.remove("hidden");
}

function hideLoading() {
    loadingEl.classList.add("hidden");
}

function showError(message) {
    errorEl.textContent = message;
    errorEl.classList.remove("hidden");
}

function hideError() {
    errorEl.classList.add("hidden");
}

function hideSoundtrack() {
    soundtrackSection.classList.add("hidden");
}

Your app now shows real movie data as a polished card grid. Next up, you'll make those cards do something powerful. When the user clicks a movie, you'll fetch its soundtrack from the API and render playable audio previews.

Fetch and Display Soundtrack Tracks

Your app can search for movies and display them as clickable cards. Now it's time to make those clicks do something meaningful.

When a user clicks a movie card, the app needs to fetch that movie's soundtrack from the iTunes API, render each track with album art and duration info, and let users play 30-second audio previews. This is where the project comes to life.

In this step, get ready to:
  • Fetch soundtrack data from the iTunes API when a movie card is clicked.
  • Render track cards with album artwork, artist names, and formatted durations.
  • Add play/pause audio controls for 30-second track previews.
Write the searchSoundtrack function

Right now, clicking a movie card just logs the title to the console. You need to replace that placeholder with a real API call that searches for the movie's soundtrack.

  • In script.js, find the placeholder searchSoundtrack function:
function searchSoundtrack(movieTitle) {
    console.log(movieTitle);
}
  • Replace the entire function with this async version:
async function searchSoundtrack(movieTitle) {
    // Update the heading to show which movie's soundtrack we're loading
    soundtrackHeading.textContent = `Soundtrack: ${movieTitle}`;
    tracksList.innerHTML = "<p class='loading'>Loading soundtrack...</p>";
    soundtrackSection.classList.remove("hidden");

    try {
        // Search iTunes for songs related to this movie's soundtrack
        const url = `https://itunes.apple.com/search?term=${encodeURIComponent(movieTitle + " soundtrack")}&media=music&entity=song&country=US&limit=10`;
        const response = await fetch(url);

        if (!response.ok) {
            throw new Error("Failed to fetch soundtrack");
        }

        const data = await response.json();
        displayTracks(data.results);
    } catch (error) {
        tracksList.innerHTML = "<p class='error'>Could not load soundtrack. Please try again.</p>";
    }
}

What does this code do?

This function immediately shows a loading message and reveals the soundtrack section so the user gets instant feedback.

The API URL appends " soundtrack" to the movie title and searches with media=music and entity=song. This tells the iTunes API to return music tracks instead of movies. If anything fails, the catch block shows a friendly error instead of crashing.

Why search for the movie title plus 'soundtrack'?

The iTunes API has no direct link between a movie and its soundtrack album. Adding the word "soundtrack" to the search term helps the API return relevant music tracks associated with the film rather than unrelated songs.

  • Save script.js.
  • Refresh your browser, search for "Interstellar", and click a movie card. You should see the heading change to "Soundtrack: Interstellar" and then an error message appear. This is expected because displayTracks does not exist yet.

Not seeing the soundtrack heading appear?

Make sure you replaced the entire old searchSoundtrack function, including the opening function keyword and closing brace. Having two functions with the same name causes unexpected behavior.

Also confirm you added the async keyword before function. Without it, await fetch(url) throws a syntax error.

Help me debug why my soundtrack heading isn't appearing when I click a movie card.

Write displayTracks and formatDuration

The API returns an array of track objects with fields like trackName, artistName, and trackTimeMillis. You need a function to turn that raw data into visible track cards, and a helper to format milliseconds into a readable duration.

  • Add the displayTracks and formatDuration functions below the searchSoundtrack function by pasting this code:
function displayTracks(tracks) {
    tracksList.innerHTML = "";

    // Handle empty results
    if (tracks.length === 0) {
        tracksList.innerHTML = "<p>No soundtrack found for this movie.</p>";
        return;
    }

    tracks.forEach(function (track) {
        const card = document.createElement("div");
        card.className = "track-card";

        // Format the track duration and get artwork URL
        const duration = formatDuration(track.trackTimeMillis);
        const artwork = track.artworkUrl100 || "";

        // Build the card HTML with artwork, track info, and duration
        card.innerHTML = `
            <img src="${artwork}" alt="${track.collectionName}">
            <div class="track-info">
                <h4>${track.trackName}</h4>
                <p>${track.artistName} &middot; ${track.collectionName}</p>
            </div>
            <span class="track-duration">${duration}</span>
        `;

        // Add a play button if this track has an audio preview
        if (track.previewUrl) {
            const playButton = document.createElement("button");
            playButton.className = "play-button";
            playButton.textContent = "\u25B6";
            playButton.addEventListener("click", function (event) {
                event.stopPropagation();
                toggleAudio(track.previewUrl, playButton);
            });
            card.appendChild(playButton);
        }

        tracksList.appendChild(card);
    });
}

function formatDuration(milliseconds) {
    // Convert milliseconds to a M:SS string
    if (!milliseconds) return "0:00";
    const totalSeconds = Math.floor(milliseconds / 1000);
    const minutes = Math.floor(totalSeconds / 60);
    const seconds = totalSeconds % 60;
    return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}

What does this code do?

  • displayTracks clears any previous results, then loops through each track object to build a card with the album artwork, track name, artist, album name, and formatted duration.
  • For tracks that include a previewUrl, it creates a circular play button that will trigger audio playback.
  • formatDuration converts the raw millisecond value from the API (like 179356) into a human-readable format like 2:59.
  • Save script.js and refresh your browser.
  • Search for "Interstellar" and click a movie card. You should see track cards appear in the soundtrack section, each with album artwork, a track name, artist name, and a duration like 2:59. Red play buttons should be visible on each card.

Track cards not appearing?

Open the browser console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows). If you see "formatDuration is not defined", make sure you pasted formatDuration directly below displayTracks and that both functions are outside any other function.

If you see tracks but no artwork images, the API might not return artworkUrl100 for every track. This is normal for some results.

Help me figure out why my track cards aren't rendering after clicking a movie card.

Add audio playback with toggleAudio

The play buttons are visible but they call toggleAudio which does not exist yet. This function needs to handle three scenarios: starting a new track, pausing the current track, and switching between tracks so only one plays at a time.

  • Add the toggleAudio function between displayTracks and formatDuration by pasting this code:
function toggleAudio(previewUrl, button) {
    // If the same button is clicked again, pause and reset
    if (currentAudio && currentPlayButton === button) {
        currentAudio.pause();
        currentAudio = null;
        button.textContent = "\u25B6";
        currentPlayButton = null;
        return;
    }

    // If a different track is playing, stop it first
    if (currentAudio) {
        currentAudio.pause();
        currentPlayButton.textContent = "\u25B6";
    }

    // Play the new track
    currentAudio = new Audio(previewUrl);
    currentPlayButton = button;
    button.textContent = "\u23F8";
    currentAudio.play();

    // Reset button when the preview finishes
    currentAudio.addEventListener("ended", function () {
        button.textContent = "\u25B6";
        currentAudio = null;
        currentPlayButton = null;
    });
}

What does this code do?

  • If the user clicks the same play button again, it pauses the track and resets the button icon back to a play triangle.
  • If a different track is already playing, it stops that one first so only one track plays at a time.
  • It creates a new Audio object from the previewUrl, starts playback, and swaps the button icon to a pause symbol.
  • The ended event listener resets everything when the 30-second preview finishes naturally.

Why use currentAudio and currentPlayButton variables?

These two variables at the top of your file act as a simple "state tracker" for audio playback. By remembering which track is playing and which button is active, the function can pause the old track before starting a new one. Without them, multiple tracks would overlap and play simultaneously.

  • Save script.js and refresh your browser.
  • Search for "Interstellar", click a movie card, then click a red play button on any track. You should hear a 30-second audio preview. The button icon should change to a pause symbol while playing.
  • Try clicking play on a different track while one is already playing. The first track should stop and the new one should start.

No audio playing when you click the button?

Some browsers block autoplay audio until the user has interacted with the page. Clicking the play button counts as an interaction, so this should work. If it still does not play, check the console for errors referencing toggleAudio.

Make sure toggleAudio is defined at the top level of your file (not nested inside displayTracks or any other function). Also verify the Unicode escape characters are correct: \u25B6 for the play triangle and \u23F8 for the pause icon.

Help me debug why clicking the play button doesn't produce any audio.

✔️ Awesome, I've got everything!

Great. Double check you have saved script.js before moving on.

ⓧ I'd like to double check the full code

Here is the complete script.js as it should look at this point:

const searchForm = document.getElementById("search-form");
const searchInput = document.getElementById("search-input");
const loadingEl = document.getElementById("loading");
const errorEl = document.getElementById("error-message");
const moviesSection = document.getElementById("movies-section");
const moviesGrid = document.getElementById("movies-grid");
const soundtrackSection = document.getElementById("soundtrack-section");
const soundtrackHeading = document.getElementById("soundtrack-heading");
const tracksList = document.getElementById("tracks-list");

let currentAudio = null;
let currentPlayButton = null;

searchForm.addEventListener("submit", async function (event) {
    event.preventDefault();
    const query = searchInput.value.trim();
    if (!query) return;

    showLoading();
    hideError();
    hideSoundtrack();

    try {
        const movies = await searchMovies(query);
        displayMovies(movies);
    } catch (error) {
        showError("Something went wrong. Please try again.");
    } finally {
        hideLoading();
    }
});

async function searchMovies(query) {
    const url = `https://itunes.apple.com/search?term=${encodeURIComponent(query)}&media=movie&entity=movie&country=US&limit=8`;
    const response = await fetch(url);

    if (!response.ok) {
        throw new Error("Failed to fetch movies");
    }

    const data = await response.json();
    return data.results;
}

function displayMovies(movies) {
    moviesGrid.innerHTML = "";

    if (movies.length === 0) {
        moviesGrid.innerHTML = "<p>No movies found. Try a different search.</p>";
        moviesSection.classList.remove("hidden");
        return;
    }

    movies.forEach(function (movie) {
        const card = document.createElement("div");
        card.className = "movie-card";

        const artwork = movie.artworkUrl100
            ? movie.artworkUrl100.replace("100x100bb", "600x600bb")
            : "";
        const year = movie.releaseDate
            ? new Date(movie.releaseDate).getFullYear()
            : "N/A";

        card.innerHTML = `
            <img src="${artwork}" alt="${movie.trackName}">
            <div class="movie-card-info">
                <h3>${movie.trackName}</h3>
                <p>${movie.primaryGenreName} &middot; ${year}</p>
            </div>
        `;

        card.addEventListener("click", function () {
            document.querySelectorAll(".movie-card").forEach(function (c) {
                c.classList.remove("selected");
            });
            card.classList.add("selected");
            searchSoundtrack(movie.trackName);
        });

        moviesGrid.appendChild(card);
    });

    moviesSection.classList.remove("hidden");
}

async function searchSoundtrack(movieTitle) {
    soundtrackHeading.textContent = `Soundtrack: ${movieTitle}`;
    tracksList.innerHTML = "<p class='loading'>Loading soundtrack...</p>";
    soundtrackSection.classList.remove("hidden");

    try {
        const url = `https://itunes.apple.com/search?term=${encodeURIComponent(movieTitle + " soundtrack")}&media=music&entity=song&country=US&limit=10`;
        const response = await fetch(url);

        if (!response.ok) {
            throw new Error("Failed to fetch soundtrack");
        }

        const data = await response.json();
        displayTracks(data.results);
    } catch (error) {
        tracksList.innerHTML = "<p class='error'>Could not load soundtrack. Please try again.</p>";
    }
}

function displayTracks(tracks) {
    tracksList.innerHTML = "";

    if (tracks.length === 0) {
        tracksList.innerHTML = "<p>No soundtrack found for this movie.</p>";
        return;
    }

    tracks.forEach(function (track) {
        const card = document.createElement("div");
        card.className = "track-card";

        const duration = formatDuration(track.trackTimeMillis);
        const artwork = track.artworkUrl100 || "";

        card.innerHTML = `
            <img src="${artwork}" alt="${track.collectionName}">
            <div class="track-info">
                <h4>${track.trackName}</h4>
                <p>${track.artistName} &middot; ${track.collectionName}</p>
            </div>
            <span class="track-duration">${duration}</span>
        `;

        if (track.previewUrl) {
            const playButton = document.createElement("button");
            playButton.className = "play-button";
            playButton.textContent = "\u25B6";
            playButton.addEventListener("click", function (event) {
                event.stopPropagation();
                toggleAudio(track.previewUrl, playButton);
            });
            card.appendChild(playButton);
        }

        tracksList.appendChild(card);
    });
}

function toggleAudio(previewUrl, button) {
    if (currentAudio && currentPlayButton === button) {
        currentAudio.pause();
        currentAudio = null;
        button.textContent = "\u25B6";
        currentPlayButton = null;
        return;
    }

    if (currentAudio) {
        currentAudio.pause();
        currentPlayButton.textContent = "\u25B6";
    }

    currentAudio = new Audio(previewUrl);
    currentPlayButton = button;
    button.textContent = "\u23F8";
    currentAudio.play();

    currentAudio.addEventListener("ended", function () {
        button.textContent = "\u25B6";
        currentAudio = null;
        currentPlayButton = null;
    });
}

function formatDuration(milliseconds) {
    if (!milliseconds) return "0:00";
    const totalSeconds = Math.floor(milliseconds / 1000);
    const minutes = Math.floor(totalSeconds / 60);
    const seconds = totalSeconds % 60;
    return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}

function showLoading() {
    loadingEl.classList.remove("hidden");
}

function hideLoading() {
    loadingEl.classList.add("hidden");
}

function showError(message) {
    errorEl.textContent = message;
    errorEl.classList.remove("hidden");
}

function hideError() {
    errorEl.classList.add("hidden");
}

function hideSoundtrack() {
    soundtrackSection.classList.add("hidden");
}

Your Movie Soundtrack Explorer is fully functional. You can search for any movie, see matching results, click one, and listen to 30-second previews of its soundtrack tracks. Next up, you will tackle the Secret Mission and add persistent search history using localStorage.

Secret mission

Add Search History with localStorage

Your Movie Soundtrack Explorer works great, but every time you refresh the page, your previous searches vanish. Can you add a persistent search history that survives page reloads using localStorage?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine with no cloud services or paid APIs. There are no ongoing costs to worry about.

Decide whether to keep your project files, or delete them entirely.

Resources you used:

  • Project folder on your Desktop containing index.html, style.css, and script.js.
  • Browser localStorage data stored under the key movieSearchHistory.

Keep everything running

No action needed. Choose this if you want to keep experimenting with your Movie Soundtrack Explorer or extend it further.

  • Your project files stay in the folder on your Desktop.
  • Your search history remains in localStorage and will appear next time you open the app.

Pause - I'll come back to this later

Since this project has no running processes or cloud services, pausing is the same as keeping. Your files sit on disk and cost nothing.

  • Close the browser tab with your app.
  • Close the project in VS Code.
  • Your files will be waiting when you come back.

Delete - I don't want to use this again

Remove all project files and stored data to start fresh.

  • Clear the localStorage data by opening your browser console and running:
localStorage.removeItem("movieSearchHistory")
  • Delete the project folder from your Desktop by running this in your terminal:

macOS

rm -rf ~/Desktop/movie-soundtrack-explorer

Windows

rmdir /s /q %USERPROFILE%\Desktop\movie-soundtrack-explorer

Nice Work!

Nice Work!

You just built a fully functional Movie Soundtrack Explorer that connects to a real API, renders dynamic content, and plays audio previews.

You've learned how to:

  • Used async/await and the fetch API to make HTTP requests to the iTunes Search API, parse JSON responses, and dynamically render UI cards from live data.
  • Built responsive movie and track card layouts with CSS Grid, complete with hover effects, loading states, and user-friendly error handling.
  • Implemented HTML5 Audio playback with play/pause controls that ensure only one track plays at a time.
  • Secret Mission: Added persistent search history using localStorage with clickable chips that survive page refreshes.

Ready to quiz yourself?