Загрузка данных


// ==UserScript==
// @name         VK и VK Видео — кнопка скачивания
// @name:en      VK and VK Video — download button
// @namespace    local.vk.dl
// @version      2.0
// @description  Добавляет кнопку скачивания к плеерам VK и VK Видео, вызывая штатный обработчик плеера
// @description:en Adds a download button to VK and VK Video players using the player's own download handler
// @author       azag_net
// @match        *://vk.com/*
// @match        *://m.vk.com/*
// @match        *://vkvideo.ru/*
// @match        *://*.vkvideo.ru/*
// @match        *://m.vkvideo.ru/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ------------------------------------------------------------
  // Настройки
  // ------------------------------------------------------------

  const TAGS = [
    'vk-video-player',
    'vk-video',
    'video-player'
  ];

  const C = {
    bg: 'rgba(20,24,33,.92)',
    text: '#e6ebf2',
    acc: '#ffcc00',
    line: 'rgba(255,255,255,.22)'
  };

  // null = определить автоматически.
  // Для принудительного языка можно поставить 'ru' или 'en'.
  const FORCE_LANG = null;

  const STR = {
    ru: {
      download: '↓ скачать',
      working: 'скачивается',
      denied: 'нет доступа',
      error: 'ошибка',
      title: 'Скачать видео (Alt+D)',
      loaded: 'загружен, плееров:'
    },

    en: {
      download: '↓ download',
      working: 'downloading',
      denied: 'unavailable',
      error: 'error',
      title: 'Download video (Alt+D)',
      loaded: 'loaded, players:'
    }
  };

  const LANG = (() => {
    if (FORCE_LANG && STR[FORCE_LANG]) {
      return FORCE_LANG;
    }

    const langs = navigator.languages?.length
      ? navigator.languages
      : [navigator.language || 'en'];

    const ru = /^(ru|be|uk|kk|ky|uz|tg|az|hy|mo)\b/i;

    return langs.some(l => ru.test(l)) ? 'ru' : 'en';
  })();

  const t = key => STR[LANG][key] || STR.en[key] || key;

  // ------------------------------------------------------------
  // Вспомогательные функции
  // ------------------------------------------------------------

  function mk(tag, style, text) {
    const el = document.createElement(tag);

    if (style) {
      Object.assign(el.style, style);
    }

    if (text != null) {
      el.textContent = text;
    }

    return el;
  }

  /*
   * VK может помещать плеер внутрь Shadow DOM.
   * Обычный document.querySelectorAll() такие элементы
   * не всегда видит.
   */
  function deepAll(selector, root, out, depth) {
    root = root || document;
    out = out || [];
    depth = depth || 0;

    try {
      root.querySelectorAll(selector).forEach(el => out.push(el));
    } catch (e) {}

    if (depth >= 8) {
      return out;
    }

    let all = [];

    try {
      all = root.querySelectorAll('*');
    } catch (e) {
      return out;
    }

    for (const el of all) {
      try {
        if (el.shadowRoot) {
          deepAll(selector, el.shadowRoot, out, depth + 1);
        }
      } catch (e) {}
    }

    return out;
  }

  // ------------------------------------------------------------
  // Поиск штатного обработчика VK
  // ------------------------------------------------------------

  /*
   * Основной вариант, который использует текущий VK player:
   *
   * player.store.actions.internal.downloadVideo()
   *
   * Остальные варианты оставлены на случай изменения VK.
   */
  const PATHS = [
    p => p.store.actions.internal.downloadVideo,

    p => p.store.actions.downloadVideo,

    p => p.actions && p.actions.downloadVideo,

    p => p.store && p.store.actions &&
         p.store.actions.internal &&
         p.store.actions.internal.download,

    p => p.store && p.store.actions &&
         p.store.actions.download
  ];

  function grabFn(player) {
    for (const get of PATHS) {
      try {
        const fn = get(player);

        if (typeof fn === 'function') {
          return fn;
        }
      } catch (e) {}
    }

    return null;
  }

  function findPlayers() {
    const found = [];

    for (const tag of TAGS) {
      deepAll(tag, document, found);
    }

    /*
     * Убираем дубликаты.
     */
    const unique = [...new Set(found)];

    /*
     * Оставляем только те плееры, у которых действительно
     * есть доступный обработчик скачивания.
     */
    return unique.filter(player => grabFn(player));
  }

  // ------------------------------------------------------------
  // Скачивание
  // ------------------------------------------------------------

  function download(player, button) {
    const fn = grabFn(player);

    if (!fn) {
      flash(button, t('denied'), true);

      console.warn(
        '[vkdl] download handler not found',
        player
      );

      return;
    }

    try {
      /*
       * Вызываем именно штатный обработчик VK.
       * Никаких сторонних серверов, API или перехвата видео.
       */
      fn();

      flash(button, t('working'));

    } catch (error) {
      flash(button, t('error'), true);

      console.error(
        '[vkdl] download handler failed:',
        error
      );
    }
  }

  function flash(button, text, bad) {
    const oldText = button.textContent;
    const oldColor = button.style.color;

    button.textContent = text;
    button.style.color = bad ? '#ff8080' : C.acc;

    setTimeout(() => {
      button.textContent = oldText;
      button.style.color = oldColor || C.text;
    }, 2200);
  }

  // ------------------------------------------------------------
  // UI
  // ------------------------------------------------------------

  let layer = null;

  /*
   * { player, host, button }
   */
  const pairs = [];

  function makeLayer() {
    if (layer) {
      return;
    }

    layer = mk('div');

    Object.assign(layer.style, {
      position: 'fixed',
      left: '0',
      top: '0',
      width: '0',
      height: '0',
      zIndex: '2147483646',
      pointerEvents: 'none'
    });

    document.body.appendChild(layer);
  }

  function makeButton(player) {
    const host = mk('div');

    Object.assign(host.style, {
      position: 'fixed',
      pointerEvents: 'auto',
      display: 'none'
    });

    /*
     * Shadow DOM нужен, чтобы стили VK не ломали нашу кнопку.
     */
    const shadow = host.attachShadow({
      mode: 'open'
    });

    const button = mk(
      'button',
      {},
      t('download')
    );

    button.type = 'button';
    button.title = t('title');

    Object.assign(button.style, {
      appearance: 'none',
      WebkitAppearance: 'none',

      background: C.bg,
      color: C.text,

      border: '1px solid ' + C.line,
      borderRadius: '9px',

      padding: '8px 12px',

      minHeight: '34px',
      minWidth: '90px',

      cursor: 'pointer',

      font: '600 13px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',

      backdropFilter: 'blur(5px)',
      WebkitBackdropFilter: 'blur(5px)',

      whiteSpace: 'nowrap',

      /*
       * Важно для мобильных браузеров:
       * запрещаем браузеру воспринимать нажатие как
       * жест/выделение текста.
       */
      userSelect: 'none',
      WebkitUserSelect: 'none',
      touchAction: 'manipulation',

      /*
       * На мобильном VK иногда есть анимации поверх плеера.
       */
      position: 'relative',
      zIndex: '2147483647'
    });

    button.addEventListener('click', event => {
      event.preventDefault();
      event.stopPropagation();

      download(player, button);
    });

    button.addEventListener('pointerdown', event => {
      event.stopPropagation();
    });

    button.addEventListener('mouseenter', () => {
      button.style.borderColor = C.acc;
    });

    button.addEventListener('mouseleave', () => {
      button.style.borderColor = C.line;
    });

    shadow.appendChild(button);
    layer.appendChild(host);

    return {
      host,
      button
    };
  }

  // ------------------------------------------------------------
  // Синхронизация с VK
  // ------------------------------------------------------------

  function sync() {
    const list = findPlayers();

    /*
     * Добавляем новые плееры.
     */
    for (const player of list) {
      if (pairs.some(x => x.player === player)) {
        continue;
      }

      const { host, button } = makeButton(player);

      pairs.push({
        player,
        host,
        button
      });
    }

    /*
     * Удаляем исчезнувшие плееры.
     */
    for (let i = pairs.length - 1; i >= 0; i--) {
      const item = pairs[i];

      if (
        !list.includes(item.player) ||
        !item.player.isConnected
      ) {
        item.host.remove();
        pairs.splice(i, 1);
      }
    }

    place();
  }

  // ------------------------------------------------------------
  // Позиционирование
  // ------------------------------------------------------------

  function place() {
    for (const item of pairs) {
      const player = item.player;
      const host = item.host;

      let rect;

      try {
        rect = player.getBoundingClientRect();
      } catch (e) {
        host.style.display = 'none';
        continue;
      }

      /*
       * Не показываем кнопку для невидимого/нулевого элемента.
       */
      const visible =
        rect.width > 120 &&
        rect.height > 80 &&
        rect.bottom > 0 &&
        rect.top < window.innerHeight &&
        rect.right > 0 &&
        rect.left < window.innerWidth;

      if (!visible) {
        host.style.display = 'none';
        continue;
      }

      /*
       * На широком экране — правый верхний угол.
       *
       * На узком экране делаем кнопку немного меньше
       * и ставим её ближе к краю, чтобы она не перекрывала
       * центральные элементы управления плеером.
       */
      const mobile = window.innerWidth <= 600;

      const buttonWidth = mobile ? 96 : 104;
      const rightOffset = mobile ? 8 : 12;
      const topOffset = mobile ? 8 : 12;

      Object.assign(host.style, {
        display: 'block',

        left: Math.round(
          rect.right - buttonWidth - rightOffset
        ) + 'px',

        top: Math.round(
          rect.top + topOffset
        ) + 'px'
      });
    }
  }

  // ------------------------------------------------------------
  // Диагностика
  // ------------------------------------------------------------

  window.vkdlDump = function () {
    const found = [];

    for (const tag of TAGS) {
      deepAll(tag, document, found);
    }

    const unique = [...new Set(found)];

    console.group('[vkdl] players');

    unique.forEach((player, index) => {
      console.group(
        '[vkdl] ' +
        player.tagName.toLowerCase() +
        ' #' +
        index
      );

      try {
        console.log('player:', player);
        console.log('store:', player.store);
        console.log(
          'actions:',
          player.store && player.store.actions
        );
        console.log(
          'internal:',
          player.store &&
          player.store.actions &&
          player.store.actions.internal
        );
        console.log(
          'download handler:',
          grabFn(player)
        );
      } catch (e) {
        console.log(
          'access error:',
          e.message
        );
      }

      console.groupEnd();
    });

    console.groupEnd();

    if (!unique.length) {
      const customElements = [
        ...new Set(
          [...document.querySelectorAll('*')]
            .map(el => el.tagName.toLowerCase())
            .filter(name => name.includes('-'))
        )
      ];

      console.log(
        '[vkdl] VK player elements not found.'
      );

      console.log(
        '[vkdl] custom elements:',
        customElements
      );

      console.log(
        '[vkdl] iframes:',
        [...document.querySelectorAll('iframe')]
          .map(frame => frame.src)
      );
    }

    return unique.length;
  };

  // ------------------------------------------------------------
  // Запуск
  // ------------------------------------------------------------

  function start() {
    if (!document.body) {
      return;
    }

    makeLayer();
    sync();

    /*
     * VK активно перерисовывает DOM при скролле,
     * переходах между видео и SPA-навигации.
     */
    const observer = new MutationObserver(() => {
      clearTimeout(start.timer);

      start.timer = setTimeout(() => {
        sync();
      }, 300);
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true
    });

    /*
     * Скролл.
     */
    window.addEventListener(
      'scroll',
      place,
      true
    );

    /*
     * Изменение размера/поворот телефона.
     */
    window.addEventListener(
      'resize',
      place
    );

    window.addEventListener(
      'orientationchange',
      () => {
        setTimeout(() => {
          sync();
        }, 300);
      }
    );

    /*
     * На мобильном VK размеры плеера могут изменяться
     * без resize/scroll.
     */
    setInterval(place, 500);

    /*
     * Alt+D на компьютере.
     *
     * На телефоне клавиатурный shortcut просто не нужен,
     * но наличие обработчика ничего не мешает.
     */
    window.addEventListener('keydown', event => {
      if (
        !event.altKey ||
        event.code !== 'KeyD'
      ) {
        return;
      }

      const middle = window.innerHeight / 2;

      let best = null;
      let distance = Infinity;

      for (const item of pairs) {
        let rect;

        try {
          rect = item.player.getBoundingClientRect();
        } catch (e) {
          continue;
        }

        if (rect.height <= 80) {
          continue;
        }

        const center =
          rect.top + rect.height / 2;

        const distanceToCenter =
          Math.abs(center - middle);

        if (distanceToCenter < distance) {
          distance = distanceToCenter;
          best = item;
        }
      }

      if (best) {
        download(
          best.player,
          best.button
        );

        event.preventDefault();
      }
    });

    console.log(
      '[vkdl]',
      t('loaded'),
      pairs.length,
      '| lang:',
      LANG,
      '| host:',
      location.hostname
    );
  }

  if (document.body) {
    start();
  } else {
    window.addEventListener(
      'DOMContentLoaded',
      start,
      { once: true }
    );
  }

})();