Greasy Fork is available in English.

Play video on hover

Facebook, Vimeo, Youtube, Streamable, Tiktok, Instagram, Twitter, X, Dailymotion, Coub, Spotify, Tableau, SoundCloud, Apple Music, Deezer, Tidal - play on hover

// ==UserScript==
// @name         Play video on hover
// @namespace    https://lukaszmical.pl/
// @version      0.5.0
// @description  Facebook, Vimeo, Youtube, Streamable, Tiktok, Instagram, Twitter, X, Dailymotion, Coub, Spotify, Tableau, SoundCloud, Apple Music, Deezer, Tidal - play on hover
// @author       Łukasz Micał
// @match        *://*/*
// @icon         https://static-00.iconduck.com/assets.00/cursor-hover-icon-512x439-vou7bdac.png
// ==/UserScript==

// libs/share/src/ui/SvgComponent.ts
const SvgComponent = class {
  constructor(tag, props = {}) {
    this.element = Dom.createSvg({ tag, ...props });
  }

  addClassName(...className) {
    this.element.classList.add(...className);
  }

  event(event, callback) {
    this.element.addEventListener(event, callback);
  }

  getElement() {
    return this.element;
  }

  mount(parent) {
    parent.appendChild(this.element);
  }
};

// libs/share/src/ui/Dom.ts
var Dom = class _Dom {
  static appendChildren(element, children, isSvgMode = false) {
    if (children) {
      element.append(
        ..._Dom.array(children).map((item) => {
          if (typeof item === 'string') {
            return document.createTextNode(item);
          }
          if (item instanceof HTMLElement || item instanceof SVGElement) {
            return item;
          }
          if (item instanceof Component || item instanceof SvgComponent) {
            return item.getElement();
          }
          const isSvg =
            'svg' === item.tag
              ? true
              : 'foreignObject' === item.tag
              ? false
              : isSvgMode;
          if (isSvg) {
            return _Dom.createSvg(item);
          }
          return _Dom.create(item);
        })
      );
    }
  }

  static applyAttrs(element, attrs) {
    if (attrs) {
      Object.entries(attrs).forEach(([key, value]) => {
        if (value === void 0 || value === false) {
          element.removeAttribute(key);
        } else {
          element.setAttribute(key, `${value}`);
        }
      });
    }
  }

  static applyClass(element, classes) {
    if (classes) {
      element.classList.add(...classes.split(' ').filter(Boolean));
    }
  }

  static applyEvents(element, events) {
    if (events) {
      Object.entries(events).forEach(([name, callback]) => {
        element.addEventListener(name, callback);
      });
    }
  }

  static applyStyles(element, styles) {
    if (styles) {
      Object.entries(styles).forEach(([key, value]) => {
        const name = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
        element.style.setProperty(name, value);
      });
    }
  }

  static array(element) {
    return Array.isArray(element) ? element : [element];
  }

  static create(data) {
    const element = document.createElement(data.tag);
    _Dom.appendChildren(element, data.children);
    _Dom.applyClass(element, data.classes);
    _Dom.applyAttrs(element, data.attrs);
    _Dom.applyEvents(element, data.events);
    _Dom.applyStyles(element, data.styles);
    return element;
  }

  static createSvg(data) {
    const element = document.createElementNS(
      'http://www.w3.org/2000/svg',
      data.tag
    );
    _Dom.appendChildren(element, data.children, true);
    _Dom.applyClass(element, data.classes);
    _Dom.applyAttrs(element, data.attrs);
    _Dom.applyEvents(element, data.events);
    _Dom.applyStyles(element, data.styles);
    return element;
  }

  static element(tag, classes, children) {
    return _Dom.create({ tag, children, classes });
  }

  static elementSvg(tag, classes, children) {
    return _Dom.createSvg({ tag, children, classes });
  }
};

