Skip to content

Commit e715a8f

Browse files
add cookbook.offscreen-user-media sample (#1717)
records microphone audio in an offscreen document. getUserMedia there cannot show a permission prompt, so a regular extension page opened in a tab grants mic access to the extension origin first. the popup drives start/stop and offers the finished recording for playback and download.
1 parent 72e2336 commit e715a8f

9 files changed

Lines changed: 359 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
This recipe shows how to record microphone audio with `getUserMedia()` in
2+
an [offscreen document][1].
3+
4+
`getUserMedia()` inside an offscreen document cannot show a permission
5+
prompt. The permission has to be granted to the extension's origin from a
6+
regular extension page first; after that, the offscreen document can record
7+
without any visible surface. This sample prompts from a page opened in a
8+
tab, records in the offscreen document, and offers the finished recording
9+
in the popup for playback and download.
10+
11+
## Running this extension
12+
13+
1. Clone this repository.
14+
2. Load this directory in Chrome as an [unpacked extension][2].
15+
3. Click the extension's action icon and press "Start recording". The first
16+
use opens a tab asking for microphone access; the tab closes itself once
17+
access is granted. Press "Start recording" again to begin recording.
18+
4. Press "Stop recording" to play back or download the recording.
19+
20+
[1]: https://developer.chrome.com/docs/extensions/reference/api/offscreen
21+
[2]: https://developer.chrome.com/docs/extensions/mv3/getstarted/development-basics/#load-unpacked
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
const OFFSCREEN_URL = 'offscreen/offscreen.html';
16+
17+
let creating;
18+
19+
async function ensureOffscreenDocument() {
20+
const contexts = await chrome.runtime.getContexts({
21+
contextTypes: ['OFFSCREEN_DOCUMENT']
22+
});
23+
if (contexts.length > 0) {
24+
return;
25+
}
26+
if (!creating) {
27+
creating = chrome.offscreen
28+
.createDocument({
29+
url: OFFSCREEN_URL,
30+
reasons: ['USER_MEDIA'],
31+
justification: 'Recording microphone audio'
32+
})
33+
.finally(() => {
34+
creating = undefined;
35+
});
36+
}
37+
await creating;
38+
}
39+
40+
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
41+
if (message.target !== 'background') {
42+
return;
43+
}
44+
handleMessage(message).then(sendResponse, (error) =>
45+
sendResponse({ state: 'idle', error: error.message })
46+
);
47+
return true;
48+
});
49+
50+
async function handleMessage(message) {
51+
switch (message.type) {
52+
case 'start-recording': {
53+
await ensureOffscreenDocument();
54+
// getUserMedia in an offscreen document cannot show a permission
55+
// prompt, so permission has to be granted to the extension origin
56+
// from a regular extension page first.
57+
const { state } = await chrome.runtime.sendMessage({
58+
target: 'offscreen',
59+
type: 'get-permission-state'
60+
});
61+
if (state === 'denied') {
62+
return { state: 'permission-denied' };
63+
}
64+
if (state !== 'granted') {
65+
await chrome.tabs.create({
66+
url: chrome.runtime.getURL('permission.html')
67+
});
68+
return { state: 'permission-needed' };
69+
}
70+
return chrome.runtime.sendMessage({
71+
target: 'offscreen',
72+
type: 'start'
73+
});
74+
}
75+
case 'stop-recording':
76+
return chrome.runtime.sendMessage({
77+
target: 'offscreen',
78+
type: 'stop'
79+
});
80+
case 'get-state': {
81+
const contexts = await chrome.runtime.getContexts({
82+
contextTypes: ['OFFSCREEN_DOCUMENT']
83+
});
84+
if (contexts.length === 0) {
85+
return { state: 'idle' };
86+
}
87+
return chrome.runtime.sendMessage({
88+
target: 'offscreen',
89+
type: 'get-state'
90+
});
91+
}
92+
default:
93+
console.warn(`Unexpected message type received: '${message.type}'.`);
94+
}
95+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "Offscreen API - Record User Media",
3+
"version": "1.0",
4+
"manifest_version": 3,
5+
"minimum_chrome_version": "116",
6+
"description": "Records microphone audio in an offscreen document.",
7+
"background": {
8+
"service_worker": "background.js"
9+
},
10+
"action": {
11+
"default_popup": "popup.html"
12+
},
13+
"permissions": ["offscreen"]
14+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<script src="offscreen.js"></script>
5+
</head>
6+
<body></body>
7+
</html>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
let recorder;
16+
let chunks = [];
17+
let lastRecordingUrl;
18+
19+
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
20+
if (message.target !== 'offscreen') {
21+
return;
22+
}
23+
handleMessage(message).then(sendResponse, (error) =>
24+
sendResponse({ state: 'idle', error: error.message })
25+
);
26+
return true;
27+
});
28+
29+
async function handleMessage(message) {
30+
switch (message.type) {
31+
case 'get-permission-state': {
32+
const { state } = await navigator.permissions.query({
33+
name: 'microphone'
34+
});
35+
return { state };
36+
}
37+
case 'get-state':
38+
return {
39+
state: recorder?.state === 'recording' ? 'recording' : 'idle',
40+
recordingUrl: lastRecordingUrl
41+
};
42+
case 'start':
43+
return startRecording();
44+
case 'stop':
45+
recorder?.stop();
46+
return { state: 'idle' };
47+
default:
48+
console.warn(`Unexpected message type received: '${message.type}'.`);
49+
}
50+
}
51+
52+
async function startRecording() {
53+
if (recorder?.state === 'recording') {
54+
return { state: 'recording' };
55+
}
56+
let stream;
57+
try {
58+
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
59+
} catch (error) {
60+
return { state: 'idle', error: error.message };
61+
}
62+
chunks = [];
63+
recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
64+
recorder.ondataavailable = (event) => chunks.push(event.data);
65+
recorder.onstop = () => {
66+
stream.getTracks().forEach((track) => track.stop());
67+
if (lastRecordingUrl) {
68+
URL.revokeObjectURL(lastRecordingUrl);
69+
}
70+
// Object URLs stay valid for other extension pages as long as this
71+
// offscreen document exists.
72+
lastRecordingUrl = URL.createObjectURL(
73+
new Blob(chunks, { type: 'audio/webm' })
74+
);
75+
chrome.runtime.sendMessage({
76+
target: 'popup',
77+
type: 'recording-ready',
78+
url: lastRecordingUrl
79+
});
80+
};
81+
recorder.start();
82+
return { state: 'recording' };
83+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>Microphone access</title>
6+
</head>
7+
<body>
8+
<p>
9+
Chrome will ask for microphone access. Granting it lets the extension
10+
record from its popup; this tab closes itself afterwards.
11+
</p>
12+
<p id="status"></p>
13+
<script src="permission.js"></script>
14+
</body>
15+
</html>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
navigator.mediaDevices
16+
.getUserMedia({ audio: true })
17+
.then((stream) => {
18+
// The stream is unused; this page exists to trigger the permission
19+
// prompt.
20+
stream.getTracks().forEach((track) => track.stop());
21+
window.close();
22+
})
23+
.catch((error) => {
24+
document.getElementById('status').textContent =
25+
'Microphone access was not granted: ' + error.message;
26+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<style>
6+
body {
7+
width: 220px;
8+
font-family: system-ui;
9+
}
10+
audio {
11+
width: 100%;
12+
}
13+
</style>
14+
</head>
15+
<body>
16+
<button id="start">Start recording</button>
17+
<button id="stop" disabled>Stop recording</button>
18+
<p id="status">Loading...</p>
19+
<div id="result" hidden>
20+
<audio id="playback" controls></audio>
21+
<a id="download" download="recording.webm">Download recording</a>
22+
</div>
23+
<script src="popup.js"></script>
24+
</body>
25+
</html>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
const STATUS_TEXT = {
16+
idle: 'Ready to record.',
17+
recording: 'Recording...',
18+
'permission-needed': 'Grant microphone access in the opened tab.',
19+
'permission-denied':
20+
'Microphone access is blocked for this extension. Reset it under ' +
21+
'chrome://settings/content/microphone, then try again.'
22+
};
23+
24+
const startButton = document.getElementById('start');
25+
const stopButton = document.getElementById('stop');
26+
const status = document.getElementById('status');
27+
28+
function showState({ state, error, recordingUrl }) {
29+
startButton.disabled = state === 'recording';
30+
stopButton.disabled = state !== 'recording';
31+
status.textContent = error ?? STATUS_TEXT[state] ?? 'Something went wrong.';
32+
if (recordingUrl) {
33+
showRecording(recordingUrl);
34+
}
35+
}
36+
37+
function showRecording(url) {
38+
document.getElementById('result').hidden = false;
39+
document.getElementById('playback').src = url;
40+
document.getElementById('download').href = url;
41+
}
42+
43+
startButton.onclick = async () => {
44+
startButton.disabled = true;
45+
showState(
46+
await chrome.runtime.sendMessage({
47+
target: 'background',
48+
type: 'start-recording'
49+
})
50+
);
51+
};
52+
53+
stopButton.onclick = async () => {
54+
showState(
55+
await chrome.runtime.sendMessage({
56+
target: 'background',
57+
type: 'stop-recording'
58+
})
59+
);
60+
};
61+
62+
chrome.runtime.onMessage.addListener((message) => {
63+
if (message.target !== 'popup') {
64+
return;
65+
}
66+
if (message.type === 'recording-ready') {
67+
showState({ state: 'idle', recordingUrl: message.url });
68+
}
69+
});
70+
71+
chrome.runtime
72+
.sendMessage({ target: 'background', type: 'get-state' })
73+
.then(showState);

0 commit comments

Comments
 (0)