Skip to main content

Record, Preview, Save — Video Recording in Oracle APEX

Oracle APEX · Video Recording

Record Video Directly in Oracle APEX ๐ŸŽฌ

Have you ever wished your users could say it instead of type it? In many modern applications, we've seen the ability to record a short video clip directly in the browser — no file uploads, no external tools. Just hit record, watch it back, and decide — save it or try again. Today, we're bringing that same experience into Oracle APEX: record, pause/resume, preview, save to DB, or retry. Let's build it.

1

Step 1 : Start by creating a Static Content Region on your APEX page. This will act as the container for our entire video recorder UI.Paste the provided HTML code into the region source. This gives you two video panels — one for the live camera preview and one for the recorded playback — along with your action buttons: Start, Pause, Resume, Stop, Save, and Retry.

<!-- Recorder UI -->
<div id="recorderUI" class="t-Form--stretchInputs">

  <!-- Live camera preview -->
  <div id="previewCard" class="video-card">
    <div class="card">
      <label class="t-Form-label">Preview</label>
      <div class="video-player">
        <video id="preview" playsinline autoplay muted></video>
      </div>
    </div>
  </div>

  <!-- Playback after recording -->
  <div id="recordedCard" class="video-card" style="display:none;">
    <div class="card">
      <label class="t-Form-label">Recorded clip</label>
      <div class="video-player">
        <video id="playback" controls></video>
      </div>
    </div>
  </div>

  <!-- Recording controls -->
  <div id="recordButtons" class="t-ButtonRegion t-ButtonRegion--noBorder">
    <button id="btnStart"  type="button" class="t-Button t-Button--hot">Start</button>
    <button id="btnPause"  type="button" class="t-Button t-Button--warning" disabled style="display:none;">Pause</button>
    <button id="btnResume" type="button" class="t-Button t-Button--warning" style="display:none;">Resume</button>
    <button id="btnStop"   type="button" class="t-Button" disabled>Stop</button>
    <span   id="timer"></span>
  </div>

  <!-- Post-recording actions -->
  <div id="postButtons" class="t-ButtonRegion t-ButtonRegion--noBorder" style="display:none;">
    <button id="btnSave"  type="button" class="t-Button t-Button--primary">Save</button>
    <button id="btnRetry" type="button" class="t-Button t-Button--danger">Retry</button>
  </div>

  <div id="status" class="u-success-text" style="margin-top:8px;"></div>

</div>
2

Step 2 : Add below css code in page level inline cssThis keeps everything looking clean and consistent with the APEX theme — rounded video panels, styled buttons, and a neat timer badge.

/* Video player container */
#recorderUI .video-player {
  width: 80%;
  aspect-ratio: 16 / 9;
  height: 400px;
  background: #000000;
  border-radius: 10px;
  overflow: hidden;
}

#recorderUI video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Button row */
#recorderUI .t-ButtonRegion {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 14px;
  flex-wrap: wrap;
}

/* Labels */
#recorderUI .t-Form-label {
  font-size: 11px;
  font-weight: 600;
  color: #777;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin-bottom: 8px;
  display: block;
}

/* Button overrides */
#recorderUI .t-Button--hot     { background: #185FA5; border-color: #185FA5; color: #fff; }
#recorderUI .t-Button--primary { background: #0F6E56; border-color: #0F6E56; color: #fff; }
#recorderUI .t-Button--danger  { background: #fff;    border-color: #E24B4A; color: #A32D2D; }

/* Timer pill */
#timer {
  background: #f5f5f0;
  border: 0.5px solid #ddd;
  border-radius: 20px;
  padding: 4px 12px;
  font-size: 13px;
  font-weight: 500;
  font-family: monospace;
}
3

Step 3 : In Execute When Page Loads, paste the JavaScript block. Here's what it does behind the scenes:

