Revamp voice channel layout
This commit is contained in:
parent
5f0e6a2b79
commit
573abae5e3
763
assets/main.css
763
assets/main.css
@ -1,322 +1,543 @@
|
|||||||
/* Basis-Styling */
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #1e1f22;
|
||||||
|
--bg-secondary: #2b2d31;
|
||||||
|
--bg-tertiary: #313338;
|
||||||
|
--bg-overlay: rgba(0, 0, 0, 0.25);
|
||||||
|
--border: rgba(255, 255, 255, 0.07);
|
||||||
|
--accent: #5865f2;
|
||||||
|
--accent-secondary: #43b581;
|
||||||
|
--danger: #f23f42;
|
||||||
|
--text-primary: #f2f3f5;
|
||||||
|
--text-secondary: #b5bac1;
|
||||||
|
--text-muted: #8b929e;
|
||||||
|
--tile-muted-bg: rgba(255, 255, 255, 0.04);
|
||||||
|
--tile-speaking: rgba(67, 181, 129, 0.35);
|
||||||
|
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
--radius-lg: 18px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Inter', 'Segoe UI', sans-serif;
|
||||||
background-color: #f5f5f5;
|
background: radial-gradient(circle at top left, rgba(88, 101, 242, 0.18), transparent 35%),
|
||||||
color: #333;
|
radial-gradient(circle at bottom right, rgba(235, 69, 158, 0.12), transparent 30%),
|
||||||
line-height: 1.6;
|
var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-container {
|
.voice-channel {
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
padding: 20px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
header h1 {
|
|
||||||
color: #2563eb;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header p {
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main Content */
|
|
||||||
.main-content {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 20px;
|
grid-template-columns: 320px 1fr;
|
||||||
grid-template-columns: 1fr;
|
grid-template-rows: 1fr auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"sidebar main"
|
||||||
|
"sidebar controls";
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-sidebar {
|
||||||
|
grid-area: sidebar;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 32px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-sidebar__head h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-sidebar__head p {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-sidebar__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-sidebar__footer {
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Connection Panel */
|
|
||||||
.connection-panel {
|
.connection-panel {
|
||||||
background: white;
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-panel h2 {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input Group Layout */
|
|
||||||
.input-group {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group label {
|
.connection-card {
|
||||||
display: block;
|
background: var(--bg-tertiary);
|
||||||
margin-bottom: 5px;
|
border: 1px solid var(--border);
|
||||||
font-weight: 500;
|
border-radius: var(--radius-md);
|
||||||
color: #374151;
|
padding: 18px;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group input {
|
.connection-card__header {
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 2px solid #e5e7eb;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
button {
|
|
||||||
padding: 10px 20px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled:hover {
|
|
||||||
background-color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connect-btn {
|
|
||||||
background-color: #2563eb;
|
|
||||||
color: white;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connect-btn:hover {
|
|
||||||
background-color: #1d4ed8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Call Controls */
|
|
||||||
.call-controls {
|
|
||||||
background: white;
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.call-controls h2 {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-buttons button {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary {
|
|
||||||
background-color: #10b981;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary:hover {
|
|
||||||
background-color: #059669;
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger {
|
|
||||||
background-color: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger:hover {
|
|
||||||
background-color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mute-btn {
|
|
||||||
background-color: #6b7280;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mute-btn:hover {
|
|
||||||
background-color: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Display */
|
|
||||||
.status-display {
|
|
||||||
background: white;
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-display h2 {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-item {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
padding: 8px 0;
|
|
||||||
border-bottom: 1px solid #f3f4f6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-label {
|
.connection-card__header h3 {
|
||||||
font-weight: 500;
|
font-size: 15px;
|
||||||
color: #374151;
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-value {
|
.pill {
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-value.disconnected {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-value.connected {
|
|
||||||
color: #10b981;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-value.requesting {
|
|
||||||
color: #f59e0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Design */
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.main-content {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-display {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Readonly Input */
|
|
||||||
.readonly-input {
|
|
||||||
background-color: #f9fafb !important;
|
|
||||||
cursor: not-allowed;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Copy Button */
|
|
||||||
.copy-btn {
|
|
||||||
margin-left: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background-color: #6b7280;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-btn:hover {
|
|
||||||
background-color: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Muted Button State */
|
|
||||||
.muted {
|
|
||||||
background-color: #ef4444 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted:hover {
|
|
||||||
background-color: #dc2626 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Peer ID Display */
|
|
||||||
.peer-id {
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background-color: #f3f4f6;
|
text-transform: uppercase;
|
||||||
padding: 2px 6px;
|
letter-spacing: 0.12em;
|
||||||
border-radius: 4px;
|
padding: 4px 10px;
|
||||||
color: #374151;
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mic Test */
|
.pill--success {
|
||||||
.mic-test-section {
|
color: var(--accent-secondary);
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 16px;
|
|
||||||
border: 2px solid #e5e7eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: #f9fafb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mic-test-section h3 {
|
.pill--danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status-list .label {
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status-list .value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status-list .value--success {
|
||||||
|
color: var(--accent-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status-list .value--danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
color: #374151;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mic-test-btn {
|
.field label {
|
||||||
background-color: #059669;
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--readonly {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 32px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
margin-right: 8px;
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mic-test-btn:hover:not(:disabled) {
|
.btn:hover {
|
||||||
background-color: #047857;
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px rgba(88, 101, 242, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mic-test-btn:disabled {
|
.btn:disabled {
|
||||||
background-color: #6b7280;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mic-stop-btn {
|
.btn--connected {
|
||||||
background-color: #ef4444;
|
background: rgba(67, 181, 129, 0.18);
|
||||||
|
color: var(--accent-secondary);
|
||||||
|
border-color: rgba(67, 181, 129, 0.4);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-main {
|
||||||
|
grid-area: main;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 40px 56px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-header__text h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-header__text p {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-header__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-pill {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-pill.secondary {
|
||||||
|
background: rgba(88, 101, 242, 0.2);
|
||||||
|
border-color: rgba(88, 101, 242, 0.4);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.participants-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-tile {
|
||||||
|
position: relative;
|
||||||
|
background: var(--tile-muted-bg);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-tile:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-tile.is-speaking {
|
||||||
|
border-color: rgba(67, 181, 129, 0.65);
|
||||||
|
background: var(--tile-speaking);
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-tile.is-muted .participant-avatar {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-tile.is-self {
|
||||||
|
border-color: rgba(88, 101, 242, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-avatar {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
color: white;
|
color: white;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mic-stop-btn:hover {
|
.participant-initials {
|
||||||
background-color: #dc2626;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Warning Message */
|
.participant-badge {
|
||||||
.warning-message {
|
position: absolute;
|
||||||
padding: 12px;
|
bottom: -6px;
|
||||||
background-color: #fef3c7;
|
right: -6px;
|
||||||
border: 1px solid #f59e0b;
|
background: var(--bg-tertiary);
|
||||||
border-radius: 6px;
|
border: 2px solid var(--bg-secondary);
|
||||||
color: #92400e;
|
border-radius: 50%;
|
||||||
font-weight: 500;
|
width: 26px;
|
||||||
margin-top: 12px;
|
height: 26px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Call Button mit zusätzlicher Disabled-State */
|
.participant-meta {
|
||||||
.call-btn:disabled {
|
display: flex;
|
||||||
opacity: 0.5;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-dock {
|
||||||
|
grid-area: controls;
|
||||||
|
background: linear-gradient(180deg, rgba(49, 51, 56, 0.85), rgba(35, 36, 40, 0.95));
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 18px 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 -8px 20px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-controls {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-controls__left,
|
||||||
|
.call-controls__right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-controls__center {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-pill {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-pill--target {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: rgba(88, 101, 242, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn {
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
background-color: #9ca3af !important;
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn--primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: rgba(88, 101, 242, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn--secondary {
|
||||||
|
background: rgba(88, 101, 242, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn--muted {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn--danger {
|
||||||
|
background: var(--danger);
|
||||||
|
border-color: rgba(242, 63, 66, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-widget {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-widget__label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-widget__value {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-widget__value--online {
|
||||||
|
color: var(--accent-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-widget__value--offline {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-widget__hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.voice-channel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"main"
|
||||||
|
"controls";
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-main {
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.channel-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-controls__center {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## Zweck
|
## Zweck
|
||||||
- Steuert Anrufstart als Initiator, Mikrofonberechtigungen und Mute/Ende-Interaktionen.
|
- Steuert Anrufstart als Initiator, Mikrofonberechtigungen und Mute/Ende-Interaktionen.
|
||||||
|
- Wird innerhalb des `ControlDock` (sticky Bottom-Bar) dargestellt.
|
||||||
|
|
||||||
## Kernfunktionen
|
## Kernfunktionen
|
||||||
- `request_microphone_access()`: nutzt `MediaManager` zum Einholen des MediaStreams und speichert ihn in `local_media` Signal.
|
- `request_microphone_access()`: nutzt `MediaManager` zum Einholen des MediaStreams und speichert ihn in `local_media` Signal.
|
||||||
@ -23,3 +24,4 @@
|
|||||||
- Visuelle Feedback-Elemente (Button-States im Discord-Stil).
|
- Visuelle Feedback-Elemente (Button-States im Discord-Stil).
|
||||||
- Device-Auswahl (Audio Output/Input) vor dem Start.
|
- Device-Auswahl (Audio Output/Input) vor dem Start.
|
||||||
- Error-Toasts (z. B. wenn Offer scheitert).
|
- Error-Toasts (z. B. wenn Offer scheitert).
|
||||||
|
- Aktive Call-State-Anzeige im ControlDock (z. B. Dauer, zweiter Channel).
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
## Zweck
|
## Zweck
|
||||||
- Stellt Verbindungsstatus dar (WebSocket, Mikrofon) und nimmt Remote-Peer-ID entgegen.
|
- Stellt Verbindungsstatus dar (WebSocket, Mikrofon) und nimmt Remote-Peer-ID entgegen.
|
||||||
- Hält Signals für Peer-Verbindung als **Responder** und verwaltet Offer/Answer-Eingang.
|
- Hält Signals für Peer-Verbindung als **Responder** und verwaltet Offer/Answer-Eingang.
|
||||||
|
- Wird in der Sidebar des `VoiceChannelLayout` als Karten-Stack (`connection-card`) dargestellt.
|
||||||
|
|
||||||
## Wichtige Signals
|
## Wichtige Signals
|
||||||
- `peer_id`, `remote_id`, `connected`, `websocket`
|
- `peer_id`, `remote_id`, `connected`, `websocket`
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
- Einfache Übersicht über Systemzustand (Placeholder für spätere KPIs wie Mitglieder, Ping, aktive Sprecher).
|
- Einfache Übersicht über Systemzustand (Placeholder für spätere KPIs wie Mitglieder, Ping, aktive Sprecher).
|
||||||
|
|
||||||
## Aktueller Stand
|
## Aktueller Stand
|
||||||
- Zeigt statisch "System Stabil" und `connected` Status (WebSocket).
|
- Zeigt Füllstand im neuen `status-widget` (Signaling Online/Offline + Hint).
|
||||||
- TODO: WebRTC-Status placeholder.
|
- TODO: WebRTC-Status placeholder.
|
||||||
|
|
||||||
## Ausbauideen
|
## Ausbauideen
|
||||||
|
|||||||
38
docs/components/voice_channel_layout.md
Normal file
38
docs/components/voice_channel_layout.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# VoiceChannelLayout Component
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
`VoiceChannelLayout` orchestrates the Discord-inspired voice channel module:
|
||||||
|
|
||||||
|
- **ChannelSidebar** – wraps connection management (`ConnectionPanel`) and `StatusDisplay` inside a sidebar with channel metadata.
|
||||||
|
- **ChannelHeader** – displays channel title/topic and view state pills.
|
||||||
|
- **ParticipantsGrid** – renders a responsive grid of `Participant` tiles. Visual states:
|
||||||
|
- `is_self` highlights the current user in accent color.
|
||||||
|
- `is_speaking` glows in the accent-green palette.
|
||||||
|
- `is_muted` dims the avatar and shows a mute badge.
|
||||||
|
- **ControlDock** – sticky bottom bar that embeds `CallControls` with the reworked button set.
|
||||||
|
|
||||||
|
## Props
|
||||||
|
```
|
||||||
|
VoiceChannelProps {
|
||||||
|
channel_name: String,
|
||||||
|
channel_topic: String,
|
||||||
|
participants: Signal<Vec<Participant>>,
|
||||||
|
peer_id: Signal<String>,
|
||||||
|
remote_id: Signal<String>,
|
||||||
|
connected: Signal<bool>,
|
||||||
|
websocket: Signal<Option<WebSocket>>,
|
||||||
|
responder_connection: Signal<Option<RtcPeerConnection>>,
|
||||||
|
initiator_connection: Signal<Option<RtcPeerConnection>>,
|
||||||
|
local_media: Signal<Option<MediaStream>>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout Notes
|
||||||
|
- Top-level grid maps to Discord style: sidebar (320px) + stage + control dock.
|
||||||
|
- Mobile breakpoint hides sidebar; channel content becomes full width.
|
||||||
|
- Partial data (mock participants) lives in `main.rs` until real presence sync exists.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
1. Replace mock participant Signal with data from signaling server (rooms/memberships).
|
||||||
|
2. Animate speaking indicator using audio levels from `MediaStreamTrack`.
|
||||||
|
3. Add quick actions (invite, copy link) to channel header action pills.
|
||||||
@ -5,6 +5,7 @@ Diese Dokumentationssammlung beschreibt das MVP-Modul "Voice Channel" im Projekt
|
|||||||
- [`architecture/signaling_flow.md`](architecture/signaling_flow.md) – High-Level-Ablauf von Signaling, WebRTC und TURN.
|
- [`architecture/signaling_flow.md`](architecture/signaling_flow.md) – High-Level-Ablauf von Signaling, WebRTC und TURN.
|
||||||
- [`config/config_management.md`](config/config_management.md) – Konfigurationen und Defaults (STUN/TURN, Appsettings).
|
- [`config/config_management.md`](config/config_management.md) – Konfigurationen und Defaults (STUN/TURN, Appsettings).
|
||||||
- [`components/`](components/) – UI-Komponenten (Discord-Voice-Channel UI) inkl. Zustandsfluss.
|
- [`components/`](components/) – UI-Komponenten (Discord-Voice-Channel UI) inkl. Zustandsfluss.
|
||||||
|
- [`voice_channel_layout.md`](components/voice_channel_layout.md)
|
||||||
- [`utils/media_manager.md`](utils/media_manager.md) – Medien- und Peer-Connection-Helfer.
|
- [`utils/media_manager.md`](utils/media_manager.md) – Medien- und Peer-Connection-Helfer.
|
||||||
|
|
||||||
## Aktueller Fokus
|
## Aktueller Fokus
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
use dioxus::prelude::*;
|
|
||||||
use web_sys::{WebSocket as BrowserWebSocket, RtcPeerConnection, MediaStream};
|
|
||||||
use crate::models::SignalingMessage;
|
use crate::models::SignalingMessage;
|
||||||
use crate::utils::MediaManager;
|
use crate::utils::MediaManager;
|
||||||
|
use dioxus::prelude::*;
|
||||||
use wasm_bindgen::prelude::Closure;
|
use wasm_bindgen::prelude::Closure;
|
||||||
use wasm_bindgen::JsValue;
|
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen::JsValue;
|
||||||
|
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn CallControls(
|
pub fn CallControls(
|
||||||
@ -21,15 +21,18 @@ pub fn CallControls(
|
|||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "call-controls",
|
div { class: "call-controls",
|
||||||
h2 { "Anruf-Steuerung" }
|
div { class: "call-controls__left",
|
||||||
|
span { class: "self-pill", "Your ID: {peer_id.read()}" }
|
||||||
div { class: "mic-permission-section",
|
if !remote_id.read().is_empty() {
|
||||||
h3 { "Mikrofon" }
|
span { class: "self-pill self-pill--target", "Target: {remote_id.read()}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "call-controls__center",
|
||||||
button {
|
button {
|
||||||
class: "mic-permission-btn primary",
|
class: if *mic_granted.read() { "ctrl-btn ctrl-btn--secondary" } else { "ctrl-btn ctrl-btn--primary" },
|
||||||
disabled: *mic_granted.read(),
|
disabled: *mic_granted.read(),
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
log::info!("🎤 Fordere Mikrofon-Berechtigung an...");
|
log::info!("Requesting microphone permission");
|
||||||
let mut mm_state = mic_granted.clone();
|
let mut mm_state = mic_granted.clone();
|
||||||
let pc_signal = peer_connection.clone();
|
let pc_signal = peer_connection.clone();
|
||||||
let mut local_media_signal = local_media.clone();
|
let mut local_media_signal = local_media.clone();
|
||||||
@ -37,49 +40,39 @@ pub fn CallControls(
|
|||||||
let mut manager = crate::utils::MediaManager::new();
|
let mut manager = crate::utils::MediaManager::new();
|
||||||
match manager.request_microphone_access().await {
|
match manager.request_microphone_access().await {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
log::info!("✅ Mikrofonzugang erteilt");
|
log::info!("Microphone granted");
|
||||||
mm_state.set(true);
|
mm_state.set(true);
|
||||||
// Speichere den Stream global, damit andere Komponenten ihn nutzen können
|
|
||||||
local_media_signal.set(Some(stream.clone()));
|
local_media_signal.set(Some(stream.clone()));
|
||||||
if let Some(pc) = pc_signal.read().as_ref() {
|
if let Some(pc) = pc_signal.read().as_ref() {
|
||||||
if let Err(e) = crate::utils::MediaManager::add_stream_to_pc(pc, &stream) {
|
if let Err(e) = crate::utils::MediaManager::add_stream_to_pc(pc, &stream) {
|
||||||
log::warn!("Fehler beim Hinzufügen der lokalen Tracks: {}", e);
|
log::warn!("Failed to attach local tracks: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("❌ Mikrofonzugriff fehlgeschlagen: {}", e);
|
log::error!("Microphone request failed: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
if *mic_granted.read() {
|
if *mic_granted.read() { "Mic ready" } else { "Enable mic" }
|
||||||
"✅ Berechtigung erteilt"
|
|
||||||
} else {
|
|
||||||
"🎤 Berechtigung erteilen"
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div { class: "control-buttons",
|
|
||||||
// **INITIATOR** WebRTC-Anruf starten
|
|
||||||
button {
|
button {
|
||||||
class: "call-btn primary",
|
class: "ctrl-btn ctrl-btn--primary",
|
||||||
disabled: !*mic_granted.read() || !*connected.read() || remote_id.read().is_empty(),
|
disabled: !*mic_granted.read() || !*connected.read() || remote_id.read().is_empty(),
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
log::info!("📞 Starte WebRTC-Anruf als Initiator...");
|
log::info!("Launching WebRTC call as initiator");
|
||||||
|
|
||||||
let mut pc_signal = peer_connection.clone();
|
let mut pc_signal = peer_connection.clone();
|
||||||
let ws_signal = websocket.clone();
|
let ws_signal = websocket.clone();
|
||||||
let from_id = peer_id.read().clone();
|
let from_id = peer_id.read().clone();
|
||||||
let to_id = remote_id.read().clone();
|
let to_id = remote_id.read().clone();
|
||||||
|
let mut in_call_flag = in_call.clone();
|
||||||
|
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
// **INITIATOR:** PeerConnection erstellen
|
|
||||||
let pc = if pc_signal.read().is_none() {
|
let pc = if pc_signal.read().is_none() {
|
||||||
match MediaManager::create_peer_connection() {
|
match MediaManager::create_peer_connection() {
|
||||||
Ok(new_pc) => {
|
Ok(new_pc) => {
|
||||||
// Attach onicecandidate handler to send candidates via websocket
|
|
||||||
let ws_clone = ws_signal.clone();
|
let ws_clone = ws_signal.clone();
|
||||||
let to_clone = to_id.clone();
|
let to_clone = to_id.clone();
|
||||||
let from_clone = from_id.clone();
|
let from_clone = from_id.clone();
|
||||||
@ -104,14 +97,12 @@ pub fn CallControls(
|
|||||||
new_pc.set_onicecandidate(Some(on_ice.as_ref().unchecked_ref()));
|
new_pc.set_onicecandidate(Some(on_ice.as_ref().unchecked_ref()));
|
||||||
on_ice.forget();
|
on_ice.forget();
|
||||||
|
|
||||||
// ontrack -> play remote audio
|
|
||||||
let on_track = Closure::wrap(Box::new(move |ev: JsValue| {
|
let on_track = Closure::wrap(Box::new(move |ev: JsValue| {
|
||||||
if let Ok(streams_val) = js_sys::Reflect::get(&ev, &JsValue::from_str("streams")) {
|
if let Ok(streams_val) = js_sys::Reflect::get(&ev, &JsValue::from_str("streams")) {
|
||||||
if streams_val.is_undefined() || streams_val.is_null() { return; }
|
if streams_val.is_undefined() || streams_val.is_null() { return; }
|
||||||
let streams_array = js_sys::Array::from(&streams_val);
|
let streams_array = js_sys::Array::from(&streams_val);
|
||||||
let first = streams_array.get(0);
|
let first = streams_array.get(0);
|
||||||
let stream_js = first.clone();
|
if let Ok(stream) = first.clone().dyn_into::<web_sys::MediaStream>() {
|
||||||
if let Ok(stream) = stream_js.dyn_into::<web_sys::MediaStream>() {
|
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
if let Some(document) = window.document() {
|
if let Some(document) = window.document() {
|
||||||
if let Ok(audio_el) = document.create_element("audio") {
|
if let Ok(audio_el) = document.create_element("audio") {
|
||||||
@ -132,11 +123,11 @@ pub fn CallControls(
|
|||||||
on_track.forget();
|
on_track.forget();
|
||||||
|
|
||||||
pc_signal.set(Some(new_pc.clone()));
|
pc_signal.set(Some(new_pc.clone()));
|
||||||
log::info!("✅ Initiator PeerConnection erstellt");
|
log::info!("Initiator PeerConnection ready");
|
||||||
new_pc
|
new_pc
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("❌ Initiator PeerConnection-Erstellung fehlgeschlagen: {}", e);
|
log::error!("Failed to create initiator peer connection: {}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -144,19 +135,12 @@ pub fn CallControls(
|
|||||||
pc_signal.read().as_ref().unwrap().clone()
|
pc_signal.read().as_ref().unwrap().clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Falls wir bereits Zugriff auf Mikrofon haben, versuchen wir die
|
|
||||||
// lokalen Tracks erneut hinzuzufügen (no-op, falls schon vorhanden).
|
|
||||||
// Hinweis: In dieser einfachen Struktur halten wir den MediaStream
|
|
||||||
// nicht global, daher ist dies ein best-effort.
|
|
||||||
|
|
||||||
// Falls ein lokaler MediaStream vorhanden ist, stelle sicher, dass seine Tracks angehängt sind
|
|
||||||
if let Some(local) = local_media.read().as_ref() {
|
if let Some(local) = local_media.read().as_ref() {
|
||||||
if let Err(e) = crate::utils::MediaManager::add_stream_to_pc(&pc, local) {
|
if let Err(e) = crate::utils::MediaManager::add_stream_to_pc(&pc, local) {
|
||||||
log::warn!("Fehler beim Hinzufügen der lokalen Tracks vor Offer: {}", e);
|
log::warn!("Failed to attach local tracks before offer: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **INITIATOR:** Offer erstellen und senden
|
|
||||||
match MediaManager::create_offer(&pc).await {
|
match MediaManager::create_offer(&pc).await {
|
||||||
Ok(offer_sdp) => {
|
Ok(offer_sdp) => {
|
||||||
if let Some(socket) = ws_signal.read().as_ref() {
|
if let Some(socket) = ws_signal.read().as_ref() {
|
||||||
@ -169,68 +153,54 @@ pub fn CallControls(
|
|||||||
|
|
||||||
if let Ok(json) = serde_json::to_string(&msg) {
|
if let Ok(json) = serde_json::to_string(&msg) {
|
||||||
let _ = socket.send_with_str(&json);
|
let _ = socket.send_with_str(&json);
|
||||||
log::info!("📤 WebRTC-Offer als Initiator gesendet an {}", to_id);
|
log::info!("Offer dispatched to {}", to_id);
|
||||||
|
in_call_flag.set(true);
|
||||||
// **SETUP:** Answer-Handler für eingehende Answers
|
|
||||||
// Note: Answer wird über connection_panel's onmessage empfangen
|
|
||||||
// und an diese Coroutine weitergeleitet
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => log::error!("❌ Initiator Offer-Erstellung fehlgeschlagen: {}", e),
|
Err(e) => log::error!("Offer creation failed: {}", e),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
"📞 WebRTC-Anruf starten"
|
"Start call"
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
class: if *audio_muted.read() { "mute-btn muted" } else { "mute-btn" },
|
class: if *audio_muted.read() { "ctrl-btn ctrl-btn--muted" } else { "ctrl-btn" },
|
||||||
disabled: !*in_call.read(),
|
disabled: !*in_call.read(),
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let current_muted = *audio_muted.read();
|
let current_muted = *audio_muted.read();
|
||||||
audio_muted.set(!current_muted);
|
audio_muted.set(!current_muted);
|
||||||
log::info!("🔊 Audio: {}", if !current_muted { "Stumm" } else { "An" });
|
log::info!("Audio {}", if !current_muted { "muted" } else { "unmuted" });
|
||||||
},
|
},
|
||||||
if *audio_muted.read() {
|
if *audio_muted.read() { "Unmute" } else { "Mute" }
|
||||||
"🔇 Stumm"
|
|
||||||
} else {
|
|
||||||
"🔊 Audio An"
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
class: "end-btn danger",
|
class: "ctrl-btn ctrl-btn--danger",
|
||||||
disabled: !*in_call.read(),
|
disabled: !*in_call.read(),
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
in_call.set(false);
|
in_call.set(false);
|
||||||
audio_muted.set(false);
|
audio_muted.set(false);
|
||||||
|
|
||||||
// **SCHRITT 1:** Prüfen ob PeerConnection existiert (Immutable Borrow)
|
|
||||||
let has_peer_connection = peer_connection.read().is_some();
|
let has_peer_connection = peer_connection.read().is_some();
|
||||||
|
|
||||||
// **SCHRITT 2:** Falls vorhanden, schließen und entfernen (Separate Borrows)
|
|
||||||
if has_peer_connection {
|
if has_peer_connection {
|
||||||
// Schritt 2a: PeerConnection holen und schließen
|
|
||||||
if let Some(pc) = peer_connection.read().as_ref() {
|
if let Some(pc) = peer_connection.read().as_ref() {
|
||||||
pc.close(); // ← Immutable borrow endet nach dieser Zeile
|
pc.close();
|
||||||
log::info!("📵 Initiator PeerConnection geschlossen");
|
log::info!("Initiator PeerConnection closed");
|
||||||
}
|
}
|
||||||
// Schritt 2b: Danach Signal leeren (Neuer mutable borrow)
|
peer_connection.set(None);
|
||||||
peer_connection.set(None); // ✅ Kein aktiver immutable borrow mehr!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("📵 Anruf beendet");
|
log::info!("Call ended");
|
||||||
},
|
},
|
||||||
"📵 Anruf beenden"
|
"Leave"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
div { class: "call-controls__right",
|
||||||
|
span { class: "connection-hint",
|
||||||
|
if *connected.read() { "Connected to signaling" } else { "Waiting for signaling" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **HIDDEN:** Answer-Handler für diese Komponente
|
|
||||||
script {
|
|
||||||
// JavaScript Bridge für Answer-Weiterleitung an Coroutine
|
|
||||||
// wird über connection_panel's WebSocket-Handler geleitet
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
use dioxus::prelude::*;
|
|
||||||
use web_sys::{WebSocket as BrowserWebSocket, RtcPeerConnection, BinaryType, MessageEvent};
|
|
||||||
use web_sys::MediaStream;
|
|
||||||
use wasm_bindgen::prelude::Closure;
|
|
||||||
use wasm_bindgen::JsValue;
|
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
use wasm_bindgen_futures::spawn_local;
|
|
||||||
use crate::models::SignalingMessage;
|
use crate::models::SignalingMessage;
|
||||||
use crate::utils::MediaManager;
|
use crate::utils::MediaManager;
|
||||||
|
use dioxus::prelude::*;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
use wasm_bindgen::prelude::Closure;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen::JsValue;
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
use web_sys::MediaStream;
|
||||||
|
use web_sys::{BinaryType, MessageEvent, RtcPeerConnection, WebSocket as BrowserWebSocket};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ConnectionPanel(
|
pub fn ConnectionPanel(
|
||||||
@ -26,7 +26,12 @@ pub fn ConnectionPanel(
|
|||||||
// **COROUTINE** für Offer-Handling (Responder empfängt Offers)
|
// **COROUTINE** für Offer-Handling (Responder empfängt Offers)
|
||||||
let offer_handler = use_coroutine(move |mut rx| async move {
|
let offer_handler = use_coroutine(move |mut rx| async move {
|
||||||
while let Some(msg) = rx.next().await {
|
while let Some(msg) = rx.next().await {
|
||||||
let SignalingMessage { from, to, msg_type, data } = msg;
|
let SignalingMessage {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
msg_type,
|
||||||
|
data,
|
||||||
|
} = msg;
|
||||||
|
|
||||||
// **KORREKT:** In der Coroutine-Loop
|
// **KORREKT:** In der Coroutine-Loop
|
||||||
if msg_type == "offer" {
|
if msg_type == "offer" {
|
||||||
@ -44,13 +49,16 @@ pub fn ConnectionPanel(
|
|||||||
let from_for_ice = from_clone.clone();
|
let from_for_ice = from_clone.clone();
|
||||||
let on_ice = Closure::wrap(Box::new(move |ev: JsValue| {
|
let on_ice = Closure::wrap(Box::new(move |ev: JsValue| {
|
||||||
// ev.candidate may be null/undefined or an object
|
// ev.candidate may be null/undefined or an object
|
||||||
if let Ok(candidate_val) = js_sys::Reflect::get(&ev, &JsValue::from_str("candidate")) {
|
if let Ok(candidate_val) =
|
||||||
|
js_sys::Reflect::get(&ev, &JsValue::from_str("candidate"))
|
||||||
|
{
|
||||||
if candidate_val.is_null() || candidate_val.is_undefined() {
|
if candidate_val.is_null() || candidate_val.is_undefined() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(ws) = ws_clone.read().as_ref() {
|
if let Some(ws) = ws_clone.read().as_ref() {
|
||||||
// Try to stringify the candidate object directly
|
// Try to stringify the candidate object directly
|
||||||
if let Ok(json_js) = js_sys::JSON::stringify(&candidate_val) {
|
if let Ok(json_js) = js_sys::JSON::stringify(&candidate_val)
|
||||||
|
{
|
||||||
if let Some(json) = json_js.as_string() {
|
if let Some(json) = json_js.as_string() {
|
||||||
let msg = crate::models::SignalingMessage {
|
let msg = crate::models::SignalingMessage {
|
||||||
from: peer_id.read().clone(),
|
from: peer_id.read().clone(),
|
||||||
@ -58,28 +66,40 @@ pub fn ConnectionPanel(
|
|||||||
msg_type: "candidate".to_string(),
|
msg_type: "candidate".to_string(),
|
||||||
data: json,
|
data: json,
|
||||||
};
|
};
|
||||||
if let Ok(text) = serde_json::to_string(&msg) { let _ = ws.send_with_str(&text); }
|
if let Ok(text) = serde_json::to_string(&msg) {
|
||||||
|
let _ = ws.send_with_str(&text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}) as Box<dyn FnMut(JsValue)>);
|
}
|
||||||
|
})
|
||||||
|
as Box<dyn FnMut(JsValue)>);
|
||||||
new_pc.set_onicecandidate(Some(on_ice.as_ref().unchecked_ref()));
|
new_pc.set_onicecandidate(Some(on_ice.as_ref().unchecked_ref()));
|
||||||
on_ice.forget();
|
on_ice.forget();
|
||||||
|
|
||||||
// ontrack -> play remote audio
|
// ontrack -> play remote audio
|
||||||
let on_track = Closure::wrap(Box::new(move |ev: JsValue| {
|
let on_track = Closure::wrap(Box::new(move |ev: JsValue| {
|
||||||
// ev.streams is an array of MediaStream
|
// ev.streams is an array of MediaStream
|
||||||
if let Ok(streams_val) = js_sys::Reflect::get(&ev, &JsValue::from_str("streams")) {
|
if let Ok(streams_val) =
|
||||||
if streams_val.is_undefined() || streams_val.is_null() { return; }
|
js_sys::Reflect::get(&ev, &JsValue::from_str("streams"))
|
||||||
|
{
|
||||||
|
if streams_val.is_undefined() || streams_val.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let streams_array = js_sys::Array::from(&streams_val);
|
let streams_array = js_sys::Array::from(&streams_val);
|
||||||
let first = streams_array.get(0);
|
let first = streams_array.get(0);
|
||||||
let stream_js = first.clone();
|
let stream_js = first.clone();
|
||||||
if let Ok(stream) = stream_js.dyn_into::<web_sys::MediaStream>() {
|
if let Ok(stream) = stream_js.dyn_into::<web_sys::MediaStream>()
|
||||||
|
{
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
if let Some(document) = window.document() {
|
if let Some(document) = window.document() {
|
||||||
if let Ok(audio_el) = document.create_element("audio") {
|
if let Ok(audio_el) =
|
||||||
if let Ok(audio) = audio_el.dyn_into::<web_sys::HtmlAudioElement>() {
|
document.create_element("audio")
|
||||||
|
{
|
||||||
|
if let Ok(audio) = audio_el
|
||||||
|
.dyn_into::<web_sys::HtmlAudioElement>(
|
||||||
|
) {
|
||||||
audio.set_autoplay(true);
|
audio.set_autoplay(true);
|
||||||
audio.set_src_object(Some(&stream));
|
audio.set_src_object(Some(&stream));
|
||||||
if let Some(body) = document.body() {
|
if let Some(body) = document.body() {
|
||||||
@ -91,7 +111,8 @@ pub fn ConnectionPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}) as Box<dyn FnMut(JsValue)>);
|
})
|
||||||
|
as Box<dyn FnMut(JsValue)>);
|
||||||
new_pc.set_ontrack(Some(on_track.as_ref().unchecked_ref()));
|
new_pc.set_ontrack(Some(on_track.as_ref().unchecked_ref()));
|
||||||
on_track.forget();
|
on_track.forget();
|
||||||
|
|
||||||
@ -124,7 +145,8 @@ pub fn ConnectionPanel(
|
|||||||
|
|
||||||
if let Ok(json) = serde_json::to_string(&answer_msg) {
|
if let Ok(json) = serde_json::to_string(&answer_msg) {
|
||||||
let _ = socket.send_with_str(&json);
|
let _ = socket.send_with_str(&json);
|
||||||
log::info!("📤 Responder Answer gesendet an {}", from_clone); // ✅ Clone verwenden
|
log::info!("📤 Responder Answer gesendet an {}", from_clone);
|
||||||
|
// ✅ Clone verwenden
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -145,7 +167,11 @@ pub fn ConnectionPanel(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Einfacher Status-String für das Mikrofon
|
// Einfacher Status-String für das Mikrofon
|
||||||
let mic_status = if local_media.read().is_some() { "✅ Erteilt" } else { "✖️ Nicht erteilt" };
|
let mic_status = if local_media.read().is_some() {
|
||||||
|
"Granted"
|
||||||
|
} else {
|
||||||
|
"Not granted"
|
||||||
|
};
|
||||||
|
|
||||||
// WebSocket verbinden
|
// WebSocket verbinden
|
||||||
let connect_websocket = move |_| {
|
let connect_websocket = move |_| {
|
||||||
@ -172,7 +198,8 @@ pub fn ConnectionPanel(
|
|||||||
log::warn!("❌ WebSocket getrennt");
|
log::warn!("❌ WebSocket getrennt");
|
||||||
ws_status_clone2.set("Getrennt".to_string());
|
ws_status_clone2.set("Getrennt".to_string());
|
||||||
connected_clone2.set(false);
|
connected_clone2.set(false);
|
||||||
}) as Box<dyn FnMut(web_sys::CloseEvent)>);
|
})
|
||||||
|
as Box<dyn FnMut(web_sys::CloseEvent)>);
|
||||||
|
|
||||||
// **MESSAGE ROUTER** - Leitet Messages an die richtigen Handler weiter
|
// **MESSAGE ROUTER** - Leitet Messages an die richtigen Handler weiter
|
||||||
let offer_tx = offer_handler.clone();
|
let offer_tx = offer_handler.clone();
|
||||||
@ -233,7 +260,8 @@ pub fn ConnectionPanel(
|
|||||||
log::info!("💬 Textnachricht: {}", msg.data);
|
log::info!("💬 Textnachricht: {}", msg.data);
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
let _ = window.alert_with_message(&format!(
|
let _ = window.alert_with_message(&format!(
|
||||||
"Nachricht von {}:\n{}", msg.from, msg.data
|
"Nachricht von {}:\n{}",
|
||||||
|
msg.from, msg.data
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -275,9 +303,15 @@ pub fn ConnectionPanel(
|
|||||||
let pc_clone = pc.clone();
|
let pc_clone = pc.clone();
|
||||||
let answer_clone = answer_sdp.clone();
|
let answer_clone = answer_sdp.clone();
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match crate::utils::MediaManager::handle_answer(&pc_clone, &answer_clone).await {
|
match crate::utils::MediaManager::handle_answer(&pc_clone, &answer_clone)
|
||||||
Ok(_) => log::info!("✅ Gepufferte Answer erfolgreich gesetzt auf Initiator-PC"),
|
.await
|
||||||
Err(e) => log::error!("❌ Fehler beim Setzen der gepufferten Answer: {}", e),
|
{
|
||||||
|
Ok(_) => log::info!(
|
||||||
|
"✅ Gepufferte Answer erfolgreich gesetzt auf Initiator-PC"
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("❌ Fehler beim Setzen der gepufferten Answer: {}", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Clear buffer
|
// Clear buffer
|
||||||
@ -289,61 +323,78 @@ pub fn ConnectionPanel(
|
|||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "connection-panel",
|
div { class: "connection-panel",
|
||||||
h2 { "Verbindung" }
|
section { class: "connection-card",
|
||||||
|
header { class: "connection-card__header",
|
||||||
div { class: "status-item",
|
h3 { "Connection" }
|
||||||
span { class: "status-label", "WebSocket:" }
|
span { class: if *connected.read() { "pill pill--success" } else { "pill pill--danger" }, "{ws_status.read()}" }
|
||||||
|
}
|
||||||
|
ul { class: "connection-status-list",
|
||||||
|
li {
|
||||||
|
span { class: "label", "WebSocket" }
|
||||||
span {
|
span {
|
||||||
class: if *connected.read() { "status-value connected" } else { "status-value disconnected" },
|
class: if *connected.read() { "value value--success" } else { "value value--danger" },
|
||||||
"{ws_status.read()}"
|
if *connected.read() { "Connected" } else { "Disconnected" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
li {
|
||||||
div { class: "status-item",
|
span { class: "label", "Microphone" }
|
||||||
span { class: "status-label", "Mikrofon:" }
|
|
||||||
span {
|
span {
|
||||||
class: if local_media.read().is_some() { "status-value connected" } else { "status-value disconnected" },
|
class: if local_media.read().is_some() { "value value--success" } else { "value value--danger" },
|
||||||
"{mic_status}"
|
"{mic_status}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
div { class: "input-group",
|
section { class: "connection-card",
|
||||||
label { "Ihre Peer-ID:" }
|
header { class: "connection-card__header",
|
||||||
|
h3 { "Session" }
|
||||||
|
}
|
||||||
|
div { class: "field",
|
||||||
|
label { "Your ID" }
|
||||||
|
div { class: "field__row",
|
||||||
input {
|
input {
|
||||||
class: "readonly-input",
|
class: "input input--readonly",
|
||||||
r#type: "text",
|
r#type: "text",
|
||||||
value: "{peer_id.read()}",
|
value: "{peer_id.read()}",
|
||||||
readonly: true
|
readonly: true
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
class: "copy-btn",
|
class: "icon-btn",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
log::info!("📋 Peer-ID kopiert: {}", peer_id.read());
|
log::info!("📋 Peer-ID kopiert: {}", peer_id.read());
|
||||||
},
|
},
|
||||||
"📋"
|
"📋"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
div { class: "input-group",
|
div { class: "field",
|
||||||
label { "Remote Peer-ID:" }
|
label { "Target ID" }
|
||||||
input {
|
input {
|
||||||
|
class: "input",
|
||||||
r#type: "text",
|
r#type: "text",
|
||||||
placeholder: "ID des anderen Teilnehmers",
|
placeholder: "Paste peer ID",
|
||||||
value: "{remote_id.read()}",
|
value: "{remote_id.read()}",
|
||||||
oninput: move |event| {
|
oninput: move |event| {
|
||||||
remote_id.set(event.value());
|
remote_id.set(event.value());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section { class: "connection-card",
|
||||||
|
header { class: "connection-card__header",
|
||||||
|
h3 { "Networking" }
|
||||||
|
}
|
||||||
button {
|
button {
|
||||||
class: if *connected.read() { "connect-btn connected" } else { "connect-btn" },
|
class: if *connected.read() { "btn btn--connected" } else { "btn" },
|
||||||
disabled: *connected.read(),
|
disabled: *connected.read(),
|
||||||
onclick: connect_websocket,
|
onclick: connect_websocket,
|
||||||
if *connected.read() {
|
if *connected.read() {
|
||||||
"✅ Verbunden"
|
"Connected"
|
||||||
} else {
|
} else {
|
||||||
"🔌 Verbinden"
|
"Connect"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
mod connection_panel;
|
|
||||||
mod call_controls;
|
mod call_controls;
|
||||||
|
mod connection_panel;
|
||||||
mod status_display;
|
mod status_display;
|
||||||
|
mod voice_channel;
|
||||||
|
|
||||||
pub use connection_panel::ConnectionPanel;
|
|
||||||
pub use call_controls::CallControls;
|
pub use call_controls::CallControls;
|
||||||
|
pub use connection_panel::ConnectionPanel;
|
||||||
pub use status_display::StatusDisplay;
|
pub use status_display::StatusDisplay;
|
||||||
|
pub use voice_channel::VoiceChannelLayout;
|
||||||
|
|||||||
@ -1,30 +1,15 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn StatusDisplay(
|
pub fn StatusDisplay(connected: Signal<bool>) -> Element {
|
||||||
connected: Signal<bool>,
|
|
||||||
) -> Element {
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "status-display",
|
div { class: "status-widget",
|
||||||
h2 { "Status" }
|
span { class: "status-widget__label", "Signaling" }
|
||||||
|
|
||||||
div { class: "status-item",
|
|
||||||
span { class: "status-label", "System:" }
|
|
||||||
span { class: "status-value connected", "✅ Stabil" }
|
|
||||||
}
|
|
||||||
|
|
||||||
div { class: "status-item",
|
|
||||||
span { class: "status-label", "WebSocket:" }
|
|
||||||
span {
|
span {
|
||||||
class: if *connected.read() { "status-value connected" } else { "status-value disconnected" },
|
class: if *connected.read() { "status-widget__value status-widget__value--online" } else { "status-widget__value status-widget__value--offline" },
|
||||||
if *connected.read() { "✅ Verbunden" } else { "❌ Getrennt" }
|
if *connected.read() { "Online" } else { "Offline" }
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div { class: "status-item",
|
|
||||||
span { class: "status-label", "WebRTC:" }
|
|
||||||
span { class: "status-value", "⚙️ Bereit für Implementation" }
|
|
||||||
}
|
}
|
||||||
|
span { class: "status-widget__hint", "TURN integration pending" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
208
src/components/voice_channel.rs
Normal file
208
src/components/voice_channel.rs
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
use crate::models::Participant;
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
|
||||||
|
|
||||||
|
use super::{CallControls, ConnectionPanel, StatusDisplay};
|
||||||
|
|
||||||
|
#[derive(Props, Clone, PartialEq)]
|
||||||
|
pub struct VoiceChannelProps {
|
||||||
|
pub channel_name: String,
|
||||||
|
pub channel_topic: String,
|
||||||
|
pub participants: Signal<Vec<Participant>>,
|
||||||
|
pub peer_id: Signal<String>,
|
||||||
|
pub remote_id: Signal<String>,
|
||||||
|
pub connected: Signal<bool>,
|
||||||
|
pub websocket: Signal<Option<BrowserWebSocket>>,
|
||||||
|
pub responder_connection: Signal<Option<RtcPeerConnection>>,
|
||||||
|
pub initiator_connection: Signal<Option<RtcPeerConnection>>,
|
||||||
|
pub local_media: Signal<Option<MediaStream>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn VoiceChannelLayout(props: VoiceChannelProps) -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "voice-channel",
|
||||||
|
ChannelSidebar {
|
||||||
|
channel_name: props.channel_name.clone(),
|
||||||
|
channel_topic: props.channel_topic.clone(),
|
||||||
|
peer_id: props.peer_id.clone(),
|
||||||
|
remote_id: props.remote_id.clone(),
|
||||||
|
connected: props.connected.clone(),
|
||||||
|
websocket: props.websocket.clone(),
|
||||||
|
responder_connection: props.responder_connection.clone(),
|
||||||
|
initiator_connection: props.initiator_connection.clone(),
|
||||||
|
local_media: props.local_media.clone(),
|
||||||
|
}
|
||||||
|
div { class: "channel-main",
|
||||||
|
ChannelHeader {
|
||||||
|
name: props.channel_name.clone(),
|
||||||
|
topic: props.channel_topic.clone()
|
||||||
|
}
|
||||||
|
ParticipantsGrid { participants: props.participants.clone() }
|
||||||
|
}
|
||||||
|
ControlDock {
|
||||||
|
peer_id: props.peer_id.clone(),
|
||||||
|
remote_id: props.remote_id.clone(),
|
||||||
|
connected: props.connected.clone(),
|
||||||
|
websocket: props.websocket.clone(),
|
||||||
|
initiator_connection: props.initiator_connection.clone(),
|
||||||
|
local_media: props.local_media.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Props, Clone, PartialEq)]
|
||||||
|
pub struct ChannelSidebarProps {
|
||||||
|
pub channel_name: String,
|
||||||
|
pub channel_topic: String,
|
||||||
|
pub peer_id: Signal<String>,
|
||||||
|
pub remote_id: Signal<String>,
|
||||||
|
pub connected: Signal<bool>,
|
||||||
|
pub websocket: Signal<Option<BrowserWebSocket>>,
|
||||||
|
pub responder_connection: Signal<Option<RtcPeerConnection>>,
|
||||||
|
pub initiator_connection: Signal<Option<RtcPeerConnection>>,
|
||||||
|
pub local_media: Signal<Option<MediaStream>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn ChannelSidebar(props: ChannelSidebarProps) -> Element {
|
||||||
|
rsx! {
|
||||||
|
aside { class: "channel-sidebar",
|
||||||
|
div { class: "channel-sidebar__head",
|
||||||
|
h2 { "{props.channel_name}" }
|
||||||
|
p { "{props.channel_topic}" }
|
||||||
|
}
|
||||||
|
div { class: "channel-sidebar__body",
|
||||||
|
ConnectionPanel {
|
||||||
|
peer_id: props.peer_id.clone(),
|
||||||
|
remote_id: props.remote_id.clone(),
|
||||||
|
connected: props.connected.clone(),
|
||||||
|
websocket: props.websocket.clone(),
|
||||||
|
peer_connection: props.responder_connection.clone(),
|
||||||
|
initiator_connection: props.initiator_connection.clone(),
|
||||||
|
local_media: props.local_media.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "channel-sidebar__footer",
|
||||||
|
StatusDisplay { connected: props.connected.clone() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Props, Clone, PartialEq)]
|
||||||
|
pub struct ChannelHeaderProps {
|
||||||
|
pub name: String,
|
||||||
|
pub topic: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn ChannelHeader(props: ChannelHeaderProps) -> Element {
|
||||||
|
rsx! {
|
||||||
|
header { class: "channel-header",
|
||||||
|
div { class: "channel-header__text",
|
||||||
|
h1 { "{props.name}" }
|
||||||
|
p { "{props.topic}" }
|
||||||
|
}
|
||||||
|
div { class: "channel-header__actions",
|
||||||
|
button { class: "channel-pill", "Voice" }
|
||||||
|
button { class: "channel-pill secondary", "Active" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Props, Clone, PartialEq)]
|
||||||
|
pub struct ParticipantsGridProps {
|
||||||
|
pub participants: Signal<Vec<Participant>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn ParticipantsGrid(props: ParticipantsGridProps) -> Element {
|
||||||
|
let participants = props.participants.read();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
section { class: "participants-grid",
|
||||||
|
for participant in participants.iter() {
|
||||||
|
ParticipantTile { participant: participant.clone() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Props, Clone, PartialEq)]
|
||||||
|
pub struct ParticipantTileProps {
|
||||||
|
pub participant: Participant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn ParticipantTile(props: ParticipantTileProps) -> Element {
|
||||||
|
let mut classes = vec!["participant-tile".to_string()];
|
||||||
|
if props.participant.is_self {
|
||||||
|
classes.push("is-self".to_string());
|
||||||
|
}
|
||||||
|
if props.participant.is_speaking {
|
||||||
|
classes.push("is-speaking".to_string());
|
||||||
|
}
|
||||||
|
if props.participant.is_muted {
|
||||||
|
classes.push("is-muted".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: classes.join(" "),
|
||||||
|
div { class: "participant-avatar",
|
||||||
|
style: "background: {props.participant.avatar_color};",
|
||||||
|
span { class: "participant-initials", "{initials(&props.participant.display_name)}" }
|
||||||
|
if props.participant.is_muted {
|
||||||
|
span { class: "participant-badge badge-mute", "🔇" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "participant-meta",
|
||||||
|
span { class: "participant-name", "{props.participant.display_name}" }
|
||||||
|
if props.participant.is_self {
|
||||||
|
span { class: "participant-tag", "(You)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initials(name: &str) -> String {
|
||||||
|
let mut letters = name
|
||||||
|
.split_whitespace()
|
||||||
|
.filter_map(|part| part.chars().next())
|
||||||
|
.map(|c| c.to_ascii_uppercase());
|
||||||
|
|
||||||
|
match (letters.next(), letters.next()) {
|
||||||
|
(Some(first), Some(second)) => format!("{}{}", first, second),
|
||||||
|
(Some(first), None) => first.to_string(),
|
||||||
|
_ => "?".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Props, Clone, PartialEq)]
|
||||||
|
pub struct ControlDockProps {
|
||||||
|
pub peer_id: Signal<String>,
|
||||||
|
pub remote_id: Signal<String>,
|
||||||
|
pub connected: Signal<bool>,
|
||||||
|
pub websocket: Signal<Option<BrowserWebSocket>>,
|
||||||
|
pub initiator_connection: Signal<Option<RtcPeerConnection>>,
|
||||||
|
pub local_media: Signal<Option<MediaStream>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn ControlDock(props: ControlDockProps) -> Element {
|
||||||
|
rsx! {
|
||||||
|
footer { class: "control-dock",
|
||||||
|
CallControls {
|
||||||
|
peer_id: props.peer_id.clone(),
|
||||||
|
remote_id: props.remote_id.clone(),
|
||||||
|
connected: props.connected.clone(),
|
||||||
|
websocket: props.websocket.clone(),
|
||||||
|
peer_connection: props.initiator_connection.clone(),
|
||||||
|
local_media: props.local_media.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/main.rs
49
src/main.rs
@ -1,10 +1,11 @@
|
|||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use console_log::init_with_level;
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use log::Level;
|
use log::Level;
|
||||||
use console_log::init_with_level;
|
use niom_webrtc::components::VoiceChannelLayout;
|
||||||
use niom_webrtc::components::{ConnectionPanel, CallControls, StatusDisplay};
|
use niom_webrtc::models::Participant;
|
||||||
use web_sys::{RtcPeerConnection, WebSocket as BrowserWebSocket, MediaStream};
|
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
|
||||||
// config functions used via fully-qualified paths below
|
// config functions used via fully-qualified paths below
|
||||||
|
|
||||||
const FAVICON: Asset = asset!("/assets/favicon.ico");
|
const FAVICON: Asset = asset!("/assets/favicon.ico");
|
||||||
@ -64,37 +65,31 @@ pub fn Content() -> Element {
|
|||||||
let websocket = use_signal(|| None::<BrowserWebSocket>);
|
let websocket = use_signal(|| None::<BrowserWebSocket>);
|
||||||
let initiator_connection = use_signal(|| None::<RtcPeerConnection>);
|
let initiator_connection = use_signal(|| None::<RtcPeerConnection>);
|
||||||
let responder_connection = use_signal(|| None::<RtcPeerConnection>);
|
let responder_connection = use_signal(|| None::<RtcPeerConnection>);
|
||||||
// globaler Signal für den lokal freigegebenen MediaStream (Mikrofon)
|
|
||||||
let local_media = use_signal(|| None::<MediaStream>);
|
let local_media = use_signal(|| None::<MediaStream>);
|
||||||
|
|
||||||
|
let participants = use_signal(|| {
|
||||||
|
vec![
|
||||||
|
Participant::new("self", "Ghost", "#5865F2", true, false, true),
|
||||||
|
Participant::new("mod-1", "Nia Moderator", "#43B581", false, false, true),
|
||||||
|
Participant::new("listener-1", "Amber", "#FAA61A", false, true, false),
|
||||||
|
Participant::new("listener-2", "Basil", "#EB459E", false, false, false),
|
||||||
|
Participant::new("listener-3", "Colt", "#5865F2", false, true, false),
|
||||||
|
Participant::new("listener-4", "Delta", "#99AAB5", false, false, false),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "app-container",
|
VoiceChannelLayout {
|
||||||
header {
|
channel_name: "Project Alpha / Voice Lounge".to_string(),
|
||||||
h1 { "Voice Chat MVP" }
|
channel_topic: "Team sync & architecture deep dive".to_string(),
|
||||||
p { "Einfache WebRTC-Demo ohne Signal-Chaos" }
|
participants,
|
||||||
}
|
|
||||||
main { class: "main-content",
|
|
||||||
ConnectionPanel {
|
|
||||||
peer_id,
|
peer_id,
|
||||||
remote_id,
|
remote_id,
|
||||||
connected,
|
connected,
|
||||||
websocket,
|
websocket,
|
||||||
peer_connection: responder_connection,
|
responder_connection,
|
||||||
initiator_connection: initiator_connection,
|
initiator_connection,
|
||||||
local_media: local_media
|
local_media,
|
||||||
}
|
|
||||||
CallControls {
|
|
||||||
peer_id,
|
|
||||||
remote_id,
|
|
||||||
connected,
|
|
||||||
websocket,
|
|
||||||
peer_connection: initiator_connection,
|
|
||||||
local_media: local_media
|
|
||||||
}
|
|
||||||
StatusDisplay {
|
|
||||||
connected,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
mod media_state;
|
mod media_state;
|
||||||
|
mod participant;
|
||||||
mod signaling_message;
|
mod signaling_message;
|
||||||
|
|
||||||
pub use media_state::MediaState;
|
pub use media_state::MediaState;
|
||||||
|
pub use participant::Participant;
|
||||||
pub use signaling_message::SignalingMessage;
|
pub use signaling_message::SignalingMessage;
|
||||||
|
|||||||
31
src/models/participant.rs
Normal file
31
src/models/participant.rs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Participant {
|
||||||
|
pub id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub avatar_color: String,
|
||||||
|
pub is_self: bool,
|
||||||
|
pub is_muted: bool,
|
||||||
|
pub is_speaking: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Participant {
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<String>,
|
||||||
|
display_name: impl Into<String>,
|
||||||
|
avatar_color: impl Into<String>,
|
||||||
|
is_self: bool,
|
||||||
|
is_muted: bool,
|
||||||
|
is_speaking: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
display_name: display_name.into(),
|
||||||
|
avatar_color: avatar_color.into(),
|
||||||
|
is_self,
|
||||||
|
is_muted,
|
||||||
|
is_speaking,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user