Last active
June 9, 2025 14:41
-
-
Save lunamoth/0a448d97bc94029911ba2e101ee61d00 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name 유튜브 볼륨 부스터 | |
// @namespace http://tampermonkey.net/ | |
// @version 3.7 | |
// @description 화면 오른쪽 하단에 표시되는 컨트롤러를 통해 유튜브의 작은 소리를 100% 이상으로 증폭시켜주는 스크립트입니다. 전체화면에서는 자동으로 숨겨집니다. | |
// @author Gemini & lunamoth | |
// @match *://*.youtube.com/* | |
// @grant GM_addStyle | |
// @run-at document-body | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
const config = { | |
MIN_VOLUME: 100, | |
MAX_VOLUME: 500, | |
VOLUME_STEP: 10, | |
WARNING_THRESHOLD: 300, | |
COLOR_NORMAL: '#0a84ff', | |
COLOR_WARNING: '#ff9500', | |
get VOLUME_RANGE() { return this.MAX_VOLUME - this.MIN_VOLUME; } | |
}; | |
const DOM = { | |
CONTAINER_ID: 'volumeBoosterGlobalContainer', | |
TOGGLE_BUTTON_ID: 'boosterToggleButton', | |
PANEL_ID: 'volumeBoosterPanel', | |
SLIDER_ID: 'volumeBoosterSlider', | |
CLASSES: { | |
HEADER: 'booster-header', | |
TITLE: 'booster-title', | |
HEADER_ACTIONS: 'booster-header-actions', | |
BTN: 'booster-btn', | |
RESET_BTN: 'booster-reset-btn', | |
CLOSE_BTN: 'booster-close-btn', | |
CONTROLS: 'booster-controls', | |
DISPLAY: 'booster-display', | |
BOOST_ACTIVE: 'boost-active', | |
} | |
}; | |
GM_addStyle(` | |
#${DOM.CONTAINER_ID} { position: fixed; bottom: 75px; right: 22px; z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, "Roboto", sans-serif; display: none; } | |
#${DOM.TOGGLE_BUTTON_ID}, #${DOM.PANEL_ID} { background: rgba(255, 255, 255, 0.7); backdrop-filter: blur(20px) saturate(1.8); -webkit-backdrop-filter: blur(20px) saturate(1.8); border: 1px solid rgba(255, 255, 255, 0.5); box-shadow: 0 4px 12px rgba(0,0,0,0.1); } | |
#${DOM.TOGGLE_BUTTON_ID} { width: 32px; height: 32px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 16px; color: #333; transition: transform 0.2s, box-shadow 0.2s, color 0.2s; } | |
#${DOM.TOGGLE_BUTTON_ID}:hover { transform: scale(1.1); } | |
#${DOM.TOGGLE_BUTTON_ID}.${DOM.CLASSES.BOOST_ACTIVE} { color: ${config.COLOR_NORMAL}; } | |
#${DOM.PANEL_ID} { display: none; width: 220px; padding: 12px 16px; border-radius: 16px; } | |
.${DOM.CLASSES.HEADER} { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } | |
.${DOM.CLASSES.TITLE} { font-size: 15px; font-weight: 600; color: #111; } | |
.${DOM.CLASSES.HEADER_ACTIONS} { display: flex; align-items: center; gap: 4px; } | |
.${DOM.CLASSES.BTN} { border: none; background: transparent; cursor: pointer; transition: background-color 0.2s; display: flex; align-items: center; justify-content: center; } | |
.${DOM.CLASSES.RESET_BTN} { padding: 4px; border-radius: 6px; font-size: 12px; font-weight: 500; color: ${config.COLOR_NORMAL}; } | |
.${DOM.CLASSES.RESET_BTN}:hover { background-color: rgba(10, 132, 255, 0.1); } | |
.${DOM.CLASSES.CLOSE_BTN} { width: 24px; height: 24px; border-radius: 50%; font-size: 12px; color: rgba(0,0,0,0.4); } | |
.${DOM.CLASSES.CLOSE_BTN}:hover { background-color: rgba(0,0,0,0.08); } | |
.${DOM.CLASSES.CONTROLS} { display: flex; align-items: center; } | |
.${DOM.CLASSES.DISPLAY} { min-width: 45px; margin-left: 14px; font-family: "SF Mono", "Menlo", monospace; font-size: 14px; font-weight: 500; text-align: right; color: #555; } | |
#${DOM.SLIDER_ID} { -webkit-appearance: none; appearance: none; width: 100%; height: 6px; background: rgba(0,0,0,0.1); border-radius: 3px; outline: none; cursor: pointer; transition: background 0.2s; } | |
#${DOM.SLIDER_ID}::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 18px; height: 18px; background: #fff; border-radius: 50%; border: 1px solid rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.2); } | |
`); | |
const state = { | |
volume: config.MIN_VOLUME, | |
isPanelVisible: false, | |
isUiVisible: false | |
}; | |
const audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
const processedVideos = new WeakMap(); | |
let uiElements = null; | |
let currentGainNode = null; | |
let currentSourceNode = null; | |
const render = () => { | |
if (!uiElements) return; | |
const { container, panel, toggleButton, slider, display } = uiElements; | |
const { volume, isPanelVisible, isUiVisible } = state; | |
container.style.display = isUiVisible ? 'block' : 'none'; | |
panel.style.display = isPanelVisible ? 'block' : 'none'; | |
toggleButton.style.display = isPanelVisible ? 'none' : 'flex'; | |
toggleButton.classList.toggle(DOM.CLASSES.BOOST_ACTIVE, volume > config.MIN_VOLUME); | |
slider.value = volume; | |
display.textContent = `${volume}%`; | |
const percentage = (volume - config.MIN_VOLUME) * 100 / config.VOLUME_RANGE; | |
const fillColor = volume >= config.WARNING_THRESHOLD ? config.COLOR_WARNING : config.COLOR_NORMAL; | |
slider.style.background = `linear-gradient(to right, ${fillColor} ${percentage}%, rgba(0,0,0,0.1) ${percentage}%)`; | |
}; | |
const setState = (newState) => { | |
Object.assign(state, newState); | |
render(); | |
}; | |
const handleVolumeChange = (newVolume) => { | |
const volume = parseInt(newVolume, 10); | |
if (currentGainNode) { | |
currentGainNode.gain.setValueAtTime(volume / 100, audioContext.currentTime); | |
} | |
setState({ volume }); | |
}; | |
const createAudioProcessor = (video) => { | |
if (processedVideos.has(video)) return; | |
if (currentSourceNode) currentSourceNode.disconnect(); | |
if (currentGainNode) currentGainNode.disconnect(); | |
try { | |
const source = audioContext.createMediaElementSource(video); | |
const gainNode = audioContext.createGain(); | |
source.connect(gainNode).connect(audioContext.destination); | |
currentSourceNode = source; | |
currentGainNode = gainNode; | |
processedVideos.set(video, true); | |
handleVolumeChange(state.volume); | |
} catch (error) { | |
setState({ isUiVisible: false }); | |
} | |
}; | |
const manageUiAndAudio = () => { | |
const video = document.querySelector('video'); | |
if (!video) { | |
setState({ isUiVisible: false }); | |
return; | |
} | |
// [수정됨] 전체화면 상태가 아닐 때만 UI를 표시하도록 조건 추가 | |
const isFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement); | |
if (!isFullscreen) { | |
setState({ isUiVisible: true }); | |
} | |
createAudioProcessor(video); | |
}; | |
// [추가됨] 전체화면 변경을 감지하는 핸들러 | |
const handleFullscreenChange = () => { | |
const isFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement); | |
if (isFullscreen) { | |
// 전체화면 진입 시 UI 숨김 | |
setState({ isUiVisible: false }); | |
} else { | |
// 전체화면 해제 시 UI 상태를 다시 확인하여 표시 | |
manageUiAndAudio(); | |
} | |
}; | |
const createElement = (tag, options = {}, children = []) => { | |
const element = document.createElement(tag); | |
Object.assign(element, options); | |
if (options.className) { | |
const classes = Array.isArray(options.className) ? options.className : [options.className]; | |
element.classList.add(...classes); | |
} | |
children.forEach(child => element.appendChild(child)); | |
return element; | |
}; | |
const createUI = () => { | |
const display = createElement('span', { className: DOM.CLASSES.DISPLAY }); | |
const slider = createElement('input', { | |
type: 'range', | |
id: DOM.SLIDER_ID, | |
min: config.MIN_VOLUME, | |
max: config.MAX_VOLUME, | |
step: config.VOLUME_STEP, | |
oninput: (e) => handleVolumeChange(e.target.value) | |
}); | |
const controls = createElement('div', { className: DOM.CLASSES.CONTROLS }, [slider, display]); | |
const resetButton = createElement('button', { | |
className: [DOM.CLASSES.BTN, DOM.CLASSES.RESET_BTN], | |
textContent: 'Reset', | |
onclick: () => handleVolumeChange(config.MIN_VOLUME) | |
}); | |
const closeButton = createElement('button', { | |
className: [DOM.CLASSES.BTN, DOM.CLASSES.CLOSE_BTN], | |
textContent: '✖', | |
onclick: () => setState({ isPanelVisible: false }) | |
}); | |
const headerActions = createElement('div', { className: DOM.CLASSES.HEADER_ACTIONS }, [resetButton, closeButton]); | |
const title = createElement('div', { className: DOM.CLASSES.TITLE, textContent: 'Volume Boost' }); | |
const header = createElement('div', { className: DOM.CLASSES.HEADER }, [title, headerActions]); | |
const panel = createElement('div', { id: DOM.PANEL_ID }, [header, controls]); | |
const toggleButton = createElement('button', { | |
id: DOM.TOGGLE_BUTTON_ID, | |
textContent: '🔊', | |
onclick: () => setState({ isPanelVisible: true }) | |
}); | |
const container = createElement('div', { id: DOM.CONTAINER_ID }, [toggleButton, panel]); | |
document.body.appendChild(container); | |
return { container, toggleButton, panel, slider, display }; | |
}; | |
const cleanup = () => { | |
if (uiElements && uiElements.container) uiElements.container.remove(); | |
if (currentSourceNode) currentSourceNode.disconnect(); | |
if (currentGainNode) currentGainNode.disconnect(); | |
document.removeEventListener('yt-navigate-finish', initialize); | |
window.removeEventListener('beforeunload', cleanup); | |
// [수정됨] 추가된 이벤트 리스너 제거 | |
document.removeEventListener('fullscreenchange', handleFullscreenChange); | |
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange); | |
}; | |
const initialize = () => { | |
if (audioContext.state === 'suspended') { | |
audioContext.resume(); | |
} | |
if (!uiElements) { | |
uiElements = createUI(); | |
} | |
manageUiAndAudio(); | |
render(); | |
if (!window.volumeBoosterInitialized) { | |
document.addEventListener('fullscreenchange', handleFullscreenChange); | |
document.addEventListener('webkitfullscreenchange', handleFullscreenChange); // for Safari/Chrome | |
window.volumeBoosterInitialized = true; | |
} | |
}; | |
document.addEventListener('yt-navigate-finish', initialize); | |
window.addEventListener('beforeunload', cleanup); | |
if (document.body) { | |
initialize(); | |
} else { | |
document.addEventListener('DOMContentLoaded', initialize); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment