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
Post a Comment