Skip to main content

Screen Recorder in Oracle APEX Multi Page

Back to Part 1: Single Page Screen Recorder
Oracle APEX Screen Recorder Series Part 2 of 2 Read Part 1

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.

🚩 Popup Window UI
Live Timer
🌟 Works Across Pages
📺 Instant Preview
⬇️ One-click Download
🚫 No Plugins
Part 1 - Single Page
Inline Region Recorder
Lives inside one APEX page. Simple, zero setup.
VS
Part 2 - Multi Page (This Post)
Floating Popup Recorder
Opens from any page. Stays open while you navigate.
The Real Story Behind This

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.

1

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.

A
📄
APEX Page (any page)
Hosts a single Static Content region with a styled button. Clicking it calls openRecorderPopup() from the global JS declaration.
B
🚩
Popup Window
A fresh browser window written with document.write(). Contains its own HTML, CSS, and JavaScript. Completely independent of the APEX page lifecycle.
C
🎧
MediaRecorder API (inside the popup)
All recording logic runs inside the popup window. It survives page navigations in the parent APEX window because the two windows have completely separate JavaScript runtimes.
D
💾
Download
When recording stops, the popup assembles a WebM blob, shows a preview, and lets the user download it. Nothing is sent to the server.
💡 If the popup is already open and the user clicks the button again, the code calls recPopup.focus() instead of opening a second window. This prevents duplicate recorder windows and keeps the experience clean.
2

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.

Page Designer Regions Create Region Type: Static Content
Region TypeStatic Content
TitleScreen Recorder (or leave blank)
TemplateBlank with Attributes
Source / HTML CodePaste the Region HTML from Step 3
✍️ My First Attempt

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.

3

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().

HTML Region Source
<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>
4

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.

Page Designer Page Function and Global Variable Declaration
🐛 The Bug That Took Me the Longest

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.

Escape the closing script tag inside the document.write() string as <\/script> and the popup will render without any parsing issues.
JavaScript Function and Global Declaration
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();
}
👉 The variable 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.
How It Works End to End
User clicks Open Screen Recorder on any APEX page. openRecorderPopup() runs and checks if a popup is already open. If it is, it simply focuses the existing window. This prevents duplicate recorder windows.
window.open() creates a new browser window at 420x340 pixels. The second argument 'ScreenRecorder' is the window name. Using a fixed name means repeated calls reuse the same window slot rather than opening new ones.
document.write() injects a complete HTML document into the popup. This includes its own style block, button layout, status label, timer, and the full recording JavaScript. Everything the popup needs is self-contained inside this string.
Inside the popup, getDisplayMedia() prompts the user to choose a screen, window, or tab to capture. The MediaRecorder collects data in 100ms chunks. A setInterval timer updates the display every second.
The user can now freely navigate across all APEX pages. The popup window has its own independent JavaScript context. Navigation in the parent APEX window does not affect the popup's recording state at all.
Clicking Stop triggers mediaRecorder.stop(). The onstop handler assembles all chunks into a single video/webm Blob, shows an inline preview, and reveals the Download button.
Download creates a temporary anchor element, sets the blob URL and a timestamped filename, and programmatically clicks it. The browser saves the .webm file to the Downloads folder. No server involved.
Screen Recorder  (420 x 340)
🎥 Screen Recorder
Start
Stop
Ready
Download
Popup preview at exact rendered size
📼
📅 What Comes Next

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!

🎥
Quick Setup Checklist
Create a Static Content region on the APEX page (or on Page 0 for all pages)
Paste the Region HTML from Step 3 into Source > HTML Code
Paste the full JavaScript from Step 4 into Page > Function and Global Variable Declaration
Run the page and click Open Screen Recorder. A popup window should open immediately.
Click Start in the popup, choose a screen or window, and allow browser permission
Navigate freely across your APEX pages while the popup records in the background
Click Stop, review the preview in the popup, then click Download to save

Comments

Popular posts from this blog

Screen Recorder in Oracle APEX (Single Page)

Oracle APEX Tutorial Screen Recorder in Oracle APEX : Single Page Build a fully functional browser-based screen recorder inside Oracle APEX using just one Static Content region and native JavaScript. No plugins, no external libraries, no server uploads required. ✍️ Why I Built This I was working on a client project where the support team needed to record screen issues and share them directly from the APEX application, without switching to any external tool. Installing third-party software was not an option on their machines, and every screen recorder extension required IT approval. That is when I thought: the browser already has everything we need. Why not build it right inside APEX? That idea turned into this. &#127916; Start / Stop Recording &#128065; Instant Preview ⬇️ One-click Download &#128266; Audio + Video ...

Freeze panes in Interactive Grid : Driven by a page item

Oracle APEX Tutorial Freeze Panes in Interactive Grid : Driven by a Page Item Lock N columns in place with a select list : no plugins, no hacks. Pure JavaScript, a Dynamic Action, and clean CSS that survives sort, search, and pagination. Live Demo GitHub Code Create page item Add page JavaScript Dynamic Action Optional CSS 1 Create the P1_FREEZE_COLS select list In Page Designer, add a Select List page item. Configure it as shown below. Also set the Interactive Grid region's Static ID to emp_grid via Properties → Advanced → Static ID . Name P1_FREEZE_COLS List of Values Static Values : Display/Return 0 through 5 Default Value 0 Template Optional / Floating Label Region Static ID emp_grid 2 Page JavaScript : Execute when Page Loads Paste t...

Excel-Style Keyboard Shortcuts in Interactive Grid

Oracle APEX Tutorial Excel-Style Keyboard Shortcuts in Interactive Grid Add Alt+A , Alt+D , Alt+S and Alt+R to your editable IG : no plugins, just four clean setup steps using APEX's built-in actions registry. Alt + A   Add Row Alt + D   Delete Row Alt + S   Save Alt + R   Refresh Alt + Shift + F1   View All Shortcuts Live Demo GitHub Code Editable IG Static ID Cursor Focus JS Init Code Testing 1 Prerequisites : Make your IG Editable ⚠️ Keyboard shortcuts only work on an Editable Interactive Grid . The row-add-row , row-delete , and save actions only exist in the IG's action registry when the grid is in editable mode. If you skip this, actions.lookup() will return null and throw an error. In your Interactive Grid region's attributes, set Edit → Enabled to...