Siap. Kita sinkronkan app.js dengan metadata v2 supaya semuanya nyambung:
categorycategorySlugtagsslugdurationorientationwidthheight
Sekalian saya buat versi yang tetap aman untuk:
mp4imageiframem3u8sourcesmulti-source kalau nanti dipakai lagi
Replace seluruh isi app.js dengan ini
const gallery = document.getElementById("gallery");
const modal = document.getElementById("playerModal");
const player = document.getElementById("player");
const typeFilters = document.getElementById("typeFilters");
const categoryFilters = document.getElementById("categoryFilters");
let currentHls = null;
let allItems = [];
let currentTypeFilter = "all";
let currentCategoryFilter = "all";
function formatDuration(seconds) {
if (!seconds || isNaN(seconds)) return "";
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hrs > 0) {
return `${hrs}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
return `${mins}:${String(secs).padStart(2, "0")}`;
}
function getBadgeText(item) {
if (item.kind === "video") return "VIDEO";
if (item.kind === "stream") return "STREAM";
if (item.kind === "image") return "IMAGE";
return item.type ? item.type.toUpperCase() : "MEDIA";
}
function getOrientationBadge(item) {
if (!item.orientation || item.orientation === "unknown") return "";
if (item.orientation === "portrait") return "PORTRAIT";
if (item.orientation === "landscape") return "LANDSCAPE";
if (item.orientation === "square") return "SQUARE";
return item.orientation.toUpperCase();
}
function destroyHls() {
if (currentHls) {
currentHls.destroy();
currentHls = null;
}
}
function escapeHtml(text) {
return String(text ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
function renderGallery(items) {
gallery.innerHTML = "";
if (!items.length) {
gallery.innerHTML = `<p>Tidak ada item untuk filter ini.</p>`;
return;
}
items.forEach(item => {
const div = document.createElement("div");
div.className = "card";
const durationText =
item.kind === "video" && item.duration
? `<span class="card-duration">${formatDuration(item.duration)}</span>`
: "";
const categoryText = item.category
? `<span class="card-category">${escapeHtml(item.category)}</span>`
: "";
const orientationText = getOrientationBadge(item)
? `<span class="card-orientation">${getOrientationBadge(item)}</span>`
: "";
div.innerHTML = `
<div class="thumb-wrap">
<img src="${item.thumb}" alt="${escapeHtml(item.title)}" loading="lazy">
<span class="card-badge">${getBadgeText(item)}</span>
${durationText}
</div>
<div class="card-body">
<p class="card-title" title="${escapeHtml(item.title)}">${escapeHtml(item.title)}</p>
<div class="card-meta">
${categoryText}
${orientationText}
</div>
</div>
`;
div.addEventListener("click", () => openPlayer(item));
gallery.appendChild(div);
});
}
function buildTypeFilters() {
const types = [
{ label: "All", value: "all" },
{ label: "Video", value: "video" },
{ label: "Stream", value: "stream" },
{ label: "Image", value: "image" }
];
typeFilters.innerHTML = "";
types.forEach(type => {
const btn = document.createElement("button");
btn.className = `filter-btn ${currentTypeFilter === type.value ? "active" : ""}`;
btn.textContent = type.label;
btn.dataset.filter = type.value;
btn.addEventListener("click", () => {
currentTypeFilter = type.value;
buildTypeFilters();
applyFilters();
});
typeFilters.appendChild(btn);
});
}
function buildCategoryFilters(items) {
const categoriesMap = new Map();
items.forEach(item => {
const slug = item.categorySlug || "uncategorized";
const label = item.category || "Uncategorized";
if (!categoriesMap.has(slug)) {
categoriesMap.set(slug, label);
}
});
categoryFilters.innerHTML = "";
const allBtn = document.createElement("button");
allBtn.className = `filter-btn ${currentCategoryFilter === "all" ? "active" : ""}`;
allBtn.textContent = "All Categories";
allBtn.dataset.filter = "all";
allBtn.addEventListener("click", () => {
currentCategoryFilter = "all";
buildCategoryFilters(allItems);
applyFilters();
});
categoryFilters.appendChild(allBtn);
Array.from(categoriesMap.entries())
.sort((a, b) => a[1].localeCompare(b[1]))
.forEach(([slug, label]) => {
const btn = document.createElement("button");
btn.className = `filter-btn ${currentCategoryFilter === slug ? "active" : ""}`;
btn.textContent = label;
btn.dataset.filter = slug;
btn.addEventListener("click", () => {
currentCategoryFilter = slug;
buildCategoryFilters(allItems);
applyFilters();
});
categoryFilters.appendChild(btn);
});
}
function applyFilters() {
let filtered = [...allItems];
if (currentTypeFilter !== "all") {
filtered = filtered.filter(item => item.kind === currentTypeFilter);
}
if (currentCategoryFilter !== "all") {
filtered = filtered.filter(
item => (item.categorySlug || "uncategorized") === currentCategoryFilter
);
}
renderGallery(filtered);
}
function openPlayer(item) {
modal.classList.remove("hidden");
destroyHls();
player.innerHTML = "";
if (item.sources && Array.isArray(item.sources) && item.sources.length > 0) {
renderMultiSource(item);
return;
}
renderSingle(item);
}
function renderSingle(item) {
if (item.type === "mp4") {
player.innerHTML = `
<div class="video-wrapper">
<video controls autoplay playsinline preload="metadata">
<source src="${item.src}" type="video/mp4">
</video>
</div>
`;
} else if (item.type === "image") {
player.innerHTML = `
<div class="image-wrapper">
<img src="${item.src}" alt="${escapeHtml(item.title)}">
</div>
`;
} else if (item.type === "iframe") {
player.innerHTML = `
<div class="iframe-wrapper">
<iframe
src="${item.src}"
allowfullscreen
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin">
</iframe>
</div>
`;
} else if (item.type === "m3u8") {
player.innerHTML = `
<div class="video-wrapper">
<video id="hlsPlayer" controls autoplay playsinline preload="metadata"></video>
</div>
`;
const video = document.getElementById("hlsPlayer");
if (window.Hls && Hls.isSupported()) {
currentHls = new Hls();
currentHls.loadSource(item.src);
currentHls.attachMedia(video);
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = item.src;
} else {
player.innerHTML = `<p>Browser tidak mendukung HLS.</p>`;
}
} else {
player.innerHTML = `<p>Tipe media tidak dikenali.</p>`;
}
}
function renderMultiSource(item) {
player.innerHTML = `
<div class="source-switcher" id="sourceButtons"></div>
<div id="videoContainer"></div>
`;
const btnContainer = document.getElementById("sourceButtons");
const container = document.getElementById("videoContainer");
item.sources.forEach((source, index) => {
const btn = document.createElement("button");
btn.className = "source-btn";
btn.textContent = source.label || `Source ${index + 1}`;
btn.addEventListener("click", () => {
document.querySelectorAll(".source-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
loadSource(source, container, item.title);
});
btnContainer.appendChild(btn);
if (index === 0) {
btn.classList.add("active");
loadSource(source, container, item.title);
}
});
}
function loadSource(source, container, title = "") {
destroyHls();
container.innerHTML = "";
if (source.type === "mp4") {
container.innerHTML = `
<div class="video-wrapper">
<video controls autoplay playsinline preload="metadata">
<source src="${source.src}" type="video/mp4">
</video>
</div>
`;
} else if (source.type === "iframe") {
container.innerHTML = `
<div class="iframe-wrapper">
<iframe
src="${source.src}"
allowfullscreen
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin">
</iframe>
</div>
`;
} else if (source.type === "m3u8") {
container.innerHTML = `
<div class="video-wrapper">
<video id="hlsPlayer" controls autoplay playsinline preload="metadata"></video>
</div>
`;
const video = document.getElementById("hlsPlayer");
if (window.Hls && Hls.isSupported()) {
currentHls = new Hls();
currentHls.loadSource(source.src);
currentHls.attachMedia(video);
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = source.src;
} else {
container.innerHTML = `<p>Browser tidak mendukung HLS.</p>`;
}
} else if (source.type === "image") {
container.innerHTML = `
<div class="image-wrapper">
<img src="${source.src}" alt="${escapeHtml(title)}">
</div>
`;
} else {
container.innerHTML = `<p>Source tidak dikenali.</p>`;
}
}
function closePlayer() {
modal.classList.add("hidden");
destroyHls();
player.innerHTML = "";
}
modal.addEventListener("click", event => {
if (event.target === modal) {
closePlayer();
}
});
document.addEventListener("keydown", event => {
if (event.key === "Escape" && !modal.classList.contains("hidden")) {
closePlayer();
}
});
fetch("data.json")
.then(res => res.json())
.then(data => {
allItems = data;
buildTypeFilters();
buildCategoryFilters(allItems);
applyFilters();
})
.catch(err => {
console.error("Gagal load data.json:", err);
gallery.innerHTML = `<p>Gagal memuat data gallery.</p>`;
});
Tambahan CSS yang perlu kamu sinkronkan
Kalau belum ada, tambahkan ini ke style.css:
.card-body {
padding: 10px;
min-height: 52px;
}
.card-title {
margin: 0;
font-size: 14px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.card-category {
display: inline-block;
font-size: 12px;
color: #aaa;
}
.card-orientation {
display: inline-block;
font-size: 11px;
color: #ddd;
background: #2a2a2a;
padding: 3px 6px;
border-radius: 6px;
}
#typeFilters,
#categoryFilters {
margin-bottom: 14px;
}
Kalau sebelumnya .card-title, .card-body, .card-category, atau .card-orientation sudah ada, replace saja dengan versi ini biar tidak dobel.
Yang sekarang sudah sinkron
Versi ini sudah cocok dengan generate.js v2, jadi item seperti ini:
{
"title": "cewek manis dance",
"category": "Social Media",
"categorySlug": "social-media",
"tags": ["viral", "fyp"],
"slug": "cewek-manis-dance",
"type": "mp4",
"kind": "video",
"orientation": "portrait",
"width": 720,
"height": 1280
}
akan:
- tampil di card
- category masuk
- orientation badge masuk
- filter category jalan
Setelah replace
Jalankan urutan ini:
npm run generate
npm run release -- "sync appjs with metadata v2"
Kalau mau lebih aman dulu, test lokal:
npx serve
Kalau semua normal baru release.