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.
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.
| Feature | How it's built |
|---|---|
| Home categories | Trending / Popular / Top Rated tabs, plus curated rows (Filipino, Korean, Anime) |
| Search | TMDB search with a "Load More" button for paginated results |
| Filters | Sort, genre, and year dropdowns on the sidebar |
| Detail view | Synopsis, cast, and "You May Also Like" suggestions |
| Video player | 12 embed sources tried in order until one plays |
| Adult filter | Hides known adult studios and title patterns |
netlify deploy command pushes the whole site live.
Three pieces talk to each other. The browser never calls TMDB directly โ it always goes through our tiny proxy:
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: "..." } ] }
| Self-hosted catalog | TMDB catalog (what we use) | |
|---|---|---|
| Setup effort | Schema, seed data, daily scrapers | One free API key, zero setup |
| Freshness | As good as your scraping | Updated continuously by TMDB |
| Cost | Server + storage + bandwidth | Free tier for low traffic |
| Data quality | Your problem | Ratings, posters, cast out of the box |
| Playback | Same either way โ embeds | Same either way โ embeds |
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.html | The page structure โ all 5 screens ("views") live here |
styles.css | Everything visual: colors, cards, grid, player layout |
config.js | Settings: your proxy path, image URL base, and the embed source list |
app.js | The behavior: fetching, rendering, search, filters, player |
netlify/functions/tmdb.js | The secret keeper: server-side proxy for the TMDB API key |
netlify.toml | Deploy 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.
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.
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.
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.
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.
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.
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.
[build] publish = "." functions = "netlify/functions"
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.
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.
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.
| View | What it shows |
|---|---|
home-view | Categories (Trending, Popular, Top Rated...) + sidebar filters |
search-view | Search results with a Load More button |
detail-view | One movie/TV show: synopsis, cast, similar titles |
player-view | The video player with embed source buttons |
setup-view | Optional: type your own TMDB key (only if you skip the server setting) |
<!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.
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.
* { 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; }
}
Three settings in one object.
| Setting | Meaning |
|---|---|
tmdbProxy | Where our Netlify function lives |
tmdbImgBase | Where poster images come from |
embedSources | All 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.
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}' }, ], }
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.
| Function | What 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 |
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()
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.
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.
| Want to change... | Then edit... |
|---|---|
| The site name & logo | index.html โ the <title> and the .logo link text |
| The colors | styles.css โ the background #0f0f0f and accent #e94560 appear throughout |
| Which categories exist | app.js โ the MOVIE_CATEGORIES / TV_CATEGORIES lists |
| The embed providers | config.js โ add/remove entries in embedSources |
| Image size / blur strength | app.js โ imgUrl() (sizes like w342, w500) |
| Which tab opens by default | app.js โ let currentTab = 'movie' |
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.
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.
// 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) }
| Endpoint | What it returns |
|---|---|
/trending/movie/week | Trending movies this week |
/trending/tv/week | Trending TV shows this week |
/movie/popular ยท /tv/popular | Popular titles |
/movie/top_rated ยท /tv/top_rated | Highest rated titles |
/search/movie ยท /search/tv | Keyword search (params: query, page) |
/discover/movie ยท /discover/tv | Filtered browsing โ genre, year, language, sort |
/movie/{id} ยท /tv/{id} | Full details for one title |
/movie/{id}/credits | Cast and crew |
/movie/{id}/similar ยท /tv/{id}/similar | "You May Also Like" suggestions |
| Symptom | Most likely cause | Fix |
|---|---|---|
| Everything loads but no posters | You are offline or image.tmdb.org is blocked | Check your internet; the poster base URL in config.js |
Server API key not configured (500) | TMDB_API_KEY is missing on Netlify | Add it under Site settings โ Environment variables |
| Search returns nothing | Empty query โ doSearch() bails early | Type something; check the search box isn't empty |
| Detail page shows nothing | TMDB returned an error or the ID is bad | Open the browser console โ look for a network 401/404 |
| Play button does nothing | All embed sources failed for that title | Try again later or pick another movie โ availability varies per title |
| Iframe shows a blocked page | That embed provider is down or blocking | Click another source button in the player |
| Load More button missing | The category really has only one page, or the fetch failed | Refresh; check the Network tab for errors |
| Browser console shows CORS errors | You 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.
| Term | In plain words |
|---|---|
| API | A website that lets other programs ask it for data |
| Endpoint | One specific address of an API, like /trending/movie/week |
| JSON | A text format for data โ { "title": "Avatar" } |
| iframes | Embedding another website inside a box on your page |
| Serverless function | Code that runs on a server for you โ you never own the machine |
| Environment variable | A secret setting stored outside your code, read at runtime |
| CORS | A browser security rule. We sidestep it by proxying server-side |
| localStorage | Browser storage that survives refreshes โ used for your filters |
| SPA | Single-Page App โ one HTML file that swaps screens in JavaScript |
| Deploy | Uploading your site so the whole world can see it |
app.js โ anyone can read it. The proxy is the only holder.
loading="lazy" on images makes big grids scroll smoothly.
total_pages โ a Load More button beats 500 cards on screen.
localStorage so returning users keep their setup.
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