Play video on hover

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

目前為 2025-03-14 提交的版本,檢視 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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',
      borderRadius: '10px',
      height: '300px',
    };
    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 _Youtube extends BaseService {
  constructor() {
    super(...arguments);
    this.styles = _Youtube.VideoSize;
  }

  static {
    this.ShortSize = {
      width: '256px',
      height: '454px',
    };
  }
  static {
    this.VideoSize = {
      width: '500px',
      height: '300px',
    };
  }

  async embeddedVideoUrl({ href, search }) {
    this.styles = _Youtube.VideoSize;
    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/shorts')) {
      this.styles = _Youtube.ShortSize;
      id = this.extractId(href, /youtube\.com\/shorts\/(?<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('youtube.com/shorts') ||
      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();
}