Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
22 changes: 22 additions & 0 deletions src/content/inject.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ class VideoSpeedExtension {

this.injectControllerCSS();
this.setupCSSLiveUpdates();
this.setupControllerExpansionLiveUpdates();
this.siteHandlerManager.initialize(document);

this.eventManager = new this.EventManager(this.config, null);
Expand Down Expand Up @@ -291,6 +292,27 @@ class VideoSpeedExtension {
});
}

/** Live-update expanded controls on every attached controller. */
setupControllerExpansionLiveUpdates() {
document.documentElement.addEventListener('VSC_STORAGE_CHANGED', (event) => {
const changes = event.detail;
if (!Object.prototype.hasOwnProperty.call(changes || {}, 'keepControlsExpanded')) {
return;
}

const expanded = changes.keepControlsExpanded?.newValue === true;
this.config.settings.keepControlsExpanded = expanded;

const mediaElements = window.VSC.stateManager?.getAllMediaElements() || [];
for (const media of mediaElements) {
const shadow = media.vsc?.div?.shadowRoot;
if (shadow) {
window.VSC.ShadowDOMManager.setControlsExpanded(shadow, expanded);
}
}
});
}

/**
* Set up observers for DOM changes and video detection
*/
Expand Down
1 change: 1 addition & 0 deletions src/core/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ if (!window.VSC.VideoSpeedConfig) {
this.settings.exclusiveKeys = Boolean(storage.exclusiveKeys);
this.settings.audioBoolean = Boolean(storage.audioBoolean);
this.settings.startHidden = Boolean(storage.startHidden);
this.settings.keepControlsExpanded = Boolean(storage.keepControlsExpanded);
this.settings.controllerOpacity = Number(storage.controllerOpacity);
this.settings.controllerButtonSize = Number(storage.controllerButtonSize);
// One-time migration: drop legacy controllerCSS key, reset to new model.
Expand Down
1 change: 1 addition & 0 deletions src/core/video-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ class VideoController {
speed: speed,
opacity: this.config.settings.controllerOpacity,
buttonSize: this.config.settings.controllerButtonSize,
keepControlsExpanded: this.config.settings.keepControlsExpanded,
});

