SDK Angular
@moraviq/voice-angular: camada fina sobre o SDK JS para Angular 20 a 22 (standalone, com zone.js ou zoneless). provideMoraviqVoice, um VoiceService injetável com Signal e Observable, e um componente de chamada opcional.
npm install @moraviq/voice-web @moraviq/voice-angular
Configurar
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideMoraviqVoice } from '@moraviq/voice-angular';
import { AppComponent } from './app/app.component';
import { instalacao } from './app/instalacao';
// O backend do parceiro diz onde está a API (sandbox ou produção) e emite o token.
const config = await (await fetch('/api/config')).json() as { baseUrl: string };
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideMoraviqVoice({
baseUrl: config.baseUrl,
// O SEU backend assina o token de aparelho da pessoa logada (aqui, o porteiro).
getToken: async () => {
const r = await fetch('/api/voz/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({ papel: 'portaria', usuario: 'port_12', installation_id: instalacao() }),
});
if (!r.ok) throw new Error('token');
return (await r.json()).token as string;
},
}),
],
}).catch((e) => console.error(e));VoiceService
| Membro | Tipo |
|---|---|
connectionState, connectionState$ | Signal<ConnectionState>, Observable |
calls, activeCall | Signal com retratos imutáveis (status, endReason, answeredBy, devicesRinging, mediaConnected, muted) |
state(id), call(id) | Signal de uma chamada |
incomingCall$, callEnded$, error$, lastError | eventos |
attachOutgoing(id, opts), placeCall(opts) | origem |
accept(id), reject(id), hangup(id), setMuted(id, b), setDisposition(id, code) | na chamada |
connect(), disconnect(), registerDevice(installationId) | conexão e aparelho |
bindAudio(id, el), handle(id), client | para montar a própria tela ou ir ao SDK JS |
O socket e a RTCPeerConnection rodam fora da zona do Angular (o ping a cada 20 s e cada candidato ICE não disparam detecção de mudança); só as mudanças de estado voltam para dentro. Destruir o injetor desconecta.
Portaria de exemplo
A portaria inteira (exemplos/portaria-angular), com o componente opcional <moraviq-call>:
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { MoraviqCallComponent, VoiceService } from '@moraviq/voice-angular';
interface Resultado {
status: string;
endReason?: string | null;
[k: string]: unknown;
}
/** Contrato com o teste ponta a ponta (tests/e2e/chamada_v2_no_navegador.py). */
declare global {
interface Window { __voiceResult?: Resultado | null }
}
/**
* Portaria de exemplo: o porteiro escolhe a unidade e liga.
*
* 1. o backend do parceiro cria a chamada (`POST /v2/calls`, modo prior) e devolve o call_id;
* 2. esta aba entra na chamada com o token do porteiro (`attachOutgoing`);
* 3. o componente opcional `<moraviq-call>` mostra o estado, mudo e desligar.
*
* Modo automático para o CI: `?auto=1&secs=6&relay=1&unidade=A-304&morador=res_77`.
*/
@Component({
selector: 'app-root',
standalone: true,
imports: [MoraviqCallComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<main>
<h1>Portaria</h1>
<p class="conexao" [attr.data-estado]="voz.connectionState()">Conexão: {{ voz.connectionState() }}</p>
<form (submit)="$event.preventDefault(); ligar()">
<label>Unidade <input name="unidade" [value]="unidade()" (input)="unidade.set(valor($event))" /></label>
<label>Morador (ext_id) <input name="morador" [value]="morador()" (input)="morador.set(valor($event))" /></label>
<label>Motivo
<select name="motivo" (change)="motivo.set(valor($event))">
<option value="visitor">Visitante</option>
<option value="delivery">Entrega</option>
<option value="service">Serviço</option>
<option value="other">Outro</option>
</select>
</label>
<button type="submit" [disabled]="ocupado()">Ligar</button>
</form>
@if (erro()) { <p class="erro" role="alert">{{ erro() }}</p> }
@if (callId(); as id) {
<moraviq-call [callId]="id" (ended)="terminou($event)" />
@if (estado(); as s) { <p class="detalhe">Estado: {{ s }}</p> }
}
</main>
`,
styles: [`
main { max-width: 480px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px #0002; }
h1 { margin: 0 0 8px; font-size: 22px; }
form { display: grid; gap: 10px; margin: 16px 0; }
label { display: grid; gap: 4px; font-size: 14px; }
input, select, button { font: inherit; padding: 8px 10px; border-radius: 8px; border: 1px solid #c8ced8; }
button { background: #1f6feb; color: #fff; border: 0; cursor: pointer; }
button[disabled] { opacity: .5; }
.erro { color: #b42318; }
.conexao, .detalhe { color: #4a5568; font-size: 14px; }
`],
})
export class AppComponent {
protected readonly voz = inject(VoiceService);
private readonly q = new URLSearchParams(location.search);
protected readonly unidade = signal(this.q.get('unidade') || 'A-304');
protected readonly morador = signal(this.q.get('morador') || 'res_77');
protected readonly motivo = signal('visitor');
protected readonly callId = signal<string | null>(null);
protected readonly erro = signal<string | null>(null);
protected readonly ocupado = computed(() => !!this.voz.activeCall());
protected readonly estado = computed(() => {
const id = this.callId();
return id ? this.voz.state(id)() : null;
});
private ultimas: Record<string, unknown> | null = null;
constructor() {
if (this.q.get('auto') === '1') {
window.__voiceResult = null;
void this.ligar().then(() => this.automatico());
}
}
protected valor(ev: Event): string {
return (ev.target as HTMLInputElement).value;
}
/** No clique: o microfone é pedido aqui, antes de qualquer chamada existir no Voice. */
async ligar(): Promise<void> {
this.erro.set(null);
try {
const r = await fetch('/api/portaria/chamar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ porteiro: 'port_12', unidade: this.unidade(), moradores: [this.morador()], motivo: this.motivo() }),
});
const dado = await r.json();
if (!r.ok) throw new Error(dado.error || 'falha ao criar a chamada');
await this.voz.attachOutgoing(dado.call_id, { forceRelay: this.q.get('relay') === '1' });
this.callId.set(dado.call_id);
} catch (e) {
const code = (e as { code?: string }).code;
this.erro.set(code === 'mic_denied' ? 'Libere o microfone para ligar.' : `Não foi possível ligar (${code || (e as Error).message}).`);
if (this.q.get('auto') === '1') window.__voiceResult = { status: `erro_${code || 'ligar'}` };
}
}
protected terminou(fim: { status: string; endReason: string | null }): void {
if (this.q.get('auto') === '1' && !window.__voiceResult) {
window.__voiceResult = { ...(this.ultimas || {}), status: fim.status, endReason: fim.endReason };
}
}
/** CI: fala `secs` segundos depois de conectar a mídia e desliga, guardando as métricas. */
private automatico(): void {
const id = this.callId();
if (!id) return;
const secs = Number(this.q.get('secs') || 6);
let falando = 0;
const t = setInterval(async () => {
const h = this.voz.handle(id);
if (!h) return;
if (h.pc) this.ultimas = { ...(await h.stats()) };
if (h.status === 'active' && h.mediaConnected) falando += 1;
if (falando >= secs) {
clearInterval(t);
await this.voz.hangup(id);
}
if (['completed', 'missed', 'cancelled', 'rejected', 'failed', 'busy'].includes(h.status)) clearInterval(t);
}, 1000);
}
}Componente opcional
<moraviq-call [callId]="id" (ended)="..."> mostra o estado, "Atendida por", Silenciar, Desligar e "Ativar o som" quando o navegador bloqueia o autoplay. Sem estilo de marca: herda a fonte e expõe as classes mv-call, mv-call__status, mv-call__mute, mv-call__hangup. Para a sua própria tela, use só o serviço.