Comick Chapter Timer

Shows timer for next chapter when 2+ chapters are available

// ==UserScript==
// @name         Comick Chapter Timer
// @namespace    https://github.com/GooglyBlox
// @version      1.0
// @description  Shows timer for next chapter when 2+ chapters are available
// @author       GooglyBlox
// @match        https://comick.io/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const API_BASE = 'https://api.comick.fun';
  let currentURL = location.href;
  let pageObserver = null;
  let bodyObserver = null;
  let processedSlugs = new Set();
  const elementProcessed = new WeakSet();
  const elementTimers = new Map();
  const slugControllers = new Map();

  const debounce = (fn, wait = 200) => {
    let t;
    return (...args) => {
      clearTimeout(t);
      t = setTimeout(() => fn(...args), wait);
    };
  };

  const scheduleLight = (fn) => {
    if ('requestIdleCallback' in window) {
      requestIdleCallback(fn, { timeout: 500 });
    } else {
      requestAnimationFrame(fn);
    }
  };

  (function () {
    const wrap = (type) => {
      const orig = history[type];
      return function () {
        const res = orig.apply(this, arguments);
        window.dispatchEvent(new Event('spa:navigation'));
        return res;
      };
    };
    history.pushState = wrap('pushState');
    history.replaceState = wrap('replaceState');
    window.addEventListener('popstate', () => window.dispatchEvent(new Event('spa:navigation')));
  })();

  function cancelInFlight() {
    for (const [, controller] of slugControllers) {
      try { controller.abort(); } catch {}
    }
    slugControllers.clear();
    for (const [, data] of elementTimers) {
      if (data && data.intervalId) clearInterval(data.intervalId);
    }
    elementTimers.clear();
  }

  function onRouteChange() {
    const newURL = location.href;
    if (newURL === currentURL) return;
    currentURL = newURL;
    cancelInFlight();
    processedSlugs = new Set();
    teardownObservers();
    scheduleLight(() => {
      observePageRoot();
      debouncedScan();
    });
  }

  window.addEventListener('spa:navigation', onRouteChange);

  setInterval(() => {
    if (location.href !== currentURL) onRouteChange();
  }, 800);

  function extractSlugFromHref(href) {
    if (!href) return null;
    const m = href.match(/\/comic\/([^\/\?#]+)/);
    return m ? m[1] : null;
  }

  function extractCurrentChapter(element) {
    const spans = element.querySelectorAll('span');
    for (const span of spans) {
      const t = span.textContent || '';
      if (t.includes('Current')) {
        const m = t.match(/Current\s+(\d+)/);
        if (m) return parseInt(m[1], 10);
      }
    }
    return null;
  }

  async function getComicData(slug, signal) {
    try {
      const res = await fetch(`${API_BASE}/comic/${slug}?tachiyomi=true`, { signal });
      if (!res.ok) return null;
      return await res.json();
    } catch {
      return null;
    }
  }

  async function getChapters(hid, signal) {
    try {
      const res = await fetch(`${API_BASE}/comic/${hid}/chapters?lang=en&chap-order=1&limit=300`, { signal });
      if (!res.ok) return null;
      return await res.json();
    } catch {
      return null;
    }
  }

  function deduplicateChapters(chapters) {
    const map = new Map();
    for (const c of chapters) {
      const num = parseFloat(c.chap);
      if (Number.isNaN(num)) continue;
      const ex = map.get(num);
      if (!ex) {
        map.set(num, c);
      } else {
        const exTime = ex.publish_at ? new Date(ex.publish_at) : new Date(0);
        const curTime = c.publish_at ? new Date(c.publish_at) : new Date(0);
        if (curTime > exTime) map.set(num, c);
      }
    }
    return Array.from(map.values());
  }

  function findNextUnpublishedChapter(chapters) {
    const now = new Date();
    const unique = deduplicateChapters(chapters);
    const upcoming = unique.filter(c => c.publish_at && new Date(c.publish_at) > now);
    if (upcoming.length === 0) return null;
    upcoming.sort((a, b) => new Date(a.publish_at) - new Date(b.publish_at));
    return upcoming[0];
  }

  function getHighestChapterNumber(chapters) {
    const unique = deduplicateChapters(chapters);
    if (unique.length === 0) return 0;
    const highest = unique.reduce((h, cur) => {
      const hn = parseFloat(h.chap) || 0;
      const cn = parseFloat(cur.chap) || 0;
      return cn > hn ? cur : h;
    });
    return parseFloat(highest.chap) || 0;
  }

  function createTimerElement(targetTime, hostElement) {
    const timerDiv = document.createElement('div');
    timerDiv.className = 'mt-3 pr-2';
    timerDiv.innerHTML = `
      <a class="btn w-full text-center text-xs px-0 border-none" style="pointer-events: none;">
        <div class="text-orange-600 dark:text-orange-400">
          <p>
            <span class="hours">00</span><span class="divider">:</span><span class="minutes">00</span><span class="divider">:</span><span class="seconds">00</span>
          </p>
        </div>
      </a>
    `;

    const update = () => {
      const now = new Date();
      const diff = targetTime - now;
      if (diff <= 0) {
        timerDiv.innerHTML = `
          <div class="flex items-center h-8">
            <span class="btn w-full text-center text-xs px-0 border-none text-green-600">Available Now</span>
          </div>
        `;
        const data = elementTimers.get(hostElement);
        if (data && data.intervalId) {
          clearInterval(data.intervalId);
          elementTimers.set(hostElement, { ...data, intervalId: null });
        }
        return;
      }
      const hours = Math.floor(diff / (1000 * 60 * 60));
      const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
      const seconds = Math.floor((diff % (1000 * 60)) / 1000);
      const hEl = timerDiv.querySelector('.hours');
      const mEl = timerDiv.querySelector('.minutes');
      const sEl = timerDiv.querySelector('.seconds');
      if (hEl) hEl.textContent = String(hours).padStart(2, '0');
      if (mEl) mEl.textContent = String(minutes).padStart(2, '0');
      if (sEl) sEl.textContent = String(seconds).padStart(2, '0');
    };

    update();
    const id = setInterval(update, 1000);
    elementTimers.set(hostElement, { intervalId: id, node: timerDiv });
    return timerDiv;
  }

  function clearTimerForElement(el) {
    const data = elementTimers.get(el);
    if (!data) return;
    if (data.intervalId) clearInterval(data.intervalId);
    elementTimers.delete(el);
  }

  function trackElementRemoval(root) {
    if (bodyObserver) return;
    bodyObserver = new MutationObserver((mutations) => {
      for (const m of mutations) {
        m.removedNodes && m.removedNodes.forEach((n) => {
          if (!(n instanceof Element)) return;
          if (elementTimers.has(n)) clearTimerForElement(n);
          for (const [el] of elementTimers) {
            if (!root.contains(el)) clearTimerForElement(el);
          }
        });
      }
    });
    bodyObserver.observe(document.body, { childList: true, subtree: true });
  }

  async function processComicElement(element) {
    try {
      if (elementProcessed.has(element)) return;
      elementProcessed.add(element);

      const links = element.querySelectorAll('a[href*="/comic/"]');
      if (links.length === 0) return;

      let comicLink = null;
      for (const link of links) {
        if (!/\/chapter\//.test(link.href)) {
          comicLink = link;
          break;
        }
      }
      if (!comicLink) return;

      const slug = extractSlugFromHref(comicLink.href);
      if (!slug) return;

      const currentChapter = extractCurrentChapter(element);
      if (currentChapter == null) return;

      if (processedSlugs.has(slug)) return;
      processedSlugs.add(slug);

      const controller = new AbortController();
      slugControllers.set(slug, controller);

      const comicData = await getComicData(slug, controller.signal);
      if (!comicData || !comicData.comic) {
        slugControllers.delete(slug);
        return;
      }

      const chaptersData = await getChapters(comicData.comic.hid, controller.signal);
      slugControllers.delete(slug);
      if (!chaptersData || !chaptersData.chapters) return;

      const highest = getHighestChapterNumber(chaptersData.chapters);
      const diff = highest - currentChapter;

      if (diff >= 2) {
        const nextUnpublished = findNextUnpublishedChapter(chaptersData.chapters);
        if (nextUnpublished) {
          const publishTime = new Date(nextUnpublished.publish_at);
          const existing = element.querySelector('.mt-3.pr-2');
          if (existing) {
            clearTimerForElement(element);
            const timer = createTimerElement(publishTime, element);
            existing.replaceWith(timer);
          } else {
            clearTimerForElement(element);
            const timer = createTimerElement(publishTime, element);
            element.appendChild(timer);
          }
        }
      }
    } catch {}
  }

  function collectCandidateCards(root = document) {
    const set = new Set();
    root.querySelectorAll('div[style*="translateX"]').forEach((el) => set.add(el));
    root.querySelectorAll('a[href*="/comic/"]').forEach((a) => {
      const card = a.closest('div');
      if (card) set.add(card);
    });
    root.querySelectorAll('span').forEach((s) => {
      const t = s.textContent || '';
      if (t.includes('Current')) {
        const card = s.closest('div');
        if (card) set.add(card);
      }
    });
    return Array.from(set);
  }

  function scanForComics() {
    const cards = collectCandidateCards(document);
    for (const card of cards) {
      let hasCurrent = false;
      for (const span of card.querySelectorAll('span')) {
        const t = span.textContent || '';
        if (t.includes('Current')) {
          hasCurrent = true;
          break;
        }
      }
      if (hasCurrent) processComicElement(card);
    }
  }

  const debouncedScan = debounce(() => scheduleLight(scanForComics), 150);

  function getPageRoot() {
    const candidates = [
      document.querySelector('main#main'),
      document.querySelector('main'),
      document.getElementById('__next'),
      document.body,
    ];
    return candidates.find(Boolean) || document.body;
  }

  function observePageRoot() {
    const root = getPageRoot();
    if (pageObserver) {
      try { pageObserver.disconnect(); } catch {}
    }
    pageObserver = new MutationObserver((mutations) => {
      const relevant = mutations.some((m) =>
        Array.from(m.addedNodes).some((n) => {
          if (!(n instanceof Element)) return false;
          if (n.matches('div[style*="translateX"]')) return true;
          if (n.querySelector && n.querySelector('div[style*="translateX"]')) return true;
          if (n.querySelector && n.querySelector('a[href*="/comic/"]')) return true;
          if ((n.textContent || '').includes('Current')) return true;
          return false;
        })
      );
      if (relevant) debouncedScan();
    });
    pageObserver.observe(root, { childList: true, subtree: true });
    trackElementRemoval(root);
  }

  function teardownObservers() {
    if (pageObserver) {
      try { pageObserver.disconnect(); } catch {}
      pageObserver = null;
    }
  }

  document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
      for (const [, data] of elementTimers) {
        if (data && data.intervalId) clearInterval(data.intervalId);
      }
      for (const [el, data] of elementTimers) {
        elementTimers.set(el, { ...data, intervalId: null });
      }
    } else {
      debouncedScan();
    }
  });

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => {
      observePageRoot();
      debouncedScan();
    });
  } else {
    observePageRoot();
    debouncedScan();
  }

  setInterval(() => debouncedScan(), 30000);
})();