diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 2b5bb46..14c0b17 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -9,12 +9,12 @@ export const routes: Routes = [
{
path: 'login',
loadComponent: () =>
- import('./features/auth/login.component').then((m) => m.LoginComponent),
+ import('./features/auth/login/login.component').then((m) => m.LoginComponent),
},
{
path: 'register',
loadComponent: () =>
- import('./features/auth/register.component').then((m) => m.RegisterComponent),
+ import('./features/auth/register/register.component').then((m) => m.RegisterComponent),
},
{
path: 'search',
@@ -26,7 +26,7 @@ export const routes: Routes = [
{
path: 'room/:roomId',
loadComponent: () =>
- import('./features/room/chat-room.component').then((m) => m.ChatRoomComponent),
+ import('./features/room/chat-room/chat-room.component').then((m) => m.ChatRoomComponent),
},
{
path: 'settings',
diff --git a/src/app/app.ts b/src/app/app.ts
index f660f59..e27564c 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -9,7 +9,7 @@ import { TimeSyncService } from './core/services/time-sync.service';
import { VoiceSessionService } from './core/services/voice-session.service';
import { ServersRailComponent } from './features/servers/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar.component';
-import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls.component';
+import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
import * as UsersActions from './store/users/users.actions';
import * as RoomsActions from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
diff --git a/src/app/core/services/webrtc.service.ts b/src/app/core/services/webrtc.service.ts
index 484a37e..06f2983 100644
--- a/src/app/core/services/webrtc.service.ts
+++ b/src/app/core/services/webrtc.service.ts
@@ -1119,7 +1119,7 @@ export class WebRTCService {
this._screenStream = null;
this._screenStreamSignal.set(null);
this._isScreenSharing.set(false);
-
+
// Immediately broadcast that we stopped sharing
this.broadcastCurrentStates();
}
diff --git a/src/app/features/admin/admin-panel.component.ts b/src/app/features/admin/admin-panel.component.ts
deleted file mode 100644
index 35a4823..0000000
--- a/src/app/features/admin/admin-panel.component.ts
+++ /dev/null
@@ -1,467 +0,0 @@
-import { Component, inject, signal } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { FormsModule } from '@angular/forms';
-import { Store } from '@ngrx/store';
-import { NgIcon, provideIcons } from '@ng-icons/core';
-import {
- lucideShield,
- lucideBan,
- lucideUserX,
- lucideSettings,
- lucideUsers,
- lucideTrash2,
- lucideCheck,
- lucideX,
- lucideLock,
- lucideUnlock,
-} from '@ng-icons/lucide';
-
-import * as UsersActions from '../../store/users/users.actions';
-import * as RoomsActions from '../../store/rooms/rooms.actions';
-import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
-import {
- selectBannedUsers,
- selectIsCurrentUserAdmin,
- selectCurrentUser,
-} from '../../store/users/users.selectors';
-import { BanEntry, Room } from '../../core/models';
-
-type AdminTab = 'settings' | 'bans' | 'permissions';
-
-@Component({
- selector: 'app-admin-panel',
- standalone: true,
- imports: [CommonModule, FormsModule, NgIcon],
- viewProviders: [
- provideIcons({
- lucideShield,
- lucideBan,
- lucideUserX,
- lucideSettings,
- lucideUsers,
- lucideTrash2,
- lucideCheck,
- lucideX,
- lucideLock,
- lucideUnlock,
- }),
- ],
- template: `
- @if (isAdmin()) {
-
-
-
-
-
Admin Panel
-
-
-
-
-
-
- Settings
-
-
-
- Bans
-
-
-
- Permissions
-
-
-
-
-
- @switch (activeTab()) {
- @case ('settings') {
-
-
Room Settings
-
-
-
- Room Name
-
-
-
-
-
- Description
-
-
-
-
-
-
-
Private Room
-
Require approval to join
-
-
- @if (isPrivate()) {
-
- } @else {
-
- }
-
-
-
-
-
- Max Users (0 = unlimited)
-
-
-
-
-
-
- Save Settings
-
-
-
-
-
Danger Zone
-
-
- Delete Room
-
-
-
- }
- @case ('bans') {
-
-
Banned Users
-
- @if (bannedUsers().length === 0) {
-
- No banned users
-
- } @else {
- @for (ban of bannedUsers(); track ban.oderId) {
-
-
- {{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }}
-
-
-
- {{ ban.displayName || 'Unknown User' }}
-
- @if (ban.reason) {
-
- Reason: {{ ban.reason }}
-
- }
- @if (ban.expiresAt) {
-
- Expires: {{ formatExpiry(ban.expiresAt) }}
-
- } @else {
-
Permanent
- }
-
-
-
-
-
- }
- }
-
- }
- @case ('permissions') {
-
-
Room Permissions
-
-
-
-
-
-
Allow Voice Chat
-
Users can join voice channels
-
-
-
-
-
-
-
Allow Screen Share
-
Users can share their screen
-
-
-
-
-
-
-
Allow File Uploads
-
Users can upload files
-
-
-
-
-
-
-
Slow Mode
-
Limit message frequency
-
-
- Off
- 5 seconds
- 10 seconds
- 30 seconds
- 1 minute
-
-
-
-
-
-
-
Admins Can Manage Rooms
-
Allow admins to create/modify chat & voice rooms
-
-
-
-
-
-
-
Moderators Can Manage Rooms
-
Allow moderators to create/modify chat & voice rooms
-
-
-
-
-
-
-
Admins Can Change Server Icon
-
Grant icon management to admins
-
-
-
-
-
-
-
Moderators Can Change Server Icon
-
Grant icon management to moderators
-
-
-
-
-
-
-
-
- Save Permissions
-
-
- }
- }
-
-
-
-
- @if (showDeleteConfirm()) {
-
-
-
Delete Room
-
- Are you sure you want to delete this room? This action cannot be undone.
-
-
-
- Cancel
-
-
- Delete Room
-
-
-
-
- }
- } @else {
-
-
You don't have admin permissions
-
- }
- `,
-})
-export class AdminPanelComponent {
- private store = inject(Store);
-
- currentRoom = this.store.selectSignal(selectCurrentRoom);
- currentUser = this.store.selectSignal(selectCurrentUser);
- isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
- bannedUsers = this.store.selectSignal(selectBannedUsers);
-
- activeTab = signal('settings');
- showDeleteConfirm = signal(false);
-
- // Settings
- roomName = '';
- roomDescription = '';
- isPrivate = signal(false);
- maxUsers = 0;
-
- // Permissions
- allowVoice = true;
- allowScreenShare = true;
- allowFileUploads = true;
- slowModeInterval = '0';
- adminsManageRooms = false;
- moderatorsManageRooms = false;
- adminsManageIcon = false;
- moderatorsManageIcon = false;
-
- constructor() {
- // Initialize from current room
- const room = this.currentRoom();
- if (room) {
- this.roomName = room.name;
- this.roomDescription = room.description || '';
- this.isPrivate.set(room.isPrivate);
- this.maxUsers = room.maxUsers || 0;
- const perms = room.permissions || {};
- this.allowVoice = perms.allowVoice !== false;
- this.allowScreenShare = perms.allowScreenShare !== false;
- this.allowFileUploads = perms.allowFileUploads !== false;
- this.slowModeInterval = String(perms.slowModeInterval ?? 0);
- this.adminsManageRooms = !!perms.adminsManageRooms;
- this.moderatorsManageRooms = !!perms.moderatorsManageRooms;
- this.adminsManageIcon = !!perms.adminsManageIcon;
- this.moderatorsManageIcon = !!perms.moderatorsManageIcon;
- }
- }
-
- togglePrivate(): void {
- this.isPrivate.update((v) => !v);
- }
-
- saveSettings(): void {
- const room = this.currentRoom();
- if (!room) return;
-
- this.store.dispatch(
- RoomsActions.updateRoom({
- roomId: room.id,
- changes: {
- name: this.roomName,
- description: this.roomDescription,
- isPrivate: this.isPrivate(),
- maxUsers: this.maxUsers,
- },
- })
- );
- }
-
- savePermissions(): void {
- const room = this.currentRoom();
- if (!room) return;
-
- this.store.dispatch(
- RoomsActions.updateRoomPermissions({
- roomId: room.id,
- permissions: {
- allowVoice: this.allowVoice,
- allowScreenShare: this.allowScreenShare,
- allowFileUploads: this.allowFileUploads,
- slowModeInterval: parseInt(this.slowModeInterval, 10),
- adminsManageRooms: this.adminsManageRooms,
- moderatorsManageRooms: this.moderatorsManageRooms,
- adminsManageIcon: this.adminsManageIcon,
- moderatorsManageIcon: this.moderatorsManageIcon,
- },
- })
- );
- }
-
- unbanUser(ban: BanEntry): void {
- this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
- }
-
- confirmDeleteRoom(): void {
- this.showDeleteConfirm.set(true);
- }
-
- deleteRoom(): void {
- const room = this.currentRoom();
- if (!room) return;
-
- this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
- this.showDeleteConfirm.set(false);
- }
-
- formatExpiry(timestamp: number): string {
- const date = new Date(timestamp);
- return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- }
-}
diff --git a/src/app/features/admin/admin-panel/admin-panel.component.html b/src/app/features/admin/admin-panel/admin-panel.component.html
new file mode 100644
index 0000000..2060bbb
--- /dev/null
+++ b/src/app/features/admin/admin-panel/admin-panel.component.html
@@ -0,0 +1,306 @@
+@if (isAdmin()) {
+
+
+
+
+
Admin Panel
+
+
+
+
+
+
+ Settings
+
+
+
+ Bans
+
+
+
+ Permissions
+
+
+
+
+
+ @switch (activeTab()) {
+ @case ('settings') {
+
+
Room Settings
+
+
+
+ Room Name
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
Private Room
+
Require approval to join
+
+
+ @if (isPrivate()) {
+
+ } @else {
+
+ }
+
+
+
+
+
+ Max Users (0 = unlimited)
+
+
+
+
+
+
+ Save Settings
+
+
+
+
+
Danger Zone
+
+
+ Delete Room
+
+
+
+ }
+ @case ('bans') {
+
+
Banned Users
+
+ @if (bannedUsers().length === 0) {
+
+ No banned users
+
+ } @else {
+ @for (ban of bannedUsers(); track ban.oderId) {
+
+
+ {{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }}
+
+
+
+ {{ ban.displayName || 'Unknown User' }}
+
+ @if (ban.reason) {
+
+ Reason: {{ ban.reason }}
+
+ }
+ @if (ban.expiresAt) {
+
+ Expires: {{ formatExpiry(ban.expiresAt) }}
+
+ } @else {
+
Permanent
+ }
+
+
+
+
+
+ }
+ }
+
+ }
+ @case ('permissions') {
+
+
Room Permissions
+
+
+
+
+
+
Allow Voice Chat
+
Users can join voice channels
+
+
+
+
+
+
+
Allow Screen Share
+
Users can share their screen
+
+
+
+
+
+
+
Allow File Uploads
+
Users can upload files
+
+
+
+
+
+
+
Slow Mode
+
Limit message frequency
+
+
+ Off
+ 5 seconds
+ 10 seconds
+ 30 seconds
+ 1 minute
+
+
+
+
+
+
+
Admins Can Manage Rooms
+
Allow admins to create/modify chat & voice rooms
+
+
+
+
+
+
+
Moderators Can Manage Rooms
+
Allow moderators to create/modify chat & voice rooms
+
+
+
+
+
+
+
Admins Can Change Server Icon
+
Grant icon management to admins
+
+
+
+
+
+
+
Moderators Can Change Server Icon
+
Grant icon management to moderators
+
+
+
+
+
+
+
+
+ Save Permissions
+
+
+ }
+ }
+
+
+
+
+ @if (showDeleteConfirm()) {
+
+
+
Delete Room
+
+ Are you sure you want to delete this room? This action cannot be undone.
+
+
+
+ Cancel
+
+
+ Delete Room
+
+
+
+
+ }
+} @else {
+
+
You don't have admin permissions
+
+}
diff --git a/src/app/features/admin/admin-panel/admin-panel.component.ts b/src/app/features/admin/admin-panel/admin-panel.component.ts
new file mode 100644
index 0000000..6bba8e4
--- /dev/null
+++ b/src/app/features/admin/admin-panel/admin-panel.component.ts
@@ -0,0 +1,160 @@
+import { Component, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Store } from '@ngrx/store';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import {
+ lucideShield,
+ lucideBan,
+ lucideUserX,
+ lucideSettings,
+ lucideUsers,
+ lucideTrash2,
+ lucideCheck,
+ lucideX,
+ lucideLock,
+ lucideUnlock,
+} from '@ng-icons/lucide';
+
+import * as UsersActions from '../../../store/users/users.actions';
+import * as RoomsActions from '../../../store/rooms/rooms.actions';
+import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
+import {
+ selectBannedUsers,
+ selectIsCurrentUserAdmin,
+ selectCurrentUser,
+} from '../../../store/users/users.selectors';
+import { BanEntry, Room } from '../../../core/models';
+
+type AdminTab = 'settings' | 'bans' | 'permissions';
+
+@Component({
+ selector: 'app-admin-panel',
+ standalone: true,
+ imports: [CommonModule, FormsModule, NgIcon],
+ viewProviders: [
+ provideIcons({
+ lucideShield,
+ lucideBan,
+ lucideUserX,
+ lucideSettings,
+ lucideUsers,
+ lucideTrash2,
+ lucideCheck,
+ lucideX,
+ lucideLock,
+ lucideUnlock,
+ }),
+ ],
+ templateUrl: './admin-panel.component.html',
+})
+export class AdminPanelComponent {
+ private store = inject(Store);
+
+ currentRoom = this.store.selectSignal(selectCurrentRoom);
+ currentUser = this.store.selectSignal(selectCurrentUser);
+ isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
+ bannedUsers = this.store.selectSignal(selectBannedUsers);
+
+ activeTab = signal('settings');
+ showDeleteConfirm = signal(false);
+
+ // Settings
+ roomName = '';
+ roomDescription = '';
+ isPrivate = signal(false);
+ maxUsers = 0;
+
+ // Permissions
+ allowVoice = true;
+ allowScreenShare = true;
+ allowFileUploads = true;
+ slowModeInterval = '0';
+ adminsManageRooms = false;
+ moderatorsManageRooms = false;
+ adminsManageIcon = false;
+ moderatorsManageIcon = false;
+
+ constructor() {
+ // Initialize from current room
+ const room = this.currentRoom();
+ if (room) {
+ this.roomName = room.name;
+ this.roomDescription = room.description || '';
+ this.isPrivate.set(room.isPrivate);
+ this.maxUsers = room.maxUsers || 0;
+ const perms = room.permissions || {};
+ this.allowVoice = perms.allowVoice !== false;
+ this.allowScreenShare = perms.allowScreenShare !== false;
+ this.allowFileUploads = perms.allowFileUploads !== false;
+ this.slowModeInterval = String(perms.slowModeInterval ?? 0);
+ this.adminsManageRooms = !!perms.adminsManageRooms;
+ this.moderatorsManageRooms = !!perms.moderatorsManageRooms;
+ this.adminsManageIcon = !!perms.adminsManageIcon;
+ this.moderatorsManageIcon = !!perms.moderatorsManageIcon;
+ }
+ }
+
+ togglePrivate(): void {
+ this.isPrivate.update((v) => !v);
+ }
+
+ saveSettings(): void {
+ const room = this.currentRoom();
+ if (!room) return;
+
+ this.store.dispatch(
+ RoomsActions.updateRoom({
+ roomId: room.id,
+ changes: {
+ name: this.roomName,
+ description: this.roomDescription,
+ isPrivate: this.isPrivate(),
+ maxUsers: this.maxUsers,
+ },
+ })
+ );
+ }
+
+ savePermissions(): void {
+ const room = this.currentRoom();
+ if (!room) return;
+
+ this.store.dispatch(
+ RoomsActions.updateRoomPermissions({
+ roomId: room.id,
+ permissions: {
+ allowVoice: this.allowVoice,
+ allowScreenShare: this.allowScreenShare,
+ allowFileUploads: this.allowFileUploads,
+ slowModeInterval: parseInt(this.slowModeInterval, 10),
+ adminsManageRooms: this.adminsManageRooms,
+ moderatorsManageRooms: this.moderatorsManageRooms,
+ adminsManageIcon: this.adminsManageIcon,
+ moderatorsManageIcon: this.moderatorsManageIcon,
+ },
+ })
+ );
+ }
+
+ unbanUser(ban: BanEntry): void {
+ this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
+ }
+
+ confirmDeleteRoom(): void {
+ this.showDeleteConfirm.set(true);
+ }
+
+ deleteRoom(): void {
+ const room = this.currentRoom();
+ if (!room) return;
+
+ this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
+ this.showDeleteConfirm.set(false);
+ }
+
+ formatExpiry(timestamp: number): string {
+ const date = new Date(timestamp);
+ return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ }
+}
diff --git a/src/app/features/auth/login.component.ts b/src/app/features/auth/login.component.ts
deleted file mode 100644
index ad4770a..0000000
--- a/src/app/features/auth/login.component.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { Component, inject, signal } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { FormsModule } from '@angular/forms';
-import { Router } from '@angular/router';
-import { Store } from '@ngrx/store';
-import { NgIcon, provideIcons } from '@ng-icons/core';
-import { lucideLogIn } from '@ng-icons/lucide';
-
-import { AuthService } from '../../core/services/auth.service';
-import { ServerDirectoryService } from '../../core/services/server-directory.service';
-import * as UsersActions from '../../store/users/users.actions';
-import { User } from '../../core/models';
-
-@Component({
- selector: 'app-login',
- standalone: true,
- imports: [CommonModule, FormsModule, NgIcon],
- viewProviders: [provideIcons({ lucideLogIn })],
- template: `
-
-
-
-
-
Login
-
-
-
-
- Username
-
-
-
- Password
-
-
-
- Server App
-
- {{ s.name }}
-
-
-
{{ error() }}
-
Login
-
-
-
-
- `,
-})
-export class LoginComponent {
- private auth = inject(AuthService);
- private serversSvc = inject(ServerDirectoryService);
- private store = inject(Store);
- private router = inject(Router);
-
- servers = this.serversSvc.servers;
- username = '';
- password = '';
- serverId: string | undefined = this.serversSvc.activeServer()?.id;
- error = signal(null);
-
- trackById(_index: number, item: { id: string }) { return item.id; }
-
- submit() {
- this.error.set(null);
- const sid = this.serverId || this.serversSvc.activeServer()?.id;
- this.auth.login({ username: this.username.trim(), password: this.password, serverId: sid }).subscribe({
- next: (resp) => {
- if (sid) this.serversSvc.setActiveServer(sid);
- const user: User = {
- id: resp.id,
- oderId: resp.id,
- username: resp.username,
- displayName: resp.displayName,
- status: 'online',
- role: 'member',
- joinedAt: Date.now(),
- };
- try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
- this.store.dispatch(UsersActions.setCurrentUser({ user }));
- this.router.navigate(['/search']);
- },
- error: (err) => {
- this.error.set(err?.error?.error || 'Login failed');
- },
- });
- }
-
- goRegister() {
- this.router.navigate(['/register']);
- }
-}
diff --git a/src/app/features/auth/login/login.component.html b/src/app/features/auth/login/login.component.html
new file mode 100644
index 0000000..9040f98
--- /dev/null
+++ b/src/app/features/auth/login/login.component.html
@@ -0,0 +1,30 @@
+
+
+
+
+
Login
+
+
+
+
+ Username
+
+
+
+ Password
+
+
+
+ Server App
+
+ {{ s.name }}
+
+
+
{{ error() }}
+
Login
+
+
+
+
diff --git a/src/app/features/auth/login/login.component.ts b/src/app/features/auth/login/login.component.ts
new file mode 100644
index 0000000..41b899b
--- /dev/null
+++ b/src/app/features/auth/login/login.component.ts
@@ -0,0 +1,63 @@
+import { Component, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { Store } from '@ngrx/store';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideLogIn } from '@ng-icons/lucide';
+
+import { AuthService } from '../../../core/services/auth.service';
+import { ServerDirectoryService } from '../../../core/services/server-directory.service';
+import * as UsersActions from '../../../store/users/users.actions';
+import { User } from '../../../core/models';
+
+@Component({
+ selector: 'app-login',
+ standalone: true,
+ imports: [CommonModule, FormsModule, NgIcon],
+ viewProviders: [provideIcons({ lucideLogIn })],
+ templateUrl: './login.component.html',
+})
+export class LoginComponent {
+ private auth = inject(AuthService);
+ private serversSvc = inject(ServerDirectoryService);
+ private store = inject(Store);
+ private router = inject(Router);
+
+ servers = this.serversSvc.servers;
+ username = '';
+ password = '';
+ serverId: string | undefined = this.serversSvc.activeServer()?.id;
+ error = signal(null);
+
+ trackById(_index: number, item: { id: string }) { return item.id; }
+
+ submit() {
+ this.error.set(null);
+ const sid = this.serverId || this.serversSvc.activeServer()?.id;
+ this.auth.login({ username: this.username.trim(), password: this.password, serverId: sid }).subscribe({
+ next: (resp) => {
+ if (sid) this.serversSvc.setActiveServer(sid);
+ const user: User = {
+ id: resp.id,
+ oderId: resp.id,
+ username: resp.username,
+ displayName: resp.displayName,
+ status: 'online',
+ role: 'member',
+ joinedAt: Date.now(),
+ };
+ try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
+ this.store.dispatch(UsersActions.setCurrentUser({ user }));
+ this.router.navigate(['/search']);
+ },
+ error: (err) => {
+ this.error.set(err?.error?.error || 'Login failed');
+ },
+ });
+ }
+
+ goRegister() {
+ this.router.navigate(['/register']);
+ }
+}
diff --git a/src/app/features/auth/register.component.ts b/src/app/features/auth/register.component.ts
deleted file mode 100644
index 49ad3c7..0000000
--- a/src/app/features/auth/register.component.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { Component, inject, signal } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { FormsModule } from '@angular/forms';
-import { Router } from '@angular/router';
-import { Store } from '@ngrx/store';
-import { NgIcon, provideIcons } from '@ng-icons/core';
-import { lucideUserPlus } from '@ng-icons/lucide';
-
-import { AuthService } from '../../core/services/auth.service';
-import { ServerDirectoryService } from '../../core/services/server-directory.service';
-import * as UsersActions from '../../store/users/users.actions';
-import { User } from '../../core/models';
-
-@Component({
- selector: 'app-register',
- standalone: true,
- imports: [CommonModule, FormsModule, NgIcon],
- viewProviders: [provideIcons({ lucideUserPlus })],
- template: `
-
-
-
-
-
Register
-
-
-
-
- Username
-
-
-
- Display Name
-
-
-
- Password
-
-
-
- Server App
-
- {{ s.name }}
-
-
-
{{ error() }}
-
Create Account
-
- Have an account?
Login
-
-
-
-
- `,
-})
-export class RegisterComponent {
- private auth = inject(AuthService);
- private serversSvc = inject(ServerDirectoryService);
- private store = inject(Store);
- private router = inject(Router);
-
- servers = this.serversSvc.servers;
- username = '';
- displayName = '';
- password = '';
- serverId: string | undefined = this.serversSvc.activeServer()?.id;
- error = signal(null);
-
- trackById(_index: number, item: { id: string }) { return item.id; }
-
- submit() {
- this.error.set(null);
- const sid = this.serverId || this.serversSvc.activeServer()?.id;
- this.auth.register({ username: this.username.trim(), password: this.password, displayName: this.displayName.trim(), serverId: sid }).subscribe({
- next: (resp) => {
- if (sid) this.serversSvc.setActiveServer(sid);
- const user: User = {
- id: resp.id,
- oderId: resp.id,
- username: resp.username,
- displayName: resp.displayName,
- status: 'online',
- role: 'member',
- joinedAt: Date.now(),
- };
- try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
- this.store.dispatch(UsersActions.setCurrentUser({ user }));
- this.router.navigate(['/search']);
- },
- error: (err) => {
- this.error.set(err?.error?.error || 'Registration failed');
- },
- });
- }
-
- goLogin() {
- this.router.navigate(['/login']);
- }
-}
diff --git a/src/app/features/auth/register/register.component.html b/src/app/features/auth/register/register.component.html
new file mode 100644
index 0000000..877b802
--- /dev/null
+++ b/src/app/features/auth/register/register.component.html
@@ -0,0 +1,34 @@
+
+
+
+
+
Register
+
+
+
+
+ Username
+
+
+
+ Display Name
+
+
+
+ Password
+
+
+
+ Server App
+
+ {{ s.name }}
+
+
+
{{ error() }}
+
Create Account
+
+ Have an account?
Login
+
+
+
+
diff --git a/src/app/features/auth/register/register.component.ts b/src/app/features/auth/register/register.component.ts
new file mode 100644
index 0000000..4457aaf
--- /dev/null
+++ b/src/app/features/auth/register/register.component.ts
@@ -0,0 +1,64 @@
+import { Component, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { Store } from '@ngrx/store';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideUserPlus } from '@ng-icons/lucide';
+
+import { AuthService } from '../../../core/services/auth.service';
+import { ServerDirectoryService } from '../../../core/services/server-directory.service';
+import * as UsersActions from '../../../store/users/users.actions';
+import { User } from '../../../core/models';
+
+@Component({
+ selector: 'app-register',
+ standalone: true,
+ imports: [CommonModule, FormsModule, NgIcon],
+ viewProviders: [provideIcons({ lucideUserPlus })],
+ templateUrl: './register.component.html',
+})
+export class RegisterComponent {
+ private auth = inject(AuthService);
+ private serversSvc = inject(ServerDirectoryService);
+ private store = inject(Store);
+ private router = inject(Router);
+
+ servers = this.serversSvc.servers;
+ username = '';
+ displayName = '';
+ password = '';
+ serverId: string | undefined = this.serversSvc.activeServer()?.id;
+ error = signal(null);
+
+ trackById(_index: number, item: { id: string }) { return item.id; }
+
+ submit() {
+ this.error.set(null);
+ const sid = this.serverId || this.serversSvc.activeServer()?.id;
+ this.auth.register({ username: this.username.trim(), password: this.password, displayName: this.displayName.trim(), serverId: sid }).subscribe({
+ next: (resp) => {
+ if (sid) this.serversSvc.setActiveServer(sid);
+ const user: User = {
+ id: resp.id,
+ oderId: resp.id,
+ username: resp.username,
+ displayName: resp.displayName,
+ status: 'online',
+ role: 'member',
+ joinedAt: Date.now(),
+ };
+ try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
+ this.store.dispatch(UsersActions.setCurrentUser({ user }));
+ this.router.navigate(['/search']);
+ },
+ error: (err) => {
+ this.error.set(err?.error?.error || 'Registration failed');
+ },
+ });
+ }
+
+ goLogin() {
+ this.router.navigate(['/login']);
+ }
+}
diff --git a/src/app/features/auth/user-bar.component.ts b/src/app/features/auth/user-bar.component.ts
deleted file mode 100644
index e59cdf9..0000000
--- a/src/app/features/auth/user-bar.component.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { Component, inject } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { Router } from '@angular/router';
-import { Store } from '@ngrx/store';
-import { NgIcon, provideIcons } from '@ng-icons/core';
-import { lucideUser, lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
-import { selectCurrentUser } from '../../store/users/users.selectors';
-
-@Component({
- selector: 'app-user-bar',
- standalone: true,
- imports: [CommonModule, NgIcon],
- viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
- template: `
-
-
- @if (user()) {
-
-
- {{ user()?.displayName }}
-
- } @else {
-
-
- Login
-
-
-
- Register
-
- }
-
- `,
-})
-export class UserBarComponent {
- private store = inject(Store);
- private router = inject(Router);
- user = this.store.selectSignal(selectCurrentUser);
-
- goto(path: 'login' | 'register') {
- this.router.navigate([`/${path}`]);
- }
-}
diff --git a/src/app/features/auth/user-bar/user-bar.component.html b/src/app/features/auth/user-bar/user-bar.component.html
new file mode 100644
index 0000000..52aa899
--- /dev/null
+++ b/src/app/features/auth/user-bar/user-bar.component.html
@@ -0,0 +1,18 @@
+
+
+ @if (user()) {
+
+
+ {{ user()?.displayName }}
+
+ } @else {
+
+
+ Login
+
+
+
+ Register
+
+ }
+
diff --git a/src/app/features/auth/user-bar/user-bar.component.ts b/src/app/features/auth/user-bar/user-bar.component.ts
new file mode 100644
index 0000000..9d0bcd1
--- /dev/null
+++ b/src/app/features/auth/user-bar/user-bar.component.ts
@@ -0,0 +1,24 @@
+import { Component, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { Router } from '@angular/router';
+import { Store } from '@ngrx/store';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideUser, lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
+import { selectCurrentUser } from '../../../store/users/users.selectors';
+
+@Component({
+ selector: 'app-user-bar',
+ standalone: true,
+ imports: [CommonModule, NgIcon],
+ viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
+ templateUrl: './user-bar.component.html',
+})
+export class UserBarComponent {
+ private store = inject(Store);
+ private router = inject(Router);
+ user = this.store.selectSignal(selectCurrentUser);
+
+ goto(path: 'login' | 'register') {
+ this.router.navigate([`/${path}`]);
+ }
+}
diff --git a/src/app/features/chat/chat-messages.component.ts b/src/app/features/chat/chat-messages.component.ts
index 3801c08..9b3223d 100644
--- a/src/app/features/chat/chat-messages.component.ts
+++ b/src/app/features/chat/chat-messages.component.ts
@@ -752,7 +752,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
const safeHtml = DOMPurify.sanitize(container.innerHTML);
return this.sanitizer.bypassSecurityTrustHtml(safeHtml);
}
-
+
// Resolve images marked for CSP-safe loading by converting to blob URLs
private async loadCspImages(): Promise {
const root = this.messagesContainer?.nativeElement;
diff --git a/src/app/features/room/chat-room.component.ts b/src/app/features/room/chat-room.component.ts
deleted file mode 100644
index a854e71..0000000
--- a/src/app/features/room/chat-room.component.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { Component, inject, signal } from '@angular/core';
-import { Router } from '@angular/router';
-import { CommonModule } from '@angular/common';
-import { Store } from '@ngrx/store';
-import { NgIcon, provideIcons } from '@ng-icons/core';
-import {
- lucideHash,
- lucideSettings,
- lucideUsers,
- lucideMenu,
- lucideX,
- lucideChevronLeft,
-} from '@ng-icons/lucide';
-
-import { ChatMessagesComponent } from '../chat/chat-messages.component';
-import { UserListComponent } from '../chat/user-list.component';
-import { ScreenShareViewerComponent } from '../voice/screen-share-viewer.component';
-import { AdminPanelComponent } from '../admin/admin-panel.component';
-import { RoomsSidePanelComponent } from './rooms-side-panel.component';
-
-import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
-import { selectIsCurrentUserAdmin } from '../../store/users/users.selectors';
-
-type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
-
-@Component({
- selector: 'app-chat-room',
- standalone: true,
- imports: [
- CommonModule,
- NgIcon,
- ChatMessagesComponent,
- ScreenShareViewerComponent,
- RoomsSidePanelComponent,
- ],
- viewProviders: [
- provideIcons({
- lucideHash,
- lucideSettings,
- lucideUsers,
- lucideMenu,
- lucideX,
- lucideChevronLeft,
- }),
- ],
- template: `
-
- @if (currentRoom()) {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- } @else {
-
-
-
-
-
No room selected
-
Select or create a room to start chatting
-
-
- }
-
- `,
-})
-export class ChatRoomComponent {
- private store = inject(Store);
- private router = inject(Router);
- showMenu = signal(false);
-
- currentRoom = this.store.selectSignal(selectCurrentRoom);
- isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
-
- // Sidebar always visible; panel toggles removed
-
- // Header moved to TitleBar
-}
diff --git a/src/app/features/room/chat-room/chat-room.component.html b/src/app/features/room/chat-room/chat-room.component.html
new file mode 100644
index 0000000..54bb5c4
--- /dev/null
+++ b/src/app/features/room/chat-room/chat-room.component.html
@@ -0,0 +1,37 @@
+
+ @if (currentRoom()) {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } @else {
+
+
+
+
+
No room selected
+
Select or create a room to start chatting
+
+
+ }
+
diff --git a/src/app/features/room/chat-room/chat-room.component.ts b/src/app/features/room/chat-room/chat-room.component.ts
new file mode 100644
index 0000000..565498d
--- /dev/null
+++ b/src/app/features/room/chat-room/chat-room.component.ts
@@ -0,0 +1,59 @@
+import { Component, inject, signal } from '@angular/core';
+import { Router } from '@angular/router';
+import { CommonModule } from '@angular/common';
+import { Store } from '@ngrx/store';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import {
+ lucideHash,
+ lucideSettings,
+ lucideUsers,
+ lucideMenu,
+ lucideX,
+ lucideChevronLeft,
+} from '@ng-icons/lucide';
+
+import { ChatMessagesComponent } from '../../chat/chat-messages.component';
+import { UserListComponent } from '../../chat/user-list.component';
+import { ScreenShareViewerComponent } from '../../voice/screen-share-viewer/screen-share-viewer.component';
+import { AdminPanelComponent } from '../../admin/admin-panel/admin-panel.component';
+import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
+
+import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
+import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
+
+type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
+
+@Component({
+ selector: 'app-chat-room',
+ standalone: true,
+ imports: [
+ CommonModule,
+ NgIcon,
+ ChatMessagesComponent,
+ ScreenShareViewerComponent,
+ RoomsSidePanelComponent,
+ ],
+ viewProviders: [
+ provideIcons({
+ lucideHash,
+ lucideSettings,
+ lucideUsers,
+ lucideMenu,
+ lucideX,
+ lucideChevronLeft,
+ }),
+ ],
+ templateUrl: './chat-room.component.html',
+})
+export class ChatRoomComponent {
+ private store = inject(Store);
+ private router = inject(Router);
+ showMenu = signal(false);
+
+ currentRoom = this.store.selectSignal(selectCurrentRoom);
+ isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
+
+ // Sidebar always visible; panel toggles removed
+
+ // Header moved to TitleBar
+}
diff --git a/src/app/features/room/rooms-side-panel.component.ts b/src/app/features/room/rooms-side-panel.component.ts
deleted file mode 100644
index aaf6408..0000000
--- a/src/app/features/room/rooms-side-panel.component.ts
+++ /dev/null
@@ -1,472 +0,0 @@
-import { Component, inject, signal } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { Store } from '@ngrx/store';
-import { NgIcon, provideIcons } from '@ng-icons/core';
-import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers } from '@ng-icons/lucide';
-import { selectOnlineUsers, selectCurrentUser } from '../../store/users/users.selectors';
-import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
-import * as UsersActions from '../../store/users/users.actions';
-import { WebRTCService } from '../../core/services/webrtc.service';
-import { VoiceSessionService } from '../../core/services/voice-session.service';
-import { VoiceControlsComponent } from '../voice/voice-controls.component';
-
-type TabView = 'channels' | 'users';
-
-@Component({
- selector: 'app-rooms-side-panel',
- standalone: true,
- imports: [CommonModule, NgIcon, VoiceControlsComponent],
- viewProviders: [
- provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers })
- ],
- template: `
-
-
-
-
-
-
-
- Channels
-
-
-
- Users
- {{ onlineUsers().length }}
-
-
-
-
-
- @if (activeTab() === 'channels') {
-
-
-
-
Text Channels
-
-
- # general
-
-
- # random
-
-
-
-
-
-
-
Voice Channels
- @if (!voiceEnabled()) {
-
Voice is disabled by host
- }
-
-
-
-
-
- 🔊 General
-
- @if (voiceOccupancy('general') > 0) {
- {{ voiceOccupancy('general') }}
- }
-
- @if (voiceUsersInRoom('general').length > 0) {
-
- @for (u of voiceUsersInRoom('general'); track u.id) {
-
- @if (u.avatarUrl) {
-
- } @else {
-
- {{ u.displayName.charAt(0).toUpperCase() }}
-
- }
-
{{ u.displayName }}
- @if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
-
- LIVE
-
- }
- @if (u.voiceState?.isMuted) {
-
- }
-
- }
-
- }
-
-
-
-
-
-
- 🔕 AFK
-
- @if (voiceOccupancy('afk') > 0) {
- {{ voiceOccupancy('afk') }}
- }
-
- @if (voiceUsersInRoom('afk').length > 0) {
-
- @for (u of voiceUsersInRoom('afk'); track u.id) {
-
- @if (u.avatarUrl) {
-
- } @else {
-
- {{ u.displayName.charAt(0).toUpperCase() }}
-
- }
-
{{ u.displayName }}
- @if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
-
- LIVE
-
- }
- @if (u.voiceState?.isMuted) {
-
- }
-
- }
-
- }
-
-
-
-
- }
-
-
- @if (activeTab() === 'users') {
-
-
- @if (currentUser()) {
-
-
You
-
-
- @if (currentUser()?.avatarUrl) {
-
- } @else {
-
- {{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
-
- }
-
-
-
-
{{ currentUser()?.displayName }}
-
- @if (currentUser()?.voiceState?.isConnected) {
-
-
- In voice
-
- }
- @if (currentUser()?.screenShareState?.isSharing || (currentUser()?.id && isUserSharing(currentUser()!.id))) {
-
-
- LIVE
-
- }
-
-
-
-
- }
-
-
- @if (onlineUsersFiltered().length > 0) {
-
-
- Online — {{ onlineUsersFiltered().length }}
-
-
- @for (user of onlineUsersFiltered(); track user.id) {
-
-
- @if (user.avatarUrl) {
-
- } @else {
-
- {{ user.displayName.charAt(0).toUpperCase() }}
-
- }
-
-
-
-
{{ user.displayName }}
-
- @if (user.voiceState?.isConnected) {
-
-
- In voice
-
- }
- @if (user.screenShareState?.isSharing || isUserSharing(user.id)) {
-
-
- LIVE
-
- }
-
-
-
- }
-
-
- }
-
-
- @if (onlineUsersFiltered().length === 0) {
-
-
No other users in this server
-
- }
-
- }
-
-
- @if (voiceEnabled()) {
-
- }
-
- `,
-})
-export class RoomsSidePanelComponent {
- private store = inject(Store);
- private webrtc = inject(WebRTCService);
- private voiceSessionService = inject(VoiceSessionService);
-
- activeTab = signal('channels');
- showFloatingControls = this.voiceSessionService.showFloatingControls;
- onlineUsers = this.store.selectSignal(selectOnlineUsers);
- currentUser = this.store.selectSignal(selectCurrentUser);
- currentRoom = this.store.selectSignal(selectCurrentRoom);
-
- // Filter out current user from online users list
- onlineUsersFiltered() {
- const current = this.currentUser();
- const currentId = current?.id;
- const currentOderId = current?.oderId;
- return this.onlineUsers().filter(u => u.id !== currentId && u.oderId !== currentOderId);
- }
-
- joinVoice(roomId: string) {
- // Gate by room permissions
- const room = this.currentRoom();
- if (room && room.permissions && room.permissions.allowVoice === false) {
- console.warn('Voice is disabled by room permissions');
- return;
- }
-
- const current = this.currentUser();
-
- // Check if already connected to voice in a DIFFERENT server - must disconnect first
- if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
- // Connected to voice in a different server - user must disconnect first
- console.warn('Already connected to voice in another server. Disconnect first before joining.');
- return;
- }
-
- // If switching channels within the same server, just update the room
- const isSwitchingChannels = current?.voiceState?.isConnected &&
- current.voiceState.serverId === room?.id &&
- current.voiceState.roomId !== roomId;
-
- // Enable microphone and broadcast voice-state
- const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
-
- enableVoicePromise.then(() => {
- if (current?.id && room) {
- this.store.dispatch(UsersActions.updateVoiceState({
- userId: current.id,
- voiceState: { isConnected: true, isMuted: current.voiceState?.isMuted ?? false, isDeafened: current.voiceState?.isDeafened ?? false, roomId: roomId, serverId: room.id }
- }));
- }
- // Start voice heartbeat to broadcast presence every 5 seconds
- this.webrtc.startVoiceHeartbeat(roomId);
- this.webrtc.broadcastMessage({
- type: 'voice-state',
- oderId: current?.oderId || current?.id,
- displayName: current?.displayName || 'User',
- voiceState: { isConnected: true, isMuted: current?.voiceState?.isMuted ?? false, isDeafened: current?.voiceState?.isDeafened ?? false, roomId: roomId, serverId: room?.id }
- });
-
- // Update voice session for floating controls
- if (room) {
- const voiceRoomName = roomId === 'general' ? '🔊 General' : roomId === 'afk' ? '🔕 AFK' : roomId;
- this.voiceSessionService.startSession({
- serverId: room.id,
- serverName: room.name,
- roomId: roomId,
- roomName: voiceRoomName,
- serverIcon: room.icon,
- serverDescription: room.description,
- serverRoute: `/room/${room.id}`,
- });
- }
- }).catch((e) => console.error('Failed to join voice room', roomId, e));
- }
-
- leaveVoice(roomId: string) {
- const current = this.currentUser();
- // Only leave if currently in this room
- if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) return;
-
- // Stop voice heartbeat
- this.webrtc.stopVoiceHeartbeat();
-
- // Disable voice locally
- this.webrtc.disableVoice();
-
- // Update store voice state
- if (current?.id) {
- this.store.dispatch(UsersActions.updateVoiceState({
- userId: current.id,
- voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
- }));
- }
-
- // Broadcast disconnect
- this.webrtc.broadcastMessage({
- type: 'voice-state',
- oderId: current?.oderId || current?.id,
- displayName: current?.displayName || 'User',
- voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
- });
-
- // End voice session
- this.voiceSessionService.endSession();
- }
-
- voiceOccupancy(roomId: string): number {
- const users = this.onlineUsers();
- const room = this.currentRoom();
- // Only count users connected to voice in this specific server and room
- return users.filter(u =>
- !!u.voiceState?.isConnected &&
- u.voiceState?.roomId === roomId &&
- u.voiceState?.serverId === room?.id
- ).length;
- }
-
- viewShare(userId: string) {
- // Focus viewer on a user's stream if present
- // Requires WebRTCService to expose a remote streams registry.
- const evt = new CustomEvent('viewer:focus', { detail: { userId } });
- window.dispatchEvent(evt);
- }
-
- viewStream(userId: string) {
- // Focus viewer on a user's stream - dispatches event to screen-share-viewer
- const evt = new CustomEvent('viewer:focus', { detail: { userId } });
- window.dispatchEvent(evt);
- }
-
- isUserSharing(userId: string): boolean {
- const me = this.currentUser();
- if (me?.id === userId) {
- // Local user: use signal
- return this.webrtc.isScreenSharing();
- }
-
- // For remote users, check the store state first (authoritative)
- const user = this.onlineUsers().find(u => u.id === userId || u.oderId === userId);
- if (user?.screenShareState?.isSharing === false) {
- // Store says not sharing - trust this over stream presence
- return false;
- }
-
- // Fall back to checking stream if store state is undefined
- const stream = this.webrtc.getRemoteStream(userId);
- return !!stream && stream.getVideoTracks().length > 0;
- }
-
- voiceUsersInRoom(roomId: string) {
- const room = this.currentRoom();
- // Only show users connected to voice in this specific server and room
- return this.onlineUsers().filter(u =>
- !!u.voiceState?.isConnected &&
- u.voiceState?.roomId === roomId &&
- u.voiceState?.serverId === room?.id
- );
- }
-
- isCurrentRoom(roomId: string): boolean {
- const me = this.currentUser();
- const room = this.currentRoom();
- // Check that voice is connected AND both the server AND room match
- return !!(
- me?.voiceState?.isConnected &&
- me.voiceState?.roomId === roomId &&
- me.voiceState?.serverId === room?.id
- );
- }
-
- voiceEnabled(): boolean {
- const room = this.currentRoom();
- return room?.permissions?.allowVoice !== false;
- }
-}
diff --git a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html
new file mode 100644
index 0000000..688c583
--- /dev/null
+++ b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html
@@ -0,0 +1,272 @@
+
+
+
+
+
+
+
+ Channels
+
+
+
+ Users
+ {{ onlineUsers().length }}
+
+
+
+
+
+ @if (activeTab() === 'channels') {
+
+
+
+
Text Channels
+
+
+ # general
+
+
+ # random
+
+
+
+
+
+
+
Voice Channels
+ @if (!voiceEnabled()) {
+
Voice is disabled by host
+ }
+
+
+
+
+
+ 🔊 General
+
+ @if (voiceOccupancy('general') > 0) {
+ {{ voiceOccupancy('general') }}
+ }
+
+ @if (voiceUsersInRoom('general').length > 0) {
+
+ @for (u of voiceUsersInRoom('general'); track u.id) {
+
+ @if (u.avatarUrl) {
+
+ } @else {
+
+ {{ u.displayName.charAt(0).toUpperCase() }}
+
+ }
+
{{ u.displayName }}
+ @if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
+
+ LIVE
+
+ }
+ @if (u.voiceState?.isMuted) {
+
+ }
+
+ }
+
+ }
+
+
+
+
+
+
+ 🔕 AFK
+
+ @if (voiceOccupancy('afk') > 0) {
+ {{ voiceOccupancy('afk') }}
+ }
+
+ @if (voiceUsersInRoom('afk').length > 0) {
+
+ @for (u of voiceUsersInRoom('afk'); track u.id) {
+
+ @if (u.avatarUrl) {
+
+ } @else {
+
+ {{ u.displayName.charAt(0).toUpperCase() }}
+
+ }
+
{{ u.displayName }}
+ @if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
+
+ LIVE
+
+ }
+ @if (u.voiceState?.isMuted) {
+
+ }
+
+ }
+
+ }
+
+
+
+
+ }
+
+
+ @if (activeTab() === 'users') {
+
+
+ @if (currentUser()) {
+
+
You
+
+
+ @if (currentUser()?.avatarUrl) {
+
+ } @else {
+
+ {{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
+
+ }
+
+
+
+
{{ currentUser()?.displayName }}
+
+ @if (currentUser()?.voiceState?.isConnected) {
+
+
+ In voice
+
+ }
+ @if (currentUser()?.screenShareState?.isSharing || (currentUser()?.id && isUserSharing(currentUser()!.id))) {
+
+
+ LIVE
+
+ }
+
+
+
+
+ }
+
+
+ @if (onlineUsersFiltered().length > 0) {
+
+
+ Online — {{ onlineUsersFiltered().length }}
+
+
+ @for (user of onlineUsersFiltered(); track user.id) {
+
+
+ @if (user.avatarUrl) {
+
+ } @else {
+
+ {{ user.displayName.charAt(0).toUpperCase() }}
+
+ }
+
+
+
+
{{ user.displayName }}
+
+ @if (user.voiceState?.isConnected) {
+
+
+ In voice
+
+ }
+ @if (user.screenShareState?.isSharing || isUserSharing(user.id)) {
+
+
+ LIVE
+
+ }
+
+
+
+ }
+
+
+ }
+
+
+ @if (onlineUsersFiltered().length === 0) {
+
+
No other users in this server
+
+ }
+
+ }
+
+
+ @if (voiceEnabled()) {
+
+ }
+
diff --git a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts
new file mode 100644
index 0000000..6452108
--- /dev/null
+++ b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts
@@ -0,0 +1,199 @@
+import { Component, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { Store } from '@ngrx/store';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers } from '@ng-icons/lucide';
+import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors';
+import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
+import * as UsersActions from '../../../store/users/users.actions';
+import { WebRTCService } from '../../../core/services/webrtc.service';
+import { VoiceSessionService } from '../../../core/services/voice-session.service';
+import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
+
+type TabView = 'channels' | 'users';
+
+@Component({
+ selector: 'app-rooms-side-panel',
+ standalone: true,
+ imports: [CommonModule, NgIcon, VoiceControlsComponent],
+ viewProviders: [
+ provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers })
+ ],
+ templateUrl: './rooms-side-panel.component.html',
+})
+export class RoomsSidePanelComponent {
+ private store = inject(Store);
+ private webrtc = inject(WebRTCService);
+ private voiceSessionService = inject(VoiceSessionService);
+
+ activeTab = signal('channels');
+ showFloatingControls = this.voiceSessionService.showFloatingControls;
+ onlineUsers = this.store.selectSignal(selectOnlineUsers);
+ currentUser = this.store.selectSignal(selectCurrentUser);
+ currentRoom = this.store.selectSignal(selectCurrentRoom);
+
+ // Filter out current user from online users list
+ onlineUsersFiltered() {
+ const current = this.currentUser();
+ const currentId = current?.id;
+ const currentOderId = current?.oderId;
+ return this.onlineUsers().filter(u => u.id !== currentId && u.oderId !== currentOderId);
+ }
+
+ joinVoice(roomId: string) {
+ // Gate by room permissions
+ const room = this.currentRoom();
+ if (room && room.permissions && room.permissions.allowVoice === false) {
+ console.warn('Voice is disabled by room permissions');
+ return;
+ }
+
+ const current = this.currentUser();
+
+ // Check if already connected to voice in a DIFFERENT server - must disconnect first
+ if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
+ // Connected to voice in a different server - user must disconnect first
+ console.warn('Already connected to voice in another server. Disconnect first before joining.');
+ return;
+ }
+
+ // If switching channels within the same server, just update the room
+ const isSwitchingChannels = current?.voiceState?.isConnected &&
+ current.voiceState.serverId === room?.id &&
+ current.voiceState.roomId !== roomId;
+
+ // Enable microphone and broadcast voice-state
+ const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
+
+ enableVoicePromise.then(() => {
+ if (current?.id && room) {
+ this.store.dispatch(UsersActions.updateVoiceState({
+ userId: current.id,
+ voiceState: { isConnected: true, isMuted: current.voiceState?.isMuted ?? false, isDeafened: current.voiceState?.isDeafened ?? false, roomId: roomId, serverId: room.id }
+ }));
+ }
+ // Start voice heartbeat to broadcast presence every 5 seconds
+ this.webrtc.startVoiceHeartbeat(roomId);
+ this.webrtc.broadcastMessage({
+ type: 'voice-state',
+ oderId: current?.oderId || current?.id,
+ displayName: current?.displayName || 'User',
+ voiceState: { isConnected: true, isMuted: current?.voiceState?.isMuted ?? false, isDeafened: current?.voiceState?.isDeafened ?? false, roomId: roomId, serverId: room?.id }
+ });
+
+ // Update voice session for floating controls
+ if (room) {
+ const voiceRoomName = roomId === 'general' ? '🔊 General' : roomId === 'afk' ? '🔕 AFK' : roomId;
+ this.voiceSessionService.startSession({
+ serverId: room.id,
+ serverName: room.name,
+ roomId: roomId,
+ roomName: voiceRoomName,
+ serverIcon: room.icon,
+ serverDescription: room.description,
+ serverRoute: `/room/${room.id}`,
+ });
+ }
+ }).catch((e) => console.error('Failed to join voice room', roomId, e));
+ }
+
+ leaveVoice(roomId: string) {
+ const current = this.currentUser();
+ // Only leave if currently in this room
+ if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) return;
+
+ // Stop voice heartbeat
+ this.webrtc.stopVoiceHeartbeat();
+
+ // Disable voice locally
+ this.webrtc.disableVoice();
+
+ // Update store voice state
+ if (current?.id) {
+ this.store.dispatch(UsersActions.updateVoiceState({
+ userId: current.id,
+ voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
+ }));
+ }
+
+ // Broadcast disconnect
+ this.webrtc.broadcastMessage({
+ type: 'voice-state',
+ oderId: current?.oderId || current?.id,
+ displayName: current?.displayName || 'User',
+ voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
+ });
+
+ // End voice session
+ this.voiceSessionService.endSession();
+ }
+
+ voiceOccupancy(roomId: string): number {
+ const users = this.onlineUsers();
+ const room = this.currentRoom();
+ // Only count users connected to voice in this specific server and room
+ return users.filter(u =>
+ !!u.voiceState?.isConnected &&
+ u.voiceState?.roomId === roomId &&
+ u.voiceState?.serverId === room?.id
+ ).length;
+ }
+
+ viewShare(userId: string) {
+ // Focus viewer on a user's stream if present
+ // Requires WebRTCService to expose a remote streams registry.
+ const evt = new CustomEvent('viewer:focus', { detail: { userId } });
+ window.dispatchEvent(evt);
+ }
+
+ viewStream(userId: string) {
+ // Focus viewer on a user's stream - dispatches event to screen-share-viewer
+ const evt = new CustomEvent('viewer:focus', { detail: { userId } });
+ window.dispatchEvent(evt);
+ }
+
+ isUserSharing(userId: string): boolean {
+ const me = this.currentUser();
+ if (me?.id === userId) {
+ // Local user: use signal
+ return this.webrtc.isScreenSharing();
+ }
+
+ // For remote users, check the store state first (authoritative)
+ const user = this.onlineUsers().find(u => u.id === userId || u.oderId === userId);
+ if (user?.screenShareState?.isSharing === false) {
+ // Store says not sharing - trust this over stream presence
+ return false;
+ }
+
+ // Fall back to checking stream if store state is undefined
+ const stream = this.webrtc.getRemoteStream(userId);
+ return !!stream && stream.getVideoTracks().length > 0;
+ }
+
+ voiceUsersInRoom(roomId: string) {
+ const room = this.currentRoom();
+ // Only show users connected to voice in this specific server and room
+ return this.onlineUsers().filter(u =>
+ !!u.voiceState?.isConnected &&
+ u.voiceState?.roomId === roomId &&
+ u.voiceState?.serverId === room?.id
+ );
+ }
+
+ isCurrentRoom(roomId: string): boolean {
+ const me = this.currentUser();
+ const room = this.currentRoom();
+ // Check that voice is connected AND both the server AND room match
+ return !!(
+ me?.voiceState?.isConnected &&
+ me.voiceState?.roomId === roomId &&
+ me.voiceState?.serverId === room?.id
+ );
+ }
+
+ voiceEnabled(): boolean {
+ const room = this.currentRoom();
+ return room?.permissions?.allowVoice !== false;
+ }
+}
diff --git a/src/app/features/server-search/server-search.component.html b/src/app/features/server-search/server-search.component.html
new file mode 100644
index 0000000..3d2d9a2
--- /dev/null
+++ b/src/app/features/server-search/server-search.component.html
@@ -0,0 +1,197 @@
+
+
+
+
My Servers
+ @if (savedRooms().length === 0) {
+
No joined servers yet
+ } @else {
+
+ @for (room of savedRooms(); track room.id) {
+
+ {{ room.name }}
+
+ }
+
+ }
+
+
+
+
+
+
+
+
+ Create New Server
+
+
+
+
+
+ @if (isSearching()) {
+
+ } @else if (searchResults().length === 0) {
+
+
+
No servers found
+
Try a different search or create your own
+
+ } @else {
+
+ @for (server of searchResults(); track server.id) {
+
+
+
+
+
+ {{ server.name }}
+
+ @if (server.isPrivate) {
+
+ } @else {
+
+ }
+
+ @if (server.description) {
+
+ {{ server.description }}
+
+ }
+ @if (server.topic) {
+
+ {{ server.topic }}
+
+ }
+
+
+
+ {{ server.userCount }}/{{ server.maxUsers }}
+
+
+
+ Hosted by {{ server.hostName }}
+
+
+ }
+
+ }
+
+
+ @if (error()) {
+
+ }
+
+
+
+@if (showCreateDialog()) {
+
+
+
Create Server
+
+
+
+
+
+ Cancel
+
+
+ Create
+
+
+
+
+}
diff --git a/src/app/features/server-search/server-search.component.ts b/src/app/features/server-search/server-search.component.ts
index ff46b61..6182933 100644
--- a/src/app/features/server-search/server-search.component.ts
+++ b/src/app/features/server-search/server-search.component.ts
@@ -31,205 +31,7 @@ import { ServerInfo } from '../../core/models';
viewProviders: [
provideIcons({ lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings }),
],
- template: `
-
-
-
-
My Servers
- @if (savedRooms().length === 0) {
-
No joined servers yet
- } @else {
-
- @for (room of savedRooms(); track room.id) {
-
- {{ room.name }}
-
- }
-
- }
-
-
-
-
-
-
-
-
- Create New Server
-
-
-
-
-
- @if (isSearching()) {
-
- } @else if (searchResults().length === 0) {
-
-
-
No servers found
-
Try a different search or create your own
-
- } @else {
-
- @for (server of searchResults(); track server.id) {
-
-
-
-
-
- {{ server.name }}
-
- @if (server.isPrivate) {
-
- } @else {
-
- }
-
- @if (server.description) {
-
- {{ server.description }}
-
- }
- @if (server.topic) {
-
- {{ server.topic }}
-
- }
-
-
-
- {{ server.userCount }}/{{ server.maxUsers }}
-
-
-
- Hosted by {{ server.hostName }}
-
-
- }
-
- }
-
-
- @if (error()) {
-
- }
-
-
-
- @if (showCreateDialog()) {
-
-
-
Create Server
-
-
-
-
-
- Cancel
-
-
- Create
-
-
-
-
- }
- `,
+ templateUrl: './server-search.component.html',
})
export class ServerSearchComponent implements OnInit {
private store = inject(Store);
diff --git a/src/app/features/servers/servers-rail.component.html b/src/app/features/servers/servers-rail.component.html
new file mode 100644
index 0000000..99a1b89
--- /dev/null
+++ b/src/app/features/servers/servers-rail.component.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ initial(room.name) }}
+
+
+
+
+
+
+
+
+
+
+
+ Leave Server
+ Forget Server
+
+
+
+
+
+
+
+
+
Forget Server?
+
+ Remove {{ contextRoom()?.name }} from your My Servers list.
+
+
+
+ Cancel
+ Forget
+
+
+
diff --git a/src/app/features/servers/servers-rail.component.ts b/src/app/features/servers/servers-rail.component.ts
index 9d97568..371a497 100644
--- a/src/app/features/servers/servers-rail.component.ts
+++ b/src/app/features/servers/servers-rail.component.ts
@@ -15,65 +15,7 @@ import * as RoomsActions from '../../store/rooms/rooms.actions';
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucidePlus })],
- template: `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ initial(room.name) }}
-
-
-
-
-
-
-
-
-
-
-
- Leave Server
- Forget Server
-
-
-
-
-
-
-
-
-
Forget Server?
-
- Remove {{ contextRoom()?.name }} from your My Servers list.
-
-
-
- Cancel
- Forget
-
-
-
- `,
+ templateUrl: './servers-rail.component.html',
})
export class ServersRailComponent {
private store = inject(Store);
diff --git a/src/app/features/settings/settings.component.html b/src/app/features/settings/settings.component.html
new file mode 100644
index 0000000..ba2096a
--- /dev/null
+++ b/src/app/features/settings/settings.component.html
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+
Settings
+
+
+
+
+
+
+
+
Server Endpoints
+
+
+
+ Test All
+
+
+
+
+ Add multiple server directories to search for rooms across different networks.
+ The active server will be used for creating and registering new rooms.
+
+
+
+
+ @for (server of servers(); track server.id) {
+
+
+
+
+
+
+
+ {{ server.name }}
+ @if (server.isActive) {
+ Active
+ }
+
+
{{ server.url }}
+ @if (server.latency !== undefined && server.status === 'online') {
+
{{ server.latency }}ms
+ }
+
+
+
+
+ @if (!server.isActive) {
+
+
+
+ }
+ @if (!server.isDefault) {
+
+
+
+ }
+
+
+ }
+
+
+
+
+
Add New Server
+
+ @if (addError()) {
+
{{ addError() }}
+ }
+
+
+
+
+
+
+
+
Connection Settings
+
+
+
+
+
+
Auto-reconnect
+
Automatically reconnect when connection is lost
+
+
+
+
+
+
+
+
+
+
Search all servers
+
Search across all configured server directories
+
+
+
+
+
+
+
+
+
diff --git a/src/app/features/settings/settings.component.ts b/src/app/features/settings/settings.component.ts
index 7f7ae39..fdd3b10 100644
--- a/src/app/features/settings/settings.component.ts
+++ b/src/app/features/settings/settings.component.ts
@@ -15,7 +15,7 @@ import {
lucideArrowLeft,
} from '@ng-icons/lucide';
-import { ServerDirectoryService, ServerEndpoint } from '../../core/services/server-directory.service';
+import { ServerDirectoryService } from '../../core/services/server-directory.service';
@Component({
selector: 'app-settings',
@@ -34,176 +34,7 @@ import { ServerDirectoryService, ServerEndpoint } from '../../core/services/serv
lucideArrowLeft,
}),
],
- template: `
-
-
-
-
-
-
-
Settings
-
-
-
-
-
-
-
-
Server Endpoints
-
-
-
- Test All
-
-
-
-
- Add multiple server directories to search for rooms across different networks.
- The active server will be used for creating and registering new rooms.
-
-
-
-
- @for (server of servers(); track server.id) {
-
-
-
-
-
-
-
- {{ server.name }}
- @if (server.isActive) {
- Active
- }
-
-
{{ server.url }}
- @if (server.latency !== undefined && server.status === 'online') {
-
{{ server.latency }}ms
- }
-
-
-
-
- @if (!server.isActive) {
-
-
-
- }
- @if (!server.isDefault) {
-
-
-
- }
-
-
- }
-
-
-
-
-
Add New Server
-
- @if (addError()) {
-
{{ addError() }}
- }
-
-
-
-
-
-
-
-
Connection Settings
-
-
-
-
-
-
Auto-reconnect
-
Automatically reconnect when connection is lost
-
-
-
-
-
-
-
-
-
-
Search all servers
-
Search across all configured server directories
-
-
-
-
-
-
-
-
-
- `,
+ templateUrl: './settings.component.html',
})
export class SettingsComponent implements OnInit {
private serverDirectory = inject(ServerDirectoryService);
diff --git a/src/app/features/shell/title-bar.component.html b/src/app/features/shell/title-bar.component.html
new file mode 100644
index 0000000..f0c13a7
--- /dev/null
+++ b/src/app/features/shell/title-bar.component.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+ {{ roomName() }}
+ {{ roomDescription() }}
+
+
+
+
+
+
Leave Server
+
+
Logout
+
+
+
+
+ {{ username() }} | {{ serverName() }}
+ Reconnecting…
+
+
+
+
+ Login
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/features/shell/title-bar.component.ts b/src/app/features/shell/title-bar.component.ts
index 721ec5d..ecf40ff 100644
--- a/src/app/features/shell/title-bar.component.ts
+++ b/src/app/features/shell/title-bar.component.ts
@@ -15,49 +15,7 @@ import { WebRTCService } from '../../core/services/webrtc.service';
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })],
- template: `
-
-
-
-
-
-
-
- {{ roomName() }}
- {{ roomDescription() }}
-
-
-
-
-
-
Leave Server
-
-
Logout
-
-
-
-
- {{ username() }} | {{ serverName() }}
- Reconnecting…
-
-
-
-
- Login
-
-
-
-
-
-
-
-
-
-
-
-
-
- `,
+ templateUrl: './title-bar.component.html',
})
export class TitleBarComponent {
private store = inject(Store);
diff --git a/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.html b/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.html
new file mode 100644
index 0000000..c3d8236
--- /dev/null
+++ b/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.html
@@ -0,0 +1,70 @@
+@if (showFloatingControls()) {
+
+
+
+
+
+
+ @if (voiceSession()?.serverIcon) {
+
+ } @else {
+
+ {{ voiceSession()?.serverName?.charAt(0)?.toUpperCase() || '?' }}
+
+ }
+
+
+
+
+
+ {{ voiceSession()?.roomName || 'Voice' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/src/app/features/voice/floating-voice-controls.component.ts b/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts
similarity index 65%
rename from src/app/features/voice/floating-voice-controls.component.ts
rename to src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts
index 3fbbad4..0b0d346 100644
--- a/src/app/features/voice/floating-voice-controls.component.ts
+++ b/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts
@@ -13,10 +13,10 @@ import {
lucideArrowLeft,
} from '@ng-icons/lucide';
-import { WebRTCService } from '../../core/services/webrtc.service';
-import { VoiceSessionService } from '../../core/services/voice-session.service';
-import * as UsersActions from '../../store/users/users.actions';
-import { selectCurrentUser } from '../../store/users/users.selectors';
+import { WebRTCService } from '../../../core/services/webrtc.service';
+import { VoiceSessionService } from '../../../core/services/voice-session.service';
+import * as UsersActions from '../../../store/users/users.actions';
+import { selectCurrentUser } from '../../../store/users/users.selectors';
@Component({
selector: 'app-floating-voice-controls',
@@ -25,7 +25,6 @@ import { selectCurrentUser } from '../../store/users/users.selectors';
viewProviders: [
provideIcons({
lucideMic,
- lucideMicOff,
lucideMonitor,
lucideMonitorOff,
lucidePhoneOff,
@@ -33,78 +32,7 @@ import { selectCurrentUser } from '../../store/users/users.selectors';
lucideArrowLeft,
}),
],
- template: `
- @if (showFloatingControls()) {
-
-
-
-
-
-
- @if (voiceSession()?.serverIcon) {
-
- } @else {
-
- {{ voiceSession()?.serverName?.charAt(0)?.toUpperCase() || '?' }}
-
- }
-
-
-
-
-
- {{ voiceSession()?.roomName || 'Voice' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- }
- `,
+ templateUrl: './floating-voice-controls.component.html'
})
export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
private webrtcService = inject(WebRTCService);
diff --git a/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.html b/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.html
new file mode 100644
index 0000000..75cfaa4
--- /dev/null
+++ b/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.html
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+ {{ activeScreenSharer()?.displayName }} is sharing their screen
+
+
+ Someone is sharing their screen
+
+
+
+
+
+ Volume: {{ screenVolume() }}%
+
+
+
+ @if (isFullscreen()) {
+
+ } @else {
+
+ }
+
+ @if (isLocalShare()) {
+
+
+
+ } @else {
+
+
+
+ }
+
+
+
+
+
+
+
+
+
Waiting for screen share...
+
+
+
diff --git a/src/app/features/voice/screen-share-viewer.component.ts b/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts
similarity index 69%
rename from src/app/features/voice/screen-share-viewer.component.ts
rename to src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts
index 867734a..5694b7b 100644
--- a/src/app/features/voice/screen-share-viewer.component.ts
+++ b/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts
@@ -10,9 +10,9 @@ import {
lucideMonitor,
} from '@ng-icons/lucide';
-import { WebRTCService } from '../../core/services/webrtc.service';
-import { selectOnlineUsers } from '../../store/users/users.selectors';
-import { User } from '../../core/models';
+import { WebRTCService } from '../../../core/services/webrtc.service';
+import { selectOnlineUsers } from '../../../store/users/users.selectors';
+import { User } from '../../../core/models';
@Component({
selector: 'app-screen-share-viewer',
@@ -26,88 +26,7 @@ import { User } from '../../core/models';
lucideMonitor,
}),
],
- template: `
-
-
-
-
-
-
-
-
-
-
- {{ activeScreenSharer()?.displayName }} is sharing their screen
-
-
- Someone is sharing their screen
-
-
-
-
-
- Volume: {{ screenVolume() }}%
-
-
-
- @if (isFullscreen()) {
-
- } @else {
-
- }
-
- @if (isLocalShare()) {
-
-
-
- } @else {
-
-
-
- }
-
-
-
-
-
-
-
-
-
Waiting for screen share...
-
-
-
- `,
+ templateUrl: './screen-share-viewer.component.html',
})
export class ScreenShareViewerComponent implements OnDestroy {
@ViewChild('screenVideo') videoRef!: ElementRef;
@@ -171,19 +90,19 @@ export class ScreenShareViewerComponent implements OnDestroy {
effect(() => {
const watchingId = this.watchingUserId();
const isWatchingRemote = this.hasStream() && !this.isLocalShare();
-
+
// Only check if we're actually watching a remote stream
if (!watchingId || !isWatchingRemote) return;
-
+
const users = this.onlineUsers();
const watchedUser = users.find(u => u.id === watchingId || u.oderId === watchingId);
-
+
// If the user is no longer sharing (screenShareState.isSharing is false), stop watching
if (watchedUser && watchedUser.screenShareState?.isSharing === false) {
this.stopWatching();
return;
}
-
+
// Also check if the stream's video tracks are still available
const stream = this.webrtcService.getRemoteStream(watchingId);
const hasActiveVideo = stream?.getVideoTracks().some(t => t.readyState === 'live');
diff --git a/src/app/features/voice/voice-controls/voice-controls.component.html b/src/app/features/voice/voice-controls/voice-controls.component.html
new file mode 100644
index 0000000..fd86ac7
--- /dev/null
+++ b/src/app/features/voice/voice-controls/voice-controls.component.html
@@ -0,0 +1,180 @@
+
+
+ @if (showConnectionError()) {
+
+
+ {{ connectionErrorMessage() || 'Connection error' }}
+ Retry
+
+ }
+
+
+
+
+ {{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
+
+
+
+ {{ currentUser()?.displayName || 'Unknown' }}
+
+
+ @if (showConnectionError()) {
+ ● Connection Error
+ } @else if (isConnected()) {
+ ● Connected
+ } @else {
+ ● Disconnected
+ }
+
+
+
+
+
+
+
+
+
+ @if (isConnected()) {
+
+
+ @if (isMuted()) {
+
+ } @else {
+
+ }
+
+
+
+
+
+
+
+
+
+ @if (isScreenSharing()) {
+
+ } @else {
+
+ }
+
+
+
+
+
+
+ }
+
+
+
+ @if (showSettings()) {
+
+
+
Voice Settings
+
+
+
+ Microphone
+
+ @for (device of inputDevices(); track device.deviceId) {
+
+ {{ device.label || 'Microphone ' + $index }}
+
+ }
+
+
+
+
+ Speaker
+
+ @for (device of outputDevices(); track device.deviceId) {
+
+ {{ device.label || 'Speaker ' + $index }}
+
+ }
+
+
+
+
+
+ Input Volume: {{ inputVolume() }}%
+
+
+
+
+
+
+ Output Volume: {{ outputVolume() }}%
+
+
+
+
+
+ Latency
+
+ Low (fast)
+ Balanced
+ High (quality)
+
+
+
+
+
Include system audio when sharing screen
+
+
Off by default; viewers will still hear your mic.
+
+
+
+
+ Audio Bitrate: {{ audioBitrate() }} kbps
+
+
+
+
+
+
+
+ Close
+
+
+
+
+ }
+
diff --git a/src/app/features/voice/voice-controls.component.ts b/src/app/features/voice/voice-controls/voice-controls.component.ts
similarity index 69%
rename from src/app/features/voice/voice-controls.component.ts
rename to src/app/features/voice/voice-controls/voice-controls.component.ts
index e4a6a40..9b6c238 100644
--- a/src/app/features/voice/voice-controls.component.ts
+++ b/src/app/features/voice/voice-controls/voice-controls.component.ts
@@ -15,11 +15,11 @@ import {
lucideHeadphones,
} from '@ng-icons/lucide';
-import { WebRTCService } from '../../core/services/webrtc.service';
-import { VoiceSessionService } from '../../core/services/voice-session.service';
-import * as UsersActions from '../../store/users/users.actions';
-import { selectCurrentUser } from '../../store/users/users.selectors';
-import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
+import { WebRTCService } from '../../../core/services/webrtc.service';
+import { VoiceSessionService } from '../../../core/services/voice-session.service';
+import * as UsersActions from '../../../store/users/users.actions';
+import { selectCurrentUser } from '../../../store/users/users.selectors';
+import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
interface AudioDevice {
deviceId: string;
@@ -43,188 +43,7 @@ interface AudioDevice {
lucideHeadphones,
}),
],
- template: `
-
-
- @if (showConnectionError()) {
-
-
- {{ connectionErrorMessage() || 'Connection error' }}
- Retry
-
- }
-
-
-
-
- {{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
-
-
-
- {{ currentUser()?.displayName || 'Unknown' }}
-
-
- @if (showConnectionError()) {
- ● Connection Error
- } @else if (isConnected()) {
- ● Connected
- } @else {
- ● Disconnected
- }
-
-
-
-
-
-
-
-
-
- @if (isConnected()) {
-
-
- @if (isMuted()) {
-
- } @else {
-
- }
-
-
-
-
-
-
-
-
-
- @if (isScreenSharing()) {
-
- } @else {
-
- }
-
-
-
-
-
-
- }
-
-
-
- @if (showSettings()) {
-
-
-
Voice Settings
-
-
-
- Microphone
-
- @for (device of inputDevices(); track device.deviceId) {
-
- {{ device.label || 'Microphone ' + $index }}
-
- }
-
-
-
-
- Speaker
-
- @for (device of outputDevices(); track device.deviceId) {
-
- {{ device.label || 'Speaker ' + $index }}
-
- }
-
-
-
-
-
- Input Volume: {{ inputVolume() }}%
-
-
-
-
-
-
- Output Volume: {{ outputVolume() }}%
-
-
-
-
-
- Latency
-
- Low (fast)
- Balanced
- High (quality)
-
-
-
-
-
Include system audio when sharing screen
-
-
Off by default; viewers will still hear your mic.
-
-
-
-
- Audio Bitrate: {{ audioBitrate() }} kbps
-
-
-
-
-
-
-
- Close
-
-
-
-
- }
-
- `,
+ templateUrl: './voice-controls.component.html',
})
export class VoiceControlsComponent implements OnInit, OnDestroy {
private webrtcService = inject(WebRTCService);