Last active
April 8, 2025 16:33
-
-
Save lunamoth/6cdda6c2b6bb450a1d4e44812529d69a 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 Video Sound Booster (영상 사운드 증폭기) | |
// @namespace http://tampermonkey.net/ | |
// @version 1.2 | |
// @description Boosts the volume of HTML5 videos and allows hiding the controls. 페이지의 HTML5 영상 사운드를 증폭시키고 컨트롤러를 숨길 수 있습니다. | |
// @author Gemini | |
// @match *://*/* | |
// @grant GM_addStyle | |
// @grant GM_getValue | |
// @grant GM_setValue | |
// @run-at document-idle | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
// --- 설정 --- | |
const MAX_GAIN = 5; // 최대 증폭 배율 (예: 5 = 500%) | |
const DEFAULT_GAIN = 1; // 기본값 (1 = 100%, 증폭 없음) | |
const REMEMBER_GAIN = true; // 페이지/사이트별 증폭 값 기억 여부 | |
// --- 설정 끝 --- | |
let audioContext = null; | |
const videoDataMap = new WeakMap(); // 비디오 요소와 오디오 노드/컨트롤러 매핑 | |
// 컨트롤러 UI 스타일 | |
GM_addStyle(` | |
.sound-booster-controls { | |
position: absolute; | |
top: 10px; | |
right: 10px; | |
background-color: rgba(0, 0, 0, 0.7); | |
color: white; | |
padding: 5px 10px; | |
border-radius: 5px; | |
z-index: 9999; | |
font-size: 12px; | |
display: flex; /* Use flexbox for layout */ | |
align-items: center; | |
cursor: move; /* 이동 가능하게 */ | |
user-select: none; /* 텍스트 선택 방지 */ | |
} | |
/* Hide controls when this class is added */ | |
.sound-booster-controls.hidden { | |
display: none; | |
} | |
.sound-booster-controls label { | |
margin-right: 5px; | |
font-weight: bold; | |
white-space: nowrap; /* Prevent label wrapping */ | |
} | |
.sound-booster-controls input[type="range"] { | |
width: 80px; | |
height: 15px; | |
margin: 0 5px; | |
vertical-align: middle; | |
} | |
.sound-booster-controls span.boost-value { /* Value display span */ | |
min-width: 35px; | |
text-align: right; | |
white-space: nowrap; | |
} | |
/* Style for the close button [x] */ | |
.sound-booster-close-btn { | |
margin-left: 8px; /* Space before the button */ | |
padding: 0 4px; | |
cursor: pointer; | |
font-weight: bold; | |
border: 1px solid transparent; | |
border-radius: 3px; | |
line-height: 1; /* Adjust line height */ | |
} | |
.sound-booster-close-btn:hover { | |
color: red; | |
border-color: #555; | |
} | |
/* 비디오 컨테이너에 relative position 추가 (컨트롤러 위치 기준) */ | |
video { | |
position: relative; /* 혹은 video의 부모 요소에 적용 필요 */ | |
} | |
/* 컨트롤러가 비디오 위에 뜨도록 */ | |
.video-container-relative { /* 부모 요소에 이 클래스 추가 시도 */ | |
position: relative !important; | |
} | |
`); | |
function getAudioContext() { | |
if (!audioContext) { | |
try { | |
audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
} catch (e) { | |
console.error("Web Audio API is not supported in this browser.", e); | |
alert("오디오 증폭 실패: 브라우저가 Web Audio API를 지원하지 않습니다."); | |
} | |
} | |
return audioContext; | |
} | |
function setupAudioProcessing(videoElement) { | |
if (videoDataMap.has(videoElement) || videoElement.dataset.audioBoosted) { | |
// Check if controls exist but are hidden, if so, show them maybe? | |
// For now, just skip if already processed. | |
const existingData = videoDataMap.get(videoElement); | |
if (existingData && existingData.controls && existingData.controls.classList.contains('hidden')) { | |
// If controls exist and are hidden, maybe clicking the video could show them again? | |
// Or just leave them hidden. Let's leave them hidden for now. | |
} else if (existingData && existingData.controls) { | |
// Controls exist and are visible, do nothing. | |
} else if (!existingData) { | |
// Proceed only if no existing data. | |
} else { | |
return; // Skip if already boosted marker exists but no map data (edge case) | |
} | |
} | |
const context = getAudioContext(); | |
if (!context) return; | |
const resumeContext = () => { | |
if (context.state === 'suspended') { | |
context.resume().then(() => { | |
console.log("AudioContext resumed on user interaction."); | |
}).catch(e => console.error("Failed to resume AudioContext:", e)); | |
} | |
videoElement.removeEventListener('play', resumeContext); | |
videoElement.removeEventListener('click', resumeContext); | |
document.removeEventListener('click', resumeContext); | |
}; | |
if (context.state === 'suspended') { | |
videoElement.addEventListener('play', resumeContext, { once: true }); | |
videoElement.addEventListener('click', resumeContext, { once: true }); | |
document.addEventListener('click', resumeContext, { once: true }); | |
} | |
try { | |
// Check if source already exists (maybe from a previous attempt) | |
let sourceNode; | |
const existingData = videoDataMap.get(videoElement); | |
if (existingData && existingData.source) { | |
sourceNode = existingData.source; | |
// Make sure it's still connected? Usually okay. | |
} else { | |
sourceNode = context.createMediaElementSource(videoElement); | |
} | |
const gainNode = context.createGain(); | |
sourceNode.connect(gainNode); | |
gainNode.connect(context.destination); | |
videoElement.dataset.audioBoosted = 'true'; | |
// Add controls and store references | |
const controlsContainer = addBoosterControls(videoElement, gainNode); | |
// Store references including the controls element | |
videoDataMap.set(videoElement, { | |
source: sourceNode, | |
gain: gainNode, | |
controls: controlsContainer // Store reference to the controls div | |
}); | |
console.log("Audio booster setup for video:", videoElement); | |
} catch (error) { | |
if (error.name === 'InvalidStateError') { | |
console.warn("Attempted to create MediaElementSource twice or invalid state for video:", videoElement); | |
// Try to recover or just ensure controls are visible if they exist | |
const existingData = videoDataMap.get(videoElement); | |
if (existingData && existingData.gain && !document.body.contains(existingData.controls)) { | |
// Controls were removed from DOM? Re-add them. | |
const controls = addBoosterControls(videoElement, existingData.gain); | |
videoDataMap.set(videoElement, { ...existingData, controls: controls }); // Update map | |
} else if (existingData && existingData.controls && existingData.controls.classList.contains('hidden')) { | |
// Controls are hidden, leave them hidden. | |
} else if (existingData && !existingData.controls) { | |
// Data exists but no controls? Try adding them. | |
const controls = addBoosterControls(videoElement, existingData.gain); | |
videoDataMap.set(videoElement, { ...existingData, controls: controls }); | |
} | |
} else { | |
console.error("Failed to setup audio processing for video:", videoElement, error); | |
} | |
} | |
} | |
function addBoosterControls(videoElement, gainNode) { | |
// 이미 컨트롤러가 있는지 확인 (ID 기반) | |
const videoId = videoElement.dataset.boosterId || `vid-${Math.random().toString(36).substr(2, 9)}`; | |
videoElement.dataset.boosterId = videoId; // ID 설정 또는 재확인 | |
let container = document.querySelector(`.sound-booster-controls[data-video-id="${videoId}"]`); | |
if (container) { | |
// 이미 존재하면 숨김 해제하고 반환 (재생성 방지) | |
container.classList.remove('hidden'); | |
return container; | |
} | |
container = document.createElement('div'); | |
container.className = 'sound-booster-controls'; | |
container.dataset.videoId = videoId; // ID 설정 | |
const label = document.createElement('label'); | |
label.textContent = '🔊 Boost:'; | |
const slider = document.createElement('input'); | |
slider.type = 'range'; | |
slider.min = 0; | |
slider.max = MAX_GAIN; | |
slider.step = 0.1; | |
slider.title = `Double-click to reset to ${DEFAULT_GAIN * 100}%`; // 툴팁 추가 | |
const valueDisplay = document.createElement('span'); | |
valueDisplay.className = 'boost-value'; // 클래스 추가 | |
// --- [x] 버튼 추가 --- | |
const closeButton = document.createElement('span'); | |
closeButton.className = 'sound-booster-close-btn'; | |
closeButton.textContent = 'x'; // '[x]' 대신 'x' 로 변경 (더 깔끔) | |
closeButton.title = 'Hide controls'; // 툴팁 추가 | |
closeButton.addEventListener('click', (event) => { | |
event.stopPropagation(); // 부모(컨테이너)의 드래그 이벤트 방지 | |
container.classList.add('hidden'); // display: none 스타일 적용 | |
// 또는 container.style.display = 'none'; 직접 설정 | |
console.log('Booster controls hidden for video:', videoElement); | |
}); | |
// --- [x] 버튼 추가 끝 --- | |
const savedGainKey = `boostGain_${window.location.hostname}_${videoElement.src || videoElement.currentSrc || 'local'}`; | |
let currentGain = DEFAULT_GAIN; | |
if (REMEMBER_GAIN) { | |
const savedValue = GM_getValue(savedGainKey, DEFAULT_GAIN); | |
currentGain = Math.min(parseFloat(savedValue), MAX_GAIN); | |
} | |
slider.value = currentGain; | |
gainNode.gain.value = currentGain; | |
valueDisplay.textContent = `${Math.round(currentGain * 100)}%`; | |
slider.addEventListener('input', () => { | |
const gainValue = parseFloat(slider.value); | |
gainNode.gain.value = gainValue; | |
valueDisplay.textContent = `${Math.round(gainValue * 100)}%`; | |
if (REMEMBER_GAIN) { | |
GM_setValue(savedGainKey, gainValue); | |
} | |
const context = getAudioContext(); | |
if (context && context.state === 'suspended') { | |
context.resume(); | |
} | |
}); | |
slider.addEventListener('dblclick', () => { | |
slider.value = DEFAULT_GAIN; | |
gainNode.gain.value = DEFAULT_GAIN; | |
valueDisplay.textContent = `${Math.round(DEFAULT_GAIN * 100)}%`; | |
if (REMEMBER_GAIN) { | |
GM_setValue(savedGainKey, DEFAULT_GAIN); | |
} | |
}); | |
container.appendChild(label); | |
container.appendChild(slider); | |
container.appendChild(valueDisplay); | |
container.appendChild(closeButton); // 생성한 닫기 버튼 추가 | |
let parent = videoElement.parentElement; | |
if (parent) { | |
const parentStyle = window.getComputedStyle(parent); | |
if (parentStyle.position === 'static') { | |
parent.classList.add('video-container-relative'); | |
} | |
parent.appendChild(container); | |
makeDraggable(container); | |
} else { | |
console.warn("Could not find parent element for video, appending controls to body."); | |
document.body.appendChild(container); | |
container.style.position = 'fixed'; | |
makeDraggable(container); | |
} | |
return container; // 생성된 컨테이너 반환 | |
} | |
function makeDraggable(element) { | |
let offsetX, offsetY, isDragging = false; | |
element.addEventListener('mousedown', (e) => { | |
// 슬라이더나 닫기 버튼 클릭 시 드래그 시작 안 함 | |
if (e.target.type === 'range' || e.target.classList.contains('sound-booster-close-btn')) { | |
return; | |
} | |
isDragging = true; | |
const currentPositionStyle = window.getComputedStyle(element).position; | |
const rect = element.getBoundingClientRect(); | |
if (currentPositionStyle === 'absolute') { | |
const parentRect = element.offsetParent.getBoundingClientRect(); | |
offsetX = e.clientX - rect.left + parentRect.left; | |
offsetY = e.clientY - rect.top + parentRect.top; | |
} else { // fixed or other | |
offsetX = e.clientX - rect.left; | |
offsetY = e.clientY - rect.top; | |
} | |
element.style.cursor = 'grabbing'; | |
// 드래그 중 텍스트 선택 방지 (body에 임시 적용) | |
document.body.style.userSelect = 'none'; | |
}); | |
document.addEventListener('mousemove', (e) => { | |
if (!isDragging) return; | |
e.preventDefault(); // 드래그 중 기본 동작 방지 (텍스트 선택 등) | |
let x = e.clientX - offsetX; | |
let y = e.clientY - offsetY; | |
// absolute 위치 조정 (부모 기준) | |
const currentPositionStyle = window.getComputedStyle(element).position; | |
if (currentPositionStyle === 'absolute') { | |
const parentRect = element.offsetParent.getBoundingClientRect(); | |
x = e.clientX - parentRect.left - offsetX + element.offsetParent.scrollLeft; | |
y = e.clientY - parentRect.top - offsetY + element.offsetParent.scrollTop; | |
} | |
element.style.left = `${x}px`; | |
element.style.top = `${y}px`; | |
element.style.right = 'auto'; | |
element.style.bottom = 'auto'; | |
}); | |
document.addEventListener('mouseup', (e) => { | |
if (isDragging) { | |
isDragging = false; | |
element.style.cursor = 'move'; | |
// 드래그 종료 시 텍스트 선택 방지 해제 | |
document.body.style.userSelect = ''; | |
} | |
}); | |
} | |
function initialScan() { | |
const videos = document.querySelectorAll('video'); | |
videos.forEach(video => { | |
if (video.readyState > 0) { | |
setupAudioProcessing(video); | |
} else { | |
video.addEventListener('loadedmetadata', () => setupAudioProcessing(video), { once: true }); | |
} | |
video.addEventListener('play', () => { | |
// Play 이벤트 발생 시 AudioContext 상태 체크 및 재개 시도 | |
const context = getAudioContext(); | |
if (context && context.state === 'suspended') { | |
context.resume(); | |
} | |
// 오디오 처리 설정 함수 호출 (이미 되어있으면 내부에서 건너뜀) | |
setupAudioProcessing(video); | |
}, { once: true }); // 한번만 실행되도록 설정할 수도 있지만, 비디오 소스가 바뀌는 경우 다시 필요할 수 있음 | |
// 필요하다면 play 이벤트 리스너를 once 없이 사용하고, setupAudioProcessing 내부에서 중복 호출 방지 강화 | |
}); | |
} | |
const observer = new MutationObserver((mutationsList) => { | |
for (const mutation of mutationsList) { | |
if (mutation.type === 'childList') { | |
mutation.addedNodes.forEach(node => { | |
if (node.nodeType === 1) { // Only process element nodes | |
// Directly added video | |
if (node.nodeName === 'VIDEO') { | |
handleVideoElement(node); | |
} | |
// Video inside an added container | |
else if (node.querySelectorAll) { | |
const videos = node.querySelectorAll('video'); | |
videos.forEach(handleVideoElement); | |
} | |
} | |
}); | |
} | |
} | |
}); | |
function handleVideoElement(video) { | |
// 비디오가 실제로 DOM에 연결되어 있는지 확인 (간혹 메모리 내 노드일 수 있음) | |
if (!document.body.contains(video)) { | |
return; | |
} | |
// 이미 처리 중이거나 처리된 비디오 건너뛰기 (dataset 활용) | |
if (video.dataset.audioBoosted === 'true' || video.dataset.processing === 'true') { | |
return; | |
} | |
video.dataset.processing = 'true'; // 처리 시작 표시 | |
const setup = () => { | |
setupAudioProcessing(video); | |
delete video.dataset.processing; // 처리 완료 후 표시 제거 | |
}; | |
if (video.readyState > 0) { | |
setup(); | |
} else { | |
video.addEventListener('loadedmetadata', setup, { once: true }); | |
// Timeout fallback in case loadedmetadata doesn't fire reliably | |
setTimeout(() => { | |
if (!video.dataset.audioBoosted && video.dataset.processing === 'true') { | |
console.warn("Fallback setup for video:", video); | |
setup(); | |
} | |
}, 2000); // 2초 후에도 안되면 시도 | |
} | |
// Play 이벤트 핸들러 추가 (위 initialScan과 유사) | |
video.addEventListener('play', () => { | |
const context = getAudioContext(); | |
if (context && context.state === 'suspended') { | |
context.resume(); | |
} | |
setupAudioProcessing(video); // 중복 호출 방지는 내부에서 처리 | |
}, { once: false }); // 동적 로드되는 경우 play가 여러번 발생 가능하므로 once 제거 고려 | |
} | |
observer.observe(document.body, { childList: true, subtree: true }); | |
// 초기 스캔 지연 실행 | |
setTimeout(initialScan, 500); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment