feat(ao-vivo): camera sempre visivel, lista estilo iOS e dados do aparelho
- palco fixo no topo: camera grande com o rosto num quadrinho no canto (toque troca os dois); botao "Ligar camera" direto no palco - controles em lista agrupada estilo Ajustes do iPhone (Grupo, Linha, Interruptor): camera e rastreamento, gestos, aparencia, personalidade - cartao do celular com modelo, Android, versao do app, bateria, Wi-Fi, IP e estado do rosto - APK 0.8.1 informa ip/modelo/android/app/wifi (ACCESS_WIFI_STATE, so o nivel do sinal); servidor usa o IP informado pelo app em vez do endereco da conexao Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
<script lang="ts">
|
||||
import { BatteryCharging, BatteryFull, BatteryLow, BatteryMedium, Moon, Smartphone, Sun, WifiOff } from '@lucide/svelte';
|
||||
import { estado } from '../estado.svelte';
|
||||
|
||||
// Cartão do aparelho com o que o próprio app informa (IP, modelo, Android, versão, Wi-Fi, bateria).
|
||||
const s = $derived(estado.app?.estado ?? {});
|
||||
const ap = $derived(s.aparelho ?? null);
|
||||
const bateria = $derived.by(() => {
|
||||
const b = s.bateria;
|
||||
const n = typeof b === 'number' ? b : b?.nivel;
|
||||
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
|
||||
});
|
||||
const carregando = $derived(typeof s.bateria === 'object' && !!s.bateria?.carregando);
|
||||
const IconeBateria = $derived(carregando ? BatteryCharging : bateria === null ? BatteryMedium : bateria < 20 ? BatteryLow : bateria < 70 ? BatteryMedium : BatteryFull);
|
||||
const ip = $derived(ap?.ip || estado.app?.celular_ip || '—');
|
||||
const visto = $derived(estado.app?.idade ?? null);
|
||||
</script>
|
||||
|
||||
<div class="celular" class:offline={!estado.celularOnline}>
|
||||
<div class="cabeca">
|
||||
<span class="aparelho" aria-hidden="true"><Smartphone size={22} /></span>
|
||||
<div class="nome">
|
||||
<strong>{ap?.modelo || 'Celular do rosto'}</strong>
|
||||
<span>
|
||||
{#if estado.celularOnline}
|
||||
{[ap?.android && 'Android ' + ap.android, ap?.app && 'app ' + ap.app].filter(Boolean).join(' · ') || 'online'}
|
||||
{:else}
|
||||
offline{visto !== null ? ` · visto há ${Math.round(visto)} s` : ''}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<span class="estado" class:on={estado.celularOnline}>{estado.celularOnline ? 'Online' : 'Offline'}</span>
|
||||
</div>
|
||||
|
||||
{#if estado.celularOnline}
|
||||
<dl class="dados">
|
||||
<div>
|
||||
<dt>Bateria</dt>
|
||||
<dd class:baixa={bateria !== null && bateria < 20 && !carregando}>
|
||||
<IconeBateria size={18} />
|
||||
{bateria !== null ? bateria + '%' : '—'}
|
||||
{#if carregando}<small>carregando</small>{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Wi-Fi</dt>
|
||||
<dd>
|
||||
{#if typeof ap?.wifi === 'number'}
|
||||
<svg viewBox="0 0 20 16" width="20" height="16" aria-hidden="true">
|
||||
{#each [0, 1, 2, 3] as i (i)}
|
||||
<rect x={i * 5} y={12 - i * 4} width="3.5" height={4 + i * 4} rx="1" class:cheia={ap.wifi > i} />
|
||||
{/each}
|
||||
</svg>
|
||||
<span class="sr-only">{ap.wifi} de 4</span>
|
||||
{#if ap.rssi}<small>{ap.rssi} dBm</small>{/if}
|
||||
{:else}
|
||||
<WifiOff size={16} /> <small>sem dado</small>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>IP</dt>
|
||||
<dd class="mono">{ip}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Rosto</dt>
|
||||
<dd>
|
||||
{#if s.dormindo}<Moon size={16} /> Dormindo{:else}<Sun size={16} /> Acordado{/if}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{:else}
|
||||
<p class="ajuda">Abra o app Carinha no celular e confira se ele aponta para este servidor (toque longo › Mudar o servidor).</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.celular {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
padding: var(--e-4);
|
||||
}
|
||||
.cabeca {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-3);
|
||||
}
|
||||
.aparelho {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #5e5ce6, #0a84ff);
|
||||
color: #fff;
|
||||
flex: none;
|
||||
}
|
||||
.offline .aparelho {
|
||||
background: var(--superficie-3);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.nome {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.nome strong {
|
||||
font-size: var(--t-md);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nome span {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.estado {
|
||||
font-size: var(--t-xs);
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie-3);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.estado.on {
|
||||
background: var(--ok-suave);
|
||||
color: var(--ok);
|
||||
}
|
||||
.dados {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--e-2);
|
||||
margin: 0;
|
||||
}
|
||||
.dados div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: var(--e-2) var(--e-3);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie-2);
|
||||
min-width: 0;
|
||||
}
|
||||
dt {
|
||||
font-size: 11px;
|
||||
color: var(--texto-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: var(--t-sm);
|
||||
flex-wrap: wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
dd small {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.baixa {
|
||||
color: var(--perigo);
|
||||
}
|
||||
rect {
|
||||
fill: var(--superficie-3);
|
||||
}
|
||||
rect.cheia {
|
||||
fill: var(--texto);
|
||||
}
|
||||
.ajuda {
|
||||
font-size: var(--t-sm);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
dd.mono {
|
||||
white-space: nowrap;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
// Lista agrupada no estilo dos Ajustes do iPhone: título pequeno, cartão arredondado, rodapé de ajuda.
|
||||
let { titulo = '', rodape = '', children }: { titulo?: string; rodape?: string; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<section class="grupo">
|
||||
{#if titulo}<h2>{titulo}</h2>{/if}
|
||||
<div class="itens">{@render children()}</div>
|
||||
{#if rodape}<p class="rodape">{rodape}</p>{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.grupo {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
h2 {
|
||||
padding: 0 var(--e-4);
|
||||
font-size: var(--t-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.itens {
|
||||
background: var(--superficie);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.rodape {
|
||||
padding: 0 var(--e-4);
|
||||
font-size: var(--t-xs);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
// Interruptor no estilo iOS (verde quando ligado). O rótulo vira o nome acessível.
|
||||
let { rotulo, checked = false, disabled = false, onchange }: {
|
||||
rotulo: string;
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
onchange: (v: boolean) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<label class="interruptor">
|
||||
<input type="checkbox" role="switch" aria-label={rotulo} {checked} {disabled} onchange={(e) => onchange(e.currentTarget.checked)} />
|
||||
<span class="trilho" aria-hidden="true"></span>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
.interruptor {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.trilho {
|
||||
width: 51px;
|
||||
height: 31px;
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie-3);
|
||||
position: relative;
|
||||
transition: background var(--transicao);
|
||||
}
|
||||
.trilho::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 0.3);
|
||||
transition: transform var(--transicao);
|
||||
}
|
||||
input:checked + .trilho {
|
||||
background: #34c759;
|
||||
}
|
||||
input:checked + .trilho::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
input:focus-visible + .trilho {
|
||||
outline: 2px solid var(--destaque);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
input:disabled + .trilho {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { ChevronRight } from '@lucide/svelte';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
|
||||
// Uma linha da lista: ícone colorido, rótulo, detalhe e um acessório (interruptor, valor ou seta).
|
||||
// Com href ou onclick a linha inteira é clicável e mostra a seta.
|
||||
let {
|
||||
icone,
|
||||
cor = 'var(--destaque)',
|
||||
rotulo,
|
||||
detalhe = '',
|
||||
href,
|
||||
onclick,
|
||||
disabled = false,
|
||||
acessorio,
|
||||
children,
|
||||
}: {
|
||||
icone?: Component<any>;
|
||||
cor?: string;
|
||||
rotulo: string;
|
||||
detalhe?: string;
|
||||
href?: string;
|
||||
onclick?: () => void;
|
||||
disabled?: boolean;
|
||||
acessorio?: Snippet;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
const clicavel = $derived(!!href || !!onclick);
|
||||
</script>
|
||||
|
||||
{#snippet corpo()}
|
||||
{#if icone}
|
||||
{@const Icone = icone}
|
||||
<span class="icone" style="--cor: {cor}" aria-hidden="true"><Icone size={16} strokeWidth={2.4} /></span>
|
||||
{/if}
|
||||
<span class="texto">
|
||||
<span class="rotulo">{rotulo}</span>
|
||||
{#if detalhe}<span class="detalhe">{detalhe}</span>{/if}
|
||||
</span>
|
||||
{#if acessorio}<span class="acessorio">{@render acessorio()}</span>{/if}
|
||||
{#if clicavel}<ChevronRight size={18} class="seta" aria-hidden="true" />{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="linha-ios" class:com-icone={!!icone} class:desativada={disabled}>
|
||||
{#if href}
|
||||
<a class="conteudo" {href}>{@render corpo()}</a>
|
||||
{:else if onclick}
|
||||
<button class="conteudo" {onclick} {disabled}>{@render corpo()}</button>
|
||||
{:else}
|
||||
<div class="conteudo">{@render corpo()}</div>
|
||||
{/if}
|
||||
{#if children}<div class="extra">{@render children()}</div>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.linha-ios {
|
||||
position: relative;
|
||||
}
|
||||
/* separador fino que começa depois do ícone, como no iOS */
|
||||
.linha-ios:not(:first-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: var(--e-4);
|
||||
border-top: 1px solid var(--borda);
|
||||
}
|
||||
.linha-ios.com-icone:not(:first-child)::before {
|
||||
left: calc(var(--e-4) + 30px + var(--e-3));
|
||||
}
|
||||
.conteudo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-3);
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: var(--e-2) var(--e-4);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--texto);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.conteudo,
|
||||
button.conteudo {
|
||||
cursor: pointer;
|
||||
}
|
||||
a.conteudo:hover,
|
||||
button.conteudo:hover:not(:disabled) {
|
||||
background: var(--superficie-2);
|
||||
}
|
||||
a.conteudo:active,
|
||||
button.conteudo:active:not(:disabled) {
|
||||
background: var(--superficie-3);
|
||||
}
|
||||
.icone {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: var(--cor);
|
||||
color: #fff;
|
||||
}
|
||||
.texto {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.rotulo {
|
||||
font-size: var(--t-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.detalhe {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--texto-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.acessorio {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-2);
|
||||
color: var(--texto-3);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
.conteudo :global(.seta) {
|
||||
flex: none;
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.extra {
|
||||
padding: 0 var(--e-4) var(--e-4);
|
||||
}
|
||||
.desativada {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -18,6 +18,8 @@ export interface Telemetria {
|
||||
bateria?: { nivel?: number; carregando?: boolean } | number | null;
|
||||
brilho?: number;
|
||||
video?: { qualidade?: string } | null;
|
||||
/** Informado pelo próprio app (APK 0.8.1+). */
|
||||
aparelho?: { ip?: string | null; modelo?: string; android?: string; app?: string | null; wifi?: number; rssi?: number } | null;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
preview: boolean;
|
||||
|
||||
Reference in New Issue
Block a user