Holotower Soundposts

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

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.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         Holotower Soundposts
// @namespace    http://tampermonkey.net/
// @version      1.7
// @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';

  // Storage key for user's volume preference
  const SITE_VOLUME_KEY = 'videovolume';
  const VOLUME_FALLBACK = 1.0;

  // Maps file paths to their associated sound URLs
  const soundMap = new Map(); // filePath → sound URL
  // Tracks active media elements with bound audio
  const active = new Map(); // media element → { audio, visibilityObserver, listeners }

  // CSS selectors for different image viewer types
  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}`;

  // Get user's saved volume preference, with validation
  const siteVolume = () => {
    let v = localStorage.getItem(SITE_VOLUME_KEY);
    if (typeof v === "string" && v.startsWith('"') && v.endsWith('"')) {
      v = v.slice(1, -1); // Remove JSON string quotes if present
    }
    v = parseFloat(v);
    if (!isFinite(v) || v < 0 || v > 1) v = VOLUME_FALLBACK;
    return v;
  };

  // Whitelist of allowed domains for sound file URLs
  const ALLOWED_SOUND_DOMAINS = [
    'files.catbox.moe',
    'litter.catbox.moe',
    'x0.at'
    // Add more domains here as needed
  ];

  // Whitelist check for sound file URLs
  const isAllowedSoundURL = (url) => {
    try {
      const urlObj = new URL(url);
      return ALLOWED_SOUND_DOMAINS.includes(urlObj.hostname);
    } catch {
      return false;
    }
  };

  // Add sound icon link next to file info (prevents duplicates)
  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;
  };

  // Validate that a sound URL is actually playable (checks file exists and format is valid)
  function validateSoundLink(url, filePath, linkElement) {
    const audio = new Audio();
    audio.preload = 'metadata'; // Only check metadata, don't download entire file

    const onSuccess = () => {
      cleanup(); // Valid file - cleanup and let it play later
    };

    const onError = () => {
      cleanup();
      soundMap.delete(filePath); // Remove from map since it's broken
      const brokenMark = document.createElement('span');
      brokenMark.textContent = ' [broken]';
      brokenMark.style.color = 'red';
      linkElement.after(brokenMark);
    };

    const cleanup = () => {
      audio.removeEventListener('canplaythrough', onSuccess);
      audio.removeEventListener('error', onError);
      audio.removeEventListener('abort', onError);
      audio.src = ''; // Stop network activity
    };

    audio.addEventListener('canplaythrough', onSuccess, { once: true });
    audio.addEventListener('error', onError, { once: true });
    audio.addEventListener('abort', onError, { once: true });

    audio.src = url; // Start validation
  }

  // Scan DOM for file download links with [sound=...] annotations
  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])); // May be double-encoded
        const fullURL = url.startsWith('http') ? url : `https://${url}`;
        const filePath = new URL(a.getAttribute('href'), location.href).pathname;

        // Security: reject URLs not from whitelisted domains
        if (!isAllowedSoundURL(fullURL)) {
          const rejectedMark = document.createElement('span');
          rejectedMark.textContent = ' [blocked]';
          rejectedMark.style.color = 'orange';
          span.after(rejectedMark);
          return;
        }

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

        soundMap.set(filePath, fullURL); // Register this file with its sound
        validateSoundLink(fullURL, filePath, linkElement);
      } catch {}
    });
  }

  // Check if element is visible (not hidden by display:none)
  function isDisplayVisible(el) {
    if (!el) return false;
    const style = el.style.display || window.getComputedStyle(el).display;
    return style === "block" || style === "inline" || style === "";
  }

  // Bind sound playback to a video element (syncs audio with video)
  function bind(video) {
    const path = new URL(video.src, location.href).pathname;
    const soundURL = soundMap.get(path);
    if (!soundURL || active.has(video)) return;
    if (!isAllowedSoundURL(soundURL)) return; // Security check

    // Find container element that controls visibility (for image viewer)
    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 = {};
    let isSeeking = false;

    // Sync audio to video position
    const syncAudioToVideo = () => {
      if (isSeeking) return;
      const d = audio.duration || video.duration || 1;
      const target = video.currentTime % d;
      if (Math.abs(audio.currentTime - target) > 0.25) {
        audio.currentTime = target;
      }
    };

    listeners.onVideoPlay = () => {
      if (!isDisplayVisible(container)) return;
      syncAudioToVideo();
      audio.play().catch(() => {});
    };

    listeners.onVideoPause = () => {
      audio.pause();
    };

    listeners.onVideoSeeking = () => {
      isSeeking = true;
    };

    listeners.onVideoSeeked = () => {
      syncAudioToVideo();
      isSeeking = false;
    };

    listeners.onVideoTimeUpdate = () => {
      // Only sync if significantly out of sync and not seeking
      if (isSeeking) return;
      const d = audio.duration || video.duration || 1;
      const target = video.currentTime % d;
      if (Math.abs(audio.currentTime - target) > 0.5) {
        audio.currentTime = target;
      }
    };

    listeners.onVolumeChange = () => {
      audio.volume = video.volume;
      audio.muted = video.muted;
    };

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

    video.addEventListener('play', listeners.onVideoPlay);
    video.addEventListener('pause', listeners.onVideoPause);
    video.addEventListener('seeking', listeners.onVideoSeeking);
    video.addEventListener('seeked', listeners.onVideoSeeked);
    video.addEventListener('timeupdate', listeners.onVideoTimeUpdate);
    video.addEventListener('volumechange', listeners.onVolumeChange);

    // Watch for visibility changes (e.g., when image viewer opens/closes)
    const visibilityObserver = new MutationObserver(() => {
      const visible = isDisplayVisible(container);
      if (visible) {
        if (!video.paused) {
          syncAudioToVideo();
        }
        audio.play().catch(() => {});
      } else if (!visible) {
        audio.pause();
      }
    });
    visibilityObserver.observe(container, { attributes: true, attributeFilter: ["style"] });

    if (isDisplayVisible(container) && !video.paused) {
      syncAudioToVideo();
      audio.play().catch(() => {});
    }
    active.set(video, { audio, visibilityObserver, listeners });
  }

  // Bind sound playback to an image element (plays when image is visible)
  function bindImage(img) {
    const path = new URL(img.src, location.href).pathname;
    const soundURL = soundMap.get(path);
    if (!soundURL || active.has(img)) return;
    if (!isAllowedSoundURL(soundURL)) return; // Security check

    // For expanded/hover images, use image itself as container
    let container = img.parentElement;
    if (img.matches(ALL_IMAGE_SELECTORS)) {
        container = img;
    } else {
      // Otherwise find parent container that controls visibility
      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 = {};

    // Play from beginning when image becomes visible
    listeners.tryPlayAudio = () => {
      if (!audio.paused && isDisplayVisible(container)) return;
      if (isDisplayVisible(container)) {
        audio.volume = siteVolume();
        audio.currentTime = 0; // Always restart from beginning
        audio.play().catch(()=>{});
      }
    };

    // Pause and reset when image is hidden
    listeners.tryPauseAudio = () => {
      if (!isDisplayVisible(container) && !audio.paused) {
        audio.pause();
        audio.currentTime = 0;
      }
    };

    // Watch for visibility changes
    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: {} });
  }

  // Scan for image elements that should have sound bound
  function scanImages(root=document) {
    root.querySelectorAll(ALL_IMAGE_SELECTORS).forEach(bindImage);
  }

  // Watch for DOM changes to handle dynamically loaded content
  const mainObserver = new MutationObserver(mutations => {
    for (const mutation of mutations) {
      // Handle newly added nodes (new posts, videos, images)
      for (const node of mutation.addedNodes) {
        if (node.nodeType !== 1) continue; // Skip non-element nodes
        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);
      }

      // Clean up removed nodes to prevent memory leaks
      for (const removedNode of mutation.removedNodes) {
        if (removedNode.nodeType !== 1) continue;
        active.forEach((value, key) => {
          if (removedNode === key || removedNode.contains(key)) {
            value.audio.pause();
            value.visibilityObserver.disconnect();

            // Remove video-specific listeners if present
            if (value.listeners.onVideoPlay) {
              key.removeEventListener('play', value.listeners.onVideoPlay);
              key.removeEventListener('pause', value.listeners.onVideoPause);
              key.removeEventListener('seeking', value.listeners.onVideoSeeking);
              key.removeEventListener('seeked', value.listeners.onVideoSeeked);
              key.removeEventListener('timeupdate', value.listeners.onVideoTimeUpdate);
              key.removeEventListener('volumechange', value.listeners.onVolumeChange);
            }

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

  // Initialize: scan existing content and start watching for changes
  scan();
  document.querySelectorAll('video').forEach(bind);
  scanImages();
  mainObserver.observe(document.body, { childList: true, subtree: true });

  // Patch Post.addClone to ensure soundposts work with cloned posts (previews, etc.)
  function patchPostCloning() {
    const postProto = window.g?.Post?.prototype;
    if (typeof postProto?.addClone !== 'function') {
      // Retry if Post prototype not available yet (up to 5 attempts)
      if ((patchPostCloning.attempts || 0) < 5) {
        setTimeout(patchPostCloning, 500);
        patchPostCloning.attempts = (patchPostCloning.attempts || 0) + 1;
      }
      return;
    }

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

    // Wrap addClone to scan and bind sound to cloned posts
    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();

})();