Comick Chapter Timer

Shows timer for next chapter when 2+ chapters are available

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

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

(function () {
  'use strict';

  const API_BASE = 'https://api.comick.dev';
  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);
})();