๐ŸŽฌ Build a Movie Streaming Site

Zero experience required. By the end of this guide you will have built, from scratch, a working movie streaming site โ€” the same kind behind JcMovies: browse trending and popular titles, search, see details, and press play. Every file is included in full, ready to copy.

1. What are we building?

A Netflix-style single-page app: browse trending, popular, and top-rated movies and TV shows, search the catalog, open a detail page with cast and trailers, and press play โ€” all for free. The site is static hosting (Netlify) plus a single serverless function that keeps the TMDB API key hidden from browsers.

๐ŸŽฌ Analogy โ€” A Cinema with No Ticket Booth
TMDB is like a giant cinema catalog: it knows every movie, poster, and rating, but it doesn't run the screening rooms. Embed players are like TV channels you tune into. Your site is the remote control that connects both โ€” pretty and easy to use.

๐ŸŽฏ Features of the Finished Site

FeatureHow it's built
Home categoriesTrending / Popular / Top Rated tabs, plus curated rows (Filipino, Korean, Anime)
SearchTMDB search with a "Load More" button for paginated results
FiltersSort, genre, and year dropdowns on the sidebar
Detail viewSynopsis, cast, and "You May Also Like" suggestions
Video player12 embed sources tried in order until one plays
Adult filterHides known adult studios and title patterns

2. Why this stack? (The Benefits)

๐Ÿ’ฐ 100% free
TMDB has a free API tier, Netlify hosts static sites free, and no server or database is needed.
๐Ÿ”’ Key stays secret
The TMDB key lives in a serverless function โ€” never shipped to the browser.
๐Ÿ“ฆ No backend to maintain
A static site can't crash, has nothing to patch, and costs nothing to keep running.
๐ŸŽ› Multi-source playback
Embed players fail sometimes โ€” with 12 sources the player keeps working.
๐Ÿ›  Real-world skills
HTML/CSS/JS, REST APIs, serverless functions, environment variables, and deploys.
๐Ÿš€ Deploys in minutes
One netlify deploy command pushes the whole site live.

3. How it works (Architecture)

Three pieces talk to each other. The browser never calls TMDB directly โ€” it always goes through our tiny proxy:

๐Ÿงญ The Request Flow

1. Browser asks our site for trending movies
fetch('/.netlify/functions/tmdb?path=/trending/movie/week')

2. The Netlify function adds the secret API key and forwards the call
https://api.themoviedb.org/3/trending/movie/week?api_key=SECRET&language=en-US

3. TMDB responds with JSON โ€” the browser renders the cards
{ page: 1, results: [ { title: "...", poster_path: "..." } ] }
๐Ÿ”‘ Analogy โ€” A Messenger with the Key
Imagine your TMDB key is your house key. You wouldn't hand it to every visitor โ€” but the contractor needs to get in to do repairs. The Netlify function is your trusted caretaker: only it holds the key, it escorts the contractor in, and comes back with just the result. Same idea: the browser asks for data, the function carries the key, and only the data returns to the browser.

๐Ÿค” Self-hosted database vs TMDB?

Self-hosted catalogTMDB catalog (what we use)
Setup effortSchema, seed data, daily scrapersOne free API key, zero setup
FreshnessAs good as your scrapingUpdated continuously by TMDB
CostServer + storage + bandwidthFree tier for low traffic
Data qualityYour problemRatings, posters, cast out of the box
PlaybackSame either way โ€” embedsSame either way โ€” embeds

5. Anatomy of the Project (The Files)

๐Ÿ“ Your project folder at a glance

my-movie-site/
โ”œโ”€โ”€ index.html
โ”œโ”€โ”€ styles.css
โ”œโ”€โ”€ config.js
โ”œโ”€โ”€ app.js
โ”œโ”€โ”€ netlify.toml
โ””โ”€โ”€ netlify/
    โ””โ”€โ”€ functions/
        โ””โ”€โ”€ tmdb.js
File (in your project folder)What it does
index.htmlThe page structure โ€” all 5 screens ("views") live here
styles.cssEverything visual: colors, cards, grid, player layout
config.jsSettings: your proxy path, image URL base, and the embed source list
app.jsThe behavior: fetching, rendering, search, filters, player
netlify/functions/tmdb.jsThe secret keeper: server-side proxy for the TMDB API key
netlify.tomlDeploy settings Netlify reads when you push the site

Everything is plain HTML, CSS, and vanilla JavaScript โ€” no build step, no framework, no npm dependencies. That keeps deploys instant and the site blazing fast.

6. Before you start (Tools & Accounts)

Grab the free tools

You need Node.js (for Netlify's command-line tool โ€” download from nodejs.org, pick the LTS button) and any code editor. VS Code (code.visualstudio.com) is the friendliest for beginners.

Make two free accounts

Sign up at themoviedb.org (the catalog) and netlify.com (the hosting). Both are free; you can sign in to Netlify with GitHub or email.

Understand what you'll do

You'll create 6 text files, paste the code from this guide, put one API key in a secret setting, and run two commands. There is no complicated configuration anywhere.

7. Step-by-Step: From Zero to a Streaming Site

Get a free TMDB API key

Log in at themoviedb.org โ†’ click your avatar โ†’ Settings โ†’ API โ†’ Request an API Key. Choose Developer, answer the questions (the site URL can be blank for now), and you'll get a v3 API key โ€” a long string of letters and numbers.

Test your key right away. Open this address in your browser and paste your key in YOUR_KEY:

https://api.themoviedb.org/3/trending/movie/week?api_key=YOUR_KEY

You should see a wall of JSON with trending movies. If you see an error, double-check that you copied the whole key.

โ˜๏ธ Keep the key classified โ€” anyone with it can use your TMDB quota. We will only ever put it in the server setting, never in our code.

Create your project folder and files

Open a terminal and create an empty folder for your project with:

mkdir my-movie-site
cd my-movie-site

Now create the folder structure for the proxy function:

mkdir -p netlify/functions

Inside my-movie-site there will be 6 files. The next step shows every one of them in full โ€” create them with your code editor (VS Code โ†’ File โ†’ Open Folder โ†’ my-movie-site โ†’ New File), paste the code, and save with the exact file names.

netlify.toml โ€” what Netlify reads

This tiny file holds two settings: publish the whole folder, and look for functions in netlify/functions. Because there is no build command, Netlify serves the files exactly as they are โ€” no build step.

๐Ÿ“„ netlify.toml (project root)

[build]
  publish = "."
  functions = "netlify/functions"
netlify/functions/tmdb.js โ€” the secret keeper

This is the only file that ever sees your API key. It receives calls like /.netlify/functions/tmdb?path=/trending/movie/week, adds the key from the environment variable TMDB_API_KEY, forwards the request to TMDB, and sends the JSON answer back.

Why not just call TMDB directly from the browser? Because the key would be visible to anyone who opens DevTools. This way it stays server-side forever.

๐Ÿ“„ netlify/functions/tmdb.js

const TMDB_BASE = 'https://api.themoviedb.org/3'

exports.handler = async (event) => {
  const q = event.queryStringParameters || {}
  const endpoint = q.path || '/'
  const customKey = q.api_key
  const apiKey = customKey || process.env.TMDB_API_KEY

  if (!apiKey) {
    return { statusCode: 500, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'Server API key not configured' }) }
  }

  const params = new URLSearchParams({ api_key: apiKey, language: 'en-US' })
  for (const [k, v] of Object.entries(q)) {
    if (k === 'path' || k === 'api_key') continue
    if (v) params.set(k, v)
  }

  const url = `${TMDB_BASE}${endpoint}?${params}`
  try {
    const res = await fetch(url)
    const body = await res.text()
    return {
      statusCode: res.status,
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*',
      },
      body,
    }
  } catch (e) {
    return { statusCode: 502, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'TMDB proxy error' }) }
  }
}

