Загрузка данных
// ==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 }
);
}
})();