// libs/share/src/ui/Component.ts
var Component = class {
  constructor(tag, props = {}) {
    this.element = Dom.create({ tag, ...props });
  }

  addClassName(...className) {
    this.element.classList.add(...className);
  }

  event(event, callback) {
    this.element.addEventListener(event, callback);
  }

  getElement() {
    return this.element;
  }

  mount(parent) {
    parent.appendChild(this.element);
  }
};

// apps/on-hover-preview/src/components/PreviewPopup.ts
const PreviewPopup = class _PreviewPopup extends Component {
  constructor() {
    super('div', {
      attrs: {
        id: _PreviewPopup.ID,
      },
      children: {
        tag: 'iframe',
        attrs: {
          allow:
            'autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share',
          allowFullscreen: true,
        },
        styles: {
          width: '100%',
          border: 'none',
          height: '100%',
        },
      },
      styles: {
        width: '500px',
        background: '#444',
        boxShadow: 'rgb(218, 218, 218) 1px 1px 5px',
        display: 'none',
        height: '300px',
        overflow: 'hidden',
        position: 'absolute',
        zIndex: '9999',
      },
    });
    this.iframeActive = false;
    this.iframe = this.element.children[0];
    if (!document.querySelector(`#${_PreviewPopup.ID}`)) {
      this.mount(document.body);
      document.addEventListener('click', this.hidePopup.bind(this));
    }
  }

  static {
    this.ID = 'play-on-hover-popup';
  }

  hidePopup() {
    this.iframeActive = false;
    this.iframe.src = '';
    this.element.style.display = 'none';
  }

  showPopup(e, url, service) {
    if (!this.iframeActive) {
      this.iframe.src = url;
      this.iframeActive = true;
      Dom.applyStyles(this.element, {
        display: 'block',
        left: `${e.pageX}px`,
        top: `${e.pageY}px`,
        ...service.styles,
      });
    }
  }
};

// libs/share/src/ui/Events.ts
const Events = class {
  static intendHover(validate, mouseover, mouseleave, timeout = 500) {
    let hover = false;
    let id = 0;
    const onHover = (event) => {
      if (!event.target || !validate(event.target)) {
        return;
      }
      const element = event.target;
      hover = true;
      element.addEventListener(
        'mouseleave',
        (ev) => {
          mouseleave.call(element, ev);
          clearTimeout(id);
          hover = false;
        },
        { once: true }
      );
      clearTimeout(id);
      id = window.setTimeout(() => {
        if (hover) {
          mouseover.call(element, event);
        }
      }, timeout);
    };
    document.body.addEventListener('mouseover', onHover);
  }
};

// apps/on-hover-preview/src/helpers/LinkHover.ts
const LinkHover = class {
  constructor(services, onHover) {
    this.services = services;
    this.onHover = onHover;
    Events.intendHover(
      this.isValidLink.bind(this),
      this.onAnchorHover.bind(this),
      () => {}
    );
  }

  anchorElement(node) {
    if (!(node instanceof HTMLElement)) {
      return void 0;
    }
    if (node instanceof HTMLAnchorElement) {
      return node;
    }
    const parent = node.closest('a');
    if (parent instanceof HTMLElement) {
      return parent;
    }
    return void 0;
  }

  findService(url = '') {
    return this.services.find((service) => service.isValidUrl(url));
  }

  isValidLink(node) {
    const anchor = this.anchorElement(node);
    if (!anchor || !anchor.href || anchor.href === '#') {
      return false;
    }
    return true;
  }

  async onAnchorHover(ev) {
    const anchor = this.anchorElement(ev.target);
    if (!anchor) {
      return;
    }
    const service = this.findService(anchor.href);
    if (!service) {
      return;
    }
    const previewUrl = await service.embeddedVideoUrl(anchor);
    if (!previewUrl) {
      return;
    }
    this.onHover(ev, previewUrl, service);
  }
};