(function () {

  // ── Element references ──────────────────────────────────────────
  const preview       = document.getElementById('preview');
  const playback      = document.getElementById('playback');
  const previewCard   = document.getElementById('previewCard');
  const recordedCard  = document.getElementById('recordedCard');
  const btnStart      = document.getElementById('btnStart');
  const btnStop       = document.getElementById('btnStop');
  const btnPause      = document.getElementById('btnPause');
  const btnResume     = document.getElementById('btnResume');
  const btnSave       = document.getElementById('btnSave');
  const btnRetry      = document.getElementById('btnRetry');
  const recordButtons = document.getElementById('recordButtons');
  const postButtons   = document.getElementById('postButtons');
  const statusEl      = document.getElementById('status');
  const timerEl       = document.getElementById('timer');

  // ── Recording state ─────────────────────────────────────────────
  let mediaStream   = null;
  let mediaRecorder = null;
  let chunks        = [];
  let startTime     = null;
  let pausedAt      = null;
  let totalPausedMs = 0;
  let timerInterval = null;
  let segmentCount  = 0;

  let lastRecording = {
    blob: null, url: null, mime: null,
    durationMs: 0, filename: null, segments: 0
  };

  // ── Helpers ──────────────────────────────────────────────────────
  function formatMs(ms) {
    const s  = Math.floor(ms / 1000);
    const m  = Math.floor(s / 60);
    const ss = s % 60;
    return `${String(m).padStart(2,'0')}:${String(ss).padStart(2,'0')}`;
  }

  async function ensureCamera() {
    if (mediaStream) return;
    try {
      mediaStream = await navigator.mediaDevices.getUserMedia({
        audio: true,
        video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } }
      });
      preview.srcObject = mediaStream;
    } catch (err) {
      statusEl.className   = 'u-warning-text';
      statusEl.textContent = `Camera/mic access failed: ${err.message}`;
      throw err;
    }
  }

  // ── View state helpers ───────────────────────────────────────────
  function setViewRecording() {
    previewCard.style.display   = '';
    recordButtons.style.display = '';
    recordedCard.style.display  = 'none';
    postButtons.style.display   = 'none';
  }

  function setViewPost() {
    previewCard.style.display   = 'none';
    recordButtons.style.display = 'none';
    recordedCard.style.display  = '';
    postButtons.style.display   = '';
  }

  // ── Pause ────────────────────────────────────────────────────────
  function pauseRecording() {
    if (!mediaRecorder || mediaRecorder.state !== 'recording') return;
    mediaRecorder.pause();
    pausedAt = Date.now();
    clearInterval(timerInterval);
    btnPause.style.display  = 'none';
    btnResume.style.display = '';
    btnStop.disabled        = false;
    statusEl.className   = '';
    statusEl.textContent = `Paused at ${timerEl.textContent}. Click Resume to continue.`;
  }

  // ── Resume ───────────────────────────────────────────────────────
  function resumeRecording() {
    if (!mediaRecorder || mediaRecorder.state !== 'paused') return;
    totalPausedMs += Date.now() - pausedAt;
    pausedAt = null;
    mediaRecorder.resume();
    segmentCount++;
    timerInterval = setInterval(() => {
      timerEl.textContent = formatMs(Date.now() - startTime - totalPausedMs);
    }, 200);
    btnResume.style.display = 'none';
    btnPause.style.display  = '';
    btnStop.disabled        = false;
    statusEl.className   = '';
    statusEl.textContent = `Recording resumed (segment ${segmentCount + 1})…`;
  }

  // ── Start ────────────────────────────────────────────────────────
  function startRecording() {
    chunks        = [];
    segmentCount  = 1;
    totalPausedMs = 0;
    pausedAt      = null;

    const options = { mimeType: 'video/webm;codecs=vp8,opus' };
    try       { mediaRecorder = new MediaRecorder(mediaStream, options); }
    catch (e) { mediaRecorder = new MediaRecorder(mediaStream); }

    mediaRecorder.ondataavailable = (e) => {
      if (e.data && e.data.size > 0) chunks.push(e.data);
    };

    mediaRecorder.onstop = async () => {
      const blob       = new Blob(chunks, { type: mediaRecorder.mimeType || 'video/webm' });
      const url        = URL.createObjectURL(blob);
      const durationMs = Date.now() - startTime - totalPausedMs;

      lastRecording = {
        blob, url,
        mime:       blob.type || 'video/webm',
        durationMs,
        filename:   `recording_${new Date().toISOString().replace(/[:.]/g,'-')}.webm`,
        segments:   segmentCount
      };

      clearInterval(timerInterval);
      timerEl.textContent = formatMs(durationMs);
      playback.src = url;
      playback.load();
      playback.play().catch(() => {});

      btnPause.style.display  = '';
      btnResume.style.display = 'none';
      statusEl.className   = '';
      statusEl.textContent = 'Recording ready. You can Save or Retry.';
      setViewPost();

      setTimeout(() => {
        if (lastRecording.url) URL.revokeObjectURL(lastRecording.url);
      }, 60000);
    };

    mediaRecorder.start(500);
    startTime           = Date.now();
    timerEl.textContent = '00:00';
    timerInterval = setInterval(() => {
      timerEl.textContent = formatMs(Date.now() - startTime - totalPausedMs);
    }, 200);

    btnStart.disabled       = true;
    btnStop.disabled        = false;
    btnPause.disabled       = false;
    btnPause.style.display  = '';
    btnResume.style.display = 'none';
    statusEl.className      = '';
    statusEl.textContent    = 'Recording…';
    setViewRecording();
  }

  // ── Stop ─────────────────────────────────────────────────────────
  function stopRecording() {
    if (mediaRecorder && mediaRecorder.state !== 'inactive') {
      if (mediaRecorder.state === 'paused' && pausedAt) {
        totalPausedMs += Date.now() - pausedAt;
        pausedAt = null;
      }
      clearInterval(timerInterval);
      mediaRecorder.stop();
      btnStop.disabled  = true;
      btnStart.disabled = false;
      btnPause.disabled = true;
      statusEl.textContent = 'Stopping…';
    }
  }

  // ── Filename dialog ──────────────────────────────────────────────
  function saveRecording() {
    if (!lastRecording.blob) {
      apex.message.alert('No recording to save. Please record and stop first.');
      return;
    }
    openFilenameDialog();
  }

  function openFilenameDialog() {
    const existing = document.getElementById('videoFilenameDialog');
    if (existing) existing.remove();

    const overlay = document.createElement('div');
    overlay.id = 'videoFilenameDialog';
    overlay.style.cssText = `
      position:fixed;inset:0;background:rgba(0,0,0,0.4);
      display:flex;align-items:center;justify-content:center;z-index:9999;
    `;
    overlay.innerHTML = `
      <div style="background:#fff;border-radius:12px;padding:28px;width:420px;
                  max-width:92vw;border:0.5px solid #ddd;font-family:Arial,sans-serif;">
        <div style="width:48px;height:48px;background:#E1F5EE;border-radius:12px;
                    display:flex;align-items:center;justify-content:center;margin-bottom:16px;">
          <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
            <path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"
                  stroke="#0F6E56" stroke-width="1.6" stroke-linejoin="round"/>
            <path d="M17 21v-8H7v8M7 3v5h8"
                  stroke="#0F6E56" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
        </div>
        <p style="font-size:17px;font-weight:500;color:#111;margin:0 0 6px;">Save recording</p>
        <p style="font-size:13px;color:#666;margin:0 0 20px;">
          Enter a file name. This will be stored in the recordings table.
        </p>
        <label style="font-size:12px;font-weight:500;color:#666;display:block;margin-bottom:6px;">File name</label>
        <input id="videoFilenameInput" type="text" maxlength="80"
          placeholder="e.g. customer-intro-video"
          style="width:100%;height:42px;border-radius:8px;border:0.5px solid #ccc;
                 padding:0 14px;font-size:14px;color:#111;outline:none;box-sizing:border-box;margin-bottom:6px;" />
        <p style="font-size:12px;color:#888;margin:0 0 6px;">
          Allowed: letters, numbers, hyphens, underscores. No spaces.
        </p>
        <p id="videoFilenameError" style="font-size:12px;color:#A32D2D;margin:0 0 16px;display:none;">
          Please enter a valid file name (no spaces or special characters).
        </p>
        <div style="display:flex;gap:10px;justify-content:flex-end;margin-top:8px;">
          <button id="videoFilenameCancelBtn"
            style="height:42px;padding:0 20px;border-radius:8px;border:0.5px solid #ccc;
                   background:transparent;font-size:14px;color:#666;cursor:pointer;">Cancel</button>
          <button id="videoFilenameConfirmBtn"
            style="height:42px;padding:0 20px;border-radius:8px;border:0.5px solid #0F6E56;
                   background:#0F6E56;font-size:14px;color:#fff;cursor:pointer;font-weight:500;">
            Save to table
          </button>
        </div>
      </div>
    `;

    document.body.appendChild(overlay);

    const input      = document.getElementById('videoFilenameInput');
    const errorEl    = document.getElementById('videoFilenameError');
    const cancelBtn  = document.getElementById('videoFilenameCancelBtn');
    const confirmBtn = document.getElementById('videoFilenameConfirmBtn');

    setTimeout(() => input.focus(), 80);

    input.addEventListener('input', () => {
      errorEl.style.display   = 'none';
      input.style.borderColor = '#ccc';
    });
    input.addEventListener('keydown', (e) => {
      if (e.key === 'Enter')  confirmBtn.click();
      if (e.key === 'Escape') cancelBtn.click();
    });
    cancelBtn.addEventListener('click', () => overlay.remove());
    confirmBtn.addEventListener('click', () => {
      const fname = input.value.trim();
      if (!fname || /[^a-zA-Z0-9\-_]/.test(fname)) {
        errorEl.style.display   = 'block';
        input.style.borderColor = '#E24B4A';
        input.focus();
        return;
      }
      lastRecording.filename = fname + '.webm';
      overlay.remove();
      uploadRecording(fname);
    });
  }

  // ── Upload ───────────────────────────────────────────────────────
  function blobToBase64(blob) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onloadend = () => resolve(reader.result);
      reader.onerror   = reject;
      reader.readAsDataURL(blob);
    });
  }

  async function uploadRecording(displayName) {
    statusEl.className   = '';
    statusEl.textContent = 'Uploading video…';
    try {
      const base64  = await blobToBase64(lastRecording.blob);
      const payload = base64.split(',')[1];

      apex.server.process('SAVE_VIDEO', {
        x01: displayName,
        x02: lastRecording.mime,
        x03: payload,
        x04: String(lastRecording.durationMs),
        x05: String(lastRecording.segments)
      }, {
        dataType: 'json',
        success: function () {
          statusEl.className   = 'u-success-text';
          statusEl.textContent = `"${displayName}" saved successfully.`;
          apex.message.showPageSuccess('Video saved.');
          resetRecordingState();
          setViewRecording();
        },
        error: function (req, status, err) {
          statusEl.className   = 'u-warning-text';
          statusEl.textContent = 'Upload failed.';
          apex.message.alert('Upload failed: ' + err);
        }
      });
    } catch (e) {
      statusEl.className   = 'u-warning-text';
      statusEl.textContent = 'Processing failed.';
    }
  }

  // ── Reset & Retry ────────────────────────────────────────────────
  function resetRecordingState() {
    if (lastRecording.url) { try { URL.revokeObjectURL(lastRecording.url); } catch(e) {} }
    lastRecording       = { blob:null, url:null, mime:null, durationMs:0, filename:null, segments:0 };
    segmentCount        = 0;
    totalPausedMs       = 0;
    pausedAt            = null;
    playback.removeAttribute('src');
    playback.load();
    timerEl.textContent  = '';
    statusEl.textContent = '';
    btnPause.style.display  = '';
    btnResume.style.display = 'none';
  }

  async function retryRecording() {
    resetRecordingState();
    setViewRecording();
    try {
      await ensureCamera();
      btnStart.disabled = false;
      btnStop.disabled  = true;
      btnPause.disabled = true;
      statusEl.textContent = 'Ready to record.';
    } catch(e) {}
  }

  // ── Wire events ──────────────────────────────────────────────────
  btnStart.addEventListener('click',  async () => {
    try { await ensureCamera(); } catch(e) { return; }
    startRecording();
  });
  btnStop.addEventListener('click',   () => stopRecording());
  btnPause.addEventListener('click',  () => pauseRecording());
  btnResume.addEventListener('click', () => resumeRecording());
  btnSave.addEventListener('click',   () => saveRecording());
  btnRetry.addEventListener('click',  () => retryRecording());

  setViewRecording();

})();
4

