├── screenshot.png ├── extension ├── icons │ └── flowy.png ├── popup.html ├── manifest.json ├── popup.css ├── background.js └── popup.js ├── README.md ├── LICENSE.md └── .github └── workflows └── main.yml /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vzze/volume-control/HEAD/screenshot.png -------------------------------------------------------------------------------- /extension/icons/flowy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vzze/volume-control/HEAD/extension/icons/flowy.png -------------------------------------------------------------------------------- /extension/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Volume Control 2 | 3 | A [Firefox extension](https://addons.mozilla.org/en-US/firefox/addon/volume-control-tabs/) that allows you to control the volume of each tab from the extension's interface. 4 | 5 |

6 | 7 |

8 | 9 | #### Special thanks to [@pandastic](https://github.com/bandastic) and [@Davilarek](https://github.com/Davilarek) 10 | -------------------------------------------------------------------------------- /extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "background": { 3 | "scripts": ["background.js"] 4 | }, 5 | 6 | "manifest_version": 2, 7 | "name": "Volume Control", 8 | "version": "2.7", 9 | 10 | "description": "Control the volume of each tab.", 11 | 12 | "browser_specific_settings": { 13 | "gecko": { 14 | "id": "volume@vzze" 15 | } 16 | }, 17 | 18 | "icons": { 19 | "48": "icons/flowy.png" 20 | }, 21 | 22 | "browser_action": { 23 | "default_icon": "icons/flowy.png", 24 | "default_title": "Volume", 25 | "default_area": "navbar", 26 | "browser_style": true, 27 | "default_popup": "popup.html" 28 | }, 29 | 30 | "permissions": [ 31 | "tabs", 32 | "", 33 | "storage" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 vzze 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /extension/popup.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 5px; 3 | padding: 0px; 4 | 5 | background: black; 6 | 7 | font-family: "Monofur", monospace; 8 | font-size: 14px; 9 | 10 | color: #FFFFFF; 11 | } 12 | 13 | .button { 14 | border: none; 15 | background: none; 16 | text-align: right; 17 | 18 | font-family: "Monofur", monospace; 19 | font-size: 14px; 20 | 21 | transition: all 500ms; 22 | 23 | color: #d3d3d3 24 | } 25 | 26 | .button:hover { 27 | color: #FFFFFF; 28 | } 29 | 30 | ul li { 31 | list-style: none; 32 | 33 | margin-left: 0px; 34 | padding-left: 0px; 35 | margin-right: 57px; 36 | } 37 | 38 | input[type=range] { 39 | -webkit-appearance: none; 40 | appearance: initial; 41 | 42 | background: none; 43 | } 44 | 45 | input[type=range]:focus { 46 | outline: none; 47 | } 48 | 49 | .Slider { 50 | margin: 10px 0 0 0; 51 | } 52 | 53 | .SliderRange { 54 | margin-left: 10px; 55 | float: right; 56 | } 57 | 58 | .SliderValue { 59 | display: inline-block; 60 | position: absolute; 61 | 62 | width: 100; 63 | right: 20px; 64 | 65 | color: 0; 66 | line-height: 20px; 67 | border-radius: 3px; 68 | 69 | padding: 0px 10px; 70 | margin-left: 8px; 71 | } 72 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Make ZIP CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - .github/workflows/main.yml 9 | - extension/** 10 | - build.sh 11 | 12 | permissions: 13 | contents: write 14 | jobs: 15 | bundle-zip: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Install zip 20 | run: sudo apt install zip 21 | - name: Make executable 22 | run: chmod +x ./build.sh 23 | - name: Make zip 24 | run: ./build.sh 25 | - name: Upload Artifact 26 | uses: actions/upload-artifact@v3 27 | with: 28 | name: VolumeControl 29 | path: extension.zip 30 | - name: Set Tag Name 31 | id: set-tag 32 | run: echo "::set-output name=tag_name::$(git rev-parse --short "$GITHUB_SHA")" 33 | # - uses: EndBug/latest-tag@latest 34 | # id: "tag_create" 35 | # with: 36 | # tag-name: "devbuild" 37 | - name: Create Dev release 38 | run: | 39 | if ! gh release view devbuild 2>/dev/null; then gh release create devbuild; fi 40 | gh release upload devbuild --clobber extension.zip 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | - name: Publish Dev release 44 | run: gh release edit devbuild --title "DevBuild $RELEASE_TAG" 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | RELEASE_TAG: ${{ steps.set-tag.outputs.tag_name }} 48 | -------------------------------------------------------------------------------- /extension/background.js: -------------------------------------------------------------------------------- 1 | const storage = browser.storage.session; 2 | const syncStorage = browser.storage.sync; 3 | 4 | browser.tabs.onRemoved.addListener(async tabId => { 5 | const data = await storage.get("volumes").catch(() => {}); 6 | 7 | if(!data) return; 8 | if(!data.volumes) return; 9 | if(data.volumes[tabId] == undefined) return; 10 | 11 | delete data.volumes[tabId]; 12 | 13 | await storage.set({ volumes: data.volumes }).catch(() => {}); 14 | }); 15 | 16 | browser.tabs.onUpdated.addListener(async tabId => { 17 | const data = await storage.get("volumes").catch(() => {}); 18 | const sync = await syncStorage.get("nonLinearVolume").catch(() => {}); 19 | 20 | if(!data) return; 21 | if(!data.volumes) return; 22 | if(data.volumes[tabId] == undefined) return; 23 | 24 | const nonLinearVolume = (sync.nonLinearVolume) ? true : false; 25 | 26 | const newVolume = (vol) => { 27 | if(nonLinearVolume) 28 | return (vol / 100) ** 2; 29 | else 30 | return (vol / 100); 31 | } 32 | 33 | const res = await browser.tabs.executeScript(tabId, { 34 | code: `document.querySelectorAll("video, audio")[0].volume` 35 | }).catch(() => {}); 36 | 37 | if(Math.floor(res[0] * 100) != data.volumes[tabId]) 38 | data.volumes[tabId] = Math.floor(res[0] * 100) 39 | 40 | await browser.tabs.executeScript(tabId, { 41 | code: `document.querySelectorAll("video, audio").forEach(elem => elem.volume = ${newVolume(data.volumes[tabId])})` 42 | }).catch(() => { data.volumes[tabId] = 100; }); 43 | 44 | await storage.set({ volumes: data.volumes }).catch(() => {}); 45 | }); 46 | -------------------------------------------------------------------------------- /extension/popup.js: -------------------------------------------------------------------------------- 1 | let volumes = {} 2 | let nonLinearVolume = false; 3 | 4 | const storage = browser.storage.session; 5 | const syncStorage = browser.storage.sync; 6 | 7 | const throttle = (callback, delay) => { 8 | let shouldWait = false; 9 | let waitingArgs = undefined; 10 | 11 | const timeoutFunc = () => { 12 | if(waitingArgs == undefined) { 13 | shouldWait = false; 14 | } else { 15 | callback(...waitingArgs); 16 | waitingArgs = undefined; 17 | setTimeout(timeoutFunc, delay); 18 | } 19 | } 20 | 21 | return (...args) => { 22 | if(shouldWait) { 23 | waitingArgs = args; 24 | } else { 25 | callback(...args); 26 | shouldWait = true; 27 | setTimeout(timeoutFunc, delay); 28 | } 29 | } 30 | } 31 | 32 | const newVolume = (vol) => { 33 | if(nonLinearVolume) 34 | return (vol / 100) ** 2; 35 | else 36 | return (vol / 100); 37 | } 38 | 39 | const updateRange = throttle(async (id, value) => { 40 | await browser.tabs.executeScript(id, { 41 | code: `document.querySelectorAll("video, audio").forEach(elem => elem.volume = ${newVolume(value)})` 42 | }).catch(() => { return; }); 43 | 44 | volumes[id] = value; 45 | 46 | await storage.set({ volumes: volumes }).catch(() => {}); 47 | }, 50); 48 | 49 | const rangeSlider = () => { 50 | const slider = document.querySelectorAll('.Slider'); 51 | const ranges = document.querySelectorAll('.SliderRange'); 52 | const value = document.querySelectorAll('.SliderValue'); 53 | 54 | slider.forEach(() => { 55 | value.forEach((currentTarget) => { 56 | currentTarget.innerHTML = currentTarget.previousElementSibling.value; 57 | }); 58 | 59 | ranges.forEach((range) => { 60 | range.addEventListener('input', ({ currentTarget }) => { 61 | currentTarget.nextElementSibling.innerHTML = currentTarget.value; 62 | 63 | updateRange(Number(currentTarget.id), Number(currentTarget.value)); 64 | }); 65 | }); 66 | }); 67 | } 68 | 69 | const createLiEl = async (tabTitle, tabId) => { 70 | const div = document.createElement("div"); 71 | 72 | div.className = "Slider"; 73 | 74 | const range = document.createElement("input"); 75 | 76 | range.className = "SliderRange"; 77 | range.value = volumes[tabId]; 78 | range.type = "range"; 79 | range.id = String(tabId); 80 | range.max = "100"; 81 | range.min = '0'; 82 | range.step = '1'; 83 | 84 | const title = document.createElement("a"); 85 | 86 | if(tabTitle.length > 20) 87 | tabTitle = tabTitle.slice(0, 17) + "..."; 88 | else 89 | while(tabTitle.length < 20) tabTitle += "."; 90 | 91 | title.text = tabTitle; 92 | 93 | const span = document.createElement("a"); 94 | 95 | span.text = String(volumes[tabId]); 96 | span.className = "SliderValue"; 97 | 98 | const pause = document.createElement("button"); 99 | 100 | pause.id = "btn" + String(tabId); 101 | pause.classList.add("button"); 102 | 103 | const is_paused = await browser.tabs.executeScript(tabId, { 104 | code: ` 105 | Array.from(document.querySelectorAll("video, audio")).reduce((n, el) => { 106 | if(el.paused != undefined) 107 | if(el.paused == false) 108 | return n + 1; 109 | else 110 | return n; 111 | else 112 | return n; 113 | }, 0) 114 | ` 115 | }); 116 | 117 | if(is_paused[0]) pause.textContent = "Pause |" 118 | else pause.textContent = "Resume |" 119 | 120 | pause.onclick = async () => { 121 | const btn = document.getElementById("btn" + String(tabId)); 122 | 123 | if(btn.textContent == "Pause |") { 124 | await browser.tabs.executeScript(tabId, { 125 | code: `document.querySelectorAll("video, audio").forEach(elem => elem.pause())` 126 | }).catch(() => { return; }); 127 | btn.textContent = "Resume |"; 128 | } else { 129 | await browser.tabs.executeScript(tabId, { 130 | code: `document.querySelectorAll("video, audio").forEach(elem => elem.play())` 131 | }).catch(() => { return; }); 132 | btn.textContent = "Pause |"; 133 | } 134 | } 135 | 136 | div.appendChild(pause); 137 | div.appendChild(title); 138 | div.appendChild(range); 139 | div.appendChild(span); 140 | 141 | return div; 142 | } 143 | 144 | const createNonLinearButton = () => { 145 | const btn = document.createElement("button"); 146 | 147 | if(nonLinearVolume) 148 | btn.textContent = "Disable non-linear volume."; 149 | else 150 | btn.textContent = "Enable non-linear volume."; 151 | 152 | btn.classList.add("button"); 153 | btn.style["font-size"] = "13px"; 154 | 155 | btn.onclick = async () => { 156 | nonLinearVolume = !nonLinearVolume; 157 | 158 | if(nonLinearVolume) 159 | btn.textContent = "Disable non-linear volume."; 160 | else 161 | btn.textContent = "Enable non-linear volume."; 162 | 163 | await syncStorage.set({ nonLinearVolume: nonLinearVolume }).catch(() => {}); 164 | 165 | const tabs = await browser.tabs.query({}).catch(() => {}); 166 | 167 | await Promise.all(tabs.map(async tab => { 168 | if(volumes[Number(tab.id)] == undefined) return; 169 | 170 | await browser.tabs.executeScript(tab.id, { 171 | code: `document.querySelectorAll("video, audio").forEach(elem => elem.volume = ${newVolume(volumes[Number(tab.id)])})` 172 | }).catch(() => { return; }); 173 | })).catch(() => {}); 174 | } 175 | 176 | return btn; 177 | } 178 | 179 | (async () => { 180 | const data = await storage.get("volumes").catch(() => {}); 181 | const sync = await syncStorage.get("nonLinearVolume").catch(() => {}); 182 | const main = await browser.windows.getCurrent().catch(() => {}); 183 | const tabs = await browser.tabs.query({}).catch(() => {}); 184 | 185 | await storage.clear().catch(() => {}); 186 | 187 | const list = document.getElementById("tbs"); 188 | 189 | if(data.volumes) volumes = data.volumes; 190 | if(sync.nonLinearVolume) nonLinearVolume = sync.nonLinearVolume 191 | 192 | let atLeastOne = false; 193 | 194 | await Promise.all(tabs.map(async tab => { 195 | if(tab.windowId != main.id) return; 196 | 197 | const res = await browser.tabs.executeScript(Number(tab.id), { 198 | code: `document.querySelectorAll("video, audio").length` 199 | }).catch(() => {}); 200 | 201 | if(!res || !res[0]) return; 202 | 203 | atLeastOne = true; 204 | 205 | if(volumes[Number(tab.id)] == undefined) volumes[Number(tab.id)] = 100; 206 | 207 | const newLi = document.createElement("li"); 208 | 209 | newLi.appendChild(await createLiEl(tab.title, Number(tab.id))); 210 | list.appendChild(newLi); 211 | })).catch(() => {}); 212 | 213 | if(!atLeastOne) { 214 | const a = document.createElement("a"); 215 | 216 | a.text = "No tabs with audio open."; 217 | a.style.paddingRight = "30px"; 218 | 219 | list.appendChild( 220 | document.createElement("li").appendChild(a) 221 | ); 222 | } 223 | 224 | const nonLinear = document.getElementById("non-linear"); 225 | nonLinear.appendChild(createNonLinearButton()); 226 | 227 | await storage.set({ volumes: volumes }).catch(() => {}); 228 | await syncStorage.set({ nonLinearVolume: nonLinearVolume }).catch(() => {}); 229 | 230 | rangeSlider(); 231 | })(); 232 | --------------------------------------------------------------------------------