Play video on hover

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

目前为 2024-12-30 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Play video on hover
  3. // @namespace https://lukaszmical.pl/
  4. // @version 0.4.1
  5. // @description Facebook, Vimeo, Youtube, Streamable, Tiktok, Instagram, Twitter, X, Dailymotion, Coub, Spotify, Tableau, SoundCloud, Apple Music, Deezer, Tidal - play on hover
  6. // @author Łukasz Micał
  7. // @match *://*/*
  8. // @icon https://static-00.iconduck.com/assets.00/cursor-hover-icon-512x439-vou7bdac.png
  9. // ==/UserScript==
  10.  
  11. // libs/share/src/ui/SvgComponent.ts
  12. const SvgComponent = class {
  13. constructor(tag, props = {}) {
  14. this.element = Dom.createSvg({ tag, ...props });
  15. }
  16.  
  17. addClassName(...className) {
  18. this.element.classList.add(...className);
  19. }
  20.  
  21. event(event, callback) {
  22. this.element.addEventListener(event, callback);
  23. }
  24.  
  25. getElement() {
  26. return this.element;
  27. }
  28.  
  29. mount(parent) {
  30. parent.appendChild(this.element);
  31. }
  32. };
  33.  
  34. // libs/share/src/ui/Dom.ts
  35. var Dom = class _Dom {
  36. static appendChildren(element, children, isSvgMode = false) {
  37. if (children) {
  38. element.append(
  39. ..._Dom.array(children).map((item) => {
  40. if (typeof item === 'string') {
  41. return document.createTextNode(item);
  42. }
  43. if (item instanceof HTMLElement || item instanceof SVGElement) {
  44. return item;
  45. }
  46. if (item instanceof Component || item instanceof SvgComponent) {
  47. return item.getElement();
  48. }
  49. const isSvg =
  50. 'svg' === item.tag
  51. ? true
  52. : 'foreignObject' === item.tag
  53. ? false
  54. : isSvgMode;
  55. if (isSvg) {
  56. return _Dom.createSvg(item);
  57. }
  58. return _Dom.create(item);
  59. })
  60. );
  61. }
  62. }
  63.  
  64. static applyAttrs(element, attrs) {
  65. if (attrs) {
  66. Object.entries(attrs).forEach(([key, value]) => {
  67. if (value === void 0 || value === false) {
  68. element.removeAttribute(key);
  69. } else {
  70. element.setAttribute(key, `${value}`);
  71. }
  72. });
  73. }
  74. }
  75.  
  76. static applyClass(element, classes) {
  77. if (classes) {
  78. element.classList.add(...classes.split(' ').filter(Boolean));
  79. }
  80. }
  81.  
  82. static applyEvents(element, events) {
  83. if (events) {
  84. Object.entries(events).forEach(([name, callback]) => {
  85. element.addEventListener(name, callback);
  86. });
  87. }
  88. }
  89.  
  90. static applyStyles(element, styles) {
  91. if (styles) {
  92. Object.entries(styles).forEach(([key, value]) => {
  93. const name = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
  94. element.style.setProperty(name, value);
  95. });
  96. }
  97. }
  98.  
  99. static array(element) {
  100. return Array.isArray(element) ? element : [element];
  101. }
  102.  
  103. static create(data) {
  104. const element = document.createElement(data.tag);
  105. _Dom.appendChildren(element, data.children);
  106. _Dom.applyClass(element, data.classes);
  107. _Dom.applyAttrs(element, data.attrs);
  108. _Dom.applyEvents(element, data.events);
  109. _Dom.applyStyles(element, data.styles);
  110. return element;
  111. }
  112.  
  113. static createSvg(data) {
  114. const element = document.createElementNS(
  115. 'http://www.w3.org/2000/svg',
  116. data.tag
  117. );
  118. _Dom.appendChildren(element, data.children, true);
  119. _Dom.applyClass(element, data.classes);
  120. _Dom.applyAttrs(element, data.attrs);
  121. _Dom.applyEvents(element, data.events);
  122. _Dom.applyStyles(element, data.styles);
  123. return element;
  124. }
  125.  
  126. static element(tag, classes, children) {
  127. return _Dom.create({ tag, children, classes });
  128. }
  129.  
  130. static elementSvg(tag, classes, children) {
  131. return _Dom.createSvg({ tag, children, classes });
  132. }
  133. };
  134.  
  135. // libs/share/src/ui/Component.ts
  136. var Component = class {
  137. constructor(tag, props = {}) {
  138. this.element = Dom.create({ tag, ...props });
  139. }
  140.  
  141. addClassName(...className) {
  142. this.element.classList.add(...className);
  143. }
  144.  
  145. event(event, callback) {
  146. this.element.addEventListener(event, callback);
  147. }
  148.  
  149. getElement() {
  150. return this.element;
  151. }
  152.  
  153. mount(parent) {
  154. parent.appendChild(this.element);
  155. }
  156. };
  157.  
  158. // apps/on-hover-preview/src/components/PreviewPopup.ts
  159. const PreviewPopup = class _PreviewPopup extends Component {
  160. constructor() {
  161. super('div', {
  162. attrs: {
  163. id: _PreviewPopup.ID,
  164. },
  165. children: {
  166. tag: 'iframe',
  167. attrs: {
  168. allow:
  169. 'autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share',
  170. allowFullscreen: true,
  171. },
  172. styles: {
  173. width: '100%',
  174. border: 'none',
  175. height: '100%',
  176. },
  177. },
  178. styles: {
  179. width: '500px',
  180. background: '#444',
  181. boxShadow: 'rgb(218, 218, 218) 1px 1px 5px',
  182. display: 'none',
  183. height: '300px',
  184. overflow: 'hidden',
  185. position: 'absolute',
  186. zIndex: '9999',
  187. },
  188. });
  189. this.iframeActive = false;
  190. this.iframe = this.element.children[0];
  191. if (!document.querySelector(`#${_PreviewPopup.ID}`)) {
  192. this.mount(document.body);
  193. document.addEventListener('click', this.hidePopup.bind(this));
  194. }
  195. }
  196.  
  197. static {
  198. this.ID = 'play-on-hover-popup';
  199. }
  200.  
  201. hidePopup() {
  202. this.iframeActive = false;
  203. this.iframe.src = '';
  204. this.element.style.display = 'none';
  205. }
  206.  
  207. showPopup(e, url, service) {
  208. if (!this.iframeActive) {
  209. this.iframe.src = url;
  210. this.iframeActive = true;
  211. Dom.applyStyles(this.element, {
  212. display: 'block',
  213. left: `${e.pageX}px`,
  214. top: `${e.pageY}px`,
  215. ...service.styles,
  216. });
  217. }
  218. }
  219. };
  220.  
  221. // apps/on-hover-preview/src/services/BaseService.ts
  222. const BaseService = class {
  223. extractId(url, match) {
  224. const result = url.match(match);
  225. if (result) {
  226. return result.groups?.id || '';
  227. }
  228. return '';
  229. }
  230.  
  231. match(url, match) {
  232. const result = url.match(match);
  233. if (result && result.groups) {
  234. return result.groups;
  235. }
  236. return void 0;
  237. }
  238.  
  239. params(params) {
  240. return Object.entries(params)
  241. .map(([key, value]) => `${key}=${value}`)
  242. .join('&');
  243. }
  244.  
  245. theme(light, dark) {
  246. return window.matchMedia('(prefers-color-scheme: dark)').matches
  247. ? dark
  248. : light;
  249. }
  250. };
  251.  
  252. // apps/on-hover-preview/src/services/Streamable.ts
  253. const Streamable = class extends BaseService {
  254. constructor() {
  255. super(...arguments);
  256. this.styles = {
  257. width: '500px',
  258. height: '300px',
  259. };
  260. }
  261.  
  262. async embeddedVideoUrl({ href }) {
  263. const id = this.extractId(href, /\.com\/([s|o]\/)?(?<id>[^?/]+).*$/);
  264. return `https://streamable.com/o/${id}?autoplay=1`;
  265. }
  266.  
  267. isValidUrl(url) {
  268. return url.includes('streamable.com');
  269. }
  270. };
  271.  
  272. // apps/on-hover-preview/src/services/Vimeo.ts
  273. const Vimeo = class extends BaseService {
  274. constructor() {
  275. super(...arguments);
  276. this.styles = {
  277. width: '500px',
  278. height: '285px',
  279. };
  280. }
  281.  
  282. async embeddedVideoUrl(element) {
  283. let id = '';
  284. if (/\/\d+(\/.*)?$/.test(element.pathname)) {
  285. id = element.pathname.replace(/\D+/g, '');
  286. } else {
  287. const response = await fetch(
  288. `https://vimeo.com/api/oembed.json?url=${element.href}`
  289. );
  290. const data = await response.json();
  291. id = data.video_id;
  292. }
  293. return `https://player.vimeo.com/video/${id}?autoplay=1`;
  294. }
  295.  
  296. isValidUrl(url) {
  297. return url.includes('vimeo.com');
  298. }
  299. };
  300.  
  301. // apps/on-hover-preview/src/services/Youtube.ts
  302. const Youtube = class extends BaseService {
  303. constructor() {
  304. super(...arguments);
  305. this.styles = {
  306. width: '500px',
  307. height: '300px',
  308. };
  309. }
  310.  
  311. async embeddedVideoUrl({ href, search }) {
  312. const urlParams = new URLSearchParams(search);
  313. let id = urlParams.get('v') || '';
  314. let start = urlParams.get('t') || '0';
  315. if (href.includes('youtu.be')) {
  316. id = this.extractId(href, /\.be\/(?<id>[^?/]+).*$/);
  317. } else if (href.includes('youtube.com/attribution_link')) {
  318. const url = decodeURIComponent(urlParams.get('u') || `/watch?v=${id}`);
  319. const attrUrl = new URL(`https://youtube.com${url}`);
  320. const attrParams = new URLSearchParams(attrUrl.search);
  321. id = attrParams.get('v') || id;
  322. start = attrParams.get('t') || start;
  323. }
  324. if (/(?:(\d+)h)?(?:(\d+)m)?(\d+)s/.test(start)) {
  325. const [hour = '0', minutes = '0', seconds = '-1'] = start.match(
  326. /(?:(\d+)h)?(?:(\d+)m)?(\d+)s/
  327. );
  328. if (seconds !== '-1') {
  329. start = `${(Number(hour) * 60 + Number(minutes)) * 60 + seconds}`;
  330. }
  331. }
  332. const params = this.params({
  333. autoplay: 1,
  334. enablejsapi: 1,
  335. fs: 1,
  336. start,
  337. });
  338. return `https://www.youtube.com/embed/${id}?${params}`;
  339. }
  340.  
  341. isValidUrl(url) {
  342. return (
  343. url.includes('youtube.com/attribution_link') ||
  344. url.includes('youtube.com/watch') ||
  345. url.includes('youtu.be/')
  346. );
  347. }
  348. };
  349.  
  350. // apps/on-hover-preview/src/services/Facebook.ts
  351. const Facebook = class extends BaseService {
  352. constructor() {
  353. super(...arguments);
  354. this.styles = {
  355. width: '500px',
  356. height: '282px',
  357. };
  358. }
  359.  
  360. async embeddedVideoUrl(element) {
  361. const params = this.params({
  362. width: '500',
  363. autoplay: 'true',
  364. href: element.href,
  365. show_text: 'false',
  366. });
  367. return `https://www.facebook.com/plugins/video.php?${params}`;
  368. }
  369.  
  370. isValidUrl(url) {
  371. return /https:\/\/(www\.|m\.)?facebook\.com\/[\w\d\-_]+\/videos\//.test(
  372. url
  373. );
  374. }
  375. };
  376.  
  377. // apps/on-hover-preview/src/services/Tiktok.ts
  378. const Tiktok = class extends BaseService {
  379. constructor() {
  380. super(...arguments);
  381. this.styles = {
  382. width: '338px',
  383. height: '575px',
  384. };
  385. }
  386.  
  387. async embeddedVideoUrl({ href }) {
  388. const id = this.extractId(href, /video\/(?<id>\d+)/);
  389. return `https://www.tiktok.com/embed/v2/${id}`;
  390. }
  391.  
  392. isValidUrl(url) {
  393. return url.includes('tiktok.com') && /video\/\d+/.test(url);
  394. }
  395. };
  396.  
  397. // apps/on-hover-preview/src/services/Instagram.ts
  398. const Instagram = class extends BaseService {
  399. constructor() {
  400. super(...arguments);
  401. this.styles = {
  402. width: '300px',
  403. height: '500px',
  404. };
  405. }
  406.  
  407. async embeddedVideoUrl({ href }) {
  408. const id = this.extractId(href, /reel\/(?<id>[^/]+)\//);
  409. return `https://www.instagram.com/p/${id}/embed/`;
  410. }
  411.  
  412. isValidUrl(url) {
  413. return /instagram\.com\/([a-zA-Z0-9._]{1,30}\/)?reel/.test(url);
  414. }
  415. };
  416.  
  417. // apps/on-hover-preview/src/services/Twitter.ts
  418. const Twitter = class extends BaseService {
  419. constructor() {
  420. super(...arguments);
  421. this.styles = {
  422. width: '480px',
  423. height: '300px',
  424. };
  425. }
  426.  
  427. async embeddedVideoUrl({ href }) {
  428. const id = this.extractId(href, /status\/(?<id>[^/?]+)[\/?]?/);
  429. const platform = href.includes('twitter.com') ? 'twitter' : 'x';
  430. const params = this.params({
  431. id,
  432. maxWidth: '480',
  433. });
  434. return `https://platform.${platform}.com/embed/Tweet.html?${params}`;
  435. }
  436.  
  437. isValidUrl(url) {
  438. return /https:\/\/(twitter|x)\.com\/.+\/status\/\d+/.test(url);
  439. }
  440. };
  441.  
  442. // libs/share/src/ui/Events.ts
  443. const Events = class {
  444. static intendHover(validate, mouseover, mouseleave, timeout = 500) {
  445. let hover = false;
  446. let id = 0;
  447. const onHover = (event) => {
  448. if (!event.target || !validate(event.target)) {
  449. return;
  450. }
  451. const element = event.target;
  452. hover = true;
  453. element.addEventListener(
  454. 'mouseleave',
  455. (ev) => {
  456. mouseleave.call(element, ev);
  457. clearTimeout(id);
  458. hover = false;
  459. },
  460. { once: true }
  461. );
  462. clearTimeout(id);
  463. id = window.setTimeout(() => {
  464. if (hover) {
  465. mouseover.call(element, event);
  466. }
  467. }, timeout);
  468. };
  469. document.body.addEventListener('mouseover', onHover);
  470. }
  471. };
  472.  
  473. // apps/on-hover-preview/src/helpers/LinkHover.ts
  474. const LinkHover = class {
  475. constructor(services2, onHover) {
  476. this.services = services2;
  477. this.onHover = onHover;
  478. Events.intendHover(
  479. this.isValidLink.bind(this),
  480. this.onAnchorHover.bind(this),
  481. () => {}
  482. );
  483. }
  484.  
  485. anchorElement(node) {
  486. if (!(node instanceof HTMLElement)) {
  487. return void 0;
  488. }
  489. if (node instanceof HTMLAnchorElement) {
  490. return node;
  491. }
  492. const parent = node.closest('a');
  493. if (parent instanceof HTMLElement) {
  494. return parent;
  495. }
  496. return void 0;
  497. }
  498.  
  499. isValidLink(node) {
  500. const anchor = this.anchorElement(node);
  501. if (!anchor || !anchor.href || anchor.href === '#') {
  502. return false;
  503. }
  504. const origin = (url) => {
  505. return new URL(url).host.split('.').slice(-2).join('.');
  506. };
  507. return origin(anchor.href) !== origin(location.href);
  508. }
  509.  
  510. async onAnchorHover(ev) {
  511. const anchor = this.anchorElement(ev.target);
  512. if (!anchor) {
  513. return;
  514. }
  515. const service = this.findService(anchor.href);
  516. if (!service) {
  517. return;
  518. }
  519. const previewUrl = await service.embeddedVideoUrl(anchor);
  520. if (!previewUrl) {
  521. return;
  522. }
  523. this.onHover(ev, previewUrl, service);
  524. }
  525.  
  526. findService(url = '') {
  527. return this.services.find((service) => service.isValidUrl(url));
  528. }
  529. };
  530.  
  531. // apps/on-hover-preview/src/services/Dailymotion.ts
  532. const Dailymotion = class extends BaseService {
  533. constructor() {
  534. super(...arguments);
  535. this.styles = {
  536. width: '500px',
  537. height: '280px',
  538. };
  539. }
  540.  
  541. async embeddedVideoUrl(element) {
  542. const id = this.extractId(element.href, /video\/(?<id>[^/?]+)[\/?]?/);
  543. return `https://geo.dailymotion.com/player.html?video=${id}`;
  544. }
  545.  
  546. isValidUrl(url) {
  547. return url.includes('dailymotion.com/video');
  548. }
  549. };
  550.  
  551. // apps/on-hover-preview/src/services/Coub.ts
  552. const Coub = class extends BaseService {
  553. constructor() {
  554. super(...arguments);
  555. this.styles = {
  556. width: '500px',
  557. height: '290px',
  558. };
  559. }
  560.  
  561. async embeddedVideoUrl({ href }) {
  562. const id = this.extractId(href, /view\/(?<id>[^/]+)\/?/);
  563. const params = this.params({
  564. muted: 'false',
  565. autostart: 'true',
  566. originalSize: 'false',
  567. startWithHD: 'true',
  568. });
  569. return `https://coub.com/embed/${id}?${params}`;
  570. }
  571.  
  572. isValidUrl(url) {
  573. return url.includes('coub.com/view');
  574. }
  575. };
  576.  
  577. // apps/on-hover-preview/src/services/Spotify.ts
  578. const Spotify = class extends BaseService {
  579. constructor() {
  580. super(...arguments);
  581. this.styles = {
  582. width: '600px',
  583. borderRadius: '12px',
  584. height: '152px',
  585. };
  586. this.regExp =
  587. /spotify\.com\/(.+\/)?(?<type>track|album|playlist|show)\/(?<id>[\w-]+)/;
  588. }
  589.  
  590. async embeddedVideoUrl({ href }) {
  591. const props = this.match(href, this.regExp);
  592. if (!props) {
  593. return void 0;
  594. }
  595. this.setStyle(props.type);
  596. const suffix = props.type === 'show' ? '/video' : '';
  597. return `https://open.spotify.com/embed/${props.type}/${props.id}${suffix}`;
  598. }
  599.  
  600. isValidUrl(url) {
  601. return this.regExp.test(url);
  602. }
  603.  
  604. setStyle(type) {
  605. if (type === 'track') {
  606. this.styles.height = '152px';
  607. } else if (type === 'album') {
  608. this.styles.height = '352px';
  609. } else if (type === 'playlist') {
  610. this.styles.height = '352px';
  611. } else if (type === 'show') {
  612. this.styles.height = '352px';
  613. } else {
  614. this.styles.height = '300px';
  615. }
  616. }
  617. };
  618.  
  619. // apps/on-hover-preview/src/services/Tableau.ts
  620. const Tableau = class extends BaseService {
  621. constructor() {
  622. super(...arguments);
  623. this.styles = {
  624. width: '850px',
  625. height: '528px',
  626. };
  627. }
  628.  
  629. async embeddedVideoUrl({ href }) {
  630. const id = this.extractId(href, /views\/(?<id>[^/]+)\/?/);
  631. const params = this.params({
  632. ':animate_transition': 'yes',
  633. ':display_count': 'yes',
  634. ':display_overlay': 'yes',
  635. ':display_spinner': 'yes',
  636. ':display_static_image': 'no',
  637. ':embed': 'y',
  638. ':embed_code_version': '3',
  639. ':host_url': 'https%3A%2F%2Fpublic.tableau.com%2F',
  640. ':language': 'en-US',
  641. ':loadOrderID': '0',
  642. ':showVizHome': 'no',
  643. ':tabs': 'yes',
  644. ':toolbar': 'yes',
  645. });
  646. return `https://public.tableau.com/views/${id}/Video?${params}`;
  647. }
  648.  
  649. isValidUrl(url) {
  650. return url.includes('public.tableau.com/views');
  651. }
  652. };
  653.  
  654. // apps/on-hover-preview/src/services/SoundCloud.ts
  655. const SoundCloud = class extends BaseService {
  656. constructor() {
  657. super(...arguments);
  658. this.styles = {
  659. width: '600px',
  660. height: '166px',
  661. };
  662. }
  663.  
  664. async embeddedVideoUrl({ href }) {
  665. const params = this.params({
  666. hide_related: 'true',
  667. auto_play: 'true',
  668. show_artwork: 'true',
  669. show_comments: 'false',
  670. show_teaser: 'false',
  671. url: encodeURIComponent(href),
  672. visual: 'false',
  673. });
  674. return `https://w.soundcloud.com/player?${params}`;
  675. }
  676.  
  677. isValidUrl(url) {
  678. return /soundcloud\.com\/[^/]+\/[^/?]+/.test(url);
  679. }
  680. };
  681.  
  682. // apps/on-hover-preview/src/services/AppleMusic.ts
  683. const AppleMusic = class extends BaseService {
  684. constructor() {
  685. super(...arguments);
  686. this.styles = {
  687. width: '500px',
  688. borderRadius: '12px',
  689. height: '450px',
  690. };
  691. this.regExp = /music\.apple\.com\/.{2}\/(?<id>music-video|artist|album)/;
  692. }
  693.  
  694. async embeddedVideoUrl({ href, pathname }) {
  695. this.setStyle(href);
  696. return `https://embed.music.apple.com${pathname}`;
  697. }
  698.  
  699. isValidUrl(url) {
  700. return this.regExp.test(url);
  701. }
  702.  
  703. setStyle(href) {
  704. const type = this.extractId(href, this.regExp);
  705. if (type === 'music-video') {
  706. this.styles.height = '281px';
  707. } else {
  708. this.styles.height = '450px';
  709. }
  710. }
  711. };
  712.  
  713. // apps/on-hover-preview/src/services/Deezer.ts
  714. const Deezer = class extends BaseService {
  715. constructor() {
  716. super(...arguments);
  717. this.styles = {
  718. width: '500px',
  719. borderRadius: '10px',
  720. height: '300px',
  721. };
  722. this.regExp =
  723. /deezer\.com\/.{2}\/(?<type>album|playlist|track|artist|podcast|episode)\/(?<id>\d+)/;
  724. }
  725.  
  726. async embeddedVideoUrl({ href }) {
  727. const theme = this.theme('light', 'dark');
  728. const props = this.match(href, this.regExp);
  729. const params = this.params({
  730. autoplay: 'true',
  731. radius: 'true',
  732. tracklist: 'false',
  733. });
  734. if (!props) {
  735. return void 0;
  736. }
  737. return `https://widget.deezer.com/widget/${theme}/${props.type}/${props.id}?${params}`;
  738. }
  739.  
  740. isValidUrl(url) {
  741. return this.regExp.test(url);
  742. }
  743. };
  744.  
  745. // apps/on-hover-preview/src/services/Tidal.ts
  746. const Tidal = class extends BaseService {
  747. constructor() {
  748. super(...arguments);
  749. this.styles = {
  750. width: '500px',
  751. height: '300px',
  752. borderRadius: '10px',
  753. };
  754. this.regExp =
  755. /tidal\.com\/(.+\/)?(?<type>track|album|video|playlist)\/(?<id>\d+|[\w-]+)/;
  756. }
  757.  
  758. async embeddedVideoUrl({ href }) {
  759. const props = this.match(href, this.regExp);
  760. if (!props) {
  761. return void 0;
  762. }
  763. this.setStyle(props.type);
  764. return `https://embed.tidal.com/${props.type}s/${props.id}`;
  765. }
  766.  
  767. isValidUrl(url) {
  768. return this.regExp.test(url);
  769. }
  770.  
  771. setStyle(type) {
  772. if (type === 'track') {
  773. this.styles.height = '120px';
  774. } else if (type === 'playlist') {
  775. this.styles.height = '400px';
  776. } else if (type === 'video') {
  777. this.styles.height = '281px';
  778. } else {
  779. this.styles.height = '300px';
  780. }
  781. }
  782. };
  783.  
  784. // apps/on-hover-preview/src/main.ts
  785. const services = [
  786. Youtube,
  787. Vimeo,
  788. Streamable,
  789. Facebook,
  790. Tiktok,
  791. Instagram,
  792. Twitter,
  793. Dailymotion,
  794. Dailymotion,
  795. Coub,
  796. Spotify,
  797. Tableau,
  798. SoundCloud,
  799. AppleMusic,
  800. Deezer,
  801. Tidal,
  802. // Odysee,
  803. // Rumble,
  804. ].map((Service) => new Service());
  805. const previewPopup = new PreviewPopup();
  806. new LinkHover(services, previewPopup.showPopup.bind(previewPopup));