Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6b8e2efd5 | ||
|
|
69d04d4972 | ||
|
|
a058c6f453 |
@@ -1,14 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="br.carinha.rosto"
|
||||
android:versionCode="8"
|
||||
android:versionName="0.8-token">
|
||||
android:versionCode="9"
|
||||
android:versionName="0.8.1">
|
||||
|
||||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
||||
|
||||
<!-- precisa de rede para falar com o servidor da carinha (troca de rosto) -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- só o nível do sinal do Wi-Fi, para o painel (sem localização nem nome da rede) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<!-- escutar o ambiente: reage a barulho (surpreso) e a silencio (dormir) -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- camera: pagina de visualizacao e, depois, seguir o movimento -->
|
||||
|
||||
@@ -865,6 +865,7 @@ async function sincronizarApp() {
|
||||
centro: [MOV.cx, MOV.cy], config: configRecebida,
|
||||
brilho: window.carinhaBrilho,
|
||||
bateria: window.carinhaBateria,
|
||||
aparelho: window.carinhaAparelho,
|
||||
video: window.carinhaVideo,
|
||||
}) });
|
||||
}
|
||||
|
||||
@@ -1021,8 +1021,53 @@ public class MainActivity extends Activity {
|
||||
return String.format(java.util.Locale.US, "%.3f", v);
|
||||
}
|
||||
|
||||
private String aparelhoJson = "null";
|
||||
private long aparelhoEm = 0;
|
||||
|
||||
/** IP, modelo, Android, versão do app e sinal do Wi-Fi; recalculado a cada 10 s. */
|
||||
private String infoAparelho() {
|
||||
long agora = System.currentTimeMillis();
|
||||
if (agora - aparelhoEm < 10000) return aparelhoJson;
|
||||
aparelhoEm = agora;
|
||||
try {
|
||||
JSONObject a = new JSONObject();
|
||||
a.put("ip", ipLocal());
|
||||
a.put("modelo", (Build.MANUFACTURER + " " + Build.MODEL).trim());
|
||||
a.put("android", Build.VERSION.RELEASE);
|
||||
try { a.put("app", getPackageManager().getPackageInfo(getPackageName(), 0).versionName); }
|
||||
catch (Exception e) { a.put("app", JSONObject.NULL); }
|
||||
try {
|
||||
android.net.wifi.WifiManager wifi = (android.net.wifi.WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE);
|
||||
android.net.wifi.WifiInfo info = wifi == null ? null : wifi.getConnectionInfo();
|
||||
// Sem localização o Android 10+ esconde o ID da rede, mas o RSSI continua valendo.
|
||||
if (info != null && info.getRssi() > -127 && info.getRssi() < 0) {
|
||||
a.put("wifi", android.net.wifi.WifiManager.calculateSignalLevel(info.getRssi(), 5));
|
||||
a.put("rssi", info.getRssi());
|
||||
}
|
||||
} catch (Exception e) { /* sem permissão ou sem Wi-Fi */ }
|
||||
aparelhoJson = a.toString();
|
||||
} catch (Exception e) {
|
||||
aparelhoJson = "null";
|
||||
}
|
||||
return aparelhoJson;
|
||||
}
|
||||
|
||||
/** Primeiro IPv4 privado da rede (Wi-Fi), sem precisar de permissão extra. */
|
||||
private static String ipLocal() {
|
||||
try {
|
||||
for (java.net.NetworkInterface n : java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) {
|
||||
if (!n.isUp() || n.isLoopback()) continue;
|
||||
for (java.net.InetAddress e : java.util.Collections.list(n.getInetAddresses())) {
|
||||
if (e instanceof java.net.Inet4Address && e.isSiteLocalAddress()) return e.getHostAddress();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) { /* sem rede */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private void atualizarBrilho() {
|
||||
if (naCamera) return;
|
||||
web.evaluateJavascript("window.carinhaAparelho=" + infoAparelho(), null);
|
||||
Intent bateria = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
|
||||
if (bateria != null) {
|
||||
int level = bateria.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
|
||||
|
||||
@@ -865,6 +865,7 @@ async function sincronizarApp() {
|
||||
centro: [MOV.cx, MOV.cy], config: configRecebida,
|
||||
brilho: window.carinhaBrilho,
|
||||
bateria: window.carinhaBateria,
|
||||
aparelho: window.carinhaAparelho,
|
||||
video: window.carinhaVideo,
|
||||
}) });
|
||||
}
|
||||
|
||||
+12
-1
@@ -1,4 +1,5 @@
|
||||
"""Configuração persistente e telemetria do celular; sinalização do vídeo WebRTC."""
|
||||
import ipaddress
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
@@ -51,6 +52,16 @@ def validar(dados):
|
||||
return dados
|
||||
|
||||
|
||||
def ip_informado(dados):
|
||||
"""IP que o próprio app informa (aparelho.ip); o endereço da conexão pode ser de um proxy."""
|
||||
aparelho = dados.get('aparelho')
|
||||
ip = aparelho.get('ip') if isinstance(aparelho, dict) else None
|
||||
try:
|
||||
return str(ipaddress.IPv4Address(ip)) if isinstance(ip, str) else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class ControlePC:
|
||||
def __init__(self, arquivo):
|
||||
self.arquivo = Path(arquivo)
|
||||
@@ -134,7 +145,7 @@ class ControlePC:
|
||||
raise ValueError('Estado deve ser objeto JSON')
|
||||
with self.lock:
|
||||
self.estado = dados
|
||||
self.celular_ip = h.client_address[0]
|
||||
self.celular_ip = ip_informado(dados) or h.client_address[0]
|
||||
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
|
||||
self.recarga["confirmada"] = True
|
||||
self.status_at = time.monotonic()
|
||||
|
||||
+3
-1
@@ -7,12 +7,14 @@
|
||||
.\deploy.ps1 # compila, publica em root@192.168.1.35:/opt/carinha-pc e reinicia
|
||||
.\deploy.ps1 -SoPacote # só gera o pacote local (nada vai para a placa)
|
||||
.\deploy.ps1 -Placa 192.168.1.40 -Usuario carinha
|
||||
.\deploy.ps1 -Ssh orangepi # usa um Host do ~/.ssh/config (chave própria)
|
||||
#>
|
||||
param(
|
||||
[string]$Placa = '192.168.1.35',
|
||||
[string]$Usuario = 'root',
|
||||
[string]$Destino = '/opt/carinha-pc',
|
||||
[int]$Porta = 8100,
|
||||
[string]$Ssh = '',
|
||||
[switch]$SemCompilar,
|
||||
[switch]$SoPacote
|
||||
)
|
||||
@@ -67,7 +69,7 @@ if ($SoPacote) {
|
||||
return
|
||||
}
|
||||
|
||||
$alvo = "$Usuario@$Placa"
|
||||
$alvo = if ($Ssh) { $Ssh } else { "$Usuario@$Placa" }
|
||||
$remoto = "/tmp/carinha-$versao-$carimbo.tar.gz"
|
||||
Write-Host "== Enviando para $alvo"
|
||||
& scp -q $pacote "${alvo}:$remoto"
|
||||
|
||||
@@ -86,6 +86,12 @@ class ControleTest(unittest.TestCase):
|
||||
self.assertTrue(json.loads(body)['online'])
|
||||
self.assertEqual(json.loads(body)['estado']['rosto'], 'miudo')
|
||||
|
||||
def test_phone_reports_its_own_ip(self):
|
||||
self.request('/app/status', {'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F'}})
|
||||
self.assertEqual(json.loads(self.request('/app/status')[1])['celular_ip'], '192.168.1.16')
|
||||
self.request('/app/status', {'aparelho': {'ip': 'não é ip'}})
|
||||
self.assertEqual(json.loads(self.request('/app/status')[1])['celular_ip'], '127.0.0.1')
|
||||
|
||||
def test_legacy_camera_and_adb_routes_are_gone(self):
|
||||
self.request('/app/config', {'preview': True})
|
||||
self.assertEqual(self.request('/camera/frame', b'\xff\xd8x\xff\xd9', 'image/jpeg')[0], 404)
|
||||
|
||||
@@ -74,8 +74,13 @@ def testar(base, server, capturas):
|
||||
|
||||
# 2. telemetria do celular chega por SSE
|
||||
page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8,
|
||||
'microfone_ativo': True, 'microfone': 0.3})
|
||||
'microfone_ativo': True, 'microfone': 0.3,
|
||||
'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F', 'android': '12',
|
||||
'app': '0.8.1', 'wifi': 3, 'rssi': -58}})
|
||||
expect(page.get_by_text('Celular online · 80%')).to_be_visible(timeout=5000)
|
||||
# Dados que o próprio APK informa aparecem no cartão do celular.
|
||||
expect(page.get_by_text('samsung SM-G973F')).to_be_visible()
|
||||
expect(page.get_by_text('192.168.1.16')).to_be_visible()
|
||||
|
||||
# 3. gesto pelo dock
|
||||
kiosk.FILA.limpar()
|
||||
|
||||
@@ -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;
|
||||
|
||||
+207
-188
@@ -1,15 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Battery, Brain, Camera, Download, Ear, Moon, RefreshCw, ScanFace } from '@lucide/svelte';
|
||||
import {
|
||||
Brain, Camera, Download, Eye, Hand, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
|
||||
} from '@lucide/svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { postar } from '../lib/api';
|
||||
import { salvarApp } from '../lib/ajustes.svelte';
|
||||
import { tentar } from '../lib/avisos.svelte';
|
||||
import { cor, CORES, hex, recarregarCelular, rgb, rosto } from '../lib/comandos.svelte';
|
||||
import Card from '../lib/componentes/Card.svelte';
|
||||
import Celular from '../lib/componentes/Celular.svelte';
|
||||
import Gestos from '../lib/componentes/Gestos.svelte';
|
||||
import Grupo from '../lib/componentes/Grupo.svelte';
|
||||
import Interruptor from '../lib/componentes/Interruptor.svelte';
|
||||
import Linha from '../lib/componentes/Linha.svelte';
|
||||
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||
import Selo from '../lib/componentes/Selo.svelte';
|
||||
import Toggle from '../lib/componentes/Toggle.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
import { Rastreamento } from '../lib/rastreamento.svelte';
|
||||
import { VideoAoVivo } from '../lib/video.svelte';
|
||||
@@ -19,6 +22,9 @@
|
||||
let video = $state<VideoAoVivo>();
|
||||
let rastreio = $state<Rastreamento>();
|
||||
|
||||
// Palco: câmera grande e rosto no quadrinho do canto; tocar no quadrinho troca os dois.
|
||||
let rostoGrande = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
video = new VideoAoVivo(elVideo);
|
||||
rastreio = new Rastreamento(elVideo, elTela, video);
|
||||
@@ -40,16 +46,11 @@
|
||||
rastreio?.atualizar(cfg, permitido);
|
||||
});
|
||||
|
||||
const sensores = $derived(estado.app?.estado ?? {});
|
||||
const bateria = $derived.by(() => {
|
||||
const b = sensores.bateria;
|
||||
const n = typeof b === 'number' ? b : b?.nivel;
|
||||
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
|
||||
});
|
||||
const pct = (v: unknown) => (typeof v === 'number' ? Math.round(Math.min(1, v) * 100) : 0);
|
||||
const cfg = $derived(estado.appConfig);
|
||||
const transmitindo = $derived(!!cfg?.preview);
|
||||
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
async function foto() {
|
||||
function foto() {
|
||||
if (!elVideo.videoWidth) return;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = elVideo.videoWidth;
|
||||
@@ -61,69 +62,106 @@
|
||||
a.click();
|
||||
}
|
||||
|
||||
const iaLocal = (ativo: boolean) => tentar(() => postar('/ia/config', { ativo }), ativo ? 'Personalidade local ligada.' : 'Personalidade local desligada.');
|
||||
const iaLocal = (ativo: boolean) =>
|
||||
tentar(() => postar('/ia/config', { ativo }), ativo ? 'Personalidade local ligada.' : 'Personalidade local desligada.');
|
||||
</script>
|
||||
|
||||
<div class="ao-vivo">
|
||||
<section class="palco">
|
||||
<div class="quadro camera">
|
||||
<!-- Palco fixo: a câmera continua visível enquanto a lista rola -->
|
||||
<section class="palco" aria-label="Câmera e rosto">
|
||||
<div class="quadro camera" class:mini={rostoGrande}>
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
|
||||
<canvas bind:this={elTela} class="sobreposicao" aria-hidden="true"></canvas>
|
||||
{#if !video?.exibindo}
|
||||
<div class="vazio">
|
||||
<Camera size={32} />
|
||||
<p>{video?.mensagem ?? 'Carregando…'}</p>
|
||||
<Camera size={rostoGrande ? 18 : 32} />
|
||||
{#if !rostoGrande}
|
||||
<p>{video?.mensagem ?? 'Carregando…'}</p>
|
||||
{#if !transmitindo && cfg}
|
||||
<button class="btn primario" onclick={() => salvarApp({ preview: true })}><Video size={16} /> Ligar câmera</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if rostoGrande}
|
||||
<button class="trocar" onclick={() => (rostoGrande = false)} aria-label="Mostrar a câmera em destaque"></button>
|
||||
{:else}
|
||||
<div class="rodape">
|
||||
<span class="ponto" class:ao-vivo-on={video?.exibindo}></span>
|
||||
<span>{video?.exibindo ? 'Ao vivo' : 'Câmera'}</span>
|
||||
{#if video?.qualidade && video.exibindo}<span class="mono">{video.qualidade}</span>{/if}
|
||||
<span class="espaco"></span>
|
||||
{#if video?.exibindo}
|
||||
<button class="btn pequeno" onclick={foto}><Download size={14} /> Foto</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="rodape">
|
||||
<Selo tom={video?.exibindo ? 'ok' : 'neutro'}>Câmera</Selo>
|
||||
{#if video?.qualidade && video.exibindo}<span class="mono fraco">{video.qualidade}</span>{/if}
|
||||
<span class="espaco"></span>
|
||||
{#if video?.exibindo}
|
||||
<button class="btn pequeno" onclick={foto}><Download size={14} /> Foto</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="quadro rosto">
|
||||
|
||||
<div class="quadro rosto" class:mini={!rostoGrande}>
|
||||
<RostoPrevia titulo="Rosto ao vivo" />
|
||||
<div class="rodape">
|
||||
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>Rosto {estado.celularOnline ? 'no celular' : '· prévia'}</Selo>
|
||||
</div>
|
||||
{#if !rostoGrande}
|
||||
<button class="trocar" onclick={() => (rostoGrande = true)} aria-label="Mostrar o rosto em destaque">
|
||||
<Repeat size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="controles">
|
||||
<div class="transmissao">
|
||||
<Toggle
|
||||
rotulo="Transmitir câmera do celular"
|
||||
ajuda="Vídeo direto do celular para este PC (WebRTC)"
|
||||
checked={!!estado.appConfig?.preview}
|
||||
disabled={!estado.appConfig}
|
||||
onchange={(v) => salvarApp({ preview: v })}
|
||||
/>
|
||||
{#if rastreio && estado.appConfig?.preview}
|
||||
<p class="fraco pequeno-texto linha"><ScanFace size={14} /> {rastreio.texto}</p>
|
||||
<div class="lista">
|
||||
<Grupo titulo="Celular">
|
||||
<Celular />
|
||||
<Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
|
||||
</Grupo>
|
||||
|
||||
<Grupo titulo="Câmera" rodape={transmitindo && rastreio ? rastreio.texto : 'O vídeo vai direto do celular para este navegador (WebRTC); nada é gravado.'}>
|
||||
<Linha icone={Video} cor="#34c759" rotulo="Transmitir câmera do celular" detalhe={video?.exibindo ? video.qualidade : ''}>
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Transmitir câmera do celular" checked={transmitindo} disabled={!cfg} onchange={(v) => salvarApp({ preview: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
{#if transmitindo}
|
||||
<Linha icone={ScanFace} cor="#af52de" rotulo="Rastrear rosto">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Rastrear rosto" checked={!!cfg?.rastrearRosto} onchange={(v) => salvarApp({ rastrearRosto: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Hand} cor="#ff9500" rotulo="Rastrear mãos">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Rastrear mãos" checked={!!cfg?.rastrearMaos} onchange={(v) => salvarApp({ rastrearMaos: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Eye} cor="#007aff" rotulo="Olhos seguem o alvo">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Olhos seguem o alvo" checked={!!cfg?.seguirRastreamento} onchange={(v) => salvarApp({ seguirRastreamento: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={PenLine} cor="#8e8e93" rotulo="Desenhar rastreamento no vídeo">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Desenhar rastreamento no vídeo" checked={!!cfg?.mostrarRastreamento} onchange={(v) => salvarApp({ mostrarRastreamento: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
|
||||
</Grupo>
|
||||
|
||||
<div class="grade-cards">
|
||||
<Card titulo="Gestos" descricao="Favoritos com atalho de 1 a 9. Fixe outros na tela de Reações ou pelo Ctrl+K.">
|
||||
<Gestos />
|
||||
</Card>
|
||||
<Grupo titulo="Gestos" rodape="Teclas 1 a 9 no teclado. Ctrl+K busca qualquer gesto.">
|
||||
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
|
||||
<Gestos />
|
||||
</Linha>
|
||||
<Linha icone={Sparkles} cor="#ff2d55" rotulo="Todos os gestos e reações" href="#/reacoes" />
|
||||
</Grupo>
|
||||
|
||||
<Card titulo="Aparência">
|
||||
<div class="campo">
|
||||
<span>Rosto</span>
|
||||
<Grupo titulo="Aparência">
|
||||
<Linha icone={Smile} cor="#5ac8fa" rotulo="Rosto">
|
||||
<div class="segmentado" role="radiogroup" aria-label="Rosto">
|
||||
{#each estado.rostos as r (r)}
|
||||
<button role="radio" aria-checked={estado.aparencia.rosto === r} onclick={() => rosto(r)}>{r}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="campo">
|
||||
<span>Cor dos olhos</span>
|
||||
</Linha>
|
||||
<Linha icone={Palette} cor="#ff9500" rotulo="Cor dos olhos">
|
||||
<div class="cores">
|
||||
{#each CORES as c (c.nome)}
|
||||
<button
|
||||
@@ -134,83 +172,38 @@
|
||||
onclick={() => cor(c.rgb)}
|
||||
></button>
|
||||
{/each}
|
||||
<label class="cor livre" style="--c: {hex(estado.aparencia.cor)}" title="Outra cor">
|
||||
<label class="cor livre" title="Outra cor">
|
||||
<input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} />
|
||||
<span class="sr-only">Escolher outra cor</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Linha>
|
||||
</Grupo>
|
||||
|
||||
<Card titulo="Celular">
|
||||
{#snippet acoes()}
|
||||
<button class="btn pequeno" onclick={recarregarCelular} disabled={!estado.celularOnline}>
|
||||
<RefreshCw size={14} /> Recarregar tela
|
||||
</button>
|
||||
{/snippet}
|
||||
{#if estado.celularOnline}
|
||||
<dl class="sensores">
|
||||
<div>
|
||||
<dt><Battery size={16} /> Bateria</dt>
|
||||
<dd>{bateria !== null ? bateria + '%' : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><Moon size={16} /> Estado</dt>
|
||||
<dd>{sensores.dormindo ? 'Dormindo' : 'Acordado'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><Ear size={16} /> Som</dt>
|
||||
<dd>
|
||||
{#if sensores.microfone_ativo}
|
||||
<meter min="0" max="100" value={pct(sensores.microfone)} aria-label="Nível de som"></meter>
|
||||
{:else}<span class="fraco">desligado</span>{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><Camera size={16} /> Movimento</dt>
|
||||
<dd>
|
||||
{#if sensores.camera_ativa}
|
||||
<meter min="0" max="100" value={pct((sensores.movimento as number) * 5)} aria-label="Movimento"></meter>
|
||||
{:else}<span class="fraco">desligado</span>{/if}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="fraco pequeno-texto mono">{estado.app?.celular_ip ?? ''}</p>
|
||||
{:else}
|
||||
<p class="fraco pequeno-texto">
|
||||
O celular não está enviando sinais. Abra o app Carinha no aparelho e confira se ele aponta para este servidor.
|
||||
</p>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card titulo="Personalidade local" descricao="Qwen escolhe expressões a partir de sons e movimentos.">
|
||||
<Toggle
|
||||
<Grupo titulo="Personalidade" rodape={estado.ia?.erro || 'Qwen escolhe expressões a partir de sons e movimentos do celular.'}>
|
||||
<Linha
|
||||
icone={Brain}
|
||||
cor="#5e5ce6"
|
||||
rotulo="Reagir sozinho com IA local"
|
||||
ajuda={estado.ia ? `${estado.ia.modelo} · ${estado.ia.executor}` : ''}
|
||||
checked={!!estado.ia?.ativo}
|
||||
disabled={!estado.ia}
|
||||
onchange={iaLocal}
|
||||
/>
|
||||
{#if estado.ia?.ultima}
|
||||
<p class="pequeno-texto linha">
|
||||
<Brain size={14} /> Último: {estado.ia.ultima.evento} → <strong>{estado.ia.ultima.gesto}</strong>
|
||||
<span class="fraco">({estado.ia.ultima.segundos}s)</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if estado.ia?.erro}<p class="erro pequeno-texto">{estado.ia.erro}</p>{/if}
|
||||
</Card>
|
||||
detalhe={estado.ia?.ultima ? `Último: ${estado.ia.ultima.evento} → ${estado.ia.ultima.gesto} (${estado.ia.ultima.segundos}s)` : estado.ia ? `${estado.ia.modelo} · ${estado.ia.executor}` : ''}
|
||||
>
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Reagir sozinho com IA local" checked={!!estado.ia?.ativo} disabled={!estado.ia} onchange={iaLocal} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
</Grupo>
|
||||
|
||||
<Card titulo="Acontecimentos" class="feed">
|
||||
<Grupo titulo="Acontecimentos">
|
||||
{#if estado.feed.length}
|
||||
<ol>
|
||||
{#each estado.feed as item (item.id)}
|
||||
<li class={item.tipo}><time class="mono fraco">{hora(item.quando)}</time> {item.texto}</li>
|
||||
<ol class="feed">
|
||||
{#each estado.feed.slice(0, 12) as item (item.id)}
|
||||
<li class={item.tipo}><time class="mono">{hora(item.quando)}</time> <span>{item.texto}</span></li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else}
|
||||
<p class="fraco pequeno-texto">Gestos, respostas e reações aparecem aqui assim que acontecerem.</p>
|
||||
<p class="vazio-feed">Gestos, respostas e reações aparecem aqui na hora.</p>
|
||||
{/if}
|
||||
</Card>
|
||||
</Grupo>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -218,19 +211,35 @@
|
||||
.ao-vivo {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
align-items: start;
|
||||
}
|
||||
/* ---------- palco: sempre visível */
|
||||
.palco {
|
||||
display: grid;
|
||||
gap: var(--e-3);
|
||||
grid-template-columns: 1fr;
|
||||
position: sticky;
|
||||
top: calc(var(--topo-altura) + env(safe-area-inset-top, 0px));
|
||||
z-index: 20;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: calc(-1 * var(--e-4)) calc(-1 * var(--e-4)) 0;
|
||||
background: #000;
|
||||
}
|
||||
.quadro {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--raio-lg);
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
border: 1px solid var(--borda);
|
||||
transition: all 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.quadro:not(.mini) {
|
||||
inset: 0;
|
||||
}
|
||||
.quadro.mini {
|
||||
right: var(--e-3);
|
||||
top: var(--e-3);
|
||||
width: 30%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--raio);
|
||||
border: 2px solid rgb(255 255 255 / 0.85);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.5);
|
||||
z-index: 2;
|
||||
}
|
||||
.camera video,
|
||||
.sobreposicao {
|
||||
@@ -249,12 +258,23 @@
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: var(--e-2);
|
||||
gap: var(--e-3);
|
||||
padding: var(--e-5);
|
||||
text-align: center;
|
||||
color: #8396ab;
|
||||
color: #a9b8c9;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
.trocar {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: start end;
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rodape {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -264,46 +284,48 @@
|
||||
align-items: center;
|
||||
gap: var(--e-2);
|
||||
padding: var(--e-2) var(--e-3);
|
||||
background: linear-gradient(transparent, rgb(0 0 0 / 0.7));
|
||||
background: linear-gradient(transparent, rgb(0 0 0 / 0.75));
|
||||
color: #e8eef6;
|
||||
font-size: var(--t-xs);
|
||||
pointer-events: none;
|
||||
}
|
||||
.rodape button {
|
||||
pointer-events: auto;
|
||||
.rodape .mono {
|
||||
opacity: 0.8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ponto {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #8e8e93;
|
||||
flex: none;
|
||||
}
|
||||
.ponto.ao-vivo-on {
|
||||
background: #ff3b30;
|
||||
box-shadow: 0 0 0 3px rgb(255 59 48 / 0.3);
|
||||
}
|
||||
.espaco {
|
||||
flex: 1;
|
||||
}
|
||||
.controles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-4);
|
||||
align-items: center;
|
||||
}
|
||||
.transmissao {
|
||||
/* ---------- lista */
|
||||
.lista {
|
||||
display: grid;
|
||||
gap: var(--e-1);
|
||||
min-width: min(100%, 420px);
|
||||
}
|
||||
.grade-cards {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
|
||||
gap: var(--e-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.segmentado {
|
||||
display: flex;
|
||||
background: var(--fundo-2);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-sm);
|
||||
padding: 3px;
|
||||
gap: 3px;
|
||||
background: var(--superficie-2);
|
||||
border-radius: 9px;
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
.segmentado button {
|
||||
flex: 1;
|
||||
min-height: 34px;
|
||||
min-height: 32px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--texto-2);
|
||||
font: 500 var(--t-sm) var(--fonte);
|
||||
@@ -311,8 +333,9 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.segmentado button[aria-checked='true'] {
|
||||
background: var(--superficie-3);
|
||||
background: var(--superficie);
|
||||
color: var(--texto);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
.cores {
|
||||
display: flex;
|
||||
@@ -341,50 +364,46 @@
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sensores {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--e-3);
|
||||
margin: 0;
|
||||
}
|
||||
.sensores div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
dt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--t-xs);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
meter {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
}
|
||||
.erro {
|
||||
color: var(--perigo);
|
||||
}
|
||||
:global(.feed) ol {
|
||||
.feed {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
max-height: 260px;
|
||||
padding: var(--e-2) 0;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
:global(.feed) li.estop {
|
||||
.feed li {
|
||||
display: flex;
|
||||
gap: var(--e-3);
|
||||
padding: 6px var(--e-4);
|
||||
}
|
||||
.feed time {
|
||||
color: var(--texto-3);
|
||||
flex: none;
|
||||
}
|
||||
.feed li.estop {
|
||||
color: var(--aviso);
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.vazio-feed {
|
||||
padding: var(--e-4);
|
||||
font-size: var(--t-sm);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.palco {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin: 0;
|
||||
border-radius: var(--raio-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--borda);
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.ao-vivo {
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(340px, 1fr);
|
||||
gap: var(--e-5);
|
||||
}
|
||||
.palco {
|
||||
top: calc(var(--topo-altura) + var(--e-5));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user