├── src ├── background │ ├── utils │ │ ├── constants.ts │ │ ├── session.ts │ │ ├── KeepAlive.ts │ │ ├── HandleChangesDebounced.ts │ │ ├── tabCapture.ts │ │ └── getAutoMedia.ts │ ├── capture.ts │ └── badge.ts ├── contentScript │ ├── isolated │ │ ├── utils │ │ │ ├── etc.ts │ │ │ ├── Cinema.css │ │ │ ├── Popover.css │ │ │ ├── Backdrop.css │ │ │ ├── index.ts │ │ │ ├── VisibleSync.ts │ │ │ ├── Backdrop.ts │ │ │ ├── isWebsite.ts │ │ │ ├── DetectOpen.ts │ │ │ ├── Indicator.css │ │ │ ├── Interactive.css │ │ │ ├── Circle.css │ │ │ ├── StratumServer.ts │ │ │ ├── Popover.ts │ │ │ ├── Indicator.ts │ │ │ └── NativeFs.ts │ │ ├── index.ts │ │ └── Overseer.ts │ ├── pageDraw │ │ └── stylesAux.css │ ├── main │ │ ├── loader.ts │ │ └── utils │ │ │ ├── nativeCodes.ts │ │ │ └── seekNetflix.ts │ └── pane │ │ ├── styles.css │ │ └── ScalableDiv.ts ├── comps │ ├── Move.css │ ├── Minmax.css │ ├── ModalText.css │ ├── MoveDrag.css │ ├── RegularTooltip.css │ ├── FloatTooltip.tsx │ ├── FloatTooltip.css │ ├── KeyPicker.css │ ├── Reset.tsx │ ├── SegmentedButtons.tsx │ ├── ErrorFallback.css │ ├── Toggle.tsx │ ├── RegularTooltip.tsx │ ├── SegmentedButtons.css │ ├── Move.tsx │ ├── GearIcon.tsx │ ├── ModalBase.css │ ├── Menu.css │ ├── Tooltip.css │ ├── SliderPlus.css │ ├── CycleInput.css │ ├── Toggle.css │ ├── ModalText.tsx │ ├── SliderMicro.css │ ├── ModalBase.tsx │ ├── Menu.tsx │ ├── SliderPlus.tsx │ ├── ErrorFallback.tsx │ ├── Minmax.tsx │ ├── KeyPicker.tsx │ ├── SliderMicro.tsx │ ├── CycleInput.tsx │ ├── Slider.tsx │ ├── svgs.tsx │ ├── MoveDrag.tsx │ ├── NumericInput.tsx │ └── ThrottledTextInput.tsx ├── globalVar.ts ├── options │ ├── List.css │ ├── WidgetModal.css │ ├── IndicatorModal.css │ ├── CinemaModal.css │ ├── SectionHelp.css │ ├── URLModal.css │ ├── CommandWarning.css │ ├── SpeedPresetModal.css │ ├── List.tsx │ ├── SectionRules.css │ ├── SectionEditor.css │ ├── options.tsx │ ├── ListItem.css │ ├── CommandWarning.tsx │ ├── options.css │ ├── ListItem.tsx │ ├── DevWarning.tsx │ ├── KebabList.tsx │ ├── SectionFlags.css │ ├── keybindControl │ │ └── styles.css │ ├── SpeedPresetModal.tsx │ └── URLModal.tsx ├── utils │ ├── supports.ts │ ├── nativeUtils.ts │ ├── hash.ts │ ├── keys.ts │ ├── gsm.ts │ ├── contextMenus.ts │ └── browserUtils.ts ├── popup │ ├── Filters.css │ ├── Origin.css │ ├── ReverseButton.css │ ├── OrlHeader.css │ ├── EqualizerControl.css │ ├── Origin.tsx │ ├── SvgFilterItem.css │ ├── popup.css │ ├── QrPromo.css │ ├── FxPanel.tsx │ ├── SvgFilterList.css │ ├── OrlHeader.tsx │ ├── FxControl.css │ ├── MainPanel.tsx │ ├── QrPromo.tsx │ ├── SpeedControl.css │ ├── AudioPanel.css │ ├── SvgFilterList.tsx │ ├── MediaView.css │ ├── Header.css │ ├── Filters.tsx │ ├── popup.tsx │ ├── ReverseButton.tsx │ └── EqualizerControl.tsx ├── defaults │ └── constants.ts ├── type.d.ts ├── hooks │ ├── useThemeSync.tsx │ ├── useStateView.ts │ └── useCaptureStatus.ts ├── offscreen │ ├── SoundTouchNode.ts │ ├── ReverseNode.ts │ ├── ReverseProcessor.ts │ └── SoundTouchProcessor.ts ├── faqs │ └── faqs.css └── placer │ ├── styles.css │ └── index.ts ├── screenshot.png ├── static ├── icons │ ├── 128.png │ ├── 128g.png │ └── qr.png ├── circles │ ├── 16.svg │ ├── 32.svg │ ├── 48.svg │ ├── 64.svg │ ├── 80.svg │ ├── 96.svg │ ├── 112.svg │ └── 128.svg ├── faqs.html ├── popup.html ├── options.html └── placer.html ├── PRIVACY_POLICY.md ├── .gitignore ├── postcss.config.js ├── staticCh ├── offscreen.html └── manifest.json ├── .babelrc ├── tsconfig.json ├── tools ├── replaceCtx.js ├── generateCircles.js └── generateGsmType.js ├── staticFf └── manifest.json ├── README.md ├── webpack.config.js └── package.json /src/background/utils/constants.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/contentScript/isolated/utils/etc.ts: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polywock/globalSpeed/HEAD/screenshot.png -------------------------------------------------------------------------------- /src/comps/Move.css: -------------------------------------------------------------------------------- 1 | 2 | .Move { 3 | display: grid; 4 | grid-auto-flow: row; 5 | } -------------------------------------------------------------------------------- /static/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polywock/globalSpeed/HEAD/static/icons/128.png -------------------------------------------------------------------------------- /static/icons/128g.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polywock/globalSpeed/HEAD/static/icons/128g.png -------------------------------------------------------------------------------- /static/icons/qr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polywock/globalSpeed/HEAD/static/icons/qr.png -------------------------------------------------------------------------------- /PRIVACY_POLICY.md: -------------------------------------------------------------------------------- 1 | 2 | # Privacy Policy 3 | 1. Global Speed does not collect any personal information. 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .vscode/ 3 | build/ 4 | buildFf/ 5 | Archive* 6 | *.md 7 | .DS_Store 8 | **/*.env 9 | private/ -------------------------------------------------------------------------------- /src/comps/Minmax.css: -------------------------------------------------------------------------------- 1 | 2 | .Minmax { 3 | display: grid; 4 | grid-template-columns: 1fr 1fr; 5 | column-gap: 5px; 6 | } -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | plugins: [ 4 | require('postcss-nested'), 5 | require('autoprefixer') 6 | ] 7 | } -------------------------------------------------------------------------------- /src/globalVar.ts: -------------------------------------------------------------------------------- 1 | 2 | // Using Webpack's ProvidePlugin available on all files without having to import. 3 | export const gvar = globalThis.gvar ?? {} -------------------------------------------------------------------------------- /src/contentScript/isolated/utils/Cinema.css: -------------------------------------------------------------------------------- 1 | :is(::-webkit-scrollbar) { 2 | display: none !important 3 | } 4 | :root, body { 5 | scrollbar-width: none !important 6 | } -------------------------------------------------------------------------------- /static/circles/16.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/circles/32.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/circles/48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/circles/64.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/circles/80.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/circles/96.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/circles/112.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/circles/128.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/contentScript/isolated/utils/Popover.css: -------------------------------------------------------------------------------- 1 | .popoverYah { 2 | z-index: 99999999999; 3 | display: none; 4 | } 5 | 6 | .popoverYah.popoverOpenYah { 7 | display: block !important; 8 | } -------------------------------------------------------------------------------- /staticCh/offscreen.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /static/faqs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /src/comps/ModalText.css: -------------------------------------------------------------------------------- 1 | 2 | .ModalText { 3 | & textarea { 4 | width: 50vw; 5 | height: 75vh; 6 | padding: 20px; 7 | border-radius: 10px; 8 | font-family: 'Courier New', Courier, monospace; 9 | } 10 | } -------------------------------------------------------------------------------- /src/comps/MoveDrag.css: -------------------------------------------------------------------------------- 1 | 2 | :root.dragging * { 3 | cursor: ns-resize !important; 4 | } 5 | 6 | .icon.MoveDrag { 7 | cursor: ns-resize; 8 | position: relative; 9 | 10 | &:focus { 11 | outline: none; 12 | } 13 | } -------------------------------------------------------------------------------- /src/options/List.css: -------------------------------------------------------------------------------- 1 | 2 | .List, .ListItem .ListItemLabel { 3 | user-select: none; 4 | cursor: ns-resize; 5 | } 6 | 7 | 8 | .ListItemCore, .ListItemLabel span { 9 | cursor: initial; 10 | user-select: initial; 11 | } 12 | 13 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/env", 4 | "@babel/typescript", 5 | ["@babel/react", { 6 | "runtime": "automatic" 7 | }] 8 | ], 9 | "plugins": [ 10 | "@babel/plugin-transform-runtime" 11 | ] 12 | } -------------------------------------------------------------------------------- /src/contentScript/pageDraw/stylesAux.css: -------------------------------------------------------------------------------- 1 | 2 | :root.noUseraSelectScroll { 3 | * { 4 | user-select: none !important; 5 | } 6 | } 7 | 8 | :root.noUseraSelect { 9 | * { 10 | user-select: none !important; 11 | } 12 | } -------------------------------------------------------------------------------- /src/comps/RegularTooltip.css: -------------------------------------------------------------------------------- 1 | 2 | .RegularTooltip > div > span { 3 | user-select: none; 4 | padding: 0px 2px; 5 | background-color: var(--fg-color); 6 | color: var(--text-color); 7 | border: 1px solid var(--border); 8 | border-radius: 4px; 9 | } -------------------------------------------------------------------------------- /src/options/WidgetModal.css: -------------------------------------------------------------------------------- 1 | 2 | .WidgetModal { 3 | width: 600px; 4 | max-width: 90vw; 5 | 6 | .NumericInput { 7 | width: 50px; 8 | } 9 | 10 | .control { 11 | display: flex; 12 | column-gap: 10px; 13 | } 14 | } -------------------------------------------------------------------------------- /src/utils/supports.ts: -------------------------------------------------------------------------------- 1 | 2 | export const HAS_PIP_API = !!("requestPictureInPicture" in HTMLVideoElement.prototype && "pictureInPictureElement" in Document.prototype) 3 | export const BLOCKS_PIP = (document as any).featurePolicy?.allowsFeature("picture-in-picture") === false -------------------------------------------------------------------------------- /static/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/options/IndicatorModal.css: -------------------------------------------------------------------------------- 1 | 2 | .IndicatorModal { 3 | width: 600px; 4 | max-width: 90vw; 5 | max-height: 90vh; 6 | background-color: var(--fg-color); 7 | padding: 20px; 8 | overflow-y: auto; 9 | 10 | & > button.reset { 11 | font-size: 1.14rem; 12 | } 13 | } -------------------------------------------------------------------------------- /static/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/comps/FloatTooltip.tsx: -------------------------------------------------------------------------------- 1 | import "./FloatTooltip.css" 2 | 3 | 4 | type FloatTooltipProps = { 5 | value: string 6 | } 7 | 8 | export const FloatTooltip = (props: FloatTooltipProps) => { 9 | return
10 |
11 | {props.value} 12 |
13 |
14 | } -------------------------------------------------------------------------------- /src/contentScript/isolated/utils/Backdrop.css: -------------------------------------------------------------------------------- 1 | :is(div:popover-open, div.popoverOpenYah) { 2 | position: fixed; 3 | width: 100vw; 4 | height: 100vh; 5 | left: 0px; 6 | top: 0px; 7 | margin: 0px; 8 | pointer-events: none; 9 | background-color: transparent; 10 | border: none; 11 | } -------------------------------------------------------------------------------- /src/popup/Filters.css: -------------------------------------------------------------------------------- 1 | 2 | .Filters > .Filter { 3 | display: grid; 4 | grid-template-columns: max-content 1fr; 5 | column-gap: 5px; 6 | align-items: start; 7 | 8 | & > .Move { 9 | row-gap: 5px; 10 | } 11 | 12 | margin-bottom: 15px; 13 | &:last-child { 14 | margin-bottom: 0; 15 | } 16 | } -------------------------------------------------------------------------------- /src/comps/FloatTooltip.css: -------------------------------------------------------------------------------- 1 | 2 | .FloatTooltip { 3 | position: absolute; 4 | left: -80px; 5 | right: -80px; 6 | bottom: 40px; 7 | font-size: 0.9em; 8 | display: grid; 9 | justify-content: center; 10 | 11 | & > div { 12 | display: inline-block; 13 | padding: 5px; 14 | background-color: red; 15 | color: white; 16 | } 17 | } -------------------------------------------------------------------------------- /src/comps/KeyPicker.css: -------------------------------------------------------------------------------- 1 | 2 | .KeyPicker { 3 | padding: 5px 0px; 4 | background-color: var(--fg-color); 5 | border: 1px solid var(--border); 6 | color: var(--text-color); 7 | text-align: center; 8 | user-select: none; 9 | cursor: pointer; 10 | line-height: 1; 11 | 12 | &:focus { 13 | outline: 1px solid var(--focus-color); 14 | } 15 | } -------------------------------------------------------------------------------- /src/options/CinemaModal.css: -------------------------------------------------------------------------------- 1 | 2 | .CinemaModal { 3 | --field-name-width: 200px; 4 | width: 600px; 5 | max-width: 90vw; 6 | 7 | .filters { 8 | max-width: 300px; 9 | margin: 30px 0px; 10 | padding: 20px 0px; 11 | border-bottom: 1px solid var(--border); 12 | border-top: 1px solid var(--border); 13 | } 14 | } -------------------------------------------------------------------------------- /src/comps/Reset.tsx: -------------------------------------------------------------------------------- 1 | import { GiAnticlockwiseRotation } from "react-icons/gi"; 2 | 3 | type ResetProps = { 4 | onClick?: () => void, 5 | active?: boolean 6 | } 7 | 8 | export function Reset(props: ResetProps) { 9 | return props.active && props.onClick()}/> 10 | } -------------------------------------------------------------------------------- /src/comps/SegmentedButtons.tsx: -------------------------------------------------------------------------------- 1 | 2 | import "./SegmentedButtons.css" 3 | 4 | export const SegmentedButtons = (props: {numbers: number[], value: number, onChange: (newNumber: number) => void}) => { 5 | return
6 | {props.numbers.map((v, i) => ( 7 | 8 | ))} 9 |
10 | } -------------------------------------------------------------------------------- /src/comps/ErrorFallback.css: -------------------------------------------------------------------------------- 1 | 2 | .ErrorFallback { 3 | padding: 10px; 4 | background-color: var(--fg-color); 5 | font-size: 1.1em; 6 | border: 3px solid red; 7 | margin-top: 20px; 8 | min-width: 600px; 9 | max-width: 800px; 10 | 11 | button { 12 | display: block; 13 | margin-top: 5px; 14 | } 15 | 16 | li { 17 | margin-bottom: 15px; 18 | 19 | &:last-child { 20 | margin-bottom: 0; 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/comps/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import "./Toggle.css" 2 | 3 | type ToggleProps = { 4 | value: boolean, 5 | onChange: (newValue: boolean) => void 6 | } 7 | 8 | export function Toggle(props: ToggleProps) { 9 | return
{ 10 | if (e.key === "Enter") { 11 | props.onChange(!props.value) 12 | } 13 | }} onClick={e => { 14 | props.onChange(!props.value) 15 | }} className={`Toggle ${props.value ? "active" : ""}`}/> 16 | } -------------------------------------------------------------------------------- /src/popup/Origin.css: -------------------------------------------------------------------------------- 1 | 2 | .Origin { 3 | display: grid; 4 | grid-template-columns: repeat(3, max-content); 5 | grid-row-gap: 15px; 6 | justify-content: space-around; 7 | 8 | & > div { 9 | border: 1px solid var(--border); 10 | background-color: var(--fg-color); 11 | width: 30px; 12 | height: 15px; 13 | 14 | &.active { 15 | background-color: var(--border); 16 | } 17 | 18 | &:focus { 19 | outline: 1px solid var(--focus-color); 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/comps/RegularTooltip.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip, TooltipProps } from "./Tooltip" 2 | import "./RegularTooltip.css" 3 | 4 | export function RegularTooltip(props: {label?: string, offset?: number, title: string, align?: TooltipProps['align'], styles?: React.CSSProperties}) { 5 | return 6 | {props.label || "?"} 7 | 8 | return 9 | } -------------------------------------------------------------------------------- /src/comps/SegmentedButtons.css: -------------------------------------------------------------------------------- 1 | 2 | .SegmentedButtons { 3 | --selected-filter: brightness(0.9); 4 | 5 | display: grid; 6 | grid-auto-flow: column; 7 | grid-auto-columns: 1fr; 8 | user-select: none; 9 | 10 | & > button { 11 | padding: 5px 10px; 12 | opacity: 0.5; 13 | 14 | &.selected { 15 | filter: var(--selected-filter); 16 | opacity: 1; 17 | } 18 | } 19 | } 20 | 21 | :root.darkTheme { 22 | .SegmentedButtons { 23 | --selected-filter: brightness(1.3); 24 | } 25 | } -------------------------------------------------------------------------------- /src/comps/Move.tsx: -------------------------------------------------------------------------------- 1 | import { GoArrowUp, GoArrowDown } from "react-icons/go" 2 | import "./Move.css" 3 | 4 | type MoveProps = { 5 | onMove: (down: boolean) => void 6 | } 7 | 8 | export function Move(props: MoveProps) { 9 | return
10 | 13 | 16 |
17 | } -------------------------------------------------------------------------------- /src/popup/ReverseButton.css: -------------------------------------------------------------------------------- 1 | .ReverseButton { 2 | width: 100%; 3 | padding: 5px; 4 | font-size: 1.3em; 5 | border-width: 2px; 6 | border-style: dashed; 7 | 8 | &:focus { 9 | border-width: 2px; 10 | } 11 | 12 | &.recording { 13 | border-color: #500; 14 | border-style: solid; 15 | background-color: lightcoral; 16 | color: #500; 17 | } 18 | 19 | &.playing { 20 | border-color: #172717; 21 | border-style: solid; 22 | background-color: lightgreen; 23 | color: #172717; 24 | } 25 | } -------------------------------------------------------------------------------- /src/comps/GearIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Gear } from "./svgs" 2 | import { Tooltip, TooltipProps } from "./Tooltip" 3 | 4 | 5 | export function GearIcon(props: { 6 | tooltip?: string, 7 | onClick: React.MouseEventHandler, 8 | align?: TooltipProps['align'] 9 | }) { 10 | return 11 | 14 | 15 | } -------------------------------------------------------------------------------- /src/comps/ModalBase.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .ModalBase { 4 | position: fixed; 5 | left: 0px; 6 | top: 0px; 7 | width: 100vw; 8 | height: 100vh; 9 | background-color: #00000088; 10 | z-index: 9999999999; 11 | 12 | display: grid; 13 | justify-content: center; 14 | align-items: center; 15 | 16 | &.passThrough { 17 | pointer-events: none; 18 | } 19 | } 20 | 21 | 22 | .ModalMain { 23 | width: 700px; 24 | max-width: 90vw; 25 | max-height: 90vh; 26 | background-color: var(--fg-color); 27 | padding: 20px; 28 | overflow-y: auto; 29 | } -------------------------------------------------------------------------------- /src/defaults/constants.ts: -------------------------------------------------------------------------------- 1 | import { type CinemaInit } from "src/types" 2 | 3 | export const MIN_SPEED_CHROMIUM = 0.07 4 | export const MAX_SPEED_CHROMIUM = 16 5 | 6 | export function getDefaultSpeedPresets() { 7 | return [0.25, 0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 16] 8 | } 9 | 10 | export function getDefaultSpeedSlider() { 11 | return { min: 0.5, max: 1.5 } 12 | } 13 | 14 | export function getDefaultCinemaInit() { 15 | return { 16 | mode: 1, 17 | color: '#000000', 18 | colorAlpha: 90, 19 | rounding: 10 20 | } as CinemaInit 21 | } -------------------------------------------------------------------------------- /src/popup/OrlHeader.css: -------------------------------------------------------------------------------- 1 | 2 | .OrmHeader { 3 | background-color: aquamarine; 4 | border-bottom: 1px solid seagreen; 5 | color: black; 6 | padding: 5px 10px; 7 | font-size: 0.8rem; 8 | font-weight: bolder; 9 | display: grid; 10 | grid-template-columns: 1fr max-content max-content; 11 | align-items: center; 12 | column-gap: 7px; 13 | user-select: none; 14 | 15 | & > span { 16 | opacity: 0.7; 17 | } 18 | 19 | & > svg:hover { 20 | opacity: 0.5; 21 | cursor: pointer; 22 | } 23 | 24 | } -------------------------------------------------------------------------------- /src/contentScript/isolated/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { isFirefox } from "src/utils/helper" 2 | import { getLeaf } from "src/utils/nativeUtils" 3 | 4 | 5 | export function documentHasFocus() { 6 | return document.hasFocus() && !(getLeaf(document, "activeElement")?.tagName === "IFRAME") 7 | } 8 | 9 | 10 | export function injectScript(text: string) { 11 | if (!(isFirefox() && text)) return 12 | const script = document.createElement("script") 13 | script.type = "text/javascript" 14 | script.text = text 15 | document.documentElement.appendChild(script) 16 | script.remove() 17 | } -------------------------------------------------------------------------------- /src/popup/EqualizerControl.css: -------------------------------------------------------------------------------- 1 | 2 | .EqualizerControl { 3 | margin-top: 20px; 4 | padding-top: 15px; 5 | border-top: 1px solid var(--text-color); 6 | 7 | & > .controls > button.levelup { 8 | outline: 1px solid var(--header-icon-active-color); 9 | & > svg { 10 | transform: scale(1.15); 11 | } 12 | } 13 | 14 | & > .preset { 15 | display: grid; 16 | grid-template-columns: max-content 1fr; 17 | grid-column-gap: 5px; 18 | margin-top: 15px; 19 | margin-bottom: 20px; 20 | width: 100%; 21 | 22 | select { 23 | width: inherit; 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /src/options/SectionHelp.css: -------------------------------------------------------------------------------- 1 | 2 | .SectionHelp { 3 | & > .card { 4 | margin-bottom: 30px; 5 | } 6 | 7 | & > .controls { 8 | display: grid; 9 | grid-template-columns: max-content max-content 1fr; 10 | column-gap: 10px; 11 | justify-items: right; 12 | 13 | & > button { 14 | text-transform: uppercase; 15 | } 16 | 17 | & > .right { 18 | display: grid; 19 | grid-template-columns: max-content max-content max-content max-content; 20 | column-gap: 5px; 21 | 22 | & > *:nth-child(3) { 23 | margin-left: 15px; 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/comps/Menu.css: -------------------------------------------------------------------------------- 1 | 2 | .Menu { 3 | position: fixed; 4 | z-index: 99999999; 5 | background-color: var(--text-color); 6 | color: var(--pure-color); 7 | user-select: none; 8 | border-radius: 5px; 9 | 10 | & > div { 11 | display: grid; 12 | grid-template-columns: 20px auto; 13 | padding: 5px 10px; 14 | border-bottom: 1px solid var(--border); 15 | padding-right: 20px; 16 | opacity: 0.85; 17 | line-height: 1.5; 18 | 19 | &:hover { 20 | opacity: 1; 21 | } 22 | } 23 | 24 | .RegularTooltip > div > span { 25 | margin-left: 10px; 26 | filter: invert(1); 27 | } 28 | } -------------------------------------------------------------------------------- /src/comps/Tooltip.css: -------------------------------------------------------------------------------- 1 | 2 | .Tooltip { 3 | display: inline-block; 4 | 5 | & > .tip { 6 | line-height: normal !important; 7 | pointer-events: none; 8 | position: fixed; 9 | display: none; 10 | background-color: var(--text-color); 11 | border: 1px solid var(--pure-color); 12 | color: var(--pure-color); 13 | padding: 10px; 14 | white-space: break-spaces; 15 | border-radius: 5px; 16 | opacity: 0.85; 17 | z-index: 9999999999999; 18 | overflow-wrap: break-word; 19 | font-size: 1rem; 20 | user-select: none; 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /src/popup/Origin.tsx: -------------------------------------------------------------------------------- 1 | import "./Origin.css" 2 | 3 | type OriginProps = { 4 | x: string, 5 | y: string, 6 | onChange: (x: string, y: string) => void 7 | } 8 | 9 | export function Origin (props: OriginProps){ 10 | return ( 11 |
12 | {Y_OPTIONS.map(y => X_OPTIONS.map(x => [x, y])).flat(1).map(([x, y]) => ( 13 |
props.onChange(x, y)}>
14 | ))} 15 |
16 | ) 17 | } 18 | 19 | const X_OPTIONS = ["left", "center", "right"] 20 | const Y_OPTIONS = ["top", "center", "bottom"] -------------------------------------------------------------------------------- /src/popup/SvgFilterItem.css: -------------------------------------------------------------------------------- 1 | .SvgFilter { 2 | margin-top: 15px; 3 | padding: 10px; 4 | border: 1px solid var(--border); 5 | 6 | & > .header { 7 | font-size: 1.2em; 8 | display: grid; 9 | grid-template-columns: max-content 1fr max-content max-content max-content; 10 | align-items: center; 11 | column-gap: 5px; 12 | margin-bottom: 5px; 13 | } 14 | 15 | & > .presets { 16 | display: grid; 17 | grid-template-columns: max-content 1fr; 18 | column-gap: 5px; 19 | align-items: center; 20 | margin-top: 7px; 21 | } 22 | 23 | & > .core { 24 | margin-top: 7px; 25 | } 26 | } -------------------------------------------------------------------------------- /src/popup/popup.css: -------------------------------------------------------------------------------- 1 | 2 | @import "../_common.css"; 3 | 4 | 5 | :root.mobile body { 6 | width: unset; 7 | } 8 | 9 | 10 | body { 11 | width: 300px; 12 | min-width: 300px; 13 | margin: 0; 14 | padding: 0; 15 | background-color: var(--fg-color); 16 | } 17 | 18 | #SuperDisable { 19 | padding: 10px; 20 | background-color: #fafafa; 21 | color: #666; 22 | cursor: pointer; 23 | } 24 | 25 | #App { 26 | 27 | &.mobile { 28 | width: unset; 29 | --font-size-scalar: 1.2; 30 | } 31 | 32 | & > .panel { 33 | padding: 8px; 34 | min-height: 40px; 35 | 36 | & > .unloaded { 37 | min-height: 9.28rem; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/contentScript/main/loader.ts: -------------------------------------------------------------------------------- 1 | 2 | function main() { 3 | if ((globalThis as any).robert) return 4 | ;(globalThis as any).robert = true 5 | const s = document.createElement("script") 6 | s.type = "text/javascript" 7 | s.text = "$$$CTX$$$" 8 | try { 9 | document.documentElement.appendChild(s) 10 | s.remove() 11 | } catch (err) { } 12 | mainAlt() 13 | } 14 | 15 | function mainAlt() { 16 | const s = document.createElement("script") 17 | s.type = "text/javascript" 18 | s.async = true 19 | s.src = chrome.runtime.getURL('main.js') 20 | document.documentElement.appendChild(s) 21 | s.remove() 22 | } 23 | 24 | main() -------------------------------------------------------------------------------- /src/contentScript/isolated/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import type { TabInfo } from '../../utils/browserUtils' 3 | import { Overseer } from './Overseer' 4 | 5 | declare global { 6 | interface GlobalVar { 7 | tabInfo: TabInfo, 8 | os: Overseer, 9 | fallbackId: number, 10 | ghostMode?: boolean, 11 | backgroundHide?: boolean, 12 | isTopFrame?: boolean, 13 | topFrameUrl?: string 14 | } 15 | } 16 | 17 | async function main() { 18 | if ((globalThis as any).gvar) return 19 | ;(globalThis as any).gvar = gvar 20 | ;(document as any).gvar = gvar 21 | gvar.isTopFrame = window.self === window.top 22 | gvar.os = new Overseer() 23 | gvar.os.init() 24 | } 25 | 26 | 27 | main() -------------------------------------------------------------------------------- /src/options/URLModal.css: -------------------------------------------------------------------------------- 1 | 2 | .URLModal { 3 | 4 | & > .header { 5 | display: grid; 6 | grid-template-columns: 1fr max-content; 7 | 8 | & > div:first-child { 9 | font-size: 1.3em; 10 | } 11 | 12 | margin-bottom: 10px; 13 | } 14 | 15 | 16 | & > .parts { 17 | margin-bottom: 20px; 18 | 19 | & > div { 20 | margin-bottom: 15px; 21 | display: grid; 22 | grid-template-columns: max-content max-content 1fr max-content; 23 | column-gap: 10px; 24 | align-items: center; 25 | } 26 | } 27 | 28 | & > .controls { 29 | display: grid; 30 | grid-template-columns: max-content max-content; 31 | column-gap: 10px; 32 | } 33 | } -------------------------------------------------------------------------------- /src/options/CommandWarning.css: -------------------------------------------------------------------------------- 1 | 2 | .CommandWarning { 3 | display: flex; 4 | align-items: center; 5 | background-color: var(--error-color); 6 | color: var(--error-text-color); 7 | border: 1px solid var(--error-border-color); 8 | padding: 0.6rem; 9 | margin-bottom: 10px; 10 | border-radius: 20px; 11 | 12 | & > svg { 13 | margin-right: 5px; 14 | } 15 | 16 | button { 17 | padding: 4px 8px; 18 | margin-left: 10px; 19 | background-color: inherit; 20 | color: inherit; 21 | border: 1px solid var(--error-border-color); 22 | border-radius: 10px; 23 | 24 | span { 25 | margin-left: 5px; 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /src/contentScript/isolated/utils/VisibleSync.ts: -------------------------------------------------------------------------------- 1 | 2 | export class VisibleSync { 3 | timeoutId: number 4 | constructor(private cb?: (...args: any[]) => void) { 5 | gvar.os.eListen.visibilityCbs.add(this.handleChange) 6 | this.sync() 7 | } 8 | release = () => { 9 | gvar.os.eListen.visibilityCbs.delete(this.handleChange) 10 | } 11 | handleChange = () => { 12 | if (this.timeoutId) { 13 | this.timeoutId = clearTimeout(this.timeoutId) as null 14 | } 15 | 16 | if (document.hidden) { 17 | this.timeoutId = setTimeout(this.sync, 1500) 18 | } else { 19 | this.sync() 20 | } 21 | } 22 | sync = () => { 23 | this.cb?.() 24 | } 25 | } -------------------------------------------------------------------------------- /src/popup/QrPromo.css: -------------------------------------------------------------------------------- 1 | 2 | :root.darkTheme .QrPromo { 3 | display: none; 4 | } 5 | 6 | .QrPromo { 7 | display: grid; 8 | grid-template-columns: 1fr max-content max-content; 9 | column-gap: 7px; 10 | padding-left: 10px; 11 | padding-top: 20px; 12 | padding-bottom: 10px; 13 | align-items: center; 14 | user-select: none; 15 | 16 | & > div { 17 | 18 | & .top { 19 | font-size: 14px; 20 | } 21 | 22 | & .bottom { 23 | font-size: 18px; 24 | font-weight: bold; 25 | color: #006439; 26 | } 27 | } 28 | 29 | & > img { 30 | cursor: pointer; 31 | } 32 | 33 | & > button { 34 | line-height: 0; 35 | } 36 | } -------------------------------------------------------------------------------- /src/type.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module "soundtouchjs" 3 | 4 | declare namespace chrome.storage { 5 | export type StorageChanges = {[key: string]: chrome.storage.StorageChange} 6 | 7 | export type StorageKeysArgument = string | string[] | {[key: string]: any} | null | undefined 8 | } 9 | 10 | declare namespace chrome.tabCapture { 11 | export interface GetMediaStreamOptions { 12 | targetTabId?: number, 13 | consumerTabId?: number 14 | } 15 | 16 | export function getMediaStreamId(options: GetMediaStreamOptions, callback: (streamId: string) => void): void; 17 | export function getMediaStreamId(options: GetMediaStreamOptions): Promise; 18 | } 19 | 20 | declare module "*.css?raw" { 21 | const content: string; 22 | export default content; 23 | } -------------------------------------------------------------------------------- /src/hooks/useThemeSync.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | import { useStateView } from "../hooks/useStateView" 3 | import { isMobile } from "src/utils/helper" 4 | 5 | 6 | export function useThemeSync() { 7 | const [view] = useStateView({darkTheme: true, fontSize: true}) 8 | useEffect(() => { 9 | if (view?.darkTheme) { 10 | document.documentElement.classList.add("darkTheme") 11 | } else { 12 | document.documentElement.classList.remove("darkTheme") 13 | } 14 | 15 | document.documentElement.style.setProperty("--font-size-scalar", `${view?.fontSize ?? (isMobile() ? 1.3 : 1)}`) 16 | }, [view?.darkTheme, view?.fontSize]) 17 | 18 | if (gvar.gsm._scale) { 19 | document.documentElement.style.setProperty("--font-lang-scalar", `${gvar.gsm._scale}`) 20 | } 21 | } -------------------------------------------------------------------------------- /src/options/SpeedPresetModal.css: -------------------------------------------------------------------------------- 1 | 2 | .SpeedPresetModal { 3 | width: 400px; 4 | --field-name-width: 150px; 5 | 6 | & > button.reset { 7 | font-size: 1.14rem; 8 | } 9 | 10 | .presetControl { 11 | display: grid; 12 | grid-template-columns: repeat(3, max-content); 13 | align-items: center; 14 | justify-items: center; 15 | gap: 10px; 16 | font-size: 0.95em; 17 | margin-bottom: 15px; 18 | 19 | 20 | & > .NumericInput { 21 | width: 60px; 22 | 23 | &.wide { 24 | width: 130px; 25 | grid-column: auto / span 2; 26 | } 27 | 28 | &.preset input { 29 | padding: var(--padding) 0px; 30 | } 31 | } 32 | } 33 | 34 | } -------------------------------------------------------------------------------- /src/comps/SliderPlus.css: -------------------------------------------------------------------------------- 1 | .SliderPlus { 2 | background-color: var(--fg-color); 3 | --label-color: var(--text-color); 4 | --reset-color: var(--icon-color); 5 | --font-weight: 400; 6 | --opacity: 0.5; 7 | 8 | 9 | &.highlight { 10 | --label-color: var(--header-icon-active-color); 11 | --reset-color: var(--header-icon-active-color); 12 | --font-weight: 600; 13 | --opacity: 1; 14 | } 15 | 16 | & > .header { 17 | display: grid; 18 | grid-template-columns: 1fr 60px max-content; 19 | grid-column-gap: 5px; 20 | margin-bottom: 2px; 21 | align-items: center; 22 | 23 | & > span { 24 | align-self: center; 25 | font-weight: var(--font-weight); 26 | color: var(--label-color); 27 | } 28 | } 29 | 30 | & > input[type="range"] { 31 | width: 100%; 32 | } 33 | } -------------------------------------------------------------------------------- /src/comps/CycleInput.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .CycleInput { 4 | 5 | & .close { 6 | opacity: 0; 7 | } 8 | 9 | &:hover .close { 10 | opacity: 0.9; 11 | } 12 | 13 | 14 | & > .values { 15 | display: grid; 16 | grid-template-columns: repeat(4, max-content); 17 | align-items: center; 18 | grid-gap: 7px; 19 | 20 | & button { 21 | font-size: 0.7rem; 22 | } 23 | 24 | & > .value { 25 | position: relative; 26 | width: 44px; 27 | 28 | & > .close { 29 | position: absolute; 30 | top: -5px; 31 | right: -5px; 32 | width: 11px; 33 | height: 11px; 34 | background-color: #d99; 35 | border: 1px solid #d66; 36 | border-radius: 50%; 37 | 38 | &:hover { 39 | opacity: 1; 40 | } 41 | } 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /src/comps/Toggle.css: -------------------------------------------------------------------------------- 1 | 2 | .Toggle { 3 | display: inline-block; 4 | width: 40px; 5 | border-radius: 5px; 6 | background-color: var(--border); 7 | border: 1.5px solid var(--border); 8 | cursor: pointer; 9 | line-height: 0; 10 | user-select: none; 11 | cursor: pointer; 12 | 13 | &::after { 14 | content: ""; 15 | display: inline-block; 16 | background-color: white; 17 | border: 1px solid #aaa; 18 | border-radius: inherit; 19 | box-sizing: border-box; 20 | width: 18px; 21 | height: 14px; 22 | transition: transform 0.05s linear; 23 | transform: translateX(0px); 24 | pointer-events: none; 25 | } 26 | 27 | &.active { 28 | background-color: var(--toggle-color); 29 | border-color: var(--border); 30 | 31 | &::after { 32 | transform: translateX(20px); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/contentScript/isolated/utils/Backdrop.ts: -------------------------------------------------------------------------------- 1 | import { Popover } from "./Popover"; 2 | import { insertStyle } from "src/utils/nativeUtils"; 3 | import styles from "./Backdrop.css?raw" 4 | 5 | export class Backdrop extends Popover { 6 | constructor() { 7 | super() 8 | insertStyle(styles, this._shadow) 9 | } 10 | svg: SVGElement 11 | release = () => { 12 | this._release() 13 | this.svg?.remove() 14 | } 15 | lastFilter: string 16 | show = (filter?: string, svg?: SVGElement) => { 17 | if (filter === this.lastFilter) return 18 | this.svg?.remove() 19 | this.svg = svg 20 | this.lastFilter = filter 21 | this._div.style.backdropFilter = filter 22 | this.svg && this._shadow.appendChild(this.svg) 23 | this._update(!!filter) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/contentScript/main/utils/nativeCodes.ts: -------------------------------------------------------------------------------- 1 | 2 | // Store native functions because some websites override them. 3 | // Annoying to use this way, but may be worth it to save debugging time. 4 | export const native = { 5 | dispatchEvent: EventTarget.prototype.dispatchEvent, 6 | stopImmediatePropagation: Event.prototype.stopImmediatePropagation, 7 | appendChild: Node.prototype.appendChild, 8 | elementRemove: Element.prototype.remove, 9 | map: { 10 | clear: Map.prototype.clear, 11 | set: Map.prototype.set, 12 | has: Map.prototype.has, 13 | get: Map.prototype.get 14 | }, 15 | array: { 16 | push: Array.prototype.push, 17 | includes: Array.prototype.includes, 18 | }, 19 | Map, 20 | ShadowRoot, 21 | HTMLMediaElement, 22 | CustomEvent, 23 | JSON: { 24 | parse: JSON.parse, 25 | stringify: JSON.stringify 26 | } 27 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["DOM", "DOM.iterable", "ESNext", "WebWorker"], 7 | "types": ["@types/chrome", "@types/react", "@types/react-dom", "@types/audioworklet"], 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | 14 | "noImplicitAny": true, 15 | "allowJs": true, 16 | "sourceMap": false, 17 | "jsx": "react-jsx", 18 | 19 | "baseUrl": "./", 20 | "paths": { 21 | "src/*": ["src/*"], 22 | "notFirefox/*": ["src/*"], 23 | "isFirefox/*": ["src/*"] 24 | }, 25 | "outDir": "build" 26 | }, 27 | "exclude": ["./node_modules", "./build", "./buildFf"] 28 | } -------------------------------------------------------------------------------- /src/popup/FxPanel.tsx: -------------------------------------------------------------------------------- 1 | import { getDefaultFx } from "src/defaults" 2 | import { useStateView } from "../hooks/useStateView" 3 | import { FxControl } from "./FxControl" 4 | import { produce } from "immer" 5 | 6 | type FxPanelProps = {} 7 | 8 | export function FxPanel(props: FxPanelProps) { 9 | const [enabledView] = useStateView({enabled: true}) 10 | const [view, setView] = useStateView({backdropFx: true, elementFx: true}) 11 | 12 | if (!view || !enabledView) return
13 | 14 | return ( 15 | { 16 | setView(produce(view, d => { 17 | d["elementFx"] = elementFx 18 | d["backdropFx"] = backdropFx 19 | })) 20 | }}/> 21 | ) 22 | } 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/popup/SvgFilterList.css: -------------------------------------------------------------------------------- 1 | 2 | .SvgFilterList { 3 | border-top: 1px solid var(--border); 4 | margin-top: 15px; 5 | 6 | & > .header { 7 | font-size: 0.9em; 8 | text-align: center; 9 | opacity: 0.5; 10 | margin-top: 6px; 11 | } 12 | 13 | & > .list { 14 | 15 | } 16 | 17 | & > .controls { 18 | display: flex; 19 | column-gap: 10px; 20 | margin-top: 10px; 21 | } 22 | 23 | 24 | 25 | div { 26 | &.active { 27 | color: var(--header-icon-active-color); 28 | 29 | &.beat { 30 | animation: 1s ease-in beat infinite; 31 | } 32 | 33 | &:hover { 34 | opacity: 0.9; 35 | } 36 | } 37 | 38 | &.muted { 39 | color: var(--header-icon-muted-color); 40 | 41 | &:hover { 42 | opacity: 0.9; 43 | } 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /static/placer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | 29 | 30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /src/comps/ModalText.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { ThrottledTextInput } from "./ThrottledTextInput" 3 | import { ModalBase } from "./ModalBase" 4 | import { GearIcon } from "./GearIcon" 5 | import "./ModalText.css" 6 | 7 | type ModalTextProps = { 8 | value: string, 9 | onChange: (newValue: string) => void, 10 | label?: string 11 | } 12 | 13 | export function ModalText(props: ModalTextProps) { 14 | const [modal, setModal] = useState(false) 15 | 16 | return
17 | {modal ? ( 18 | { 19 | setModal(false) 20 | }}> 21 | { 22 | props.onChange(v) 23 | }}/> 24 | 25 | ) : ( 26 | setModal(!modal)}/> 27 | )} 28 |
29 | } -------------------------------------------------------------------------------- /src/comps/SliderMicro.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .SliderMicro { 4 | background-color: var(--fg-color); 5 | --label-color: var(--text-color); 6 | --reset-color: var(--icon-color); 7 | --font-weight: 400; 8 | --opacity: 0.5; 9 | 10 | &.highlight { 11 | --label-color: var(--header-icon-active-color); 12 | --reset-color: var(--header-icon-active-color); 13 | --font-weight: 600; 14 | --opacity: 1; 15 | } 16 | 17 | 18 | 19 | display: grid; 20 | grid-template-columns: 1fr max-content; 21 | grid-column-gap: 5px; 22 | margin-bottom: 2px; 23 | align-items: center; 24 | 25 | &.withInput { 26 | grid-template-columns: 1fr 60px max-content; 27 | } 28 | 29 | &.withLabel { 30 | grid-template-columns: 80px 1fr max-content; 31 | } 32 | 33 | &.withLabel.withInput { 34 | grid-template-columns: 80px 1fr 60px max-content; 35 | } 36 | 37 | & > input[type="range"] { 38 | width: 100%; 39 | } 40 | } -------------------------------------------------------------------------------- /src/utils/nativeUtils.ts: -------------------------------------------------------------------------------- 1 | 2 | export function getShadow(x: HTMLElement): ShadowRoot { 3 | if (!x) return 4 | return chrome.dom ? chrome.dom.openOrClosedShadowRoot(x) : (x as any).openOrClosedShadowRoot 5 | } 6 | 7 | 8 | export function getLeaf(document: DocumentOrShadowRoot, key: keyof DocumentOrShadowRoot): Element { 9 | let doc: DocumentOrShadowRoot = document 10 | while (true) { 11 | let item = doc[key] 12 | if (!item) return (doc as ShadowRoot)?.host 13 | doc = getShadow(item as HTMLElement) 14 | if (!doc) return item as Element 15 | } 16 | } 17 | 18 | export function getReal(v: T): T { 19 | return ((v as any)?.wrappedJSObject ?? v) as T 20 | } 21 | 22 | export function insertStyle(styles: string, root: Element | ShadowRoot) { 23 | let style = document.createElement("style") 24 | style.innerHTML = styles 25 | root.appendChild(style) 26 | return style 27 | } 28 | -------------------------------------------------------------------------------- /src/offscreen/SoundTouchNode.ts: -------------------------------------------------------------------------------- 1 | 2 | export class SoundTouchNode extends AudioWorkletNode { 3 | static addedModule = false 4 | static triedAddingModule = false 5 | static addModule = async (ctx: AudioContext) => { 6 | if (SoundTouchNode.addedModule) return 7 | if (SoundTouchNode.triedAddingModule) throw Error("Already failed adding module") 8 | await ctx.audioWorklet.addModule('sound-touch-processor.js') 9 | SoundTouchNode.addedModule = true 10 | } 11 | constructor(private ctx: AudioContext) { 12 | super(ctx, 'sound-touch-processor', {channelCount: 2, channelCountMode: "clamped-max"}) 13 | 14 | if (this.ctx.sampleRate !== 44100) { 15 | console.warn("Audio context sample rate should be 44100.") 16 | } 17 | } 18 | release = () => { 19 | this.port.postMessage({type: "RELEASE"}) 20 | } 21 | update = (semitone: number) => { 22 | this.parameters.get("semitone").value = semitone 23 | } 24 | } -------------------------------------------------------------------------------- /src/hooks/useStateView.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback, useMemo } from "react" 2 | import { StateView, StateViewSelector } from "../types" 3 | import { SubscribeView } from "../utils/state" 4 | 5 | type Env = { 6 | client?: SubscribeView 7 | } 8 | 9 | export function useStateView(selector: StateViewSelector | (keyof StateView)[], wait?: number, maxWait?: number): [StateView, SetView] { 10 | const [view, _setView] = useState(null as StateView) 11 | const env = useMemo(() => ({} as Env), []) 12 | 13 | useEffect(() => { 14 | env.client = new SubscribeView(selector, gvar.tabInfo?.tabId, true, _setView, wait, maxWait) 15 | 16 | return () => { 17 | env.client.release() 18 | delete env.client 19 | } 20 | }, []) 21 | 22 | let setView = useCallback((view: StateView) => { 23 | env.client?.push(view) 24 | }, []) 25 | 26 | return [view, setView] 27 | } 28 | 29 | 30 | export type SetView = (view: StateView) => void -------------------------------------------------------------------------------- /src/contentScript/pane/styles.css: -------------------------------------------------------------------------------- 1 | 2 | .pane:focus { outline: none; } 3 | .pane.hasBorder:focus { outline: 2px solid black; } 4 | 5 | .menu { 6 | position: absolute; 7 | right: 3px; 8 | top: 3px; 9 | opacity: 0; 10 | display: grid; 11 | pointer-events: none; 12 | grid-auto-flow: column; 13 | justify-content: right; 14 | grid-column-gap: 5px; 15 | font-size: 16px; 16 | transition: opacity 0.1s ease-out; 17 | } 18 | 19 | .inner { 20 | width: 100%; 21 | height: 100%; 22 | } 23 | 24 | .pane:focus > .menu, .pane:hover > .menu { 25 | opacity: 1; 26 | pointer-events: initial; 27 | } 28 | 29 | svg { 30 | opacity: 0.5; 31 | background-color: black; 32 | color: white; 33 | padding: 5px; 34 | } 35 | 36 | svg:hover { 37 | opacity: 1; 38 | } 39 | 40 | input[type="color"] { 41 | position: absolute; 42 | pointer-events: none; 43 | opacity: 0; 44 | width: 0px; 45 | height: 0px; 46 | } -------------------------------------------------------------------------------- /src/popup/OrlHeader.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { BsXCircle, BsArrowUpCircle } from "react-icons/bs" 3 | import { useStateView } from "src/hooks/useStateView" 4 | import "./OrlHeader.css" 5 | 6 | type OrlHeaderProps = {} 7 | 8 | export function OrlHeader(props: OrlHeaderProps) { 9 | const [view, setView] = useStateView({hasOrl: true, minimizeOrlBanner: true, hideOrlBanner: true}) 10 | if (!view || !view.hasOrl || view.hideOrlBanner) return
11 | const m = view.minimizeOrlBanner 12 | 13 | return
{ 14 | setView({minimizeOrlBanner: m ? null : true}) 15 | }}> 16 | {m ? null : <> 17 | {gvar.gsm.options.rules.status} 18 | 19 | { 20 | setView({hasOrl: false}) 21 | e.stopPropagation() 22 | }} size={"1.285rem"}/> 23 | } 24 |
25 | } -------------------------------------------------------------------------------- /src/hooks/useCaptureStatus.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import { MessageCallback } from "../utils/browserUtils" 3 | 4 | declare global { 5 | interface Message { 6 | CAPTURE_STATUS: {type: "CAPTURE_STATUS", tabId: number, value: boolean}, 7 | } 8 | } 9 | 10 | export function useCaptureStatus() { 11 | const [status, setStatus] = useState(null as boolean) 12 | 13 | useEffect(() => { 14 | const handleMessage: MessageCallback = (msg: Messages, sender, reply) => { 15 | if (msg.type === "CAPTURE_STATUS") { 16 | if (msg.tabId === gvar.tabInfo.tabId) { 17 | setStatus(msg.value) 18 | reply(true) 19 | return 20 | } 21 | } 22 | } 23 | chrome.runtime.onMessage.addListener(handleMessage) 24 | chrome.runtime.sendMessage({type: "REQUEST_CAPTURE_STATUS", tabId: gvar.tabInfo.tabId, ping: true}) 25 | 26 | return () => { 27 | chrome.runtime.onMessage.removeListener(handleMessage) 28 | } 29 | }, []) 30 | 31 | return status 32 | } -------------------------------------------------------------------------------- /src/contentScript/main/utils/seekNetflix.ts: -------------------------------------------------------------------------------- 1 | 2 | declare global { 3 | interface Window { 4 | netflix?: any 5 | } 6 | } 7 | 8 | function getNetflixVideoPlayers() { 9 | const videoPlayer = window.netflix.appContext.state.playerApp.getAPI().videoPlayer 10 | const sessionIds = videoPlayer.getAllPlayerSessionIds() 11 | let players = sessionIds.map((id: any) => ( 12 | videoPlayer.getVideoPlayerBySessionId(id) 13 | )).filter((v: any) => v.isReady()) 14 | if (players.length > 1) { 15 | return players.filter((v: any) => v.isPlaying()) 16 | } 17 | return players 18 | } 19 | 20 | export function seekNetflix(value: number) { 21 | try { 22 | getNetflixVideoPlayers().forEach((player: any) => { 23 | let time = value * 1000 24 | try { 25 | let video = player.getElement().querySelector('video') 26 | let start = video.currentTime - (player.getCurrentTime() / 1000) 27 | time = (value - start) * 1000 28 | } catch {} 29 | player.seek(time) 30 | }) 31 | } catch (err) { 32 | 33 | } 34 | } -------------------------------------------------------------------------------- /src/contentScript/isolated/utils/isWebsite.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | export let IS_NETFLIX = false 5 | export let IS_AMAZON = false 6 | export let IS_BILIBILI = false 7 | export let IS_YOUTUBE = false 8 | export let IS_VIMEO = false 9 | export let IS_REDDIT = false 10 | export let IS_FACEBOOK = false 11 | 12 | if (location.hostname === "www.netflix.com") { 13 | IS_NETFLIX = true 14 | } else if ((document.URL || "").startsWith("www.amazon")) { 15 | IS_AMAZON = true 16 | } else if (location.hostname === "www.bilibili.com") { 17 | IS_BILIBILI = true 18 | } else if (location.hostname === "www.youtube.com") { 19 | IS_YOUTUBE = true 20 | } else if (location.hostname === "vimeo.com") { 21 | IS_VIMEO = true 22 | } else if (location.hostname.endsWith("reddit.com")) { 23 | IS_REDDIT = true 24 | } else if (location.hostname.endsWith("facebook.com")) { 25 | IS_FACEBOOK = true 26 | } 27 | 28 | export const IS_SPECIAL_SEEK = IS_NETFLIX || IS_AMAZON 29 | export const IS_NATIVE = !(IS_NETFLIX || IS_FACEBOOK) 30 | export const IS_SMART = !(IS_VIMEO || IS_REDDIT) 31 | 32 | -------------------------------------------------------------------------------- /src/faqs/faqs.css: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | background-color: #ddd; 4 | font-family: "Segoe UI", "Avenir", system-ui, Courier, monospace; 5 | font-size: 1rem; 6 | display: grid; 7 | justify-content: center; 8 | padding: 20px; 9 | } 10 | 11 | .Group { 12 | 13 | h2 { 14 | margin-top: 0; 15 | } 16 | 17 | width: 800px; 18 | background-color: white; 19 | padding: 20px; 20 | margin-bottom: 30px; 21 | } 22 | 23 | .Item { 24 | margin-bottom: 10px; 25 | 26 | &.marginTop { 27 | margin-top: 40px; 28 | } 29 | 30 | & > .header { 31 | display: grid; 32 | grid-template-columns: max-content 1fr; 33 | grid-column-gap: 10px; 34 | font-size: 1.3em; 35 | align-items: center; 36 | padding: 5px; 37 | 38 | & > button:first-child { 39 | min-width: 30px; 40 | } 41 | } 42 | } 43 | 44 | 45 | code { 46 | background-color: #eee; 47 | 48 | &.yellow { 49 | background-color: yellow; 50 | font-weight: bold; 51 | } 52 | } 53 | 54 | li { 55 | margin-bottom: 10px; 56 | 57 | &:last-child { 58 | margin-bottom: 0px; 59 | } 60 | } 61 | 62 | ul { 63 | list-style-type: none; 64 | } -------------------------------------------------------------------------------- /src/background/utils/session.ts: -------------------------------------------------------------------------------- 1 | 2 | declare global { 3 | interface GlobalVar { 4 | sess: Session 5 | } 6 | } 7 | 8 | 9 | class Session { 10 | installCbs: Set<() => void> = new Set() 11 | safeCbs: Set<() => void> = new Set() 12 | safeStartupCbs: Set<() => void> = new Set() 13 | #loadedForSession = false 14 | constructor() { 15 | chrome.runtime.onInstalled.addListener(this.handleInstall) 16 | chrome.runtime.onStartup.addListener(this.handleStartup) 17 | } 18 | handleInstall = async () => { 19 | if (this.#loadedForSession) return 20 | this.#loadedForSession = true 21 | this.installCbs.forEach(cb => cb()) 22 | this.handleCommon() 23 | } 24 | handleStartup = async () => { 25 | if (this.#loadedForSession) return 26 | this.#loadedForSession = true 27 | this.handleCommon() 28 | } 29 | handleCommon = async () => { 30 | await gvar.installPromise 31 | this.safeCbs.forEach(cb => cb()) 32 | this.safeStartupCbs.forEach(cb => cb()) 33 | } 34 | } 35 | 36 | gvar.sess = new Session() 37 | 38 | export {} 39 | -------------------------------------------------------------------------------- /src/comps/ModalBase.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { useEffect } from "react" 3 | import { ReactElement, useRef } from "react" 4 | import "./ModalBase.css" 5 | 6 | type Props = { 7 | children: ReactElement, 8 | onClose: () => void, 9 | color?: string, 10 | keepOnWheel?: boolean, 11 | passThrough?: boolean 12 | } 13 | 14 | export function ModalBase(props: Props) { 15 | const ref = useRef() 16 | 17 | useEffect(() => { 18 | if (props.keepOnWheel || props.passThrough) { 19 | return 20 | } 21 | 22 | const handleScroll = (e: Event) => { 23 | props.onClose() 24 | } 25 | 26 | document.addEventListener("wheel", handleScroll, {passive: true, capture: true}) 27 | return () => { 28 | document.removeEventListener("wheel", handleScroll, true) 29 | } 30 | }, [props.keepOnWheel, props.passThrough]) 31 | 32 | return
{ 33 | if (e.target === ref.current) { 34 | props.onClose() 35 | } 36 | }} className={`ModalBase ${props.passThrough ? "passThrough" : ""}`}> 37 | {props.children} 38 |
39 | } 40 | -------------------------------------------------------------------------------- /src/contentScript/isolated/utils/DetectOpen.ts: -------------------------------------------------------------------------------- 1 | import { randomId } from "src/utils/helper" 2 | 3 | 4 | export class DetectOpen { 5 | eventFlag = false 6 | eventName = randomId() 7 | cbs = new Set() as Set<() => void> 8 | constructor() { 9 | document.addEventListener(this.eventName, this.handleEvent, {capture: true}) 10 | this.mo.observe(document, {childList: true}) 11 | } 12 | handleEvent = (e: Event) => { 13 | e.stopImmediatePropagation() 14 | this.eventFlag = true 15 | } 16 | handleMut = (muts: MutationRecord[]): void => { 17 | if (muts.some(mut => { 18 | for (let r of mut.removedNodes) { 19 | if (r.nodeName === "HTML") return true 20 | } 21 | })) { 22 | 23 | document.dispatchEvent(new Event(this.eventName)) 24 | const flag = this.eventFlag 25 | this.eventFlag = false 26 | 27 | if (!flag) { 28 | document.addEventListener(this.eventName, this.handleEvent, {capture: true}) 29 | this.cbs.forEach(cb => cb()) 30 | } 31 | } 32 | } 33 | mo = new MutationObserver(this.handleMut) 34 | } -------------------------------------------------------------------------------- /src/options/List.tsx: -------------------------------------------------------------------------------- 1 | import { MutableRefObject } from "react" 2 | import "./List.css" 3 | 4 | type ListProps = { 5 | children: React.ReactNode, 6 | listRef: MutableRefObject, 7 | spacingChange: (idx: number) => void 8 | } 9 | 10 | 11 | export function List(props: ListProps) { 12 | return ( 13 |
handlePointerDown(props.listRef, props.spacingChange, e)}> 14 | {props.children} 15 |
16 | ) 17 | } 18 | 19 | function handlePointerDown(listRef: React.MutableRefObject, onSpacingChange: ListProps['spacingChange'], e: React.MouseEvent) { 20 | if (!(e.target === listRef.current || (e.target as HTMLElement).classList.contains('ListItemLabel') || (e.target as HTMLElement).classList.contains('ListItemSub'))) return 21 | 22 | 23 | const y = e.clientY 24 | const children = [...(listRef.current as HTMLDivElement).getElementsByClassName("ListItemCore")] 25 | 26 | let index = -1 27 | for (let child of children) { 28 | if (y < child.getBoundingClientRect().y) break 29 | index++ 30 | } 31 | 32 | index >= 0 && onSpacingChange(index) 33 | } 34 | 35 | -------------------------------------------------------------------------------- /tools/replaceCtx.js: -------------------------------------------------------------------------------- 1 | // /// 2 | 3 | // Must be done after Webpack builds for Firefox. 4 | // Replaces $$$CTX$$$ placeholder within mainLoader.js to main.js code. 5 | 6 | const { readFileSync, writeFileSync, unlinkSync } = require("fs") 7 | 8 | function replace(targetPath, contentPath, stub) { 9 | let cs = readFileSync(targetPath, {encoding: "utf8"}) 10 | let ctx = readFileSync(contentPath, {encoding: "utf8"}) 11 | 12 | let placeholderCount = 0 13 | 14 | // In loop, since if webpack is being built for development their may be multiple copies. 15 | // In production mode, only 1 copy should remain. 16 | for (let i = 0; i < 10; i++) { 17 | if (cs.indexOf(stub) === -1) break 18 | cs = cs.replace(stub, JSON.stringify(ctx).slice(1, -1)) 19 | placeholderCount++ 20 | } 21 | 22 | if (!placeholderCount) throw Error(`This shouldn't happen. Could not find ${stub} placeholder.`) 23 | 24 | writeFileSync(targetPath, cs, {encoding: "utf8", flags: "w+"}) 25 | console.log(`REPLACED ${stub} PLACEHOLDER (${placeholderCount})`) 26 | } 27 | 28 | function main() { 29 | replace(`buildFf/unpacked/mainLoader.js`, `buildFf/unpacked/main.js`, "$$$CTX$$$") 30 | } 31 | 32 | main() -------------------------------------------------------------------------------- /src/popup/FxControl.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | #App.firefox .FxControl { 4 | margin-right: 20px; 5 | } 6 | 7 | .FxControl { 8 | font-size: 1rem; 9 | background-color: var(--fg-color); 10 | user-select: none; 11 | 12 | & > * { 13 | margin-bottom: 10px; 14 | } 15 | 16 | & > .controls { 17 | display: grid; 18 | grid-template-columns: 1fr 1fr 1fr; 19 | grid-column-gap: 10px; 20 | 21 | & > button { 22 | color: var(--header-icon-color); 23 | &.active { 24 | color: var(--header-icon-active-color); 25 | } 26 | &.muted { 27 | color: var(--header-icon-muted-color); 28 | } 29 | 30 | &.active.levelup { 31 | outline: 1px solid var(--header-icon-active-color); 32 | & > svg { 33 | transform: scale(1.15); 34 | } 35 | } 36 | } 37 | } 38 | 39 | & > .buttons { 40 | display: grid; 41 | grid-template-columns: 1fr 1fr; 42 | column-gap: 10px; 43 | } 44 | 45 | & > .selector { 46 | ma12px-top: 10px; 47 | display: grid; 48 | grid-row-gap: 5px; 49 | } 50 | 51 | & > .origin { 52 | display: grid; 53 | grid-template-columns: 1fr max-content max-content; 54 | gr12pxolumn-gap: 10px; 55 | } 56 | } -------------------------------------------------------------------------------- /tools/generateCircles.js: -------------------------------------------------------------------------------- 1 | const { access, constants, rm, mkdir, writeFile } = require("fs").promises 2 | // /// 3 | 4 | const { join } = require("path") 5 | const CIRCLES_PATH = "static/circles/" 6 | 7 | async function main() { 8 | if (!await pathExists("static")) return console.error("Static folder does not exist.") 9 | try { 10 | await rm(CIRCLES_PATH, {recursive: true}) 11 | } catch (err) {} 12 | mkdir(CIRCLES_PATH) 13 | for (let i = 1; i < 9; i++) { 14 | const diameter = Math.round(i * 16) 15 | const svg = createCircleSvg(diameter) 16 | await writeFile(join(CIRCLES_PATH, `${diameter}.svg`), svg) 17 | } 18 | } 19 | 20 | async function pathExists(path) { 21 | try { 22 | await access(path, constants.W_OK) 23 | return true 24 | } catch (err) { 25 | return false 26 | } 27 | } 28 | 29 | main() 30 | 31 | function createCircleSvg(diameter) { 32 | const radius = Math.round(diameter / 2) 33 | return `` 34 | } 35 | -------------------------------------------------------------------------------- /src/contentScript/isolated/utils/Indicator.css: -------------------------------------------------------------------------------- 1 | @keyframes keyframe_1 { 2 | from { transform: scale(0.95); opacity: 1 } 3 | to { transform: scale(1.05); opacity: 0 } 4 | } 5 | 6 | @keyframes keyframe_3 { 7 | 0% { transform: scale(1) } 8 | 80% { transform: scale(0.75) } 9 | 100% { transform: scale(0) } 10 | } 11 | 12 | @keyframes keyframe_4 { 13 | 0% { transform: scale(1) } 14 | 60% { transform: scale(0.95) } 15 | 90% { transform: scale(0.5); opacity: 0.5 } 16 | 100% { transform: scale(1.2); opacity: 0 } 17 | } 18 | 19 | @keyframes keyframe_5 { 20 | 0% { transform: scale(0.95) } 21 | 30% { transform: scale(1.05) } 22 | 100% { transform: scale(0) rotateZ(100deg); opacity: 0 } 23 | } 24 | 25 | 26 | :is(div:popover-open, div.popoverOpenYah, #proo) { 27 | border: none; 28 | position: fixed; 29 | width: 100vw; 30 | left: 0px; 31 | top: 0px; 32 | height: 100vh; 33 | margin: 0px; 34 | pointer-events: none; 35 | white-space: pre; 36 | grid-auto-flow: column; 37 | align-items: center; 38 | justify-content: center; 39 | display: grid !important; 40 | background-color: transparent; 41 | font-family: "Segoe UI", "Avenir", system-ui, Courier, monospace; 42 | } -------------------------------------------------------------------------------- /src/background/utils/KeepAlive.ts: -------------------------------------------------------------------------------- 1 | export class KeepAlive { 2 | intervalId: number 3 | started = Date.now() 4 | softMax = Date.now() + 60_000 * 10 5 | hardMax: number 6 | constructor(minutes: number) { 7 | this.softMax = this.started + 60_000 * minutes 8 | this.intervalId = setInterval(this.handleInterval, 20_000) 9 | } 10 | release = () => { 11 | clearInterval(this.intervalId) 12 | } 13 | handleInterval = async () => { 14 | const now = Date.now() 15 | if (now > this.softMax || now > this.hardMax) { 16 | this.release() 17 | } 18 | await chrome.storage.local.get("g:version") 19 | } 20 | static keepAlive?: KeepAlive 21 | static start(minutes: number) { 22 | if (KeepAlive.keepAlive) { 23 | const delta = KeepAlive.keepAlive.softMax - Date.now() 24 | if (delta > 60_000 * minutes) { 25 | return 26 | } 27 | KeepAlive.keepAlive.release() 28 | delete KeepAlive.keepAlive 29 | } 30 | KeepAlive.keepAlive = new KeepAlive(minutes) 31 | } 32 | static clear() { 33 | KeepAlive.keepAlive?.release() 34 | delete KeepAlive.keepAlive 35 | } 36 | } -------------------------------------------------------------------------------- /src/utils/hash.ts: -------------------------------------------------------------------------------- 1 | 2 | // This is weak so used only for non-critical uses, only stored locally on user's computer. 3 | // Sometimes the domain needs to be stored locally, but it's better to hash a little than none. 4 | let encoder: TextEncoder 5 | export async function hash(content: string, salt: string) { 6 | encoder = encoder ?? new TextEncoder() 7 | content = `${content}:${salt}` 8 | return bufferToHex(await crypto.subtle.digest('sha-1', encoder.encode(content))) 9 | } 10 | 11 | 12 | function bufferToHex(buf: ArrayBuffer) { 13 | return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('') 14 | } 15 | 16 | function generateSalt() { 17 | return crypto.randomUUID().slice(0, 18).replaceAll('-', '') 18 | } 19 | 20 | // Remember to call and await this before hashing to avoid changing salt. 21 | export async function getStoredSalt() { 22 | let salt = (await chrome.storage.local.get('f:salt'))['f:salt'] 23 | if (!salt) { 24 | salt = generateSalt() 25 | await chrome.storage.local.set({'f:salt': salt}) 26 | } 27 | return salt 28 | } 29 | 30 | export async function hashWithStoredSalt(content: string, truncate = 6) { 31 | return (await hash(content, await getStoredSalt())).slice(0, truncate ?? 99) 32 | } 33 | -------------------------------------------------------------------------------- /src/options/SectionRules.css: -------------------------------------------------------------------------------- 1 | 2 | .SectionRules { 3 | & > .List .Rule { 4 | display: grid; 5 | grid-column-gap: 10px; 6 | grid-template-columns: max-content 600px 1fr repeat(2, max-content); 7 | align-items: center; 8 | 9 | & > .show { 10 | border-radius: 5px; 11 | } 12 | 13 | & > .left { 14 | display: grid; 15 | align-items: center; 16 | justify-content: left; 17 | grid-auto-flow: column; 18 | grid-auto-columns: max-content; 19 | column-gap: 10px; 20 | 21 | & > .NumericInput { 22 | width: 60px; 23 | } 24 | 25 | & > .FxControlButton { 26 | & > .wrapper { 27 | position: fixed; 28 | left: 0; 29 | top: 0; 30 | width: 100vw; 31 | height: 100vh; 32 | z-index: 9999999999; 33 | background-color: #00000066; 34 | display: grid; 35 | justify-content: center; 36 | align-content: center; 37 | 38 | & > .FxControl { 39 | padding: 8px; 40 | max-height: 80vh; 41 | overflow-y: scroll; 42 | width: 300px; 43 | max-width: 400px; 44 | } 45 | } 46 | } 47 | } 48 | } 49 | 50 | & > button.create { 51 | margin-top: 30px; 52 | display: block; 53 | } 54 | } -------------------------------------------------------------------------------- /src/comps/Menu.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { ModalBase } from "./ModalBase" 3 | import { FaCheck } from "react-icons/fa" 4 | import "./Menu.css" 5 | 6 | export type MenuProps = { 7 | position: {x?: number, y?: number, aligned?: boolean, centered?: boolean}, 8 | onClose: () => void, 9 | onSelect: (name: string) => void, 10 | items: {name: string, checked?: boolean, close?: boolean, preLabel?: string, label?: string | React.ReactElement, className?: string}[], 11 | menuRef: React.Ref 12 | } 13 | 14 | export const Menu = (props: MenuProps) => { 15 | let centered = props.position.centered 16 | return 17 |
18 | {props.items.map(v => { 19 | const handleClick = (e: React.MouseEvent) => { 20 | props.onSelect(v.name) 21 | if (v.close) props.onClose() 22 | } 23 | 24 | return
25 | {v.checked === true ? :
{v.preLabel ?? ""}
}
26 | {v.label ?? v.name} 27 |
28 | })} 29 |
30 |
31 | } -------------------------------------------------------------------------------- /src/comps/SliderPlus.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react" 2 | import { clamp } from "../utils/helper" 3 | import { NumericInput } from "../comps/NumericInput" 4 | import { Slider } from "./Slider" 5 | import { Reset } from "./Reset" 6 | import "./SliderPlus.css" 7 | 8 | type SliderPlusProps = { 9 | label: ReactNode, 10 | value: number, 11 | sliderMin: number, 12 | sliderMax: number, 13 | sliderStep?: number, 14 | min?: number, 15 | max?: number, 16 | default: number, 17 | onChange?: (newValue: number) => void, 18 | noReset?: boolean 19 | } 20 | 21 | 22 | export function SliderPlus(props: SliderPlusProps) { 23 | 24 | const handleValueChange = (value: number) => { 25 | props.onChange(clamp(props.min, props.max, value)) 26 | } 27 | 28 | const activated = props.default !== props.value 29 | 30 | return
31 |
32 | {props.label} 33 | 34 | {props.noReset ?
: handleValueChange(props.default)}/>} 35 |
36 | 37 |
38 | } -------------------------------------------------------------------------------- /src/comps/ErrorFallback.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ReactElement } from "react" 2 | import { restoreConfig } from "../utils/state" 3 | import { getDefaultState } from "src/defaults" 4 | import "./ErrorFallback.css" 5 | 6 | export class ErrorFallback extends Component<{children: ReactElement}, {hasError: boolean}> { 7 | state = {hasError: false} 8 | componentDidCatch(error: any, errorInfo: any) { 9 | console.log("ERROR: ", error) 10 | console.log("ERROR INFO: ", errorInfo) 11 | this.setState({hasError: true}) 12 | return 13 | } 14 | handleReset = async () => { 15 | await chrome.storage.local.clear() 16 | await restoreConfig(getDefaultState(), false) 17 | setTimeout(() => { 18 | window.location.reload() 19 | }, 50) 20 | } 21 | handleRefresh = () => { 22 | window.location.reload() 23 | } 24 | render() { 25 | if (this.state.hasError) { 26 | return
27 |
An error occurred.
28 |
    29 |
  1. Try refreshing this page.
  2. 30 |
  3. If that didn't work, click this button to reset the settings.
  4. 31 |
  5. As a final resort, try reinstalling the extension.
  6. 32 |
33 |
34 | } 35 | return this.props.children 36 | } 37 | } -------------------------------------------------------------------------------- /src/popup/MainPanel.tsx: -------------------------------------------------------------------------------- 1 | import { conformSpeed } from "../utils/configUtils" 2 | import { SpeedControl } from "./SpeedControl" 3 | import { MediaView } from "./MediaView" 4 | import { useStateView } from "../hooks/useStateView" 5 | import { useMediaWatch } from "../hooks/useMediaWatch" 6 | import { QrPromo } from "./QrPromo" 7 | 8 | 9 | export function MainPanel(props: {}) { 10 | const [view, setView] = useStateView({speed: true, hideMediaView: true, enabled: true, speedChangeCounter: true}) 11 | if (!view) return
12 | 13 | return ( 14 |
15 | { 16 | setView({ 17 | speed: conformSpeed(v), 18 | enabled: true, 19 | latestViaShortcut: false, 20 | speedChangeCounter: (view.speedChangeCounter || 0) + 1 21 | }) 22 | }}/> 23 | {view.hideMediaView ? null : } 24 | {} 25 |
26 | ) 27 | } 28 | 29 | 30 | 31 | export function MediaViews(props: {}) { 32 | const watchInfo = useMediaWatch() 33 | 34 | if (!watchInfo?.infos?.length) return 35 | 36 | return ( 37 |
38 | {watchInfo.infos.map(info => ( 39 | 40 | ))} 41 |
42 | ) 43 | } -------------------------------------------------------------------------------- /src/options/SectionEditor.css: -------------------------------------------------------------------------------- 1 | 2 | .SectionEditor { 3 | 4 | & > .header { 5 | display: grid; 6 | margin-bottom: 25px; 7 | grid-auto-flow: column; 8 | justify-content: start; 9 | column-gap: 15px; 10 | 11 | 12 | & > div:first-child { 13 | display: grid; 14 | grid-auto-flow: column; 15 | grid-auto-columns: max-content; 16 | grid-column-gap: 10px; 17 | 18 | & > div { 19 | width: 2px; 20 | background-color: var(--icon-vibrant-color); 21 | transform: rotateZ(20deg) scaleY(1.3); 22 | } 23 | } 24 | 25 | .modeSpan { 26 | opacity: 0.6; 27 | 28 | &:hover { 29 | opacity: 1; 30 | } 31 | 32 | & > svg { 33 | margin-left: 8px; 34 | transform: scale(1.2); 35 | margin-right: 5px; 36 | } 37 | } 38 | } 39 | 40 | 41 | & > .keybindControls { 42 | margin-top: 20px; 43 | 44 | &, & > .KeybindControl > .label { 45 | user-select: none; 46 | cursor: ns-resize; 47 | } 48 | 49 | & > .KeybindControl, & > .KeybindControl > .label > span { 50 | user-select: text; 51 | cursor: auto; 52 | } 53 | } 54 | 55 | & > .controls { 56 | margin-top: 30px; 57 | display: grid; 58 | grid-template-columns: repeat(3, max-content) 1fr; 59 | justify-items: right; 60 | align-items: stretch; 61 | grid-column-gap: 10px; 62 | } 63 | } -------------------------------------------------------------------------------- /staticFf/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "__MSG_appName__", 3 | "version": "3.2.46", 4 | "browser_specific_settings": { 5 | "gecko": { 6 | "id": "{f4961478-ac79-4a18-87e9-d2fb8c0442c4}", 7 | "strict_min_version": "112.0", 8 | "data_collection_permissions": { 9 | "required": ["none"] 10 | } 11 | } 12 | }, 13 | "default_locale": "en", 14 | "description": "__MSG_appDesc__", 15 | "manifest_version": 3, 16 | "permissions": ["storage", "webNavigation", "scripting", "contextMenus"], 17 | "optional_permissions": ["clipboardRead", "clipboardWrite"], 18 | "host_permissions": ["https://*/*", "http://*/*", "file://*/*"], 19 | "web_accessible_resources": [ 20 | {"resources": ["circles/*.svg"], "matches": ["http://*/*", "https://*/*"]}, 21 | {"resources": ["main.js"], "matches": ["https://*/*", "http://*/*"]} 22 | ], 23 | "action": { 24 | "default_popup": "popup.html" 25 | }, 26 | "icons": { "128": "icons/128.png" }, 27 | "background": { 28 | "scripts": ["background.js"] 29 | }, 30 | "content_scripts": [{ 31 | "matches": ["https://*/*", "http://*/*", "file://*/*"], 32 | "exclude_matches": ["https://*.ubs.com/*"], 33 | "js": ["isolated.js", "mainLoader.js"], 34 | "all_frames": true, 35 | "match_about_blank": true, 36 | "run_at": "document_start" 37 | }], 38 | "options_ui": { 39 | "open_in_tab": true, 40 | "page": "options.html" 41 | } 42 | } -------------------------------------------------------------------------------- /src/background/utils/HandleChangesDebounced.ts: -------------------------------------------------------------------------------- 1 | import debounce from "lodash.debounce" 2 | import type { DebouncedFunc, DebounceSettingsLeading } from "lodash" 3 | 4 | type HandleChangesFunction = (changes: chrome.storage.StorageChanges) => void 5 | 6 | export class HandleChangesDebounced { 7 | private changes: chrome.storage.StorageChanges = {} 8 | private wrapDeb: DebouncedFunc 9 | released = false 10 | constructor(public rawHandler: HandleChangesFunction, wait: number, init?: DebounceSettingsLeading) { 11 | this.wrapDeb = debounce(this.wrap, wait, init) 12 | } 13 | release = () => { 14 | if (this.released) return 15 | this.released = true 16 | this.wrapDeb?.cancel() 17 | delete this.wrapDeb 18 | } 19 | private consume = () => { 20 | let changes = this.changes 21 | this.changes = {} 22 | return changes 23 | } 24 | private wrap = () => { 25 | this.rawHandler(this.consume()) 26 | } 27 | public handler = (changes: chrome.storage.StorageChanges) => { 28 | for (let key in changes) { 29 | if (this.changes[key]) { 30 | this.changes[key] = {oldValue: this.changes[key].oldValue, newValue: changes[key].newValue} 31 | } else { 32 | this.changes[key] = changes[key] 33 | } 34 | } 35 | this.wrapDeb() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/comps/Minmax.tsx: -------------------------------------------------------------------------------- 1 | import { NumericInput } from "./NumericInput" 2 | import { domRectGetOffset, feedbackText } from "src/utils/helper" 3 | import "./Minmax.css" 4 | 5 | type MinmaxProps = { 6 | onChange: (min: number, max: number) => void 7 | min: number, 8 | max: number, 9 | defaultMin: number, 10 | defaultMax: number, 11 | realMin?: number, 12 | realMax?: number, 13 | noNull?: boolean 14 | } 15 | 16 | export function Minmax (props: MinmaxProps) { 17 | return
18 | { 21 | props.onChange(v, props.max) 22 | }} 23 | min={props.realMin} 24 | max={props.realMax} 25 | noNull={props.noNull} 26 | placeholder={props.defaultMin?.toString()} 27 | onFocus={e => { 28 | feedbackText(gvar.gsm.token.min, domRectGetOffset((e.currentTarget as any).getBoundingClientRect(), 20, -50, true)) 29 | }} 30 | /> 31 | { 34 | props.onChange(props.min, v) 35 | }} 36 | min={props.realMin} 37 | max={props.realMax} 38 | noNull={props.noNull} 39 | placeholder={props.defaultMax?.toString()} 40 | onFocus={e => { 41 | feedbackText(gvar.gsm.token.max, domRectGetOffset((e.currentTarget as any).getBoundingClientRect(), 20, -50, true)) 42 | }} 43 | /> 44 | 45 |
46 | } -------------------------------------------------------------------------------- /src/offscreen/ReverseNode.ts: -------------------------------------------------------------------------------- 1 | import { assertType } from "src/utils/helper" 2 | 3 | declare global { 4 | interface AudioWorkletMessage { 5 | RELEASED: {type: "RELEASED"} 6 | RELEASE: {type: "RELEASE"} 7 | PLAYING: {type: "PLAYING"} 8 | } 9 | } 10 | 11 | export class ReverseNode extends AudioWorkletNode { 12 | static addedModule = false 13 | static triedAddingModule = false 14 | static addModule = async (ctx: AudioContext) => { 15 | if (ReverseNode.addedModule) return 16 | if (ReverseNode.triedAddingModule) throw Error("Already failed adding module") 17 | await ctx.audioWorklet.addModule('reverse-sound-processor.js') 18 | ReverseNode.addedModule = true 19 | } 20 | endedCb: (tabId: number) => void 21 | playingCb: (tabId: number) => void 22 | ended = false 23 | constructor(private ctx: AudioContext, private tabId: number, maxDuration = 300) { 24 | super(ctx, 'reverse-sound-processor', {channelCount: 1, channelCountMode: "explicit", processorOptions: {maxSize: maxDuration * ctx.sampleRate}}) 25 | this.port.onmessage = ({data}) => { 26 | assertType(data) 27 | if (data.type === "RELEASED") { 28 | this.ended = true 29 | this.endedCb?.(this.tabId) 30 | } else if (data.type === "PLAYING") { 31 | this.playingCb?.(this.tabId) 32 | } 33 | } 34 | } 35 | forceEnd = () => { 36 | if (this.ended) return 37 | this.port.postMessage({type: "RELEASE"} as AudioWorkletMessages) 38 | } 39 | } -------------------------------------------------------------------------------- /src/options/options.tsx: -------------------------------------------------------------------------------- 1 | import { Root, createRoot } from "react-dom/client" 2 | import { SectionFlags } from "./SectionFlags" 3 | import { SectionEditor } from "./SectionEditor" 4 | import { SectionHelp } from "./SectionHelp" 5 | import { SectionRules } from "./SectionRules" 6 | import { ErrorFallback } from "../comps/ErrorFallback" 7 | import { useThemeSync } from "src/hooks/useThemeSync" 8 | import { loadGsm } from "src/utils/gsm" 9 | import { requestTabInfo } from "src/utils/browserUtils" 10 | import type { Indicator } from "src/contentScript/isolated/utils/Indicator" 11 | import { isMac, isMobile } from "src/utils/helper" 12 | import "./options.css" 13 | 14 | declare global { 15 | interface Window { 16 | root?: Root 17 | 18 | } 19 | 20 | interface GlobalVar { 21 | indicator?: Indicator, 22 | isOptionsPage?: boolean 23 | } 24 | } 25 | 26 | const Options = (props: {}) => { 27 | useThemeSync() 28 | return
29 | 30 | 31 | {!(isMac() && isMobile()) && } 32 | 33 |
34 | } 35 | 36 | if (isMobile()) document.documentElement.classList.add("mobile") 37 | Promise.all([loadGsm(), requestTabInfo()]).then(([gsm, tabInfo]) => { 38 | gvar.isOptionsPage = true 39 | gvar.gsm = gsm 40 | gvar.tabInfo = tabInfo 41 | document.documentElement.lang = gsm._lang 42 | window.root = createRoot(document.querySelector("#root")) 43 | window.root.render() 44 | }) 45 | -------------------------------------------------------------------------------- /src/options/ListItem.css: -------------------------------------------------------------------------------- 1 | .ListItem { 2 | position: relative; 3 | margin-bottom: 10px; 4 | 5 | &.spacing { 6 | margin-bottom: 25px; 7 | } 8 | 9 | &.doubleSpacing { 10 | margin-bottom: 40px; 11 | } 12 | 13 | &:last-child { 14 | margin-bottom: 0; 15 | } 16 | 17 | &.focus { 18 | left: 10px; 19 | z-index: 2; 20 | box-shadow: -4px 4px 4px var(--shadow-color); 21 | } 22 | 23 | & > .ListItemLabel { 24 | & > span { 25 | display: inline-block; 26 | padding: 2px 10px; 27 | background-color: blue; 28 | color: white; 29 | font-size: 0.95em; 30 | user-select: none; 31 | 32 | &:hover { 33 | svg { 34 | display: inline-block; 35 | } 36 | } 37 | 38 | svg { 39 | line-height: none; 40 | display: none; 41 | transform: scale(2); 42 | padding-left: 5px; 43 | } 44 | } 45 | } 46 | 47 | & > .ListItemCore { 48 | display: grid; 49 | grid-template-columns: max-content 1fr max-content; 50 | background-color: var(--fg-color); 51 | align-items: center; 52 | padding-top: 10px; 53 | position: relative; 54 | column-gap: 5px; 55 | } 56 | 57 | & > .ListItemSub { 58 | padding-bottom: 15px; 59 | border-bottom: 1px solid var(--border); 60 | } 61 | } -------------------------------------------------------------------------------- /src/contentScript/isolated/utils/Interactive.css: -------------------------------------------------------------------------------- 1 | 2 | :is(div:popover-open, div.popoverOpenYah) { 3 | background-color: transparent; 4 | position: fixed; 5 | left: 0px; 6 | top: 0px; 7 | width: 100vw; 8 | height: 100vh; 9 | border: none; 10 | margin: 0; 11 | user-select: none; 12 | touch-action: none; 13 | pointer-events: all !important; 14 | 15 | .slider, .ref { 16 | pointer-events: none; 17 | left: 0px; 18 | position: fixed; 19 | border: 1px solid #00000066; 20 | 21 | 22 | &.slider { 23 | pointer-events: none; 24 | background-color: blue; 25 | border-radius: 50%; 26 | } 27 | 28 | &.ref { 29 | background-color: #ff0000; 30 | border-radius: 50%; 31 | opacity: 0.8; 32 | } 33 | } 34 | 35 | 36 | 37 | .cancel, .reset { 38 | position: fixed; 39 | box-sizing: border-box; 40 | font-size: 55px; 41 | line-height: 1; 42 | bottom: calc(10vh - 52px); 43 | background-color: black; 44 | box-shadow: 1px 1px 40px 4px white; 45 | border-radius: 50%; 46 | color: white; 47 | opacity: 0.8; 48 | padding: 25px; 49 | 50 | &:hover { 51 | opacity: 1; 52 | } 53 | 54 | &.cancel { 55 | right: calc(10vw - 52px); 56 | } 57 | 58 | &.reset { 59 | left: calc(10vw - 52px); 60 | display: none; 61 | } 62 | } 63 | 64 | } -------------------------------------------------------------------------------- /src/comps/KeyPicker.tsx: -------------------------------------------------------------------------------- 1 | import { useState, KeyboardEvent } from "react" 2 | import { Hotkey, extractHotkey, formatHotkey } from "../utils/keys" 3 | import "./KeyPicker.css" 4 | 5 | 6 | 7 | type KeyPickerProps = { 8 | onChange: (key: Hotkey) => void 9 | value: Hotkey 10 | virtual: boolean 11 | } 12 | 13 | const MODIFIER_KEYS = ["Control", "Alt", "Shift", "Meta"] 14 | 15 | export const KeyPicker = (props: KeyPickerProps) => { 16 | const [enterState, setEnterState] = useState(false) 17 | 18 | const handleOnKeyDown = (e: KeyboardEvent) => { 19 | if (!enterState && e.key === "Enter") { 20 | setEnterState(!enterState) 21 | return 22 | } 23 | 24 | if (!enterState) { 25 | return 26 | } 27 | 28 | 29 | // skip if modifier fear is target. 30 | if (MODIFIER_KEYS.includes(e.key)) { 31 | return 32 | } 33 | 34 | e.preventDefault() 35 | 36 | props.onChange && props.onChange(extractHotkey(e.nativeEvent, !props.virtual, props.virtual)) 37 | setEnterState(false) 38 | } 39 | 40 | return ( 41 |
setEnterState(false)} 43 | onKeyDown={handleOnKeyDown} 44 | onClick={e => setEnterState(!enterState)} 45 | tabIndex={0} 46 | onContextMenuCapture={e => { 47 | e.preventDefault() 48 | e.stopPropagation() 49 | enterState && setEnterState(false) 50 | props.onChange?.(null) 51 | }} 52 | style={{fontFamily: enterState ? 'monospace' : null}} 53 | className="KeyPicker"> 54 | {enterState ? '...' : formatHotkey(props.value)} 55 |
56 | ) 57 | } -------------------------------------------------------------------------------- /src/options/CommandWarning.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from "react" 2 | import { Keybind, Trigger } from "../types" 3 | import { requestCreateTab } from "../utils/browserUtils" 4 | import { FaLink } from "react-icons/fa" 5 | import { MdWarning } from "react-icons/md" 6 | import "./CommandWarning.css" 7 | 8 | 9 | 10 | type Props = { 11 | keybinds: Keybind[] 12 | } 13 | 14 | export function CommandWarning(props: Props) { 15 | const [show, setShow] = useState(false) 16 | 17 | const env = useRef({} as {keybinds?: Keybind[], show?: boolean}).current 18 | env.show = show 19 | env.keybinds = props.keybinds 20 | 21 | useEffect(() => { 22 | 23 | const handleInterval = () => { 24 | chrome.commands.getAll(cc => { 25 | const target = cc.some(c => c.name.startsWith("command") && c.shortcut && ( 26 | !env.keybinds.some(kb => kb.enabled && kb.trigger === Trigger.GLOBAL && (kb.globalKey || "commandA") === c.name) 27 | )) 28 | target !== env.show && setShow(target) 29 | }) 30 | } 31 | 32 | const intervalId = setInterval(handleInterval, 1000) 33 | 34 | return () => { 35 | clearInterval(intervalId) 36 | } 37 | }, []) 38 | 39 | if (!show) return null 40 | 41 | return
42 | 43 | {gvar.gsm.warnings.unusedGlobal} 44 | 48 |
49 | 50 | 51 | } -------------------------------------------------------------------------------- /src/contentScript/isolated/utils/Circle.css: -------------------------------------------------------------------------------- 1 | 2 | :root[gspointerdown] { 3 | -webkit-touch-callout: none !important; 4 | -webkit-user-select: none; 5 | user-select: none; 6 | } 7 | 8 | :is(div:popover-open, div.popoverOpenYah) { 9 | background-color: transparent; 10 | position: fixed; 11 | left: 0px; 12 | top: 0px; 13 | width: 100vw; 14 | height: 100vh; 15 | pointer-events: none; 16 | user-select: none; 17 | touch-action: none; 18 | -webkit-user-select: none; 19 | margin: 0px; 20 | padding: 0px; 21 | border: none; 22 | transition: 300ms ease-in opacity; 23 | 24 | .circle, .ref, .delete { 25 | position: fixed; 26 | border-radius: 50%; 27 | box-sizing: border-box; 28 | user-select: none; 29 | transition: transform 50ms ease-out, border-color 100ms ease-out, opacity 100ms ease-out; 30 | } 31 | 32 | .circle, .ref { 33 | background-color: white; 34 | } 35 | 36 | .delete { 37 | background-color: black; 38 | color: white; 39 | left: calc(50vw - 30px); 40 | top: calc(50vh + 60px); 41 | width: 60px; 42 | height: 60px; 43 | overflow: hidden; 44 | font-size: 30px; 45 | padding: 20px 0; 46 | text-align: center; 47 | opacity: 0; 48 | } 49 | 50 | .circle { 51 | background-color: white; 52 | pointer-events: none; 53 | touch-action: none; 54 | border: 5px solid white; 55 | z-index: 3; 56 | } 57 | 58 | .ref { 59 | pointer-events: none; 60 | opacity: 0; 61 | z-index: 2; 62 | } 63 | } -------------------------------------------------------------------------------- /src/comps/SliderMicro.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, DetailedHTMLProps, HTMLAttributes } from "react" 2 | import { clamp, round } from "../utils/helper" 3 | import { NumericInput } from "../comps/NumericInput" 4 | import { Slider } from "./Slider" 5 | import { Reset } from "./Reset" 6 | import "./SliderMicro.css" 7 | 8 | type SliderMicroProps = { 9 | label?: ReactNode, 10 | value: number, 11 | sliderMin: number, 12 | sliderMax: number, 13 | sliderStep?: number, 14 | min?: number, 15 | max?: number, 16 | default: number, 17 | onChange?: (newValue: number) => void, 18 | withInput?: boolean, 19 | pass?: DetailedHTMLProps, HTMLDivElement> 20 | } 21 | 22 | 23 | export function SliderMicro(props: SliderMicroProps) { 24 | const handleValueChange = (value: number) => { 25 | props.onChange(clamp(props.min, props.max, value)) 26 | } 27 | 28 | const activated = props.default !== props.value 29 | 30 | return
31 | {props.label != null && {props.label}} 32 | 33 | {props.withInput && ( 34 | { 35 | handleValueChange(v) 36 | }}/> 37 | )} 38 | handleValueChange(props.default)}/> 39 |
40 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Global Speed 2 | Universal speed control for video and audio. 3 | 4 | ## Install the [Chrome](https://chrome.google.com/webstore/detail/global-speed-youtube-netf/jpbjcnkcffbooppibceonlgknpkniiff), [Firefox](https://addons.mozilla.org/firefox/addon/global-speed/), or [Edge](https://microsoftedge.microsoft.com/addons/detail/mjhlabbcmjflkpjknnicihkfnmbdfced) extension. 5 | 6 | ### Speed Control 7 | - Set the speed once and forget: it automatically applies to all video and audio 8 | - Define URL rules to auto-apply your favorite speeds on specific sites 9 | - Compatible with YouTube, Netflix, Spotify, podcast sites, and more 10 | 11 | ### Media Hotkeys 12 | - Conveniently change speed through customizable shortcuts 13 | - Rewind/forward, frame-by-frame analysis, volume up/down and more 14 | - Support for multiple trigger modes, including context menu and global shortcuts; control background music or PiP videos while using another app 15 | 16 | ### Filters & Effects 17 | - Netflix movie too dark? Brighten it and dial in the contrast 18 | - Video too quiet? Boost volume up to 600% 19 | - Listen to songs or shows in a new way with pitch shift 20 | - Optionally assign hotkeys to toggle filters and effects on the fly 21 | _Audio Effects [Chromium Only]_ 22 | 23 | 24 | 25 | 26 | ## Build 27 | 1. `npm install` to install required dependencies. 28 | 1. `npm run build:dev` build unpacked version. 29 | 1. Load the unpacked folder 30 | 1. Chrome: open extensions page, enable dev mode, load unpacked. 31 | 1. Edge: open extensions page, load unpacked. 32 | -------------------------------------------------------------------------------- /src/comps/CycleInput.tsx: -------------------------------------------------------------------------------- 1 | import { NumericInput } from "./NumericInput" 2 | import { produce } from "immer" 3 | import { FaPlus } from "react-icons/fa" 4 | import "./CycleInput.css" 5 | 6 | type CycleInputProps = { 7 | values: number[], 8 | onChange: (newValues: number[]) => void 9 | min?: number, 10 | max?: number, 11 | defaultValue: number 12 | } 13 | 14 | export function CycleInput (props: CycleInputProps) { 15 | return
16 |
17 | {<> 18 | {props.values.map((value, i) => ( 19 |
20 | 21 | {/* Value */} 22 | { 25 | props.onChange(produce(props.values, d => { 26 | d[i] = v 27 | })) 28 | }} 29 | min={props.min} 30 | max={props.max} 31 | noNull={true} 32 | /> 33 | 34 | {/* Delete circle */} 35 | {props.values.length > 0 && ( 36 |
{ 37 | props.onChange(produce(props.values, d => { 38 | d.splice(i, 1) 39 | })) 40 | }}/> 41 | )} 42 |
43 | ))} 44 | 45 | {/* Add button */} 46 |
47 | 54 |
55 | } 56 |
57 |
58 | } -------------------------------------------------------------------------------- /src/placer/styles.css: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | background-color: black; 4 | font-family: Segoe UI, Avenir, system-ui, Courier, monospace; 5 | color: white; 6 | margin: 0; 7 | padding: 0; 8 | height: 100vh; 9 | 10 | display: grid; 11 | justify-content: center; 12 | justify-items: center; 13 | align-items: center; 14 | align-content: center; 15 | font-size: 22px; 16 | 17 | #intro { 18 | font-size: 0.9em; 19 | margin-bottom: 30px; 20 | text-align: center; 21 | max-width: 50vw; 22 | } 23 | 24 | .dims { 25 | display: grid; 26 | grid-template-columns: max-content max-content; 27 | gap: 25px; 28 | padding: 25px; 29 | border: 1px solid #888; 30 | margin-bottom: 25px; 31 | 32 | & > div { 33 | display: grid; 34 | justify-items: center; 35 | row-gap: 10px; 36 | 37 | & > div:first-child { 38 | opacity: 0.7; 39 | font-size: 0.9em; 40 | } 41 | 42 | & > div:last-child { 43 | 44 | } 45 | } 46 | } 47 | 48 | .controls { 49 | display: grid; 50 | grid-auto-flow: column; 51 | justify-content: center; 52 | column-gap: 20px; 53 | 54 | button { 55 | font-size: 0.9em; 56 | font-family: inherit; 57 | background-color: inherit; 58 | color: inherit; 59 | width: max-content; 60 | padding: 8px; 61 | border: 1px solid #888; 62 | 63 | &:hover { 64 | background-color: #448; 65 | } 66 | } 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /src/popup/QrPromo.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { TiDelete } from "react-icons/ti" 3 | import { useStateView } from "src/hooks/useStateView" 4 | import { pushView } from "src/utils/state" 5 | import { isEdge, isMobile } from "src/utils/helper" 6 | import "./QrPromo.css" 7 | 8 | const ALWAYS_SHOW = false 9 | 10 | let wasHidden = false 11 | 12 | export function QrPromo(props: {}) { 13 | const [view, setView] = useStateView({qrCodeHide: true, speedChangeCounter: true, qrCodeSeenCounter: true}) 14 | if (!view || wasHidden) return null 15 | 16 | if (!ALWAYS_SHOW && (view.qrCodeHide || !validUserAgent() || (view.speedChangeCounter || 0) < 20 || view.qrCodeSeenCounter > 60)) { 17 | wasHidden = true 18 | document.documentElement.classList.add("noBottomBorderMediaItem") 19 | return null 20 | } 21 | !ALWAYS_SHOW && indicateSeen(view.qrCodeSeenCounter) 22 | 23 | return
24 |
25 |
{gvar.gsm.options.flags.qrCodeTop}
26 |
{gvar.gsm.options.flags.qrCodeBottom}
27 |
28 | { 29 | chrome.tabs.create({url: "https://edgemobileapp.microsoft.com?adjustId=1mhapodf_1mwtc6ik"}) 30 | }} src={chrome.runtime.getURL("icons/qr.png")}/> 31 | 34 |
35 | } 36 | 37 | let ranAlready = false 38 | function indicateSeen(seenX: number) { 39 | if (ranAlready) return 40 | ranAlready = true 41 | pushView({override: {qrCodeSeenCounter: (seenX || 0) + 1}}) 42 | } 43 | 44 | function validUserAgent() { 45 | return !isMobile() && isEdge() 46 | } -------------------------------------------------------------------------------- /src/options/options.css: -------------------------------------------------------------------------------- 1 | 2 | @import "../_common.css"; 3 | 4 | 5 | #root { 6 | @media only screen and (min-width: 1000px) { 7 | display: grid; 8 | justify-content: center; 9 | } 10 | scrollbar-width: thin; 11 | } 12 | 13 | #App { 14 | margin: 0px; 15 | width: 1000px; 16 | margin-top: 40px; 17 | 18 | .labelWithTooltip { 19 | display: grid; 20 | grid-template-columns: max-content max-content; 21 | align-items: center; 22 | grid-column-gap: 10px; 23 | } 24 | 25 | & > .section { 26 | background-color: var(--fg-color); 27 | padding: 20px; 28 | margin-bottom: 40px; 29 | box-shadow: var(--card-shadow); 30 | 31 | &.promo { 32 | display: inline-block; 33 | cursor: pointer; 34 | 35 | &:hover { 36 | opacity: 0.9; 37 | } 38 | 39 | .a { 40 | color: var(--link-color); 41 | &:hover { 42 | text-decoration: underline; 43 | } 44 | font-weight: bold; 45 | font-size: 1.2em; 46 | 47 | img { 48 | width: 1.3em; 49 | vertical-align: middle; 50 | } 51 | } 52 | 53 | svg { 54 | margin-left: 10px; 55 | 56 | & * { 57 | pointer-events: none; 58 | } 59 | } 60 | } 61 | 62 | & > h2 { 63 | margin-top: 0; 64 | } 65 | } 66 | } 67 | 68 | 69 | 70 | svg.tr85 { transform: scale(0.85) } 71 | svg.tr90 { transform: scale(0.9) } 72 | svg.tr95 { transform: scale(0.95) } 73 | svg.tr103 { transform: scale(1.03) } 74 | svg.tr105 { transform: scale(1.05) } 75 | svg.tr110 { transform: scale(1.1) } 76 | svg.tr115 { transform: scale(1.15) } 77 | svg.tr120 { transform: scale(1.2) } 78 | svg.tr125 { transform: scale(1.25) } 79 | svg.tr130 { transform: scale(1.3) } 80 | svg.tr140 { transform: scale(1.4) } 81 | -------------------------------------------------------------------------------- /src/contentScript/isolated/utils/StratumServer.ts: -------------------------------------------------------------------------------- 1 | 2 | export class StratumServer { 3 | parasite: HTMLDivElement 4 | parasiteRoot: ShadowRoot 5 | wiggleCbs = new Set<(target: Node & ParentNode) => void>() 6 | msgCbs = new Set<(data: any) => void>() 7 | initCbs = new Set<() => void>() 8 | #serverName: string 9 | #clientName: string 10 | initialized = false 11 | 12 | constructor() { 13 | window.addEventListener("GS_INIT", this.handleInit, {capture: true, once: true}) 14 | } 15 | handleInit = (e: CustomEvent) => { 16 | if (!(e.target instanceof HTMLDivElement && e.target.id === "GS_PARASITE" && e.target.shadowRoot)) return 17 | this.parasite = e.target 18 | this.parasiteRoot = e.target.shadowRoot 19 | this.#serverName = `GS_SERVER_${e.detail}` 20 | this.#clientName = `GS_CLIENT_${e.detail}` 21 | 22 | this.parasiteRoot.addEventListener(this.#serverName, this.handle, {capture: true}) 23 | 24 | this.initCbs.forEach(cb => cb()) 25 | this.initCbs.clear() 26 | this.initialized = true 27 | } 28 | handle = (e: CustomEvent) => { 29 | e.stopImmediatePropagation() 30 | let detail: any 31 | try { 32 | detail = JSON.parse(e.detail) 33 | } catch (err) {} 34 | 35 | 36 | if (detail.type === "WIGGLE") { 37 | const parent = this.parasite.parentNode 38 | if (parent) { 39 | this.parasite.remove() 40 | this.wiggleCbs.forEach(cb => cb(parent)) 41 | } 42 | } else if (detail.type === "MSG") { 43 | this.msgCbs.forEach(cb => cb(detail.data || {})) 44 | } 45 | } 46 | send = (data: any) => { 47 | this.parasiteRoot.dispatchEvent(new CustomEvent(this.#clientName, {detail: JSON.stringify(data)})) 48 | } 49 | } -------------------------------------------------------------------------------- /src/popup/SpeedControl.css: -------------------------------------------------------------------------------- 1 | 2 | .SpeedControl { 3 | user-select: none; 4 | font-size: 1.1em; 5 | background-color: var(--fg-color); 6 | 7 | & > .options { 8 | display: grid; 9 | grid-template-columns: repeat(3, 1fr); 10 | justify-items: center; 11 | grid-gap: 3px; 12 | 13 | & > button { 14 | width: 75%; 15 | text-align: center; 16 | border: none; 17 | padding: var(--padding) 0px; 18 | 19 | &:focus { 20 | outline: 1px solid var(--focus-color); 21 | } 22 | 23 | &.selected { 24 | background-color: var(--speed-focus-bg-color); 25 | color: var(--speed-focus-text-color); 26 | border-radius: 3px; 27 | /* font-weight: bold; */ 28 | } 29 | } 30 | 31 | } 32 | 33 | & > .NumericControl { 34 | margin-top: 15px; 35 | 36 | button, input[type="text"] { 37 | padding: var(--padding) 0px !important; 38 | } 39 | } 40 | 41 | & > .slider { 42 | display: grid; 43 | grid-template-columns: max-content 1fr; 44 | align-items: center; 45 | margin-top: 15px; 46 | column-gap: 5px; 47 | 48 | & > svg { 49 | color: var(--header-icon-color); 50 | opacity: 0.5; 51 | 52 | &.active { 53 | opacity: 1; 54 | color: var(--header-icon-active-color); 55 | } 56 | } 57 | } 58 | } 59 | 60 | 61 | .NumericControl { 62 | display: grid; 63 | grid-template-columns: repeat(2, 50fr) 64fr repeat(2, 50fr); 64 | column-gap: 5px; 65 | align-items: stretch; 66 | 67 | & > button { 68 | font-size: 0.75em; 69 | } 70 | 71 | & > button, & input[type="text"] { 72 | padding: none; 73 | text-align: center; 74 | } 75 | 76 | & > .NumericInput > input[type="text"] { 77 | padding: 2px 0; 78 | } 79 | 80 | & > .NumericInput { 81 | font-size: 0.9em; 82 | } 83 | } -------------------------------------------------------------------------------- /src/popup/AudioPanel.css: -------------------------------------------------------------------------------- 1 | 2 | .AudioPanel { 3 | font-size: 0.928rem; 4 | background-color: var(--fg-color); 5 | padding-right: 20px; 6 | 7 | & > * { 8 | margin-bottom: 10px; 9 | } 10 | 11 | & > .capture { 12 | margin-bottom: 15px; 13 | width: 100%; 14 | font-size: 1.3em; 15 | padding: 5px; 16 | margin-top: 10px; 17 | border-width: 3px; 18 | background-color: var(--mg-color); 19 | border-style: dashed; 20 | 21 | &.active { 22 | border-color: var(--focus-color); 23 | border-style: solid; 24 | color: var(--focus-color); 25 | } 26 | } 27 | 28 | & > .SliderPlus { 29 | margin-bottom: 20px; 30 | 31 | /* for pitch HD button. */ 32 | button.toggle { 33 | padding: 0 5px; 34 | font-size: 0.9em; 35 | 36 | &.active { 37 | color: inherit; 38 | border-color: inherit; 39 | } 40 | } 41 | } 42 | 43 | & > .mainControls { 44 | margin-bottom: 30px; 45 | display: grid; 46 | grid-template-columns: 1fr 1fr; 47 | grid-column-gap: 10px; 48 | 49 | & button { 50 | border-width: 2px; 51 | } 52 | } 53 | 54 | & > .control { 55 | display: grid; 56 | grid-template-columns: 1fr max-content; 57 | grid-column-gap: 10px; 58 | 59 | &.split { 60 | margin-bottom: 30px; 61 | } 62 | } 63 | 64 | & > .audioTab { 65 | 66 | & > .controls { 67 | display: grid; 68 | grid-template-columns: 1fr 1fr; 69 | grid-column-gap: 10px; 70 | 71 | & > button { 72 | color: var(--header-icon-color); 73 | &.active { 74 | color: var(--header-icon-active-color); 75 | } 76 | &.muted { 77 | color: var(--header-icon-muted-color); 78 | } 79 | } 80 | } 81 | 82 | & .SliderPlus { 83 | margin-top: 10px; 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /src/options/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react" 2 | import { MoveDrag } from "src/comps/MoveDrag" 3 | import clsx from "clsx" 4 | import { GoX } from "react-icons/go" 5 | import "./ListItem.css" 6 | import { Tooltip } from "src/comps/Tooltip" 7 | 8 | type ListItemProps = { 9 | children?: React.ReactNode, 10 | listRef: React.MutableRefObject, 11 | spacing: number, 12 | label: string, 13 | onMove: (newIndex: number) => void, 14 | onRemove: () => void, 15 | onClearLabel: () => void 16 | 17 | } 18 | 19 | export function ListItem(props: ListItemProps) { 20 | const itemRef = useRef() 21 | const [focus, setFocus] = useState(false) 22 | 23 | return ( 24 |
29 | {props.label && ( 30 |
31 | {props.label} 32 |
33 | )} 34 |
35 | {/* Grippper */} 36 | setFocus(v)} itemRef={itemRef} listRef={props.listRef} onMove={props.onMove} /> 37 | 38 |
39 | {props.children} 40 |
41 | 42 | {/* Delete */} 43 | 44 | 47 | 48 |
49 |
50 |
51 | ) 52 | } 53 | 54 | -------------------------------------------------------------------------------- /src/background/capture.ts: -------------------------------------------------------------------------------- 1 | import debounce from "lodash.debounce" 2 | import { AUDIO_CONTEXT_KEYS, AnyDict } from "src/types" 3 | import { fetchView } from "src/utils/state" 4 | 5 | 6 | 7 | async function handleChange(changes: chrome.storage.StorageChanges) { 8 | let raw = await gvar.es.getAllUnsafe() 9 | if (raw["g:superDisable"]) { 10 | chrome.runtime.sendMessage({type: "OFFSCREEN_PUSH", superDisable: true}) 11 | return 12 | } 13 | const capturedTabs = raw["s:captured"] || [] 14 | if (capturedTabs.length == 0) return 15 | const tabsToPush = checkTabsToPush(changes, raw, capturedTabs) 16 | if (tabsToPush.length) { 17 | const updates = await Promise.all(tabsToPush.map(t => fetchView(AUDIO_CONTEXT_KEYS, t).then(view => ({view, tabId: t})))) 18 | chrome.runtime.sendMessage({type: "OFFSCREEN_PUSH", updates}) 19 | } 20 | } 21 | 22 | 23 | let afxRelevantKeys = [...AUDIO_CONTEXT_KEYS, 'isPinned'] 24 | 25 | function checkTabsToPush(changes: chrome.storage.StorageChanges, raw: AnyDict, captured: number[]): number[] { 26 | let keysChangedFor = [] 27 | 28 | for (let tab of captured) { 29 | // check t:x:afx and a:x:isPinned was changed 30 | if (afxRelevantKeys.some(key => changes[`t:${tab}:${key}`] || changes[`r:${tab}:${key}`])) { 31 | keysChangedFor.push(tab) 32 | continue 33 | } 34 | 35 | // check current a:x:isPinned, and if not pinned, check if g:afx was changed. 36 | if (!raw[`t:${tab}:isPinned`] && AUDIO_CONTEXT_KEYS.some(key => changes[`g:${key}`])) { 37 | keysChangedFor.push(tab) 38 | } 39 | } 40 | 41 | return keysChangedFor 42 | } 43 | 44 | const handleChangeDeb = debounce(handleChange, 500, {maxWait: 500, leading: true, trailing: true}) 45 | 46 | 47 | chrome.tabCapture && chrome.offscreen && gvar.es.addWatcher([], handleChangeDeb) -------------------------------------------------------------------------------- /tools/generateGsmType.js: -------------------------------------------------------------------------------- 1 | // /// 2 | 3 | const { access, constants, writeFile, readFile } = require("fs").promises 4 | const { join } = require("path") 5 | 6 | const EN_PATH = join("static", "locales", "en.json") 7 | const GSM_PATH = join("src", "utils", "GsmType.ts") 8 | 9 | 10 | let newData = "" 11 | async function main() { 12 | if (!await pathExists(EN_PATH)) return console.error("en.json does not exist") 13 | const data = JSON.parse( await readFile(EN_PATH, {encoding: "utf8"})) 14 | walk(data) 15 | writeFile(GSM_PATH, newData, {encoding: "utf8"}) 16 | } 17 | 18 | function walk(d, level = 0) { 19 | if (level === 0) newData = "\nexport type Gsm = {" 20 | const e = Object.entries(d) 21 | for (let i = 0; i < e.length; i++) { 22 | if (e[i][0].startsWith(":")) continue 23 | let postfix = (i === e.length - 1) ? "" : "," 24 | let l = level + 1 25 | let p = "\n".concat(" ".repeat(l * 2)) 26 | let isOptional = e[i][0].startsWith("_") 27 | 28 | let startsWithLetter = /^[a-zA-Z_]/.test(e[i][0]) 29 | let displayKey = startsWithLetter ? e[i][0] : `"${e[i][0]}"` 30 | 31 | const type = typeof e[i][1] 32 | if (type !== "object") { 33 | newData = newData.concat(p, displayKey, isOptional ? "?" : "", `: ${type}`, postfix) 34 | } else if (Array.isArray(e[i][1])) { 35 | newData = newData.concat(p, displayKey, ": {") 36 | walk(e[i][1][0], l) 37 | newData = newData.concat(p, "}[]", postfix) 38 | } else { 39 | newData = newData.concat(p, displayKey, ": {") 40 | walk(e[i][1], l) 41 | newData = newData.concat(p, "}", postfix) 42 | } 43 | } 44 | if (level === 0) newData = newData.concat("\n}") 45 | } 46 | 47 | async function pathExists(path) { 48 | try { 49 | await access(path, constants.W_OK) 50 | return true 51 | } catch (err) { 52 | return false 53 | } 54 | } 55 | 56 | main() -------------------------------------------------------------------------------- /src/utils/keys.ts: -------------------------------------------------------------------------------- 1 | 2 | export type FullHotkey = { 3 | code?: string, 4 | altKey?: boolean, 5 | ctrlKey?: boolean, 6 | shiftKey?: boolean, 7 | metaKey?: boolean, 8 | key?: string 9 | } 10 | 11 | export type Hotkey = FullHotkey | string 12 | 13 | export function formatHotkey(hot: Hotkey) { 14 | if (!hot) { 15 | return gvar.gsm?.token.none || "None" 16 | } 17 | if (typeof(hot) === "string") { 18 | return hot 19 | } else { 20 | let parts: string[] = [] 21 | if (hot.ctrlKey) { 22 | parts.push(`⌃`) 23 | } 24 | if (hot.altKey) { 25 | parts.push(`⌥`) 26 | } 27 | if (hot.shiftKey) { 28 | parts.push(`⇧`) 29 | } 30 | if (hot.metaKey) { 31 | parts.push(`⌘`) 32 | } 33 | let visualKey = hot.key 34 | if (visualKey && visualKey.trim() === "") visualKey = "Space" 35 | 36 | parts.push(hot.key ? visualKey : hot.code) 37 | return parts.join(" ") 38 | } 39 | } 40 | 41 | export function extractHotkey(event: KeyboardEvent, physical = true, virtual?: boolean): FullHotkey { 42 | return { 43 | ctrlKey: event.ctrlKey, 44 | altKey: event.altKey, 45 | shiftKey: event.shiftKey, 46 | metaKey: event.metaKey, 47 | code: physical ? event.code : undefined, 48 | key: virtual ? event.key : undefined 49 | } 50 | } 51 | 52 | export function compareHotkeys(lhs: Hotkey, rhs: Hotkey) { 53 | if (lhs == null || rhs == null) { 54 | return false 55 | } 56 | if (typeof(lhs) === "string") { 57 | lhs = {code: lhs} as FullHotkey 58 | } 59 | if (typeof(rhs) === "string") { 60 | rhs = {code: rhs} as FullHotkey 61 | } 62 | 63 | const pre = 64 | (lhs.ctrlKey === true) == (rhs.ctrlKey === true) && 65 | (lhs.altKey === true) == (rhs.altKey === true) && 66 | (lhs.shiftKey === true) == (rhs.shiftKey === true) && 67 | (lhs.metaKey === true) == (rhs.metaKey === true) 68 | 69 | if (!pre) return false 70 | 71 | 72 | if (lhs.key && rhs.key && lhs.key === rhs.key) return true 73 | if (lhs.code && rhs.code && lhs.code === rhs.code) return true 74 | } -------------------------------------------------------------------------------- /src/background/utils/tabCapture.ts: -------------------------------------------------------------------------------- 1 | import { AUDIO_CONTEXT_KEYS } from "src/types" 2 | import { fetchView } from "src/utils/state" 3 | 4 | const offscreenUrl = chrome.runtime.getURL("offscreen.html") 5 | 6 | export async function hasOffscreen(): Promise { 7 | const contexts = await (chrome.runtime as any).getContexts({ 8 | contextTypes: ['OFFSCREEN_DOCUMENT'], 9 | documentUrls: [offscreenUrl] 10 | }) 11 | return !!contexts.length 12 | } 13 | 14 | export async function ensureOffscreen() { 15 | const has = await hasOffscreen() 16 | if (has) return 17 | await (chrome.offscreen as any).createDocument({ 18 | url: offscreenUrl, 19 | reasons: [chrome.offscreen.Reason.USER_MEDIA], 20 | justification: 'For audio effects like volume gain, pitch shift, etc.', 21 | }) 22 | } 23 | 24 | export async function initTabCapture(tabId: number): Promise { 25 | await ensureOffscreen() 26 | try { 27 | const [streamId, view] = await Promise.all([ 28 | chrome.tabCapture.getMediaStreamId({targetTabId: tabId}), 29 | fetchView(AUDIO_CONTEXT_KEYS, tabId) 30 | ]) 31 | return chrome.runtime.sendMessage({type: "CAPTURE", streamId, tabId, view}) 32 | } catch (err) { 33 | if (err?.message?.includes("invoked")) { 34 | return false 35 | } else { 36 | return true 37 | } 38 | } 39 | } 40 | 41 | export async function releaseTabCapture(tabId: number) { 42 | const has = await hasOffscreen() 43 | if (!has) return 44 | chrome.runtime.sendMessage({type: "CAPTURE", tabId}) 45 | } 46 | 47 | 48 | export async function isTabCaptured(tabId?: number): Promise { 49 | const has = await hasOffscreen() 50 | if (!has) return false 51 | return chrome.runtime.sendMessage({type: "REQUEST_CAPTURE_STATUS", tabId}) 52 | } 53 | 54 | export async function connectReversePort(tabId: number) { 55 | // ensure captured 56 | if (!isTabCaptured(tabId)) { 57 | await initTabCapture(tabId) 58 | } 59 | 60 | return chrome.runtime.connect({name: `REVERSE ${JSON.stringify({tabId})}`}) 61 | } -------------------------------------------------------------------------------- /src/comps/Slider.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useCallback, useEffect, useState } from "react" 2 | import { clamp, inverseLerp, lerp } from "../utils/helper" 3 | import debounce from "lodash.debounce" 4 | 5 | type SliderProps = { 6 | min: number, 7 | max: number, 8 | step: number, 9 | default: number, 10 | value: number, 11 | onChange: (newValue: number) => void, 12 | maxWait?: number, 13 | wait?: number 14 | } 15 | 16 | export function Slider(props: SliderProps) { 17 | const [anchor, setAnchor] = useState(null as [number]) 18 | const env = useMemo(() => ({props} as {props: SliderProps, handleChange?: (v: number) => void}), []) 19 | env.props = props 20 | 21 | env.handleChange = useCallback(debounce((value: number) => { 22 | const { props } = env 23 | props.onChange(clamp(props.min, props.max, value)) 24 | }, props.wait ?? 25, {maxWait: props.maxWait ?? 50, leading: true, trailing: true}), []) 25 | 26 | useEffect(() => { 27 | return () => { 28 | (env.handleChange as any)?.flush() 29 | } 30 | }, []) 31 | 32 | let min = props.min 33 | let max = props.max 34 | let step = props.step ?? 0.01 35 | if (anchor) { 36 | const normal = inverseLerp(props.min, props.max, anchor[0]) 37 | min = clamp(props.min, props.max, lerp(props.min, props.max, normal - (1 / 20))) 38 | max = clamp(props.min, props.max, lerp(props.min, props.max, normal + (1 / 20))) 39 | step *= 0.1 40 | } 41 | 42 | const ensureAnchored = () => { 43 | setAnchor([props.value]) 44 | } 45 | 46 | const clearAnchor = () => { 47 | setAnchor(null) 48 | } 49 | 50 | return ( 51 | { 55 | e.shiftKey && ensureAnchored() 56 | }} 57 | onKeyDown={e => { 58 | e.shiftKey && ensureAnchored() 59 | }} 60 | onBlur={clearAnchor} 61 | type="range" min={min} max={max} step={step} value={props.value} onChange={e => { 62 | env.handleChange(e.target.valueAsNumber) 63 | }} 64 | /> 65 | ) 66 | } -------------------------------------------------------------------------------- /src/options/DevWarning.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from "react" 2 | import { Keybind } from "../types" 3 | import { canPotentiallyUserScriptExecute, canUserScript, requestCreateTab } from "../utils/browserUtils" 4 | import { FaLink } from "react-icons/fa" 5 | import { MdWarning } from "react-icons/md" 6 | import { isEdge } from "src/utils/helper" 7 | 8 | enum WarningType { 9 | NONE = 0, 10 | ENABLE_DEV = 1, 11 | NO_SUPPORT = 2 12 | } 13 | 14 | export function DevWarning(props: { 15 | hasJs?: boolean, 16 | forUrlRules?: boolean 17 | }) { 18 | const [show, setShow] = useState(0 as WarningType) 19 | const env = useRef({} as {show: typeof show}).current 20 | env.show = show 21 | 22 | useEffect(() => { 23 | if (!props.hasJs) { 24 | setShow(null) 25 | return 26 | } 27 | 28 | const handleInterval = () => { 29 | let target = WarningType.NO_SUPPORT 30 | if (props.forUrlRules || canPotentiallyUserScriptExecute()) { 31 | target = canUserScript() ? WarningType.NONE : WarningType.ENABLE_DEV 32 | } 33 | 34 | target !== env.show && setShow(target) 35 | env.show = target 36 | } 37 | 38 | const intervalId = setInterval(handleInterval, 300) 39 | 40 | return () => { 41 | clearInterval(intervalId) 42 | } 43 | }, [props.hasJs]) 44 | 45 | if (!show) return null 46 | 47 | return
48 | 49 | {show === WarningType.ENABLE_DEV && ( 50 | {gvar.gsm.warnings[`${props.forUrlRules ? "jsWarningRules" : "jsWarning"}${isEdge() ? 'Edge' : ''}`]} 51 | )} 52 | {show === WarningType.NO_SUPPORT && ( 53 | {gvar.gsm.warnings.jsUpdate} 54 | )} 55 | {show === WarningType.ENABLE_DEV && ( 56 | 60 | )} 61 |
62 | 63 | 64 | } -------------------------------------------------------------------------------- /src/popup/SvgFilterList.tsx: -------------------------------------------------------------------------------- 1 | import { produce } from "immer" 2 | import { useState } from "react" 3 | import { SvgFilter } from "src/types" 4 | import { svgFilterGenerate, svgFilterInfos } from "src/defaults/filters" 5 | import { SvgFilterItem } from "./SvgFilterItem" 6 | import { SVG_FILTER_ADDITIONAL } from "src/defaults/svgFilterAdditional" 7 | import { svgFilterIsValid } from "src/defaults/filters" 8 | import "./SvgFilterList.css" 9 | 10 | const filterTypes = Object.keys(svgFilterInfos) 11 | filterTypes.splice(filterTypes.findIndex(f => f === "custom"), 1) 12 | 13 | export function SvgFilterList(props: { 14 | svgFilters: SvgFilter[], 15 | onChange: (newSvgFilters: SvgFilter[], forceEnable?: boolean) => void 16 | }) { 17 | const [command, setCommand] = useState("rgb") 18 | 19 | return
20 |
{gvar.gsm.filter.otherFilters.header}
21 |
22 | {props.svgFilters.map(f => { 23 | const typeInfo = SVG_FILTER_ADDITIONAL[newFilter.type] 24 | 25 | const isActive = newFilter.enabled && svgFilterIsValid(newFilter, typeInfo.isValid) 26 | props.onChange(produce(props.svgFilters, dArr => { 27 | let idx = dArr.findIndex(v => v.id === f.id) 28 | if (idx >= 0) dArr[idx] = newFilter 29 | }), isActive) 30 | }} list={props.svgFilters} listOnChange={props.onChange}/>)} 31 |
32 |
33 | 40 | 47 |
48 |
49 | } 50 | 51 | -------------------------------------------------------------------------------- /src/options/KebabList.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react" 2 | import { IoEllipsisVertical } from "react-icons/io5" 3 | import { Menu, type MenuProps } from "src/comps/Menu" 4 | import { Tooltip } from "src/comps/Tooltip" 5 | 6 | export type KebabListProps = { 7 | list: MenuProps["items"], 8 | onSelect: (name: string) => boolean | void, 9 | divIfEmpty?: boolean, 10 | title?: string, 11 | centered?: boolean, 12 | onOpen?: () => void 13 | } 14 | 15 | export function KebabList(props: KebabListProps) { 16 | const [menu, setMenu] = useState(null as { x?: number, y?: number, adjusted?: boolean, centered?: boolean }) 17 | const menuRef = useRef() 18 | const buttonRef = useRef() 19 | 20 | const onContext = (e: React.MouseEvent) => { 21 | e.preventDefault() 22 | props.onOpen?.() 23 | if (props.centered) { 24 | setMenu({centered: true}) 25 | return 26 | } 27 | setMenu({ x: e.clientX, y: e.clientY }) 28 | } 29 | 30 | useEffect(() => { 31 | if (!menu || menu.adjusted || menu.centered) return 32 | 33 | const bounds = menuRef.current.getBoundingClientRect() 34 | const buttonBounds = buttonRef.current.getBoundingClientRect() 35 | let x = menu.x 36 | let y = menu.y 37 | 38 | 39 | if ((bounds.x + bounds.width) > (window.innerWidth - 15)) { 40 | x = buttonBounds.x - 10 - bounds.width 41 | } 42 | if ((bounds.y + bounds.height) > window.innerHeight) { 43 | y = buttonBounds.y - 10 - bounds.height 44 | } 45 | setMenu({x, y, adjusted: true}) 46 | }, [menu]) 47 | 48 | return <> 49 | {props.title} 50 | 51 | 54 | 55 | {!menu ? (props.divIfEmpty ?
: null) : ( 56 | setMenu(null)} onSelect={props.onSelect} /> 57 | 58 | )} 59 | 60 | } -------------------------------------------------------------------------------- /src/options/SectionFlags.css: -------------------------------------------------------------------------------- 1 | .IndicatorModal { 2 | background-color: var(--fg-color); 3 | } 4 | 5 | .SectionFlags { 6 | --field-name-width: 300px; 7 | 8 | & button.icon.gear { 9 | color: var(--text-color); 10 | } 11 | 12 | } 13 | 14 | .SectionFlags > .fields, .IndicatorModal, .SpeedPresetModal, .WidgetModal, .CinemaModal { 15 | margin-top: 20px; 16 | 17 | & > .presetControl { 18 | margin-top: 20px; 19 | margin-left: 20px; 20 | } 21 | 22 | & > .field { 23 | display: grid; 24 | grid-template-columns: var(--field-name-width, 300px) max-content; 25 | grid-column-gap: 10px; 26 | margin-bottom: 14px; 27 | align-items: center; 28 | 29 | & > .SliderMicro { 30 | grid-template-columns: 120px max-content; 31 | } 32 | 33 | &.indentFloat > .fieldValue > .float { 34 | left: 50px; 35 | top: -4px; 36 | position: absolute; 37 | } 38 | 39 | &.speedSlider { 40 | margin-bottom: 10px; 41 | 42 | & > .control { 43 | display: grid; 44 | grid-template-columns: 8rem max-content; 45 | column-gap: 5px; 46 | } 47 | 48 | } 49 | 50 | &.holdToSpeed { 51 | margin-bottom: 30px; 52 | 53 | & > .control { 54 | display: grid; 55 | grid-template-columns: 4rem max-content; 56 | column-gap: 5px; 57 | } 58 | } 59 | 60 | 61 | & > .fieldValue { 62 | position: relative; 63 | line-height: 0; 64 | } 65 | 66 | & > .fieldValue div.NumericInput { 67 | width: 60px; 68 | display: inline-block; 69 | } 70 | 71 | &.indent { 72 | & > span:first-child, & > div.labelWithTooltip:first-child { 73 | margin-left: 20px; 74 | } 75 | } 76 | 77 | &.marginTop { 78 | margin-top: 30px; 79 | } 80 | 81 | & > .colorControl { 82 | display: grid; 83 | grid-template-columns: repeat(3, max-content); 84 | grid-gap: 10px; 85 | align-items: center; 86 | } 87 | 88 | & > .col { 89 | display: grid; 90 | grid-auto-flow: column; 91 | grid-auto-columns: max-content; 92 | grid-column-gap: 20px; 93 | } 94 | } 95 | 96 | & > .showMoreTooltip { 97 | margin-top: 20px; 98 | 99 | & > div > button { 100 | font-family: monospace; 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/popup/MediaView.css: -------------------------------------------------------------------------------- 1 | 2 | :root.noBottomBorderMediaItem { 3 | .MediaView { 4 | &:last-child { 5 | border-bottom: none !important; 6 | } 7 | } 8 | } 9 | 10 | .MediaViews { 11 | padding-left: 5px; 12 | } 13 | 14 | .MediaView { 15 | padding: 10px 5px; 16 | border-top: 1px solid var(--border); 17 | 18 | &:first-child { 19 | margin-top: 16px; 20 | } 21 | 22 | &:last-child { 23 | /* margin-top: 16px; */ 24 | border-bottom: 1px solid var(--border); 25 | } 26 | 27 | & > .header { 28 | margin-bottom: 2px; 29 | overflow-wrap: anywhere; 30 | 31 | & > .meta { 32 | font-size: 0.85em; 33 | opacity: 0.55; 34 | 35 | &:hover { 36 | opacity: 1; 37 | text-decoration: underline; 38 | } 39 | } 40 | 41 | & > .jump { 42 | border: none; 43 | padding: 0px 5px; 44 | margin: 0px; 45 | border-radius: 5px; 46 | transform: scale(1.2) translateY(-2px); 47 | margin-left: 5px; 48 | opacity: 0.7; 49 | 50 | &:hover { 51 | background-color: var(--bg-color); 52 | opacity: 1; 53 | } 54 | 55 | & > svg { 56 | opacity: 1; 57 | } 58 | } 59 | 60 | & > .title { 61 | white-space: nowrap; 62 | text-overflow: ellipsis; 63 | overflow: hidden; 64 | } 65 | } 66 | 67 | & > .controls { 68 | display: grid; 69 | grid-template-columns: repeat(4, max-content) 1fr repeat(3, max-content); 70 | align-items: center; 71 | grid-column-gap: 5px; 72 | 73 | & > input[type="range"] { 74 | min-width: 0; 75 | } 76 | 77 | & > button { 78 | background-color: transparent; 79 | color: var(--header-icon-color); 80 | border-color: transparent; 81 | 82 | &:first-child { 83 | margin-left: -5px; 84 | } 85 | 86 | &.active { 87 | color: var(--header-icon-active-color); 88 | } 89 | &.muted { 90 | color: var(--header-icon-muted-color); 91 | } 92 | 93 | &:hover { 94 | background-color: var(--control-hover-bg-color); 95 | } 96 | &:focus { 97 | border-color: 1px solid var(--focus-color); 98 | } 99 | } 100 | 101 | & > .duration { 102 | font-size: 0.9em; 103 | margin-right: 8px; 104 | justify-self: end; 105 | } 106 | } 107 | 108 | } -------------------------------------------------------------------------------- /src/popup/Header.css: -------------------------------------------------------------------------------- 1 | 2 | .Header { 3 | display: grid; 4 | grid-template-columns: repeat(3, max-content) 1fr repeat(5, max-content); 5 | justify-items: right; 6 | align-items: center; 7 | padding: 3px 5px 0px 5px; 8 | border-bottom: 1px solid var(--border); 9 | background-color: var(--fg-color); 10 | 11 | & > .kebab { 12 | margin-top: -3px; 13 | padding-left: 2px; 14 | margin-left: -5px; 15 | line-height: 0; 16 | position: relative; 17 | 18 | .alert { 19 | pointer-events: none; 20 | position: absolute; 21 | right: -5px; 22 | top: -6px; 23 | color: var(--header-warning-color); 24 | } 25 | } 26 | 27 | 28 | & .Tooltip { 29 | margin-top: -3px; 30 | padding-left: 2px; 31 | 32 | &, & > div, & button { 33 | line-height: 0; 34 | } 35 | } 36 | 37 | 38 | & > div { 39 | padding: 0 5px; 40 | color: var(--header-icon-color); 41 | border: none; 42 | cursor: pointer; 43 | 44 | &.noPadding { 45 | padding: 0; 46 | } 47 | 48 | &:focus { 49 | outline: none; 50 | } 51 | 52 | &:hover { 53 | opacity: 0.9; 54 | } 55 | 56 | @keyframes beat { 57 | 0% {transform: scale(1)} 58 | 90% {transform: scale(0.8)} 59 | 95% {transform: scale(1.1)} 60 | } 61 | 62 | &.active { 63 | color: var(--header-icon-active-color); 64 | 65 | &.red { 66 | color: red; 67 | } 68 | 69 | &.beat { 70 | animation: 1s ease-in beat infinite; 71 | } 72 | 73 | &:hover { 74 | opacity: 0.9; 75 | } 76 | } 77 | 78 | &.muted { 79 | color: var(--header-icon-muted-color); 80 | 81 | &:hover { 82 | opacity: 0.9; 83 | } 84 | } 85 | 86 | 87 | & > svg { 88 | vertical-align: baseline; 89 | } 90 | } 91 | } 92 | 93 | 94 | .kebabOverlayOutline, .kebabOverlayMessage { 95 | z-index: 999999999999; 96 | pointer-events: none; 97 | position: fixed; 98 | 99 | &.kebabOverlayOutline { 100 | border: 4px solid var(--header-warning-color-alt); 101 | } 102 | 103 | &.kebabOverlayMessage { 104 | background-color: black; 105 | color: var(--header-warning-color-alt); 106 | padding: 10px; 107 | width: 100%; 108 | text-align: center; 109 | font-weight: bold; 110 | font-size: 0.9em; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/placer/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Rect } from "../types"; 3 | import { loadGsm } from "../utils/gsm"; 4 | import "./styles.css" 5 | 6 | let id = new URL(location.href).searchParams.get('id') 7 | if (!id) window.close() 8 | 9 | loadGsm().then(gsm => { 10 | gvar.gsm = gsm 11 | 12 | document.documentElement.setAttribute("lang", gvar.gsm._lang) 13 | if (gvar.gsm) main() 14 | }) 15 | 16 | let bounds: Rect = {left: screenLeft, top: screenTop, width: outerWidth, height: outerHeight} 17 | let leftDiv = document.querySelector(".left") 18 | let topDiv = document.querySelector(".top") 19 | let widthDiv = document.querySelector(".width") 20 | let heightDiv = document.querySelector(".height") 21 | 22 | 23 | let applyButton = document.querySelector("#apply") 24 | let resetButton = document.querySelector("#reset") 25 | 26 | applyButton.addEventListener("click", async e => { 27 | if (bounds) { 28 | const keybinds = (await chrome.storage.local.get('g:keybinds'))['g:keybinds'] 29 | const kb = keybinds.find((kb: any) => kb.id === id) 30 | if (kb) { 31 | kb.valuePopupRect = bounds 32 | chrome.storage.local.set({'g:keybinds': keybinds}) 33 | } 34 | } 35 | window.close() 36 | }) 37 | 38 | resetButton.addEventListener("click", e => { 39 | window.close() 40 | }) 41 | 42 | 43 | function main() { 44 | document.querySelector("#intro").textContent = gvar.gsm.placer.windowPrompt 45 | leftDiv.children[0].textContent = gvar.gsm.placer.windowBounds.left 46 | topDiv.children[0].textContent = gvar.gsm.placer.windowBounds.top 47 | widthDiv.children[0].textContent = gvar.gsm.placer.windowBounds.width 48 | heightDiv.children[0].textContent = gvar.gsm.placer.windowBounds.height 49 | 50 | applyButton.textContent = gvar.gsm.placer.apply 51 | resetButton.textContent = gvar.gsm.placer.cancel 52 | sync() 53 | setInterval(onInterval, 300) 54 | } 55 | 56 | function onInterval() { 57 | let alt = {left: screenLeft, top: screenTop, width: outerWidth, height: outerHeight} 58 | if (bounds.left !== alt.left || bounds.top !== alt.top || bounds.width !== alt.width || bounds.height !== alt.height) { 59 | bounds = alt 60 | sync() 61 | } 62 | } 63 | 64 | function sync() { 65 | leftDiv.children[1].textContent = `${bounds.left}px` 66 | topDiv.children[1].textContent = `${bounds.top}px` 67 | widthDiv.children[1].textContent = `${bounds.width}px` 68 | heightDiv.children[1].textContent = `${bounds.height}px` 69 | 70 | } -------------------------------------------------------------------------------- /staticCh/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "__MSG_appName__", 3 | "short_name": "Global Speed", 4 | "version": "3.2.46", 5 | "default_locale": "en", 6 | "description": "__MSG_appDesc__", 7 | "manifest_version": 3, 8 | "host_permissions": ["https://*/*", "http://*/*", "file://*/*"], 9 | "permissions": ["storage", "tabCapture", "webNavigation", "scripting", "offscreen", "userScripts", "contextMenus"], 10 | "action": { 11 | "default_popup": "popup.html" 12 | }, 13 | "icons": { "128": "icons/128.png" }, 14 | "background": { 15 | "service_worker": "background.js", 16 | "type": "module" 17 | }, 18 | "web_accessible_resources": [ 19 | {"resources": ["circles/*.svg"], "matches": ["http://*/*", "https://*/*"]} 20 | ], 21 | "content_scripts": [ 22 | { 23 | "matches": ["https://*/*", "http://*/*", "file://*/*"], 24 | "exclude_matches": ["https://*.ubs.com/*", "https://*.591.com.tw/*", "https://*.91huayi.com/*"], 25 | "js": ["isolated.js"], 26 | "all_frames": true, 27 | "match_about_blank": true, 28 | "run_at": "document_start" 29 | }, 30 | { 31 | "matches": ["https://*/*", "http://*/*", "file://*/*"], 32 | "exclude_matches": ["https://*.ubs.com/*", "https://*.591.com.tw/*", "https://*.91huayi.com/*"], 33 | "js": ["main.js"], 34 | "all_frames": true, 35 | "match_about_blank": true, 36 | "run_at": "document_start", 37 | "world": "MAIN" 38 | } 39 | ], 40 | "options_ui": { 41 | "open_in_tab": true, 42 | "page": "options.html" 43 | }, 44 | "commands": { 45 | "commandA": {"description": "command A"}, 46 | "commandB": {"description": "command B"}, 47 | "commandC": {"description": "command C"}, 48 | "commandD": {"description": "command D"}, 49 | "commandE": {"description": "command E"}, 50 | "commandF": {"description": "command F"}, 51 | "commandG": {"description": "command G"}, 52 | "commandH": {"description": "command H"}, 53 | "commandI": {"description": "command I"}, 54 | "commandJ": {"description": "command J"}, 55 | "commandK": {"description": "command K"}, 56 | "commandL": {"description": "command L"}, 57 | "commandM": {"description": "command M"}, 58 | "commandN": {"description": "command N"}, 59 | "commandO": {"description": "command O"}, 60 | "commandP": {"description": "command P"}, 61 | "commandQ": {"description": "command Q"}, 62 | "commandR": {"description": "command R"}, 63 | "commandS": {"description": "command S"} 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/comps/svgs.tsx: -------------------------------------------------------------------------------- 1 | 2 | import * as React from "react" 3 | 4 | 5 | type SvgPropsBase = { 6 | width?: React.SVGAttributes["width"], 7 | height?: React.SVGAttributes["height"], 8 | style?: React.SVGAttributes["style"], 9 | className?: React.SVGAttributes["className"], 10 | color?: React.SVGAttributes["color"] 11 | } 12 | 13 | export type SvgProps = SvgPropsBase & { 14 | size?: number | string 15 | } 16 | 17 | function prepareProps(props: SvgProps) { 18 | props = {...(props ?? {})} 19 | props.width = props.width ?? props.size ?? "1em" 20 | props.height = props.height ?? props.size ?? "1em" 21 | 22 | delete props.size 23 | return props as SvgPropsBase 24 | } 25 | 26 | 27 | export function Zap(props: SvgProps) { 28 | return ( 29 | 38 | 39 | 40 | ) 41 | } 42 | 43 | export function Pin(props: SvgProps) { 44 | return ( 45 | 54 | 59 | 60 | ) 61 | } 62 | 63 | 64 | export function Gear(props: SvgProps) { 65 | return ( 66 | 75 | 80 | 81 | ) 82 | } 83 | 84 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("path") 2 | const { env } = require("process") 3 | const webpack = require('webpack') 4 | 5 | const entry = { 6 | isolated: "./src/contentScript/isolated/index.ts", 7 | background: "./src/background/index.ts", 8 | popup: "./src/popup/popup.tsx", 9 | options: "./src/options/options.tsx", 10 | faqs: "./src/faqs/faqs.tsx", 11 | main: "./src/contentScript/main/index.ts", 12 | pageDraw: "./src/contentScript/pageDraw/index.ts", 13 | pane: "./src/contentScript/pane/index.ts", 14 | placer: "./src/placer/index.ts" 15 | } 16 | 17 | if (env.FIREFOX) { 18 | entry["mainLoader"] = "./src/contentScript/main/loader.ts" 19 | } else { 20 | entry["sound-touch-processor"] = "./src/offscreen/SoundTouchProcessor.ts" 21 | entry["reverse-sound-processor"] = "./src/offscreen/ReverseProcessor.ts" 22 | entry["offscreen"] = "./src/offscreen/index.ts" 23 | } 24 | 25 | 26 | 27 | const common = { 28 | entry, 29 | output: { 30 | path: resolve(__dirname, env.FIREFOX ? "buildFf": "build", "unpacked") 31 | }, 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.tsx?$/, 36 | exclude: /node_modules/, 37 | use: "babel-loader", 38 | }, 39 | { 40 | sideEffects: true, 41 | test: /\.css$/, 42 | exclude: /node_modules/, 43 | resourceQuery: { not: [/raw/] }, 44 | use: [ 45 | "style-loader", 46 | { 47 | loader: "css-loader", 48 | options: { 49 | url: false, 50 | importLoaders: 1 51 | } 52 | }, 53 | "postcss-loader" 54 | ], 55 | }, 56 | { 57 | test: /\.css$/, 58 | resourceQuery: /raw/, 59 | exclude: [/node_modules/], 60 | type: 'asset/source', 61 | use: [ 62 | "postcss-loader" 63 | ] 64 | } 65 | ] 66 | }, 67 | resolve: { 68 | extensions: [".tsx", ".ts", '.js'], 69 | alias: { 70 | src: resolve(__dirname, "src"), 71 | notFirefox: env.FIREFOX ? false : resolve(__dirname, "src"), 72 | isFirefox: env.FIREFOX ? resolve(__dirname, "src") : false 73 | } 74 | }, 75 | plugins: [ 76 | new webpack.ProvidePlugin({ 77 | gvar: [resolve(__dirname, "src", "globalVar.ts"), 'gvar'] 78 | }) 79 | ] 80 | } 81 | 82 | if (env.NODE_ENV === "production") { 83 | module.exports = { 84 | ...common, 85 | mode: "production" 86 | } 87 | } else { 88 | module.exports = { 89 | ...common, 90 | mode: "development", 91 | devtool: false 92 | } 93 | } -------------------------------------------------------------------------------- /src/comps/MoveDrag.tsx: -------------------------------------------------------------------------------- 1 | import { VscGripper } from "react-icons/vsc" 2 | import { useRef, MutableRefObject, useEffect } from "react" 3 | import "./MoveDrag.css" 4 | 5 | type MoveDragProps = { 6 | onMove: (newIndex: number) => void 7 | itemRef: MutableRefObject 8 | listRef: MutableRefObject 9 | setFocus: (focused: boolean) => void 10 | } 11 | 12 | type Env = { 13 | focused?: HTMLElement 14 | props?: MoveDragProps 15 | } 16 | 17 | export function MoveDrag(props: MoveDragProps) { 18 | const env = useRef({setFocus: props.setFocus} as Env).current 19 | env.props = props 20 | 21 | useEffect(() => { 22 | const handlePointerUp = (e: PointerEvent) => { 23 | if (!env.focused) return 24 | env.props.setFocus(false) 25 | env.focused = null 26 | document.documentElement.classList.remove("dragging") 27 | } 28 | 29 | const handlePointerMove = (e: PointerEvent) => { 30 | if (!env.focused) return 31 | let itemIdx = 0 32 | const items = [...props.listRef.current.children].map((v, i) => { 33 | const b = v.getBoundingClientRect() 34 | const focused = v === env.focused 35 | if (focused) { 36 | itemIdx = i 37 | } 38 | return {y: b.y + b.height / 2, i: i, focused} 39 | }) 40 | 41 | // determine new position. 42 | let cursorIdx = 0 43 | for (let item of items) { 44 | if (e.clientY < item.y) break 45 | cursorIdx++ 46 | } 47 | 48 | const delta = cursorIdx - itemIdx 49 | 50 | let newIndex = itemIdx 51 | if (delta >= 2) { 52 | newIndex = itemIdx + delta - 1 53 | } else if (delta <= -1) { 54 | newIndex = itemIdx + delta 55 | } 56 | 57 | if (newIndex === itemIdx) return 58 | 59 | env.props.onMove(newIndex) 60 | } 61 | 62 | 63 | window.addEventListener("pointerup", handlePointerUp, true) 64 | window.addEventListener("pointermove", handlePointerMove, true) 65 | 66 | return () => { 67 | window.removeEventListener("pointerup", handlePointerUp, true) 68 | window.removeEventListener("pointermove", handlePointerMove, true) 69 | } 70 | }, []) 71 | 72 | 73 | const handlePointerDown = (e: React.PointerEvent) => { 74 | if (!props.itemRef.current || !props.listRef.current) return 75 | props.setFocus(true) 76 | document.documentElement.classList.add("dragging") 77 | env.focused = props.itemRef.current 78 | } 79 | 80 | return ( 81 | 84 | ) 85 | } -------------------------------------------------------------------------------- /src/background/badge.ts: -------------------------------------------------------------------------------- 1 | import { formatSpeedForBadge } from "src/utils/configUtils" 2 | import { fetchView } from "src/utils/state" 3 | import debounce from "lodash.debounce" 4 | import { isMobile } from "src/utils/helper" 5 | 6 | type BadgeInit = Awaited> 7 | 8 | let commonInit: BadgeInit 9 | 10 | const standardIcons = {"128": `icons/128.png`} 11 | const grayscaleIcons = {"128": `icons/128g.png`} 12 | 13 | async function updateVisible(tabs?: chrome.tabs.Tab[]) { 14 | if (!commonInit) { 15 | commonInit = await getBadgeInit(0) 16 | } 17 | writeBadge(commonInit, undefined) 18 | updateTabs(tabs ?? (await chrome.tabs.query({active: true}))) 19 | } 20 | 21 | const updateVisibleDeb = debounce(updateVisible, 100, {leading: true, trailing: true, maxWait: 1000}) 22 | 23 | 24 | async function updateTabs(tabs: chrome.tabs.Tab[]) { 25 | return Promise.all(tabs.map(tab => updateTab(tab))) 26 | } 27 | 28 | async function updateTab(tab: chrome.tabs.Tab) { 29 | const init = await getBadgeInit(tab.id) 30 | writeBadge(init, tab.id) 31 | } 32 | 33 | 34 | async function getBadgeInit(tabId: number) { 35 | const { isPinned, speed, enabled, hasOrl, superDisable, hideBadge } = await fetchView({hideBadge: true, superDisable: true, isPinned: true, speed: true, enabled: true, hasOrl: true}, tabId) 36 | 37 | const isEnabled = enabled && !superDisable 38 | let showBadge = isEnabled && !hideBadge 39 | 40 | let badgeIcons = isEnabled ? standardIcons : grayscaleIcons 41 | let badgeText = showBadge ? formatSpeedForBadge(speed ?? 1) : "" 42 | let badgeColor = "#000" 43 | 44 | if (hasOrl && !isEnabled && !hideBadge) { 45 | showBadge = true 46 | badgeText = "OFF" 47 | } 48 | 49 | if (showBadge) { 50 | badgeColor = hasOrl ? "#7fffd4" : (isPinned ? "#44a" : "#a33") 51 | } 52 | return {badgeText, badgeColor, badgeIcons} 53 | } 54 | 55 | async function writeBadge(init: BadgeInit, tabId?: number) { 56 | chrome.action.setBadgeText({text: init.badgeText, tabId}) 57 | chrome.action.setBadgeBackgroundColor({color: init.badgeColor, tabId}) 58 | chrome.action.setIcon({path: init.badgeIcons, tabId}) 59 | } 60 | 61 | const WATCHERS = [ 62 | /^g:(speed|enabled|superDisable|hideBadge)/, 63 | /^[rt]:[\d\w]+:(speed|isPinned|enabled)/, 64 | /^[r]:[\d\w]+:(elementFx|backdropFx|latestViaShortcut|)/ 65 | ] 66 | 67 | if (!isMobile()) { 68 | gvar.es.addWatcher(WATCHERS, changes => { 69 | updateVisibleDeb() 70 | }) 71 | gvar.sess.safeCbs.add(() => updateVisible()) 72 | chrome.webNavigation.onCommitted.addListener(() => updateVisible()) 73 | chrome.tabs.onActivated.addListener(() => updateVisible()) 74 | } -------------------------------------------------------------------------------- /src/offscreen/ReverseProcessor.ts: -------------------------------------------------------------------------------- 1 | class ReverseProcessor extends AudioWorkletProcessor { 2 | buffer = new Float32Array(44100 * 10) 3 | recordSize = 0 4 | playSize = 0 5 | maxBufferSize: number 6 | phase = Phase.pre 7 | 8 | constructor(init: AudioWorkletNodeOptions) { 9 | super() 10 | this.maxBufferSize = init.processorOptions.maxSize || 44100 * 60 11 | 12 | this.port.onmessage = ({data}) => { 13 | if (data.type === "RELEASE") { 14 | this.release() 15 | } 16 | } 17 | } 18 | release = () => { 19 | if (this.phase !== Phase.released) { 20 | this.phase = Phase.released 21 | this.port.postMessage({type: "RELEASED"}) 22 | } 23 | } 24 | 25 | process (inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record) { 26 | if (this.phase === Phase.released) return false 27 | 28 | const input = inputs[0] 29 | const output = outputs[0] 30 | 31 | const batchSize = output[0].length 32 | 33 | if (this.phase === Phase.pre) { 34 | if (input[0]) { 35 | this.phase = Phase.recording 36 | } else { 37 | return true 38 | } 39 | } 40 | 41 | if (this.phase === Phase.recording) { 42 | if (!input[0]) { 43 | this.phase = Phase.playing 44 | this.buffer.subarray(0, this.recordSize).reverse() 45 | this.port.postMessage({type: "PLAYING"}) 46 | } else { 47 | const newBufferSize = this.recordSize + batchSize 48 | if (newBufferSize > this.maxBufferSize) { 49 | this.phase = Phase.playing 50 | this.buffer.subarray(0, this.recordSize).reverse() 51 | this.port.postMessage({type: "PLAYING"}) 52 | } else { 53 | 54 | // might need to enlarge buffer. 55 | if (newBufferSize > this.buffer.length) { 56 | const biggerBuffer = new Float32Array(this.buffer.length * 2) 57 | biggerBuffer.set(this.buffer) 58 | this.buffer = biggerBuffer 59 | } 60 | 61 | 62 | this.buffer.set(input[0], this.recordSize) 63 | this.recordSize = newBufferSize 64 | } 65 | } 66 | } 67 | 68 | if (this.phase === Phase.playing) { 69 | if (this.playSize < this.recordSize) { 70 | output[0].set(this.buffer.subarray( this.playSize, this.playSize + batchSize)) 71 | this.playSize += batchSize 72 | return true 73 | } else { 74 | this.release() 75 | return false 76 | } 77 | } 78 | 79 | output[0].set(input[0]) 80 | 81 | return true 82 | } 83 | } 84 | 85 | enum Phase { 86 | "pre" = 1, 87 | "recording", 88 | "playing", 89 | "released" 90 | } 91 | 92 | registerProcessor('reverse-sound-processor', ReverseProcessor) -------------------------------------------------------------------------------- /src/popup/Filters.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { FilterEntry } from "../types" 3 | import { filterInfos } from "../defaults/filters" 4 | import { SliderPlus } from "../comps/SliderPlus" 5 | import { moveItem } from "../utils/helper" 6 | import { produce } from "immer" 7 | import { Move } from "../comps/Move" 8 | import "./Filters.css" 9 | 10 | 11 | type FiltersProps = { 12 | filters: FilterEntry[], 13 | onChange: (newValue: FilterEntry[]) => void, 14 | className?: string 15 | } 16 | 17 | export function Filters(props: FiltersProps) { 18 | const [syncScale, setSyncScale] = useState(false) 19 | 20 | return
21 | {props.filters.map(entry => ( 22 | { 26 | props.onChange(produce(props.filters, d => { 27 | moveItem(d, v => v.name === entry.name, down ? "D" : "U") 28 | })) 29 | }} 30 | onChange={newValue => { 31 | props.onChange(produce(props.filters, d => { 32 | const dFilter = d.find(f => f.name === entry.name) 33 | dFilter.value = newValue.value 34 | 35 | if (syncScale && dFilter.name.startsWith("scale")) { 36 | d.filter(entry => entry.name.startsWith("scale")).forEach(entry => { 37 | entry.value = newValue.value 38 | }) 39 | } 40 | })) 41 | }} 42 | syncChange={entry.name.startsWith("scale") ? () => setSyncScale(!syncScale) : null} 43 | syncValue={syncScale} 44 | /> 45 | ))} 46 |
47 | } 48 | 49 | 50 | type FilterProps = { 51 | entry: FilterEntry, 52 | onChange: (newValue: FilterEntry) => void, 53 | onMove: (down: boolean) => void, 54 | syncChange?: () => void 55 | syncValue?: boolean 56 | } 57 | 58 | export function Filter(props: FilterProps) { 59 | const { entry } = props 60 | const ref = filterInfos[entry.name].ref 61 | 62 | return
63 | props.onMove(down)}/> 64 | 66 | {gvar.gsm.filter[entry.name]} 67 | {!props.syncChange ? null : } 68 | } 69 | value={entry.value ?? ref.default} 70 | sliderMin={ref.sliderMin} 71 | sliderMax={ref.sliderMax} 72 | sliderStep={ref.sliderStep} 73 | min={ref.min} 74 | max={ref.max} 75 | default={ref.default} 76 | onChange={newValue => { 77 | props.onChange(produce(entry, d => { 78 | d.value = newValue 79 | })) 80 | }} 81 | /> 82 |
83 | } -------------------------------------------------------------------------------- /src/comps/NumericInput.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, ChangeEvent } from "react" 2 | import { FloatTooltip } from "./FloatTooltip" 3 | import { round } from "../utils/helper" 4 | 5 | const NUMERIC_REGEX = /^-?(?=[\d\.])\d*(\.\d+)?$/ 6 | 7 | type NumericInputProps = { 8 | value: number, 9 | onChange: (newValue: number) => any, 10 | onFocus?: (e: React.FocusEvent) => void 11 | placeholder?: string, 12 | noNull?: boolean 13 | min?: number, 14 | max?: number, 15 | rounding?: number, 16 | disabled?: boolean, 17 | className?: string 18 | } 19 | 20 | 21 | export const NumericInput = (props: NumericInputProps) => { 22 | const [ghostValue, setGhostValue] = useState("") 23 | const [problem, setProblem] = useState(null as string) 24 | 25 | useEffect(() => { 26 | setProblem(null) 27 | if (props.value == null) { 28 | ghostValue !== "" && setGhostValue("") 29 | } else { 30 | let parsedGhostValue = parseFloat(ghostValue) 31 | if (parsedGhostValue !== props.value) { 32 | setGhostValue(`${round(props.value, props.rounding ?? 4)}`) 33 | } 34 | } 35 | }, [props.value]) 36 | 37 | 38 | const handleOnChange = (e: ChangeEvent) => { 39 | setGhostValue(e.target.value) 40 | const value = e.target.value.trim() 41 | 42 | const parsed = round(parseFloat(value), props.rounding ?? 4) 43 | 44 | if (!props.noNull && !value.length) { 45 | setProblem(null) 46 | if (props.value != null) { 47 | props.onChange(null) 48 | } 49 | } 50 | 51 | if (!isNaN(parsed) && NUMERIC_REGEX.test(value)) { 52 | let min = props.min 53 | let max = props.max 54 | 55 | if (min != null && parsed < min) { 56 | setProblem(`>= ${min}`) 57 | return 58 | } 59 | if (max != null && parsed > max) { 60 | setProblem(`<= ${max}`) 61 | return 62 | } 63 | 64 | if (parsed !== round(props.value, props.rounding ?? 4)) { 65 | props.onChange(parsed) 66 | } 67 | setProblem(null) 68 | } else { 69 | setProblem(`NaN`) 70 | } 71 | 72 | } 73 | 74 | return ( 75 |
76 | { 79 | setProblem(null) 80 | setGhostValue(props.value == null ? "" : `${round(props.value, props.rounding ?? 4)}`) 81 | }} 82 | className={problem ? "error" : ""} 83 | placeholder={props.placeholder} 84 | type="text" 85 | onChange={handleOnChange} value={ghostValue} 86 | onFocus={props.onFocus} 87 | /> 88 | {problem && ( 89 | 90 | )} 91 |
92 | ) 93 | } 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /src/comps/ThrottledTextInput.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState, useEffect, useCallback, DetailedHTMLProps, InputHTMLAttributes, TextareaHTMLAttributes } from "react" 2 | import debounce from "lodash.debounce" 3 | import { assertType } from "src/utils/helper" 4 | 5 | type ThrottledTextInputProps = { 6 | value: string, 7 | onChange: (newValue: string) => void, 8 | passInput?: DetailedHTMLProps, HTMLInputElement>, 9 | passTextArea?: DetailedHTMLProps, HTMLTextAreaElement>, 10 | textArea?: boolean, 11 | placeholder?: string 12 | } 13 | 14 | type Env = { 15 | intervalId?: number, 16 | sendUpdateDebounced?: ((value: string) => void) & {flush: () => void}, 17 | handleBlur?: () => void 18 | props?: ThrottledTextInputProps 19 | } 20 | 21 | export function ThrottledTextInput(props: ThrottledTextInputProps) { 22 | const [ghostValue, setGhostValue] = useState(props.value) 23 | const env = useMemo(() => ({}), []) 24 | env.props = props 25 | 26 | useEffect(() => { 27 | setGhostValue(props.value) 28 | }, [props.value]) 29 | 30 | 31 | env.sendUpdateDebounced = useCallback(debounce((value: string) => { 32 | env.props.onChange(value) 33 | }, 500, { 34 | maxWait: 3000, 35 | leading: true, 36 | trailing: true 37 | }), []) 38 | 39 | env.handleBlur = () => { 40 | env.sendUpdateDebounced.flush() 41 | } 42 | 43 | useEffect(() => { 44 | window.addEventListener("beforeunload", e => { 45 | env.handleBlur() 46 | }) 47 | return () => { 48 | env.handleBlur() 49 | } 50 | }, []) 51 | 52 | if (props.textArea) { 53 | return ( 54 |