// Set up control events
Expand Down
10 changes: 10 additions & 0 deletions src/ui/options/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ <h3>Preferences</h3>
>
<input id="startHidden" type="checkbox" />
</div>
<div class="row">
<label for="keepControlsExpanded"
>Keep controls expanded<br />
<em
>Always show the rewind, speed, and advance buttons instead of revealing them only on
hover.</em
></label
>
<input id="keepControlsExpanded" type="checkbox" />
</div>
<div class="row">
<label for="exclusiveKeys"
>Exclusive keyboard shortcuts<br />
Expand Down
3 changes: 3 additions & 0 deletions src/ui/options/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,7 @@ async function save_options() {
const exclusiveKeys = document.getElementById('exclusiveKeys').checked;
const audioBoolean = document.getElementById('audioBoolean').checked;
const startHidden = document.getElementById('startHidden').checked;
const keepControlsExpanded = document.getElementById('keepControlsExpanded').checked;
const controllerOpacity = Number(document.getElementById('controllerOpacity').value);
const controllerButtonSize = Number(document.getElementById('controllerButtonSize').value);
const logLevel = parseInt(document.getElementById('logLevel').value);
Expand Down Expand Up @@ -765,6 +766,7 @@ async function save_options() {
exclusiveKeys: exclusiveKeys,
audioBoolean: audioBoolean,
startHidden: startHidden,
keepControlsExpanded: keepControlsExpanded,
controllerOpacity: controllerOpacity,
controllerButtonSize: controllerButtonSize,
logLevel: logLevel,
Expand Down Expand Up @@ -811,6 +813,7 @@ async function restore_options() {
document.getElementById('exclusiveKeys').checked = storage.exclusiveKeys;
document.getElementById('audioBoolean').checked = storage.audioBoolean;
document.getElementById('startHidden').checked = storage.startHidden;
document.getElementById('keepControlsExpanded').checked = storage.keepControlsExpanded;
document.getElementById('controllerOpacity').value = storage.controllerOpacity;
document.getElementById('controllerButtonSize').value = storage.controllerButtonSize;
document.getElementById('logLevel').value = storage.logLevel;
Expand Down
26 changes: 23 additions & 3 deletions src/ui/shadow-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ class ShadowDOMManager {
* @returns {ShadowRoot} Created shadow root
*/
static createShadowDOM(wrapper, options = {}) {
const { top = '0px', left = '0px', speed = '1.00', opacity = 0.3, buttonSize = 14 } = options;
const {
top = '0px',
left = '0px',
speed = '1.00',
opacity = 0.3,
buttonSize = 14,
keepControlsExpanded = false,
} = options;

const shadow = wrapper.attachShadow({ mode: 'open' });

Expand All @@ -25,7 +32,8 @@ class ShadowDOMManager {
font-size: 13px;
}

:host(:hover) #controls {
:host(:hover) #controls,
#controller.vsc-expanded #controls {
display: inline-block;
}

Expand Down Expand Up @@ -72,7 +80,8 @@ class ShadowDOMManager {
opacity: 0.7;
}

#controller:hover>.draggable {
#controller:hover>.draggable,
#controller.vsc-expanded>.draggable {
margin-right: 0.8em;
}

Expand Down Expand Up @@ -183,6 +192,7 @@ class ShadowDOMManager {

controller.appendChild(controls);
shadow.appendChild(controller);
this.setControlsExpanded(shadow, keepControlsExpanded);

window.VSC.logger.debug('Shadow DOM created for video controller');
return shadow;
Expand All @@ -206,6 +216,16 @@ class ShadowDOMManager {
return shadow.querySelector('#controls');
}

/**
* Keep the controller button row expanded without changing controller visibility.
* @param {ShadowRoot} shadow - Shadow root containing the controller
* @param {boolean} expanded - Whether controls should remain expanded
*/
static setControlsExpanded(shadow, expanded) {
const controller = this.getController(shadow);
controller?.classList.toggle('vsc-expanded', Boolean(expanded));
}

/**
* Get draggable speed indicator from shadow DOM
* @param {ShadowRoot} shadow - Shadow root
Expand Down
1 change: 1 addition & 0 deletions src/utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ if (!window.VSC.Constants.DEFAULT_SETTINGS) {
exclusiveKeys: false, // default: false
audioBoolean: true, // default: true (enable audio controller support)
startHidden: false, // default: false
keepControlsExpanded: false, // default: false
controllerOpacity: 0.3, // default: 0.3
controllerButtonSize: 14,
customCSS: '', // user's additional CSS injected alongside the built-in defaults
Expand Down
77 changes: 77 additions & 0 deletions tests/e2e/display-toggle.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,50 @@ async function waitForState(page, expected, context) {
assertState(await getControllerState(page), expected, context);
}

async function getControlsExpansionState(page) {
return page.evaluate(() => {
const host = document.querySelector('vsc-controller');
const controller = host?.shadowRoot?.querySelector('#controller');
const controls = host?.shadowRoot?.querySelector('#controls');
const draggable = host?.shadowRoot?.querySelector('.draggable');
return {
found: !!controller && !!controls && !!draggable,
expanded: !!controller?.classList.contains('vsc-expanded'),
controlsVisible: controls ? getComputedStyle(controls).display !== 'none' : false,
indicatorMarginRight: draggable ? parseFloat(getComputedStyle(draggable).marginRight) : 0,
};
});
}

async function waitForControlsExpansion(page, expected, context) {
await page.waitForFunction(
(expanded) => {
const host = document.querySelector('vsc-controller');
const controller = host?.shadowRoot?.querySelector('#controller');
const controls = host?.shadowRoot?.querySelector('#controls');
if (!controller || !controls) {
return false;
}
return (
controller.classList.contains('vsc-expanded') === expanded &&
(getComputedStyle(controls).display !== 'none') === expanded
);
},
{ timeout: 2000, polling: 50 },
expected
);

const state = await getControlsExpansionState(page);
if (
!state.found ||
state.expanded !== expected ||
state.controlsVisible !== expected ||
(expected && state.indicatorMarginRight <= 0)
) {
throw new Error(`${context}: unexpected expansion state ${JSON.stringify(state)}`);
}
}

async function installControllerCSSForDomain(page, hostname) {
const autohideRuleLoaded = await page.evaluate((domain) => {
const sheet = new CSSStyleSheet();
Expand Down Expand Up @@ -267,6 +311,39 @@ async function testDisplayToggle() {
throw new Error('Controller never appeared');
}

// Keep the pointer outside the controller so computed display reflects the
// preference rather than the existing hover behavior.
await page.mouse.move(1200, 700);
await waitForControlsExpansion(page, false, 'controls collapsed by default');

await page.evaluate(() => {
document.documentElement.dispatchEvent(
new CustomEvent('VSC_STORAGE_CHANGED', {
detail: { keepControlsExpanded: { oldValue: false, newValue: true } },
})
);
});
await waitForControlsExpansion(page, true, 'live preference enables expanded controls');

await page.evaluate(() => {
document
.querySelector('vsc-controller')
?.shadowRoot?.querySelector('button[data-action="advance"]')
?.click();
});
await new Promise((resolve) => setTimeout(resolve, 1200));
await waitForControlsExpansion(page, true, 'expanded controls remain after use and timeout');

await page.evaluate(() => {
document.documentElement.dispatchEvent(
new CustomEvent('VSC_STORAGE_CHANGED', {
detail: { keepControlsExpanded: { oldValue: true, newValue: false } },
})
);
});
await waitForControlsExpansion(page, false, 'live preference restores hover behavior');
console.log(' ✅ Live controls expansion preference');

// The fixture is file://, so install the actual production defaults after
// resolving their domain markers as YouTube. This keeps the matrix on the
// shipped light-DOM selector instead of a test-only approximation.
Expand Down
2 changes: 2 additions & 0 deletions tests/helpers/chrome-mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const mockStorage = {

audioBoolean: false,
startHidden: false,
keepControlsExpanded: false,
controllerOpacity: 0.3,
controllerButtonSize: 14,
blacklist: 'www.instagram.com\nx.com',
Expand Down Expand Up @@ -137,6 +138,7 @@ export function resetMockStorage() {

audioBoolean: false,
startHidden: false,
keepControlsExpanded: false,
controllerOpacity: 0.3,
controllerButtonSize: 14,
blacklist: 'www.instagram.com\nx.com',
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/content/inject.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -379,4 +379,35 @@ describe('Inject', () => {
expect(extension._customSheet).toBeNull();
expect(document.adoptedStyleSheets).toContain(extension._controllerSheet);
});

it('applies keepControlsExpanded changes to existing controllers immediately', async () => {
extension = window.VSC_controller;
await extension.config.load();

const eventManager = new window.VSC.EventManager(extension.config, null);
const actionHandler = new window.VSC.ActionHandler(extension.config, eventManager);
const video = createMockVideo();
mockDOM.container.appendChild(video);
const controller = new window.VSC.VideoController(video, null, extension.config, actionHandler);
const innerController = controller.div.shadowRoot.querySelector('#controller');
extension.setupControllerExpansionLiveUpdates();

document.documentElement.dispatchEvent(
new CustomEvent('VSC_STORAGE_CHANGED', {
detail: { keepControlsExpanded: { newValue: true } },
})
);

expect(extension.config.settings.keepControlsExpanded).toBe(true);
expect(innerController.classList.contains('vsc-expanded')).toBe(true);

document.documentElement.dispatchEvent(
new CustomEvent('VSC_STORAGE_CHANGED', {
detail: { keepControlsExpanded: { newValue: false } },
})
);

expect(extension.config.settings.keepControlsExpanded).toBe(false);
expect(innerController.classList.contains('vsc-expanded')).toBe(false);
});
});
5 changes: 4 additions & 1 deletion tests/unit/core/settings-race-condition.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ describe('SettingsRaceCondition', () => {
rememberSpeed: true,
audioBoolean: true,
startHidden: false,
keepControlsExpanded: true,
controllerOpacity: 0.5,
controllerButtonSize: 16,
logLevel: 4,
Expand All @@ -238,6 +239,7 @@ describe('SettingsRaceCondition', () => {

// But options settings should be saved
expect(storage.rememberSpeed).toBe(true);
expect(storage.keepControlsExpanded).toBe(true);
expect(storage.controllerOpacity).toBe(0.5);
});

Expand All @@ -251,10 +253,11 @@ describe('SettingsRaceCondition', () => {
await config.load();

// Options-page style change in another context
simulateExternalStorageWrite({ controllerOpacity: 0.8 });
simulateExternalStorageWrite({ controllerOpacity: 0.8, keepControlsExpanded: true });

// In-memory should be updated via onChanged
expect(config.settings.controllerOpacity).toBe(0.8);
expect(config.settings.keepControlsExpanded).toBe(true);
});

it('onChanged listener never adopts remote lastSpeed (session isolation, #1559)', async () => {
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/core/video-controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,29 @@ describe('VideoController', () => {
expect(controller.speedIndicator).toBeDefined();
});

it('keeps controls collapsed by default and expands them when configured', async () => {
const config = window.VSC.videoSpeedConfig;
await config.load();
const eventManager = new window.VSC.EventManager(config, null);
const actionHandler = new window.VSC.ActionHandler(config, eventManager);

const collapsedVideo = createMockVideo();
mockDOM.container.appendChild(collapsedVideo);
const collapsed = new window.VSC.VideoController(collapsedVideo, null, config, actionHandler);
expect(
collapsed.div.shadowRoot.querySelector('#controller').classList.contains('vsc-expanded')
).toBe(false);
collapsed.remove();

config.settings.keepControlsExpanded = true;
const expandedVideo = createMockVideo();
mockDOM.container.appendChild(expandedVideo);
const expanded = new window.VSC.VideoController(expandedVideo, null, config, actionHandler);
expect(
expanded.div.shadowRoot.querySelector('#controller').classList.contains('vsc-expanded')
).toBe(true);
});

it('tracks automatic media visibility beneath an explicit override', async () => {
const config = window.VSC.videoSpeedConfig;
await config.load();
Expand Down