diff --git a/README.md b/README.md new file mode 100644 index 0000000..6fe73ff --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Volume Booster — Chrome Extension + +Ein Volume Booster für Chrome: macht jeden Tab lauter (bis 600 %) oder leiser, +mit stummschalten und Schnellwahl-Presets. + +## Installieren + +1. ZIP entpacken (falls noch nicht geschehen). +2. In Chrome `chrome://extensions` öffnen. +3. Oben rechts **Entwicklermodus** einschalten. +4. **Entpackte Erweiterung laden** klicken und den Ordner `volume-booster` auswählen. +5. Das Lautsprecher-Icon erscheint in der Toolbar — anpinnen, fertig. + +## Benutzen + +- Auf das Icon klicken, um das Panel zu öffnen. +- **Slider** ziehen oder die **+ / –** Knöpfe nutzen (10 %-Schritte). +- **Mute**-Knopf (mittig) schaltet stumm und beim erneuten Klick wieder zurück. +- **Presets**: 100 % / 200 % / 400 % / 600 % für einen Klick. +- Pfeiltasten ↑ / ↓ funktionieren ebenfalls, wenn das Panel offen ist. + +## Wie es funktioniert + +- Bis **100 %** passiert nichts Besonderes — die Seite spielt normal. +- Über **100 %** holt der Service Worker per `chrome.tabCapture.getMediaStreamId` + eine Stream-ID des Tabs und reicht sie an ein **Offscreen-Dokument** weiter. + Dort wird der Stream durch einen Web-Audio `GainNode` geschickt und + verstärkt wieder ausgegeben. +- Der Umweg über das Offscreen-Dokument ist in Manifest V3 nötig: Der + Service Worker hat kein DOM und kann selbst kein Audio verarbeiten. +- Pro Tab wird ein eigener Verstärkungswert gespeichert (für die Session). +- Schließt du den Tab oder lädst ihn neu, wird der Stream sauber freigegeben. + +## Hinweise + +- Bei sehr hohen Werten kann der Ton übersteuern/verzerren — das ist eine + physikalische Grenze der Quelle, dann einfach etwas zurückdrehen. +- Funktioniert nicht auf internen Seiten wie `chrome://`, + dem Web Store oder anderen Extension-Seiten — dort gibt Chrome keinen + Audio-Stream frei. +- Beim ersten Verstärken eines Tabs muss kurz Audio laufen, damit Chrome + den Stream herausgibt. +- Verstärkung gilt jeweils für den Tab, in dem du sie einstellst. +- Nach einem Update der Extension immer einmal auf `chrome://extensions` + neu laden. + +## Dateien + +| Datei | Zweck | +|------------------|----------------------------------------------------------| +| `manifest.json` | Extension-Manifest (Manifest V3) | +| `background.js` | Service Worker — holt Stream-ID, steuert Offscreen-Doc | +| `offscreen.html` | Unsichtbares Dokument, Träger des Audio-Codes | +| `offscreen.js` | AudioContext + GainNode — das eigentliche Verstärken | +| `popup.html` | Aufbau des Interfaces | +| `popup.css` | Styling (dunkles Studio-Theme, Amber-Akzent) | +| `popup.js` | Logik, State, Kommunikation mit dem Worker | +| `icons/` | Icon in 16 / 32 / 48 / 128 px + SVG-Quelle | diff --git a/background.js b/background.js new file mode 100644 index 0000000..5b6b3af --- /dev/null +++ b/background.js @@ -0,0 +1,124 @@ +// ============================================================ +// Volume Booster — Background Service Worker +// In Manifest V3 kann der Service Worker selbst KEIN Audio +// capturen (kein DOM, kein getUserMedia). Er holt nur eine +// Stream-ID via chrome.tabCapture.getMediaStreamId und reicht +// sie an ein Offscreen-Dokument weiter, das den GainNode hält. +// ============================================================ + +let creating = null; // Promise-Lock, damit nicht zwei Offscreen-Docs entstehen + +async function ensureOffscreen() { + const contexts = await chrome.runtime.getContexts({ + contextTypes: ["OFFSCREEN_DOCUMENT"], + }); + if (contexts.length > 0) return; + + if (creating) { + await creating; + return; + } + + creating = chrome.offscreen.createDocument({ + url: "offscreen.html", + reasons: ["USER_MEDIA"], + justification: "Tab-Audio verstärken (Volume Booster)", + }); + await creating; + creating = null; +} + +// Nachrichten aus dem Popup +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + // Nachrichten fürs Offscreen-Dokument hier nicht behandeln + if (msg.target === "offscreen") return false; + + (async () => { + try { + if (msg.type === "SET_VOLUME") { + const { tabId, value } = msg; // value: 0.0 – 6.0 + + await ensureOffscreen(); + + if (value === 1) { + // Exakt 100 %: Boost beenden, kein Capture nötig + const res = await chrome.runtime.sendMessage({ + target: "offscreen", + type: "STOP", + tabId, + }); + sendResponse(res || { ok: true, boosting: false }); + return; + } + + // ERST prüfen, ob dieser Tab schon einen aktiven Stream hat. + // Wenn ja, NUR den Gain ändern — keine neue Stream-ID holen! + // (Eine getMediaStreamId-ID ist einmalig und der Tab kann nicht + // ein zweites Mal gecaptured werden, solange der erste Stream läuft.) + const state = await chrome.runtime.sendMessage({ + target: "offscreen", + type: "STATE", + tabId, + }); + + if (state && state.boosting) { + // Tab läuft schon: nur Lautstärke anpassen + const gainRes = await chrome.runtime.sendMessage({ + target: "offscreen", + type: "GAIN", + tabId, + value, + }); + if (gainRes && gainRes.ok) { + sendResponse(gainRes); + return; + } + // Falls der Stream zwischendurch verloren ging: unten neu capturen + } + + // Tab noch nicht aktiv: jetzt eine frische Stream-ID holen + const streamId = await chrome.tabCapture.getMediaStreamId({ + targetTabId: tabId, + }); + + const applyRes = await chrome.runtime.sendMessage({ + target: "offscreen", + type: "APPLY", + tabId, + streamId, + value, + }); + sendResponse( + applyRes || { ok: false, error: "Keine Antwort vom Offscreen-Dokument" } + ); + } else if (msg.type === "GET_STATE") { + await ensureOffscreen(); + const res = await chrome.runtime.sendMessage({ + target: "offscreen", + type: "STATE", + tabId: msg.tabId, + }); + sendResponse(res || { ok: true, boosting: false, value: 1 }); + } else if (msg.type === "RESET") { + await ensureOffscreen(); + const res = await chrome.runtime.sendMessage({ + target: "offscreen", + type: "STOP", + tabId: msg.tabId, + }); + sendResponse(res || { ok: true }); + } + } catch (err) { + sendResponse({ ok: false, error: String((err && err.message) || err) }); + } + })(); + + return true; // async sendResponse +}); + +// Aufräumen, wenn ein Tab geschlossen wird +chrome.tabs.onRemoved.addListener((tabId) => { + chrome.runtime + .sendMessage({ target: "offscreen", type: "STOP", tabId }) + .catch(() => {}); +}); diff --git a/icons/icon.svg b/icons/icon.svg new file mode 100644 index 0000000..4183a92 --- /dev/null +++ b/icons/icon.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000..74e6e79 Binary files /dev/null and b/icons/icon128.png differ diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000..7f16d3c Binary files /dev/null and b/icons/icon16.png differ diff --git a/icons/icon32.png b/icons/icon32.png new file mode 100644 index 0000000..74f6239 Binary files /dev/null and b/icons/icon32.png differ diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000..72b7db6 Binary files /dev/null and b/icons/icon48.png differ diff --git a/manifest.json b/manifest.json index 1db0585..c095b4b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,7 @@ { "manifest_version": 3, "name": "Volume Booster", +<<<<<<< HEAD "version": "1.0", "description": "Boost audio up to 600%", "permissions": ["activeTab", "storage"], @@ -14,4 +15,29 @@ "js": ["content.js"] } ] +======= + "author": "Tronax mail@tronax.dev", + "version": "1.0.0", + "description": "Boost or lower the volume of any browser tab — up to 600%. Mit schönem Interface.", + "permissions": ["activeTab", "tabs", "storage", "tabCapture", "offscreen"], + "action": { + "default_popup": "popup.html", + "default_title": "Volume Booster", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "background": { + "service_worker": "background.js" + } +>>>>>>> 2056a0d (First Commit, Code by Claude 4.7 Opus) } diff --git a/offscreen.html b/offscreen.html new file mode 100644 index 0000000..5094571 --- /dev/null +++ b/offscreen.html @@ -0,0 +1,8 @@ + + +Volume Booster — Offscreen + + + + + diff --git a/offscreen.js b/offscreen.js new file mode 100644 index 0000000..4c3c706 --- /dev/null +++ b/offscreen.js @@ -0,0 +1,95 @@ +// ============================================================ +// Volume Booster — Offscreen Document +// Läuft in einem unsichtbaren DOM-Kontext. Nur hier sind +// getUserMedia + AudioContext verfügbar. Hält pro Tab einen +// GainNode und gibt den verstärkten Stream wieder aus. +// ============================================================ + +// Map +const active = new Map(); + +function teardown(tabId) { + const entry = active.get(tabId); + if (!entry) return; + try { + entry.source.disconnect(); + entry.gain.disconnect(); + entry.stream.getTracks().forEach((t) => t.stop()); + entry.context.close(); + } catch (e) { + /* trotzdem aufräumen */ + } + active.delete(tabId); +} + +async function applyBoost(tabId, streamId, value) { + // Falls (z. B. nach Fehler) noch ein alter Eintrag existiert: erst sauber weg. + if (active.has(tabId)) { + teardown(tabId); + } + + // Stream aus der ID erzeugen + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + mandatory: { + chromeMediaSource: "tab", + chromeMediaSourceId: streamId, + }, + }, + video: false, + }); + + const context = new AudioContext(); + const source = context.createMediaStreamSource(stream); + const gain = context.createGain(); + gain.gain.value = value; + + source.connect(gain); + gain.connect(context.destination); // Audio bleibt für den Nutzer hörbar + + // Wenn der Stream endet (Tab neu geladen / geschlossen) + const track = stream.getAudioTracks()[0]; + if (track) { + track.addEventListener("ended", () => teardown(tabId)); + } + + active.set(tabId, { context, gain, source, stream }); + return { ok: true, boosting: true, value }; +} + +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg.target !== "offscreen") return false; + + (async () => { + try { + if (msg.type === "APPLY") { + const res = await applyBoost(msg.tabId, msg.streamId, msg.value); + sendResponse(res); + } else if (msg.type === "GAIN") { + // Nur die Lautstärke eines bereits aktiven Tabs ändern + const entry = active.get(msg.tabId); + if (entry) { + entry.gain.gain.value = msg.value; + sendResponse({ ok: true, boosting: true, value: msg.value }); + } else { + // Kein aktiver Stream (mehr) — Popup soll neu anwenden + sendResponse({ ok: false, error: "no active stream", boosting: false }); + } + } else if (msg.type === "STOP") { + teardown(msg.tabId); + sendResponse({ ok: true, boosting: false }); + } else if (msg.type === "STATE") { + const entry = active.get(msg.tabId); + sendResponse({ + ok: true, + boosting: !!entry, + value: entry ? entry.gain.gain.value : 1, + }); + } + } catch (err) { + sendResponse({ ok: false, error: String((err && err.message) || err) }); + } + })(); + + return true; // async +}); diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..5ff046e --- /dev/null +++ b/popup.css @@ -0,0 +1,217 @@ +/* ============================================================ + Volume Booster — Popup Styles + Aesthetik: dunkles "Audio-Studio"-Panel, warmer Amber-Akzent, + prägnante Typografie, taktile Buttons. + ============================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;600;700&family=DM+Mono:wght@400;500&display=swap'); + +:root { + --bg: #15171c; + --panel: #1d2027; + --panel-2: #23272f; + --line: #2f343d; + --text: #e8e6e1; + --text-dim: #8b8f99; + --accent: #ff9d2f; + --accent-dk: #e07d12; + --accent-gl: rgba(255, 157, 47, 0.18); + --danger: #ff5d5d; + --radius: 14px; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + width: 320px; + font-family: 'Sora', sans-serif; + background: + radial-gradient(120% 80% at 50% -10%, #232730 0%, var(--bg) 60%); + color: var(--text); + -webkit-font-smoothing: antialiased; + user-select: none; +} + +.app { + padding: 18px 18px 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* ---------- Kopf ---------- */ +.head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.brand { display: flex; align-items: center; gap: 11px; } + +.brand-ico { + width: 34px; height: 34px; + color: var(--accent); + padding: 7px; + background: var(--accent-gl); + border-radius: 10px; + flex-shrink: 0; +} + +.brand-text { display: flex; flex-direction: column; line-height: 1.25; } +.brand-name { font-weight: 700; font-size: 14px; letter-spacing: .2px; } +.brand-site { + font-size: 11px; + color: var(--text-dim); + max-width: 150px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.badge { + font-family: 'DM Mono', monospace; + font-size: 12px; + font-weight: 500; + padding: 5px 9px; + border-radius: 8px; + background: var(--panel-2); + border: 1px solid var(--line); + color: var(--text-dim); + transition: all .2s ease; +} +.badge.boosting { + color: var(--accent); + border-color: var(--accent-dk); + background: var(--accent-gl); +} + +/* ---------- Großer Wert ---------- */ +.readout { + display: flex; + align-items: baseline; + justify-content: center; + gap: 4px; + padding: 6px 0 2px; +} +.readout-value { + font-family: 'DM Mono', monospace; + font-size: 54px; + font-weight: 500; + letter-spacing: -1px; + line-height: 1; + background: linear-gradient(180deg, #fff, #cfcdc7); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + transition: color .15s; +} +.readout.boosting .readout-value { + background: linear-gradient(180deg, #ffd9a0, var(--accent)); + -webkit-background-clip: text; +} +.readout-unit { + font-size: 20px; + color: var(--text-dim); + font-weight: 600; +} + +/* ---------- Slider ---------- */ +.slider-wrap { padding: 0 2px; } + +input[type="range"] { + -webkit-appearance: none; + width: 100%; + height: 8px; + border-radius: 6px; + background: var(--panel-2); + border: 1px solid var(--line); + outline: none; + cursor: pointer; +} +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 22px; height: 22px; + border-radius: 50%; + background: radial-gradient(circle at 35% 30%, #ffce8f, var(--accent) 70%); + border: 2px solid #1a1c22; + box-shadow: 0 0 0 1px var(--accent-dk), 0 4px 10px rgba(0,0,0,.5); + transition: transform .12s ease; +} +input[type="range"]::-webkit-slider-thumb:active { transform: scale(1.18); } + +.ticks { + display: flex; + justify-content: space-between; + margin-top: 7px; + font-family: 'DM Mono', monospace; + font-size: 10px; + color: var(--text-dim); +} + +/* ---------- Steuerknöpfe ---------- */ +.controls { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; +} +.ctrl { + height: 50px; + border-radius: var(--radius); + border: 1px solid var(--line); + background: linear-gradient(180deg, var(--panel-2), var(--panel)); + color: var(--text); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform .1s ease, border-color .15s, background .15s; +} +.ctrl svg { width: 22px; height: 22px; } +.ctrl:hover { border-color: var(--accent-dk); } +.ctrl:active { transform: translateY(2px) scale(.97); } + +.ctrl-mute { color: var(--text-dim); } +.ctrl-mute.active { + color: var(--danger); + border-color: var(--danger); + background: rgba(255, 93, 93, .12); +} + +/* ---------- Presets ---------- */ +.presets { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} +.preset { + font-family: 'DM Mono', monospace; + font-size: 11px; + font-weight: 500; + padding: 8px 4px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--panel); + color: var(--text-dim); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + transition: all .15s ease; +} +.preset svg { width: 14px; height: 14px; } +.preset:hover { color: var(--text); border-color: #3c424d; } +.preset.active { + color: var(--accent); + border-color: var(--accent-dk); + background: var(--accent-gl); +} + +/* ---------- Hinweis ---------- */ +.note { + font-size: 10.5px; + color: var(--text-dim); + text-align: center; + line-height: 1.5; + padding-top: 2px; +} +.note.error { color: var(--danger); } diff --git a/popup.html b/popup.html index 3b5998d..9799242 100644 --- a/popup.html +++ b/popup.html @@ -1,6 +1,7 @@ +<<<<<<< HEAD