fapnip / EOS Dynamic Images

// ==UserScript==
// @name        EOS Dynamic Images
// @namespace   EOS Module - Dynamic Images
// @description Add dynamic images to EOS
// @version     0.8
// @license     MIT
// @include     https://eosscript.com/*
// @include     https://milovana.com/webteases/*
// @include     https://milovana.com/eos/*
// @inject-into auto
// @grant       GM_addScript
// @grant       GM_xmlhttpRequest
// @run-at      document-start
// @require     https://cdnjs.cloudflare.com/ajax/libs/arrive/2.4.1/arrive.min.js

// ==/UserScript==

/***************************
 * READ ME:
 * Make sure you change @name, @namespace, @description and var name to something unique to you!
 * 
 * Make sure you keep your module names namespace unique as well.
 * 
 */

// Module specific code.  Just about everything here should be changed for your specific use case!

const DEBUG = true;

let slides = false;
let slide = 0;
let slideTime = 10000;
let slideLoop = false;
let slideTimeout;

// Startup stuff
function moduleInit() {
  
  localEvents.addEventListener('preload-start', () => {
    console.warn('Image Preload Starting');
  }, false);
  
  localEvents.addEventListener('preload-end', () => {
    console.warn('Image Preload Ended');
  }, false);
  
}

const modules = {
  dynamicImage: {
    version() {
      return interpreter.nativeToPseudo(GM.info.script.version);
    },
    load(locator) {
      // Dump what we got
      if (DEBUG) console.warn('load Got Value:',  locator, getEOSHost());
      setImageFromLocator(locator);
    },
    preload(locator) {
      // Dump what we got
      if (DEBUG) console.warn('preload Got Value:', getEOSRoot(), locator, getEOSHost());
      setImageFromLocator(locator, true);
    },
    startSlides(locators, time, loop) {
      if (DEBUG) console.warn('startSlides Got Value:', locators, time, loop);
      if (typeof locators === 'object') {
          locators = locators.properties
      }
      if (typeof locators === 'string') {
          locators = [locators]
      }
      if (!locators || !locators.length) {
        console.error('Invalid array of images', locators)
        return
      }
      slides = locators;
      slide = 0;
      slideLoop = !!loop;
      slideTime = time || 10000
      if (slideTimeout) clearTimeout(slideTimeout);
      nextSlide ();
    },
    getImageState(locator) {
      return interpreter.nativeToPseudo(imageLoadState[locator]);
    },
    stopSlides() {
      slides = false;
    },
    pauseSlides() {
      if (slideTimeout) clearTimeout(slideTimeout);
    },
    consintueSlides() {
      nextSlide();
    },
    nextSlide() {
      nextSlide();
    },
    setBackgroundColor () {
      
    },
  },
  // More modules ...
};


// Actions that will run on child (eosscripts.com, iframe)
const childActions = {
  // More childActions ...
};

// Actions that will run on parent (milovana.com, parent window)
const parentActions = {
  // More parentActions ...
};


function nextSlide () {
  if (slides) {
    setImageFromLocator(slides[slide]);
    dispatchEOSEvent('dynamicImage', 'slidetick');
    slide ++;
    if (slide >= slides.length) {
        slide = 0;
    }
    if (slide < slides.length) {
      slideTimeout = setTimeout(nextSlide, slideTime);
    }
  }
}



/****************************************************************
 * Common Module Injector Operations / Core EOS Userscript
 * **************************************************************
 * !!!! Shouldn't need to change anything below here !!!! 
 * (If you do, it'll make it more difficult to keep up-to-date with the core EOS userscript.)
 * 
 * **************************************************************
 */

const name = GM.info.script.name;

if (DEBUG) console.warn(`Installing ${name} v${GM.info.script.version} on ${window.location.host}`);

let interpreter;
let host;
let hooked = false;
let preLoading = 0;
const protos = {};
const imageCache = {};
const lastRandomImage = {};
const imageLoadState = {};
const localEvents = document.createElement('div');
const preloadStartEvent = new Event('preload-start');
const preloadEndEvent = new Event('preload-end');
const eosContainerId = "#eosContainer";

