├── .gitignore ├── static ├── icon.png └── manifest.json ├── tslint.json ├── .babelrc ├── src ├── interfaces │ └── Message.ts ├── popup.scss ├── popup.html ├── popup.ts └── background.ts ├── tsconfig.json ├── LICENSE ├── README.md └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | 3 | .cache/ 4 | node_modules/ 5 | -------------------------------------------------------------------------------- /static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piousdeer/chrome-volume-manager/HEAD/static/icon.png -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-standeerd", 3 | "rules": { 4 | "no-reference": false 5 | } 6 | } -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "browsers": ["last 2 Chrome versions"] 6 | } 7 | }] 8 | ] 9 | } -------------------------------------------------------------------------------- /src/interfaces/Message.ts: -------------------------------------------------------------------------------- 1 | type Message = { 2 | name: 'get-tab-volume', 3 | tabId: number 4 | } | { 5 | name: 'set-tab-volume', 6 | tabId: number, 7 | value: number 8 | } 9 | 10 | export default Message 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "lib": ["es2018", "dom"], 7 | "esModuleInterop": true 8 | }, 9 | "exclude": [ 10 | "node_modules" 11 | ] 12 | } -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Volume Manager", 4 | "version": "1.1.0", 5 | "description": "Control any tab's volume separately", 6 | "permissions": [ 7 | "activeTab", 8 | "tabCapture" 9 | ], 10 | "browser_action": { 11 | "default_icon": "icon.png", 12 | "default_popup": "popup.html" 13 | }, 14 | "background": { 15 | "scripts": ["background.js"] 16 | }, 17 | "icons": { 18 | "512": "icon.png" 19 | } 20 | } -------------------------------------------------------------------------------- /src/popup.scss: -------------------------------------------------------------------------------- 1 | @import '@material/slider/dist/mdc.slider.css'; 2 | 3 | :root { 4 | --mdc-theme-secondary: #3367d6; 5 | } 6 | 7 | html, body { 8 | height: 77px; 9 | width: 360px; 10 | } 11 | 12 | body { 13 | margin: 0; 14 | display: flex; 15 | align-items: center; 16 | background: #000; 17 | } 18 | 19 | main { 20 | width: 100%; 21 | margin: 0 20px; 22 | } 23 | 24 | .mdc-slider { 25 | transition: opacity 500ms; 26 | } 27 | 28 | .mdc-slider__pin-value-marker { 29 | font-size: 0.575rem; 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, piousdeer 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /src/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 |
12 |
13 |
14 |
15 |
16 | 17 | 18 | 19 |
20 |
21 |
22 |
23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Volume Manager

3 |

A simple Chrome extension to control any tab's volume separately.

4 | 5 |

6 | 7 | * Click on the extension icon and drag the slider to adjust the volume of the active tab 8 | * You can reduce its volume down to 0% and boost it up to 600% 9 | * The current volume is displayed as a badge next to the icon 10 | 11 | There are several similar extensions. However, they're either filled with telemetry and analytics, or their UI sucks. 12 | 13 | # Usage 14 | This extension is not in Chrome Web Store yet; you have to build it yourself. 15 | 1. Clone this repository 16 | 2. Run `npm install && npm run build` 17 | 3. Go to `chrome://extensions/`, enable developer mode and load `dist` folder as an unpacked extension 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-volume-manager", 3 | "version": "1.1.0", 4 | "description": "Chrome extension to control any tab's volume separately", 5 | "files": [ 6 | "dist", 7 | "!*.map" 8 | ], 9 | "scripts": { 10 | "build": "parcel build src/popup.html src/background.ts", 11 | "watch": "parcel watch src/popup.html src/background.ts" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/piousdeer/chrome-volume-manager.git" 16 | }, 17 | "author": "piousdeer", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/piousdeer/chrome-volume-manager/issues" 21 | }, 22 | "homepage": "https://github.com/piousdeer/chrome-volume-manager#readme", 23 | "devDependencies": { 24 | "@types/material-components-web": "^0.43.1", 25 | "babel-core": "^6.26.3", 26 | "babel-preset-env": "^1.7.0", 27 | "parcel-bundler": "^1.12.4", 28 | "parcel-plugin-static-files-copy": "^2.2.1", 29 | "sass": "^1.23.2", 30 | "tslint": "^5.20.0", 31 | "tslint-config-standeerd": "^4.0.0", 32 | "typescript": "^3.6.4" 33 | }, 34 | "dependencies": { 35 | "chrome-extension-async": "^3.4.1", 36 | "material-components-web": "^3.2.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/popup.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import 'chrome-extension-async' 3 | 4 | import Message from './interfaces/Message' 5 | import { MDCSlider } from '@material/slider' 6 | 7 | const sliderElem: HTMLDivElement = document.querySelector('#volume-slider') 8 | const slider = new MDCSlider(sliderElem) 9 | 10 | void (async () => { 11 | // Hide the slider until we know the initial volume 12 | sliderElem.style.opacity = '0' 13 | 14 | const initialValue = await getActiveTabVolume() 15 | slider.value = initialValue * 100 16 | 17 | sliderElem.style.opacity = '1' 18 | })() 19 | 20 | slider.listen('MDCSlider:input', () => { 21 | const value = slider.value / 100 22 | setActiveTabVolume(value) 23 | }) 24 | 25 | async function getActiveTabVolume () { 26 | const tabId = await getActiveTabId() 27 | const message: Message = { name: 'get-tab-volume', tabId } 28 | return chrome.runtime.sendMessage(message) 29 | } 30 | 31 | async function setActiveTabVolume (value: number) { 32 | const tabId = await getActiveTabId() 33 | const message: Message = { name: 'set-tab-volume', tabId, value } 34 | return chrome.runtime.sendMessage(message) 35 | } 36 | 37 | async function getActiveTabId () { 38 | const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }) 39 | return activeTab.id 40 | } 41 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import 'chrome-extension-async' 3 | 4 | import Message from './interfaces/Message' 5 | 6 | // Handle messages from popup 7 | chrome.runtime.onMessage.addListener(async (message: Message, sender, respond) => { 8 | switch (message.name) { 9 | case 'get-tab-volume': 10 | respond(await getTabVolume(message.tabId)) 11 | break 12 | case 'set-tab-volume': 13 | respond(undefined) // Nothing to send here. 14 | await setTabVolume(message.tabId, message.value) 15 | break 16 | default: 17 | throw Error(`Unknown message received: ${message}`) 18 | } 19 | }) 20 | 21 | // Clean everything up once the tab is closed 22 | chrome.tabs.onRemoved.addListener(disposeTab) 23 | 24 | interface CapturedTab { 25 | audioContext: AudioContext, 26 | // While we will never use `streamSource` property in the code, 27 | // it is necessary to keep a reference to it, or else 28 | // it will get garbage-collected and the sound will be gone. 29 | streamSource: MediaStreamAudioSourceNode, 30 | gainNode: GainNode 31 | } 32 | 33 | // We use promises to fight race conditions. 34 | const tabs: { [tabId: number]: Promise } = {} 35 | 36 | /** 37 | * Captures a tab's sound, allowing it to be programmatically modified. 38 | * Puts a promise into the `tabs` object. We only need to call this function 39 | * if the tab isn't yet in that object. 40 | * @param tabId Tab ID 41 | */ 42 | function captureTab (tabId: number) { 43 | tabs[tabId] = new Promise(async resolve => { 44 | const stream = await chrome.tabCapture.capture({ audio: true, video: false }) 45 | 46 | const audioContext = new AudioContext() 47 | const streamSource = audioContext.createMediaStreamSource(stream) 48 | const gainNode = audioContext.createGain() 49 | 50 | streamSource.connect(gainNode) 51 | gainNode.connect(audioContext.destination) 52 | 53 | resolve({ audioContext, streamSource, gainNode }) 54 | }) 55 | } 56 | 57 | /** 58 | * Returns a tab's volume, `1` if the tab isn't captured yet. 59 | * @param tabId Tab ID 60 | */ 61 | async function getTabVolume (tabId: number) { 62 | return tabId in tabs ? (await tabs[tabId]).gainNode.gain.value : 1 63 | } 64 | 65 | /** 66 | * Sets a tab's volume. Captures the tab if it wasn't captured. 67 | * @param tabId Tab ID 68 | * @param value Volume. `1` means 100%, `0.5` is 50%, etc 69 | */ 70 | async function setTabVolume (tabId: number, value: number) { 71 | if (!(tabId in tabs)) { 72 | captureTab(tabId) 73 | } 74 | 75 | (await tabs[tabId]).gainNode.gain.value = value 76 | updateBadge(tabId, value) 77 | } 78 | 79 | /** 80 | * Updates the badge which represents current volume. 81 | * @param tabId Tab ID 82 | * @param value Volume. `1` will display 100, `0.5` - 50, etc 83 | */ 84 | async function updateBadge (tabId: number, value: number) { 85 | if (tabId in tabs) { 86 | const text = String(Math.round(value * 100)) // I love rounding errors! 87 | chrome.browserAction.setBadgeText({ text, tabId }) 88 | } 89 | } 90 | 91 | /** 92 | * Removes the tab from `tabs` object and closes its AudioContext. 93 | * This function gets called when a tab is closed. 94 | * @param tabId Tab ID 95 | */ 96 | async function disposeTab (tabId: number) { 97 | if (tabId in tabs) { 98 | (await tabs[tabId]).audioContext.close() 99 | delete tabs[tabId] 100 | } 101 | } 102 | --------------------------------------------------------------------------------