Holotower Soundposts

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Holotower Soundposts
// @namespace    http://tampermonkey.net/
// @version      1.5
// @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         data:image/gif;base64,R0lGODlhIAAgAHAAACH5BAEAAPsALAAAAAAgACAAh2x+yAc0uCFFvQo5vgI5wwY7wjpexwo8wQQ8xAU/xgVAyAxEyVh60w0/wwZAyAhCzAhEzghGzwhH0hRNz3eV3YnB7kin7E+v8VCz8VC571G88VHC8UzF9HrP7g1EyAdDzQlI0QpJ1AtL1gpO1w1P2ihf2Wiy8Rub+R6i+R6p+iKv+iO2/CK8/SvB+g9GzAhH0QpJ0wxN1wtP2Q1R2w5S3w1V3xFX4UJ142u28B6f+SKl9yKs+ySz+yS6/CXA/XHS9w1KzwpK1QtN1QtP1w1Q2w1S3Q9U3xBX4BBY5BBb5Q5d6BVf5laI5mu68R+i+CKq+iOw+yS3/CS9/jPC+XKO2nST3nSU3nOV4HSV4nWX5XaY43Sb5nib5nad6Hed6nWd7W6b64Ck6mu88R+m+iOt+iS0+yS7/SfB/ZDb9my88iCr+iSx+yS4/CS//kfH92y/8CKu+SS1+yS8/SrB/GzA8yGz+iS5/STA/mHN92rD8yO2+yS9/S7C+o6s3mzF8yO6/CbA/X/V9pGy7l2O5m3G8yK+/TvE+CNz7GOX6mzJ8yjA/J/f9nap7hR18mie7GbK9EvH9iB+7xV99Wmi7mnK8Fif8BmE8xiE9Wqn8CGL8huL9xqL9myr8EGe7xuT9xyT9xqT92yx8Y3H8iGa9h+b+B6b+B2b+Gy38S2k9B+i+SCi+G67827C8CCp+iGp+h+p+m/A83bT82nR93HQ9nHM9nDK9m3J9W7G9W/E83DA9G6/82y982258mu183u58Cqw9yOx+iSw+yKw+3LF9FnK9CvB/CO//SS7/CS3+yO0+iOw+SGs+iCo+iGk+B+h+B6d+iub71PC9CO4/SS5/CW4/CO4/HHL9UjH9ijB/SO+/iS6/SS2/CSy+yOu+yKr+iKn+B+j+TKm9Jzd9Si/+yPA/m3N9pbc9jfD+SOt+yCq+zSr9ZPX8IHW94LX9n7V9nnU9SS0/COw+jey81/N9ji29kjH9zm+9pTc9jPC+DTB94jW8QAAAAAAAAAAAAAAAAAAAAj/APcJFAhgoMGDCBMiDCBAocOHAwcQKGAAosWDBxAkULCAwcWLDRw8gBBBwgQKDytYuIAhg4YNHDoc9PAhAogQIkaQKOHQxAkUKVSsYNECoYsXMETEkDGDRg0bNxLiyKFjB48ePn4gBBJEyBAiRYwcQZJEyRImB5s4eQIlipQpCalUsXIFSxYtW7h08fIFTBiDYsaQKWPmDJqPENOoWcOmjRvEEN/AiSNnDmSIdOrYuYPn8sM8evbwudjno58/gAJZFDToI6FChi4eQvQxkaJFFhk1cvTxEaSLkSRN+kjpYiVLlzB5fphJ0yZOyxV28vQJVKjoCEWNIlXK1CnsB1GlnFKlCsUq8ANZtXLF/hUsg7FkzaJVy9YtXLl07eLVy1fCX8AEI6AwwxxETDHGHINMMsosw0wzzjwDDULRSDMNNRhWY01C12CTjTbbcNONN9+AE85B4ozjw4orklOOQuac48Me08SxBjrpqHPQOuy046OP7kD0Dh93yEENPPHIg95A88zRhhlR0LOkQfWckY09Uxp0Dz75ZHmQPgcFBAA7
// @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, fullURL) => {
    if (span.dataset.soundControlsAdded) return;
    span.dataset.soundControlsAdded = 'true';
    const link = document.createElement('a');
    link.href = fullURL;
    link.target = '_blank';
    link.textContent = ' 🔊';
    link.title = 'This file has sound. Click to open sound source.';
    span.after(link);
    return link;
  };

  function validateSoundLink(url, filePath, linkElement) {
    const audio = new Audio();
    audio.preload = 'metadata'; // We only need to know if it's playable, not download the whole thing.

    // --- Define our event handlers ---

    // SUCCESS HANDLER: The browser confirmed it can play this file.
    const onSuccess = () => {
      cleanup(); // Important: Stop listening to prevent memory leaks.
      // The link is valid, so we do nothing and let the sound play later.
    };

    // ERROR HANDLER: The browser failed to load the media.
    const onError = () => {
      cleanup();
      // This is where we mark the link as broken.
      soundMap.delete(filePath);
      const brokenMark = document.createElement('span');
      brokenMark.textContent = ' [broken]';
      brokenMark.style.color = 'red';
      linkElement.after(brokenMark);
    };
    
    // --- Cleanup function to remove event listeners ---
    const cleanup = () => {
      audio.removeEventListener('canplaythrough', onSuccess);
      audio.removeEventListener('error', onError);
      audio.removeEventListener('abort', onError);
      audio.src = ''; // Stop any potential network activity.
    };
    
    // --- Attach listeners and start the loading process ---
    audio.addEventListener('canplaythrough', onSuccess, { once: true });
    audio.addEventListener('error', onError, { once: true });
    audio.addEventListener('abort', onError, { once: true }); // Also catch if the download is aborted.
    
    audio.src = url;
  }

  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;

        const linkElement = tagIcon(span, fullURL);
        if (!linkElement) return;

        soundMap.set(filePath, fullURL);

        validateSoundLink(fullURL, filePath, linkElement);
      } 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();

})();