if (window.location.host.match(/eosscript\.com/)) {
  // EOS Script site
  let origInterpreterRun;
  // Wait for EOS container
  function install () {
    // Our EOS container was created.  Should be able to hook the Interpereter now.
    if (DEBUG) console.log('Hooking Interpereter Run');
    origInterpreterRun = window.Interpreter.prototype.run;
    // Hook JS Interpreter's run method
    window.Interpreter.prototype.run = function(...args) {
      // Wait until PageManager property exists so we know EOS has added its methods
      if (!this[`_RUN_START_${name}`] && this.globalObject.properties.PageManager) {
        // Now inject our methods into the Interpreter
        this[`_RUN_START_${name}`] = true; // And make sure we don't do it again.
        if (DEBUG) console.log('Intercepting Interpereter Run');
        console.log(`Installing ${name} modules...`);
        interpreter = this;
        addObjectToInterpreter(interpreter.globalObject, modules);
        if (moduleInit) moduleInit();
      }
      return origInterpreterRun.apply(this, args);
    }
  }
  if (!document.getElementById(eosContainerId)) {
    if (DEBUG) console.log('Waiting for eosContainer...');
    document.arrive(eosContainerId, function() {
      install();
    }); 
  } else {
    // Already here?
     install();
  }
  // Listen for events for parent<->child messages
  window.addEventListener("message", function({data}) {
    if (data && data.source === name && childActions[data.action]) {
      if (DEBUG) console.log('Running child action', data.action, data);
      childActions[data.action].apply(this, data.values || [])
    }
  }, false);
} else {
  // Milovana.com?
  // Wait for iframe to arrive:
  // watch for element creation in the whole HTML document
  document.arrive(".eosIframe", function() {
    // For firefox, we need to remove the sandbox attribute, else violentmonkey wont see the iframe.
    this.removeAttribute("sandbox");
    // Listen for events for parent<->child messages
    window.addEventListener("message", function({data}) {
      if (data && data.source === name && parentActions[data.action]) {
        if (DEBUG) console.log('Running parent action', data.action, data);
        parentActions[data.action].apply(this, data.values || [])
      }
    }, false);
  });
}

function randomIntFromInterval(min, max) { // min and max included 
  return Math.floor(Math.random() * (max - min + 1) + min);
}

function base64ImageType (string) {
  return typeof string === 'string' && string.match(/^data:(image\/[a-z]+);base64/i);
}

function b64toBlob(dataURI) {
    
    var byteString = atob(dataURI.split(',')[1]);
    var ab = new ArrayBuffer(byteString.length);
    var ia = new Uint8Array(ab);

    for (var i = 0; i < byteString.length; i++) {
        ia[i] = byteString.charCodeAt(i);
    }
    return new Blob([ab], { type: base64ImageType(dataURI)[1] });
}

function addImageToCache (name, uri) {
  if (DEBUG) console.log('Adding Image from cache:', name, uri);
  imageCache[name] = uri;
}

function getImageFromCache (name) {
  if (DEBUG && imageCache[name]) console.log('Getting Image from cache:', name, imageCache[name]);
  return imageCache[name];
}

