Compare commits
3 Commits
eb0106d296
...
bbe7e71274
| Author | SHA1 | Date | |
|---|---|---|---|
| bbe7e71274 | |||
| 254d9337bf | |||
| cc734790ef |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -43,6 +43,9 @@ node_modules/
|
|||||||
|
|
||||||
# Coverage
|
# Coverage
|
||||||
coverage/
|
coverage/
|
||||||
|
recordings/
|
||||||
|
!client/public/recordings/
|
||||||
|
!client/public/recordings/**
|
||||||
*.lcov
|
*.lcov
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
|
||||||
@@ -184,4 +187,4 @@ out/
|
|||||||
.history/
|
.history/
|
||||||
|
|
||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|||||||
@@ -21,15 +21,13 @@ export function createCallViewController({ store, chatMessage, notify }) {
|
|||||||
toggleVideo();
|
toggleVideo();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRecording() {
|
async function toggleRecording() {
|
||||||
const state = store.getState();
|
try {
|
||||||
const currentState = state.session.localUser.mediaState.recording || false;
|
const result = await store.toggleRecording();
|
||||||
store.updateLocalMedia('recording', !currentState);
|
notify(result.message);
|
||||||
|
}
|
||||||
if (!currentState) {
|
catch (error) {
|
||||||
notify('\u5f00\u59cb\u5f55\u5236');
|
notify(error.message || '\u5f55\u5236\u5931\u8d25');
|
||||||
} else {
|
|
||||||
notify('\u505c\u6b62\u5f55\u5236');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -253,3 +253,508 @@ body {
|
|||||||
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recordings-page {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-shell {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-toolbar {
|
||||||
|
min-height: 76px;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 140px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-stat {
|
||||||
|
min-width: 112px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-stat-label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-search,
|
||||||
|
.recordings-select,
|
||||||
|
.recordings-input {
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(15, 23, 42, 0.58);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-search input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-search input::placeholder,
|
||||||
|
.recordings-input::placeholder {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-select {
|
||||||
|
padding: 0 12px;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-select option {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-content {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px minmax(420px, 1fr) 360px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-upload,
|
||||||
|
.recordings-list,
|
||||||
|
.recordings-preview {
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-upload {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-list {
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-list-head {
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-preview {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-dropzone {
|
||||||
|
min-height: 132px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px dashed rgba(129, 140, 248, 0.55);
|
||||||
|
background: rgba(79, 70, 229, 0.08);
|
||||||
|
color: #c7d2fe;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-dropzone:hover {
|
||||||
|
background: rgba(79, 70, 229, 0.16);
|
||||||
|
border-color: rgba(165, 180, 252, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 12px;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-input:focus,
|
||||||
|
.recordings-select:focus,
|
||||||
|
.recordings-search:focus-within {
|
||||||
|
border-color: rgba(129, 140, 248, 0.9);
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-primary-btn,
|
||||||
|
.recordings-secondary-btn,
|
||||||
|
.recordings-text-btn,
|
||||||
|
.recordings-icon-btn {
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, background 0.2s, color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-primary-btn,
|
||||||
|
.recordings-secondary-btn {
|
||||||
|
min-height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-primary-btn {
|
||||||
|
width: 100%;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-primary-btn:hover {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-primary-btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-secondary-btn {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-secondary-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-text-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #c7d2fe;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-icon-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 11px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #cbd5e1;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-icon-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-danger {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-danger:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(239, 68, 68, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-table-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-table th,
|
||||||
|
.recordings-table td {
|
||||||
|
padding: 13px 14px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-table th:nth-child(1) { width: 38%; }
|
||||||
|
.recordings-table th:nth-child(2) { width: 18%; }
|
||||||
|
.recordings-table th:nth-child(3) { width: 12%; }
|
||||||
|
.recordings-table th:nth-child(4) { width: 18%; }
|
||||||
|
.recordings-table th:nth-child(5) { width: 14%; }
|
||||||
|
|
||||||
|
.recordings-table tbody tr {
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-table tbody tr:hover,
|
||||||
|
.recordings-row-active {
|
||||||
|
background: rgba(99, 102, 241, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-file-cell {
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-file-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 46px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(16, 185, 129, 0.16);
|
||||||
|
color: #6ee7b7;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-file-name,
|
||||||
|
.recordings-file-sub {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-file-name {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-file-sub {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-empty {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 260px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-preview-video {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #020617;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-preview-video video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-preview-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
background: linear-gradient(135deg, rgba(49, 46, 129, 0.55), rgba(8, 47, 73, 0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-preview-meta {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-preview-meta h2 {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-detail-row span {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-detail-row strong {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-preview-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-dialog {
|
||||||
|
width: min(440px, calc(100vw - 32px));
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 82px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -16px);
|
||||||
|
z-index: 60;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #ffffff;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-notification-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-notification-error i {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.recordings-content {
|
||||||
|
grid-template-columns: 260px minmax(420px, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-preview {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 360px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(360px, 0.8fr) 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.recordings-page {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-shell {
|
||||||
|
padding: 14px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-toolbar {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-search,
|
||||||
|
.recordings-select {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-content,
|
||||||
|
.recordings-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-list,
|
||||||
|
.recordings-upload,
|
||||||
|
.recordings-preview {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-table {
|
||||||
|
min-width: 760px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
336
client/public/meeting-recorder.js
Normal file
336
client/public/meeting-recorder.js
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
const DEFAULT_WIDTH = 1280;
|
||||||
|
const DEFAULT_HEIGHT = 720;
|
||||||
|
const DEFAULT_FPS = 30;
|
||||||
|
const MIME_TYPE_CANDIDATES = [
|
||||||
|
{ mimeType: 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', extension: 'mp4' },
|
||||||
|
{ mimeType: 'video/mp4', extension: 'mp4' },
|
||||||
|
{ mimeType: 'video/webm;codecs=vp9,opus', extension: 'webm' },
|
||||||
|
{ mimeType: 'video/webm;codecs=vp8,opus', extension: 'webm' },
|
||||||
|
{ mimeType: 'video/webm', extension: 'webm' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function getSupportedFormat(mediaRecorderCtor) {
|
||||||
|
if (!mediaRecorderCtor || typeof mediaRecorderCtor.isTypeSupported !== 'function') {
|
||||||
|
return {
|
||||||
|
mimeType: '',
|
||||||
|
extension: 'webm'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return MIME_TYPE_CANDIDATES.find(format => mediaRecorderCtor.isTypeSupported(format.mimeType)) || {
|
||||||
|
mimeType: '',
|
||||||
|
extension: 'webm'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isElementVisible(element) {
|
||||||
|
if (!element || element.classList.contains('hidden')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.width > 0 && rect.height > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawVideoCover(context, video, x, y, width, height) {
|
||||||
|
const videoWidth = video.videoWidth || width;
|
||||||
|
const videoHeight = video.videoHeight || height;
|
||||||
|
const sourceRatio = videoWidth / videoHeight;
|
||||||
|
const targetRatio = width / height;
|
||||||
|
let sourceX = 0;
|
||||||
|
let sourceY = 0;
|
||||||
|
let sourceWidth = videoWidth;
|
||||||
|
let sourceHeight = videoHeight;
|
||||||
|
|
||||||
|
if (sourceRatio > targetRatio) {
|
||||||
|
sourceWidth = videoHeight * targetRatio;
|
||||||
|
sourceX = (videoWidth - sourceWidth) / 2;
|
||||||
|
} else {
|
||||||
|
sourceHeight = videoWidth / targetRatio;
|
||||||
|
sourceY = (videoHeight - sourceHeight) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.drawImage(video, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawEmptyFrame(context, canvas) {
|
||||||
|
context.fillStyle = '#111827';
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.fillStyle = '#9ca3af';
|
||||||
|
context.font = '24px sans-serif';
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
context.fillText('等待会议画面...', canvas.width / 2, canvas.height / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid(context, videos, canvas) {
|
||||||
|
const columns = Math.ceil(Math.sqrt(videos.length));
|
||||||
|
const rows = Math.ceil(videos.length / columns);
|
||||||
|
const gap = 8;
|
||||||
|
const tileWidth = (canvas.width - gap * (columns - 1)) / columns;
|
||||||
|
const tileHeight = (canvas.height - gap * (rows - 1)) / rows;
|
||||||
|
|
||||||
|
videos.forEach((video, index) => {
|
||||||
|
const column = index % columns;
|
||||||
|
const row = Math.floor(index / columns);
|
||||||
|
const x = column * (tileWidth + gap);
|
||||||
|
const y = row * (tileHeight + gap);
|
||||||
|
drawVideoCover(context, video, x, y, tileWidth, tileHeight);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLocalPreview(context, localVideo, canvas) {
|
||||||
|
const previewWidth = Math.floor(canvas.width * 0.22);
|
||||||
|
const previewHeight = Math.floor(previewWidth * 9 / 16);
|
||||||
|
const margin = 24;
|
||||||
|
const x = canvas.width - previewWidth - margin;
|
||||||
|
const y = canvas.height - previewHeight - margin;
|
||||||
|
|
||||||
|
context.fillStyle = 'rgba(0, 0, 0, 0.4)';
|
||||||
|
context.fillRect(x - 4, y - 4, previewWidth + 8, previewHeight + 8);
|
||||||
|
drawVideoCover(context, localVideo, x, y, previewWidth, previewHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectStreams({ localStream, remoteStream, remoteStreams } = {}) {
|
||||||
|
return [
|
||||||
|
localStream,
|
||||||
|
remoteStream,
|
||||||
|
...Object.values(remoteStreams || {})
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectLiveAudioTracks(streams) {
|
||||||
|
return streams.flatMap(stream => stream.getAudioTracks())
|
||||||
|
.filter(track => track.readyState !== 'ended');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MeetingRecorder {
|
||||||
|
constructor({
|
||||||
|
documentRef = document,
|
||||||
|
windowRef = window,
|
||||||
|
width = DEFAULT_WIDTH,
|
||||||
|
height = DEFAULT_HEIGHT,
|
||||||
|
fps = DEFAULT_FPS
|
||||||
|
} = {}) {
|
||||||
|
this.document = documentRef;
|
||||||
|
this.window = windowRef;
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.fps = fps;
|
||||||
|
this.mediaRecorder = null;
|
||||||
|
this.chunks = [];
|
||||||
|
this.animationFrameId = null;
|
||||||
|
this.audioContext = null;
|
||||||
|
this.audioSources = [];
|
||||||
|
this.recordingStream = null;
|
||||||
|
this.connectionId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
isSupported() {
|
||||||
|
return Boolean(
|
||||||
|
this.window.MediaRecorder &&
|
||||||
|
this.document.createElement('canvas').captureStream
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isRecording() {
|
||||||
|
return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive');
|
||||||
|
}
|
||||||
|
|
||||||
|
async start({ localStream, remoteStream, remoteStreams, connectionId } = {}) {
|
||||||
|
if (this.isRecording()) {
|
||||||
|
throw new Error('会议正在录制中');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isSupported()) {
|
||||||
|
throw new Error('当前浏览器不支持会议录制');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = this.document.createElement('canvas');
|
||||||
|
canvas.width = this.width;
|
||||||
|
canvas.height = this.height;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('无法创建录制画布');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectionId = connectionId || '';
|
||||||
|
this.chunks = [];
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.context = context;
|
||||||
|
|
||||||
|
const canvasStream = canvas.captureStream(this.fps);
|
||||||
|
const streams = collectStreams({ localStream, remoteStream, remoteStreams });
|
||||||
|
const audioTrack = this.createMixedAudioTrack(streams);
|
||||||
|
const tracks = [
|
||||||
|
...canvasStream.getVideoTracks(),
|
||||||
|
...(audioTrack ? [audioTrack] : [])
|
||||||
|
];
|
||||||
|
|
||||||
|
this.recordingStream = new this.window.MediaStream(tracks);
|
||||||
|
try {
|
||||||
|
this.startDrawing();
|
||||||
|
this.startMediaRecorder(this.recordingStream);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
this.cleanup();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (!this.isRecording()) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pendingStop = { resolve, reject };
|
||||||
|
this.mediaRecorder.stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createMixedAudioTrack(streams) {
|
||||||
|
const audioTracks = collectLiveAudioTracks(streams);
|
||||||
|
|
||||||
|
if (audioTracks.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioContextCtor = this.window.AudioContext || this.window.webkitAudioContext;
|
||||||
|
if (!AudioContextCtor) {
|
||||||
|
return audioTracks[0].clone ? audioTracks[0].clone() : audioTracks[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.audioContext = new AudioContextCtor();
|
||||||
|
const destination = this.audioContext.createMediaStreamDestination();
|
||||||
|
|
||||||
|
audioTracks.forEach(track => {
|
||||||
|
const sourceStream = new this.window.MediaStream([track]);
|
||||||
|
const source = this.audioContext.createMediaStreamSource(sourceStream);
|
||||||
|
source.connect(destination);
|
||||||
|
this.audioSources.push(source);
|
||||||
|
});
|
||||||
|
|
||||||
|
return destination.stream.getAudioTracks()[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
startMediaRecorder(stream) {
|
||||||
|
const MediaRecorderCtor = this.window.MediaRecorder;
|
||||||
|
const format = getSupportedFormat(MediaRecorderCtor);
|
||||||
|
const options = format.mimeType ? { mimeType: format.mimeType } : {};
|
||||||
|
this.fileExtension = format.extension;
|
||||||
|
|
||||||
|
this.mediaRecorder = new MediaRecorderCtor(stream, options);
|
||||||
|
this.mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data && event.data.size > 0) {
|
||||||
|
this.chunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.mediaRecorder.onerror = (event) => {
|
||||||
|
if (this.pendingStop) {
|
||||||
|
this.pendingStop.reject(event.error || new Error('录制失败'));
|
||||||
|
this.pendingStop = null;
|
||||||
|
}
|
||||||
|
this.cleanup();
|
||||||
|
};
|
||||||
|
this.mediaRecorder.onstop = () => {
|
||||||
|
const blob = new Blob(this.chunks, { type: this.mediaRecorder.mimeType || 'video/webm' });
|
||||||
|
const filename = this.buildFilename();
|
||||||
|
const mimeType = blob.type || this.mediaRecorder.mimeType || 'video/webm';
|
||||||
|
this.cleanup();
|
||||||
|
if (this.pendingStop) {
|
||||||
|
this.pendingStop.resolve({ blob, filename, mimeType });
|
||||||
|
this.pendingStop = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mediaRecorder.start(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
startDrawing() {
|
||||||
|
const draw = () => {
|
||||||
|
this.drawFrame();
|
||||||
|
this.animationFrameId = this.window.requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawFrame() {
|
||||||
|
const context = this.context;
|
||||||
|
const canvas = this.canvas;
|
||||||
|
const videos = this.getRecordableVideos();
|
||||||
|
const localVideo = videos.find(video => video.id === 'localVideo');
|
||||||
|
const remoteVideos = videos.filter(video => video !== localVideo);
|
||||||
|
|
||||||
|
context.fillStyle = '#020617';
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (remoteVideos.length > 0) {
|
||||||
|
drawGrid(context, remoteVideos, canvas);
|
||||||
|
if (localVideo) {
|
||||||
|
drawLocalPreview(context, localVideo, canvas);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localVideo) {
|
||||||
|
drawVideoCover(context, localVideo, 0, 0, canvas.width, canvas.height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawEmptyFrame(context, canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecordableVideos() {
|
||||||
|
return Array.from(this.document.querySelectorAll('#participantGrid video, #remoteVideo, #localVideo'))
|
||||||
|
.filter(video => video.srcObject && isElementVisible(video) && video.readyState >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
download(blob, filename = this.buildFilename()) {
|
||||||
|
const url = this.window.URL.createObjectURL(blob);
|
||||||
|
const link = this.document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.style.display = 'none';
|
||||||
|
this.document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
|
||||||
|
this.window.setTimeout(() => {
|
||||||
|
this.window.URL.revokeObjectURL(url);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFilename() {
|
||||||
|
const datePart = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const meetingPart = this.connectionId ? `-${this.connectionId}` : '';
|
||||||
|
return `meeting-recording${meetingPart}-${datePart}.${this.fileExtension || 'webm'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if (this.animationFrameId) {
|
||||||
|
this.window.cancelAnimationFrame(this.animationFrameId);
|
||||||
|
this.animationFrameId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.recordingStream) {
|
||||||
|
this.recordingStream.getTracks().forEach(track => track.stop());
|
||||||
|
this.recordingStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.audioContext) {
|
||||||
|
this.audioContext.close();
|
||||||
|
this.audioContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.audioSources = [];
|
||||||
|
this.mediaRecorder = null;
|
||||||
|
this.canvas = null;
|
||||||
|
this.context = null;
|
||||||
|
this.chunks = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
205
client/public/recordings/index.html
Normal file
205
client/public/recordings/index.html
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VideoCall - 录制管理</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="min-h-screen w-screen text-white bg-grid recordings-page">
|
||||||
|
<div class="min-h-screen bg-black/70 flex flex-col">
|
||||||
|
<header class="glass-strong h-16 flex items-center justify-between px-6 border-b border-white/10">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<div class="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<i class="fas fa-video text-white text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="font-bold text-lg tracking-tight">录制管理后台</h1>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-gray-400">
|
||||||
|
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||||
|
<span id="recordingRootText" class="truncate max-w-[60vw]">recordings</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/" class="recordings-icon-btn glass" title="返回视频通话">
|
||||||
|
<i class="fas fa-phone"></i>
|
||||||
|
</a>
|
||||||
|
<button id="refreshBtn" class="recordings-icon-btn glass" title="刷新列表">
|
||||||
|
<i class="fas fa-rotate-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="recordings-shell flex-1 overflow-hidden">
|
||||||
|
<section class="recordings-toolbar glass">
|
||||||
|
<div class="recordings-stat">
|
||||||
|
<span class="recordings-stat-value" id="totalCount">0</span>
|
||||||
|
<span class="recordings-stat-label">总录制</span>
|
||||||
|
</div>
|
||||||
|
<div class="recordings-stat">
|
||||||
|
<span class="recordings-stat-value" id="meetingCount">0</span>
|
||||||
|
<span class="recordings-stat-label">会议数</span>
|
||||||
|
</div>
|
||||||
|
<div class="recordings-stat">
|
||||||
|
<span class="recordings-stat-value" id="storageSize">0 MB</span>
|
||||||
|
<span class="recordings-stat-label">占用空间</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recordings-search">
|
||||||
|
<i class="fas fa-magnifying-glass text-gray-500"></i>
|
||||||
|
<input id="searchInput" type="search" placeholder="搜索会议、文件或用户" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select id="typeFilter" class="recordings-select">
|
||||||
|
<option value="all">全部格式</option>
|
||||||
|
<option value="mp4">MP4</option>
|
||||||
|
<option value="webm">WebM</option>
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="recordings-content">
|
||||||
|
<aside class="recordings-upload glass">
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-semibold">新增录制</h2>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">上传到 recordings 目录</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-indigo-500/20 flex items-center justify-center text-indigo-300">
|
||||||
|
<i class="fas fa-cloud-arrow-up"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="uploadForm" class="space-y-4">
|
||||||
|
<label class="recordings-dropzone" for="recordingFile">
|
||||||
|
<input id="recordingFile" type="file" accept="video/mp4,video/webm" class="hidden" required>
|
||||||
|
<i class="fas fa-file-video text-2xl text-indigo-300"></i>
|
||||||
|
<span id="fileNameText">选择 MP4 或 WebM 文件</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="recordings-label" for="uploadMeetingId">会议 ID</label>
|
||||||
|
<input id="uploadMeetingId" class="recordings-input" type="text" placeholder="例如 665-261-326" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="recordings-label" for="uploadUserId">用户 ID</label>
|
||||||
|
<input id="uploadUserId" class="recordings-input" type="text" placeholder="可选">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="uploadBtn" class="recordings-primary-btn" type="submit">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
<span>上传录制</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="recordings-list glass">
|
||||||
|
<div class="recordings-list-head">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-semibold">录制文件</h2>
|
||||||
|
<p class="text-xs text-gray-500 mt-1" id="listSummary">等待加载</p>
|
||||||
|
</div>
|
||||||
|
<button id="clearSearchBtn" class="recordings-text-btn hidden">
|
||||||
|
<i class="fas fa-xmark"></i>
|
||||||
|
<span>清空筛选</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loadingState" class="recordings-empty">
|
||||||
|
<div class="w-10 h-10 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<span>正在读取 recordings 目录...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="emptyState" class="recordings-empty hidden">
|
||||||
|
<i class="fas fa-folder-open text-3xl text-gray-500"></i>
|
||||||
|
<span>还没有录制文件</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="recordingsTableWrap" class="recordings-table-wrap hidden">
|
||||||
|
<table class="recordings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>文件</th>
|
||||||
|
<th>会议</th>
|
||||||
|
<th>大小</th>
|
||||||
|
<th>上传时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="recordingsTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="recordings-preview glass">
|
||||||
|
<div class="recordings-preview-video">
|
||||||
|
<video id="previewVideo" controls playsinline></video>
|
||||||
|
<div id="previewPlaceholder" class="recordings-preview-placeholder">
|
||||||
|
<i class="fas fa-play text-2xl"></i>
|
||||||
|
<span>选择一条录制预览</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recordings-preview-meta">
|
||||||
|
<h2 id="previewTitle">未选择录制</h2>
|
||||||
|
<div id="previewDetails" class="space-y-3 text-sm text-gray-400">
|
||||||
|
<p>从左侧列表选择文件后,可播放、下载、编辑或删除。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="editDialog" class="fixed inset-0 bg-black/70 hidden items-center justify-center z-50">
|
||||||
|
<form id="editForm" class="recordings-dialog glass">
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">编辑录制信息</h2>
|
||||||
|
<p class="text-xs text-gray-500 mt-1" id="editFilenameText"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="closeEditBtn" class="recordings-icon-btn" title="关闭">
|
||||||
|
<i class="fas fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="recordings-label" for="editMeetingId">会议 ID</label>
|
||||||
|
<input id="editMeetingId" class="recordings-input" type="text" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="recordings-label" for="editOriginalFilename">显示名称</label>
|
||||||
|
<input id="editOriginalFilename" class="recordings-input" type="text" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="recordings-label" for="editUserId">用户 ID</label>
|
||||||
|
<input id="editUserId" class="recordings-input" type="text">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 mt-6">
|
||||||
|
<button type="button" id="cancelEditBtn" class="recordings-secondary-btn">取消</button>
|
||||||
|
<button type="submit" class="recordings-primary-btn">
|
||||||
|
<i class="fas fa-floppy-disk"></i>
|
||||||
|
<span>保存</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="notification" class="recordings-notification glass">
|
||||||
|
<i class="fas fa-info-circle text-indigo-400"></i>
|
||||||
|
<span id="notificationText"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/recordings/recordings-admin.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
400
client/public/recordings/recordings-admin.js
Normal file
400
client/public/recordings/recordings-admin.js
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
const state = {
|
||||||
|
recordings: [],
|
||||||
|
filtered: [],
|
||||||
|
selectedKey: '',
|
||||||
|
editing: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const elements = {
|
||||||
|
refreshBtn: document.getElementById('refreshBtn'),
|
||||||
|
searchInput: document.getElementById('searchInput'),
|
||||||
|
typeFilter: document.getElementById('typeFilter'),
|
||||||
|
clearSearchBtn: document.getElementById('clearSearchBtn'),
|
||||||
|
uploadForm: document.getElementById('uploadForm'),
|
||||||
|
uploadBtn: document.getElementById('uploadBtn'),
|
||||||
|
recordingFile: document.getElementById('recordingFile'),
|
||||||
|
fileNameText: document.getElementById('fileNameText'),
|
||||||
|
uploadMeetingId: document.getElementById('uploadMeetingId'),
|
||||||
|
uploadUserId: document.getElementById('uploadUserId'),
|
||||||
|
totalCount: document.getElementById('totalCount'),
|
||||||
|
meetingCount: document.getElementById('meetingCount'),
|
||||||
|
storageSize: document.getElementById('storageSize'),
|
||||||
|
recordingRootText: document.getElementById('recordingRootText'),
|
||||||
|
listSummary: document.getElementById('listSummary'),
|
||||||
|
loadingState: document.getElementById('loadingState'),
|
||||||
|
emptyState: document.getElementById('emptyState'),
|
||||||
|
recordingsTableWrap: document.getElementById('recordingsTableWrap'),
|
||||||
|
recordingsTableBody: document.getElementById('recordingsTableBody'),
|
||||||
|
previewVideo: document.getElementById('previewVideo'),
|
||||||
|
previewPlaceholder: document.getElementById('previewPlaceholder'),
|
||||||
|
previewTitle: document.getElementById('previewTitle'),
|
||||||
|
previewDetails: document.getElementById('previewDetails'),
|
||||||
|
editDialog: document.getElementById('editDialog'),
|
||||||
|
editForm: document.getElementById('editForm'),
|
||||||
|
editFilenameText: document.getElementById('editFilenameText'),
|
||||||
|
editMeetingId: document.getElementById('editMeetingId'),
|
||||||
|
editOriginalFilename: document.getElementById('editOriginalFilename'),
|
||||||
|
editUserId: document.getElementById('editUserId'),
|
||||||
|
closeEditBtn: document.getElementById('closeEditBtn'),
|
||||||
|
cancelEditBtn: document.getElementById('cancelEditBtn'),
|
||||||
|
notification: document.getElementById('notification'),
|
||||||
|
notificationText: document.getElementById('notificationText')
|
||||||
|
};
|
||||||
|
|
||||||
|
function recordingKey(recording) {
|
||||||
|
return `${recording.meetingId}/${recording.filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
const value = Number(bytes) || 0;
|
||||||
|
if (value < 1024) {
|
||||||
|
return `${value} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||||
|
let size = value / 1024;
|
||||||
|
let index = 0;
|
||||||
|
while (size >= 1024 && index < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotification(message, isError = false) {
|
||||||
|
elements.notificationText.textContent = message;
|
||||||
|
elements.notification.classList.toggle('recordings-notification-error', isError);
|
||||||
|
elements.notification.classList.add('recordings-notification-visible');
|
||||||
|
window.clearTimeout(showNotification.timer);
|
||||||
|
showNotification.timer = window.setTimeout(() => {
|
||||||
|
elements.notification.classList.remove('recordings-notification-visible');
|
||||||
|
}, 2600);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(url, options = {}) {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || payload.success === false) {
|
||||||
|
throw new Error(payload.message || `请求失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(isLoading) {
|
||||||
|
elements.loadingState.classList.toggle('hidden', !isLoading);
|
||||||
|
elements.recordingsTableWrap.classList.toggle('hidden', isLoading || state.filtered.length === 0);
|
||||||
|
elements.emptyState.classList.toggle('hidden', isLoading || state.filtered.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecordings() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = await requestJson('/api/recordings');
|
||||||
|
state.recordings = payload.recordings || [];
|
||||||
|
elements.recordingRootText.textContent = payload.root || 'recordings';
|
||||||
|
applyFilters();
|
||||||
|
const selected = state.filtered.find(item => recordingKey(item) === state.selectedKey) || state.filtered[0];
|
||||||
|
selectRecording(selected || null);
|
||||||
|
} catch (error) {
|
||||||
|
state.recordings = [];
|
||||||
|
applyFilters();
|
||||||
|
selectRecording(null);
|
||||||
|
showNotification(error.message, true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const query = elements.searchInput.value.trim().toLowerCase();
|
||||||
|
const type = elements.typeFilter.value;
|
||||||
|
|
||||||
|
state.filtered = state.recordings.filter((recording) => {
|
||||||
|
const extension = (recording.filename || '').split('.').pop().toLowerCase();
|
||||||
|
const haystack = [
|
||||||
|
recording.meetingId,
|
||||||
|
recording.filename,
|
||||||
|
recording.originalFilename,
|
||||||
|
recording.userId
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
return (type === 'all' || extension === type) && (!query || haystack.includes(query));
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.clearSearchBtn.classList.toggle('hidden', !query && type === 'all');
|
||||||
|
renderSummary();
|
||||||
|
renderTable();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary() {
|
||||||
|
const meetings = new Set(state.recordings.map(recording => recording.meetingId));
|
||||||
|
const totalSize = state.recordings.reduce((sum, recording) => sum + (Number(recording.size) || 0), 0);
|
||||||
|
|
||||||
|
elements.totalCount.textContent = state.recordings.length;
|
||||||
|
elements.meetingCount.textContent = meetings.size;
|
||||||
|
elements.storageSize.textContent = formatBytes(totalSize);
|
||||||
|
elements.listSummary.textContent = `当前显示 ${state.filtered.length} 条,共 ${state.recordings.length} 条`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
elements.recordingsTableBody.innerHTML = state.filtered.map((recording) => {
|
||||||
|
const key = recordingKey(recording);
|
||||||
|
const active = key === state.selectedKey ? 'recordings-row-active' : '';
|
||||||
|
const ext = escapeHtml((recording.filename || '').split('.').pop().toUpperCase());
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="${active}" data-key="${escapeHtml(key)}">
|
||||||
|
<td>
|
||||||
|
<button class="recordings-file-cell" data-action="select" data-key="${escapeHtml(key)}">
|
||||||
|
<span class="recordings-file-icon">${ext}</span>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="recordings-file-name">${escapeHtml(recording.originalFilename || recording.filename)}</span>
|
||||||
|
<span class="recordings-file-sub">${escapeHtml(recording.filename)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>${escapeHtml(recording.meetingId)}</td>
|
||||||
|
<td>${formatBytes(recording.size)}</td>
|
||||||
|
<td>${formatDate(recording.uploadedAt)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="recordings-actions">
|
||||||
|
<button class="recordings-icon-btn" data-action="preview" data-key="${escapeHtml(key)}" title="预览">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
</button>
|
||||||
|
<a class="recordings-icon-btn" href="${escapeHtml(recording.downloadUrl)}" title="下载">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
<button class="recordings-icon-btn" data-action="edit" data-key="${escapeHtml(key)}" title="编辑">
|
||||||
|
<i class="fas fa-pen"></i>
|
||||||
|
</button>
|
||||||
|
<button class="recordings-icon-btn recordings-danger" data-action="delete" data-key="${escapeHtml(key)}" title="删除">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRecording(key) {
|
||||||
|
return state.recordings.find(recording => recordingKey(recording) === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRecording(recording) {
|
||||||
|
if (!recording) {
|
||||||
|
state.selectedKey = '';
|
||||||
|
elements.previewVideo.removeAttribute('src');
|
||||||
|
elements.previewVideo.load();
|
||||||
|
elements.previewPlaceholder.classList.remove('hidden');
|
||||||
|
elements.previewTitle.textContent = '未选择录制';
|
||||||
|
elements.previewDetails.innerHTML = '<p>从左侧列表选择文件后,可播放、下载、编辑或删除。</p>';
|
||||||
|
renderTable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.selectedKey = recordingKey(recording);
|
||||||
|
elements.previewVideo.src = recording.streamUrl;
|
||||||
|
elements.previewPlaceholder.classList.add('hidden');
|
||||||
|
elements.previewTitle.textContent = recording.originalFilename || recording.filename;
|
||||||
|
elements.previewDetails.innerHTML = `
|
||||||
|
<div class="recordings-detail-row"><span>会议 ID</span><strong>${escapeHtml(recording.meetingId)}</strong></div>
|
||||||
|
<div class="recordings-detail-row"><span>文件名</span><strong>${escapeHtml(recording.filename)}</strong></div>
|
||||||
|
<div class="recordings-detail-row"><span>格式</span><strong>${escapeHtml(recording.mimetype)}</strong></div>
|
||||||
|
<div class="recordings-detail-row"><span>大小</span><strong>${formatBytes(recording.size)}</strong></div>
|
||||||
|
<div class="recordings-detail-row"><span>用户 ID</span><strong>${escapeHtml(recording.userId || '-')}</strong></div>
|
||||||
|
<div class="recordings-detail-row"><span>上传时间</span><strong>${formatDate(recording.uploadedAt)}</strong></div>
|
||||||
|
<div class="recordings-preview-actions">
|
||||||
|
<a class="recordings-primary-btn" href="${escapeHtml(recording.downloadUrl)}">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
<span>下载</span>
|
||||||
|
</a>
|
||||||
|
<button class="recordings-secondary-btn" type="button" data-preview-action="edit">编辑</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(recording) {
|
||||||
|
if (!recording) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.editing = recording;
|
||||||
|
elements.editFilenameText.textContent = recording.filename;
|
||||||
|
elements.editMeetingId.value = recording.meetingId || '';
|
||||||
|
elements.editOriginalFilename.value = recording.originalFilename || recording.filename || '';
|
||||||
|
elements.editUserId.value = recording.userId || '';
|
||||||
|
elements.editDialog.classList.remove('hidden');
|
||||||
|
elements.editDialog.classList.add('flex');
|
||||||
|
elements.editMeetingId.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEdit() {
|
||||||
|
state.editing = null;
|
||||||
|
elements.editDialog.classList.add('hidden');
|
||||||
|
elements.editDialog.classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const file = elements.recordingFile.files[0];
|
||||||
|
if (!file) {
|
||||||
|
showNotification('请选择录制文件', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('recording', file, file.name);
|
||||||
|
formData.append('filename', file.name);
|
||||||
|
formData.append('meetingId', elements.uploadMeetingId.value.trim());
|
||||||
|
formData.append('userId', elements.uploadUserId.value.trim());
|
||||||
|
|
||||||
|
elements.uploadBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await requestJson('/api/recordings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
elements.uploadForm.reset();
|
||||||
|
elements.fileNameText.textContent = '选择 MP4 或 WebM 文件';
|
||||||
|
showNotification('录制已上传');
|
||||||
|
await loadRecordings();
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(error.message, true);
|
||||||
|
} finally {
|
||||||
|
elements.uploadBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEdit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!state.editing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recording = state.editing;
|
||||||
|
try {
|
||||||
|
const payload = await requestJson(`/api/recordings/${encodeURIComponent(recording.meetingId)}/${encodeURIComponent(recording.filename)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
meetingId: elements.editMeetingId.value.trim(),
|
||||||
|
originalFilename: elements.editOriginalFilename.value.trim(),
|
||||||
|
userId: elements.editUserId.value.trim()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
state.selectedKey = recordingKey(payload.recording);
|
||||||
|
closeEdit();
|
||||||
|
showNotification('录制信息已更新');
|
||||||
|
await loadRecordings();
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRecording(recording) {
|
||||||
|
if (!recording) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = window.confirm(`确定删除录制文件 "${recording.originalFilename || recording.filename}" 吗?`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestJson(`/api/recordings/${encodeURIComponent(recording.meetingId)}/${encodeURIComponent(recording.filename)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (state.selectedKey === recordingKey(recording)) {
|
||||||
|
state.selectedKey = '';
|
||||||
|
}
|
||||||
|
showNotification('录制已删除');
|
||||||
|
await loadRecordings();
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
elements.refreshBtn.addEventListener('click', loadRecordings);
|
||||||
|
elements.searchInput.addEventListener('input', applyFilters);
|
||||||
|
elements.typeFilter.addEventListener('change', applyFilters);
|
||||||
|
elements.clearSearchBtn.addEventListener('click', () => {
|
||||||
|
elements.searchInput.value = '';
|
||||||
|
elements.typeFilter.value = 'all';
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
elements.recordingFile.addEventListener('change', () => {
|
||||||
|
const file = elements.recordingFile.files[0];
|
||||||
|
elements.fileNameText.textContent = file ? file.name : '选择 MP4 或 WebM 文件';
|
||||||
|
});
|
||||||
|
elements.uploadForm.addEventListener('submit', handleUpload);
|
||||||
|
elements.editForm.addEventListener('submit', handleEdit);
|
||||||
|
elements.closeEditBtn.addEventListener('click', closeEdit);
|
||||||
|
elements.cancelEditBtn.addEventListener('click', closeEdit);
|
||||||
|
elements.editDialog.addEventListener('click', (event) => {
|
||||||
|
if (event.target === elements.editDialog) {
|
||||||
|
closeEdit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.recordingsTableBody.addEventListener('click', (event) => {
|
||||||
|
const control = event.target.closest('[data-action]');
|
||||||
|
if (!control) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recording = findRecording(control.dataset.key);
|
||||||
|
const action = control.dataset.action;
|
||||||
|
if (action === 'select' || action === 'preview') {
|
||||||
|
selectRecording(recording);
|
||||||
|
if (action === 'preview') {
|
||||||
|
elements.previewVideo.play().catch(() => undefined);
|
||||||
|
}
|
||||||
|
} else if (action === 'edit') {
|
||||||
|
openEdit(recording);
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
deleteRecording(recording);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.previewDetails.addEventListener('click', (event) => {
|
||||||
|
const control = event.target.closest('[data-preview-action]');
|
||||||
|
if (control && control.dataset.previewAction === 'edit') {
|
||||||
|
openEdit(findRecording(state.selectedKey));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
|
loadRecordings();
|
||||||
@@ -9,6 +9,7 @@ import { buildStatsLogPayload, createAudioAnalyser, getAudioLevel } from './medi
|
|||||||
import { bindInviteSocketEvents, buildSocketUserInfoPayload, createSignalingInstance, ensureSignalingStarted, getActiveSignalingInstance, sendInviteSignal, sendSocketUserInfo } from './signaling-session.js';
|
import { bindInviteSocketEvents, buildSocketUserInfoPayload, createSignalingInstance, ensureSignalingStarted, getActiveSignalingInstance, sendInviteSignal, sendSocketUserInfo } from './signaling-session.js';
|
||||||
import { getNetworkQualityFromSummary, summarizeInboundStats } from './webrtc-stats.js';
|
import { getNetworkQualityFromSummary, summarizeInboundStats } from './webrtc-stats.js';
|
||||||
import { createLogger } from './logger.js';
|
import { createLogger } from './logger.js';
|
||||||
|
import { MeetingRecorder } from './meeting-recorder.js';
|
||||||
|
|
||||||
const logger = createLogger('store');
|
const logger = createLogger('store');
|
||||||
class CallStateManager {
|
class CallStateManager {
|
||||||
@@ -27,6 +28,7 @@ class CallStateManager {
|
|||||||
this.listeners = [];
|
this.listeners = [];
|
||||||
this.socketEventHandlers = {};
|
this.socketEventHandlers = {};
|
||||||
this._inviteEventSignaling = null;
|
this._inviteEventSignaling = null;
|
||||||
|
this.meetingRecorder = new MeetingRecorder();
|
||||||
}
|
}
|
||||||
subscribe(callback) {
|
subscribe(callback) {
|
||||||
this.listeners.push(callback);
|
this.listeners.push(callback);
|
||||||
@@ -107,6 +109,78 @@ class CallStateManager {
|
|||||||
await this._updateLocalMediaRefactored(mediaType, value);
|
await this._updateLocalMediaRefactored(mediaType, value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
async toggleRecording() {
|
||||||
|
const isRecording = this.state.session.localUser.mediaState.recording || false;
|
||||||
|
|
||||||
|
if (isRecording) {
|
||||||
|
return this.stopRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.startRecording();
|
||||||
|
}
|
||||||
|
async startRecording() {
|
||||||
|
if (this.state.session.status !== 'ongoing') {
|
||||||
|
throw new Error('会议连接成功后才能开始录制');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.meetingRecorder.start({
|
||||||
|
localStream: this.state.localStream,
|
||||||
|
remoteStream: this.state.remoteStream,
|
||||||
|
remoteStreams: this.state.remoteStreams,
|
||||||
|
connectionId: this.connectionId
|
||||||
|
});
|
||||||
|
await this._updateLocalMediaRefactored('recording', true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
recording: true,
|
||||||
|
message: '开始录制会议'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async stopRecording() {
|
||||||
|
const result = await this.meetingRecorder.stop();
|
||||||
|
await this._updateLocalMediaRefactored('recording', false);
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
recording: false,
|
||||||
|
message: '停止录制会议'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploadResult = await this.uploadRecording(result);
|
||||||
|
return {
|
||||||
|
recording: false,
|
||||||
|
message: uploadResult?.url ? `录制已上传到服务器:${uploadResult.url}` : `录制已上传:${result.filename}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logger.error('Recording upload failed:', error);
|
||||||
|
this.meetingRecorder.download(result.blob, result.filename);
|
||||||
|
return {
|
||||||
|
recording: false,
|
||||||
|
message: `上传失败,已下载到本地:${result.filename}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async uploadRecording({ blob, filename }) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('meetingId', this.connectionId || this.state.session.id || 'unknown');
|
||||||
|
formData.append('userId', this.state.session.localUser.id || '');
|
||||||
|
formData.append('filename', filename);
|
||||||
|
formData.append('recording', blob, filename);
|
||||||
|
|
||||||
|
const response = await fetch('/api/recordings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || responseBody.success === false) {
|
||||||
|
throw new Error(responseBody.message || 'Recording upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseBody;
|
||||||
|
}
|
||||||
async _updateLocalMediaRefactored(mediaType, value) {
|
async _updateLocalMediaRefactored(mediaType, value) {
|
||||||
if (mediaType === 'video' && value) {
|
if (mediaType === 'video' && value) {
|
||||||
await this._enableLocalVideo();
|
await this._enableLocalVideo();
|
||||||
@@ -423,6 +497,14 @@ class CallStateManager {
|
|||||||
this.startActivityDetection(this.state.remoteStream, { isLocal: false });
|
this.startActivityDetection(this.state.remoteStream, { isLocal: false });
|
||||||
}
|
}
|
||||||
async hangUp() {
|
async hangUp() {
|
||||||
|
if (this.meetingRecorder?.isRecording()) {
|
||||||
|
try {
|
||||||
|
await this.stopRecording();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logger.error('Error stopping recording before hangUp:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
this.clearStatsMessage();
|
this.clearStatsMessage();
|
||||||
this.stopNetworkQualityDetection();
|
this.stopNetworkQualityDetection();
|
||||||
if (this.durationInterval) {
|
if (this.durationInterval) {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
123
client/test/meeting-recorder.test.js
Normal file
123
client/test/meeting-recorder.test.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { MeetingRecorder } from '../public/meeting-recorder.js';
|
||||||
|
|
||||||
|
class MediaStreamMock {
|
||||||
|
constructor(tracks = []) {
|
||||||
|
this.tracks = tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTracks() {
|
||||||
|
return this.tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAudioTracks() {
|
||||||
|
return this.tracks.filter(track => track.kind === 'audio');
|
||||||
|
}
|
||||||
|
|
||||||
|
getVideoTracks() {
|
||||||
|
return this.tracks.filter(track => track.kind === 'video');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MediaRecorderMock {
|
||||||
|
static isTypeSupported() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(stream, options) {
|
||||||
|
this.stream = stream;
|
||||||
|
this.mimeType = options.mimeType;
|
||||||
|
this.state = 'inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.state = 'recording';
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.state = 'inactive';
|
||||||
|
this.ondataavailable({ data: new Blob(['recording'], { type: this.mimeType }) });
|
||||||
|
this.onstop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTrack(kind) {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
readyState: 'live',
|
||||||
|
stop: jest.fn(),
|
||||||
|
clone: jest.fn(() => createTrack(kind))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindowMock({ mediaRecorder = MediaRecorderMock } = {}) {
|
||||||
|
return {
|
||||||
|
MediaRecorder: mediaRecorder,
|
||||||
|
MediaStream: MediaStreamMock,
|
||||||
|
URL: {
|
||||||
|
createObjectURL: jest.fn(() => 'blob:recording'),
|
||||||
|
revokeObjectURL: jest.fn()
|
||||||
|
},
|
||||||
|
requestAnimationFrame: jest.fn(() => 1),
|
||||||
|
cancelAnimationFrame: jest.fn(),
|
||||||
|
setTimeout: jest.fn((callback) => {
|
||||||
|
callback();
|
||||||
|
return 1;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MeetingRecorder', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
|
||||||
|
fillStyle: '',
|
||||||
|
font: '',
|
||||||
|
textAlign: '',
|
||||||
|
textBaseline: '',
|
||||||
|
fillRect: jest.fn(),
|
||||||
|
fillText: jest.fn(),
|
||||||
|
drawImage: jest.fn()
|
||||||
|
}));
|
||||||
|
HTMLCanvasElement.prototype.captureStream = jest.fn(() => new MediaStreamMock([createTrack('video')]));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starts and stops recording a meeting file', async () => {
|
||||||
|
const windowRef = createWindowMock();
|
||||||
|
const recorder = new MeetingRecorder({ documentRef: document, windowRef });
|
||||||
|
const localVideo = document.createElement('video');
|
||||||
|
localVideo.id = 'localVideo';
|
||||||
|
localVideo.srcObject = new MediaStreamMock([createTrack('video')]);
|
||||||
|
Object.defineProperty(localVideo, 'readyState', { value: 2 });
|
||||||
|
Object.defineProperty(localVideo, 'videoWidth', { value: 640 });
|
||||||
|
Object.defineProperty(localVideo, 'videoHeight', { value: 360 });
|
||||||
|
localVideo.getBoundingClientRect = () => ({ width: 320, height: 180 });
|
||||||
|
document.body.appendChild(localVideo);
|
||||||
|
|
||||||
|
await recorder.start({
|
||||||
|
localStream: new MediaStreamMock([createTrack('audio')]),
|
||||||
|
connectionId: '123-456-789'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recorder.isRecording()).toBe(true);
|
||||||
|
|
||||||
|
const result = await recorder.stop();
|
||||||
|
|
||||||
|
expect(result.filename).toContain('meeting-recording-123-456-789');
|
||||||
|
expect(result.mimeType).toBe('video/mp4;codecs=avc1.42E01E,mp4a.40.2');
|
||||||
|
expect(result.filename).toMatch(/\.mp4$/);
|
||||||
|
expect(windowRef.URL.createObjectURL).not.toHaveBeenCalled();
|
||||||
|
expect(recorder.isRecording()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports unsupported browsers', async () => {
|
||||||
|
HTMLCanvasElement.prototype.captureStream = undefined;
|
||||||
|
const windowRef = createWindowMock({ mediaRecorder: undefined });
|
||||||
|
const recorder = new MeetingRecorder({ documentRef: document, windowRef });
|
||||||
|
|
||||||
|
await expect(recorder.start()).rejects.toThrow('当前浏览器不支持会议录制');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ import Offer from './offer';
|
|||||||
import Answer from './answer';
|
import Answer from './answer';
|
||||||
import Candidate from './candidate';
|
import Candidate from './candidate';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { onGetAllConnectionIds, onGetOnlineUsers as onGetWsOnlineUsers } from './websockethandler';
|
import { onGetAllConnectionIds, onGetOnlineUsers as onGetWsOnlineUsers, onGetRooms as onGetWsRooms } from './websockethandler';
|
||||||
import { log, LogLevel } from '../log';
|
import { log, LogLevel } from '../log';
|
||||||
/**
|
/**
|
||||||
* 断开连接记录类
|
* 断开连接记录类
|
||||||
@@ -1039,40 +1039,13 @@ function postCandidate(req: Request, res: Response): void {
|
|||||||
* description: 总房间数
|
* description: 总房间数
|
||||||
*/
|
*/
|
||||||
function onGetConnections(req: Request, res: Response): void {
|
function onGetConnections(req: Request, res: Response): void {
|
||||||
|
const connectionId = typeof req.query.connectionId === 'string' ? req.query.connectionId : undefined;
|
||||||
|
const wsRooms = onGetWsRooms(connectionId).map((room) => ({
|
||||||
|
...room,
|
||||||
|
users: room.members
|
||||||
|
}));
|
||||||
|
|
||||||
// 收集所有房间ID和链接用户信息
|
res.json({ rooms: wsRooms, totalRooms: wsRooms.length });
|
||||||
const rooms = [];
|
|
||||||
|
|
||||||
// 遍历所有连接对
|
|
||||||
for (const [connectionId, pair] of Array.from(connectionPair.entries())) {
|
|
||||||
// 收集房间中的用户信息
|
|
||||||
const users = [];
|
|
||||||
|
|
||||||
// 添加第一个用户
|
|
||||||
if (pair[0] && clients.has(pair[0])) {
|
|
||||||
users.push({
|
|
||||||
sessionId: pair[0],
|
|
||||||
connected: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加第二个用户
|
|
||||||
if (pair[1] && clients.has(pair[1])) {
|
|
||||||
users.push({
|
|
||||||
sessionId: pair[1],
|
|
||||||
connected: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加房间信息
|
|
||||||
rooms.push({
|
|
||||||
roomId: connectionId,
|
|
||||||
users: users,
|
|
||||||
userCount: users.length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ rooms: rooms, totalRooms: rooms.length });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -53,12 +53,37 @@ interface OnlineUser {
|
|||||||
avatar: string;
|
avatar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RoomMemberInfo extends OnlineUser {
|
||||||
|
joinedAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomSnapshot {
|
||||||
|
roomId: string;
|
||||||
|
connectionId: string;
|
||||||
|
hostSocketId: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
members: RoomMemberInfo[];
|
||||||
|
userCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredRoom {
|
||||||
|
roomId: string;
|
||||||
|
connectionId: string;
|
||||||
|
hostSocketId: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
members: Map<string, RoomMemberInfo>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 连接组映射
|
* 连接组映射
|
||||||
* 键: connectionId
|
* 键: connectionId
|
||||||
* 值: ConnectionGroup(1个host + 多个participants)
|
* 值: ConnectionGroup(1个host + 多个participants)
|
||||||
*/
|
*/
|
||||||
const connectionGroup: Map<string, ConnectionGroup> = new Map<string, ConnectionGroup>();
|
const connectionGroup: Map<string, ConnectionGroup> = new Map<string, ConnectionGroup>();
|
||||||
|
const rooms: Map<string, StoredRoom> = new Map<string, StoredRoom>();
|
||||||
|
|
||||||
function asAppWebSocket(ws: WebSocket): AppWebSocket {
|
function asAppWebSocket(ws: WebSocket): AppWebSocket {
|
||||||
return ws as AppWebSocket;
|
return ws as AppWebSocket;
|
||||||
@@ -89,7 +114,12 @@ function getUserInfo(ws: WebSocket): UserInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setUserInfo(ws: WebSocket, userInfo: UserInfo): void {
|
function setUserInfo(ws: WebSocket, userInfo: UserInfo): void {
|
||||||
asAppWebSocket(ws).userInfo = userInfo;
|
asAppWebSocket(ws).userInfo = {
|
||||||
|
id: userInfo.id || '',
|
||||||
|
name: userInfo.name || '',
|
||||||
|
avatar: userInfo.avatar || ''
|
||||||
|
};
|
||||||
|
updateRoomMembersForSocket(ws);
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeSend(ws: WebSocket, payload: unknown): boolean {
|
function safeSend(ws: WebSocket, payload: unknown): boolean {
|
||||||
@@ -111,6 +141,106 @@ function findParticipantSocket(group: ConnectionGroup, participantId: string): W
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSocketRoleInRoom(ws: WebSocket, connectionId: string): 'host' | 'participant' | 'idle' {
|
||||||
|
const group = connectionGroup.get(connectionId);
|
||||||
|
if (group) {
|
||||||
|
if (group.host === ws) {
|
||||||
|
return 'host';
|
||||||
|
}
|
||||||
|
if (group.participants.has(ws)) {
|
||||||
|
return 'participant';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isPrivate ? 'idle' : 'participant';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRoomMember(ws: WebSocket, connectionId: string, existing?: RoomMemberInfo): RoomMemberInfo {
|
||||||
|
const userInfo = getUserInfo(ws);
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
socketId: ensureSocketId(ws),
|
||||||
|
connectionId,
|
||||||
|
participantId: ensureParticipantId(ws),
|
||||||
|
role: getSocketRoleInRoom(ws, connectionId),
|
||||||
|
userId: userInfo.id || '',
|
||||||
|
name: userInfo.name || '',
|
||||||
|
avatar: userInfo.avatar || '',
|
||||||
|
joinedAt: existing ? existing.joinedAt : now,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateRoom(connectionId: string, ws: WebSocket): StoredRoom {
|
||||||
|
let room = rooms.get(connectionId);
|
||||||
|
const now = Date.now();
|
||||||
|
const socketId = ensureSocketId(ws);
|
||||||
|
if (!room) {
|
||||||
|
room = {
|
||||||
|
roomId: connectionId,
|
||||||
|
connectionId,
|
||||||
|
hostSocketId: '',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
members: new Map<string, RoomMemberInfo>()
|
||||||
|
};
|
||||||
|
rooms.set(connectionId, room);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room.hostSocketId || getSocketRoleInRoom(ws, connectionId) === 'host') {
|
||||||
|
room.hostSocketId = socketId;
|
||||||
|
}
|
||||||
|
room.updatedAt = now;
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRoomMember(ws: WebSocket, connectionId: string): void {
|
||||||
|
const room = getOrCreateRoom(connectionId, ws);
|
||||||
|
const socketId = ensureSocketId(ws);
|
||||||
|
const existing = room.members.get(socketId);
|
||||||
|
room.members.set(socketId, toRoomMember(ws, connectionId, existing));
|
||||||
|
room.updatedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRoomMembersForSocket(ws: WebSocket): void {
|
||||||
|
const connectionIds = clients.get(ws);
|
||||||
|
if (!connectionIds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionIds.forEach(connectionId => {
|
||||||
|
if (rooms.has(connectionId)) {
|
||||||
|
saveRoomMember(ws, connectionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRoomMember(ws: WebSocket, connectionId: string): void {
|
||||||
|
const room = rooms.get(connectionId);
|
||||||
|
if (!room) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
room.members.delete(getSocketId(ws));
|
||||||
|
room.updatedAt = Date.now();
|
||||||
|
if (room.members.size === 0 || room.hostSocketId === getSocketId(ws)) {
|
||||||
|
rooms.delete(connectionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRoomSnapshot(room: StoredRoom): RoomSnapshot {
|
||||||
|
const members = Array.from(room.members.values());
|
||||||
|
return {
|
||||||
|
roomId: room.roomId,
|
||||||
|
connectionId: room.connectionId,
|
||||||
|
hostSocketId: room.hostSocketId,
|
||||||
|
createdAt: room.createdAt,
|
||||||
|
updatedAt: room.updatedAt,
|
||||||
|
members,
|
||||||
|
userCount: members.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取或创建WebSocket会话的连接ID集合
|
* 获取或创建WebSocket会话的连接ID集合
|
||||||
* @param session WebSocket会话实例
|
* @param session WebSocket会话实例
|
||||||
@@ -140,6 +270,7 @@ function reset(mode: string): void {
|
|||||||
isPrivate = mode == "private";
|
isPrivate = mode == "private";
|
||||||
clients.clear();
|
clients.clear();
|
||||||
connectionGroup.clear();
|
connectionGroup.clear();
|
||||||
|
rooms.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,12 +332,16 @@ function remove(ws: WebSocket): void {
|
|||||||
group.participants.forEach(participantWs => {
|
group.participants.forEach(participantWs => {
|
||||||
safeSend(participantWs, { type: "disconnect", connectionId: connectionId, reason: "host-left" });
|
safeSend(participantWs, { type: "disconnect", connectionId: connectionId, reason: "host-left" });
|
||||||
});
|
});
|
||||||
|
rooms.delete(connectionId);
|
||||||
connectionGroup.delete(connectionId);
|
connectionGroup.delete(connectionId);
|
||||||
} else {
|
} else {
|
||||||
group.participants.delete(ws);
|
group.participants.delete(ws);
|
||||||
|
removeRoomMember(ws, connectionId);
|
||||||
// 包含participantId,让host能识别是哪个participant离开
|
// 包含participantId,让host能识别是哪个participant离开
|
||||||
safeSend(group.host, { type: "participant-left", connectionId: connectionId, participantId: getParticipantId(ws) });
|
safeSend(group.host, { type: "participant-left", connectionId: connectionId, participantId: getParticipantId(ws) });
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
removeRoomMember(ws, connectionId);
|
||||||
}
|
}
|
||||||
log(LogLevel.log, `Remove connectionId: ${connectionId}`);
|
log(LogLevel.log, `Remove connectionId: ${connectionId}`);
|
||||||
});
|
});
|
||||||
@@ -242,6 +377,7 @@ function onConnect(ws: WebSocket, connectionId: string): void {
|
|||||||
const connectionIds = getOrCreateConnectionIds(ws);
|
const connectionIds = getOrCreateConnectionIds(ws);
|
||||||
connectionIds.add(connectionId);
|
connectionIds.add(connectionId);
|
||||||
const role = polite ? 'participant' : 'host';
|
const role = polite ? 'participant' : 'host';
|
||||||
|
saveRoomMember(ws, connectionId);
|
||||||
safeSend(ws, { type: "connect", connectionId: connectionId, polite: polite, role: role, participantId: participantId });
|
safeSend(ws, { type: "connect", connectionId: connectionId, polite: polite, role: role, participantId: participantId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,14 +402,18 @@ function onDisconnect(ws: WebSocket, connectionId: string): void {
|
|||||||
group.participants.forEach(participantWs => {
|
group.participants.forEach(participantWs => {
|
||||||
safeSend(participantWs, { type: "disconnect", connectionId: connectionId, reason: "host-left" });
|
safeSend(participantWs, { type: "disconnect", connectionId: connectionId, reason: "host-left" });
|
||||||
});
|
});
|
||||||
|
rooms.delete(connectionId);
|
||||||
connectionGroup.delete(connectionId);
|
connectionGroup.delete(connectionId);
|
||||||
log(LogLevel.log, `Host disconnected, room ${connectionId} deleted, notified ${group.participants.size} participants`);
|
log(LogLevel.log, `Host disconnected, room ${connectionId} deleted, notified ${group.participants.size} participants`);
|
||||||
} else {
|
} else {
|
||||||
// participant断开连接,从组中移除并通知host(使用participant-left类型,host不会关闭房间)
|
// participant断开连接,从组中移除并通知host(使用participant-left类型,host不会关闭房间)
|
||||||
group.participants.delete(ws);
|
group.participants.delete(ws);
|
||||||
|
removeRoomMember(ws, connectionId);
|
||||||
safeSend(group.host, { type: "participant-left", connectionId: connectionId, participantId: getParticipantId(ws) });
|
safeSend(group.host, { type: "participant-left", connectionId: connectionId, participantId: getParticipantId(ws) });
|
||||||
log(LogLevel.log, `Participant left connectionId: ${connectionId}, remaining participants: ${group.participants.size}`);
|
log(LogLevel.log, `Participant left connectionId: ${connectionId}, remaining participants: ${group.participants.size}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
removeRoomMember(ws, connectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 向当前连接发送断开连接消息
|
// 向当前连接发送断开连接消息
|
||||||
@@ -328,6 +468,7 @@ function onOffer(ws: WebSocket, message: any): void {
|
|||||||
if (!connectionGroup.has(connectionId)) {
|
if (!connectionGroup.has(connectionId)) {
|
||||||
connectionGroup.set(connectionId, { host: ws, participants: new Set<WebSocket>() });
|
connectionGroup.set(connectionId, { host: ws, participants: new Set<WebSocket>() });
|
||||||
}
|
}
|
||||||
|
saveRoomMember(ws, connectionId);
|
||||||
// 向所有其他客户端广播offer
|
// 向所有其他客户端广播offer
|
||||||
clients.forEach((_v, k) => {
|
clients.forEach((_v, k) => {
|
||||||
if (k == ws) {
|
if (k == ws) {
|
||||||
@@ -570,8 +711,11 @@ function RemoveHeartbeat(ws: WebSocket) {
|
|||||||
*/
|
*/
|
||||||
function onGetAllConnectionIds(): string[] {
|
function onGetAllConnectionIds(): string[] {
|
||||||
// 获取所有connectionId
|
// 获取所有connectionId
|
||||||
const connectionIds = Array.from(connectionGroup.keys());
|
const connectionIds = new Set<string>(Array.from(connectionGroup.keys()));
|
||||||
return connectionIds;
|
rooms.forEach((_room, connectionId) => {
|
||||||
|
connectionIds.add(connectionId);
|
||||||
|
});
|
||||||
|
return Array.from(connectionIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -629,6 +773,17 @@ function onGetOnlineUsers(connectionId?: string): OnlineUser[] {
|
|||||||
return onlineUsers;
|
return onlineUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onGetRooms(connectionId?: string): RoomSnapshot[] {
|
||||||
|
const roomSnapshots: RoomSnapshot[] = [];
|
||||||
|
rooms.forEach((room, roomConnectionId) => {
|
||||||
|
if (connectionId && roomConnectionId !== connectionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
roomSnapshots.push(toRoomSnapshot(room));
|
||||||
|
});
|
||||||
|
return roomSnapshots;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理chat-message信令(1对多模式)
|
* 处理chat-message信令(1对多模式)
|
||||||
* host的消息转发给所有participants,participant的消息转发给host
|
* host的消息转发给所有participants,participant的消息转发给host
|
||||||
@@ -677,5 +832,5 @@ function onMessage(ws: WebSocket, message: any): void {
|
|||||||
* 导出WebSocket处理器函数
|
* 导出WebSocket处理器函数
|
||||||
*/
|
*/
|
||||||
export { reset, add, remove, onConnect, onDisconnect, onOffer, onAnswer, onCandidate, onCallConnectionId,
|
export { reset, add, remove, onConnect, onDisconnect, onOffer, onAnswer, onCandidate, onCallConnectionId,
|
||||||
onBroadcast, onGetAllConnectionIds, onGetOnlineUsers, AddHeartbeat, RemoveHeartbeat, onMessage, isHost,
|
onBroadcast, onGetAllConnectionIds, onGetOnlineUsers, onGetRooms, AddHeartbeat, RemoveHeartbeat, onMessage, isHost,
|
||||||
broadcastToGroup, connectionGroup, onHostUserInfo, onInviteCall };
|
broadcastToGroup, connectionGroup, onHostUserInfo, onInviteCall };
|
||||||
|
|||||||
494
src/server.ts
494
src/server.ts
@@ -15,6 +15,21 @@ const multer = require('multer');
|
|||||||
const AVATAR_UPLOAD_LIMIT_BYTES = 2 * 1024 * 1024;
|
const AVATAR_UPLOAD_LIMIT_BYTES = 2 * 1024 * 1024;
|
||||||
const ALLOWED_AVATAR_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
const ALLOWED_AVATAR_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||||
const ALLOWED_AVATAR_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
|
const ALLOWED_AVATAR_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
|
||||||
|
const DEFAULT_RECORDING_UPLOAD_LIMIT_BYTES = 2 * 1024 * 1024 * 1024;
|
||||||
|
const ALLOWED_RECORDING_MIME_TYPES = new Set(['video/webm', 'video/mp4', 'application/octet-stream']);
|
||||||
|
const ALLOWED_RECORDING_EXTENSIONS = new Set(['.webm', '.mp4']);
|
||||||
|
|
||||||
|
type RecordingMetadata = {
|
||||||
|
id?: string;
|
||||||
|
meetingId?: string;
|
||||||
|
filename?: string;
|
||||||
|
originalFilename?: string;
|
||||||
|
mimetype?: string;
|
||||||
|
size?: number;
|
||||||
|
userId?: string;
|
||||||
|
uploadedAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function safeAvatarExtension(file: any): string {
|
function safeAvatarExtension(file: any): string {
|
||||||
const originalExt = path.extname(file.originalname || '').toLowerCase();
|
const originalExt = path.extname(file.originalname || '').toLowerCase();
|
||||||
@@ -40,6 +55,135 @@ function isAllowedAvatar(file: any): boolean {
|
|||||||
return ALLOWED_AVATAR_MIME_TYPES.has(file.mimetype) && safeAvatarExtension(file).length > 0;
|
return ALLOWED_AVATAR_MIME_TYPES.has(file.mimetype) && safeAvatarExtension(file).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRecordingRoot(): string {
|
||||||
|
return path.resolve(process.env.RECORDING_DIR || path.join(process.cwd(), 'recordings'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordingUploadLimitBytes(): number {
|
||||||
|
const value = Number(process.env.RECORDING_MAX_BYTES);
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : DEFAULT_RECORDING_UPLOAD_LIMIT_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePathSegment(value: string | undefined, fallback: string): string {
|
||||||
|
const sanitized = (value || fallback).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120);
|
||||||
|
return sanitized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInside(parent: string, child: string): boolean {
|
||||||
|
const relative = path.relative(parent, child);
|
||||||
|
return relative.length === 0 || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRecordingExtension(file: any): string {
|
||||||
|
const originalExt = path.extname(file.originalname || '').toLowerCase();
|
||||||
|
if (ALLOWED_RECORDING_EXTENSIONS.has(originalExt)) {
|
||||||
|
return originalExt;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (normalizeMimeType(file.mimetype)) {
|
||||||
|
case 'video/mp4':
|
||||||
|
return '.mp4';
|
||||||
|
case 'video/webm':
|
||||||
|
return '.webm';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMimeType(mimetype: string | undefined): string {
|
||||||
|
return (mimetype || '').split(';')[0].trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedRecording(file: any): boolean {
|
||||||
|
const mimetype = normalizeMimeType(file.mimetype);
|
||||||
|
const ext = safeRecordingExtension(file);
|
||||||
|
const isCompatibleMime = ALLOWED_RECORDING_MIME_TYPES.has(mimetype) || mimetype.startsWith('video/') || mimetype === 'text/plain' || mimetype === '';
|
||||||
|
return ext.length > 0 && isCompatibleMime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecordingMetadata(metadataPath: string): RecordingMetadata {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(metadataPath)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
log(LogLevel.warn, 'Failed to read recording metadata:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordingMimeType(filename: string, metadata: RecordingMetadata): string {
|
||||||
|
if (metadata.mimetype) {
|
||||||
|
return metadata.mimetype;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.extname(filename).toLowerCase() === '.mp4' ? 'video/mp4' : 'video/webm';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecordingInfo(recordingRoot: string, meetingId: string, filename: string) {
|
||||||
|
const filePath = path.join(recordingRoot, meetingId, filename);
|
||||||
|
const metadataPath = path.join(recordingRoot, meetingId, `${filename}.json`);
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
const metadata = readRecordingMetadata(metadataPath);
|
||||||
|
const resolvedMeetingId = metadata.meetingId || meetingId;
|
||||||
|
const resolvedFilename = metadata.filename || filename;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: metadata.id || path.basename(filename, path.extname(filename)),
|
||||||
|
meetingId: resolvedMeetingId,
|
||||||
|
filename: resolvedFilename,
|
||||||
|
originalFilename: metadata.originalFilename || filename,
|
||||||
|
mimetype: getRecordingMimeType(filename, metadata),
|
||||||
|
size: stat.size,
|
||||||
|
userId: metadata.userId || '',
|
||||||
|
uploadedAt: metadata.uploadedAt || stat.birthtime.toISOString(),
|
||||||
|
updatedAt: metadata.updatedAt || stat.mtime.toISOString(),
|
||||||
|
modifiedAt: stat.mtime.toISOString(),
|
||||||
|
downloadUrl: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(filename)}/download`,
|
||||||
|
streamUrl: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(filename)}/stream`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listRecordings(recordingRoot: string) {
|
||||||
|
if (!fs.existsSync(recordingRoot)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordings = [];
|
||||||
|
const meetingIds = fs.readdirSync(recordingRoot).filter((name) => {
|
||||||
|
const fullPath = path.join(recordingRoot, name);
|
||||||
|
return name !== '.tmp' && fs.statSync(fullPath).isDirectory();
|
||||||
|
});
|
||||||
|
|
||||||
|
meetingIds.forEach((meetingId) => {
|
||||||
|
const meetingDir = path.join(recordingRoot, meetingId);
|
||||||
|
fs.readdirSync(meetingDir).forEach((filename) => {
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
const filePath = path.join(meetingDir, filename);
|
||||||
|
if (!ALLOWED_RECORDING_EXTENSIONS.has(ext) || !fs.statSync(filePath).isFile()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordings.push(buildRecordingInfo(recordingRoot, meetingId, filename));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
recordings.sort((a, b) => Date.parse(b.uploadedAt) - Date.parse(a.uploadedAt));
|
||||||
|
return recordings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEmptyDirectory(directory: string): void {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(directory) && fs.readdirSync(directory).length === 0) {
|
||||||
|
fs.rmdirSync(directory);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(LogLevel.warn, 'Failed to remove empty recording directory:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const createServer = (config: Options): express.Express => {
|
export const createServer = (config: Options): express.Express => {
|
||||||
const app: express.Express = express();
|
const app: express.Express = express();
|
||||||
resetHandler(config.mode);
|
resetHandler(config.mode);
|
||||||
@@ -61,6 +205,20 @@ export const createServer = (config: Options): express.Express => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
app.use('/signaling', signaling);
|
app.use('/signaling', signaling);
|
||||||
|
|
||||||
|
app.get(['/recordings', '/recordings/'], (_req, res) => {
|
||||||
|
const recordingsPagePath = path.join(__dirname, '../client/public/recordings/index.html');
|
||||||
|
fs.access(recordingsPagePath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
log(LogLevel.warn, `Can't find file '${recordingsPagePath}'`);
|
||||||
|
res.status(404).send(`Can't find file ${recordingsPagePath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.sendFile(recordingsPagePath);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, '../client/public')));
|
app.use(express.static(path.join(__dirname, '../client/public')));
|
||||||
app.use('/module', express.static(path.join(__dirname, '../client/src')));
|
app.use('/module', express.static(path.join(__dirname, '../client/src')));
|
||||||
|
|
||||||
@@ -148,6 +306,342 @@ export const createServer = (config: Options): express.Express => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const recordingRoot = getRecordingRoot();
|
||||||
|
const recordingTempDir = path.join(recordingRoot, '.tmp');
|
||||||
|
const recordingStorage = multer.diskStorage({
|
||||||
|
destination: (_req: any, _file: any, cb: (error: Error | null, destination: string) => void) => {
|
||||||
|
if (!fs.existsSync(recordingTempDir)) {
|
||||||
|
fs.mkdirSync(recordingTempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
cb(null, recordingTempDir);
|
||||||
|
},
|
||||||
|
filename: (_req: any, file: any, cb: (error: Error | null, filename: string) => void) => {
|
||||||
|
cb(null, `${uuid()}${safeRecordingExtension(file)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const recordingUpload = multer({
|
||||||
|
storage: recordingStorage,
|
||||||
|
limits: {
|
||||||
|
fileSize: getRecordingUploadLimitBytes()
|
||||||
|
},
|
||||||
|
fileFilter: (_req: express.Request, file: any, cb: (error: Error | null, acceptFile?: boolean) => void) => {
|
||||||
|
if (!isAllowedRecording(file)) {
|
||||||
|
log(LogLevel.warn, 'Recording upload rejected by type filter:', {
|
||||||
|
originalname: file.originalname,
|
||||||
|
mimetype: file.mimetype,
|
||||||
|
normalizedMimetype: normalizeMimeType(file.mimetype),
|
||||||
|
extension: safeRecordingExtension(file)
|
||||||
|
});
|
||||||
|
cb(new Error('Only mp4 or webm recordings are allowed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(null, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/recordings', (_req: express.Request, res: express.Response) => {
|
||||||
|
try {
|
||||||
|
const recordings = listRecordings(recordingRoot);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
recordings,
|
||||||
|
totalCount: recordings.length,
|
||||||
|
root: recordingRoot
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log(LogLevel.error, 'Error listing recordings:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to list recordings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/recordings', (req: express.Request, res: express.Response) => {
|
||||||
|
recordingUpload.single('recording')(req, res, (error: Error) => {
|
||||||
|
if (error) {
|
||||||
|
log(LogLevel.warn, 'Recording upload rejected:', error.message);
|
||||||
|
const isSizeLimit = error.name === 'MulterError' && (error as any).code === 'LIMIT_FILE_SIZE';
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: isSizeLimit ? 'Recording file is too large' : error.message
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = req as any;
|
||||||
|
if (!request.file) {
|
||||||
|
res.status(400).json({ success: false, message: 'No recording uploaded' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = safeRecordingExtension(request.file);
|
||||||
|
if (!ext) {
|
||||||
|
fs.unlink(request.file.path, () => undefined);
|
||||||
|
res.status(400).json({ success: false, message: 'Unsupported recording file type' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordingId = uuid();
|
||||||
|
const meetingId = sanitizePathSegment(request.body.meetingId, 'unknown');
|
||||||
|
const originalFilename = path.basename(request.body.filename || request.file.originalname || `recording${ext}`);
|
||||||
|
const finalFilename = `${new Date().toISOString().replace(/[:.]/g, '-')}-${recordingId}${ext}`;
|
||||||
|
const meetingDir = path.join(recordingRoot, meetingId);
|
||||||
|
const finalPath = path.join(meetingDir, finalFilename);
|
||||||
|
|
||||||
|
if (!isPathInside(recordingRoot, finalPath)) {
|
||||||
|
fs.unlink(request.file.path, () => undefined);
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid recording path' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdir(meetingDir, { recursive: true }, (mkdirError) => {
|
||||||
|
if (mkdirError) {
|
||||||
|
fs.unlink(request.file.path, () => undefined);
|
||||||
|
log(LogLevel.error, 'Error creating recording directory:', mkdirError);
|
||||||
|
res.status(500).json({ success: false, message: 'Recording directory unavailable' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rename(request.file.path, finalPath, (renameError) => {
|
||||||
|
if (renameError) {
|
||||||
|
fs.unlink(request.file.path, () => undefined);
|
||||||
|
log(LogLevel.error, 'Error saving recording:', renameError);
|
||||||
|
res.status(500).json({ success: false, message: 'Recording save failed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
id: recordingId,
|
||||||
|
meetingId,
|
||||||
|
filename: finalFilename,
|
||||||
|
originalFilename,
|
||||||
|
mimetype: normalizeMimeType(request.file.mimetype),
|
||||||
|
size: request.file.size,
|
||||||
|
userId: request.body.userId || '',
|
||||||
|
uploadedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
fs.writeFile(path.join(meetingDir, `${finalFilename}.json`), JSON.stringify(metadata, null, 2), () => undefined);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
recordingId,
|
||||||
|
meetingId,
|
||||||
|
filename: finalFilename,
|
||||||
|
originalFilename,
|
||||||
|
size: request.file.size,
|
||||||
|
url: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(finalFilename)}/download`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/recordings/:meetingId/:filename', (req: express.Request, res: express.Response) => {
|
||||||
|
const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown');
|
||||||
|
const filename = sanitizePathSegment(req.params.filename, '');
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
|
||||||
|
if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid recording filename' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(recordingRoot, meetingId, filename);
|
||||||
|
if (!isPathInside(recordingRoot, filePath) || !fs.existsSync(filePath)) {
|
||||||
|
res.status(404).json({ success: false, message: 'Recording not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
res.json({ success: true, recording: buildRecordingInfo(recordingRoot, meetingId, filename) });
|
||||||
|
} catch (error) {
|
||||||
|
log(LogLevel.error, 'Error reading recording:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to read recording' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/recordings/:meetingId/:filename', (req: express.Request, res: express.Response) => {
|
||||||
|
const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown');
|
||||||
|
const filename = sanitizePathSegment(req.params.filename, '');
|
||||||
|
const nextMeetingId = sanitizePathSegment(req.body.meetingId, meetingId);
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
|
||||||
|
if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid recording filename' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceDir = path.join(recordingRoot, meetingId);
|
||||||
|
const sourcePath = path.join(sourceDir, filename);
|
||||||
|
const sourceMetadataPath = path.join(sourceDir, `${filename}.json`);
|
||||||
|
const targetDir = path.join(recordingRoot, nextMeetingId);
|
||||||
|
const targetPath = path.join(targetDir, filename);
|
||||||
|
const targetMetadataPath = path.join(targetDir, `${filename}.json`);
|
||||||
|
|
||||||
|
if (!isPathInside(recordingRoot, sourcePath) || !isPathInside(recordingRoot, targetPath)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid recording path' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(sourcePath)) {
|
||||||
|
res.status(404).json({ success: false, message: 'Recording not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourcePath !== targetPath && fs.existsSync(targetPath)) {
|
||||||
|
res.status(409).json({ success: false, message: 'Recording already exists in target meeting' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(targetDir)) {
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourcePath !== targetPath) {
|
||||||
|
fs.renameSync(sourcePath, targetPath);
|
||||||
|
if (fs.existsSync(sourceMetadataPath)) {
|
||||||
|
fs.renameSync(sourceMetadataPath, targetMetadataPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = readRecordingMetadata(targetMetadataPath);
|
||||||
|
const nextMetadata = {
|
||||||
|
...metadata,
|
||||||
|
meetingId: nextMeetingId,
|
||||||
|
filename,
|
||||||
|
originalFilename: typeof req.body.originalFilename === 'string' && req.body.originalFilename.trim()
|
||||||
|
? path.basename(req.body.originalFilename.trim())
|
||||||
|
: metadata.originalFilename || filename,
|
||||||
|
userId: typeof req.body.userId === 'string' ? req.body.userId.trim() : metadata.userId || '',
|
||||||
|
mimetype: metadata.mimetype || (ext === '.mp4' ? 'video/mp4' : 'video/webm'),
|
||||||
|
size: fs.statSync(targetPath).size,
|
||||||
|
uploadedAt: metadata.uploadedAt || fs.statSync(targetPath).birthtime.toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(targetMetadataPath, JSON.stringify(nextMetadata, null, 2));
|
||||||
|
if (sourcePath !== targetPath) {
|
||||||
|
removeEmptyDirectory(sourceDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, recording: buildRecordingInfo(recordingRoot, nextMeetingId, filename) });
|
||||||
|
} catch (error) {
|
||||||
|
log(LogLevel.error, 'Error updating recording:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to update recording' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/recordings/:meetingId/:filename', (req: express.Request, res: express.Response) => {
|
||||||
|
const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown');
|
||||||
|
const filename = sanitizePathSegment(req.params.filename, '');
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
|
||||||
|
if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid recording filename' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meetingDir = path.join(recordingRoot, meetingId);
|
||||||
|
const filePath = path.join(meetingDir, filename);
|
||||||
|
const metadataPath = path.join(meetingDir, `${filename}.json`);
|
||||||
|
if (!isPathInside(recordingRoot, filePath)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid recording path' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
res.status(404).json({ success: false, message: 'Recording not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
if (fs.existsSync(metadataPath)) {
|
||||||
|
fs.unlinkSync(metadataPath);
|
||||||
|
}
|
||||||
|
removeEmptyDirectory(meetingDir);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
log(LogLevel.error, 'Error deleting recording:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to delete recording' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/recordings/:meetingId/:filename/stream', (req: express.Request, res: express.Response) => {
|
||||||
|
const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown');
|
||||||
|
const filename = sanitizePathSegment(req.params.filename, '');
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
|
||||||
|
if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid recording filename' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(recordingRoot, meetingId, filename);
|
||||||
|
if (!isPathInside(recordingRoot, filePath) || !fs.existsSync(filePath)) {
|
||||||
|
res.status(404).json({ success: false, message: 'Recording not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
const range = req.headers.range;
|
||||||
|
const contentType = ext === '.mp4' ? 'video/mp4' : 'video/webm';
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Length': stat.size,
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Accept-Ranges': 'bytes'
|
||||||
|
});
|
||||||
|
fs.createReadStream(filePath).pipe(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = range.replace(/bytes=/, '').split('-');
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
|
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
|
||||||
|
|
||||||
|
if (Number.isNaN(start) || Number.isNaN(end) || start >= stat.size || end >= stat.size || start > end) {
|
||||||
|
res.status(416).set('Content-Range', `bytes */${stat.size}`).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(206, {
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': end - start + 1,
|
||||||
|
'Content-Type': contentType
|
||||||
|
});
|
||||||
|
fs.createReadStream(filePath, { start, end }).pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/recordings/:meetingId/:filename/download', (req: express.Request, res: express.Response) => {
|
||||||
|
const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown');
|
||||||
|
const filename = sanitizePathSegment(req.params.filename, '');
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
|
||||||
|
if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid recording filename' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(recordingRoot, meetingId, filename);
|
||||||
|
if (!isPathInside(recordingRoot, filePath)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid recording path' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.access(filePath, fs.constants.R_OK, (accessError) => {
|
||||||
|
if (accessError) {
|
||||||
|
res.status(404).json({ success: false, message: 'Recording not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.download(filePath, filename);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.use('/uploads', express.static(path.join(__dirname, '../client/public/uploads')));
|
app.use('/uploads', express.static(path.join(__dirname, '../client/public/uploads')));
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const router: express.Router = express.Router();
|
|||||||
// 不需要会话ID的路由
|
// 不需要会话ID的路由
|
||||||
router.get('/connection-ids', handler.getAllConnectionIds);
|
router.get('/connection-ids', handler.getAllConnectionIds);
|
||||||
router.get('/users', handler.getOnlineUsers);
|
router.get('/users', handler.getOnlineUsers);
|
||||||
|
router.get('/rooms', handler.onGetConnections);
|
||||||
|
|
||||||
// 需要会话ID的路由
|
// 需要会话ID的路由
|
||||||
router.use(handler.checkSessionId);
|
router.use(handler.checkSessionId);
|
||||||
|
|||||||
@@ -161,6 +161,35 @@ describe('websocket signaling test in private mode', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('save room and member info', async () => {
|
||||||
|
wsHandler.onHostUserInfo(client, { id: 'host-user', name: 'Host User', avatar: '/host.png' });
|
||||||
|
wsHandler.onHostUserInfo(client2, { id: 'guest-user', name: 'Guest User', avatar: '/guest.png' });
|
||||||
|
|
||||||
|
expect(wsHandler.onGetRooms()).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
roomId: connectionId,
|
||||||
|
connectionId: connectionId,
|
||||||
|
userCount: 2,
|
||||||
|
members: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
connectionId: connectionId,
|
||||||
|
role: 'host',
|
||||||
|
userId: 'host-user',
|
||||||
|
name: 'Host User',
|
||||||
|
avatar: '/host.png'
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
connectionId: connectionId,
|
||||||
|
role: 'participant',
|
||||||
|
userId: 'guest-user',
|
||||||
|
name: 'Guest User',
|
||||||
|
avatar: '/guest.png'
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('send offer from session1', async () => {
|
test('send offer from session1', async () => {
|
||||||
await wsHandler.onOffer(client, { connectionId: connectionId, sdp: testsdp });
|
await wsHandler.onOffer(client, { connectionId: connectionId, sdp: testsdp });
|
||||||
const receiveOffer = new Offer(testsdp, Date.now(), true);
|
const receiveOffer = new Offer(testsdp, Date.now(), true);
|
||||||
|
|||||||
Reference in New Issue
Block a user