Step 4 : When the user clicks Save, a clean dialog appears asking for a file name. Once confirmed, the video is converted to Base64 and sent to an APEX server-side process called SAVE_VIDEO — where you handle the insert into your recordings table. ✅ Happy with the clip? Save it. ๐Ÿ” Not quite right? Hit Retry and go again.

DECLARE
  l_blob      BLOB;
  l_filename  VARCHAR2(255) := apex_application.g_x01;
  l_mime      VARCHAR2(100) := apex_application.g_x02;
  l_b64       CLOB          := apex_application.g_x03;
  l_duration  NUMBER        := TO_NUMBER(apex_application.g_x04);
BEGIN
  l_blob := apex_web_service.clobbase642blob(l_b64);

  INSERT INTO video_capture (filename, mime_type, blob_content, duration_ms, created_by)
  VALUES (l_filename, l_mime, l_blob, l_duration, v('APP_USER'));

  apex_json.open_object;
  apex_json.write('status', 'ok');
  apex_json.close_object;

EXCEPTION
  WHEN OTHERS THEN
    apex_json.open_object;
    apex_json.write('status', 'error');
    apex_json.write('message', SQLERRM);
    apex_json.close_object;
END;

Final result ✅

Record Pause / Resume Preview playback Save to DB Retry

Comments