// apps/on-hover-preview/src/services/BaseService.ts
const BaseService = class {
  extractId(url, match) {
    const result = url.match(match);
    if (result) {
      return result.groups?.id || '';
    }
    return '';
  }

  match(url, match) {
    const result = url.match(match);
    if (result && result.groups) {
      return result.groups;
    }
    return void 0;
  }

  params(params) {
    return Object.entries(params)
      .map(([key, value]) => `${key}=${value}`)
      .join('&');
  }

  theme(light, dark) {
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? dark
      : light;
  }
};

// apps/on-hover-preview/src/services/AppleMusic.ts
const AppleMusic = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '500px',
      borderRadius: '12px',
      height: '450px',
    };
    this.regExp = /music\.apple\.com\/.{2}\/(?<id>music-video|artist|album)/;
  }

  async embeddedVideoUrl({ href, pathname }) {
    this.setStyle(href);
    return `https://embed.music.apple.com${pathname}`;
  }

  isValidUrl(url) {
    return this.regExp.test(url);
  }

  setStyle(href) {
    const type = this.extractId(href, this.regExp);
    if (type === 'music-video') {
      this.styles.height = '281px';
    } else {
      this.styles.height = '450px';
    }
  }
};

// apps/on-hover-preview/src/services/Coub.ts
const Coub = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '500px',
      height: '290px',
    };
  }

  async embeddedVideoUrl({ href }) {
    const id = this.extractId(href, /view\/(?<id>[^/]+)\/?/);
    const params = this.params({
      autostart: 'true',
      muted: 'false',
      originalSize: 'false',
      startWithHD: 'true',
    });
    return `https://coub.com/embed/${id}?${params}`;
  }

  isValidUrl(url) {
    return url.includes('coub.com/view');
  }
};

// apps/on-hover-preview/src/services/Dailymotion.ts
const Dailymotion = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '500px',
      height: '280px',
    };
  }

  async embeddedVideoUrl(element) {
    const id = this.extractId(element.href, /video\/(?<id>[^/?]+)[/?]?/);
    return `https://geo.dailymotion.com/player.html?video=${id}`;
  }

  isValidUrl(url) {
    return url.includes('dailymotion.com/video');
  }
};

// apps/on-hover-preview/src/services/Deezer.ts
const Deezer = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '500px',
      borderRadius: '10px',
      height: '300px',
    };
    this.regExp =
      /deezer\.com\/.{2}\/(?<type>album|playlist|track|artist|podcast|episode)\/(?<id>\d+)/;
  }

  async embeddedVideoUrl({ href }) {
    const theme = this.theme('light', 'dark');
    const props = this.match(href, this.regExp);
    const params = this.params({
      autoplay: 'true',
      radius: 'true',
      tracklist: 'false',
    });
    if (!props) {
      return void 0;
    }
    return `https://widget.deezer.com/widget/${theme}/${props.type}/${props.id}?${params}`;
  }

  isValidUrl(url) {
    return this.regExp.test(url);
  }
};

// apps/on-hover-preview/src/services/Facebook.ts
const Facebook = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '500px',
      height: '282px',
    };
  }

  async embeddedVideoUrl(element) {
    const params = this.params({
      width: '500',
      autoplay: 'true',
      href: element.href,
      show_text: 'false',
    });
    return `https://www.facebook.com/plugins/video.php?${params}`;
  }

  isValidUrl(url) {
    return /https:\/\/(www\.|m\.)?facebook\.com\/[\w\-_]+\/videos\//.test(url);
  }
};

// apps/on-hover-preview/src/services/Instagram.ts
const Instagram = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '300px',
      height: '500px',
    };
  }

  async embeddedVideoUrl({ href }) {
    const id = this.extractId(href, /reel\/(?<id>[^/]+)\//);
    return `https://www.instagram.com/p/${id}/embed/`;
  }

  isValidUrl(url) {
    return /instagram\.com\/([a-zA-Z0-9._]{1,30}\/)?reel/.test(url);
  }
};

