Greasy Fork

Chzzk 올인원 스크립트 (Auto Quality + Ad Popup Removal + Unmute)

Chzzk 방송에서 자동 화질 설정, 광고 팝업 차단, 음소거 자동 해제, 스크롤 잠금 해제

// ==UserScript==
// @name Chzzk 올인원 스크립트 (Auto Quality + Ad Popup Removal + Unmute)
// @namespace http://tampermonkey.net/
// @version 3.6.7
// @description Chzzk 방송에서 자동 화질 설정, 광고 팝업 차단, 음소거 자동 해제, 스크롤 잠금 해제
// @match https://chzzk.naver.com/*
// @icon  https://chzzk.naver.com/favicon.ico
// @grant GM.getValue
// @grant GM.setValue
// @grant unsafeWindow
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
  const originalRemoveChild = Node.prototype.removeChild;
  Node.prototype.removeChild = function (child) {
    if (!child || child.parentNode !== this) return child;
    return originalRemoveChild.call(this, child);
  };
})();

(async () => {
  "use strict";
  const APPLY_COOLDOWN = 1000;
  const CONFIG = {
    minTimeout: 500,
    defaultTimeout: 2000,
    storageKeys: {
      quality: "chzzkPreferredQuality",
      autoUnmute: "chzzkAutoUnmute",
      debugLog: "chzzkDebugLog",
      screenSharpness: "chzzkScreenSharp",
    },
    selectors: {
      popup: 'div[class^="popup_container"]',
      qualityBtn: 'button[command="SettingCommands.Toggle"]',
      qualityMenu: 'div[class*="pzp-pc-setting-intro-quality"]',
      qualityItems: 'li.pzp-ui-setting-quality-item[role="menuitem"]',
      headerMenu: ".header_service__DyG7M",
    },
    styles: {
      success: "font-weight:bold; color:green",
      error: "font-weight:bold; color:red",
      info: "font-weight:bold; color:skyblue",
      warn: "font-weight:bold; color:orange",
    },
  };

  const common = {
    regex: {
      adBlockDetect: /광고\s*차단\s*프로그램.*사용\s*중/i,
    },
    async: {
      sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
      waitFor: (selector, timeout = CONFIG.defaultTimeout) => {
        const effective = Math.max(timeout, CONFIG.minTimeout);
        return new Promise((resolve, reject) => {
          const el = document.querySelector(selector);
          if (el) return resolve(el);
          const mo = new MutationObserver(() => {
            const found = document.querySelector(selector);
            if (found) {
              mo.disconnect();
              resolve(found);
            }
          });
          mo.observe(document.body, { childList: true, subtree: true });
          setTimeout(() => {
            mo.disconnect();
            reject(new Error("Timeout waiting for " + selector));
          }, effective);
        });
      },
    },
    text: {
      clean: (txt) => txt.trim().split(/\s+/).filter(Boolean).join(", "),
      extractResolution: (txt) => {
        const m = txt.match(/(\d{3,4})p/);
        return m ? parseInt(m[1], 10) : null;
      },
    },
    dom: {
      remove: (el) => el?.remove(),
      clearStyle: (el) => el?.removeAttribute("style"),
    },
    log: {
      DEBUG: true,
      info: (...args) => common.log.DEBUG && console.log(...args),
      success: (...args) => common.log.DEBUG && console.log(...args),
      warn: (...args) => common.log.DEBUG && console.warn(...args),
      error: (...args) => common.log.DEBUG && console.error(...args),
      groupCollapsed: (...args) =>
        common.log.DEBUG && console.groupCollapsed(...args),
      table: (...args) => common.log.DEBUG && console.table(...args),
      groupEnd: (...args) => common.log.DEBUG && console.groupEnd(...args),
    },
    observeElement: (selector, callback, once = true) => {
      const mo = new MutationObserver(() => {
        const el = document.querySelector(selector);
        if (el) callback(el);
        if (once) mo.disconnect();
      });
      mo.observe(document.body, { childList: true, subtree: true });
      const initial = document.querySelector(selector);
      if (initial) {
        callback(initial);
        if (once) mo.disconnect();
      }
    },
  };

  const TOGGLE_CLASS = "chzzk-helper-toggle";

  async function addHeaderMenu() {
    const header = await common.async.waitFor(CONFIG.selectors.headerMenu);
    if (header.querySelector(`.${TOGGLE_CLASS}`)) return;

    const separator = document.createElement("div");
    separator.classList.add(TOGGLE_CLASS);
    separator.style.cssText =
      "width:100%; height:1px; margin:4px 0; background-color:currentColor; opacity:0.2;";
    header.appendChild(separator);

    const items = header.querySelectorAll("a.header_item__MFv39");
    if (!items.length) return;
    const template = items[items.length - 1];

    const debugItem = template.cloneNode(true);
    debugItem.classList.add(TOGGLE_CLASS);
    debugItem.removeAttribute("aria-current");
    const debugSvg = `
    <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="header_icon__8SHkt">
      <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/>
    </svg>`;
    debugItem.querySelector("svg").outerHTML = debugSvg;

    let debugState = await GM.getValue(CONFIG.storageKeys.debugLog, false);
    debugItem.href = "#";
    debugItem.querySelector(".header_text__SNWKj").textContent = `디버그 로그 ${
      debugState ? "ON" : "OFF"
    }`;

    debugItem.addEventListener("click", async (e) => {
      e.preventDefault();
      e.stopPropagation();

      debugState = !debugState;
      debugItem.querySelector(
        ".header_text__SNWKj"
      ).textContent = `디버그 로그 ${debugState ? "ON" : "OFF"}`;

      await GM.setValue(CONFIG.storageKeys.debugLog, debugState);

      alert(`디버그 로그: ${debugState ? "ON" : "OFF"}\n페이지를 새로고침합니다.`);
      location.reload();
    });
    header.appendChild(debugItem);

    const unmuteItem = template.cloneNode(true);
    unmuteItem.classList.add(TOGGLE_CLASS);
    unmuteItem.removeAttribute("aria-current");
    let unmuteState = await GM.getValue(CONFIG.storageKeys.autoUnmute, false);

    const unmuteSvgOff = `
    <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="header_icon__8SHkt">
      <path stroke-linecap="round" stroke-linejoin="round" d="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"/>
    </svg>`;
    const unmuteSvgOn = `
    <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="header_icon__8SHkt">
      <path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"/>
    </svg>`;

    unmuteItem.querySelector("svg").outerHTML = unmuteState
      ? unmuteSvgOn
      : unmuteSvgOff;
    unmuteItem.href = "#";
    unmuteItem.querySelector(
      ".header_text__SNWKj"
    ).textContent = `음소거 해제 ${unmuteState ? "ON" : "OFF"}`;

    unmuteItem.addEventListener("click", async (e) => {
      e.preventDefault();
      e.stopPropagation();

      unmuteState = !unmuteState;
      unmuteItem.querySelector("svg").outerHTML = unmuteState
        ? unmuteSvgOn
        : unmuteSvgOff;
      unmuteItem.querySelector(
        ".header_text__SNWKj"
      ).textContent = `음소거 해제 ${unmuteState ? "ON" : "OFF"}`;

      await GM.setValue(CONFIG.storageKeys.autoUnmute, unmuteState);

      alert(`음소거 자동 해제: ${unmuteState ? "ON" : "OFF"}\n페이지를 새로고침합니다.`);
      location.reload();
    });
    header.appendChild(unmuteItem);

    const sharpItem = template.cloneNode(true);
    sharpItem.classList.add(TOGGLE_CLASS);
    sharpItem.removeAttribute("aria-current");
    let sharpState = await GM.getValue(
      CONFIG.storageKeys.screenSharpness,
      false
    );

    const sharpSvg = `
    <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="header_icon__8SHkt">
      <path stroke-linecap="round" stroke-linejoin="round" d="M6 20.25h12m-7.5-3v3m3-3v3m-10.125-3h17.25c.621 0 1.125-.504 1.125-1.125V4.875c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125Z" />
    </svg>`;
    sharpItem.querySelector("svg").outerHTML = sharpSvg;
    sharpItem.href = "#";
    sharpItem.querySelector(".header_text__SNWKj").textContent = `선명한 화면 ${
      sharpState ? "ON" : "OFF"
    }`;

    sharpItem.addEventListener("click", async (e) => {
      e.preventDefault();
      e.stopPropagation();

      sharpState = !sharpState;
      sharpItem.querySelector(
        ".header_text__SNWKj"
      ).textContent = `선명한 화면 ${sharpState ? "ON" : "OFF"}`;

      await GM.setValue(CONFIG.storageKeys.screenSharpness, sharpState);

      alert(`선명한 화면: ${sharpState ? "ON" : "OFF"}\n페이지를 새로고침합니다.`);
      location.reload();
    });
    header.appendChild(sharpItem);
  }

  const quality = {
    observeManualSelect() {
      document.body.addEventListener(
        "click",
        async (e) => {
          const li = e.target.closest('li[class*="quality"]');
          if (!li) return;
          const raw = li.textContent;
          const res = common.text.extractResolution(raw);
          if (res) {
            await GM.setValue(CONFIG.storageKeys.quality, res);
            common.log.groupCollapsed(
              "%c💾 [Quality] 수동 화질 저장됨",
              CONFIG.styles.success
            );
            common.log.table([
              { "선택 해상도": res, 원본: common.text.clean(raw) },
            ]);
            common.log.groupEnd();
          }
        },
        { capture: true }
      );
    },

    async getPreferred() {
      const stored = await GM.getValue(CONFIG.storageKeys.quality, 1080);
      return parseInt(stored, 10);
    },

    async applyPreferred() {
      const now = Date.now();
      if (this._applying || now - this._lastApply < APPLY_COOLDOWN) return;
      this._applying = true;
      this._lastApply = now;

      const target = await this.getPreferred();
      let cleaned = "(선택 실패)",
        pick = null;

      try {
        const btn = await common.async.waitFor(CONFIG.selectors.qualityBtn);
        btn.click();
        const menu = await common.async.waitFor(CONFIG.selectors.qualityMenu);
        menu.click();
        await common.async.sleep(CONFIG.minTimeout);

        const items = Array.from(
          document.querySelectorAll(CONFIG.selectors.qualityItems)
        );
        pick =
          items.find(
            (i) => common.text.extractResolution(i.textContent) === target
          ) ||
          items.find((i) => /\d+p/.test(i.textContent)) ||
          items[0];
        cleaned = pick ? common.text.clean(pick.textContent) : cleaned;
        if (pick)
          pick.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
        else common.log.warn("[Quality] 화질 항목을 찾지 못함");
      } catch (e) {
        common.log.error(`[Quality] 선택 실패: ${e.message}`);
      }
      common.log.groupCollapsed(
        "%c⚙️ [Quality] 자동 화질 적용",
        CONFIG.styles.info
      );
      common.log.table([{ "대상 해상도": target }]);
      common.log.table([
        { "선택 화질": cleaned, "선택 방식": pick ? "자동" : "없음" },
      ]);
      common.log.groupEnd();
      this._applying = false;
    },
  };

  const handler = {
    interceptXHR() {
      const oOpen = XMLHttpRequest.prototype.open;
      const oSend = XMLHttpRequest.prototype.send;
      XMLHttpRequest.prototype.open = function (m, u, ...a) {
        this._url = u;
        return oOpen.call(this, m, u, ...a);
      };
      XMLHttpRequest.prototype.send = function (body) {
        if (this._url?.includes("live-detail")) {
          this.addEventListener("readystatechange", () => {
            if (this.readyState === 4 && this.status === 200) {
              try {
                const data = JSON.parse(this.responseText);
                if (data.content?.p2pQuality) {
                  data.content.p2pQuality = [];
                  const mod = JSON.stringify(data);
                  Object.defineProperty(this, "responseText", { value: mod });
                  Object.defineProperty(this, "response", { value: mod });
                  setTimeout(() => quality.applyPreferred(), CONFIG.minTimeout);
                }
              } catch (e) {
                common.log.error(`[XHR] JSON 파싱 오류: ${e.message}`);
              }
            }
          });
        }
        return oSend.call(this, body);
      };
      common.log.info("[XHR] live-detail 요청 감시 시작");
    },
    trackURLChange() {
      let lastUrl = location.href;
      let lastId = null;

      const getId = (url) => (url.match(/live\/([\w-]+)/) ?? [])[1] || null;

      const onUrlChange = () => {
        const currentUrl = location.href;
        if (currentUrl === lastUrl) return;

        lastUrl = currentUrl;

        const id = getId(currentUrl);
        if (!id) {
          common.log.info("[URLChange] 방송 ID 없음");
        } else if (id !== lastId) {
          lastId = id;
          setTimeout(() => {
            quality.applyPreferred();
            injectSharpnessScript();
          }, CONFIG.minTimeout);
        } else {
          common.log.warn(`[URLChange] 같은 방송(${id}), 스킵`);
        }
        const svg = document.getElementById("sharpnessSVGContainer");
        const style = document.getElementById("sharpnessStyle");
        if (svg) svg.remove();
        if (style) style.remove();
        if (window.sharpness) {
          window.sharpness.init();
          window.sharpness.observeMenus();
        }
      };
      ["pushState", "replaceState"].forEach((method) => {
        const original = history[method];
        history[method] = function (...args) {
          const result = original.apply(this, args);
          window.dispatchEvent(new Event("locationchange"));
          return result;
        };
      });
      window.addEventListener("popstate", () =>
        window.dispatchEvent(new Event("locationchange"))
      );
      window.addEventListener("locationchange", onUrlChange);
    },
  };

  const observer = {
    start() {
      const mo = new MutationObserver((muts) => {
        for (const mut of muts) {
          for (const node of mut.addedNodes) {
            if (node.nodeType !== 1) continue;
            this.tryRemoveAdPopup(node);
            let vid = null;
            if (node.tagName === "VIDEO") vid = node;
            else if (node.querySelector?.("video"))
              vid = node.querySelector("video");
            if (/^\/live\/[^/]+/.test(location.pathname) && vid) {
              this.unmuteAll(vid);
              checkAndFixLowQuality(vid);

              (async () => {
                await new Promise((resolve) => {
                  const waitForReady = () => {
                    if (vid.readyState >= 4) return resolve();
                    setTimeout(waitForReady, 100);
                  };
                  waitForReady();
                });

                try {
                  await vid.play();
                  common.log.success(
                    "%c▶️ [AutoPlay] 재생 성공",
                    CONFIG.styles.info
                  );
                } catch (e) {
                  common.log.error(`⚠️ [AutoPlay] 재생 실패: ${e.message}`);
                }
              })();
            }
          }
        }
        if (document.body.style.overflow === "hidden") {
          common.dom.clearStyle(document.body);
          common.log.info("[BodyStyle] overflow:hidden 제거됨");
        }
      });
      mo.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ["style"],
      });
      common.log.info("[Observer] 통합 감시 시작");
    },

    async unmuteAll(video) {
      const autoUnmute = await GM.getValue(CONFIG.storageKeys.autoUnmute, true);
      if (!autoUnmute) return common.log.info("[Unmute] 설정에 따라 스킵");
      if (video.muted) {
        video.muted = false;
        common.log.success("[Unmute] video.muted 해제");
      }
      const btn = document.querySelector(
        'button.pzp-pc-volume-button[aria-label*="음소거 해제"]'
      );
      if (btn) {
        btn.click();
        common.log.success("[Unmute] 버튼 클릭");
      }
    },

    async tryRemoveAdPopup(node) {
      try {
        const txt = node.innerText || "";
        if (common.regex.adBlockDetect.test(txt)) {
          const cont = node.closest(CONFIG.selectors.popup) || node;
          cont.remove();
          common.dom.clearStyle(document.body);
          common.log.groupCollapsed(
            "%c✅ [AdPopup] 제거 성공",
            CONFIG.styles.success
          );
          common.log.table([
            { "제거된 텍스트": txt.slice(0, 100), 클래스: cont.className },
          ]);
          common.log.groupEnd();
        }
      } catch (e) {
        common.log.error(`[AdPopup] 제거 실패: ${e.message}`);
      }
    },
  };

  async function checkAndFixLowQuality(video) {
    if (!video || video.__checkedAlready) return;
    video.__checkedAlready = true;

    await common.async.sleep(CONFIG.defaultTimeout);

    let height = video.videoHeight || 0;
    if (height === 0) {
      await common.async.sleep(1000);
      height = video.videoHeight || 0;
    }
    if (height === 0) {
      return;
    }

    if (height <= 360) {
      const preferred = await quality.getPreferred();
      if (preferred !== height) {
        common.log.warn(
          `[QualityCheck] 저화질(${height}p) 감지, ${preferred}p로 복구`
        );
        await quality.applyPreferred();
      } else {
        common.log.warn(
          "[QualityCheck] 현재 해상도가 사용자 선호값과 동일하여 복구 생략"
        );
      }
    }
  }

  async function setDebugLogging() {
    common.log.DEBUG = await GM.getValue(CONFIG.storageKeys.debugLog, false);
  }

  async function injectSharpnessScript() {
    const enabled = await GM.getValue(
      CONFIG.storageKeys.screenSharpness,
      false
    );
    if (!enabled) return;
    const script = document.createElement("script");
    script.src =
      "https://update.greasyfork.org/scripts/534918/Chzzk%20%EC%84%A0%EB%AA%85%ED%95%9C%20%ED%99%94%EB%A9%B4%20%EC%97%85%EA%B7%B8%EB%A0%88%EC%9D%B4%EB%93%9C.user.js";
    script.async = true;
    document.head.appendChild(script);
    common.log.success(
      "%c[Sharpness] 외부 스크립트 삽입 완료",
      CONFIG.styles.info
    );
  }

  async function init() {
    await setDebugLogging();
    if (document.body.style.overflow === "hidden") {
      common.dom.clearStyle(document.body);
      common.log.success("[Init] overflow 잠금 해제");
    }
    if ((await GM.getValue(CONFIG.storageKeys.quality)) === undefined) {
      await GM.setValue(CONFIG.storageKeys.quality, 1080);
      common.log.success("[Init] 기본 화질 1080 저장");
    }
    if ((await GM.getValue(CONFIG.storageKeys.autoUnmute)) === undefined) {
      await GM.setValue(CONFIG.storageKeys.autoUnmute, true);
      common.log.success("[Init] 기본 언뮤트 ON 저장");
    }
    await addHeaderMenu();
    common.observeElement(
      CONFIG.selectors.headerMenu,
      () => {
        addHeaderMenu().catch(console.error);
      },
      false
    );

    await quality.applyPreferred();

    await injectSharpnessScript();
  }

  function onDomReady() {
    console.log("%c🔔 [ChzzkHelper] 스크립트 시작", CONFIG.styles.info);
    quality.observeManualSelect();
    observer.start();
    init().catch(console.error);
  }

  handler.interceptXHR();
  handler.trackURLChange();

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", onDomReady);
  } else {
    onDomReady();
  }
})();

(function () {
  const skip = (t) =>
    ["INPUT", "TEXTAREA"].includes(t.tagName) || t.isContentEditable;
  const getBtn = () =>
    document.querySelector(
      'button[aria-label="넓은 화면"],button[aria-label="좁은 화면"]'
    );
  document.addEventListener(
    "keydown",
    (e) => {
      if (skip(e.target) || e.ctrlKey || e.altKey || e.metaKey) return;
      const v = document.querySelector("video");
      if (!v) return;
      const k = e.key.toLowerCase();
      const actions = {
        " ": () => (v.paused ? v.play() : v.pause()),
        k: () => (v.paused ? v.play() : v.pause()),
        m: () => (v.muted = !v.muted),
        t: () => {
          const b = getBtn();
          b && b.click();
        },
        f: () =>
          document.fullscreenElement
            ? document.exitFullscreen()
            : v.requestFullscreen && v.requestFullscreen(),
      };
      if (actions[k]) {
        actions[k]();
        e.preventDefault();
        e.stopPropagation();
      }
    },
    true
  );
})();