// ==UserScript==
// @name YouTubeTV Volume Control with Memory
// @namespace https://github.com/Nick2bad4u/UserStyles
// @version 4.0
// @description Remembers and controls volume levels on YouTube TV with keyboard shortcuts and a UI for manual input
// @author Nick2bad4u
// @match *://tv.youtube.com/*
// @grant GM.setValue
// @grant GM.getValue
// @icon https://www.google.com/s2/favicons?sz=64&domain=tv.youtube.com
// @license UnLicense
// @tag youtube
// ==/UserScript==
(function () {
'use strict';
// Wait for the YouTube player and controls to load
const playerReady = setInterval(() => {
const videoPlayer = document.querySelector('video');
const leftControls = document.querySelector('.ytp-left-controls');
const volumeSliderHandle = document.querySelector('.ytp-volume-slider-handle');
const volumePanel = document.querySelector('.ytp-volume-panel');
const muteButton = document.querySelector('.ytp-mute-button');
if (videoPlayer && leftControls && volumeSliderHandle && muteButton) {
clearInterval(playerReady);
// Retrieve the saved volume level from localStorage
let ytVolumeData = localStorage.getItem('yt-player-volume');
let savedVolume = videoPlayer.volume;
let savedMuted = videoPlayer.muted;
if (ytVolumeData) {
try {
ytVolumeData = JSON.parse(ytVolumeData);
const data = JSON.parse(ytVolumeData.data);
savedVolume = data.volume / 100; // YouTube stores volume from 0 to 100
savedMuted = data.muted;
} catch (e) {
console.error('Failed to parse yt-player-volume:', e);
}
}
// Ensure savedVolume is within [0, 1] range
videoPlayer.volume = Math.max(0, Math.min(1, savedVolume));
videoPlayer.muted = savedMuted;
// Update the slider handle position
const updateSliderHandle = () => {
if (videoPlayer.muted) {
volumeSliderHandle.style.left = `0%`;
} else {
volumeSliderHandle.style.left = `${videoPlayer.volume * 100}%`;
}
};
updateSliderHandle();
// Set the aria-valuenow attribute on the volume panel
if (volumePanel) {
volumePanel.setAttribute('aria-valuenow', videoPlayer.volume * 100);
}
// Create input element for volume control
const volumeInput = document.createElement('input');
volumeInput.type = 'number';
volumeInput.min = 0;
volumeInput.max = 100;
volumeInput.value = videoPlayer.muted ? 0 : Math.round(videoPlayer.volume * 100);
// Style the input field
Object.assign(volumeInput.style, {
width: '40px',
marginLeft: '10px',
backgroundColor: 'rgba(255, 255, 255, 0.0)',
color: 'white',
border: '0px solid rgba(255, 255, 255, 0.0)',
borderRadius: '4px',
zIndex: 9999,
height: '24px',
fontSize: '16px',
padding: '0 4px',
transition: 'border-color 0.3s, background-color 0.3s',
outline: 'none',
position: 'relative',
top: '13px',
});
// Prevent hotkeys from interfering with the input
volumeInput.addEventListener('keydown', (e) => e.stopPropagation());
// Input focus and hover styling
volumeInput.addEventListener(
'focus',
() => (volumeInput.style.borderColor = 'rgba(255, 255, 255, 0.6)')
);
volumeInput.addEventListener(
'blur',
() => (volumeInput.style.borderColor = 'rgba(255, 255, 255, 0.3)')
);
volumeInput.addEventListener(
'mouseenter',
() => (volumeInput.style.backgroundColor = 'rgba(0, 0, 0, 0.8)')
);
volumeInput.addEventListener(
'mouseleave',
() => (volumeInput.style.backgroundColor = 'rgba(255, 255, 255, 0.0)')
);
// Handle volume change from input
let lastSetVolume = videoPlayer.volume;
volumeInput.addEventListener('input', () => {
let volume = parseInt(volumeInput.value, 10);
volume = isNaN(volume) ? 100 : Math.max(0, Math.min(100, volume)); // Clamp between 0 and 100
videoPlayer.volume = volume / 100; // Convert to [0, 1] range
if (volume === 0) {
videoPlayer.muted = true;
} else {
videoPlayer.muted = false;
}
lastSetVolume = videoPlayer.volume;
// Update the slider handle position
updateSliderHandle();
// Save the new volume to localStorage
const ytVolumeObject = {
data: JSON.stringify({
volume: volume, // Volume from 0 to 100
muted: videoPlayer.muted,
}),
expiration: Date.now() + 2592000000, // Expires in 30 days
creation: Date.now(),
};
const ytVolumeString = JSON.stringify(ytVolumeObject);
localStorage.setItem('yt-player-volume', ytVolumeString);
});
// Update input value when volume changes from other controls
let previousMutedState = videoPlayer.muted;
videoPlayer.addEventListener('volumechange', () => {
if (previousMutedState && !videoPlayer.muted) {
// Player was muted and is now unmuted
videoPlayer.volume = lastSetVolume;
volumeInput.value = Math.round(videoPlayer.volume * 100);
updateSliderHandle();
}
previousMutedState = videoPlayer.muted;
// Update lastSetVolume if the volume changed and not muted
if (!videoPlayer.muted) lastSetVolume = videoPlayer.volume;
volumeInput.value = videoPlayer.muted ? 0 : Math.round(videoPlayer.volume * 100);
// Update the slider handle position
updateSliderHandle();
// Save the volume to localStorage
const volumePercent = Math.round(videoPlayer.volume * 100);
const ytVolumeObject = {
data: JSON.stringify({
volume: volumePercent,
muted: videoPlayer.muted,
}),
expiration: Date.now() + 2592000000,
creation: Date.now(),
};
const ytVolumeString = JSON.stringify(ytVolumeObject);
localStorage.setItem('yt-player-volume', ytVolumeString);
});
// Insert the input into the left controls
leftControls.appendChild(volumeInput);
}
}, 500);
})();