Popular posts from this blog

Generate Custom PDFs in Oracle APEX with jsPDF

Generate Custom PDFs in Oracle APEX with jsPDF A complete step-by-step guide to building a client-side PDF generator with profile image embedding and PL/SQL database persistence. &#128196; Step-by-step guide Oracle APEX jsPDF Dynamic Actions PL/SQL JavaScript "Picture this: your Oracle APEX application is polished, your users love it — but when they ask for a downloadable report with a profile picture and custom styling, you realize vanilla APEX isn't built for that out of the box. What if a single JavaScript library and one Dynamic Action could change everything?" Welcome to the world of jsPDF inside Oracle APEX . In this tutorial, we wire up a PDF generator that captures a user's name, email, phone number, and profile picture — renders them into a beautiful PDF layout — and saves everything to Oracle, all in one button click. ⓘ What you'll build: A dynamic PDF generat...

APEX Custom Auth Settings, Decoded!

Oracle APEX Security PL/SQL APEX Custom Auth Settings, Decoded Why I Build this I was building an internal APEX app — strictly for people on our corporate network. The default authentication worked, but it had a few things that kept bothering me. Anyone with valid credentials could log in from anywhere. Sessions expired with a useless error page. There was no audit trail — no way to know who logged in, when, or from where. And passwords were stored in plain text, which I just couldn't leave alone. So I did what any developer does — I built it myself. Custom authentication, from scratch, in PL/SQL. Turned out to be one of the best learning experiences I've had with APEX. Here's exactly how I did it. Img 1 : Head to Shared Components → Authentication Schemes → Create, choose Custom as the Scheme Type, and plug in your function and procedure names. Quick note before we get into the code — the s...