Skip to content

Commit 5735f0d

Browse files
steveseguinactions-user
authored andcommitted
feat(youtube): Implement in-page audio output picker
Adds a new setting and functionality to allow users to select a specific audio output device directly from YouTube watch pages. - Introduces a "youtubeAudioPicker" toggle setting in `popup.html` for user control. - `background.js` is updated to recognize and push changes for the new setting. - `sources/static/youtube_static.js` contains the core logic for the in-page picker: - Renders a button and a dynamic panel for listing and selecting audio output devices. - Utilizes `navigator.mediaDevices.selectAudioOutput()` for device enumeration and selection capabilities. - Applies the chosen audio output device to the active YouTube video element. - The audio picker runs only when enabled via the new extension setting, ensuring it's not always active. [auto-enhanced]
1 parent d2423e0 commit 5735f0d

File tree

3 files changed

+272
-11
lines changed

3 files changed

+272
-11
lines changed

background.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3913,6 +3913,9 @@ chrome.runtime.onMessage.addListener(async function (request, sender, sendRespon
39133913
if (request.setting == "youtubeLargerFont") {
39143914
pushSettingChange();
39153915
}
3916+
if (request.setting == "youtubeAudioPicker") {
3917+
pushSettingChange();
3918+
}
39163919
if (request.setting == "vdoninjadiscord") {
39173920
pushSettingChange();
39183921
}

popup.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4995,6 +4995,14 @@ <h3><span data-translate="misc-chat-options">Miscellaneous options for sites</sp
49954995
</label>
49964996
<img class="icon" src="./sources/images/youtube.png" style="display: inline-block;" /> <span data-translate="flip-youtube-layout">Flip the Youtube watch page layout</span>
49974997
</div>
4998+
4999+
<div title="Show an in-page audio output picker on YouTube watch pages. The picker only runs when enabled.">
5000+
<label class="switch">
5001+
<input type="checkbox" data-setting="youtubeAudioPicker" />
5002+
<span class="slider round"></span>
5003+
</label>
5004+
<img class="icon" src="./sources/images/youtube.png" style="display: inline-block;" />🔊 <span>Enable YouTube audio output picker (in-page)</span>
5005+
</div>
49985006

49995007
<div title="Hide "Paid Promotion" banners in Youtube">
50005008
<label class="switch">

sources/static/youtube_static.js

Lines changed: 261 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
chrome.runtime.sendMessage(chrome.runtime.id, { "getSettings": true }, function(response){ // {"state":isExtensionOn,"streamID":channel, "settings":settings}
3131
if ("settings" in response){
3232
settings = response.settings;
33+
applyAudioPickerSetting();
3334
}
3435
if ("state" in response){
3536
isExtensionOn = response.state;
@@ -63,7 +64,7 @@
6364
if (typeof request === "object"){
6465
if ("settings" in request){
6566
settings = request.settings;
66-
67+
applyAudioPickerSetting();
6768
}
6869
if ("state" in request){
6970
isExtensionOn = request.state;
@@ -75,6 +76,7 @@
7576
document.getElementById("startupbutton").style.display = "none";
7677
}
7778
}
79+
applyAudioPickerSetting();
7880
}
7981
sendResponse(true);
8082
return;
@@ -231,9 +233,266 @@
231233
}, 2000);
232234
}
233235

236+
const AUDIO_OUTPUT_BUTTON_ID = "ssn-audio-output-picker";
237+
const AUDIO_OUTPUT_PANEL_ID = "ssn-audio-output-panel";
238+
const AUDIO_OUTPUT_SELECT_ID = "ssn-audio-output-select";
239+
const AUDIO_OUTPUT_STATUS_ID = "ssn-audio-output-status";
240+
let isPickingAudioOutput = false;
241+
let hasVisibilityListeners = false;
242+
243+
function removeAudioOutputButton() {
244+
const existing = document.getElementById(AUDIO_OUTPUT_BUTTON_ID);
245+
if (existing) {
246+
existing.remove();
247+
}
248+
249+
const panel = document.getElementById(AUDIO_OUTPUT_PANEL_ID);
250+
if (panel) {
251+
panel.remove();
252+
}
253+
}
254+
255+
function getActiveVideo() {
256+
return document.querySelector(".html5-main-video") || document.querySelector("video");
257+
}
258+
259+
function renderAudioOutputPanel(outputs, selectedId) {
260+
let panel = document.getElementById(AUDIO_OUTPUT_PANEL_ID);
261+
if (!panel) {
262+
panel = document.createElement("div");
263+
panel.id = AUDIO_OUTPUT_PANEL_ID;
264+
panel.style = "position: fixed; right: 18px; bottom: 70px; z-index: 2147483647; background: #0f0f0f; color: #fff; border: 1px solid #3ea6ff; border-radius: 10px; padding: 10px; box-shadow: 0 6px 14px rgba(0,0,0,0.35); font-size: 13px; font-family: inherit; min-width: 240px;";
265+
266+
const header = document.createElement("div");
267+
header.style = "display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px;";
268+
const title = document.createElement("span");
269+
title.textContent = "Audio output";
270+
const close = document.createElement("button");
271+
close.type = "button";
272+
close.textContent = "✕";
273+
close.style = "background: transparent; border: none; color: #fff; cursor: pointer; font-size: 14px; padding: 0 4px;";
274+
close.addEventListener("click", () => panel.remove());
275+
header.appendChild(title);
276+
header.appendChild(close);
277+
panel.appendChild(header);
278+
279+
const select = document.createElement("select");
280+
select.id = AUDIO_OUTPUT_SELECT_ID;
281+
select.style = "width: 100%; margin-bottom: 6px; background: #121212; color: #fff; border: 1px solid #3ea6ff; border-radius: 6px; padding: 6px;";
282+
panel.appendChild(select);
283+
284+
const status = document.createElement("div");
285+
status.id = AUDIO_OUTPUT_STATUS_ID;
286+
status.style = "opacity: 0.85;";
287+
panel.appendChild(status);
288+
289+
(document.body || document.documentElement).appendChild(panel);
290+
}
291+
292+
const selectEl = panel.querySelector("#" + AUDIO_OUTPUT_SELECT_ID);
293+
const statusEl = panel.querySelector("#" + AUDIO_OUTPUT_STATUS_ID);
294+
295+
selectEl.innerHTML = "";
296+
const defaultOpt = document.createElement("option");
297+
defaultOpt.value = "default";
298+
defaultOpt.textContent = "System default";
299+
selectEl.appendChild(defaultOpt);
300+
301+
outputs.forEach((device, idx) => {
302+
const opt = document.createElement("option");
303+
opt.value = device.deviceId;
304+
opt.textContent = device.label || `Audio output ${idx + 1}`;
305+
selectEl.appendChild(opt);
306+
});
307+
308+
if (selectedId && selectEl.querySelector(`option[value="${selectedId}"]`)) {
309+
selectEl.value = selectedId;
310+
} else {
311+
selectEl.value = "default";
312+
}
313+
314+
statusEl.textContent = selectEl.value === "default" ? "Using system default" : `Using ${selectEl.options[selectEl.selectedIndex].textContent}`;
315+
316+
selectEl.onchange = () => {
317+
const chosenId = selectEl.value;
318+
statusEl.textContent = "Switching...";
319+
applyAudioOutput(chosenId, outputs).then(() => {
320+
const label = selectEl.options[selectEl.selectedIndex].textContent;
321+
statusEl.textContent = chosenId === "default" ? "Using system default" : `Using ${label}`;
322+
}).catch((err) => {
323+
console.warn("Failed to switch audio output", err);
324+
statusEl.textContent = err && err.message ? err.message : "Failed to switch output";
325+
});
326+
};
327+
328+
return { panel, selectEl, statusEl };
329+
}
330+
331+
async function applyAudioOutput(deviceId, outputs) {
332+
const video = getActiveVideo();
333+
if (!video) {
334+
throw new Error("No video element found on this page.");
335+
}
336+
if (typeof video.setSinkId !== "function") {
337+
throw new Error("Audio output selection is not supported in this browser.");
338+
}
339+
await video.setSinkId(deviceId || "default");
340+
}
341+
342+
async function listAudioOutputsWithPermission() {
343+
if (!navigator.mediaDevices) {
344+
throw new Error("Media devices API is unavailable.");
345+
}
346+
347+
let devices = [];
348+
try {
349+
devices = await navigator.mediaDevices.enumerateDevices();
350+
} catch (err) {
351+
console.warn("enumerateDevices failed before permission", err);
352+
}
353+
354+
const hasAudioOutputs = devices.some((d) => d.kind === "audiooutput" && d.deviceId);
355+
if (!hasAudioOutputs) {
356+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
357+
stream.getTracks().forEach((track) => track.stop());
358+
devices = await navigator.mediaDevices.enumerateDevices();
359+
}
360+
361+
return devices.filter((d) => d.kind === "audiooutput" && d.deviceId);
362+
}
363+
364+
function isFullPlayerMode() {
365+
if (document.fullscreenElement) {
366+
return true;
367+
}
368+
const player = document.querySelector(".html5-video-player");
369+
return !!(player && (player.classList.contains("ytp-fullscreen") || player.classList.contains("ytp-big-mode")));
370+
}
371+
372+
function syncAudioPickerVisibility() {
373+
const hide = isFullPlayerMode();
374+
const button = document.getElementById(AUDIO_OUTPUT_BUTTON_ID);
375+
const panel = document.getElementById(AUDIO_OUTPUT_PANEL_ID);
376+
if (button) {
377+
button.style.display = hide ? "none" : "block";
378+
}
379+
if (panel) {
380+
panel.style.display = hide ? "none" : "";
381+
}
382+
}
383+
384+
function applyAudioPickerSetting() {
385+
const onWatchPage = window.location.href.startsWith("https://www.youtube.com/watch");
386+
if (!isExtensionOn || !settings.youtubeAudioPicker || !onWatchPage) {
387+
removeAudioOutputButton();
388+
return;
389+
}
390+
ensureAudioOutputButton();
391+
}
392+
393+
async function pickAudioOutputForCurrentVideo(buttonEl) {
394+
if (isPickingAudioOutput) {
395+
return;
396+
}
397+
398+
const video = getActiveVideo();
399+
if (!video) {
400+
throw new Error("No video element found on this page.");
401+
}
402+
if (typeof video.setSinkId !== "function") {
403+
throw new Error("Audio output selection is not supported in this browser.");
404+
}
405+
if (!navigator.mediaDevices) {
406+
throw new Error("Media devices API is unavailable.");
407+
}
408+
409+
isPickingAudioOutput = true;
410+
const defaultLabel = "Pick audio output";
411+
buttonEl.disabled = true;
412+
buttonEl.textContent = "Requesting...";
413+
414+
try {
415+
let selectedDeviceId = null;
416+
if (navigator.mediaDevices.selectAudioOutput) {
417+
try {
418+
const selection = await navigator.mediaDevices.selectAudioOutput();
419+
selectedDeviceId = selection && selection.deviceId;
420+
} catch (err) {
421+
console.warn("selectAudioOutput failed or was dismissed", err);
422+
}
423+
}
424+
425+
const outputs = await listAudioOutputsWithPermission();
426+
427+
if (selectedDeviceId) {
428+
await applyAudioOutput(selectedDeviceId, outputs);
429+
buttonEl.textContent = selectedDeviceId === "default" ? "Using system default" : "Audio output set";
430+
renderAudioOutputPanel(outputs, selectedDeviceId);
431+
return;
432+
}
433+
434+
if (!outputs.length) {
435+
throw new Error("No audio outputs exposed by the browser.");
436+
}
437+
438+
const panel = renderAudioOutputPanel(outputs);
439+
// Immediately apply the current selection (default) to refresh status.
440+
await applyAudioOutput(panel.selectEl.value, outputs);
441+
panel.statusEl.textContent = panel.selectEl.value === "default" ? "Using system default" : `Using ${panel.selectEl.options[panel.selectEl.selectedIndex].textContent}`;
442+
buttonEl.textContent = "Picker ready";
443+
} finally {
444+
isPickingAudioOutput = false;
445+
buttonEl.disabled = false;
446+
setTimeout(() => {
447+
if (buttonEl && buttonEl.isConnected) {
448+
buttonEl.textContent = defaultLabel;
449+
}
450+
}, 2000);
451+
}
452+
}
453+
454+
function ensureAudioOutputButton() {
455+
if (document.getElementById(AUDIO_OUTPUT_BUTTON_ID)) {
456+
syncAudioPickerVisibility();
457+
return;
458+
}
459+
460+
const button = document.createElement("button");
461+
button.id = AUDIO_OUTPUT_BUTTON_ID;
462+
button.type = "button";
463+
button.textContent = "Pick audio output";
464+
button.title = "Select where this tab's audio should play";
465+
button.style = "position: fixed; right: 18px; bottom: 18px; z-index: 2147483647; background: #0f0f0f; color: #fff; border: 1px solid #3ea6ff; border-radius: 10px; padding: 10px 12px; box-shadow: 0 6px 14px rgba(0,0,0,0.35); cursor: pointer; font-size: 13px; font-family: inherit;";
466+
button.addEventListener("click", function (event) {
467+
event.preventDefault();
468+
pickAudioOutputForCurrentVideo(button).catch((err) => {
469+
console.warn("Audio output selection failed", err);
470+
button.textContent = err && err.message ? err.message : "Selection failed";
471+
setTimeout(() => {
472+
if (button && button.isConnected) {
473+
button.textContent = "Pick audio output";
474+
}
475+
}, 2500);
476+
});
477+
});
478+
479+
(document.body || document.documentElement).appendChild(button);
480+
syncAudioPickerVisibility();
481+
482+
if (!hasVisibilityListeners) {
483+
hasVisibilityListeners = true;
484+
document.addEventListener("fullscreenchange", syncAudioPickerVisibility, false);
485+
window.addEventListener("resize", syncAudioPickerVisibility, false);
486+
}
487+
}
488+
234489
function preStartup(){
235490

236-
if (!isExtensionOn){return;}
491+
applyAudioPickerSetting();
492+
493+
if (!isExtensionOn){
494+
return;
495+
}
237496

238497
if (!window.location.href.startsWith("https://www.youtube.com/watch")){
239498
return;
@@ -333,12 +592,3 @@
333592

334593

335594

336-
337-
338-
339-
340-
341-
342-
343-
344-

0 commit comments

Comments
 (0)