// ==UserScript==
// @name Piped Video Previews
// @name:ru Piped Video Previews
// @namespace VideoPreviews
// @version 1.1
// @description Displays an animated video preview when hovering over its thumbnail on Piped websites
// @description:ru Показывает анимированное превью видео при наведении курсора на его миниатюру на сайтах Piped
// @author SearchDL
// @match *://piped.video/*
// @match *://*.piped.video/*
// @match *://piped.kavin.rocks/*
// @match *://piped.yt/*
// @icon https://piped.video/favicon.ico
// @license MIT
// @grant none
// ==/UserScript==
// Settings
var ThumbChangingSpeedMs = 250; // Time in milliseconds before frame change during preview | Время в мс до смены кадра во время предпросмотра
var DelayForListViewMs = 0; // Delay before requesting preview frames when hovering over a thumbnail for videos displayed in 1 column (related videos) | Задержка до запроса эскизов при наведении на миниатюру для видео, отображающихся в 1 ряд (похожие видео)
var DelayForGridViewMs = 300; // Delay before requesting preview frames for grid-displayed videos (channel and playlist videos, watch history, search results) | Задержка до запроса эскизов для видео, отображающихся сеткой (видео канала и плейлиста, история просмотра, результаты поиска)
var FallbackApiUrl = "https://pipedapi.kavin.rocks" // Piped instance API URL used when it is impossible to get the current API URL from the page: in watch history, in search results and in trends | API-адрес зеркала Piped, используемый в случае, когда нельзя получить текущий API-адрес со страницы: в истории просмотра, в поиске и в трендах
// List of instances API URLs: https://github.com/TeamPiped/Piped/wiki/Instances
var prevbox;
var canvas;
var hovered = false;
var timeout;
var finished = false;
var apiurl = FallbackApiUrl;
function getApiUrl(t) {
var rss = t.querySelector('i.i-fa6-solid\\:rss');
if (rss) {
var url = new URL(rss.parentNode.href);
apiurl = url.protocol + "//" + url.host;
}
}
function updatePreviewBoxes(t) {
var boxes = t.querySelectorAll(".aspect-video.w-full.object-contain");
boxes.forEach(
function(cbox) {
cbox.addEventListener("mouseover", thumbnailIn, false);
cbox.addEventListener("mouseout", thumbnailOut, false);
}
);
}
(function() {
'use strict';
getApiUrl(document);
updatePreviewBoxes(document);
})();
var observer = new MutationObserver(function(mutations){
mutations.forEach(function(mutation){
getApiUrl(mutation.target);
updatePreviewBoxes(mutation.target);
});
});
observer.observe(document.body, {childList:true,subtree:true});
function thumbnailIn() {
if (finished) {
finished = false;
return;
}
if (hovered && prevbox) {
restore(prevbox);
}
var url = this.parentNode.parentNode.attributes.href.value;
prevbox = this;
hovered = true;
if (!url.includes("watch?v=")) return;
if (window.location.href.includes("watch?v=") && !url.includes("list=")) timeout = setTimeout(() => processThumbnails(this), DelayForListViewMs);
else timeout = setTimeout(() => processThumbnails(this), DelayForGridViewMs);
}
function processThumbnails(box) {
var url = box.parentNode.parentNode.attributes.href.value;
box.style.opacity = '0.5';
box.style.transition = 'opacity 0.5s ease-in-out';
fetchData(apiurl + "/streams/" + url.substring(url.indexOf("=") + 1)).then(data => {
if (prevbox.src !== box.src) {
return;
}
if (hovered) {
if (!data || data.previewFrames.length < 1) {
box.style.border = '2px solid';
box.style.borderColor = 'red';
return;
}
var maxn = 0;
var maxh = 0;
for (let i = 0; i < data.previewFrames.length; i++) {
if (data.previewFrames[i].frameHeight > maxh) {
maxn = i;
maxh = data.previewFrames[i].frameHeight;
}
}
var frames = data.previewFrames[maxn];
var img, next;
canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
canvas.width = parseInt(box.width);
canvas.height = parseInt(box.height);
function changeImage(i, y, x) {
if (!hovered) return;
var X = frames.framesPerPageX;
var Y = frames.framesPerPageY;
if (i * X*Y + y * X + x >= frames.totalCount - 1) {
finished = true;
canvas.replaceWith(box); // эта замена вызывает события выхода и захода курсора в область превью
return;
}
if (i < 0 || (y == Y-1 && x == X-1)) {
i++;
x = 0;
y = 0;
img = new Image();
next = new Image();
img.src = frames.urls[i];
if (i < frames.urls.length - 1) {
next.src = frames.urls[i + 1]; // предзагрузка следующего атласа миниатюр
}
}
else if (x == X-1) {
y++;
x = 0;
}
else x++;
if (!img.complete || img.naturalWidth == 0) {
timeout = setTimeout(() => changeImage(i, y, x-1), 50);
}
else {
var sx = x * frames.frameWidth;
var sy = y * frames.frameHeight;
var sw = frames.frameWidth;
var sh = frames.frameHeight;
var scaleX = canvas.width / sw;
var scaleY = canvas.height / sh;
var scale = Math.min(scaleX, scaleY);
var offsetX = (canvas.width - sw*scale) / 2;
var offsetY = (canvas.height - sh*scale) / 2;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, sx, sy, sw, sh, offsetX, offsetY, sw*scale, sh*scale);
box.replaceWith(canvas);
canvas.onmouseleave = () => {
restore(box);
};
timeout = setTimeout(() => changeImage(i, y, x), ThumbChangingSpeedMs);
}
}
changeImage(-1, 0, 0);
}
});
}
function thumbnailOut() {
if (!finished) restore(this);
}
function restore(box) {
hovered = false;
box.style.opacity = '';
box.style.transition = '';
box.style.border = '';
box.style.borderColor = '';
clearTimeout(timeout);
if (canvas) canvas.replaceWith(box);
}
async function fetchData(url) {
var response = await fetch(url);
if (!response.ok) {
return "";
}
var data = await response.json();
return data;
}
const originalReplaceState = history.replaceState;
history.replaceState = function () { // переход по страницам на сайте
originalReplaceState.apply(this, arguments);
if (hovered) restore(prevbox);
}
window.addEventListener('popstate', function(event) { // навигация по истории браузера вручную
if (hovered) restore(prevbox);
});