Walk through it: event.queryStringParameters is the ? part of the URL, process.env.TMDB_API_KEY reads the environment variable, params.set(k, v) copies every extra parameter (like query or page), and fetch + await res.text() relays the answer in one piece.

index.html โ€” the shell with 5 screens

The whole site is one page with five views โ€” divs that get shown or hidden by JavaScript. The navbar holds the logo, the Movies/TV tabs, and the search box. The sidebar holds the filters.

ViewWhat it shows
home-viewCategories (Trending, Popular, Top Rated...) + sidebar filters
search-viewSearch results with a Load More button
detail-viewOne movie/TV show: synopsis, cast, similar titles
player-viewThe video player with embed source buttons
setup-viewOptional: type your own TMDB key (only if you skip the server setting)

๐Ÿ“„ index.html (project root)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>JcMovies - Watch Free Movies & TV</title>
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>๐ŸŽฌ</text></svg>">
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <div id="app">
    <nav id="navbar">
      <div class="nav-inner">
        <a href="#" class="logo" onclick="navigate('home')">JcMovies</a>
        <div class="tabs">
          <button class="tab-btn active" onclick="switchTab('movie')" id="tab-movie">Movies</button>
          <button class="tab-btn" onclick="switchTab('tv')" id="tab-tv">TV Shows</button>
        </div>
        <div class="nav-right">
          <div class="search-box">
            <input type="text" id="searchInput" placeholder="Search movies & TV..." onkeydown="if(event.key==='Enter')doSearch()">
            <button onclick="doSearch()">Search</button>
          </div>
          <button class="settings-btn" id="filterBtn" onclick="toggleAdultFilter()" title="Toggle adult content filter">Filter: On</button>
          <button class="settings-btn" onclick="showSetup()" title="Change API Key">Key</button>
        </div>
      </div>
    </nav>

    <main id="main-content">
      <div class="content-wrap">
        <aside id="sidebar">
          <h3>Filters</h3>
          <label class="filter-label">Sort By</label>
          <select id="filterSort" onchange="applyFilters()">
            <option value="">Popular</option>
            <option value="vote_average.desc">Top Rated</option>
            <option value="release_date.desc">Newest</option>
            <option value="release_date.asc">Oldest</option>
          </select>

          <label class="filter-label">Genre</label>
          <select id="filterGenre" onchange="applyFilters()">
            <option value="">All Genres</option>
          </select>

          <label class="filter-label">Year</label>
          <select id="filterYear" onchange="applyFilters()">
            <option value="">Any Year</option>
          </select>

          <button class="filter-reset" onclick="resetFilters()">Reset</button>
        </aside>

      <div id="home-view">
        <div id="category-bar"></div>
        <section id="category-section">
          <h2 id="categoryTitle">Trending Movies</h2>
          <div class="movie-grid" id="categoryGrid"></div>
          <div id="categoryLoadMore" style="display:none"><button onclick="loadMoreCategory()">Load More</button></div>
        </section>
      </div>

      <div id="search-view" style="display:none">
        <h2 id="searchTitle">Search Results</h2>
        <div class="movie-grid" id="searchGrid"></div>
        <div id="searchLoadMore"><button onclick="loadMoreSearch()">Load More</button></div>
      </div>
      </div>

      <div id="detail-view" style="display:none">
        <button class="back-btn" onclick="navigate('back')">Back</button>
        <div id="detailContent"></div>
        <section id="cast-section" style="margin-top:30px">
          <h3>Cast</h3>
          <div class="cast-grid" id="castGrid"></div>
        </section>
        <section id="similar-section" style="margin-top:30px">
          <h3>You May Also Like</h3>
          <div class="movie-grid" id="similarGrid"></div>
        </section>
      </div>

      <div id="player-view" style="display:none">
        <button class="back-btn" onclick="closePlayer()">Back</button>
        <div id="playerContent"></div>
      </div>

      <div id="setup-view" style="display:none">
        <div class="setup-box">
          <h2>TMDB API Key</h2>
          <p>Get a free key from <a href="https://www.themoviedb.org/settings/api" target="_blank">TMDB</a> (signup required, free).</p>
          <input type="text" id="apiKeyInput" placeholder="Enter your TMDB API Key">
          <button onclick="saveApiKey()">Save</button>
        </div>
      </div>
    </main>
  </div>

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

Notice the pattern: a normal web page is a page per URL; a single-page app is one URL and many div screens. All clicks either fetch data or switch views โ€” the browser never reloads.

styles.css โ€” the dark theme