// apps/on-hover-preview/src/services/SoundCloud.ts
const SoundCloud = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '600px',
      height: '166px',
    };
  }

  async embeddedVideoUrl({ href }) {
    const params = this.params({
      hide_related: 'true',
      auto_play: 'true',
      show_artwork: 'true',
      show_comments: 'false',
      show_teaser: 'false',
      url: encodeURIComponent(href),
      visual: 'false',
    });
    return `https://w.soundcloud.com/player?${params}`;
  }

  isValidUrl(url) {
    return /soundcloud\.com\/[^/]+\/[^/?]+/.test(url);
  }
};

// apps/on-hover-preview/src/services/Spotify.ts
const Spotify = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '600px',
      borderRadius: '12px',
      height: '152px',
    };
    this.regExp =
      /spotify\.com\/(.+\/)?(?<type>track|album|playlist|show)\/(?<id>[\w-]+)/;
  }

  async embeddedVideoUrl({ href }) {
    const props = this.match(href, this.regExp);
    if (!props) {
      return void 0;
    }
    this.setStyle(props.type);
    const suffix = props.type === 'show' ? '/video' : '';
    return `https://open.spotify.com/embed/${props.type}/${props.id}${suffix}`;
  }

  isValidUrl(url) {
    return this.regExp.test(url);
  }

  setStyle(type) {
    if (type === 'track') {
      this.styles.height = '152px';
    } else if (type === 'album') {
      this.styles.height = '352px';
    } else if (type === 'playlist') {
      this.styles.height = '352px';
    } else if (type === 'show') {
      this.styles.height = '352px';
    } else {
      this.styles.height = '300px';
    }
  }
};

// apps/on-hover-preview/src/services/Streamable.ts
const Streamable = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '500px',
      height: '300px',
    };
  }

  async embeddedVideoUrl({ href }) {
    const id = this.extractId(href, /\.com\/([s|o]\/)?(?<id>[^?/]+).*$/);
    return `https://streamable.com/o/${id}?autoplay=1`;
  }

  isValidUrl(url) {
    return url.includes('streamable.com');
  }
};

// apps/on-hover-preview/src/services/Tableau.ts
const Tableau = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '850px',
      height: '528px',
    };
  }

  async embeddedVideoUrl({ href }) {
    const id = this.extractId(href, /views\/(?<id>[^/]+)\/?/);
    const params = this.params({
      ':animate_transition': 'yes',
      ':display_count': 'yes',
      ':display_overlay': 'yes',
      ':display_spinner': 'yes',
      ':display_static_image': 'no',
      ':embed': 'y',
      ':embed_code_version': '3',
      ':host_url': 'https%3A%2F%2Fpublic.tableau.com%2F',
      ':language': 'en-US',
      ':loadOrderID': '0',
      ':showVizHome': 'no',
      ':tabs': 'yes',
      ':toolbar': 'yes',
    });
    return `https://public.tableau.com/views/${id}/Video?${params}`;
  }

  isValidUrl(url) {
    return url.includes('public.tableau.com/views');
  }
};

// apps/on-hover-preview/src/services/Tidal.ts
const Tidal = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '500px',
      height: '300px',
      borderRadius: '10px',
    };
    this.regExp =
      /tidal\.com\/(.+\/)?(?<type>track|album|video|playlist)\/(?<id>\d+|[\w-]+)/;
  }

  async embeddedVideoUrl({ href }) {
    const props = this.match(href, this.regExp);
    if (!props) {
      return void 0;
    }
    this.setStyle(props.type);
    return `https://embed.tidal.com/${props.type}s/${props.id}`;
  }

  isValidUrl(url) {
    return this.regExp.test(url);
  }

  setStyle(type) {
    if (type === 'track') {
      this.styles.height = '120px';
    } else if (type === 'playlist') {
      this.styles.height = '400px';
    } else if (type === 'video') {
      this.styles.height = '281px';
    } else {
      this.styles.height = '300px';
    }
  }
};

