Holotower Soundposts

Play sound for soundposts (video and image) on Holotower.

// ==UserScript==
// @name         Holotower Soundposts
// @namespace    http://tampermonkey.net/
// @version      1.2
// @author       grem
// @license      MIT
// @description  Play sound for soundposts (video and image) on Holotower.
// @match        https://boards.holotower.org/*
// @match        https://holotower.org/*
// @grant        none
// @icon         https://boards.holotower.org/favicon.gif
// @run-at       document-end
// ==/UserScript==

(() => {
  'use strict';

  const SITE_VOLUME_KEY = 'videovolume';
  const VOLUME_FALLBACK = 1.0;

  const soundMap = new Map(); // filePath → sound URL
  const active   = new Map(); // media element → { audio, visibilityObserver, listeners }

  const NATIVE_EXPANDED_IMAGE_SELECTOR = 'img.full-image';
  const NATIVE_HOVER_IMAGE_SELECTOR = '#chx_hoverImage';
  const IQ_HOVER_IMAGE_SELECTOR = 'img[style*="position: fixed"][style*="pointer-events: none"]';
  const ALL_IMAGE_SELECTORS = `${NATIVE_EXPANDED_IMAGE_SELECTOR}, ${NATIVE_HOVER_IMAGE_SELECTOR}, ${IQ_HOVER_IMAGE_SELECTOR}`;


  const siteVolume = () => {
    let v = localStorage.getItem(SITE_VOLUME_KEY);
    if (typeof v === "string" && v.startsWith('"') && v.endsWith('"')) {
      v = v.slice(1, -1);
    }
    v = parseFloat(v);
    if (!isFinite(v) || v < 0 || v > 1) v = VOLUME_FALLBACK;
    return v;
  };

  const tagIcon = span => {
    if (span.dataset.soundControlsAdded) return;
    span.dataset.soundControlsAdded = 'true';
    const mark = document.createElement('span');
    mark.textContent = ' 🔊';
    mark.title = 'This file has sound.';
    span.after(mark);
  };

  function scan(root = document) {
    root.querySelectorAll('p.fileinfo a[download]').forEach(a => {
      const span = a.closest('span.unimportant');
      if (!span) return;
      const m = a.download.match(/\[sound=([^\]]+)]/i);
      if (!m) return;
      try {
        const url = decodeURIComponent(decodeURIComponent(m[1]));
        const fullURL = url.startsWith('http') ? url : `https://${url}`;
        const filePath = new URL(a.getAttribute('href'), location.href).pathname;
        soundMap.set(filePath, fullURL);
        tagIcon(span);
      } catch {}
    });
  }

  function isDisplayVisible(el) {
    if (!el) return false;
    const style = el.style.display || window.getComputedStyle(el).display;
    return style === "block" || style === "inline" || style === "";
  }

  // SOUNDPOST VIDEO HANDLER
  function bind(video) {
    const path = new URL(video.src, location.href).pathname;
    const soundURL = soundMap.get(path);
    if (!soundURL || active.has(video)) return;

    let container = video.parentElement;
    for (let i = 0; i < 2; ++i) {
      if (!container) break;
      if (container.style && (container.style.display !== undefined)) break;
      container = container.parentElement;
    }
    if (!container || container === document.body) container = video;

    const audio = new Audio(soundURL);
    audio.preload = 'auto';
    audio.loop    = true;
    audio.volume  = video.volume ?? siteVolume();
    audio.muted   = video.muted;
    video.loop = true;

    const listeners = {};

    listeners.tighten = () => {
      const d = video.duration || 1;
      const target = audio.currentTime % d;
      if (Math.abs(video.currentTime - target) > 0.25) {
        video.currentTime = target;
      }
      if (video.paused) video.play().catch(()=>{});
    };

    listeners.tryPlayAudio = () => {
      if (!audio.paused && !video.paused) return;
      if (isDisplayVisible(container) && !video.paused) {
        listeners.tighten();
        audio.play().catch(()=>{});
      }
    };

    listeners.tryPauseAudio = () => {
      if (!isDisplayVisible(container) && !audio.paused) {
        audio.pause();
      }
    };

    listeners.onVideoPause = () => { if (!audio.paused) audio.pause(); };
    listeners.onVolumeChange = () => {
      audio.volume = video.volume;
      audio.muted  = video.muted;
    };

    audio.addEventListener('timeupdate', listeners.tighten);
    video.addEventListener('play', listeners.tryPlayAudio);
    video.addEventListener('pause', listeners.onVideoPause);
    video.addEventListener('volumechange', listeners.onVolumeChange);

    const visibilityObserver = new MutationObserver(() => {
      const visible = isDisplayVisible(container);
      if (visible) {
        listeners.tryPlayAudio();
      } else {
        listeners.tryPauseAudio();
      }
    });
    visibilityObserver.observe(container, { attributes: true, attributeFilter: ["style"] });

    if (isDisplayVisible(container) && !video.paused) {
      listeners.tryPlayAudio();
    }
    active.set(video, { audio, visibilityObserver, listeners });
  }

  // SOUNDPOST IMAGE HANDLER
  function bindImage(img) {
    const path = new URL(img.src, location.href).pathname;
    const soundURL = soundMap.get(path);
    if (!soundURL || active.has(img)) return;

    let container = img.parentElement;
    if (img.matches(ALL_IMAGE_SELECTORS)) {
        container = img;
    } else {
      for (let i = 0; i < 2; ++i) {
        if (!container) break;
        if (container.style && (container.style.display !== undefined)) break;
        container = container.parentElement;
      }
      if (!container || container === document.body) container = img;
    }

    const audio = new Audio(soundURL);
    audio.preload = 'auto';
    audio.loop    = true;

    const listeners = {};

    listeners.tryPlayAudio = () => {
      if (!audio.paused && isDisplayVisible(container)) return;
      if (isDisplayVisible(container)) {
        audio.volume = siteVolume();
        audio.currentTime = 0;
        audio.play().catch(()=>{});
      }
    };

    listeners.tryPauseAudio = () => {
      if (!isDisplayVisible(container) && !audio.paused) {
        audio.pause();
        audio.currentTime = 0;
      }
    };

    const visibilityObserver = new MutationObserver(() => {
      const visible = isDisplayVisible(container);
      if (visible) {
        listeners.tryPlayAudio();
      } else {
        listeners.tryPauseAudio();
      }
    });
    visibilityObserver.observe(container, { attributes: true, attributeFilter: ["style"] });

    if (isDisplayVisible(container)) {
      listeners.tryPlayAudio();
    }
    active.set(img, { audio, visibilityObserver, listeners: {} });
  }

  function scanImages(root=document) {
    root.querySelectorAll(ALL_IMAGE_SELECTORS).forEach(bindImage);
  }

  // CENTRALIZED OBSERVER FOR ADDING AND REMOVING NODES

  const mainObserver = new MutationObserver(mutations => {
    for (const mutation of mutations) {
      // Handle newly added nodes
      for (const node of mutation.addedNodes) {
        if (node.nodeType !== 1) continue;
        if (node.matches('.post, .reply')) scan(node);
        node.querySelectorAll?.('.post, .reply').forEach(scan);
        if (node.matches('video')) bind(node);
        node.querySelectorAll?.('video').forEach(bind);
        if (node.matches(ALL_IMAGE_SELECTORS)) bindImage(node);
        node.querySelectorAll?.(ALL_IMAGE_SELECTORS).forEach(bindImage);
      }

      // Handle removed nodes (GARBAGE COLLECTION)
      for (const removedNode of mutation.removedNodes) {
        if (removedNode.nodeType !== 1) continue;
        active.forEach((value, key) => {
          if (removedNode === key || removedNode.contains(key)) {
            // Full teardown to prevent zombie listeners
            value.audio.pause();
            value.visibilityObserver.disconnect();

            if (value.listeners.tighten) {
              value.audio.removeEventListener('timeupdate', value.listeners.tighten);
              key.removeEventListener('play', value.listeners.tryPlayAudio);
              key.removeEventListener('pause', value.listeners.onVideoPause);
              key.removeEventListener('volumechange', value.listeners.onVolumeChange);
            }

            active.delete(key);
          }
        });
      }
    }
  });

  // BOOTSTRAP
  scan();
  document.querySelectorAll('video').forEach(bind);
  scanImages();
  mainObserver.observe(document.body, { childList: true, subtree: true });

  function patchPostCloning() {
    const postProto = window.g?.Post?.prototype;
    if (typeof postProto?.addClone !== 'function') {
      if ((patchPostCloning.attempts || 0) < 5) {
        setTimeout(patchPostCloning, 500);
        patchPostCloning.attempts = (patchPostCloning.attempts || 0) + 1;
      }
      return;
    }

    const originalAddClone = postProto.addClone;
    if (originalAddClone.isPatchedBySoundposts) return;

    postProto.addClone = function(...args) {
      const cloneObj = originalAddClone.apply(this, args);
      if (cloneObj?.nodes?.root) {
        const clonedPost = cloneObj.nodes.root;
        scan(clonedPost);
        clonedPost.querySelectorAll('video').forEach(bind);
        scanImages(clonedPost);
      }
      return cloneObj;
    };
    postProto.addClone.isPatchedBySoundposts = true;
  }

  patchPostCloning();

})();