Holotower Soundposts

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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();

})();