// apps/on-hover-preview/src/services/Tiktok.ts
const Tiktok = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '338px',
      height: '575px',
    };
  }

  async embeddedVideoUrl({ href }) {
    const id = this.extractId(href, /video\/(?<id>\d+)/);
    return `https://www.tiktok.com/embed/v2/${id}`;
  }

  isValidUrl(url) {
    return url.includes('tiktok.com') && /video\/\d+/.test(url);
  }
};

// apps/on-hover-preview/src/services/Twitter.ts
const Twitter = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '480px',
      height: '300px',
    };
  }

  async embeddedVideoUrl({ href }) {
    const id = this.extractId(href, /status\/(?<id>[^/?]+)[\/?]?/);
    const platform = href.includes('twitter.com') ? 'twitter' : 'x';
    const params = this.params({
      id,
      maxWidth: '480',
    });
    return `https://platform.${platform}.com/embed/Tweet.html?${params}`;
  }

  isValidUrl(url) {
    return /https:\/\/(twitter|x)\.com\/.+\/status\/\d+/.test(url);
  }
};

// apps/on-hover-preview/src/services/Vimeo.ts
const Vimeo = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '500px',
      height: '285px',
    };
  }

  async embeddedVideoUrl(element) {
    let id = '';
    if (/\/\d+(\/.*)?$/.test(element.pathname)) {
      id = element.pathname.replace(/\D+/g, '');
    } else {
      const response = await fetch(
        `https://vimeo.com/api/oembed.json?url=${element.href}`
      );
      const data = await response.json();
      id = data.video_id;
    }
    return `https://player.vimeo.com/video/${id}?autoplay=1`;
  }

  isValidUrl(url) {
    return url.includes('vimeo.com');
  }
};

// apps/on-hover-preview/src/services/Youtube.ts
const Youtube = class extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = {
      width: '500px',
      height: '300px',
    };
  }

  async embeddedVideoUrl({ href, search }) {
    const urlParams = new URLSearchParams(search);
    let id = urlParams.get('v') || '';
    let start = urlParams.get('t') || '0';
    if (href.includes('youtu.be')) {
      id = this.extractId(href, /\.be\/(?<id>[^?/]+).*$/);
    } else if (href.includes('youtube.com/attribution_link')) {
      const url = decodeURIComponent(urlParams.get('u') || `/watch?v=${id}`);
      const attrUrl = new URL(`https://youtube.com${url}`);
      const attrParams = new URLSearchParams(attrUrl.search);
      id = attrParams.get('v') || id;
      start = attrParams.get('t') || start;
    }
    if (/(?:(\d+)h)?(?:(\d+)m)?(\d+)s/.test(start)) {
      const [hour = '0', minutes = '0', seconds = '-1'] = start.match(
        /(?:(\d+)h)?(?:(\d+)m)?(\d+)s/
      );
      if (seconds !== '-1') {
        start = `${(Number(hour) * 60 + Number(minutes)) * 60 + seconds}`;
      }
    }
    const params = this.params({
      autoplay: 1,
      enablejsapi: 1,
      fs: 1,
      start,
    });
    return `https://www.youtube.com/embed/${id}?${params}`;
  }

  isValidUrl(url) {
    return (
      url.includes('youtube.com/attribution_link') ||
      url.includes('youtube.com/watch') ||
      url.includes('youtu.be/')
    );
  }
};

// apps/on-hover-preview/src/main.ts
function run() {
  const services = [
    Youtube,
    Vimeo,
    Streamable,
    Facebook,
    Tiktok,
    Instagram,
    Twitter,
    Dailymotion,
    Dailymotion,
    Coub,
    Spotify,
    Tableau,
    SoundCloud,
    AppleMusic,
    Deezer,
    Tidal,
    // Odysee,
    // Rumble,
  ].map((Service) => new Service());
  const previewPopup = new PreviewPopup();
  new LinkHover(services, previewPopup.showPopup.bind(previewPopup));
}

if (window.top == window.self) {
  run();
}