Vibe coding with AI course · Module 6: YouTube Clone

Playlists and Collections - Organizing Videos

8 min read ·
In this lesson5

The captain recorded twelve episodes about hunting the Kraken. A viewer who wants to watch them in order currently has to search for each one separately. The solution is a playlist: an ordered list of videos under one title, like a collection of chronicles from a single voyage.

The playlist model

A playlist needs a title, a description, a creator and a list of videos. The Playlist interface describes all of it:

interface Playlist {
  id: string;
  title: string;
  description: string;
  thumbnail: string; // Thumbnail of the first video
  creator: {
    id: string;
    name: string;
    avatar: string;
  };
  videos: Video[];
  isPublic: boolean;
  createdDate: Date;
  updatedDate: Date;
}

The videos field is an array of Video objects from the previous lesson, so the playlist doesn't invent a new video format. isPublic decides whether everyone can see the playlist or only its author, and thumbnail is usually the thumbnail of the first video. There are no comments or subscribers here, because those belong to videos and channels.

The playlist card

In a list of playlists, each one is represented by a card. From the top: the thumbnail with the video count, the title, the creator's name and a row with the visibility:

function PlaylistCard({ playlist }: { playlist: Playlist }) {
  return (
    <div className="group cursor-pointer">
      {/* Playlist Thumbnail Stack */}
      <div className="relative aspect-video bg-gray-900 rounded-lg overflow-hidden mb-2">
        {/* Main Thumbnail */}
        <img
          src={playlist.thumbnail}
          alt={playlist.title}
          className="w-full h-full object-cover"
        />

        {/* Playlist Overlay */}
        <div className="absolute inset-0 bg-gradient-to-r from-transparent to-black/80 flex items-center justify-end p-4">
          <div className="text-white text-right">
            <div className="text-4xl mb-2">◆</div>
            <p className="font-bold">{playlist.videos.length}</p>
            <p className="text-xs">videos</p>
          </div>
        </div>

        {/* Play All Button */}
        <div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-opacity flex items-center justify-center">
          <button className="opacity-0 group-hover:opacity-100 transition-opacity bg-white text-black px-6 py-3 rounded-full font-bold flex items-center gap-2">
            ▶ Play all
          </button>
        </div>
      </div>

      {/* Playlist Info */}
      <h3 className="font-semibold line-clamp-2 mb-1">{playlist.title}</h3>
      <p className="text-sm text-gray-600">
        {playlist.creator.name}
      </p>
      <p className="text-sm text-gray-600">
        {playlist.isPublic ? 'Public' : 'Private'} • {playlist.videos.length} videos
      </p>
    </div>
  );
}

The video count is simply playlist.videos.length. The expression playlist.isPublic ? 'Public' : 'Private' picks the label, and the Play all button only appears on hover, thanks to the familiar group-hover. The image comes from the playlist.thumbnail field. The card only displays data, it doesn't change anything in the playlist.

In Tailwind 4, hover: variants only apply on devices with a cursor, so on a phone Play all never appears. Don't hide an important action behind hover alone.

The playlist page and the current video

On the playlist page the player takes the left side, below it there is a bar with the playlist info, and on the right the list of videos. We also need to remember which video is playing right now:

'use client';

import { useState } from 'react';

