Compare commits

..

41 Commits

Author SHA1 Message Date
joshpatra de582ae157 v5.2.6.0-beta.1 2026-04-11 13:13:14 -04:00
joshpatra eb64de6ea3 Bump version to 5.2.6.0 2026-04-11 13:13:13 -04:00
joshpatra eab6d10a19 refactor: Better explanations, feat: made show/hide controller shortcut optional 2026-04-11 13:13:05 -04:00
joshpatra 76f6bd1a1e v5.2.5.0-beta.1 2026-04-11 13:01:55 -04:00
joshpatra 29a9a1d07f Bump version to 5.2.5.0 2026-04-11 13:01:54 -04:00
joshpatra 4d37b4f570 feat(options): site-rule shortcut picker, chevrons, defaults and site-rule help copy
fix(inject,popup): isSpeederActiveForSite whitelist/blacklist; tests; clear activeSiteRule on no match
2026-04-11 12:59:46 -04:00
joshpatra 9d41e6ceeb v5.2.3-beta.1 2026-04-10 13:18:18 -04:00
joshpatra eaffab1f27 v5.2.2.0-beta.1 2026-04-09 16:39:28 -04:00
joshpatra 3f5983685f v5.2.0-beta.1 2026-04-07 15:38:54 -04:00
joshpatra ab7ae99807 v5.1.9.0-beta.1 2026-04-07 14:54:34 -04:00
joshpatra a2b041e225 v5.1.8.0-beta.1 2026-04-07 14:33:07 -04:00
joshpatra 25d3acf576 v5.1.7.0-beta.1 2026-04-02 22:34:00 -04:00
joshpatra 8b9e4bea1d fix: refresh site rules on DOM video changes 2026-04-02 21:16:37 -04:00
joshpatra 8c94cc2088 fix: clarify site rule auto-hide copy 2026-04-02 21:08:15 -04:00
joshpatra 19d3af02a2 refactor: store settings as sparse diffs 2026-04-02 21:07:31 -04:00
joshpatra 306e0e3ea0 Exclude Lucide cache from backups 2026-04-02 20:47:32 -04:00
joshpatra 1536c13c3e v5.1.6-beta.1 2026-04-02 18:20:49 -04:00
joshpatra 939ee08466 v5.1.4-beta.1 2026-04-02 14:17:18 -04:00
joshpatra fad0c49e65 v5.1.3-beta.1 2026-04-02 13:56:22 -04:00
joshpatra 76a7b933bb v5.1.2-beta.1 2026-04-02 13:52:04 -04:00
joshpatra edd997037a v5.1.1-beta.1 2026-04-02 13:11:47 -04:00
joshpatra e34ec17f33 v5.1.0-beta.1 2026-04-02 12:53:10 -04:00
joshpatra 53f66f1eeb v5.0.4-beta.1 2026-04-01 16:31:49 -04:00
joshpatra 3275d1f322 v5.0.2-beta.1 2026-04-01 16:24:24 -04:00
joshpatra 8eb3901121 v5.0.2-beta.1 2026-04-01 15:32:37 -04:00
joshpatra 6bf48fa479 v5.0.1-beta.1 2026-04-01 11:28:47 -04:00
joshpatra 3a583ce3b8 v5.0.1-beta.1 2026-04-01 11:19:06 -04:00
joshpatra 5c20d7ae0b v5.0.0-beta.1 2026-03-31 15:03:56 -04:00
joshpatra b0498cd1d7 Merge dev 2026-03-31 15:01:17 -04:00
joshpatra 85215ae54b Merge main: update beta install instructions 2026-03-31 14:53:41 -04:00
joshpatra 9f8c235cea Merge dev: update beta install instructions 2026-03-31 14:53:41 -04:00
joshpatra 597c7a8af5 Merge main: fix beta signing via AMO unlisted 2026-03-31 14:48:47 -04:00
joshpatra d215ad4224 Merge dev: fix beta signing via AMO unlisted 2026-03-31 14:48:46 -04:00
joshpatra df18e638c2 Merge main: fix XPI rename glob 2026-03-31 14:44:46 -04:00
joshpatra 9ff3ce4f74 Merge dev: fix XPI rename glob 2026-03-31 14:44:39 -04:00
joshpatra 91dc47f5a8 Merge main: restructure CI/CD workflow 2026-03-31 14:39:11 -04:00
joshpatra 36a60bbe34 Merge dev: restructure CI/CD workflow 2026-03-31 14:39:04 -04:00
joshpatra 3bcb36f6d0 Merge branch 'dev' into beta 2026-03-31 14:22:24 -04:00
joshpatra 2747c15924 Test beta prerelease 2026-03-31 14:18:49 -04:00
joshpatra 5e7d5d6d21 Merge branch 'dev' into beta 2026-03-31 14:18:32 -04:00
joshpatra 38174a2a76 Test beta prerelease 2026-03-31 14:15:17 -04:00
12 changed files with 1133 additions and 106 deletions
+14 -7
View File
@@ -969,8 +969,8 @@ function ensureController(node, parent) {
}
// href selects site rules; re-run on every new/usable media so margins/opacity match current URL.
var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) {
applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
return null;
}
refreshAllControllerGeometry();
@@ -2016,6 +2016,7 @@ function defineVideoController() {
function applySiteRuleOverrides() {
resetSettingsFromSiteRuleBase();
tc.activeSiteRule = null;
if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) {
return false;
@@ -2024,7 +2025,9 @@ function applySiteRuleOverrides() {
var currentUrl = location.href;
var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
if (!matchedRule) return false;
if (!matchedRule) {
return false;
}
tc.activeSiteRule = matchedRule;
log(`Matched site rule: ${matchedRule.pattern}`, 4);
@@ -2104,8 +2107,10 @@ function refreshAllControllerGeometry() {
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */
function reapplySiteRulesAndControllerGeometry() {
var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) return;
applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
return;
}
refreshAllControllerGeometry();
}
@@ -2453,8 +2458,10 @@ function attachNavigationListeners() {
function initializeNow(doc, forceReinit = false) {
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return;
var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) return;
applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
return;
}
if (!doc.body.classList.contains("vsc-initialized")) {
doc.body.classList.add("vsc-initialized");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
"version": "5.2.3",
"version": "5.2.6.0",
"manifest_version": 2,
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
"homepage_url": "https://github.com/SoPat712/speeder",
+55 -4
View File
@@ -343,11 +343,40 @@ label em {
font-weight: 500;
}
.shortcut-label em {
display: block;
margin-top: 4px;
color: var(--muted);
font-style: normal;
font-weight: 400;
}
.customKey,
.customValue {
text-align: center;
}
/* Chevron: native menu indicator is often missing with themed controls */
#addShortcutSelector,
.site-add-shortcut-selector {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-color: var(--panel);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%234b5563' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px 16px;
padding-right: 38px;
cursor: pointer;
}
#addShortcutSelector:disabled,
.site-add-shortcut-selector:disabled {
cursor: not-allowed;
opacity: 0.72;
}
#addShortcutSelector {
width: min(220px, 100%);
margin-top: 12px;
@@ -489,7 +518,7 @@ label em {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
align-items: start;
font-weight: 600;
margin-bottom: 8px;
cursor: pointer;
@@ -502,6 +531,11 @@ label em {
.site-override-lead span {
margin: 0;
font-weight: 600;
}
.site-override-lead span em {
font-weight: 400;
}
.site-rule-override-section .site-override-fields,
@@ -935,6 +969,10 @@ button.lucide-result-tile.lucide-picked {
color: var(--text);
}
.site-rule-split-label span em {
font-weight: 400;
}
.site-rule-split-label input[type="checkbox"] {
justify-self: end;
margin-top: 0;
@@ -969,16 +1007,22 @@ button.lucide-result-tile.lucide-picked {
}
.site-shortcuts-container .shortcut-row {
grid-template-columns: minmax(0, 1fr) 110px 110px minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) 110px 110px minmax(0, 1fr) 38px;
padding: 8px 0;
border-top: 1px solid var(--border);
}
.site-shortcuts-container .shortcut-row:first-child {
.site-shortcuts-rows .shortcut-row:first-child {
padding-top: 0;
border-top: 0;
}
.site-add-shortcut-selector {
width: min(220px, 100%);
align-self: flex-start;
margin-top: 0;
}
.force-label {
display: flex;
align-items: center;
@@ -1120,7 +1164,8 @@ button.lucide-result-tile.lucide-picked {
}
.action-row button,
#addShortcutSelector {
#addShortcutSelector,
.site-add-shortcut-selector {
width: 100%;
}
@@ -1204,6 +1249,12 @@ button.lucide-result-tile.lucide-picked {
background: rgba(255, 255, 255, 0.04);
}
#addShortcutSelector,
.site-add-shortcut-selector {
background-color: var(--panel);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
}
input[type="text"]:focus,
select:focus,
textarea:focus {
+211 -34
View File
@@ -96,7 +96,11 @@
<section id="customs" class="settings-card">
<div class="section-heading">
<h3>Shortcuts</h3>
<p class="section-intro">Backspace clears a shortcut. Escape disables it.</p>
<p class="section-intro">
Backspace clears a key. Escape disables optional shortcuts. If a site
steals a shortcut, use a site rule with Override shortcuts (and
per-key blocking) for that URL.
</p>
</div>
<div class="shortcuts-grid">
<div class="shortcut-row" id="display" data-action="display">
@@ -132,7 +136,10 @@
/>
</div>
<div class="shortcut-row" id="slower" data-action="slower">
<div class="shortcut-label">Decrease speed</div>
<div class="shortcut-label">
Decrease speed
<em>Required: Speeder needs a key for this action.</em>
</div>
<input
class="customKey"
type="text"
@@ -146,7 +153,10 @@
/>
</div>
<div class="shortcut-row" id="faster" data-action="faster">
<div class="shortcut-label">Increase speed</div>
<div class="shortcut-label">
Increase speed
<em>Required: Speeder needs a key for this action.</em>
</div>
<input
class="customKey"
type="text"
@@ -253,11 +263,22 @@
<h4 class="defaults-sub-heading">General</h4>
<div class="row row-checkbox">
<label for="enabled">Enable</label>
<label for="enabled"
>Enable<br />
<em
>On: site rules can block sites (blacklist). Off: only matched rules keep Speeder on (whitelist).</em
>
</label>
<input id="enabled" type="checkbox" />
</div>
<div class="row row-checkbox">
<label for="audioBoolean">Work on audio</label>
<label for="audioBoolean"
>Work on audio<br />
<em
>Also controls plain HTML5 audio (not just video). Turn off if
you only want Speeder on video players.</em
>
</label>
<input id="audioBoolean" type="checkbox" />
</div>
@@ -265,7 +286,14 @@
<h4 class="defaults-sub-heading">Playback</h4>
<div class="row row-checkbox">
<label for="rememberSpeed">Remember playback speed</label>
<label for="rememberSpeed"
>Remember playback speed<br />
<em
>Stores speed per source so revisiting the same media can restore
it. Separate from &ldquo;Force last saved speed,&rdquo; which
fights players that reset rate.</em
>
</label>
<input id="rememberSpeed" type="checkbox" />
</div>
<div class="row row-checkbox">
@@ -283,11 +311,23 @@
<h4 class="defaults-sub-heading">Controller</h4>
<div class="row row-checkbox">
<label for="startHidden">Hide controller by default</label>
<label for="startHidden"
>Hide controller by default<br />
<em
>Starts with the overlay hidden; use shortcuts (show/hide,
move) or site behavior to reveal it.</em
>
</label>
<input id="startHidden" type="checkbox" />
</div>
<div class="row">
<label for="controllerLocation">Default controller location</label>
<label for="controllerLocation"
>Default controller location<br />
<em
>Corner or edge anchor for the hover bar. Site rules can override
this for specific URLs.</em
>
</label>
<select id="controllerLocation">
<option value="top-left">Top left</option>
<option value="top-center">Top center</option>
@@ -300,7 +340,13 @@
</select>
</div>
<div class="row">
<label for="controllerOpacity">Controller opacity</label>
<label for="controllerOpacity"
>Controller opacity<br />
<em
>0&ndash;1 (decimals). Lower is more transparent. Applies to the
in-page controller only.</em
>
</label>
<input id="controllerOpacity" type="text" value="" />
</div>
<div class="row row-controller-margin">
@@ -416,11 +462,23 @@
</p>
</div>
<div class="row row-checkbox">
<label for="showPopupControlBar">Show popup control bar</label>
<label for="showPopupControlBar"
>Show popup control bar<br />
<em
>Shows buttons in the extension popup (toolbar icon). Can be
overridden per site in site rules.</em
>
</label>
<input id="showPopupControlBar" type="checkbox" />
</div>
<div class="row row-checkbox">
<label for="popupMatchHoverControls">Match hover controls</label>
<label for="popupMatchHoverControls"
>Match hover controls<br />
<em
>When on, the popup copies the hover bar&rsquo;s buttons and
order. When off, customize the popup layout below.</em
>
</label>
<input id="popupMatchHoverControls" type="checkbox" />
</div>
<div id="popupCbEditorWrap" class="cb-editor cb-editor-disabled">
@@ -568,19 +626,36 @@
<div class="site-rule-body">
<div class="site-rule-option site-rule-option-checkbox">
<label class="site-rule-split-label">
<span>Enable Speeder on this site</span>
<span
>Enable Speeder on this site<br /><em
>For this URL pattern only: off blocks when global Speeder
is on (blacklist); on allows when global is off
(whitelist)&mdash;same pairing as Defaults &rarr;
Enable.</em
></span
>
<input type="checkbox" class="site-enabled" />
</label>
</div>
<div class="site-rule-content">
<div class="site-rule-override-section">
<label class="site-override-lead">
<span>Override placement for this site</span>
<span
>Override placement for this site<br /><em
>When on, location and margin below replace general
defaults for matching URLs.</em
></span
>
<input type="checkbox" class="override-placement" />
</label>
<div class="site-placement-container">
<div class="site-rule-option site-rule-option-field">
<label>Default controller location:</label>
<label
>Default controller location:<br /><em
>Corner or edge anchor for the hover bar. Replaces the
general default for matching URLs only.</em
></label
>
<select class="site-controllerLocation">
<option value="top-left">Top left</option>
<option value="top-center">Top center</option>
@@ -595,7 +670,8 @@
<div class="site-rule-option site-rule-margin-option">
<label
>Controller margin (px):<br /><em
>Shifts the whole control. 0&ndash;200.</em
>Shifts the whole control from its preset position (CSS
margins). Top and bottom. 0&ndash;200.</em
></label
>
<div class="controller-margin-inputs">
@@ -613,85 +689,163 @@
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span>Override hide-by-default for this site</span>
<span
>Override hide-by-default for this site<br /><em
>When on, the hide-by-default toggle below replaces the
general default for matching URLs.</em
></span
>
<input type="checkbox" class="override-visibility" />
</label>
<div class="site-visibility-container">
<div class="site-rule-option site-rule-option-checkbox">
<label>Hide controller by default:</label>
<label
>Hide controller by default:<br /><em
>Starts with the overlay hidden; use shortcuts
(show/hide, move) or site behavior to reveal it.</em
></label
>
<input type="checkbox" class="site-startHidden" />
</div>
</div>
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span>Override auto-hide for this site</span>
<span
>Override auto-hide for this site<br /><em
>When on, hide-with-controls and timer below replace
general defaults for matching URLs.</em
></span
>
<input type="checkbox" class="override-autohide" />
</label>
<div class="site-autohide-container">
<div class="site-rule-option site-rule-option-checkbox">
<label class="site-rule-split-label">
<span>Hide with controls (idle-based)</span>
<span
>Hide with controls (idle-based)<br /><em
>Fade the controller in and out with the video
interface: perfect sync on YouTube, idle-based
elsewhere.</em
></span
>
<input type="checkbox" class="site-hideWithControls" />
</label>
</div>
<div class="site-rule-option site-rule-option-field">
<label>Auto-hide timer (0.1&ndash;15s):</label>
<label
>Auto-hide timer (0.1&ndash;15s):<br /><em
>Seconds of inactivity before hiding: 0.1&ndash;15 for
non-YouTube sites.</em
></label
>
<input type="text" class="site-hideWithControlsTimer" />
</div>
</div>
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span>Override playback for this site</span>
<span
>Override playback for this site<br /><em
>When on, remember speed / force / audio below replace
general defaults for matching URLs.</em
></span
>
<input type="checkbox" class="override-playback" />
</label>
<div class="site-playback-container">
<div class="site-rule-option site-rule-option-checkbox">
<label>Remember playback speed:</label>
<label
>Remember playback speed:<br /><em
>Stores speed per source so revisiting the same media
can restore it. Separate from &ldquo;Force last saved
speed,&rdquo; which fights players that reset
rate.</em
></label
>
<input type="checkbox" class="site-rememberSpeed" />
</div>
<div class="site-rule-option site-rule-option-checkbox">
<label>Force last saved speed:</label>
<label
>Force last saved speed:<br /><em
>Useful when a video player tries to override the speed
you set in Speeder.</em
></label
>
<input type="checkbox" class="site-forceLastSavedSpeed" />
</div>
<div class="site-rule-option site-rule-option-checkbox">
<label>Work on audio:</label>
<label
>Work on audio:<br /><em
>Also controls plain HTML5 audio (not just video). Turn
off if you only want Speeder on video players.</em
></label
>
<input type="checkbox" class="site-audioBoolean" />
</div>
</div>
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span>Override opacity for this site</span>
<span
>Override opacity for this site<br /><em
>When on, opacity below replaces the general default for
matching URLs.</em
></span
>
<input type="checkbox" class="override-opacity" />
</label>
<div class="site-opacity-container">
<div class="site-rule-option site-rule-option-field">
<label>Controller opacity:</label>
<label
>Controller opacity:<br /><em
>0&ndash;1 (decimals). Lower is more transparent.
Applies to the in-page controller only.</em
></label
>
<input type="text" class="site-controllerOpacity" />
</div>
</div>
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span>Override subtitle nudge for this site</span>
<span
>Override subtitle nudge for this site<br /><em
>When on, nudge options below replace general defaults for
matching URLs.</em
></span
>
<input type="checkbox" class="override-subtitleNudge" />
</label>
<div class="site-subtitleNudge-container">
<div class="site-rule-option site-rule-option-checkbox">
<label>Enable subtitle nudge:</label>
<label
>Enable subtitle nudge:<br /><em
>Makes tiny playback changes to help keep subtitles
aligned.</em
></label
>
<input type="checkbox" class="site-enableSubtitleNudge" />
</div>
<div class="site-rule-option site-rule-option-field">
<label>Nudge interval (10&ndash;1000ms):</label>
<label
>Nudge interval (10&ndash;1000ms):<br /><em
>How often to nudge: 10&ndash;1000. Smaller values are
more frequent. Default: 50.</em
></label
>
<input type="text" class="site-subtitleNudgeInterval" placeholder="50" />
</div>
</div>
</div>
<div class="site-rule-controlbar">
<label class="site-override-lead">
<span>Override in-player control bar for this site</span>
<span
>Override in-player control bar for this site<br /><em
>Same idea as Hover control bar: drag blocks between
Active and Available for matching URLs only.</em
></span
>
<input type="checkbox" class="override-controlbar" />
</label>
<div class="site-controlbar-container">
@@ -709,12 +863,22 @@
</div>
<div class="site-rule-controlbar">
<label class="site-override-lead">
<span>Override extension popup for this site</span>
<span
>Override extension popup for this site<br /><em
>Popup layout for matching URLs; mirrors the global Popup
control bar when you customize it here.</em
></span
>
<input type="checkbox" class="override-popup-controlbar" />
</label>
<div class="site-popup-controlbar-container">
<div class="site-rule-option site-rule-option-checkbox">
<label>Show popup control bar</label>
<label
>Show popup control bar<br /><em
>Shows buttons in the extension popup (toolbar icon).
Replaces the general default for this pattern.</em
></label
>
<input type="checkbox" class="site-showPopupControlBar" />
</div>
<div class="cb-editor">
@@ -731,10 +895,23 @@
</div>
<div class="site-rule-shortcuts">
<label class="site-override-lead">
<span>Override shortcuts for this site</span>
<span
>Override shortcuts for this site<br /><em
>Add shortcuts from the menu; none by default. Leave off
to use global Shortcuts.</em
></span
>
<input type="checkbox" class="override-shortcuts" />
</label>
<div class="site-shortcuts-container"></div>
<div class="site-shortcuts-container">
<div class="site-shortcuts-rows"></div>
<select
class="site-add-shortcut-selector"
aria-label="Add shortcut for this site"
>
<option value="">Add shortcut&hellip;</option>
</select>
</div>
</div>
</div>
</div>
+108 -59
View File
@@ -233,7 +233,7 @@ const actionLabels = {
};
const speedBindingActions = ["slower", "faster", "fast", "softer", "louder"];
const requiredShortcutActions = new Set(["display", "slower", "faster"]);
const requiredShortcutActions = new Set(["slower", "faster"]);
function formatSpeedBindingDisplay(action, value) {
if (!speedBindingActions.includes(action)) {
@@ -319,6 +319,70 @@ function refreshAddShortcutSelector() {
}
}
function refreshSiteRuleAddShortcutSelector(ruleEl) {
if (!ruleEl) return;
var selector = ruleEl.querySelector(".site-add-shortcut-selector");
if (!selector) return;
while (selector.options.length > 1) {
selector.remove(1);
}
var usedActions = new Set();
ruleEl.querySelectorAll(".site-shortcuts-rows .shortcut-row.customs").forEach(function (row) {
var action = row.dataset.action;
if (action) usedActions.add(action);
});
Object.keys(actionLabels).forEach(function (action) {
if (!usedActions.has(action)) {
var option = document.createElement("option");
option.value = action;
option.textContent = actionLabels[action];
selector.appendChild(option);
}
});
var overrideShortcutsOn =
ruleEl.querySelector(".override-shortcuts") &&
ruleEl.querySelector(".override-shortcuts").checked;
if (selector.options.length === 1) {
selector.disabled = true;
selector.options[0].text = "All shortcuts added";
} else {
selector.disabled = !overrideShortcutsOn;
selector.options[0].text = "Add shortcut\u2026";
}
}
function getGlobalBindingSnapshotForSiteShortcut(action) {
var row = document.querySelector(
'#customs .shortcut-row[data-action="' + action + '"]'
);
if (row) {
var keyInput = row.querySelector(".customKey");
var binding = normalizeStoredBinding(keyInput && keyInput.vscBinding);
if (binding) {
var valueInput = row.querySelector(".customValue");
var value = customActionsNoValues.includes(action)
? 0
: Number(valueInput && valueInput.value);
return { binding: binding, value: value };
}
}
var def = tcDefaults.keyBindings.find(function (b) {
return b.action === action;
});
if (def) {
return {
binding: normalizeStoredBinding(def),
value: def.value
};
}
return { binding: null, value: undefined };
}
function ensureDefaultBinding(storage, action, code, value) {
if (storage.keyBindings.some((item) => item.action === action)) return;
@@ -942,35 +1006,18 @@ function ensureAllDefaultBindings(storage) {
});
}
function addSiteRuleShortcut(container, action, binding, value, force) {
function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
if (!rowsEl) return;
var div = document.createElement("div");
div.setAttribute("class", "shortcut-row customs");
div.dataset.action = action;
var actionLabel = document.createElement("div");
actionLabel.className = "shortcut-label";
var actionLabels = {
display: "Show/hide controller",
move: "Move controller",
slower: "Decrease speed",
faster: "Increase speed",
rewind: "Rewind",
advance: "Advance",
reset: "Reset speed",
fast: "Preferred speed",
toggleSubtitleNudge: "Toggle subtitle nudge",
pause: "Play / Pause",
muted: "Mute / Unmute",
louder: "Increase volume",
softer: "Decrease volume",
mark: "Set marker",
jump: "Jump to marker"
};
var actionLabelText = actionLabels[action] || action;
if (action === "toggleSubtitleNudge") {
// Check if the site rule is for YouTube.
// We look up the pattern from the site rule element this container belongs to.
var ruleEl = container.closest(".site-rule");
var ruleEl = rowsEl.closest(".site-rule");
var pattern = ruleEl ? ruleEl.querySelector(".site-pattern").value : "";
if (!pattern.toLowerCase().includes("youtube.com")) {
actionLabelText += " (only for YouTube embeds)";
@@ -1014,12 +1061,18 @@ function addSiteRuleShortcut(container, action, binding, value, force) {
forceLabel.appendChild(forceCheckbox);
forceLabel.appendChild(forceText);
var removeButton = document.createElement("button");
removeButton.className = "removeParent";
removeButton.type = "button";
removeButton.textContent = "\u00d7";
div.appendChild(actionLabel);
div.appendChild(keyInput);
div.appendChild(valueInput);
div.appendChild(forceLabel);
div.appendChild(removeButton);
container.appendChild(div);
rowsEl.appendChild(div);
}
function createSiteRule(rule) {
@@ -1157,56 +1210,24 @@ function createSiteRule(rule) {
rule && Array.isArray(rule.shortcuts) && rule.shortcuts.length > 0
);
ruleEl.querySelector(".override-shortcuts").checked = hasShortcutOverride;
var container = ruleEl.querySelector(".site-shortcuts-container");
var rowsEl = ruleEl.querySelector(".site-shortcuts-rows");
if (hasShortcutOverride) {
rule.shortcuts.forEach((shortcut) => {
addSiteRuleShortcut(
container,
rowsEl,
shortcut.action,
shortcut,
shortcut.value,
shortcut.force
);
});
} else {
populateDefaultSiteShortcuts(container);
}
applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container");
refreshSiteRuleAddShortcutSelector(ruleEl);
document.getElementById("siteRulesContainer").appendChild(ruleEl);
}
function populateDefaultSiteShortcuts(container) {
var bindings = [];
document.querySelectorAll("#customs .shortcut-row").forEach((row) => {
var action = row.dataset.action;
if (!action) return;
var keyInput = row.querySelector(".customKey");
var binding = normalizeStoredBinding(keyInput && keyInput.vscBinding);
if (!binding) return;
var valueInput = row.querySelector(".customValue");
bindings.push({
action: action,
code: binding.code,
disabled: binding.disabled === true,
value: customActionsNoValues.includes(action)
? 0
: Number(valueInput && valueInput.value),
force: false
});
});
if (bindings.length === 0) {
bindings = tcDefaults.keyBindings.slice();
}
bindings.forEach((binding) => {
addSiteRuleShortcut(container, binding.action, binding, binding.value, false);
});
}
function createControlBarBlock(buttonId) {
var def = controllerButtonDefs[buttonId];
if (!def) return null;
@@ -1780,8 +1801,13 @@ document.addEventListener("DOMContentLoaded", function () {
var removeParentButton = targetEl.closest(".removeParent");
if (removeParentButton) {
removeParentButton.parentNode.remove();
var removedRow = removeParentButton.parentNode;
var siteRuleForShortcut = removedRow.closest(".site-rule");
removedRow.remove();
refreshAddShortcutSelector();
if (siteRuleForShortcut) {
refreshSiteRuleAddShortcutSelector(siteRuleForShortcut);
}
return;
}
var removeSiteRuleButton = targetEl.closest(".remove-site-rule");
@@ -1808,6 +1834,26 @@ document.addEventListener("DOMContentLoaded", function () {
}
}
if (event.target.classList.contains("site-add-shortcut-selector")) {
var action = event.target.value;
if (!action) return;
var siteRuleRoot = event.target.closest(".site-rule");
var rows = siteRuleRoot && siteRuleRoot.querySelector(".site-shortcuts-rows");
if (rows) {
var snap = getGlobalBindingSnapshotForSiteShortcut(action);
addSiteRuleShortcut(
rows,
action,
snap.binding,
snap.value,
false
);
refreshSiteRuleAddShortcutSelector(siteRuleRoot);
}
event.target.value = "";
return;
}
// Site rule: show/hide optional override sections
var siteOverrideContainers = {
"override-placement": "site-placement-container",
@@ -1829,6 +1875,9 @@ document.addEventListener("DOMContentLoaded", function () {
if (targetBox) {
setSiteOverrideContainerState(targetBox, event.target.checked);
}
if (ocb === "override-shortcuts") {
refreshSiteRuleAddShortcutSelector(siteRuleRoot);
}
return;
}
}
+4 -1
View File
@@ -279,7 +279,10 @@ document.addEventListener("DOMContentLoaded", function () {
var url = context && context.url ? context.url : "";
var siteRule = matchSiteRule(url, storage.siteRules);
var siteDisabled = isSiteRuleDisabled(siteRule);
var siteAvailable = storage.enabled !== false && !siteDisabled;
var siteAvailable = siteRuleUtils.isSpeederActiveForSite(
storage.enabled,
siteRule
);
var showBar = storage.showPopupControlBar !== false;
if (siteRule && siteRule.showPopupControlBar !== undefined) {
+644
View File
@@ -0,0 +1,644 @@
(function (global) {
"use strict";
var SITE_RULES_DIFF_FORMAT = "defaults-diff-v1";
var DEFAULT_BUTTONS = ["rewind", "slower", "faster", "advance", "display"];
var SITE_RULE_OVERRIDE_KEYS = [
"controllerLocation",
"controllerMarginTop",
"controllerMarginBottom",
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"controllerOpacity",
"enableSubtitleNudge",
"subtitleNudgeInterval",
"controllerButtons",
"showPopupControlBar",
"popupControllerButtons",
"shortcuts",
"preferredSpeed"
];
var DIFFABLE_OPTION_KEYS = [
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"enabled",
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"controllerLocation",
"controllerOpacity",
"controllerMarginTop",
"controllerMarginBottom",
"keyBindings",
"siteRules",
"siteRulesMeta",
"siteRulesFormat",
"controllerButtons",
"showPopupControlBar",
"popupMatchHoverControls",
"popupControllerButtons",
"enableSubtitleNudge",
"subtitleNudgeInterval",
"subtitleNudgeAmount"
];
var MANAGED_SYNC_KEYS = DIFFABLE_OPTION_KEYS.concat([
"hideWithYouTubeControls"
]);
var DEFAULT_SETTINGS = {
speed: 1.0,
lastSpeed: 1.0,
displayKeyCode: 86,
rememberSpeed: false,
audioBoolean: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
hideWithControlsTimer: 2.0,
controllerLocation: "top-left",
forceLastSavedSpeed: false,
enabled: true,
controllerOpacity: 0.3,
controllerMarginTop: 0,
controllerMarginRight: 0,
controllerMarginBottom: 65,
controllerMarginLeft: 0,
keyBindings: [
{
action: "display",
key: "V",
keyCode: 86,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "move",
key: "P",
keyCode: 80,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "slower",
key: "S",
keyCode: 83,
code: null,
disabled: false,
value: 0.1,
force: false,
predefined: true
},
{
action: "faster",
key: "D",
keyCode: 68,
code: null,
disabled: false,
value: 0.1,
force: false,
predefined: true
},
{
action: "rewind",
key: "Z",
keyCode: 90,
code: null,
disabled: false,
value: 10,
force: false,
predefined: true
},
{
action: "advance",
key: "X",
keyCode: 88,
code: null,
disabled: false,
value: 10,
force: false,
predefined: true
},
{
action: "reset",
key: "R",
keyCode: 82,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "fast",
key: "G",
keyCode: 71,
code: null,
disabled: false,
value: 1.8,
force: false,
predefined: true
},
{
action: "toggleSubtitleNudge",
key: "N",
keyCode: 78,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
}
],
siteRules: [
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/",
enabled: true,
enableSubtitleNudge: true,
subtitleNudgeInterval: 50
},
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
enabled: true,
rememberSpeed: true,
controllerMarginTop: 60,
controllerMarginBottom: 85
}
],
controllerButtons: DEFAULT_BUTTONS.slice(),
showPopupControlBar: true,
popupMatchHoverControls: true,
popupControllerButtons: DEFAULT_BUTTONS.slice(),
enableSubtitleNudge: false,
subtitleNudgeInterval: 50,
subtitleNudgeAmount: 0.001
};
function clonePlainData(value) {
if (value === undefined) {
return undefined;
}
return JSON.parse(JSON.stringify(value));
}
function hasOwn(obj, key) {
return Boolean(obj) && Object.prototype.hasOwnProperty.call(obj, key);
}
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function sortComparableValue(value) {
if (Array.isArray(value)) {
return value.map(sortComparableValue);
}
if (isPlainObject(value)) {
var sorted = {};
Object.keys(value)
.sort()
.forEach(function (key) {
if (value[key] === undefined) {
return;
}
sorted[key] = sortComparableValue(value[key]);
});
return sorted;
}
return value;
}
function areComparableValuesEqual(a, b) {
return (
JSON.stringify(sortComparableValue(a)) ===
JSON.stringify(sortComparableValue(b))
);
}
function deepMergeDefaults(defaults, overrides) {
if (Array.isArray(defaults)) {
return Array.isArray(overrides)
? clonePlainData(overrides)
: clonePlainData(defaults);
}
if (isPlainObject(defaults)) {
var result = clonePlainData(defaults) || {};
if (!isPlainObject(overrides)) {
return result;
}
Object.keys(overrides).forEach(function (key) {
if (overrides[key] === undefined) {
return;
}
if (hasOwn(defaults, key)) {
result[key] = deepMergeDefaults(defaults[key], overrides[key]);
} else {
result[key] = clonePlainData(overrides[key]);
}
});
return result;
}
return overrides === undefined
? clonePlainData(defaults)
: clonePlainData(overrides);
}
function deepDiff(current, defaults) {
if (current === undefined) {
return undefined;
}
if (Array.isArray(current)) {
return areComparableValuesEqual(current, defaults)
? undefined
: clonePlainData(current);
}
if (isPlainObject(current)) {
var result = {};
Object.keys(current).forEach(function (key) {
var diff = deepDiff(current[key], defaults && defaults[key]);
if (diff !== undefined) {
result[key] = diff;
}
});
return Object.keys(result).length > 0 ? result : undefined;
}
return areComparableValuesEqual(current, defaults)
? undefined
: clonePlainData(current);
}
function getDefaultSiteRules() {
return clonePlainData(DEFAULT_SETTINGS.siteRules) || [];
}
function getDefaultSiteRulesByPattern() {
var map = Object.create(null);
getDefaultSiteRules().forEach(function (rule) {
if (!rule || typeof rule.pattern !== "string" || !rule.pattern) {
return;
}
map[rule.pattern] = rule;
});
return map;
}
function normalizeSiteRuleForDiff(rule, baseSettings) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return null;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (!pattern) {
return null;
}
var normalized = { pattern: pattern };
var baseEnabled = hasOwn(baseSettings, "enabled")
? Boolean(baseSettings.enabled)
: true;
var ruleEnabled = hasOwn(rule, "enabled")
? Boolean(rule.enabled)
: hasOwn(rule, "disableExtension")
? !Boolean(rule.disableExtension)
: baseEnabled;
if (!areComparableValuesEqual(ruleEnabled, baseEnabled)) {
normalized.enabled = ruleEnabled;
}
SITE_RULE_OVERRIDE_KEYS.forEach(function (key) {
var baseValue = clonePlainData(baseSettings[key]);
var effectiveValue = hasOwn(rule, key)
? clonePlainData(rule[key])
: baseValue;
if (!areComparableValuesEqual(effectiveValue, baseValue)) {
normalized[key] = effectiveValue;
}
});
Object.keys(rule).forEach(function (key) {
if (
key === "pattern" ||
key === "enabled" ||
key === "disableExtension" ||
SITE_RULE_OVERRIDE_KEYS.indexOf(key) !== -1 ||
rule[key] === undefined
) {
return;
}
normalized[key] = clonePlainData(rule[key]);
});
return normalized;
}
function compressSiteRules(siteRules, baseSettings) {
if (!Array.isArray(siteRules)) {
return {};
}
var defaultRules = getDefaultSiteRules();
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
var currentPatterns = new Set();
var exportRules = [];
siteRules.forEach(function (rule) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (pattern) {
currentPatterns.add(pattern);
}
var normalizedRule = normalizeSiteRuleForDiff(rule, baseSettings);
if (!normalizedRule || Object.keys(normalizedRule).length === 1) {
return;
}
var defaultRule = pattern ? defaultRulesByPattern[pattern] : null;
var normalizedDefaultRule = defaultRule
? normalizeSiteRuleForDiff(defaultRule, baseSettings)
: null;
if (normalizedDefaultRule) {
if (areComparableValuesEqual(normalizedRule, normalizedDefaultRule)) {
return;
}
var defaultRuleDiff = deepDiff(normalizedRule, normalizedDefaultRule);
if (defaultRuleDiff && Object.keys(defaultRuleDiff).length > 0) {
defaultRuleDiff.pattern = pattern;
exportRules.push(defaultRuleDiff);
}
return;
}
exportRules.push(normalizedRule);
});
var removedDefaultPatterns = defaultRules
.map(function (rule) {
return rule && typeof rule.pattern === "string" ? rule.pattern : "";
})
.filter(function (pattern) {
return pattern && !currentPatterns.has(pattern);
});
var result = {};
if (exportRules.length > 0) {
result.siteRules = exportRules;
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
}
if (removedDefaultPatterns.length > 0) {
result.siteRulesMeta = {
removedDefaultPatterns: removedDefaultPatterns
};
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
}
return result;
}
function expandSiteRules(siteRules, siteRulesMeta) {
var defaultRules = getDefaultSiteRules();
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
if (defaultRules.length === 0) {
return Array.isArray(siteRules) ? clonePlainData(siteRules) : [];
}
var removedDefaultPatterns = new Set(
siteRulesMeta && Array.isArray(siteRulesMeta.removedDefaultPatterns)
? siteRulesMeta.removedDefaultPatterns
: []
);
var modifiedDefaultRules = Object.create(null);
var customRules = [];
if (Array.isArray(siteRules)) {
siteRules.forEach(function (rule) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (
pattern &&
Object.prototype.hasOwnProperty.call(defaultRulesByPattern, pattern)
) {
modifiedDefaultRules[pattern] = clonePlainData(rule);
return;
}
customRules.push(clonePlainData(rule));
});
}
var mergedRules = [];
defaultRules.forEach(function (rule) {
var pattern = rule && typeof rule.pattern === "string" ? rule.pattern : "";
if (!pattern || removedDefaultPatterns.has(pattern)) {
return;
}
if (modifiedDefaultRules[pattern]) {
mergedRules.push(
Object.assign(
{},
clonePlainData(rule),
clonePlainData(modifiedDefaultRules[pattern])
)
);
return;
}
mergedRules.push(clonePlainData(rule));
});
customRules.forEach(function (rule) {
mergedRules.push(rule);
});
return mergedRules;
}
function buildStoredSettingsDiff(currentSettings) {
var defaults = clonePlainData(DEFAULT_SETTINGS);
var normalized = deepMergeDefaults(defaults, currentSettings || {});
var siteRuleData = compressSiteRules(normalized.siteRules, normalized);
var diffDefaults = {};
var diff = {};
delete normalized.siteRules;
delete normalized.siteRulesMeta;
delete normalized.siteRulesFormat;
delete normalized.hideWithYouTubeControls;
if (siteRuleData.siteRules) {
normalized.siteRules = siteRuleData.siteRules;
}
if (siteRuleData.siteRulesMeta) {
normalized.siteRulesMeta = siteRuleData.siteRulesMeta;
}
if (siteRuleData.siteRulesFormat) {
normalized.siteRulesFormat = siteRuleData.siteRulesFormat;
}
DIFFABLE_OPTION_KEYS.forEach(function (key) {
if (hasOwn(DEFAULT_SETTINGS, key)) {
diffDefaults[key] = clonePlainData(DEFAULT_SETTINGS[key]);
}
if (!hasOwn(normalized, key)) {
return;
}
var valueDiff = deepDiff(normalized[key], diffDefaults[key]);
if (valueDiff !== undefined) {
diff[key] = valueDiff;
}
});
return diff;
}
function expandStoredSettings(storage) {
var raw = clonePlainData(storage) || {};
var expanded = deepMergeDefaults(DEFAULT_SETTINGS, raw);
if (
!hasOwn(raw, "hideWithControls") &&
hasOwn(raw, "hideWithYouTubeControls")
) {
expanded.hideWithControls = Boolean(raw.hideWithYouTubeControls);
}
expanded.hideWithYouTubeControls = expanded.hideWithControls;
if (raw.siteRulesFormat === SITE_RULES_DIFF_FORMAT) {
expanded.siteRules = expandSiteRules(raw.siteRules, raw.siteRulesMeta);
} else if (Array.isArray(raw.siteRules)) {
expanded.siteRules = clonePlainData(raw.siteRules);
} else {
expanded.siteRules = getDefaultSiteRules();
}
return expanded;
}
function escapeStringRegExp(str) {
var matcher = /[|\\{}()[\]^$+*?.]/g;
return String(str).replace(matcher, "\\$&");
}
function siteRuleMatchesUrl(rule, currentUrl) {
if (!rule || !rule.pattern || !currentUrl) {
return false;
}
var pattern = String(rule.pattern).trim();
if (!pattern) {
return false;
}
var regex;
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
try {
var lastSlash = pattern.lastIndexOf("/");
regex = new RegExp(
pattern.substring(1, lastSlash),
pattern.substring(lastSlash + 1)
);
} catch (_error) {
return false;
}
} else {
regex = new RegExp(escapeStringRegExp(pattern));
}
return Boolean(regex && regex.test(currentUrl));
}
function mergeMatchingSiteRules(currentUrl, siteRules) {
if (!currentUrl || !Array.isArray(siteRules)) {
return null;
}
var matchedRules = [];
for (var i = 0; i < siteRules.length; i++) {
if (siteRuleMatchesUrl(siteRules[i], currentUrl)) {
matchedRules.push(siteRules[i]);
}
}
if (!matchedRules.length) {
return null;
}
var mergedRule = {};
matchedRules.forEach(function (rule) {
Object.keys(rule).forEach(function (key) {
var value = rule[key];
if (Array.isArray(value)) {
mergedRule[key] = clonePlainData(value);
return;
}
if (isPlainObject(value)) {
mergedRule[key] = clonePlainData(value);
return;
}
mergedRule[key] = value;
});
});
return mergedRule;
}
function isSiteRuleDisabled(rule) {
return Boolean(
rule &&
(
rule.enabled === false ||
(typeof rule.enabled === "undefined" && rule.disableExtension === true)
)
);
}
global.vscClonePlainData = clonePlainData;
global.vscAreComparableValuesEqual = areComparableValuesEqual;
global.vscDeepMergeDefaults = deepMergeDefaults;
global.vscBuildStoredSettingsDiff = buildStoredSettingsDiff;
global.vscExpandStoredSettings = expandStoredSettings;
global.vscGetSettingsDefaults = function () {
return clonePlainData(DEFAULT_SETTINGS);
};
global.vscGetManagedSyncKeys = function () {
return MANAGED_SYNC_KEYS.slice();
};
global.vscGetSiteRulesDiffFormat = function () {
return SITE_RULES_DIFF_FORMAT;
};
global.vscMatchSiteRule = mergeMatchingSiteRules;
global.vscSiteRuleMatchesUrl = siteRuleMatchesUrl;
global.vscIsSiteRuleDisabled = isSiteRuleDisabled;
})(typeof globalThis !== "undefined" ? globalThis : this);
+18
View File
@@ -60,10 +60,28 @@
);
}
/**
* Whether Speeder should run on this URL given global enabled and the matched rule (if any).
* - No rule: follows global (enabled unless explicitly false).
* - Rule with site "off" / disableExtension: always inactive (blacklist).
* - Rule with site "on": active even when global is off (whitelist).
*/
function isSpeederActiveForSite(globalEnabled, siteRule) {
var globalOn = globalEnabled !== false;
if (!siteRule) {
return globalOn;
}
if (isSiteRuleDisabled(siteRule)) {
return false;
}
return true;
}
return {
compileSiteRulePattern: compileSiteRulePattern,
escapeStringRegExp: escapeStringRegExp,
isSiteRuleDisabled: isSiteRuleDisabled,
isSpeederActiveForSite: isSpeederActiveForSite,
matchSiteRule: matchSiteRule
};
});
+22
View File
@@ -51,6 +51,28 @@ async function bootInject({ sync = {}, local = {} } = {}) {
}
describe("inject runtime", () => {
it("treats a matching site rule with site enabled as active when global enable is off", async () => {
await bootInject({
sync: {
enabled: false,
siteRules: [{ pattern: "example.org", enabled: true }]
}
});
expect(window.tc.settings.enabled).toBe(false);
window.captureSiteRuleBase();
window.applySiteRuleOverrides();
expect(window.tc.activeSiteRule).toEqual(
expect.objectContaining({ pattern: "example.org", enabled: true })
);
expect(
window.SpeederShared.siteRules.isSpeederActiveForSite(
window.tc.settings.enabled,
window.tc.activeSiteRule
)
).toBe(true);
});
it("keeps subtitle nudge disabled when the effective setting is off", async () => {
await bootInject({
sync: {
+26
View File
@@ -96,6 +96,32 @@ describe("options page", () => {
expect(toggle.getAttribute("aria-label")).toBe("Collapse site rule");
});
it("site rule shortcut override shows no rows by default and adds via selector", async () => {
await setupOptions({ sync: { siteRules: [] } });
globalThis.createSiteRule({ pattern: "example.com" });
const rule = document.getElementById("siteRulesContainer").lastElementChild;
const rows = rule.querySelector(".site-shortcuts-rows");
const selector = rule.querySelector(".site-add-shortcut-selector");
expect(rows.querySelectorAll(".shortcut-row").length).toBe(0);
expect(selector).not.toBeNull();
expect(selector.disabled).toBe(true);
rule.querySelector(".override-shortcuts").checked = true;
rule.querySelector(".override-shortcuts").dispatchEvent(
new Event("change", { bubbles: true })
);
expect(selector.disabled).toBe(false);
expect(selector.options.length).toBeGreaterThan(1);
selector.value = "pause";
selector.dispatchEvent(new Event("change", { bubbles: true }));
expect(rows.querySelectorAll('.shortcut-row[data-action="pause"]').length).toBe(1);
});
it("keeps site override settings visible but disabled until enabled", async () => {
await setupOptions({ sync: { siteRules: [] } });
+16
View File
@@ -36,6 +36,22 @@ describe("popup UI", () => {
).toBeGreaterThan(0);
});
it("shows controls when globally disabled but a whitelist site rule matches", async () => {
await setupPopup({
sync: {
enabled: false,
siteRules: [{ pattern: "example.com", enabled: true }]
}
});
expect(document.getElementById("status").classList.contains("hide")).toBe(
true
);
expect(document.getElementById("popupControlBar").style.display).not.toBe(
"none"
);
});
it("shows disabled state for a matching site rule", async () => {
await setupPopup({
sync: {
+14
View File
@@ -24,6 +24,20 @@ describe("shared helpers", () => {
expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true);
});
it("combines global enabled with matched site rules (whitelist / blacklist)", () => {
const allowSite = { pattern: "good.test", enabled: true };
const blockSite = { pattern: "bad.test", enabled: false };
expect(siteRules.isSpeederActiveForSite(true, null)).toBe(true);
expect(siteRules.isSpeederActiveForSite(false, null)).toBe(false);
expect(siteRules.isSpeederActiveForSite(true, blockSite)).toBe(false);
expect(siteRules.isSpeederActiveForSite(false, blockSite)).toBe(false);
expect(siteRules.isSpeederActiveForSite(true, allowSite)).toBe(true);
expect(siteRules.isSpeederActiveForSite(false, allowSite)).toBe(true);
});
it("sanitizes and resolves popup button orders", () => {
const controllerButtonDefs = {
rewind: {},