Compare commits

..

18 Commits

Author SHA1 Message Date
joshpatra e652850dfa v5.2.7.0-beta.1 2026-04-11 13:20:07 -04:00
joshpatra bfe20a84dd Bump version to 5.2.7.0 2026-04-11 13:20:06 -04:00
joshpatra 46a8a62110 refactor: reordered donate buttons, moved to icons over text 2026-04-11 13:19:57 -04:00
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 6d10a569d9 Bump version to 5.2.3 2026-04-10 13:18:17 -04:00
joshpatra 5f63718d62 fix(export): exclude lucide tags cache from backup payload 2026-04-10 13:17:49 -04:00
joshpatra eaffab1f27 v5.2.2.0-beta.1 2026-04-09 16:39:28 -04:00
joshpatra d01c01a216 Bump version to 5.2.2.0 2026-04-09 16:39:27 -04:00
joshpatra 0b6bc5d0a0 fix: some warnings, refactor: changed the donation look, moved it up 2026-04-09 16:39:13 -04:00
joshpatra 3f5983685f v5.2.0-beta.1 2026-04-07 15:38:54 -04:00
joshpatra 41d89e0993 Bump version to 5.2.0 2026-04-07 15:38:53 -04:00
joshpatra a424dea5ca feat: add optional volume shortcuts and control bar actions 2026-04-07 15:38:03 -04:00
24 changed files with 1217 additions and 202 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
buy_me_a_coffee: treeman183
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+13
View File
@@ -0,0 +1,13 @@
<svg width="241" height="194" viewBox="0 0 241 194" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="kofiSymbolMask" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="0" width="242" height="194">
<path d="M240.469 0.958984H-0.00585938V193.918H240.469V0.958984Z" fill="white"/>
</mask>
<g mask="url(#kofiSymbolMask)">
<path d="M96.1344 193.911C61.1312 193.911 32.6597 178.256 15.9721 149.829C1.19788 124.912 -0.00585938 97.9229 -0.00585938 67.7662C-0.00585938 49.8876 5.37293 34.3215 15.5413 22.7466C24.8861 12.1157 38.1271 5.22907 52.8317 3.35378C70.2858 1.14271 91.9848 0.958984 114.545 0.958984C151.259 0.958984 161.63 1.4088 176.075 2.85328C195.29 4.76026 211.458 11.932 222.824 23.5955C234.368 35.4428 240.469 51.2624 240.469 69.3627V72.9994C240.469 103.885 219.821 129.733 191.046 136.759C188.898 141.827 186.237 146.871 183.089 151.837L183.006 151.964C172.869 167.632 149.042 193.918 103.401 193.918H96.1281L96.1344 193.911Z" fill="white"/>
<path d="M174.568 17.9772C160.927 16.6151 151.38 16.1589 114.552 16.1589C90.908 16.1589 70.9008 16.387 54.7644 18.4334C33.3949 21.164 15.2058 37.5285 15.2058 67.7674C15.2058 98.0066 16.796 121.422 29.0741 142.107C42.9425 165.751 66.1302 178.707 96.1412 178.707H103.414C140.242 178.707 160.25 159.156 170.253 143.698C174.574 136.874 177.754 130.058 179.801 123.234C205.947 120.96 225.27 99.3624 225.27 72.9941V69.3577C225.27 40.9432 206.631 21.164 174.574 17.9772H174.568Z" fill="white"/>
<path d="M15.1975 67.7674C15.1975 37.5285 33.3866 21.164 54.7559 18.4334C70.8987 16.387 90.906 16.1589 114.544 16.1589C151.372 16.1589 160.919 16.6151 174.559 17.9772C206.617 21.1576 225.255 40.937 225.255 69.3577V72.9941C225.255 99.3687 205.932 120.966 179.786 123.234C177.74 130.058 174.559 136.874 170.238 143.698C160.235 159.156 140.228 178.707 103.4 178.707H96.1264C66.1155 178.707 42.9277 165.751 29.0595 142.107C16.7814 121.422 15.1912 98.4563 15.1912 67.7674" fill="#202020"/>
<path d="M32.2469 67.9899C32.2469 97.3168 34.0654 116.184 43.6127 133.689C54.5225 153.924 74.3018 161.653 96.8117 161.653H103.857C133.411 161.653 147.736 147.329 155.693 134.829C159.558 128.462 162.966 121.417 164.784 112.547L166.147 106.864H174.332C192.521 106.864 208.208 92.09 208.208 73.2166V69.8082C208.208 48.6669 195.024 37.5228 172.058 34.7987C159.102 33.6646 151.372 33.2084 114.538 33.2084C89.7602 33.2084 72.0272 33.4364 58.6152 35.4828C39.7483 38.2134 32.2407 48.8951 32.2407 67.9899" fill="white"/>
<path d="M166.158 83.6801C166.158 86.4107 168.204 88.4572 171.841 88.4572C183.435 88.4572 189.802 81.8619 189.802 70.9523C189.802 60.0427 183.435 53.2195 171.841 53.2195C168.204 53.2195 166.158 55.2657 166.158 57.9963V83.6866V83.6801Z" fill="#202020"/>
<path d="M54.5321 82.3198C54.5321 95.732 62.0332 107.326 71.5807 116.424C77.9478 122.562 87.9515 128.93 94.7685 133.022C96.8147 134.157 98.8611 134.841 101.136 134.841C103.866 134.841 106.134 134.157 107.959 133.022C114.782 128.93 124.779 122.562 130.919 116.424C140.694 107.332 148.195 95.7383 148.195 82.3198C148.195 67.7673 137.286 54.8115 121.599 54.8115C112.28 54.8115 105.912 59.5882 101.136 66.1772C96.8147 59.582 90.2259 54.8115 80.9001 54.8115C64.9855 54.8115 54.5256 67.7673 54.5256 82.3198" fill="#FF5A16"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+1 -1
View File
@@ -12,7 +12,7 @@ function exportSettings() {
chrome.storage.local.get(null, function (localStorage) {
const backup = importExportUtils.buildBackupPayload(
storage,
localStorage,
importExportUtils.filterLocalSettingsForExport(localStorage),
new Date()
);
+70 -10
View File
@@ -130,11 +130,14 @@ var controllerButtonDefs = {
display: { label: "", className: "hideButton" },
reset: { label: "\u21BB", className: "" },
fast: { label: "", className: "" },
settings: { label: "", className: "" },
nudge: { label: "", className: "" },
pause: { label: "", className: "" },
muted: { label: "", className: "" },
louder: { label: "", className: "" },
softer: { label: "", className: "" },
mark: { label: "", className: "" },
jump: { label: "", className: "" }
jump: { label: "", className: "" },
settings: { label: "", className: "" }
};
function createDefaultBinding(action, code, value) {
@@ -966,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();
@@ -2013,6 +2016,7 @@ function defineVideoController() {
function applySiteRuleOverrides() {
resetSettingsFromSiteRuleBase();
tc.activeSiteRule = null;
if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) {
return false;
@@ -2021,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);
@@ -2101,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();
}
@@ -2450,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");
@@ -2586,6 +2596,8 @@ function runAction(action, value, e) {
"advance",
"faster",
"slower",
"louder",
"softer",
"reset",
"fast",
"move",
@@ -2708,6 +2720,12 @@ function runAction(action, value, e) {
case "muted":
muted(v);
break;
case "louder":
volumeUp(v, Number.isFinite(numValue) ? numValue : 0.1);
break;
case "softer":
volumeDown(v, Number.isFinite(numValue) ? numValue : 0.1);
break;
case "mark":
setMark(v);
break;
@@ -2772,7 +2790,49 @@ function resetSpeed(v, target, isFastKey = false) {
}
function muted(v) {
v.muted = !v.muted;
var nextMuted = !v.muted;
v.muted = nextMuted;
if (!isOnYouTube()) return;
var ytApi = getYouTubePlayerApi(v);
if (!ytApi) return;
if (nextMuted && typeof ytApi.mute === "function") ytApi.mute();
if (!nextMuted && typeof ytApi.unMute === "function") ytApi.unMute();
}
function getYouTubePlayerApi(video) {
if (!isOnYouTube()) return null;
var playerEl =
(video && video.closest ? video.closest(".html5-video-player") : null) ||
document.getElementById("movie_player") ||
document.querySelector(".html5-video-player");
if (!playerEl) return null;
return playerEl.wrappedJSObject || playerEl;
}
function syncYouTubePlayerVolume(video, volume) {
var ytApi = getYouTubePlayerApi(video);
if (!ytApi || typeof ytApi.setVolume !== "function") return;
ytApi.setVolume(Math.round(volume * 100));
if (volume > 0 && typeof ytApi.unMute === "function") {
ytApi.unMute();
}
}
function setVideoVolume(video, targetVolume) {
var nextVolume = Math.max(0, Math.min(1, Number(targetVolume.toFixed(2))));
video.volume = nextVolume;
if (nextVolume > 0 && video.muted) {
video.muted = false;
}
syncYouTubePlayerVolume(video, nextVolume);
}
function volumeUp(v, value) {
setVideoVolume(v, v.volume + value);
}
function volumeDown(v, value) {
setVideoVolume(v, v.volume - value);
}
function setMark(v) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
"version": "5.1.9.0",
"version": "5.2.7.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",
+146 -10
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;
@@ -1010,17 +1054,85 @@ button.lucide-result-tile.lucide-picked {
display: none;
}
.support-footer {
padding: 16px 20px;
.support-cta {
margin-top: 14px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel-subtle);
}
.support-cta-text {
margin: 0 0 12px;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.support-footer p {
margin: 0;
.support-cta-links {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.support-footer a {
font-weight: 600;
.support-cta-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
text-decoration: none;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.support-cta-link:hover {
background: var(--toggle-open-bg);
border-color: var(--toggle-open-border);
}
.support-cta-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.support-cta-link svg {
width: 24px;
height: 24px;
display: block;
}
.support-cta-link--kofi {
background: #fff4ef;
}
.support-cta-link--kofi:hover {
background: #ffe8de;
}
.support-cta-kofi-img {
display: block;
height: 26px;
width: auto;
}
.support-cta-link--bmc {
color: #0d0c22;
background: #ffdd00;
}
.support-cta-link--bmc:hover {
background: #f7d500;
}
.support-cta-link--bmc svg {
width: 22px;
height: 22px;
display: block;
}
@media (max-width: 720px) {
@@ -1052,7 +1164,8 @@ button.lucide-result-tile.lucide-picked {
}
.action-row button,
#addShortcutSelector {
#addShortcutSelector,
.site-add-shortcut-selector {
width: 100%;
}
@@ -1136,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 {
@@ -1162,4 +1281,21 @@ button.lucide-result-tile.lucide-picked {
filter: brightness(0) invert(1);
opacity: 0.92;
}
.support-cta-link--kofi {
background: #2c241f;
}
.support-cta-link--kofi:hover {
background: #3a312a;
}
.support-cta-link--bmc {
color: #ffdd00;
background: #2a2618;
}
.support-cta-link--bmc:hover {
background: #3d3510;
}
}
+277 -52
View File
@@ -24,13 +24,83 @@
</div>
<div class="version">v<span id="app-version"></span></div>
</div>
<div class="support-cta" role="region" aria-label="Support Speeder">
<p class="support-cta-text">
If Speeder has been useful, please consider supporting its development!
</p>
<div class="support-cta-links">
<a
class="support-cta-link support-cta-link--kofi"
href="https://ko-fi.com/joshpatra"
target="_blank"
rel="noopener noreferrer"
aria-label="Support on Ko-fi (opens in new tab)"
>
<img
class="support-cta-kofi-img"
src="images/kofi_symbol.svg"
width="241"
height="194"
alt=""
decoding="async"
/>
</a>
<a
class="support-cta-link support-cta-link--github"
href="https://github.com/sponsors/SoPat712"
target="_blank"
rel="noopener noreferrer"
aria-label="Sponsor on GitHub (opens in new tab)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
</svg>
</a>
<a
class="support-cta-link support-cta-link--bmc"
href="https://buymeacoffee.com/treeman183"
target="_blank"
rel="noopener noreferrer"
aria-label="Support on Buy Me a Coffee (opens in new tab)"
>
<svg
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z"
/>
</svg>
</a>
</div>
</div>
</header>
<main class="settings-stack">
<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">
@@ -66,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"
@@ -80,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"
@@ -187,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>
@@ -199,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">
@@ -217,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>
@@ -234,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">
@@ -350,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">
@@ -502,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>
@@ -529,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">
@@ -547,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">
@@ -643,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">
@@ -665,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>
@@ -701,24 +944,6 @@
</p>
</section>
<footer class="support-footer settings-card">
<p>
If Speeder has been useful, consider supporting its development via
<a
href="https://github.com/sponsors/SoPat712"
target="_blank"
rel="noopener noreferrer"
>GitHub Sponsor</a
>
or
<a
href="https://ko-fi.com/joshpatra"
target="_blank"
rel="noopener noreferrer"
>Ko-Fi</a
>.
</p>
</footer>
</main>
</div>
</body>
+152 -67
View File
@@ -74,11 +74,13 @@ var controllerButtonDefs = {
reset: { icon: "\u21BB", name: "Reset speed" },
fast: { icon: "\u2605", name: "Preferred speed" },
nudge: { icon: "\u2713", name: "Subtitle nudge" },
settings: { icon: "\u2699", name: "Settings" },
pause: { icon: "\u23EF", name: "Pause / Play" },
pause: { icon: "\u23EF", name: "Play / Pause" },
muted: { icon: "M", name: "Mute / Unmute" },
louder: { icon: "+", name: "Increase volume" },
softer: { icon: "\u2212", name: "Decrease volume" },
mark: { icon: "\u2691", name: "Set marker" },
jump: { icon: "\u21E5", name: "Jump to marker" }
jump: { icon: "\u21E5", name: "Jump to marker" },
settings: { icon: "\u2699", name: "Settings" },
};
var popupExcludedButtonIds = new Set(["settings"]);
@@ -221,15 +223,17 @@ const actionLabels = {
advance: "Advance",
reset: "Reset speed",
fast: "Preferred speed",
muted: "Mute",
pause: "Pause",
toggleSubtitleNudge: "Toggle subtitle nudge",
pause: "Play / Pause",
muted: "Mute / Unmute",
louder: "Increase volume",
softer: "Decrease volume",
mark: "Set marker",
jump: "Jump to marker",
toggleSubtitleNudge: "Toggle subtitle nudge"
jump: "Jump to marker"
};
const speedBindingActions = ["slower", "faster", "fast"];
const requiredShortcutActions = new Set(["display", "slower", "faster"]);
const speedBindingActions = ["slower", "faster", "fast", "softer", "louder"];
const requiredShortcutActions = new Set(["slower", "faster"]);
function formatSpeedBindingDisplay(action, value) {
if (!speedBindingActions.includes(action)) {
@@ -242,6 +246,30 @@ function formatSpeedBindingDisplay(action, value) {
return n.toFixed(2);
}
function getDefaultShortcutValue(action) {
if (action === "louder" || action === "softer") {
return 0.1;
}
var defaultBinding = tcDefaults.keyBindings.find(function (binding) {
return binding.action === action;
});
if (defaultBinding && Number.isFinite(Number(defaultBinding.value))) {
return Number(defaultBinding.value);
}
return 0;
}
function resolveShortcutValue(action, value) {
if (value === undefined || value === null) {
return getDefaultShortcutValue(action);
}
var numericValue = Number(value);
if (Number.isFinite(numericValue)) {
return numericValue;
}
return 0;
}
const customActionsNoValues = [
"reset",
"display",
@@ -291,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;
@@ -581,7 +673,10 @@ function add_shortcut(action, value) {
valueInput.value = "N/A";
valueInput.disabled = true;
} else {
valueInput.value = formatSpeedBindingDisplay(action, value || 0);
valueInput.value = formatSpeedBindingDisplay(
action,
resolveShortcutValue(action, value)
);
}
var removeButton = document.createElement("button");
@@ -911,33 +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",
muted: "Mute",
pause: "Pause",
mark: "Set marker",
jump: "Jump to marker",
toggleSubtitleNudge: "Toggle subtitle nudge"
};
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)";
@@ -959,7 +1039,10 @@ function addSiteRuleShortcut(container, action, binding, value, force) {
valueInput.value = "N/A";
valueInput.disabled = true;
} else {
valueInput.value = formatSpeedBindingDisplay(action, value || 0);
valueInput.value = formatSpeedBindingDisplay(
action,
resolveShortcutValue(action, value)
);
}
var forceLabel = document.createElement("label");
@@ -978,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) {
@@ -1121,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;
@@ -1744,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");
@@ -1772,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",
@@ -1793,6 +1875,9 @@ document.addEventListener("DOMContentLoaded", function () {
if (targetBox) {
setSiteOverrideContainerState(targetBox, event.target.checked);
}
if (ocb === "override-shortcuts") {
refreshSiteRuleAddShortcutSelector(siteRuleRoot);
}
return;
}
}
+58 -7
View File
@@ -210,21 +210,63 @@ button:focus-visible {
.donate-split {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(3, 1fr);
}
.donate-split button {
.donate-icon-btn {
display: flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 6px 4px;
background: var(--panel);
border: 1px solid var(--border-strong);
color: var(--text);
text-decoration: none;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.donate-icon-btn:hover {
background: #f8f9fb;
border-color: #c5ccd5;
}
.donate-icon-btn:active {
background: #f1f3f5;
}
.donate-icon-btn:focus-visible {
outline: 2px solid rgba(17, 24, 39, 0.14);
outline-offset: 2px;
position: relative;
z-index: 1;
}
.donate-icon-btn svg {
display: block;
flex-shrink: 0;
}
.donate-icon-btn--kofi img {
display: block;
height: 22px;
width: auto;
border-radius: 0;
min-height: 28px;
max-width: 40px;
object-fit: contain;
}
.donate-split button:first-child {
.donate-icon-btn:first-child {
border-radius: 8px 0 0 8px;
border-right: 0;
border-right-width: 0;
}
.donate-split button:last-child {
.donate-icon-btn:nth-child(2) {
border-radius: 0;
border-right-width: 0;
}
.donate-icon-btn:last-child {
border-radius: 0 8px 8px 0;
}
@@ -266,4 +308,13 @@ button:focus-visible {
background: #dfe3e8;
border-color: #dfe3e8;
}
.donate-icon-btn:hover {
background: #1f2226;
border-color: #4a515a;
}
.donate-icon-btn:active {
background: #252a2f;
}
}
+61 -2
View File
@@ -35,8 +35,67 @@
<div id="donateWrap" class="donate-wrap">
<button id="donate" class="secondary">Donate</button>
<div id="donateOptions" class="donate-split hide">
<button id="donateKofi" class="secondary">Ko-fi</button>
<button id="donateGithub" class="secondary">Sponsors</button>
<a
id="donateGithub"
class="donate-icon-btn donate-icon-btn--github"
href="https://github.com/sponsors/SoPat712"
target="_blank"
rel="noopener noreferrer"
aria-label="Sponsor on GitHub (opens in new tab)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="22"
height="22"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
</svg>
</a>
<a
id="donateKofi"
class="donate-icon-btn donate-icon-btn--kofi"
href="https://ko-fi.com/joshpatra"
target="_blank"
rel="noopener noreferrer"
aria-label="Support on Ko-fi (opens in new tab)"
>
<img
src="images/kofi_symbol.svg"
width="28"
height="22"
alt=""
decoding="async"
/>
</a>
<a
id="donateBmc"
class="donate-icon-btn donate-icon-btn--bmc"
href="https://buymeacoffee.com/treeman183"
target="_blank"
rel="noopener noreferrer"
aria-label="Support on Buy Me a Coffee (opens in new tab)"
>
<svg
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="22"
height="22"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z"
/>
</svg>
</a>
</div>
</div>
</div>
+8 -11
View File
@@ -14,11 +14,13 @@ document.addEventListener("DOMContentLoaded", function () {
reset: { label: "\u21BB", className: "" },
fast: { label: "", className: "" },
nudge: { label: "", className: "" },
settings: { label: "", className: "" },
pause: { label: "", className: "" },
muted: { label: "", className: "" },
louder: { label: "", className: "" },
softer: { label: "", className: "" },
mark: { label: "", className: "" },
jump: { label: "", className: "" }
jump: { label: "", className: "" },
settings: { label: "", className: "" }
};
var defaultButtons = ["rewind", "slower", "faster", "advance", "display"];
@@ -228,14 +230,6 @@ document.addEventListener("DOMContentLoaded", function () {
document.querySelector("#donateOptions").classList.remove("hide");
});
document.querySelector("#donateKofi").addEventListener("click", function () {
window.open("https://ko-fi.com/joshpatra");
});
document.querySelector("#donateGithub").addEventListener("click", function () {
window.open("https://github.com/sponsors/SoPat712");
});
document.querySelector("#enable").addEventListener("click", function () {
toggleEnabled(true, settingsSavedReloadMessage);
});
@@ -277,7 +271,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) {
+24
View File
@@ -46,6 +46,29 @@
});
}
/**
* Local-only keys excluded from backup JSON. These are disposable caches
* (e.g. Lucide tags.json) that bloat exports and are refetched when needed.
* Keep in sync with lucide-client.js (LUCIDE_TAGS_CACHE_KEY + "At").
*/
var localSettingsKeysOmittedFromExport = [
"lucideTagsCacheV1",
"lucideTagsCacheV1At"
];
function filterLocalSettingsForExport(local) {
if (!local || typeof local !== "object" || Array.isArray(local)) {
return {};
}
var out = {};
for (var key in local) {
if (!Object.prototype.hasOwnProperty.call(local, key)) continue;
if (localSettingsKeysOmittedFromExport.indexOf(key) !== -1) continue;
out[key] = local[key];
}
return out;
}
function generateBackupFilename(now) {
var date = now instanceof Date ? now : new Date(now || Date.now());
var year = date.getFullYear();
@@ -117,6 +140,7 @@
return {
buildBackupPayload: buildBackupPayload,
extractImportSettings: extractImportSettings,
filterLocalSettingsForExport: filterLocalSettingsForExport,
generateBackupFilename: generateBackupFilename,
isRecognizedRawSettingsObject: isRecognizedRawSettingsObject,
parseImportText: parseImportText
+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
};
});
+64 -8
View File
@@ -1,7 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
import { vi } from "vitest";
import { applyJSDOMWindow } from "./jsdom-globals.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -11,19 +13,73 @@ function readRepoFile(relPath) {
return fs.readFileSync(path.join(repoRoot, relPath), "utf8");
}
/**
* Parse HTML into a fresh JSDOM document so tests can reload scripts without
* top-level `const` redeclaration errors (avoids document.write).
*/
export function loadHtmlString(html) {
const dom = new JSDOM(html, {
url: "https://example.org/",
pretendToBeVisual: true,
runScripts: "dangerously"
});
applyJSDOMWindow(dom.window);
}
export function loadHtml(relPath) {
document.open();
document.write(readRepoFile(relPath));
document.close();
loadHtmlString(readRepoFile(relPath));
}
const WINDOW_GLOBAL_SKIP = new Set([
"alert",
"atob",
"blur",
"btoa",
"cancelAnimationFrame",
"captureEvents",
"clearInterval",
"clearTimeout",
"close",
"confirm",
"fetch",
"focus",
"getComputedStyle",
"matchMedia",
"open",
"prompt",
"queueMicrotask",
"releaseEvents",
"requestAnimationFrame",
"setInterval",
"setTimeout",
"stop"
]);
function mirrorExtensionGlobalsFromWindow(win) {
if (!win) return;
if (win.tc) {
globalThis.tc = win.tc;
}
for (const key of Object.keys(win)) {
if (WINDOW_GLOBAL_SKIP.has(key)) continue;
if (/^[A-Z]/.test(key)) continue;
const val = win[key];
if (typeof val === "function") {
globalThis[key] = val;
}
}
}
export function loadScript(relPath) {
window.eval(
const source =
"var chrome = window.chrome || globalThis.chrome;\n" +
readRepoFile(relPath) +
"\n//# sourceURL=" +
relPath
);
readRepoFile(relPath) +
"\n//# sourceURL=" +
relPath;
const el = document.createElement("script");
el.textContent = source;
document.head.appendChild(el);
mirrorExtensionGlobalsFromWindow(window);
}
export async function flushAsyncWork() {
+79 -10
View File
@@ -1,5 +1,6 @@
const fs = require("fs");
const path = require("path");
const { JSDOM } = require("jsdom");
const { vi } = require("vitest");
const ROOT = path.resolve(__dirname, "..", "..");
@@ -18,22 +19,90 @@ function readWorkspaceFile(relPath) {
}
function loadHtmlFile(relPath) {
document.open();
document.write(readWorkspaceFile(relPath));
document.close();
loadHtmlString(readWorkspaceFile(relPath));
}
function applyJSDOMWindow(win) {
globalThis.window = win;
globalThis.document = win.document;
globalThis.navigator = win.navigator;
globalThis.customElements = win.customElements;
globalThis.HTMLElement = win.HTMLElement;
globalThis.Element = win.Element;
globalThis.Node = win.Node;
globalThis.Text = win.Text;
globalThis.DocumentFragment = win.DocumentFragment;
globalThis.Event = win.Event;
globalThis.MouseEvent = win.MouseEvent;
globalThis.KeyboardEvent = win.KeyboardEvent;
globalThis.DOMParser = win.DOMParser;
globalThis.URL = win.URL;
globalThis.Blob = win.Blob;
globalThis.FileReader = win.FileReader;
win.Date = globalThis.Date;
win.open = vi.fn();
win.close = vi.fn();
}
function loadHtmlString(html) {
document.open();
document.write(html);
document.close();
const dom = new JSDOM(html, {
url: "https://example.org/",
pretendToBeVisual: true,
runScripts: "dangerously"
});
applyJSDOMWindow(dom.window);
}
const WINDOW_GLOBAL_SKIP = new Set([
"alert",
"atob",
"blur",
"btoa",
"cancelAnimationFrame",
"captureEvents",
"clearInterval",
"clearTimeout",
"close",
"confirm",
"fetch",
"focus",
"getComputedStyle",
"matchMedia",
"open",
"prompt",
"queueMicrotask",
"releaseEvents",
"requestAnimationFrame",
"setInterval",
"setTimeout",
"stop"
]);
function mirrorExtensionGlobalsFromWindow(win) {
if (!win) return;
if (win.tc) {
globalThis.tc = win.tc;
}
for (const key of Object.keys(win)) {
if (WINDOW_GLOBAL_SKIP.has(key)) continue;
if (/^[A-Z]/.test(key)) continue;
const val = win[key];
if (typeof val === "function") {
globalThis[key] = val;
}
}
}
function evaluateScript(relPath) {
const source = readWorkspaceFile(relPath);
window.eval(
`${source}\n//# sourceURL=${workspacePath(relPath).replace(/\\/g, "/")}`
);
const absPath = workspacePath(relPath);
const source =
"var chrome = window.chrome || globalThis.chrome;\n" +
readWorkspaceFile(relPath) +
`\n//# sourceURL=${absPath.replace(/\\/g, "/")}`;
const el = document.createElement("script");
el.textContent = source;
document.head.appendChild(el);
mirrorExtensionGlobalsFromWindow(window);
}
function fireDOMContentLoaded() {
+30
View File
@@ -0,0 +1,30 @@
/**
* Point Vitest/jsdom test globals at a new JSDOM window (no document.write).
* Call after creating `new JSDOM(html, options).window`.
*/
import { vi } from "vitest";
export function applyJSDOMWindow(win) {
globalThis.window = win;
globalThis.document = win.document;
globalThis.navigator = win.navigator;
globalThis.customElements = win.customElements;
globalThis.HTMLElement = win.HTMLElement;
globalThis.Element = win.Element;
globalThis.Node = win.Node;
globalThis.Text = win.Text;
globalThis.DocumentFragment = win.DocumentFragment;
globalThis.Event = win.Event;
globalThis.MouseEvent = win.MouseEvent;
globalThis.KeyboardEvent = win.KeyboardEvent;
globalThis.DOMParser = win.DOMParser;
globalThis.URL = win.URL;
globalThis.Blob = win.Blob;
globalThis.FileReader = win.FileReader;
// Vitest fake timers patch host `Date`; jsdoms window keeps its own otherwise.
win.Date = globalThis.Date;
win.open = vi.fn();
win.close = vi.fn();
}
+80 -17
View File
@@ -9,7 +9,9 @@ async function setupImportExport(overrides = {}) {
loadHtml("options.html");
globalThis.chrome = createChromeMock(overrides);
window.chrome = globalThis.chrome;
globalThis.restore_options = vi.fn();
const restoreSpy = vi.fn();
globalThis.restore_options = restoreSpy;
window.restore_options = restoreSpy;
loadScript("shared/import-export.js");
loadScript("importExport.js");
await flushAsyncWork();
@@ -24,8 +26,8 @@ describe("import/export flows", () => {
sync: { rememberSpeed: true },
local: { customButtonIcons: { faster: { slug: "rocket" } } }
});
const OriginalBlob = globalThis.Blob;
globalThis.Blob = class TestBlob {
const OriginalBlob = window.Blob;
class TestBlob {
constructor(parts, options) {
this.parts = parts;
this.options = options;
@@ -34,24 +36,28 @@ describe("import/export flows", () => {
async text() {
return this.parts.join("");
}
};
}
globalThis.Blob = TestBlob;
window.Blob = TestBlob;
let capturedBlob = null;
let clickedDownload = null;
Object.defineProperty(URL, "createObjectURL", {
Object.defineProperty(window.URL, "createObjectURL", {
configurable: true,
value: vi.fn((blob) => {
capturedBlob = blob;
return "blob:test";
})
});
Object.defineProperty(URL, "revokeObjectURL", {
Object.defineProperty(window.URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(() => {})
});
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function () {
clickedDownload = this.download;
});
vi.spyOn(window.HTMLAnchorElement.prototype, "click").mockImplementation(
function () {
clickedDownload = this.download;
}
);
document.getElementById("exportSettings").click();
@@ -70,6 +76,55 @@ describe("import/export flows", () => {
expect(chrome.storage.sync.get).toHaveBeenCalled();
expect(chrome.storage.local.get).toHaveBeenCalled();
globalThis.Blob = OriginalBlob;
window.Blob = OriginalBlob;
});
it("export strips lucideTagsCacheV1 from localSettings", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 3, 4, 8, 9, 10));
await setupImportExport({
sync: { rememberSpeed: true },
local: {
customButtonIcons: { faster: { slug: "rocket", svg: "<svg/>" } },
lucideTagsCacheV1: { "a-arrow-down": ["letter"] },
lucideTagsCacheV1At: 42
}
});
const OriginalBlob = window.Blob;
class TestBlob {
constructor(parts) {
this.parts = parts;
}
async text() {
return this.parts.join("");
}
}
globalThis.Blob = TestBlob;
window.Blob = TestBlob;
let capturedBlob = null;
Object.defineProperty(window.URL, "createObjectURL", {
configurable: true,
value: vi.fn((blob) => {
capturedBlob = blob;
return "blob:test";
})
});
Object.defineProperty(window.URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(() => {})
});
vi.spyOn(window.HTMLAnchorElement.prototype, "click").mockImplementation(
() => {}
);
document.getElementById("exportSettings").click();
await flushAsyncWork();
expect(JSON.parse(await capturedBlob.text()).localSettings).toEqual({
customButtonIcons: { faster: { slug: "rocket", svg: "<svg/>" } }
});
globalThis.Blob = OriginalBlob;
window.Blob = OriginalBlob;
});
it("imports wrapped backup payloads and refreshes options", async () => {
@@ -87,7 +142,7 @@ describe("import/export flows", () => {
return el;
});
globalThis.FileReader = class MockFileReader {
class MockFileReader {
readAsText(file) {
this.onload({
target: {
@@ -95,7 +150,9 @@ describe("import/export flows", () => {
}
});
}
};
}
globalThis.FileReader = MockFileReader;
window.FileReader = MockFileReader;
globalThis.importSettings();
createdInput.onchange({
@@ -145,7 +202,7 @@ describe("import/export flows", () => {
return el;
});
globalThis.FileReader = class MockFileReader {
class MockFileReader {
readAsText(file) {
this.onload({
target: {
@@ -153,7 +210,9 @@ describe("import/export flows", () => {
}
});
}
};
}
globalThis.FileReader = MockFileReader;
window.FileReader = MockFileReader;
globalThis.importSettings();
createdInput.onchange({
@@ -200,7 +259,7 @@ describe("import/export flows", () => {
return el;
});
globalThis.FileReader = class MockFileReader {
class MockFileReader {
readAsText(file) {
this.onload({
target: {
@@ -208,7 +267,9 @@ describe("import/export flows", () => {
}
});
}
};
}
globalThis.FileReader = MockFileReader;
window.FileReader = MockFileReader;
globalThis.importSettings();
createdInput.onchange({
@@ -247,7 +308,7 @@ describe("import/export flows", () => {
return el;
});
globalThis.FileReader = class MockFileReader {
class MockFileReader {
readAsText(file) {
this.onload({
target: {
@@ -255,7 +316,9 @@ describe("import/export flows", () => {
}
});
}
};
}
globalThis.FileReader = MockFileReader;
window.FileReader = MockFileReader;
globalThis.importSettings();
createdInput.onchange({
+30
View File
@@ -87,6 +87,36 @@ describe("importExport.js", () => {
expect(document.querySelector("#status").textContent).toContain("exported");
});
it("omits Lucide tags cache from exported localSettings", async () => {
vi.spyOn(window.HTMLAnchorElement.prototype, "click").mockImplementation(
() => {}
);
const { createObjectURL } = bootImportExport({
syncData: { rememberSpeed: true },
localData: {
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
},
lucideTagsCacheV1: { "a-arrow-down": ["letter", "text"] },
lucideTagsCacheV1At: 999
}
});
document.querySelector("#exportSettings").click();
await flushAsyncWork();
const blob = createObjectURL.mock.calls[0][0];
const backup = JSON.parse(await blob.text());
expect(backup.localSettings).toEqual({
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
}
});
expect(backup.localSettings.lucideTagsCacheV1).toBeUndefined();
expect(backup.localSettings.lucideTagsCacheV1At).toBeUndefined();
});
it("imports wrapped backups, restores local data, and refreshes the options page", async () => {
const { chrome } = bootImportExport();
window.restore_options = vi.fn();
+29 -4
View File
@@ -1,10 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import { createChromeMock, flushAsyncWork, loadScript } from "./helpers/browser.js";
import {
createChromeMock,
flushAsyncWork,
loadHtmlString,
loadScript
} from "./helpers/browser.js";
function loadBlankDocument() {
document.open();
document.write("<!doctype html><html><body></body></html>");
document.close();
loadHtmlString("<!doctype html><html><body></body></html>");
}
async function bootInject({ sync = {}, local = {} } = {}) {
@@ -48,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: {
+5 -1
View File
@@ -16,7 +16,11 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers();
delete globalThis.SpeederShared;
delete globalThis.restore_options;
try {
delete globalThis.restore_options;
} catch {
globalThis.restore_options = undefined;
}
if (typeof document !== "undefined") {
document.head.innerHTML = "";
document.body.innerHTML = "";
+24
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: {},
@@ -149,5 +163,15 @@ describe("shared helpers", () => {
expect(importExportUtils.isRecognizedRawSettingsObject({ wat: true })).toBe(
false
);
expect(
importExportUtils.filterLocalSettingsForExport({
customButtonIcons: { faster: { slug: "zap" } },
lucideTagsCacheV1: { "a-arrow-down": ["letter"] },
lucideTagsCacheV1At: 123
})
).toEqual({
customButtonIcons: { faster: { slug: "zap" } }
});
});
});
+4
View File
@@ -16,6 +16,10 @@ var vscUiIconPaths = {
slower: '<line x1="5" y1="12" x2="19" y2="12"/>',
faster:
'<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
softer:
'<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="16" y1="12" x2="22" y2="12"/>',
louder:
'<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="17" y1="9" x2="17" y2="15"/><line x1="14" y1="12" x2="20" y2="12"/>',
moreHorizontal:
'<circle cx="6" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="18" cy="12" r="1.5"/>',
chevronUp: '<path d="m18 15-6-6-6 6"/>',