Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions src/content/inject.js
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ class VideoSpeedExtension {
// Remove all controllers from tracked media elements
const videos = window.VSC.stateManager ? window.VSC.stateManager.getAllMediaElements() : [];
for (const video of videos) {
this.actionHandler?.resetVolumeBoost(video);
if (video.vsc) {
video.vsc.remove();
}
Expand Down Expand Up @@ -364,14 +365,26 @@ class VideoSpeedExtension {
(function () {
const extension = new VideoSpeedExtension();

function getPopupTargetMedia() {
const trackedMedia = window.VSC.stateManager
? window.VSC.stateManager.getAllMediaElements()
: [];
const discoveredMedia = Array.from(document.querySelectorAll('video, audio'));

if (trackedMedia.length === 0) {
return discoveredMedia;
}

return Array.from(new Set([...trackedMedia, ...discoveredMedia]));
}

// Lifecycle commands from bridge (popup, background, storage changes)
document.documentElement.addEventListener('VSC_MESSAGE', (event) => {
const message = event.detail;

// Handle namespaced VSC message types
if (typeof message === 'object' && message.type && message.type.startsWith('VSC_')) {
// Use state manager for complete media element discovery (includes shadow DOM)
const videos = window.VSC.stateManager ? window.VSC.stateManager.getAllMediaElements() : [];
const videos = getPopupTargetMedia();

switch (message.type) {
case window.VSC.Constants.MESSAGE_TYPES.SET_SPEED:
Expand Down Expand Up @@ -425,6 +438,66 @@ class VideoSpeedExtension {
window.VSC.logger?.debug(`Reset speed on ${videos.length} media elements`);
break;

case window.VSC.Constants.MESSAGE_TYPES.SET_VOLUME:
if (message.payload && typeof message.payload.level === 'number') {
const { MIN, MAX } = window.VSC.Constants.VOLUME_LIMITS;
const targetLevel = Math.min(Math.max(message.payload.level, MIN), MAX);
videos.forEach((video) => {
if (extension.actionHandler) {
extension.actionHandler.setVolumeLevel(video, targetLevel);
} else {
video.volume = Math.min(targetLevel, 1);
}
});

window.VSC.logger?.debug(
`Set volume to ${targetLevel} on ${videos.length} media elements`
);
}
break;

case window.VSC.Constants.MESSAGE_TYPES.ADJUST_VOLUME:
if (message.payload && typeof message.payload.delta === 'number') {
const delta = message.payload.delta;
videos.forEach((video) => {
if (extension.actionHandler) {
extension.actionHandler.setVolumeLevel(
video,
extension.actionHandler.getVolumeLevel(video) + delta
);
} else {
video.volume = Math.min(Math.max(video.volume + delta, 0), 1);
}
});

window.VSC.logger?.debug(
`Adjusted volume by ${delta} on ${videos.length} media elements`
);
}
break;

case window.VSC.Constants.MESSAGE_TYPES.GET_VOLUME_STATE: {
const primaryMedia = videos[0] || null;
const payload = extension.actionHandler
? extension.actionHandler.getVolumeState(primaryMedia)
: {
hasMedia: false,
level: 1,
percent: 100,
maxLevel: window.VSC.Constants.VOLUME_LIMITS.MAX,
};

document.documentElement.dispatchEvent(
new CustomEvent('VSC_MESSAGE_RESPONSE', {
detail: {
requestId: message.requestId,
payload,
},
})
);
break;
}

case window.VSC.Constants.MESSAGE_TYPES.TOGGLE_DISPLAY:
if (extension.actionHandler) {
extension.actionHandler.runAction('display', null, null);
Expand Down
181 changes: 179 additions & 2 deletions src/core/action-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class ActionHandler {
constructor(config, eventManager) {
this.config = config;
this.eventManager = eventManager;
this.audioContext = null;
}

/**
Expand Down Expand Up @@ -254,7 +255,8 @@ class ActionHandler {
* @param {number} value - Amount to increase
*/
volumeUp(video, value) {
video.volume = Math.min(1, (video.volume + value).toFixed(2));
this.setVolumeLevel(video, this.getVolumeLevel(video) + value);
this.showVolumeFeedback(video);
}

/**
Expand All @@ -263,7 +265,182 @@ class ActionHandler {
* @param {number} value - Amount to decrease
*/
volumeDown(video, value) {
video.volume = Math.max(0, (video.volume - value).toFixed(2));
this.setVolumeLevel(video, this.getVolumeLevel(video) - value);
this.showVolumeFeedback(video);
}

/**
* Show temporary volume percentage feedback in the controller indicator.
* @param {HTMLMediaElement} video - Media element
*/
showVolumeFeedback(video) {
const controller = video?.vsc?.div;
const feedbackIndicator = video?.vsc?.feedbackIndicator;
if (!controller || !feedbackIndicator) {
return;
}

const volumePercent = Math.round(this.getVolumeLevel(video) * 100);
feedbackIndicator.textContent = `${volumePercent}%`;

if (controller?.volumeFeedbackTimer) {
clearTimeout(controller.volumeFeedbackTimer);
}

controller?.classList.add('vsc-volume-feedback');
controller?.classList.add('vsc-feedback-show');

controller.volumeFeedbackTimer = setTimeout(() => {
controller?.classList.remove('vsc-volume-feedback');
controller?.classList.remove('vsc-feedback-show');
controller.volumeFeedbackTimer = undefined;
}, 1200);
}

/**
* Return the current effective volume level for a media element.
* Above 1.0 uses a GainNode-backed boost chain.
* @param {HTMLMediaElement} video - Media element
* @returns {number} Current volume level
*/
getVolumeLevel(video) {
const state = this.getVolumeStateRecord(video);
if (typeof state.level === 'number') {
return state.level;
}
return Number((video.volume ?? 1).toFixed(2));
}

/**
* Get popup-friendly volume state for a media element.
* @param {HTMLMediaElement|null} video - Media element
* @returns {Object} Volume info
*/
getVolumeState(video) {
const fallback = {
hasMedia: false,
level: 1,
percent: 100,
maxLevel: window.VSC.Constants.VOLUME_LIMITS.MAX,
};

if (!video) {
return fallback;
}

const level = this.getVolumeLevel(video);
return {
hasMedia: true,
level: Number(level.toFixed(2)),
percent: Math.round(level * 100),
maxLevel: window.VSC.Constants.VOLUME_LIMITS.MAX,
};
}

/**
* Set effective volume, allowing boosted output above the native 1.0 cap.
* @param {HTMLMediaElement} video - Media element
* @param {number} level - Target effective volume
* @returns {number} Applied volume level
*/
setVolumeLevel(video, level) {
if (!video) {
return 1;
}

const { MIN, MAX } = window.VSC.Constants.VOLUME_LIMITS;
const targetLevel = Number(Math.min(Math.max(level, MIN), MAX).toFixed(2));
const state = this.getVolumeStateRecord(video);
state.level = targetLevel;

if (targetLevel <= 1 || !this.ensureBoostChain(video, state)) {
if (state.gainNode) {
state.gainNode.gain.value = 1;
}
video.volume = targetLevel;
return targetLevel;
}

video.volume = 1;
state.gainNode.gain.value = targetLevel;
return targetLevel;
}

/**
* Reset any active volume boost when the extension is torn down.
* @param {HTMLMediaElement} video - Media element
*/
resetVolumeBoost(video) {
if (!video || !video._vscVolumeState) {
return;
}

const state = video._vscVolumeState;
const normalizedLevel = Math.min(this.getVolumeLevel(video), 1);
state.level = normalizedLevel;

if (state.gainNode) {
state.gainNode.gain.value = 1;
}

video.volume = normalizedLevel;
}

/**
* Get or initialize per-media volume state.
* @param {HTMLMediaElement} video - Media element
* @returns {Object} Mutable state record
* @private
*/
getVolumeStateRecord(video) {
if (!video._vscVolumeState) {
video._vscVolumeState = {
level: Number((video.volume ?? 1).toFixed(2)),
sourceNode: null,
gainNode: null,
};
}

return video._vscVolumeState;
}

/**
* Lazily create the Web Audio boost chain for a media element.
* @param {HTMLMediaElement} video - Media element
* @param {Object} state - Volume state record
* @returns {boolean} True when boost is available
* @private
*/
ensureBoostChain(video, state) {
if (state.gainNode) {
return true;
}

const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
if (!AudioContextCtor) {
return false;
}

if (!this.audioContext) {
this.audioContext = new AudioContextCtor();
}

try {
if (this.audioContext.state === 'suspended') {
this.audioContext.resume?.().catch(() => {});
}

state.sourceNode = this.audioContext.createMediaElementSource(video);
state.gainNode = this.audioContext.createGain();
state.sourceNode.connect(state.gainNode);
state.gainNode.connect(this.audioContext.destination);
return true;
} catch (error) {
window.VSC.logger.warn(`Unable to initialize boosted volume: ${error.message}`);
state.sourceNode = null;
state.gainNode = null;
return false;
}
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/core/video-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ class VideoController {

// Store speed indicator reference
this.speedIndicator = window.VSC.ShadowDOMManager.getSpeedIndicator(shadow);
this.feedbackIndicator = window.VSC.ShadowDOMManager.getFeedbackIndicator(shadow);

// Insert into DOM FIRST — position calculation needs the wrapper in the DOM
this.insertIntoDOM(document, wrapper);
Expand All @@ -184,6 +185,11 @@ class VideoController {
const innerController = window.VSC.ShadowDOMManager.getController(shadow);
innerController.style.top = position.top;
innerController.style.left = position.left;

if (this.feedbackIndicator) {
this.feedbackIndicator.style.top = `calc(${position.top} + 14px)`;
this.feedbackIndicator.style.left = `calc(${position.left} + 14px)`;
}
}

window.VSC.logger.debug('initializeControls End');
Expand Down
56 changes: 54 additions & 2 deletions src/entries/content-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import { matchSiteRule } from '../utils/site-pattern.js';
// Duplicated from constants.js (ISOLATED world can't import page modules).
const SPEED_MIN = 0.07;
const SPEED_MAX = 16;

Comment thread
GalSasson6 marked this conversation as resolved.
const docEl = document.documentElement;
let bridgeInitialized = false;

Expand Down Expand Up @@ -110,7 +109,60 @@ async function init() {
});

// --- Ongoing: popup/background message relay ---
chrome.runtime.onMessage.addListener((request) => {
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (request?.type === 'VSC_GET_VOLUME_STATE') {
const requestId = `vsc-volume-${Date.now()}-${Math.random().toString(36).slice(2)}`;
let settled = false;

const cleanup = () => {
docEl.removeEventListener('VSC_MESSAGE_RESPONSE', handleResponse);
clearTimeout(timeoutId);
};

const handleResponse = (event) => {
if (settled || event.detail?.requestId !== requestId) {
return;
}

settled = true;
cleanup();
sendResponse(
event.detail.payload || {
hasMedia: false,
level: 1,
percent: 100,
maxLevel: 4,
}
);
};

const timeoutId = setTimeout(() => {
if (settled) {
return;
}

settled = true;
cleanup();
sendResponse({
hasMedia: false,
level: 1,
percent: 100,
maxLevel: 4,
});
}, 500);

docEl.addEventListener('VSC_MESSAGE_RESPONSE', handleResponse);
docEl.dispatchEvent(
new CustomEvent('VSC_MESSAGE', {
detail: {
...request,
requestId,
},
})
);
return true;
}

docEl.dispatchEvent(new CustomEvent('VSC_MESSAGE', { detail: request }));
});

Expand Down
Loading