feat: Android APP V1 - Experimental Alpha

This commit is contained in:
2026-06-05 07:40:25 +02:00
parent bf4e6891d1
commit 9a1305f976
179 changed files with 8031 additions and 120 deletions

View File

@@ -27,6 +27,24 @@
/>
</button>
@if (showSpeakerphoneButton()) {
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
[class.ring-2]="speakerphoneEnabled()"
[class.ring-primary]="speakerphoneEnabled()"
[disabled]="!connected()"
(click)="speakerphoneToggled.emit()"
[attr.aria-label]="speakerphoneEnabled() ? 'Use earpiece' : 'Use speakerphone'"
[title]="speakerphoneEnabled() ? 'Use earpiece' : 'Use speakerphone'"
>
<ng-icon
name="lucideVolume2"
class="h-5 w-5"
/>
</button>
}
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"

View File

@@ -12,7 +12,8 @@ import {
lucidePhone,
lucidePhoneOff,
lucideVideo,
lucideVideoOff
lucideVideoOff,
lucideVolume2
} from '@ng-icons/lucide';
@Component({
@@ -28,7 +29,8 @@ import {
lucidePhone,
lucidePhoneOff,
lucideVideo,
lucideVideoOff
lucideVideoOff,
lucideVolume2
})
],
templateUrl: './private-call-controls.component.html'
@@ -38,10 +40,13 @@ export class PrivateCallControlsComponent {
readonly muted = input.required<boolean>();
readonly cameraEnabled = input.required<boolean>();
readonly screenSharing = input.required<boolean>();
readonly showSpeakerphoneButton = input(false);
readonly speakerphoneEnabled = input(false);
readonly joinRequested = output<void>();
readonly muteToggled = output<void>();
readonly cameraToggled = output<void>();
readonly screenShareToggled = output<void>();
readonly leaveRequested = output<void>();
readonly joinRequested = output();
readonly muteToggled = output();
readonly cameraToggled = output();
readonly screenShareToggled = output();
readonly speakerphoneToggled = output();
readonly leaveRequested = output();
}

View File

@@ -198,10 +198,13 @@
[muted]="isMuted()"
[cameraEnabled]="isCameraEnabled()"
[screenSharing]="isScreenSharing()"
[showSpeakerphoneButton]="showSpeakerphoneButton()"
[speakerphoneEnabled]="speakerphoneEnabled()"
(joinRequested)="join()"
(muteToggled)="toggleMute()"
(cameraToggled)="toggleCamera()"
(screenShareToggled)="toggleScreenShare()"
(speakerphoneToggled)="toggleSpeakerphone()"
(leaveRequested)="leave()"
/>
</div>

View File

@@ -43,6 +43,7 @@ import {
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session';
import { ScreenShareQualityDialogComponent } from '../../shared';
import { ViewportService } from '../../core/platform';
import { MobileMediaService, MobilePlatformService } from '../../infrastructure/mobile';
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
import { UsersActions } from '../../store/users/users.actions';
import { User } from '../../shared-kernel';
@@ -87,11 +88,15 @@ export class PrivateCallComponent {
private readonly playback = inject(VoicePlaybackService);
private readonly screenShare = inject(ScreenShareFacade);
private readonly viewport = inject(ViewportService);
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly mobileMedia = inject(MobileMediaService);
private chatResizing = false;
readonly allUsers = this.store.selectSignal(selectAllUsers);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly isMobile = this.viewport.isMobile;
readonly showSpeakerphoneButton = computed(() => this.mobilePlatform.isNativeMobile());
readonly speakerphoneEnabled = signal(true);
readonly callIdInput = input<string | null>(null);
readonly overlayMode = input(false);
readonly routeCallId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), {
@@ -342,6 +347,13 @@ export class PrivateCallComponent {
this.broadcastLocalVoiceState();
}
async toggleSpeakerphone(): Promise<void> {
const nextEnabled = !this.speakerphoneEnabled();
this.speakerphoneEnabled.set(nextEnabled);
await this.mobileMedia.setSpeakerphoneEnabled(nextEnabled);
}
toggleDeafen(): void {
const nextDeafened = !this.isDeafened();

View File

@@ -25,6 +25,10 @@ import {
import { UserAvatarComponent } from '../../../../shared';
import { ViewportService } from '../../../../core/platform';
import {
MobileAppLifecycleService,
MobilePictureInPictureService
} from '../../../../infrastructure/mobile';
import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service';
import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
@@ -55,6 +59,8 @@ import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
private readonly viewport = inject(ViewportService);
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
private readonly mobilePictureInPicture = inject(MobilePictureInPictureService);
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
readonly item = input.required<VoiceWorkspaceStreamItem>();
@@ -74,6 +80,10 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
readonly muted = signal(false);
constructor() {
void this.mobileLifecycle.initialize();
this.mobileLifecycle.onAppStateChange((isActive) => {
void this.handleAppStateChange(isActive);
});
effect(() => {
const ref = this.videoRef();
const item = this.item();
@@ -150,6 +160,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
ngOnDestroy(): void {
this.clearFullscreenHeaderHideTimeout();
void this.mobilePictureInPicture.exit();
const tile = this.tileRef()?.nativeElement;
@@ -160,6 +171,24 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
this.unlockOrientation();
}
private async handleAppStateChange(isActive: boolean): Promise<void> {
if (isActive || !this.focused() || !this.mobilePictureInPicture.isSupported()) {
if (isActive) {
await this.mobilePictureInPicture.exit();
}
return;
}
const video = this.videoRef()?.nativeElement;
if (!video || !this.item().stream) {
return;
}
await this.mobilePictureInPicture.enter(video);
}
canToggleFullscreen(): boolean {
return !this.mini() && !this.compact();
}