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 | {
48 | props.onChange(produce(props.values, d => {
49 | d.push(props.defaultValue ?? 0)
50 | }))
51 | }}>
52 |
53 |
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 |
{
32 | setView({qrCodeHide: true})
33 | }} className="icon">
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 | props.onRemove()}>
45 |
46 |
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 | isEdge() ? requestCreateTab(`chrome://extensions`) : requestCreateTab(`chrome://extensions/?id=${chrome.runtime.id}#:~:text=${encodeURIComponent("Allow User Scripts")}`)}>
57 |
58 | {gvar.gsm.token.openPage}
59 |
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 | {
34 | setCommand(e.target.value)
35 | }}>
36 | {filterTypes.map(t => (
37 | {(gvar.gsm.filter.otherFilters as any)[t]}
38 | ))}
39 |
40 | {
41 | props.onChange(produce(props.svgFilters, dArr => {
42 | let cmd = command as keyof typeof svgFilterInfos
43 | if (e.shiftKey && e.metaKey) cmd = "custom"
44 | dArr.push(svgFilterGenerate(cmd))
45 | }), true)
46 | }}>{gvar.gsm.token.create}
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 |
52 |
53 |
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 |
82 |
83 |
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 : props.syncChange()} style={{padding: "0px 5px", marginLeft: "10px"}} className={`toggle ${props.syncValue ? "active" : ""}`}>: }
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 |