Siap. Kita sinkronkan app.js dengan metadata v2 supaya semuanya nyambung:

  • category
  • categorySlug
  • tags
  • slug
  • duration
  • orientation
  • width
  • height

Sekalian saya buat versi yang tetap aman untuk:

  • mp4
  • image
  • iframe
  • m3u8
  • sources multi-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, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

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.