UI tweak for video control bar
// ==UserScript==
// @name Jable video control bar tweak
// @namespace http://tampermonkey.net
// @version 7.0.4
// @description UI tweak for video control bar
// @author php
// @match *://jable.tv/videos/*
// @grant none
// @run-at document-end
// @license MIT
// ==/UserScript==
(function() {
'use strict';
let hideTimeout = null;
// Add CSS for Custom Skip Buttons AND Vertical Volume Bar
const style = document.createElement('style');
style.innerHTML = `
/* Force player to stay visible during interactions */
.plyr.keep-showing .plyr__controls {
opacity: 1 !important;
visibility: visible !important;
pointer-events: auto !important;
transform: translateY(0) !important;
}
/* NEW: Skip buttons styles moved from JS to CSS for cleaner code */
.custom-skip-btn {
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
padding: 0 !important;
flex-shrink: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
/* Strip residual padding/flex from the container so it tightly wraps the button */
.plyr__controls__item.plyr__volume {
position: relative !important;
overflow: visible !important;
flex: 0 0 auto !important;
min-width: auto !important;
width: auto !important;
padding: 0 !important;
margin: 0 !important;
display: flex !important;
justify-content: center !important;
}
/* Take the volume range out of normal document flow */
.plyr__controls__item.plyr__volume input[data-plyr="volume"] {
position: absolute !important;
bottom: 84px !important;
left: calc(50% - 60px) !important;
width: 120px !important;
height: 32px !important;
margin: 0 !important;
transform: rotate(-90deg) !important;
transform-origin: center center !important;
touch-action: none !important; /* NEW: Disables native screen scrolling while touching the bar */
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.25s ease, visibility 0.25s ease !important;
z-index: 100 !important;
flex: none !important;
max-width: none !important;
}
/* Invisible hover bridge to cover the empty gap between the icon and the bar */
.plyr__controls__item.plyr__volume.show-volume::after {
content: '';
position: absolute;
bottom: 100%; /* Starts exactly at the top of the volume icon container, never covering the button */
left: -20px;
right: -20px;
height: 80px; /* Reaches up to cover the empty space to the slider */
z-index: 99;
}
/* Show state applied via JavaScript */
.plyr__controls__item.plyr__volume.show-volume input[data-plyr="volume"] {
opacity: 1 !important;
visibility: visible !important;
pointer-events: auto !important;
}
`;
document.head.appendChild(style);
function injectUI() {
const controlBar = document.querySelector('.plyr__controls');
const video = document.querySelector('video');
const plyrContainer = document.querySelector('.plyr');
// --- Feature 1: Skip Buttons ---
if (controlBar && video && !document.querySelector('.custom-skip-btn')) {
const createBtn = (seconds, label) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'plyr__controls__item plyr__control custom-skip-btn';
btn.innerHTML = label;
const handleClick = (e) => {
e.preventDefault();
e.stopPropagation();
video.currentTime += seconds;
if (plyrContainer) {
if (hideTimeout) clearTimeout(hideTimeout);
plyrContainer.classList.add('keep-showing');
hideTimeout = setTimeout(() => plyrContainer.classList.remove('keep-showing'), 5000);
}
};
btn.addEventListener('click', handleClick);
btn.addEventListener('touchstart', handleClick, { passive: false });
return btn;
};
const rwBtn = createBtn(-10, '<svg aria-hidden="true" focusable="false"><use xlink:href="#plyr-rewind"></use></svg><span class="plyr__sr-only">Rewind 10s</span>');
const ffBtn = createBtn(10, '<svg aria-hidden="true" focusable="false"><use xlink:href="#plyr-fast-forward"></use></svg><span class="plyr__sr-only">Forward 10s</span>');
const playBtn = controlBar.querySelector('button[data-plyr="play"]');
if (playBtn) {
playBtn.before(rwBtn);
playBtn.after(ffBtn);
}
}
// --- Feature 2: Vertical Volume Bar Logic ---
const volumeContainer = document.querySelector('.plyr__volume');
if (volumeContainer && !volumeContainer.classList.contains('custom-vol-enabled')) {
volumeContainer.classList.add('custom-vol-enabled');
const volumeBtn = volumeContainer.querySelector('button[data-plyr="mute"]');
const isHoverable = () => window.matchMedia('(hover: hover)').matches;
// 1. Desktop: Hover Interactions
let hoverTimer = null;
const volumeInput = volumeContainer.querySelector('input[data-plyr="volume"]');
const keepOpen = () => {
if (isHoverable()) {
clearTimeout(hoverTimer); // Cancels closing whenever mouse is on icon OR bar
volumeContainer.classList.add('show-volume');
}
};
const delayClose = () => {
if (isHoverable()) {
clearTimeout(hoverTimer);
// Grants a 300ms window to move between the icon and slider
hoverTimer = setTimeout(() => {
volumeContainer.classList.remove('show-volume');
}, 300);
}
};
// Listen on the volume icon container
volumeContainer.addEventListener('mouseenter', keepOpen);
volumeContainer.addEventListener('mouseleave', delayClose);
// Listen on the vertical volume slider itself
if (volumeInput) {
volumeInput.addEventListener('mouseenter', keepOpen);
volumeInput.addEventListener('mouseleave', delayClose);
}
// 2. Mobile: Click/Touch Interactions
if (volumeBtn) {
volumeBtn.addEventListener('click', () => {
if (!isHoverable()) volumeContainer.classList.add('show-volume');
});
}
// 3. Mobile: Close when clicking elsewhere
document.addEventListener('click', (e) => {
if (volumeContainer.classList.contains('show-volume')) {
// Close if clicked completely outside the control bar OR clicked another interactive element in the control bar
if (!controlBar.contains(e.target) || (!volumeContainer.contains(e.target) && e.target.closest('button, input'))) {
volumeContainer.classList.remove('show-volume');
}
}
});
// 4. Mobile: Vertical Volume Calculation
//const volumeInput = volumeContainer.querySelector('input[data-plyr="volume"]');
if (volumeInput && !volumeInput.dataset.touchBound) {
volumeInput.dataset.touchBound = "true";
const handleVerticalTouch = (e) => {
if (!e.touches || e.touches.length === 0) return;
// Stop the video player from applying its horizontal math
e.stopPropagation();
e.stopImmediatePropagation();
// Calculate the vertical position
const rect = volumeInput.getBoundingClientRect();
const clientY = e.touches[0].clientY;
// Figure out the percentage from bottom (0) to top (1)
let pct = (rect.bottom - clientY) / rect.height;
pct = Math.max(0, Math.min(1, pct)); // Keep it between 0 and 1
// Apply the new volume level and update the pink fill bar
volumeInput.value = pct;
volumeInput.style.setProperty('--value', `${pct * 100}%`);
// Tell the video player that the volume changed
volumeInput.dispatchEvent(new Event('input', { bubbles: true }));
};
// capture: true intercepts the touch before the video player sees it
volumeInput.addEventListener('touchstart', handleVerticalTouch, { passive: false, capture: true });
volumeInput.addEventListener('touchmove', handleVerticalTouch, { passive: false, capture: true });
}
}
}
const observer = new MutationObserver(injectUI);
observer.observe(document.body, { childList: true, subtree: true });
injectUI();
})();