function setImageFromLocator(locator, noDisplay) {
  if (locator instanceof Blob) {
      setImage(locator, locator, null, noDisplay);
  }
  if (typeof locator !== 'string') {
    console.error('Invalid image locator:', locator);
    return
  }
  if (base64ImageType(locator)) {
    return locator;
  }
  if (noDisplay && isPreloaded(locator)) return;
  
  const galleryM = locator.match(/^gallery:([^\/]+)\/(.*)$/)
  let image;
  if (galleryM) {
    // Gallery image
    // if (DEBUG) console.log('Looking for gallery:', galleryM[1], getEOSHost())
    const gallery = getEOSSript().galleries && getEOSSript().galleries[galleryM[1]];
    if (!gallery){
      console.error('Invalid Gallery: ' + galleryM[1], getEOSSript().galleries)
      return
    }
    if (galleryM[2] === '*') {
      // Random image from gallery
      const images = gallery.images;
      if (noDisplay) {
        // Preloading
        for (let i0 = 0, l0 = images.length; i0 < l0; i0++) {
          if (DEBUG) console.log('Preloading image: ' + 'gallery:' + galleryM[1] + '/' + images[i0].id)
          setImageFromLocator('gallery:' + galleryM[1] + '/' + images[i0].id, noDisplay)
        }
        return
      }
      for (let i = 10; i > 0; i--) {
        image = images[randomIntFromInterval(0, images.length-1)]
        if (image !== lastRandomImage[locator] || matched.length < 3) {
            break;
        }
      }
      if (!image) {
        console.error('Unable to randomly select image from ' + gallery.name);
        return
      }
    } else {
      // Specific image
      const imageId = parseInt(galleryM[2], 10)
      image = gallery.images.find(i => i.id === imageId)
      if (!image) {
        console.error('Unable to find imgage ID ' + imageId + ' in ' + gallery.name);
        return
      }
      lastRandomImage[locator] = image
    }
  } else {
    const fileM = locator.match(/^file:(.+)$/)
    if (!fileM) {
      console.error('Invalid image locator:', locator);
      return
    }
    let fileName;
    if (fileM[1].match(/^(.*)\*/)) {
      // Random image from wildcard
      const fileRegExp = new RegExp(escapeRegExp(fileM[1]).replace(/\\\*/, '.*'));
      const files = getEOSSript().files || {};
      const matched = Object.keys(files).filter(f => f.match(fileRegExp));
      if (noDisplay) {
        // Preloading
        for (let m1 = 0, l1 = matched.length; m1 < l1; m1++) {
          setImageFromLocator('file:' + matched[m1], noDisplay)
        }
        return
      }
      for (let i2 = 10; i2 > 0; i2--) {
        const randomIndex = randomIntFromInterval(0, matched.length-1);
        fileName = matched[randomIndex];
        image = files[fileName];
        if (image !== lastRandomImage[locator] || matched.length < 3) {
            break;
        }
      }
      if (!image) {
        console.error('Unable to randomly select image from file pattern: ' + fileM[1]);
        return
      }
      lastRandomImage[locator] = image
    } else {
      const files = getEOSSript().files
      const file = files && files[fileM[1]];
      if (!file) {
        console.error('Invalid file:', fileM[1]);
        return
      }
      fileName = fileM[1];
      image = file
    }
    console.log('Selecte Image:', image);
    
    if (fileName.match(/\.mp3$/i)) {
      // File is an mp3 file.  See if we can extract an image from it
      const mp3Url = 'https://media.milovana.com/timg/' + image.hash + '.mp3'
      const name = 'file:' + fileName;
      const cachedImage = getImageFromCache(name);
      if (cachedImage) {
        setImage(cachedImage, locator, null, noDisplay);
        return;
      }
      // We'll need to ask the parent window to load this since we're restricted.
      // runParentAction('__loadImageFromMp3', mp3Url, locator, noDisplay);
      loadImageFromMp3(mp3Url, locator, name, noDisplay)
      return 
    }
  }
  setImage(image, locator, noDisplay ? locator : null, noDisplay);
}

function getFile(url, options) {
  let promise = new Promise(function(resolve, reject) {
    const opt = {
      url: url,
      method: 'GET',
      responseType: 'blob',
      timeout: 10000,
      onload: resolve,
      onerror: reject, 
      // ...options
    }
    try {
      GM_xmlhttpRequest(opt);
    } catch (e) {
      alert('GM_xmlhttpRequest Error');
    }
    
  });
  return promise;
}

function failedPreload(locator) {
  imageLoadState[locator] = false
  preLoading --;
  if (preLoading < 0) {
    console.error('Negative preload counter on failedPreload');    
  }
  if (!preLoading) {
      localEvents.dispatchEvent(preloadEndEvent);
  }

}
function isPreloaded(locator) {
  return !!imageLoadState[locator]
}
function startPreload(locator) {
  imageLoadState[locator] = undefined;
  if (!preLoading) {
      localEvents.dispatchEvent(preloadStartEvent);
  }
  preLoading ++;
  
}
function preloadedImage(locator) {
  imageLoadState[locator] = true
  preLoading --;
  if (preLoading < 0) {
    console.error('Negative preload counter on preloadedImage');    
  }
  if (!preLoading) {
      localEvents.dispatchEvent(preloadEndEvent);
  }
}