export default function PlaylistPage({ playlist }: { playlist: Playlist }) {
  const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
  const currentVideo = playlist.videos[currentVideoIndex];

  return (
    <div className="max-w-7xl mx-auto p-4">
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
        {/* Left - Video Player */}
        <div className="lg:col-span-2">
          <VideoPlayer video={currentVideo} />

          <div className="mt-4 bg-gradient-to-r from-purple-600 to-blue-600 text-white p-4 rounded-lg">
            <div className="flex items-center gap-3 mb-2">
              <span className="text-sm font-semibold">List</span>
              <div>
                <h2 className="font-bold text-lg">{playlist.title}</h2>
                <p className="text-sm opacity-90">
                  {currentVideoIndex + 1} / {playlist.videos.length}
                </p>
              </div>
            </div>
            <p className="text-sm opacity-80">{playlist.description}</p>
          </div>
        </div>

        {/* Right - Playlist Videos */}
        <div className="bg-white rounded-lg shadow">
          <div className="p-4 border-b">
            <h3 className="font-bold">Playlist</h3>
            <p className="text-sm text-gray-600">
              {playlist.videos.length} videos
            </p>
          </div>

          <div className="max-h-[600px] overflow-y-auto">
            {playlist.videos.map((video, index) => (
              <div
                key={video.id}
                onClick={() => setCurrentVideoIndex(index)}
                className={`flex gap-3 p-3 cursor-pointer border-b hover:bg-gray-50 ${
                  index === currentVideoIndex ? 'bg-blue-50' : ''
                }`}
              >
                {/* Index */}
                <div className="shrink-0 w-6 text-center text-gray-600 font-medium">
                  {index === currentVideoIndex ? '▶' : index + 1}
                </div>

                {/* Thumbnail */}
                <div className="relative w-32 aspect-video bg-gray-200 rounded shrink-0">
                  <img
                    src={video.thumbnail}
                    alt={video.title}
                    className="w-full h-full object-cover"
                  />
                  <div className="absolute bottom-1 right-1 bg-black/80 text-white text-xs px-1 rounded">
                    {formatTime(video.duration)}
                  </div>
                </div>

                {/* Info */}
                <div className="flex-1 min-w-0">
                  <h4 className="text-sm font-semibold line-clamp-2 mb-1">
                    {video.title}
                  </h4>
                  <p className="text-xs text-gray-600">
                    {video.channel.name}
                  </p>
                </div>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

Plain state is enough for that, no localStorage or cookies needed. const [currentVideoIndex, setCurrentVideoIndex] = useState(0) starts with the first video, and playlist.videos[currentVideoIndex] picks the current one. Clicking an item in the list calls setCurrentVideoIndex(index), so the playing video changes while the playlist itself stays untouched. The counter {currentVideoIndex + 1} / {playlist.videos.length} shows e.g. "2 / 12", because we count indexes from zero and viewers count from one.

When a video ends, the playlist should move on to the next one. You just need to advance the index without going past the end of the array:

const playNext = () =>
  setCurrentVideoIndex(i => Math.min(i + 1, playlist.videos.length - 1));

The function passed to setCurrentVideoIndex receives the current index, and Math.min stops it at the last video. You can hook it up to the onEnded event of the <video> element, and the list of videos stays unchanged.

Creating a playlist

A new playlist is created with a form that has a title, a description and a visibility checkbox:

'use client';

import { useState } from 'react';

export default function CreatePlaylist({ onSave }: { onSave: (playlist: Playlist) => void }) {
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [isPublic, setIsPublic] = useState(true);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    const newPlaylist: Playlist = {
      id: Date.now().toString(),
      title,
      description,
      thumbnail: '/default-playlist.jpg',
      creator: {
        id: 'current-user',
        name: 'Captain Redbeard',
        avatar: '/avatar.jpg',
      },
      videos: [],
      isPublic,
      createdDate: new Date(),
      updatedDate: new Date(),
    };

    onSave(newPlaylist);
  };

  return (
    <div className="bg-white rounded-lg shadow p-6">
      <h2 className="text-xl font-bold mb-4">Create New Playlist</h2>

      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="block font-medium mb-2">Title</label>
          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            className="w-full p-3 border rounded-lg"
            required
          />
        </div>

        <div>
          <label className="block font-medium mb-2">Description</label>
          <textarea
            value={description}
            onChange={(e) => setDescription(e.target.value)}
            className="w-full p-3 border rounded-lg resize-none"
            rows={4}
          />
        </div>

        <div>
          <label className="flex items-center gap-2 cursor-pointer">
            <input
              type="checkbox"
              checked={isPublic}
              onChange={(e) => setIsPublic(e.target.checked)}
              className="w-4 h-4"
            />
            <span className="font-medium">Public</span>
          </label>
          <p className="text-sm text-gray-600 ml-6">
            {isPublic
              ? 'Anyone can view this playlist'
              : 'Only you can view this playlist'}
          </p>
        </div>

        <button
          type="submit"
          className="w-full py-3 bg-red-600 text-white rounded-lg font-bold hover:bg-red-700 transition"
        >
          Create Playlist
        </button>
      </form>
    </div>
  );
}

The flow looks like this: the user types the title and description, sets the isPublic checkbox, clicks Create Playlist, and handleSubmit builds the newPlaylist object and passes it to onSave(newPlaylist). Before that, e.preventDefault() stops the page from reloading. The component doesn't save anything itself, the parent that provides onSave decides. An id from Date.now() is fine in a prototype, but in a real app the database assigns it.

Adding a video to a playlist

The Save button opens a modal with the list of playlists, and each one has a checkbox next to it:

'use client';

import { useState } from 'react';

interface AddToPlaylistProps {
  video: Video;
  playlists: Playlist[];
  onAdd: (playlistId: string, video: Video) => void;
}

export default function AddToPlaylist({ video, playlists, onAdd }: AddToPlaylistProps) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button
        onClick={() => setIsOpen(true)}
        className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-full hover:bg-gray-200"
      >
        Save
      </button>

      {isOpen && (
        <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
          <div className="bg-white rounded-lg max-w-md w-full">
            <div className="flex items-center justify-between p-4 border-b">
              <h3 className="font-bold">Save to playlist</h3>
              <button
                onClick={() => setIsOpen(false)}
                className="text-gray-500 hover:text-gray-700 text-2xl"
              >
                ×
              </button>
            </div>

            <div className="p-4 max-h-96 overflow-y-auto">
              {playlists.length === 0 ? (
                <p className="text-gray-600 text-center py-8">
                  No playlists yet. Create one!
                </p>
              ) : (
                <div className="space-y-2">
                  {playlists.map(playlist => {
                    const isInPlaylist = playlist.videos.some(v => v.id === video.id);

                    return (
                      <label
                        key={playlist.id}
                        className="flex items-center gap-3 p-3 hover:bg-gray-50 rounded cursor-pointer"
                      >
                        <input
                          type="checkbox"
                          checked={isInPlaylist}
                          onChange={() => {
                            if (!isInPlaylist) {
                              onAdd(playlist.id, video);
                            }
                          }}
                          className="w-4 h-4"
                        />
                        <div className="flex-1">
                          <h4 className="font-medium">{playlist.title}</h4>
                          <p className="text-sm text-gray-600">
                            {playlist.videos.length} videos • {playlist.isPublic ? 'Public' : 'Private'}
                          </p>
                        </div>
                      </label>
                    );
                  })}
                </div>
              )}
            </div>

            <div className="p-4 border-t">
              <button className="w-full py-2 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700">
                + Create new playlist
              </button>
            </div>
          </div>
        </div>
      )}
    </>
  );
}

playlist.videos.some(v => v.id === video.id) checks whether the video is already in a given playlist, and if so the box is checked. Checking an empty box calls onAdd, while unchecking does nothing for now, because we haven't handled removal yet. We use checkboxes (input type="checkbox"), not radio buttons, because one video can go into several playlists at once.

Captain Redbeard's tip: don't keep a copy of the current video in separate state. The index is enough, and you can derive the video from the array. One source of truth means fewer storms when you hunt for bugs.

Remember: a playlist is an array of videos plus the index of the one that is playing. In the editor below you have a playlist page: a list of videos and the index of the current one in the currentIdx state. Switch between a few videos, then solve the tasks.

Code for this lesson: App.jsx
// Playlist Page with Video List
import { useState } from 'react';

const playlist = {
  title: 'Pirate Adventures',
  videos: [
    { id: 1, title: 'Battle with the Kraken', channel: 'Redbeard', duration: 720, thumb: '' },
    { id: 2, title: 'Treasure on the island', channel: 'Redbeard', duration: 480, thumb: '' },
    { id: 3, title: 'Storm at sea', channel: 'Redbeard', duration: 540, thumb: '' },
  ]
};

export default function App() {
  const [currentIdx, setCurrentIdx] = useState(0);
  const current = playlist.videos[currentIdx];

  return (
    <div className="min-h-screen bg-gray-100 p-4">
      <div className="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-3 gap-4">
        <div className="lg:col-span-2">
          <div className="aspect-video bg-gradient-to-br from-purple-900 to-blue-900 rounded-lg flex items-center justify-center text-8xl mb-4">
            {current.thumb}
          </div>
          <div className="bg-gradient-to-r from-purple-600 to-blue-600 text-white p-4 rounded-lg">
            <div className="flex items-center gap-2 mb-2">
              <span className="text-sm font-semibold">List</span>
              <div>
                <h2 className="font-bold text-lg">{playlist.title}</h2>
                <p className="text-sm opacity-90">{currentIdx + 1} / {playlist.videos.length}</p>
              </div>
            </div>
          </div>
        </div>

        <div className="bg-white rounded-lg shadow">
          <div className="p-4 border-b">
            <h3 className="font-bold">Playlist</h3>
            <p className="text-sm text-gray-600">{playlist.videos.length} videos</p>
          </div>
          <div className="max-h-96 overflow-y-auto">
            {playlist.videos.map((v, i) => (
              <div
                key={v.id}
                onClick={() => setCurrentIdx(i)}
                className={`flex gap-2 p-3 cursor-pointer border-b hover:bg-gray-50 ${i === currentIdx ? 'bg-blue-50' : ''}`}
              >
                <div className="w-6 text-center">{i === currentIdx ? '▶' : i+1}</div>
                <div className="w-24 aspect-video bg-gradient-to-br from-purple-900 to-blue-900 rounded flex items-center justify-center text-3xl">
                  {v.thumb}
                </div>
                <div className="flex-1">
                  <h4 className="text-sm font-semibold line-clamp-2">{v.title}</h4>
                  <p className="text-xs text-gray-600">{v.channel}</p>
                </div>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What does the Playlist interface contain?

  2. 2. How does PlaylistPage get the currently playing video?

These are 2 of 6 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Horizontal ordering

    Arrange the expression that shows the playlist visibility label (single line):

  • Vertical ordering

    Arrange the elements of the playlist card (PlaylistCard) from top to bottom:

  • Code editor

    Playlist card component

  • Horizontal ordering

    Arrange the sections of the playlist page:

  • Click in order

    Build the state declaration that tracks the current video in a playlist:

  • Vertical ordering

    Arrange the steps of creating a new playlist in CreatePlaylist:

  • Code editor

    Add to playlist modal

  • Horizontal ordering

    Arrange the expression that shows the 'X / total' position in a playlist (single line):

  • Click in order

    Build the expression that reads the currently playing video from the playlist:

Useful articles