Skip to content

Instantly share code, notes, and snippets.

@lunamoth
Last active April 8, 2025 16:33
Show Gist options
  • Save lunamoth/6cdda6c2b6bb450a1d4e44812529d69a to your computer and use it in GitHub Desktop.
Save lunamoth/6cdda6c2b6bb450a1d4e44812529d69a to your computer and use it in GitHub Desktop.
// ==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