async function loadImageFromMp3(url, locator, name, noDisplay) {
  if (DEBUG) console.log('Downloading MP3 file as image:', url);
  try {
    if (noDisplay) startPreload(locator);
    const response = await getFile(url);
    const responseBlob = await response.response;
    const blobCheck = responseBlob.slice(0, 90);
    const partial = String.fromCharCode.apply(null, new Int8Array(await blobCheck.arrayBuffer()));
    const typeCheck = imageTypeFromData(partial);
    if (typeCheck) {
      // MP3 is actually an image  
      const imageBlob = new Blob([responseBlob], {type: typeCheck});
      if (DEBUG) console.log('Converting MP3', url, 'to', typeCheck, imageBlob.type);
      // const imageBlob = responseBlob.slice(0, responseBlob.size, {type: typeCheck});
      if (noDisplay)  preloadedImage(locator);
      setImage(imageBlob, locator, name, noDisplay);
      return;
    } else {
      console.error("Couldn't determine image type of MP3 file", url);
      failedPreload(locator);
    }
  } catch (e) {
    console.error('Faild loading image from MP3', url, locator, e);
    failedPreload(locator);
    setImageFromLocator('gallery:735c4bbe-f318-4993-809a-a71f258f3678/1104250')
  }
}

function imageTypeFromData (data) {
  let result;
  for (let t in imageTypeLookups) {
    let l = imageTypeLookups[t];
    result = (typeof l.values !== 'string' || data.substr(l.offset, l.values.length) === l.values) && 
      (!l.check || l.check(data, l.values, l.offset)) ? 
      t : 
      null;
    if (result) return result;
  }
  return result;
}

const imageTypeLookups = {
  'image/gif': {
    values: String.fromCharCode.apply(null, new Int8Array([0x47, 0x49, 0x46])),
    offset: 0
  },
  'image/webp': {
    values: 'WEBP',
    offset: 8
  },
  'image/jpeg': {
    values: String.fromCharCode.apply(null, new Int8Array([0xFF, 0xD8, 0xFF])),
    offset: 0
  },
  'image/png': {
    values: String.fromCharCode.apply(null, new Int8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])),
    offset: 0,
//     check: (data, values, offset) => {
      // todo: add proper apng/png detection
//     },
  },
}

function Uint8ToString(u8a){
  var CHUNK_SZ = 0x8000;
  var c = [];
  for (var i=0; i < u8a.length; i+=CHUNK_SZ) {
    c.push(String.fromCharCode.apply(null, u8a.subarray(i, i+CHUNK_SZ)));
  }
  return c.join("");
}

async function setImage(imageObj, locator, name, noDisplay) {
  const container = getImageContainer();
  if (!noDisplay && !container) {
    // Container hasn't been built yet.
    // Wait for it.
    requestAnimationFrame(() => setImage(imageObj, locator, name, noDisplay))
    return;
  }
  if (typeof imageObj === 'string' && base64ImageType(imageObj)) {
    imageObj = b64toBlob(imageObj);
  }
  if (imageObj instanceof Blob) {
    if (!noDisplay) container.src = URL.createObjectURL(imageObj);
    if (name) addImageToCache(name, imageObj);
  } else {
    const url = imageObj.altUrl ? imageObj.altUrl : 'https://media.milovana.com/timg/tb_xl/' + imageObj.hash + '.jpg';
    if (!noDisplay) {
      container.src = url;
    } else if (typeof locator === 'string') {
      // pre-load
      startPreload(locator);
      try {
        await getFile(url);
        preloadedImage(locator);
      } catch (e) {
        failedPreload(locator);
      }
    }
  }
  
}

function getLastTextP () {
  const tb = getLastTextBubble()
  if (!tb) {
      return
      }
  result =  tb.querySelector('p')

  console.log('getLastTextP', result);
  return result;
}


function getLastTextBubble () {
  const result = getBubbleQueue().querySelectorAll('[class^="SingleBubble_root"]');
  console.log('getLastTextBubble', result);
  if (!result) {
    return
  }
  return result[result.length-1]
}

function getBubbleQueue () {
  return getEOSContainer().querySelector('[class^="BubbleQueue_scrollView"]');
}

function getImageContainer () {
  return getEOSContainer().querySelector('[class^="Picture_picture"]');
}

function escapeRegExp(string) {
  return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
}

function getEOSContainer() {
  return document.getElementById('eosContainer');
}

function getEOSRoot() {
  return getEOSContainer()._reactRootContainer._internalRoot
}

function getEOSHost() {
  if (!host) {
      host = getEOSRoot().current.child.stateNode.props.host
  }
  return host
}

function getEOSSript() {
  return getEOSHost().script.getScript();
}

function dispatchEOSEvent(target, type) {
  if (!protos[target]) {
    console.error('Unable to dispatchEOSEvent.  No known proto:', target);
    return;
  }
  getEOSHost().virtualMachine.dispatchEvent({ target: protos[target], type });
}

// Add defined modules / properties / functions to interpereter
function addObjectToInterpreter (base, obj) {
  for (const i in obj) {
    if (base[i] !== undefined || (base.properties && base.properties[i] !== undefined)) {
      console.error(`Property \`${i}\` already exists in object.  Unable to add 3rd party module/property`, i, base, base.properties);
      continue;
    }
    const el = obj[i];
    if (typeof el === 'object') {
      const protoName = i.charAt(0).toUpperCase() + i.slice(1);
      if (base[protoName] !== undefined || (base.properties && base.properties[protoName] !== undefined)) {
        console.error(`Prototype \`${protoName}\` already exists in object.  Unable to add 3rd party module/property`, i, protoName, base, base.properties);
        continue;
      }
      // var container = interpreter.nativeToPseudo({});
      // interpreter.setProperty(base, i, container);
      const constructor = () => {
        throw new Error(`Cannot construct ${protoName} object, use \`${i}\` global`)
      };
      
      const constructorf = interpreter.createNativeFunction(constructor, true);
      interpreter.setProperty(
        constructorf,
        'prototype',
        interpreter.createObject(interpreter.globalObject.properties['EventTarget']),
        Interpreter.NONENUMERABLE_DESCRIPTOR
      )
      
      addObjectToInterpreter(constructorf, el);
      
      const protof = constructorf.properties['prototype'];
      interpreter.setProperty(interpreter.globalObject, protoName, constructorf);
      const proto = interpreter.createObjectProto(protof);
      protos[protoName] = proto;
      protos[i] = proto;
      
      interpreter.setProperty(base, i, proto);
      
      if (base === interpreter.globalObject) console.log('Loaded 3rd party module:', protoName);
      
    } else if (typeof el === 'function') {
      if (base === interpreter.globalObject) {
        interpreter.setProperty(base, i, interpreter.createNativeFunction(el));  
      } else {
        interpreter.setNativeFunctionPrototype(base, i, el);
      }
    } else {
      interpreter.setProperty(base, i, el);
    }
  }
}

function runChildAction(action) {
  if (DEBUG) console.log('Requesting child action', action, arguments);
  if (!childActions[action]) {
    console.error('No child action defined for:', action);
    return;
  }
  const values = [];
  for (var i = 1, l = arguments.length; i < l; i++) {
    values.push(arguments[i]);
  }
  const iframes = document.getElementsByClassName('eosIframe');
  const message = {
    source: name, 
    action,
    values
  };
  for (var i = 0, l = iframes.length; i < l; i++) {
    iframes[i].contentWindow.postMessage(message, '*');
  }
}

function runParentAction(action) {
  if (DEBUG) console.log('Requesting parent action', action, arguments);
  if (!parentActions[action]) {
    console.error('No parent action defined for:', action);
    return;
  }
  const values = [];
  for (i = 1, l = arguments.length; i < l; i++) {
    values.push(arguments[i]);
  }
  window.parent.postMessage({
    source: name, 
    action,
    values
  }, '*');
}

// Polyfills
(function () {
  File.prototype.arrayBuffer = File.prototype.arrayBuffer || myArrayBuffer;
  Blob.prototype.arrayBuffer = Blob.prototype.arrayBuffer || myArrayBuffer;

  function myArrayBuffer() {
    // this: File or Blob
    return new Promise((resolve) => {
      let fr = new FileReader();
      fr.onload = () => {
        resolve(fr.result);
      };
      fr.readAsArrayBuffer(this);
    })
  }
})();