Screen Recorder in Oracle APEX Multi Page
A floating popup window recorder that works across every page of your APEX app. Open it from anywhere, record anything, download instantly. No plugins, no uploads, no server calls.
After I published the Single Page version, I got a message from a developer on our team: "This is great, but what if I need to record a bug that happens across three different APEX pages? I have to go back to page one just to start the recorder." That one message stuck with me.
My first thought was to put the recorder button in the APEX navigation bar so it would appear on every page. I tried that. It worked visually, but the JavaScript reset every time the page changed because APEX was doing a full page load between navigations. The recording was just gone.
Then I thought about using APEX Global Page (Page 0). I put the region there, it showed up on every page. But the same problem remained. Page navigation kills the JavaScript state. Any recording in progress would be lost the moment you clicked a menu item.
Finally this approach worked: a detached popup window. The popup has its own JavaScript context that lives independently of the main APEX window. You can navigate every page of your app, submit forms, even refresh the main window and the popup just keeps recording. That was the breakthrough.
Architecture Overview
Before writing any code, it helps to understand how the two pieces connect.
The main APEX page holds a single button that fires openRecorderPopup().
That function creates a detached browser window with a complete self-contained recorder UI inside it.
openRecorderPopup() from the global JS declaration.document.write(). Contains its own HTML, CSS, and JavaScript. Completely independent of the APEX page lifecycle.recPopup.focus() instead of opening a second window.
This prevents duplicate recorder windows and keeps the experience clean.
Create a Static Content Region
On any APEX page where you want the recorder button to appear (or on Page 0 to show it everywhere), add a new Static Content region. This region holds only the button and a small status label.
I tried adding the button directly to a navigation region thinking it would look cleaner. The button rendered fine but APEX's navigation bar has strict CSS scoping and my custom gradient styling got completely overridden by the Universal Theme. I switched to a dedicated Static Content region and the styling worked exactly as expected.
Region HTML Code
Paste this into the region's Source > HTML Code attribute.
It is just a styled container with one button that calls openRecorderPopup().
<div id="apexScreenRecorder"> <style> #apexScreenRecorder { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: #f9fafb; border: 1px solid #e0e0e0; border-radius: 10px; box-shadow: 0 2px 6px rgba(0,0,0,0.05); width: fit-content; } #apexScreenRecorder .t-Button { font-size: 14px; font-weight: 600; border-radius: 8px; padding: 8px 16px; transition: all 0.25s ease-in-out; letter-spacing: 0.3px; } #apexScreenRecorder .t-Button--primary { background: linear-gradient(135deg, #4CAF50, #81C784); border: none; color: #fff; } #apexScreenRecorder .t-Button--primary:hover { background: linear-gradient(135deg, #43A047, #66BB6A); transform: translateY(-1px); box-shadow: 0 4px 10px rgba(76,175,80,0.3); } #apexScreenRecorder .t-Button--primary:active { transform: scale(0.97); box-shadow: 0 2px 4px rgba(0,0,0,0.15); } #apexScreenRecorder .t-Button:focus { outline: none; box-shadow: 0 0 0 3px rgba(76,175,80,0.3); } #apexScreenRecorder #recStatus { font-size: 13px; color: #666; font-style: italic; white-space: nowrap; } #apexScreenRecorder:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); transition: box-shadow 0.3s ease; } </style> <button type="button" class="t-Button t-Button--primary" onclick="openRecorderPopup()"> Open Screen Recorder </button> <span id="recStatus" style="margin-left:12px;font-size:13px;color:gray;"> Recorder runs in a separate window </span> </div>
Global JavaScript: Function and Global Variable Declaration
This is where all the real logic lives. Paste the entire block into
Page > Function and Global Variable Declaration.
This declares recPopup as a global and defines
openRecorderPopup() which the button in Step 3 calls.
When I first tried document.write() inside the popup, the entire
screen recorder JavaScript inside the popup string was ending early because of a
</script> tag inside the template literal. The browser was treating
it as the closing tag for the outer script block and the popup just showed a broken page.
I spent way too long staring at this. The fix was to escape the closing script tag as
<\/script> using a backslash inside the string. That tells the
JavaScript string parser to treat it as text, not as a real closing tag.
Once I did that, the popup rendered perfectly every time.
document.write() string as
<\/script> and the popup will render without any parsing issues.
var recPopup = null; function openRecorderPopup() { if (recPopup && !recPopup.closed) { recPopup.focus(); return; } recPopup = window.open('', 'ScreenRecorder', 'width=420,height=340,top=100,left=100,resizable=yes,scrollbars=no' ); recPopup.document.open(); recPopup.document.write(` <!DOCTYPE html> <html> <head> <title>Screen Recorder</title> <style> body { font-family: "Segoe UI", Arial, sans-serif; padding: 16px; background: linear-gradient(135deg, #f0f4f8, #f9fafb); margin: 0; } .rec-card { background: #fff; padding: 16px; border-radius: 12px; box-shadow: 0 4px 14px rgba(0,0,0,0.08); border: 1px solid #e5e7eb; } h3 { margin: 0 0 14px; font-size: 16px; color: #222; display: flex; align-items: center; gap: 6px; } button { padding: 8px 16px; margin-right: 8px; border-radius: 8px; border: none; cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.25s ease; letter-spacing: 0.3px; } #startBtn { background: linear-gradient(135deg, #0076DF, #4da3ff); color: #fff; } #stopBtn { background: linear-gradient(135deg, #D94F00, #ff8a50); color: #fff; } #downloadBtn { background: linear-gradient(135deg, #2E7D32, #66bb6a); color: #fff; margin-top: 12px; display: none; } button:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 10px rgba(0,0,0,0.15); } button:active:not(:disabled) { transform: scale(0.96); } button:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; } #recStatus { display: block; margin-top: 12px; font-size: 13px; color: #555; } #timer { font-size: 13px; color: #D94F00; font-weight: bold; display: none; margin-top: 6px; animation: blink 1s infinite; } @keyframes blink { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } } video { width: 100%; margin-top: 14px; border-radius: 10px; display: none; background: #000; border: 1px solid #ddd; box-shadow: 0 2px 8px rgba(0,0,0,0.2); } .btn-row { margin-bottom: 6px; } </style> </head> <body> <div class="rec-card"> <h3>Screen Recorder</h3> <div class="btn-row"> <button type="button" id="startBtn" onclick="startRec()">Start</button> <button type="button" id="stopBtn" onclick="stopRec()" disabled>Stop</button> </div> <span id="recStatus">Ready</span> <span id="timer">00:00</span> <br> <button type="button" id="downloadBtn" onclick="downloadRec()">Download</button> <video id="previewVideo" controls></video> </div> <script> var mediaRecorder, recChunks = [], recStream, recBlobUrl; var timerInterval, seconds = 0; function startRec() { navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }) .then(function(stream) { recStream = stream; recChunks = []; seconds = 0; mediaRecorder = new MediaRecorder(stream); mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) recChunks.push(e.data); }; mediaRecorder.onstop = function() { var blob = new Blob(recChunks, { type: 'video/webm' }); recBlobUrl = URL.createObjectURL(blob); var vid = document.getElementById('previewVideo'); vid.src = recBlobUrl; vid.style.display = 'block'; document.getElementById('downloadBtn').style.display = 'inline-block'; document.getElementById('recStatus').textContent = 'Recording saved'; document.getElementById('timer').style.display = 'none'; clearInterval(timerInterval); }; mediaRecorder.start(100); timerInterval = setInterval(function() { seconds++; var m = String(Math.floor(seconds / 60)).padStart(2, '0'); var s = String(seconds % 60).padStart(2, '0'); document.getElementById('timer').textContent = m + ':' + s; }, 1000); document.getElementById('startBtn').disabled = true; document.getElementById('stopBtn').disabled = false; document.getElementById('recStatus').textContent = 'Recording in progress...'; document.getElementById('timer').style.display = 'block'; document.getElementById('downloadBtn').style.display = 'none'; document.getElementById('previewVideo').style.display = 'none'; stream.getVideoTracks()[0].onended = stopRec; }) .catch(function() { document.getElementById('recStatus').textContent = 'Permission denied or cancelled'; }); } function stopRec() { if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop(); if (recStream) recStream.getTracks().forEach(function(t) { t.stop(); }); document.getElementById('startBtn').disabled = false; document.getElementById('stopBtn').disabled = true; } function downloadRec() { if (!recBlobUrl) return; var a = document.createElement('a'); a.href = recBlobUrl; a.download = 'recording-' + Date.now() + '.webm'; a.click(); } <\/script> </body> </html> `); recPopup.document.close(); }
var recPopup = null is declared at the very top.
Because this code lives in Function and Global Variable Declaration,
it is in the true global scope. The onclick on the button always
finds openRecorderPopup() even after partial page refreshes.
openRecorderPopup() runs and checks if a popup is already open. If it is, it simply focuses the existing window. This prevents duplicate recorder windows.'ScreenRecorder' is the window name. Using a fixed name means repeated calls reuse the same window slot rather than opening new ones.MediaRecorder collects data in 100ms chunks. A setInterval timer updates the display every second.mediaRecorder.stop(). The onstop handler assembles all chunks into a single video/webm Blob, shows an inline preview, and reveals the Download button..webm file to the Downloads folder. No server involved.
This two-part series covered both the inline single-page recorder and the floating
popup multi-page version. Both are 100% client-side with no APEX
collections, no REST calls, and no server uploads required.
Future ideas include: saving recordings directly to an APEX Collection, uploading the
blob to an Object Storage REST endpoint, and annotating recordings with timestamps before
download. If those would be useful for your project, let me know in the comments.
Follow the blog series to get notified when new posts go up!
Source > HTML CodePage > Function and Global Variable Declaration
Comments
Post a Comment