diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php
index a71007f..7aae89d 100644
--- a/app/Http/Controllers/InterviewController.php
+++ b/app/Http/Controllers/InterviewController.php
@@ -32,6 +32,15 @@ public function index()
}
$interviews = $user->getAccessibleInterviews();
+
+ // Auto-terminate call session if interview timeline has expired
+ foreach ($interviews as $interview) {
+ if ($interview->isExpired() && $interview->call_status === 'active') {
+ $interview->update(['call_status' => 'ended', 'active_peers' => []]);
+ $interview->call_status = 'ended';
+ }
+ }
+
$interviewers = User::orderBy('name', 'asc')->get();
return view('interview.index', compact('interviews', 'interviewers'));
@@ -49,6 +58,13 @@ public function show($id)
}
$interview = Interview::findOrFail($id);
+
+ // Auto-terminate call session if interview timeline has expired
+ if ($interview->isExpired() && $interview->call_status === 'active') {
+ $interview->update(['call_status' => 'ended', 'active_peers' => []]);
+ $interview->call_status = 'ended';
+ }
+
$interviewers = User::orderBy('name', 'asc')->get();
return view('interview.show', compact('interview', 'interviewers'));
@@ -625,7 +641,8 @@ public function getActiveCalls(Request $request)
// Auto-terminate call session if interview timeline has expired
if ($interview->isExpired()) {
if ($interview->call_status === 'active') {
- $interview->update(['call_status' => 'ended']);
+ $interview->update(['call_status' => 'ended', 'active_peers' => []]);
+ $interview->call_status = 'ended';
}
continue;
}
diff --git a/app/Models/Interview.php b/app/Models/Interview.php
index a81f96b..22bdc45 100644
--- a/app/Models/Interview.php
+++ b/app/Models/Interview.php
@@ -70,6 +70,18 @@ public function isExpired(): bool
return now()->greaterThan($this->expires_at);
}
+ /**
+ * Check if the interview call is active and unexpired.
+ */
+ public function isCallActive(): bool
+ {
+ if ($this->isExpired() || in_array($this->status, ['completed', 'expired'])) {
+ return false;
+ }
+
+ return $this->call_status === 'active';
+ }
+
/**
* Check if the current time is within the interview timeline.
*/
diff --git a/resources/js/interview-call.js b/resources/js/interview-call.js
index cdbea41..93cff5b 100644
--- a/resources/js/interview-call.js
+++ b/resources/js/interview-call.js
@@ -2505,7 +2505,7 @@ export function startCallRecording() {
recordingCompositor = new RecordingCompositor({
width: 1280,
height: 720,
- fps: 24,
+ fps: 30,
getParticipantsData: () => {
const candStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
const candCamIcon = document.getElementById('cand-cam-status');
diff --git a/resources/js/recording-compositor.js b/resources/js/recording-compositor.js
index 5756daa..368c28e 100644
--- a/resources/js/recording-compositor.js
+++ b/resources/js/recording-compositor.js
@@ -16,8 +16,8 @@ export class RecordingCompositor {
* @param {{width:number, height:number, fps: number, getParticipantsData: () => {}}} options
*/
constructor(options = {}) {
- this.width = options.width || 1920;
- this.height = options.height || 1080;
+ this.width = options.width || 1280;
+ this.height = options.height || (options.width ? Math.round((options.width * 9) / 16) : 720);
this.fps = options.fps || 30;
this.canvas = document.createElement("canvas");
@@ -257,17 +257,29 @@ export class RecordingCompositor {
secondaryFeeds = [selfFeed, ...panelistFeeds];
}
- // Compute geometry with strict 16:9 aspect ratio preservation for every tile
- const gap = 16;
- const padX = 24;
+ // Compute dynamic geometry with strict 16:9 aspect ratio preservation for every tile
+ const padX = Math.max(8, Math.round(W * 0.015));
+ const padY = Math.max(8, Math.round(H * 0.02));
+ const gap = Math.max(6, Math.round(W * 0.01));
const totalSecondary = secondaryFeeds.length;
if (totalSecondary === 0) {
- // Case 0: Only Main Feed (Full Canvas 16:9)
- const mainH = 1032;
- const mainW = Math.round(mainH * (16 / 9)); // 1835px
+ // Case 0: Only Main Feed (Full Canvas 16:9 with padding)
+ const maxAvailW = W - 2 * padX;
+ const maxAvailH = H - 2 * padY;
+
+ let mainW, mainH;
+ if (maxAvailW / maxAvailH > 16 / 9) {
+ mainH = maxAvailH;
+ mainW = Math.round(mainH * (16 / 9));
+ } else {
+ mainW = maxAvailW;
+ mainH = Math.round(mainW * (9 / 16));
+ }
+
const mainX = Math.round((W - mainW) / 2);
const mainY = Math.round((H - mainH) / 2);
+
this.renderTile(
ctx,
mainFeed,
@@ -276,10 +288,20 @@ export class RecordingCompositor {
);
} else if (totalSecondary === 1) {
// Case 1: Main + 1 Sidebar Tile (Both 16:9)
- const sideW = 480;
- const sideH = Math.round(sideW * (9 / 16)); // 270px
- const mainW = 1376;
- const mainH = Math.round(mainW * (9 / 16)); // 774px
+ const availW = W - 2 * padX - gap;
+ const availH = H - 2 * padY;
+
+ let sideW = Math.round(availW * 0.26);
+ let sideH = Math.round(sideW * (9 / 16));
+ let mainW = availW - sideW;
+ let mainH = Math.round(mainW * (9 / 16));
+
+ if (mainH > availH) {
+ mainH = availH;
+ mainW = Math.round(mainH * (16 / 9));
+ sideW = Math.min(availW - mainW, Math.round(availH * (16 / 9)));
+ sideH = Math.round(sideW * (9 / 16));
+ }
const mainX = padX;
const mainY = Math.round((H - mainH) / 2);
@@ -299,17 +321,30 @@ export class RecordingCompositor {
false,
);
} else if (totalSecondary === 2) {
- // Case 2: Main + 2 Sidebar Tiles (All 16:9)
- const sideW = 480;
- const sideH = Math.round(sideW * (9 / 16)); // 270px
- const mainW = 1376;
- const mainH = Math.round(mainW * (9 / 16)); // 774px
+ // Case 2: Main + 2 Sidebar Tiles stacked vertically (All 16:9)
+ const availW = W - 2 * padX - gap;
+ const availH = H - 2 * padY;
+
+ let sideW = Math.round(availW * 0.28);
+ let sideH = Math.round(sideW * (9 / 16));
+
+ if (2 * sideH + gap > availH) {
+ sideH = Math.floor((availH - gap) / 2);
+ sideW = Math.round(sideH * (16 / 9));
+ }
+
+ const totalSideH = 2 * sideH + gap;
+ let mainW = availW - sideW;
+ let mainH = Math.round(mainW * (9 / 16));
+
+ if (mainH > availH) {
+ mainH = availH;
+ mainW = Math.round(mainH * (16 / 9));
+ }
const mainX = padX;
const mainY = Math.round((H - mainH) / 2);
- const sideX = padX + mainW + gap;
-
- const totalSideH = 2 * sideH + gap; // 556px
+ const sideX = W - padX - sideW;
const sideYStart = Math.round((H - totalSideH) / 2);
this.renderTile(
@@ -331,18 +366,24 @@ export class RecordingCompositor {
false,
);
} else if (totalSecondary === 3) {
- // Case 3: Main + 3 Sidebar Tiles (All 16:9)
- const sideH = 330;
- const sideW = Math.round(sideH * (16 / 9)); // 587px
- const totalSideH = 3 * sideH + 2 * gap; // 1022px
- const topY = Math.round((H - totalSideH) / 2); // 29px
+ // Case 3: Main + 3 Sidebar Tiles stacked vertically (All 16:9)
+ const availH = H - 2 * padY;
+ const sideH = Math.floor((availH - 2 * gap) / 3);
+ const sideW = Math.round(sideH * (16 / 9));
+ const totalSideH = 3 * sideH + 2 * gap;
+ const topY = Math.round((H - totalSideH) / 2);
const sideX = W - padX - sideW;
- const mainW = sideX - gap - padX; // 1270px
- const mainH = Math.round(mainW * (9 / 16)); // 714px
+ let mainW = sideX - gap - padX;
+ let mainH = Math.round(mainW * (9 / 16));
+
+ if (mainH > totalSideH) {
+ mainH = totalSideH;
+ mainW = Math.round(mainH * (16 / 9));
+ }
const mainX = padX;
- const mainY = topY;
+ const mainY = Math.round((H - mainH) / 2);
this.renderTile(
ctx,
@@ -363,16 +404,25 @@ export class RecordingCompositor {
}
} else {
// Case 4: Main + 3 Sidebar Tiles + Bottom Overflow Row (All 16:9)
- const sideH = 330;
- const sideW = Math.round(sideH * (16 / 9)); // 587px
- const totalSideH = 3 * sideH + 2 * gap; // 1022px
- const topY = Math.round((H - totalSideH) / 2); // 29px
+ const availH = H - 2 * padY;
+ const sideH = Math.floor((availH - 2 * gap) / 3);
+ const sideW = Math.round(sideH * (16 / 9));
+ const totalSideH = 3 * sideH + 2 * gap;
+ const topY = Math.round((H - totalSideH) / 2);
const sideX = W - padX - sideW;
- const mainW = sideX - gap - padX; // 1270px
- const mainH = Math.round(mainW * (9 / 16)); // 714px
+ const mainAreaW = sideX - gap - padX;
- const mainX = padX;
+ const targetMainH = Math.round((totalSideH - gap) * 0.68);
+ let mainW = Math.round(targetMainH * (16 / 9));
+ let mainH = targetMainH;
+
+ if (mainW > mainAreaW) {
+ mainW = mainAreaW;
+ mainH = Math.round(mainW * (9 / 16));
+ }
+
+ const mainX = padX + Math.round((mainAreaW - mainW) / 2);
const mainY = topY;
// 1. Render Main
@@ -398,12 +448,11 @@ export class RecordingCompositor {
// 3. Render Bottom Overflow Tiles (each 16:9)
const overflowFeeds = secondaryFeeds.slice(3);
const numOverflow = overflowFeeds.length;
- const botMaxH = totalSideH - mainH - gap; // 292px
- const botYBase = topY + mainH + gap; // 759px
+ const botMaxH = totalSideH - mainH - gap;
+ const botYBase = topY + mainH + gap;
- // Calculate width per tile to fit in mainW
const candBotW = Math.floor(
- (mainW - (numOverflow - 1) * gap) / numOverflow,
+ (mainAreaW - (numOverflow - 1) * gap) / numOverflow,
);
const candBotH = Math.round(candBotW * (9 / 16));
@@ -417,7 +466,7 @@ export class RecordingCompositor {
}
const totalBotW = numOverflow * botW + (numOverflow - 1) * gap;
- const botXStart = mainX + Math.round((mainW - totalBotW) / 2);
+ const botXStart = padX + Math.round((mainAreaW - totalBotW) / 2);
const botY = botYBase + Math.round((botMaxH - botH) / 2);
overflowFeeds.forEach((feed, j) => {
@@ -434,7 +483,7 @@ export class RecordingCompositor {
renderTile(ctx, feed, rect, isMain = false) {
const { x, y, w, h } = rect;
- const radius = 12;
+ const radius = Math.max(6, Math.round(Math.min(w, h) * 0.035));
ctx.save();
this.drawRoundedClip(ctx, x, y, w, h, radius);
@@ -465,7 +514,7 @@ export class RecordingCompositor {
ctx.restore();
// Overlay Pill Tag (Bottom-Left)
- this.drawPillTag(ctx, feed, x + 10, y + h - 12);
+ this.drawPillTag(ctx, feed, x, y, w, h);
}
drawVideoCover(ctx, video, x, y, w, h) {
@@ -524,10 +573,10 @@ export class RecordingCompositor {
drawPlaceholder(ctx, feed, x, y, w, h, isMain = false) {
const cx = x + w / 2;
- const cy = y + h / 2 - (isMain ? 15 : 8);
+ const cy = y + h / 2 - (isMain ? Math.round(h * 0.03) : Math.round(h * 0.02));
const circleRadius = isMain
- ? Math.min(54, h * 0.18)
- : Math.min(32, h * 0.22);
+ ? Math.min(Math.round(h * 0.18), Math.round(w * 0.12), 54)
+ : Math.min(Math.round(h * 0.2), Math.round(w * 0.14), 36);
// Circular gradient avatar
const gradient = ctx.createLinearGradient(
@@ -546,54 +595,60 @@ export class RecordingCompositor {
ctx.fillStyle = gradient;
ctx.beginPath();
- ctx.arc(cx, cy, circleRadius, 0, Math.PI * 2);
+ ctx.arc(cx, cy, Math.max(1, circleRadius), 0, Math.PI * 2);
ctx.fill();
// Avatar Initials Text
+ const initialsFont = Math.max(10, Math.round(circleRadius * 0.85));
ctx.fillStyle = "#ffffff";
- ctx.font = `bold ${Math.round(circleRadius * 0.85)}px sans-serif`;
+ ctx.font = `bold ${initialsFont}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(feed.initials || "IV", cx, cy);
// Participant Name
+ const nameFont = Math.max(10, Math.round(isMain ? Math.min(20, h * 0.045) : Math.min(14, h * 0.06)));
ctx.fillStyle = "#ffffff";
- ctx.font = `600 ${isMain ? 18 : 13}px sans-serif`;
+ ctx.font = `600 ${nameFont}px sans-serif`;
ctx.textBaseline = "top";
- ctx.fillText(feed.name, cx, cy + circleRadius + (isMain ? 14 : 8));
+ ctx.fillText(feed.name, cx, cy + circleRadius + Math.max(6, Math.round(h * 0.02)));
// Subtitle status
+ const subFont = Math.max(9, Math.round(isMain ? Math.min(14, h * 0.032) : Math.min(11, h * 0.045)));
ctx.fillStyle = "#94a3b8";
- ctx.font = `400 ${isMain ? 13 : 10.5}px sans-serif`;
+ ctx.font = `400 ${subFont}px sans-serif`;
ctx.fillText(
feed.isCamOn ? "Connecting video..." : "Camera turned off",
cx,
- cy + circleRadius + (isMain ? 36 : 24),
+ cy + circleRadius + Math.max(6, Math.round(h * 0.02)) + nameFont + Math.max(4, Math.round(h * 0.01)),
);
}
- drawPillTag(ctx, feed, x, y) {
+ drawPillTag(ctx, feed, tileX, tileY, tileW, tileH) {
const text = feed.name;
- const fontSize = 11;
+ const fontSize = Math.max(9, Math.min(12, Math.round(tileH * 0.045)));
ctx.font = `600 ${fontSize}px sans-serif`;
const textMetrics = ctx.measureText(text);
- const tagPaddingX = 8;
+ const tagPaddingX = Math.max(6, Math.round(fontSize * 0.7));
const tagW = textMetrics.width + tagPaddingX * 2;
- const tagH = 20;
+ const tagH = Math.max(16, Math.round(fontSize * 1.8));
- const tagX = x;
- const tagY = y - tagH;
+ const marginX = Math.max(6, Math.round(tileW * 0.02));
+ const marginY = Math.max(6, Math.round(tileH * 0.03));
+ const tagX = tileX + marginX;
+ const tagY = tileY + tileH - marginY - tagH;
+ const tagRadius = Math.max(3, Math.round(tagH * 0.25));
ctx.save();
// Background
ctx.fillStyle = "rgba(9, 13, 22, 0.8)";
- this.drawRoundedFill(ctx, tagX, tagY, tagW, tagH, 5);
+ this.drawRoundedFill(ctx, tagX, tagY, tagW, tagH, tagRadius);
// Border
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
ctx.lineWidth = 1;
- this.drawRoundedStroke(ctx, tagX, tagY, tagW, tagH, 5);
+ this.drawRoundedStroke(ctx, tagX, tagY, tagW, tagH, tagRadius);
// Name text
ctx.fillStyle = "#e2e8f0";
diff --git a/resources/views/components/interview/call-controls.blade.php b/resources/views/components/interview/call-controls.blade.php
index 35804d7..cc2f890 100644
--- a/resources/views/components/interview/call-controls.blade.php
+++ b/resources/views/components/interview/call-controls.blade.php
@@ -10,11 +10,11 @@