Every card, button, grid, and screen style lives here. The color scheme is a dark navy background (#0f0f0f) with a red-pink accent (#e94560). To re-theme the site later, start by changing these two colors.

๐Ÿ“„ styles.css (project root)

* { margin:0; padding:0; box-sizing:border-box; }
body {
  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
  background:#0f0f0f; color:#eee;
  min-height:100vh;
}
a { color:#5a9eff; text-decoration:none; }

#navbar {
  position:fixed; top:0; left:0; right:0; z-index:100;
  background:#1a1a2e; border-bottom:1px solid #2a2a4a;
}
.nav-inner {
  max-width:1200px; margin:0 auto;
  display:flex; align-items:center; justify-content:space-between;
  padding:12px 20px; gap:12px;
}
.logo { font-size:1.4rem; font-weight:700; color:#e94560; white-space:nowrap; }

.tabs { display:flex; gap:4px; }
.tab-btn {
  background:transparent; color:#888; border:1px solid #3a3a5a;
  border-radius:6px; padding:8px 16px; cursor:pointer; font-size:0.85rem;
  white-space:nowrap;
}
.tab-btn:hover { color:#eee; border-color:#5a5a7a; }
.tab-btn.active { background:#e94560; color:#fff; border-color:#e94560; }

.nav-right { display:flex; align-items:center; gap:6px; }
.search-box { display:flex; gap:4px; }
.search-box input {
  background:#16213e; border:1px solid #2a2a4a; border-radius:6px;
  color:#eee; padding:8px 14px; width:200px; font-size:0.9rem;
  outline:none;
}
.search-box input:focus { border-color:#e94560; }
.search-box button {
  background:#e94560; color:#fff; border:none; border-radius:6px;
  padding:8px 14px; cursor:pointer; font-weight:600; font-size:0.85rem;
}
.search-box button:hover { background:#d63851; }
#filterBtn.active { background:#2a7a3a; color:#fff; border-color:#2a7a3a; }
#filterBtn { transition:all .2s; }
.settings-btn {
  background:transparent; color:#888; border:1px solid #3a3a5a; border-radius:6px;
  padding:8px 12px; cursor:pointer; font-size:0.8rem;
}
.settings-btn:hover { color:#eee; border-color:#5a5a7a; }

#main-content { max-width:1200px; margin:0 auto; padding:80px 20px 40px; }

.content-wrap { display:flex; gap:24px; align-items:flex-start; }
#home-view, #search-view { flex:1; min-width:0; }

#sidebar {
  width:210px; flex-shrink:0; position:sticky; top:80px;
  background:#1a1a2e; border:1px solid #2a2a4a;
  border-radius:10px; padding:18px; display:flex; flex-direction:column; gap:12px;
}
#sidebar h3 { font-size:1rem; margin-bottom:4px; color:#e94560; }
.filter-label { font-size:0.75rem; color:#888; text-transform:uppercase; letter-spacing:0.5px; margin-top:4px; }
#sidebar select {
  width:100%; background:#0f0f0f; color:#eee; border:1px solid #2a2a4a;
  border-radius:8px; padding:10px 12px; font-size:0.9rem; cursor:pointer; outline:none;
}
#sidebar select:focus { border-color:#e94560; }
.filter-reset {
  margin-top:6px; background:#e94560; color:#fff; border:none; border-radius:8px;
  padding:10px; cursor:pointer; font-weight:600;
}
.filter-reset:hover { background:#d63851; }

@media (max-width:760px) {
  .content-wrap { flex-direction:column; }
  #sidebar { width:100%; position:static; flex-direction:row; flex-wrap:wrap; }
  #sidebar select { width:calc(50% - 6px); }
}

h2 { font-size:1.5rem; margin:30px 0 16px; color:#ddd; }
h3 { font-size:1.2rem; margin:20px 0 12px; color:#ccc; }

.movie-grid {
  display:grid;
  grid-template-columns:repeat(auto-fill,minmax(160px,1fr));
  gap:16px;
}
.movie-card {
  cursor:pointer; border-radius:8px; overflow:hidden;
  background:#1a1a2e; transition:transform .2s,box-shadow .2s;
}
.movie-card:hover { transform:translateY(-4px); box-shadow:0 8px 24px rgba(233,69,96,.2); }
.movie-card img {
  width:100%; aspect-ratio:2/3; object-fit:cover;
  display:block; background:#2a2a4a;
}
.movie-card .card-body { padding:10px; }
.movie-card .title {
  font-size:0.85rem; font-weight:600; white-space:nowrap;
  overflow:hidden; text-overflow:ellipsis;
}
.movie-card .meta {
  font-size:0.75rem; color:#888; margin-top:4px;
  display:flex; justify-content:space-between;
}
.movie-card .rating { color:#ffc107; }
.movie-card .year { color:#666; }
.movie-card .media-type {
  position:absolute; top:8px; right:8px;
  background:rgba(233,69,96,.85); color:#fff;
  font-size:0.65rem; padding:2px 6px; border-radius:4px;
  font-weight:600;
}
.movie-card { position:relative; }

.cast-grid {
  display:grid;
  grid-template-columns:repeat(auto-fill,minmax(120px,1fr));
  gap:12px;
}
.cast-card {
  text-align:center; background:#1a1a2e; border-radius:8px;
  padding:10px; overflow:hidden;
}
.cast-card img {
  width:80px; height:80px; border-radius:50%; object-fit:cover;
  display:block; margin:0 auto 8px; background:#2a2a4a;
}
.cast-card .name { font-size:0.8rem; font-weight:600; }
.cast-card .role { font-size:0.7rem; color:#888; margin-top:2px; }

.back-btn {
  background:#2a2a4a; color:#eee; border:none; border-radius:6px;
  padding:10px 20px; cursor:pointer; font-size:0.9rem; margin-bottom:20px;
}
.back-btn:hover { background:#3a3a5a; }

#detailContent { display:flex; gap:30px; flex-wrap:wrap; }
.detail-poster { flex-shrink:0; }
.detail-poster img { width:300px; border-radius:10px; box-shadow:0 4px 20px rgba(0,0,0,.5); }
.detail-info { flex:1; min-width:280px; }
.detail-info h1 { font-size:2rem; margin-bottom:4px; }
.detail-info .tagline { font-size:1rem; color:#888; margin-bottom:16px; }
.detail-info .overview { line-height:1.6; color:#bbb; margin-bottom:16px; }
.detail-info .detail-meta { display:flex; gap:20px; flex-wrap:wrap; margin-bottom:12px; }
.detail-info .detail-meta span { font-size:0.9rem; color:#999; }
.detail-info .genres { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; }
.detail-info .genres span {
  background:#2a2a4a; padding:4px 12px; border-radius:20px; font-size:0.8rem;
}
.detail-info .episode-selector {
  display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:16px;
}
.detail-info .episode-selector select {
  background:#16213e; color:#eee; border:1px solid #2a2a4a;
  border-radius:6px; padding:8px 12px; font-size:0.9rem; outline:none;
}
.watch-btn {
  display:inline-block; background:#e94560; color:#fff;
  border:none; border-radius:8px; padding:14px 40px;
  font-size:1.1rem; font-weight:700; cursor:pointer; margin-top:10px;
}
.watch-btn:hover { background:#d63851; }
.watch-btn.secondary { background:#2a2a4a; margin-left:10px; }
.watch-btn.secondary:hover { background:#3a3a5a; }
.watch-btn.trailer-btn { background:#1a1a2e; border:1px solid #5a5a7a; }

#playerContent iframe {
  width:100%; max-width:1000px; height:calc(100vw * 9/20);
  max-height:80vh; border:none; border-radius:10px;
  display:block; margin:0 auto;
}
.player-shell {
  position:relative; max-width:1000px; margin:0 auto;
}
.player-shell iframe {
  width:100%; height:calc(100vw * 9/20); max-height:80vh;
  border:none; border-radius:10px; display:block;
}
.player-shield {
  position:absolute; inset:0; z-index:10;
  background:rgba(26,26,46,0.82); border-radius:10px;
  display:flex; flex-direction:column; gap:12px;
  align-items:center; justify-content:center; cursor:pointer;
  transition:opacity 0.2s;
}
.player-shield:hover { background:rgba(26,26,46,0.7); }
.shield-play {
  width:80px; height:80px; border-radius:50%;
  background:#e94560; color:#fff; font-size:2rem;
  display:flex; align-items:center; justify-content:center;
  padding-left:6px; box-shadow:0 4px 20px rgba(233,69,96,0.5);
}
.shield-text { color:#ccc; font-size:1rem; }
.source-selector {
  display:flex; gap:8px; flex-wrap:wrap; margin:16px 0;
  justify-content:center;
}
.source-btn {
  background:#2a2a4a; color:#ccc; border:1px solid #3a3a5a;
  border-radius:6px; padding:8px 16px; cursor:pointer; font-size:0.85rem;
}
.source-btn.active { background:#e94560; color:#fff; border-color:#e94560; }
.source-btn:hover { background:#3a3a5a; }

#searchLoadMore, #categoryLoadMore { text-align:center; margin:30px 0; }
#searchLoadMore button, #categoryLoadMore button {
  background:#2a2a4a; color:#eee; border:none; border-radius:6px;
  padding:12px 30px; cursor:pointer; font-size:1rem;
}
#searchLoadMore button:hover, #categoryLoadMore button:hover { background:#3a3a5a; }

.setup-box {
  max-width:500px; margin:80px auto; text-align:center;
  background:#1a1a2e; border-radius:12px; padding:40px;
}
.setup-box h2 { margin-top:0; }
.setup-box p { margin-bottom:20px; line-height:1.6; color:#aaa; }
.setup-box input {
  width:100%; background:#16213e; border:1px solid #2a2a4a;
  border-radius:6px; color:#eee; padding:12px 16px; font-size:1rem;
  margin-bottom:16px; outline:none;
}
.setup-box button {
  background:#e94560; color:#fff; border:none; border-radius:6px;
  padding:12px 30px; cursor:pointer; font-size:1rem; font-weight:600;
}
.setup-box button:hover { background:#d63851; }

.loading { text-align:center; padding:40px; color:#666; }

#category-bar {
  display:flex; gap:8px; flex-wrap:wrap;
  margin:20px 0 0; padding:12px 0;
  border-top:1px solid #2a2a4a;
}
.category-btn {
  background:transparent; color:#888; border:1px solid #3a3a5a;
  border-radius:20px; padding:8px 18px; cursor:pointer;
  font-size:0.82rem; font-weight:500; transition:all .2s;
  white-space:nowrap;
}
.category-btn:hover { color:#eee; border-color:#5a5a7a; }
.category-btn.active { background:#e94560; color:#fff; border-color:#e94560; }

@media(max-width:700px) {
  #category-bar { justify-content:center; }
  .category-btn { font-size:0.75rem; padding:6px 14px; }
  .nav-inner { flex-wrap:wrap; }
  .tabs { order:3; width:100%; justify-content:center; }
  .search-box input { width:140px; }
  .movie-grid { grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:12px; }
  #detailContent { flex-direction:column; align-items:center; }
  .detail-poster img { width:100%; max-width:300px; }
  .detail-info h1 { font-size:1.5rem; }
}
config.js โ€” the settings file

Three settings in one object.

SettingMeaning
tmdbProxyWhere our Netlify function lives
tmdbImgBaseWhere poster images come from
embedSourcesAll 12 video providers the player will try

Each embed source is a template with placeholders: {id} is replaced with the movie/TV id, {s} and {e} with season and episode numbers. You can add or remove providers here without touching any other file.

๐Ÿ“„ config.js (project root)

const CONFIG = {
  tmdbProxy: '/.netlify/functions/tmdb',
  tmdbImgBase: 'https://image.tmdb.org/t/p',

  embedSources: [
    { name: 'VidSrc', url: 'https://vidsrc.to/embed/movie/{id}', tvUrl: 'https://vidsrc.to/embed/tv/{id}/{s}/{e}' },
    { name: 'VidLink', url: 'https://vidlink.pro/movie/{id}', tvUrl: 'https://vidlink.pro/tv/{id}/{s}/{e}' },
    { name: 'VidSrc 2', url: 'https://vidsrc.in/embed/movie/{id}', tvUrl: 'https://vidsrc.in/embed/tv/{id}/{s}/{e}' },
    { name: '2Embed', url: 'https://www.2embed.cc/embed/{id}', tvUrl: '' },
    { name: 'VidCore', url: 'https://vidcore.org/embed/movie/{id}', tvUrl: 'https://vidcore.org/embed/tv/{id}/{s}/{e}' },
    { name: 'Vidsrc.cc', url: 'https://vidsrc.cc/embed/movie/{id}', tvUrl: 'https://vidsrc.cc/embed/tv/{id}/{s}/{e}' },
    { name: 'SuperEmbed', url: 'https://multiembed.mov/?video_id={id}&tmdb=1', tvUrl: 'https://multiembed.mov/?video_id={id}&tmdb=1&s={s}&e={e}' },
    { name: 'EZVidAPI', url: 'https://ezvidapi.com/embed/movie/{id}', tvUrl: 'https://ezvidapi.com/embed/tv/{id}/{s}/{e}' },
    { name: 'VidLux', url: 'https://vidlux.xyz/embed/movie/{id}', tvUrl: 'https://vidlux.xyz/embed/tv/{id}/{s}/{e}' },
    { name: 'AutoEmbed', url: 'https://autoembed.co/embed/movie/{id}', tvUrl: 'https://autoembed.co/embed/tv/{id}/{s}/{e}' },
    { name: 'VidFast', url: 'https://vidfast.pro/embed/movie/{id}', tvUrl: 'https://vidfast.pro/embed/tv/{id}/{s}/{e}' },
    { name: 'VSembed', url: 'https://vsembed.su/embed/movie/{id}', tvUrl: 'https://vsembed.su/embed/tv/{id}/{s}/{e}' },
  ],
}

app.js โ€” the brain

This is where everything happens: fetching, building cards, switching views, search, filters, details, and the player. Don't read it top to bottom like a book โ€” start with the function map, then read tmdbFetch, switchCategory, doSearch, and showDetail first.

FunctionWhat it does
tmdbFetch()The single door to TMDB โ€” every request goes through it
createCard()Builds one poster card (image, year, rating)
switchCategory() / loadMoreCategory()Loads Trending/Popular/etc. with pagination
applyFilters() / filterVariables()Turns the sidebar dropdowns into API parameters
doSearch() / loadMoreSearch()Searches TMDB and pages through the results
toggleAdultFilter()Blocks known adult studios and title patterns
showDetail()Detail page: overview, cast, "You May Also Like"
loadPlayer() / findWorkingSource()Builds the player, tries each embed source until one plays
watchMovie() / watchTV()Entry points from the Watch buttons
showSetup() / saveApiKey()The "Key" button โ€” type a TMDB key (kept only in your browser)
initApp()Runs when the page loads โ€” shows the home screen

๐Ÿ“„ app.js (project root)

let currentTab = 'movie'
let searchPage = 1
let categoryPage = 1
let currentCategoryId = null
let lastQuery = ''
let currentItem = null
let currentEmbedIndex = 0
let currentSeason = 1
let currentEpisode = 1
let currentShowSeasonData = null

const MOVIE_GENRES = [
  { id: 28, name: 'Action' }, { id: 12, name: 'Adventure' }, { id: 16, name: 'Animation' },
  { id: 35, name: 'Comedy' }, { id: 80, name: 'Crime' }, { id: 99, name: 'Documentary' },
  { id: 18, name: 'Drama' }, { id: 10751, name: 'Family' }, { id: 14, name: 'Fantasy' },
  { id: 36, name: 'History' }, { id: 27, name: 'Horror' }, { id: 10402, name: 'Music' },
  { id: 9648, name: 'Mystery' }, { id: 10749, name: 'Romance' }, { id: 878, name: 'Science Fiction' },
  { id: 10770, name: 'TV Movie' }, { id: 53, name: 'Thriller' }, { id: 10752, name: 'War' },
  { id: 37, name: 'Western' },
]
const TV_GENRES = [
  { id: 10759, name: 'Action & Adventure' }, { id: 16, name: 'Animation' }, { id: 35, name: 'Comedy' },
  { id: 80, name: 'Crime' }, { id: 99, name: 'Documentary' }, { id: 18, name: 'Drama' },
  { id: 10751, name: 'Family' }, { id: 10762, name: 'Kids' }, { id: 9648, name: 'Mystery' },
  { id: 10765, name: 'Sci-Fi & Fantasy' }, { id: 10764, name: 'Reality' }, { id: 10767, name: 'Talk' },
  { id: 10766, name: 'Soap' }, { id: 10768, name: 'War & Politics' }, { id: 37, name: 'Western' },
]
const MOVIE_FILTER_KEY = 'jcmovies_filters'
let activeFilters = {}

const STORAGE_KEY = 'jcmovies_tmdb_key'
const FILTER_KEY = 'jcmovies_adult_filter'
const ADULT_COMPANIES = '149142,173083'

const ADULT_PATTERNS = /(^|\s)(sex\s|porn|erotik?c|softcore|escort|adultery|horny|orgy|stripper|cheating wife|sugar (mommy|mom|daddy)|foursome|threesome|virgin people|deflower)/i

let adultFilter = localStorage.getItem(FILTER_KEY) !== 'off'

function getApiKey() {
  return localStorage.getItem(STORAGE_KEY) || ''
}

function showSetup() {
  hideAllViews()
  document.getElementById('setup-view').style.display = 'block'
  document.getElementById('apiKeyInput').value = getApiKey()
}

function saveApiKey() {
  const key = document.getElementById('apiKeyInput').value.trim()
  if (!key) return
  localStorage.setItem(STORAGE_KEY, key)
  document.getElementById('setup-view').style.display = 'none'
  initApp()
}

async function tmdbFetch(endpoint, params = {}) {
  const qs = new URLSearchParams({ ...params, path: endpoint })
  const customKey = getApiKey()
  if (customKey) qs.set('api_key', customKey)
  const url = `${CONFIG.tmdbProxy}?${qs}`
  try {
    const res = await fetch(url)
    if (!res.ok) {
      if (res.status === 401) { localStorage.removeItem(STORAGE_KEY); showSetup(); return null }
      return null
    }
    return await res.json()
  } catch (e) { return null }
}

function imgUrl(path, size = 'w342') {
  return path ? `${CONFIG.tmdbImgBase}/${size}${path}` : ''
}

function createCard(item, type) {
  const card = document.createElement('div')
  card.className = 'movie-card'
  const mediaType = type || item.media_type || 'movie'
  card.onclick = () => showDetail(item.id, mediaType)
  const poster = imgUrl(item.poster_path)
  const title = item.title || item.name || ''
  const year = (item.release_date || item.first_air_date || '').slice(0, 4)
  const rating = item.vote_average ? item.vote_average.toFixed(1) : ''
  card.innerHTML = `
    <img src="${poster || `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 300 450%22><rect fill=%22%232a2a4a%22 width=%22300%22 height=%22450%22/><text x=%22150%22 y=%22225%22 text-anchor=%22middle%22 fill=%22%23666%22 font-size=%2230%22>?</text></svg>`}" alt="${title}" loading="lazy">
    <span class="media-type">${mediaType === 'tv' ? 'TV' : 'Film'}</span>
    <div class="card-body">
      <div class="title">${title}</div>
      <div class="meta">
        ${year ? `<span class="year">${year}</span>` : ''}
        ${rating ? `<span class="rating">โ˜… ${rating}</span>` : ''}
      </div>
    </div>
  `
  return card
}

function renderMovies(gridId, items, type) {
  const grid = document.getElementById(gridId)
  items.forEach(item => {
    if (item.poster_path && !item.adult) grid.appendChild(createCard(item, type))
  })
}

const MOVIE_CATEGORIES = [
  { id: 'trending', label: 'Trending', endpoint: '/trending/movie/week' },
  { id: 'popular', label: 'Popular', endpoint: '/movie/popular' },
  { id: 'top_rated', label: 'Top Rated', endpoint: '/movie/top_rated' },
  { id: 'filipino', label: 'Filipino Movies', endpoint: '/discover/movie', params: { with_original_language: 'tl', sort_by: 'popularity.desc', 'vote_count.gte': '10' } },
  { id: 'korean', label: 'Korean Movies', endpoint: '/discover/movie', params: { with_original_language: 'ko', sort_by: 'popularity.desc' } },
  { id: 'japanese', label: 'Japanese Movies', endpoint: '/discover/movie', params: { with_original_language: 'ja', sort_by: 'popularity.desc', 'without_genres': '16' } },
  { id: 'anime', label: 'Anime', endpoint: '/discover/movie', params: { with_genres: '16', with_original_language: 'ja', sort_by: 'popularity.desc' } },
]
const TV_CATEGORIES = [
  { id: 'trending', label: 'Trending', endpoint: '/trending/tv/week' },
  { id: 'popular', label: 'Popular', endpoint: '/tv/popular' },
  { id: 'top_rated', label: 'Top Rated', endpoint: '/tv/top_rated' },
  { id: 'korean', label: 'Korean Dramas', endpoint: '/discover/tv', params: { with_original_language: 'ko', sort_by: 'popularity.desc' } },
]

function populateGenreFilter() {
  const sel = document.getElementById('filterGenre')
  if (!sel) return
  const genres = currentTab === 'movie' ? MOVIE_GENRES : TV_GENRES
  const current = sel.value
  sel.innerHTML = '<option value="">All Genres</option>' +
    genres.map(g => `<option value="${g.id}">${g.name}</option>`).join('')
  sel.value = current
}

function populateYearFilter() {
  const sel = document.getElementById('filterYear')
  if (!sel || sel.options.length > 1) return
  const year = new Date().getFullYear()
  for (let y = year; y >= 1950; y--) {
    const o = document.createElement('option')
    o.value = y
    o.textContent = y
    sel.appendChild(o)
  }
}

function loadSavedFilters() {
  try { activeFilters = JSON.parse(localStorage.getItem(MOVIE_FILTER_KEY)) || {} }
  catch { activeFilters = {} }
}

function syncFilterUI() {
  ;['sort','genre','year'].forEach(k => {
    const el = document.getElementById('filter' + k.charAt(0).toUpperCase() + k.slice(1))
    if (el) el.value = activeFilters[k] || ''
  })
}

function applyFilters() {
  ;['sort','genre','year'].forEach(k => {
    const el = document.getElementById('filter' + k.charAt(0).toUpperCase() + k.slice(1))
    activeFilters[k] = el ? el.value : ''
  })
  localStorage.setItem(MOVIE_FILTER_KEY, JSON.stringify(activeFilters))
  const catId = document.querySelector('.category-btn.active')?.dataset?.cat
  if (catId) switchCategory(catId)
  else if (lastQuery) doSearch()
}

function resetFilters() {
  activeFilters = {}
  localStorage.removeItem(MOVIE_FILTER_KEY)
  syncFilterUI()
  const catId = document.querySelector('.category-btn.active')?.dataset?.cat
  if (catId) switchCategory(catId)
  else if (lastQuery) doSearch()
}

function filterVariables() {
  const v = {}
  if (activeFilters.sort) v.sort_by = activeFilters.sort
  if (activeFilters.genre) v.with_genres = activeFilters.genre
  if (activeFilters.year) {
    const key = currentTab === 'movie' ? 'primary_release_year' : 'first_air_date_year'
    v[key] = activeFilters.year
  }
  return v
}

async function loadHome() {
  renderCategoryBar()
  const cats = currentTab === 'movie' ? MOVIE_CATEGORIES : TV_CATEGORIES
  if (cats.length) switchCategory('trending')
}

function renderCategoryBar() {
  const isMovie = currentTab === 'movie'
  const cats = isMovie ? MOVIE_CATEGORIES : TV_CATEGORIES
  const bar = document.getElementById('category-bar')
  bar.innerHTML = cats.map(c =>
    `<button class="category-btn" data-cat="${c.id}" onclick="switchCategory('${c.id}')">${c.label}</button>`
  ).join('')
}

function toggleAdultFilter() {
  adultFilter = !adultFilter
  localStorage.setItem(FILTER_KEY, adultFilter ? 'on' : 'off')
  document.getElementById('filterBtn').textContent = adultFilter ? 'Filter: On' : 'Filter: Off'
  document.getElementById('filterBtn').classList.toggle('active', adultFilter)
  const catId = document.querySelector('.category-btn.active')?.dataset?.cat
  if (catId) switchCategory(catId)
}

async function switchCategory(id) {
  document.querySelectorAll('.category-btn').forEach(b => {
    b.classList.toggle('active', b.dataset.cat === id)
  })
  currentCategoryId = id
  categoryPage = 1
  const isMovie = currentTab === 'movie'
  const cats = isMovie ? MOVIE_CATEGORIES : TV_CATEGORIES
  const cat = cats.find(c => c.id === id)
  if (!cat) return
  const section = document.getElementById('category-section')
  const title = document.getElementById('categoryTitle')
  const grid = document.getElementById('categoryGrid')
  const loadMore = document.getElementById('categoryLoadMore')
  title.textContent = cat.label
  grid.innerHTML = '<div class="loading">Loading...</div>'
  loadMore.style.display = 'none'
  document.getElementById('searchLoadMore').style.display = 'none'
  section.style.display = 'block'
  let params = { ...(cat.params || {}), page: categoryPage }
  if (adultFilter) {
    params['without_companies'] = ADULT_COMPANIES
  }
  const fv = filterVariables()
  const hasFilters = Object.keys(fv).length > 0
  const endpoint = hasFilters ? (isMovie ? '/discover/movie' : '/discover/tv') : cat.endpoint
  if (hasFilters) {
    params = { ...params, ...fv }
    if (adultFilter) params['without_companies'] = ADULT_COMPANIES
  }
  const data = await tmdbFetch(endpoint, params)
  grid.innerHTML = ''
  if (data && data.results) {
    data.results.forEach(item => {
      if (!item.poster_path || item.adult) return
      if (adultFilter && ADULT_PATTERNS.test(item.title || item.name || '')) return
      grid.appendChild(createCard(item, isMovie ? 'movie' : 'tv'))
    })
    loadMore.style.display = data.total_pages > categoryPage ? 'block' : 'none'
  }
}

async function loadMoreCategory() {
  if (!currentCategoryId) return
  categoryPage++
  const isMovie = currentTab === 'movie'
  const cats = isMovie ? MOVIE_CATEGORIES : TV_CATEGORIES
  const cat = cats.find(c => c.id === currentCategoryId)
  if (!cat) return
  const grid = document.getElementById('categoryGrid')
  const loadMore = document.getElementById('categoryLoadMore')
  let params = { ...(cat.params || {}), page: categoryPage }
  if (adultFilter) params['without_companies'] = ADULT_COMPANIES
  const fv = filterVariables()
  const hasFilters = Object.keys(fv).length > 0
  const endpoint = hasFilters ? (isMovie ? '/discover/movie' : '/discover/tv') : cat.endpoint
  if (hasFilters) {
    params = { ...params, ...fv }
    if (adultFilter) params['without_companies'] = ADULT_COMPANIES
  }
  const data = await tmdbFetch(endpoint, params)
  if (data && data.results) {
    data.results.forEach(item => {
      if (!item.poster_path || item.adult) return
      if (adultFilter && ADULT_PATTERNS.test(item.title || item.name || '')) return
      grid.appendChild(createCard(item, isMovie ? 'movie' : 'tv'))
    })
    loadMore.style.display = data.total_pages > categoryPage ? 'block' : 'none'
  }
}

function switchTab(tab) {
  currentTab = tab
  document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'))
  document.getElementById(`tab-${tab}`).classList.add('active')
  hideAllViews()
  document.getElementById('home-view').style.display = 'block'
  showWrap()
  populateGenreFilter()
  loadHome()
}

function hideAllViews() {
  ;['home-view','search-view','detail-view','player-view','setup-view']
    .forEach(id => document.getElementById(id).style.display = 'none')
  const wrap = document.querySelector('.content-wrap')
  if (wrap) wrap.style.display = 'none'
}

function showWrap() {
  const wrap = document.querySelector('.content-wrap')
  if (wrap) wrap.style.display = 'flex'
}

function navigate(view) {
  if (view === 'back') {
    if (document.getElementById('player-view').style.display !== 'none') { closePlayer(); return }
    if (document.getElementById('detail-view').style.display !== 'none') {
      hideAllViews(); document.getElementById('home-view').style.display = 'block'; showWrap(); return
    }
  }
  if (view === 'home') { hideAllViews(); document.getElementById('home-view').style.display = 'block'; showWrap(); loadHome() }
}

function showView(id) {
  hideAllViews()
  document.getElementById(id).style.display = 'block'
  const wrap = document.querySelector('.content-wrap')
  if (wrap) wrap.style.display = (id === 'home-view' || id === 'search-view') ? 'flex' : 'none'
}

async function doSearch() {
  const query = document.getElementById('searchInput').value.trim()
  if (!query) return
  lastQuery = query; searchPage = 1
  const grid = document.getElementById('searchGrid')
  grid.innerHTML = ''

  const isMovie = currentTab === 'movie'
  const ep = isMovie ? '/search/movie' : '/search/tv'
  const data = await tmdbFetch(ep, { query, page: searchPage })
  if (!data || !data.results) return

  document.getElementById('searchTitle').textContent = `Results for "${query}"`
  document.getElementById('searchLoadMore').style.display = data.total_pages > searchPage ? 'block' : 'none'
  document.getElementById('categoryLoadMore').style.display = 'none'
  showView('search-view')
  data.results.forEach(item => {
    if (item.poster_path && !item.adult) grid.appendChild(createCard(item, isMovie ? 'movie' : 'tv'))
  })
}

async function loadMoreSearch() {
  searchPage++
  const isMovie = currentTab === 'movie'
  const ep = isMovie ? '/search/movie' : '/search/tv'
  const data = await tmdbFetch(ep, { query: lastQuery, page: searchPage })
  if (data && data.results) {
    data.results.forEach(item => {
      if (item.poster_path && !item.adult)
        document.getElementById('searchGrid').appendChild(createCard(item, isMovie ? 'movie' : 'tv'))
    })
    document.getElementById('searchLoadMore').style.display = data.total_pages > searchPage ? 'block' : 'none'
  }
}

async function showDetail(itemId, type) {
  currentItem = { id: itemId, type }
  const ep = type === 'tv' ? `/tv/${itemId}` : `/movie/${itemId}`
  const data = await tmdbFetch(ep)
  if (!data) return
  showView('detail-view')
  const el = document.getElementById('detailContent')
  const poster = imgUrl(data.poster_path, 'w500')
  const backdrop = imgUrl(data.backdrop_path, 'w1280')
  const title = data.title || data.name || ''
  const year = (data.release_date || data.first_air_date || '').slice(0, 4)
  const rating = data.vote_average ? data.vote_average.toFixed(1) : ''
  const runtime = type === 'movie' && data.runtime ? `${Math.floor(data.runtime / 60)}h ${data.runtime % 60}m` : ''
  const genres = data.genres ? data.genres.map(g => `<span>${g.name}</span>`).join('') : ''
  const seasons = data.seasons ? data.seasons.filter(s => s.season_number > 0) : []

  let providersHtml = ''
  const watchData = await tmdbFetch(`/${type === 'tv' ? 'tv' : 'movie'}/${itemId}/watch/providers`)
  // Check PH providers first, fall back to US
  const region = (watchData && watchData.results && watchData.results.PH) ? 'PH' : 'US'
  const provData = watchData?.results?.[region]
  if (provData) {
    const all = [...(provData.flatrate || []), ...(provData.buy || []), ...(provData.rent || [])]
      .filter((p,i,a) => a.findIndex(x => x.provider_id === p.provider_id) === i)
    if (all.length) {
      providersHtml = `<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px">
        ${all.slice(0,8).map(p => `<img src="${imgUrl(p.logo_path, 'w92')}" alt="${p.provider_name}" title="${p.provider_name}" style="width:40px;height:40px;border-radius:8px;background:#2a2a4a">`).join('')}
        </div><div style="font-size:0.75rem;color:#666;margin-bottom:12px">Available on (${region === 'PH' ? 'Philippines' : 'US'}):</div>`
    }
  }

  let episodeSelector = ''
  if (type === 'tv' && seasons.length) {
    episodeSelector = `
      <div class="episode-selector">
        <select id="seasonSelect" onchange="onSeasonChange(this.value)">
          ${seasons.map(s => `<option value="${s.season_number}">Season ${s.season_number} (${s.episode_count || '?'} ep)</option>`).join('')}
        </select>
        <select id="episodeSelect" onchange="onEpisodeChange()">
          <option value="1">Loading...</option>
        </select>
      </div>
      <div id="episodeInfo" class="episode-info" style="display:none;margin-bottom:16px;gap:16px;align-items:flex-start">
        <img id="episodeStill" src="" alt="" style="width:200px;border-radius:8px;flex-shrink:0">
        <div>
          <h3 id="episodeTitle" style="font-size:1rem;margin-bottom:6px"></h3>
          <p id="episodeOverview" style="font-size:0.85rem;color:#bbb;line-height:1.5"></p>
        </div>
      </div>`
  }

  el.innerHTML = `
    ${backdrop ? `<div style="width:100%;height:200px;border-radius:10px;overflow:hidden;margin-bottom:20px;background:#1a1a2e;background-image:url(${backdrop});background-size:cover;background-position:center 20%"></div>` : ''}
    <div style="display:flex;gap:30px;flex-wrap:wrap">
      <div class="detail-poster"><img src="${poster || `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 300 450%22><rect fill=%22%232a2a4a%22 width=%22300%22 height=%22450%22/><text x=%22150%22 y=%22225%22 text-anchor=%22middle%22 fill=%22%23666%22 font-size=%2230%22>?</text></svg>`}" alt="${title}"></div>
      <div class="detail-info">
        <h1>${title} ${year ? `(${year})` : ''}</h1>
        ${data.tagline ? `<div class="tagline">${data.tagline}</div>` : ''}
        <div class="detail-meta">
          ${rating ? `<span>โ˜… ${rating}</span>` : ''}
          ${runtime ? `<span>${runtime}</span>` : ''}
          ${data.release_date || data.first_air_date ? `<span>${data.release_date || data.first_air_date}</span>` : ''}
          ${data.status ? `<span>${data.status}</span>` : ''}
          ${type === 'tv' ? `<span>${data.number_of_seasons || '?'} Seasons</span>` : ''}
        </div>
        ${genres ? `<div class="genres">${genres}</div>` : ''}
        ${providersHtml}
        ${episodeSelector}
        ${data.overview ? `<div class="overview">${data.overview}</div>` : ''}
        ${type === 'tv' && seasons.length
          ? `<button class="watch-btn" onclick="watchTV(${itemId})">โ–ถ Watch Episode</button>`
          : `<button class="watch-btn" onclick="watchMovie(${itemId})">โ–ถ Watch Free</button>`}
        <button class="watch-btn trailer-btn" onclick="playTrailer(${itemId},'${type}')" style="margin-left:10px">โ–ถ Trailer</button>
      </div>
    </div>
  `

  if (type === 'tv' && seasons.length) fetchAndPopulateEpisodes(itemId, seasons[0].season_number)

  // Cast
  const credits = await tmdbFetch(`/${ep}/credits`.replace('/tv/', '/tv/').replace('/movie/', '/movie/'))
  const castGrid = document.getElementById('castGrid')
  castGrid.innerHTML = ''
  if (credits && credits.cast) {
    credits.cast.slice(0, 12).forEach(p => {
      const div = document.createElement('div')
      div.className = 'cast-card'
      div.innerHTML = `
        <img src="${imgUrl(p.profile_path, 'w185') || `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 200 200%22><rect fill=%22%232a2a4a%22 width=%22200%22 height=%22200%22/><text x=%22100%22 y=%22110%22 text-anchor=%22middle%22 fill=%22%23666%22 font-size=%2250%22>?</text></svg>`}" alt="${p.name}">
        <div class="name">${p.name}</div>
        <div class="role">${p.character || ''}</div>
      `
      castGrid.appendChild(div)
    })
    document.getElementById('cast-section').style.display = 'block'
  } else {
    document.getElementById('cast-section').style.display = 'none'
  }

  // Similar
  const similarEp = type === 'tv' ? `/tv/${itemId}/similar` : `/movie/${itemId}/similar`
  const similar = await tmdbFetch(similarEp)
  const similarGrid = document.getElementById('similarGrid')
  similarGrid.innerHTML = ''
  if (similar && similar.results) {
    similar.results.slice(0, 8).forEach(item => {
      if (item.poster_path) similarGrid.appendChild(createCard(item, type))
    })
  }
}

async function fetchAndPopulateEpisodes(showId, seasonNumber) {
  currentSeason = seasonNumber
  const data = await tmdbFetch(`/tv/${showId}/season/${seasonNumber}`)
  if (!data || !data.episodes) return
  currentShowSeasonData = data
  const epSelect = document.getElementById('episodeSelect')
  epSelect.innerHTML = ''
  data.episodes.forEach(ep => {
    const opt = document.createElement('option')
    opt.value = ep.episode_number
    opt.textContent = `Ep ${ep.episode_number}: ${ep.name || `Episode ${ep.episode_number}`}`
    epSelect.appendChild(opt)
  })
  const epInfo = document.getElementById('episodeInfo')
  if (data.episodes.length) {
    epInfo.style.display = 'flex'
    showEpisodeInfo(data.episodes[0])
  } else {
    epInfo.style.display = 'none'
  }
}

function showEpisodeInfo(ep) {
  const still = document.getElementById('episodeStill')
  if (ep.still_path) {
    still.src = imgUrl(ep.still_path, 'w500')
    still.style.display = 'block'
  } else {
    still.style.display = 'none'
  }
  document.getElementById('episodeTitle').textContent = `Episode ${ep.episode_number}: ${ep.name || ''}`
  document.getElementById('episodeOverview').textContent = ep.overview || 'No overview available.'
}

function onSeasonChange(seasonNum) {
  if (currentItem && currentItem.type === 'tv') {
    fetchAndPopulateEpisodes(currentItem.id, parseInt(seasonNum))
  }
}

function onEpisodeChange() {
  const epNum = parseInt(document.getElementById('episodeSelect').value)
  currentEpisode = epNum
  if (currentShowSeasonData && currentShowSeasonData.episodes) {
    const ep = currentShowSeasonData.episodes.find(e => e.episode_number === epNum)
    if (ep) showEpisodeInfo(ep)
  }
}

function watchMovie(id) {
  currentItem = { id, type: 'movie' }
  currentEmbedIndex = 0
  currentSeason = currentEpisode = 1
  loadPlayer(id, 'movie', 1, 1)
}

function watchTV(id) {
  const season = parseInt(document.getElementById('seasonSelect')?.value || 1)
  const episode = parseInt(document.getElementById('episodeSelect')?.value || 1)
  currentSeason = season
  currentEpisode = episode
  currentItem = { id, type: 'tv' }
  currentEmbedIndex = 0
  loadPlayer(id, 'tv', season, episode)
}

function getEmbedUrl(id, type, season, episode, sourceIndex) {
  const source = CONFIG.embedSources[sourceIndex] || CONFIG.embedSources[0]
  if (type === 'tv' && source.tvUrl) {
    return source.tvUrl.replace('{id}', id).replace('{s}', season).replace('{e}', episode)
  }
  return source.url.replace('{id}', id)
}

async function loadPlayer(id, type, season, episode) {
  showView('player-view')
  const content = document.getElementById('playerContent')
  content.innerHTML = `
    <div class="source-selector">
      ${CONFIG.embedSources.map((s, i) =>
        `<button class="source-btn ${i === 0 ? 'active' : ''}" onclick="switchSource(${i})">${s.name}</button>`
      ).join('')}
    </div>
    <div class="loading">Probing sources... this may take a few seconds.</div>
  `
  const index = await findWorkingSource(id, type, season, episode)
  renderIframe(id, type, season, episode, index)
}

async function findWorkingSource(id, type, season, episode) {
  const timeout = ms => new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms))
  const probes = CONFIG.embedSources.map(async (_, i) => {
    const url = getEmbedUrl(id, type, season, episode, i)
    try {
      await Promise.race([fetch(url, { mode: 'no-cors' }), timeout(4000)])
      return i
    } catch { return -1 }
  })
  const results = await Promise.all(probes)
  const idx = results.find(i => i >= 0)
  return idx !== undefined ? idx : 0
}

const IFRAME_SANDBOX = ''

function renderIframe(id, type, season, episode, index) {
  currentEmbedIndex = index
  const src = getEmbedUrl(id, type, season, episode, index)
  const content = document.getElementById('playerContent')
  const sel = CONFIG.embedSources.map((s, i) =>
    `<button class="source-btn ${i === index ? 'active' : ''}" onclick="switchSource(${i})">${s.name}</button>`
  ).join('')
  content.innerHTML = `
    <div class="source-selector">${sel}</div>
    <div class="player-shell">
      <iframe src="${src}" allowfullscreen allow="autoplay;fullscreen;picture-in-picture;encrypted-media;clipboard-write" loading="lazy" onerror="autoAdvanceSource()"></iframe>
      <div class="player-shield" id="playerShield" onclick="enablePlayback()">
        <div class="shield-play">โ–ถ</div>
        <div class="shield-text">Click to enable playback</div>
      </div>
    </div>
    <div style="text-align:center;margin-top:8px;color:#666;font-size:0.8rem">
      Source not working? Try another source above.
    </div>
  `
}

function enablePlayback() {
  const shield = document.getElementById('playerShield')
  if (shield) shield.remove()
}

function autoAdvanceSource() {
  const next = currentEmbedIndex + 1
  if (next < CONFIG.embedSources.length) {
    renderIframe(currentItem.id, currentItem.type, currentSeason, currentEpisode, next)
  }
}

function switchSource(index) {
  renderIframe(currentItem.id, currentItem.type, currentSeason, currentEpisode, index)
}

function closePlayer() {
  document.getElementById('playerContent').innerHTML = ''
  if (currentItem) showDetail(currentItem.id, currentItem.type)
  else navigate('home')
}

async function playTrailer(id, type) {
  const ep = type === 'tv' ? `/tv/${id}/videos` : `/movie/${id}/videos`
  const data = await tmdbFetch(ep)
  if (!data || !data.results) return
  const trailer = data.results.find(v => v.type === 'Trailer' && v.site === 'YouTube')
  if (!trailer) return
  const key = trailer.key
  document.getElementById('playerContent').innerHTML = `
    <iframe src="https://www.youtube.com/embed/${key}?autoplay=1" allowfullscreen allow="autoplay;fullscreen" loading="lazy"></iframe>
  `
  if (document.getElementById('player-view').style.display === 'none') {
    showView('player-view')
  } else {
    document.getElementById('player-view').style.display = 'block'
  }
}

async function initApp() {
  const fb = document.getElementById('filterBtn')
  if (fb) {
    fb.textContent = adultFilter ? 'Filter: On' : 'Filter: Off'
    fb.classList.toggle('active', adultFilter)
  }
  loadSavedFilters()
  populateGenreFilter()
  populateYearFilter()
  syncFilterUI()
  await loadHome()
}
initApp()

Run it on your computer first

Before deploying, test locally. Netlify provides a command that serves your files and runs the function โ€” exactly like production:

npx netlify dev

Open http://localhost:8888 in your browser. You should see the Trending movies. Try the tabs, the search box, the filters, and click into a detail page. The "play" embeds need internet, so media playback will work too.

โš ๏ธ If you see Server API key not configured, you haven't given the function the key yet (that happens in step 10, Deploy to Netlify). For local testing you can skip ahead, or set it for the session: run export TMDB_API_KEY=your_key_here first in the same terminal, then npx netlify dev.

Deploy to Netlify

From your project folder, run:

npx netlify login          # opens your browser to sign in
npx netlify init           # follow the prompts โ€” pick "Create & configure a new site"

Now add the secret key so the function can read it (don't paste your real key here, this is the menu path):

Netlify dashboard โ†’ your site โ†’ Site settings โ†’ Environment variables โ†’ Add a variable
Name:  TMDB_API_KEY
Value: your real TMDB v3 key

Finally, publish:

npx netlify deploy --prod --dir=.

After a few seconds Netlify prints your live URL โ€” something like https://your-site.netlify.app. That's it โ€” your site is on the internet.

To attach your own domain (optional): Netlify dashboard โ†’ Domain management โ†’ Add custom domain, then point a CNAME record at your-site.netlify.app in your DNS provider. For example, movies.jcmatira.org points to the live JcMovies site.

Making changes later: edit a file, then run npx netlify deploy --prod --dir=. again. One command always redeploys.

8. Make it Yours (10-Minute Customization)

Want to change...Then edit...
The site name & logoindex.html โ€” the <title> and the .logo link text
The colorsstyles.css โ€” the background #0f0f0f and accent #e94560 appear throughout
Which categories existapp.js โ€” the MOVIE_CATEGORIES / TV_CATEGORIES lists
The embed providersconfig.js โ€” add/remove entries in embedSources
Image size / blur strengthapp.js โ€” imgUrl() (sizes like w342, w500)
Which tab opens by defaultapp.js โ€” let currentTab = 'movie'
๐Ÿงฉ Analogy โ€” Building Blocks
Think of the files as LEGO pieces with fixed connection points: config.js stores the settings, app.js reads them, index.html shows the results. As long as you respect the connection points, you can swap pieces freely โ€” that's the whole design.

9. How Video Playback Works

Your site never stores or streams video files. Providers (VidSrc, VidLink, 2Embed, and friends) embed their own players on their domains โ€” you simply load them in an <iframe>. Different sources = different availability; that's why findWorkingSource() walks the list and stops at the first that responds.

๐Ÿ”„ Switching Sources

// every source gets a button; players can swap mid-watch
const buttons = sources.map((s, i) =>
  `<button class="source-btn" onclick="switchSource(${i})">${s.name}</button>`)

function switchSource(i) {
  currentEmbedIndex = i  // remember for the "dead source" fallback
  renderIframe(currentItem.id, currentItem.type,
               currentSeason, currentEpisode, i)
}
๐Ÿ“บ Analogy โ€” Channel Surfing
Some channels have no signal, some have a cable outage. Embed sources are like TV channels: when one is dead, you just switch to the next. That's why the player always offers a list of choices instead of betting on a single channel.

10. Quick Reference โ€” Common Endpoints

EndpointWhat it returns
/trending/movie/weekTrending movies this week
/trending/tv/weekTrending TV shows this week
/movie/popular ยท /tv/popularPopular titles
/movie/top_rated ยท /tv/top_ratedHighest rated titles
/search/movie ยท /search/tvKeyword search (params: query, page)
/discover/movie ยท /discover/tvFiltered browsing โ€” genre, year, language, sort
/movie/{id} ยท /tv/{id}Full details for one title
/movie/{id}/creditsCast and crew
/movie/{id}/similar ยท /tv/{id}/similar"You May Also Like" suggestions

11. Troubleshooting (When Something Goes Wrong)

SymptomMost likely causeFix
Everything loads but no postersYou are offline or image.tmdb.org is blockedCheck your internet; the poster base URL in config.js
Server API key not configured (500)TMDB_API_KEY is missing on NetlifyAdd it under Site settings โ†’ Environment variables
Search returns nothingEmpty query โ€” doSearch() bails earlyType something; check the search box isn't empty
Detail page shows nothingTMDB returned an error or the ID is badOpen the browser console โ€” look for a network 401/404
Play button does nothingAll embed sources failed for that titleTry again later or pick another movie โ€” availability varies per title
Iframe shows a blocked pageThat embed provider is down or blockingClick another source button in the player
Load More button missingThe category really has only one page, or the fetch failedRefresh; check the Network tab for errors
Browser console shows CORS errorsYou are opening index.html directly from disk (file://)Use netlify dev or the deployed URL instead

Rule of thumb: open the browser's developer tools (right-click โ†’ Inspect โ†’ Console and Network tabs). Half of all problems show their message there first.

12. Glossary for Beginners

TermIn plain words
APIA website that lets other programs ask it for data
EndpointOne specific address of an API, like /trending/movie/week
JSONA text format for data โ€” { "title": "Avatar" }
iframesEmbedding another website inside a box on your page
Serverless functionCode that runs on a server for you โ€” you never own the machine
Environment variableA secret setting stored outside your code, read at runtime
CORSA browser security rule. We sidestep it by proxying server-side
localStorageBrowser storage that survives refreshes โ€” used for your filters
SPASingle-Page App โ€” one HTML file that swaps screens in JavaScript
DeployUploading your site so the whole world can see it

13. Best Practices & Next Steps

๐Ÿ” Keep the key server-side
Never put the TMDB key in app.js โ€” anyone can read it. The proxy is the only holder.
๐Ÿ–ผ Lazy-load posters
loading="lazy" on images makes big grids scroll smoothly.
๐Ÿงน Filter in two layers
Block adult studios on the API side, and double-check titles in the UI โ€” defense in depth.
๐Ÿ” Always paginate
Categories and search both hit total_pages โ€” a Load More button beats 500 cards on screen.
๐Ÿ’พ Persist preferences
Save filters and the adult toggle in localStorage so returning users keep their setup.
๐Ÿงญ Go deeper
Next: progress tracking, watchlists, a mobile app port, or "Play in VLC" deep links โ€” all possible on this base.

You just built a full streaming site from scratch โ€” TMDB for the catalog, embed players for playback, and a serverless function guarding the API key. Same skills, bigger projects from here.

Created by jcmatira