mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Merge main into thumbnail-backfill
This commit is contained in:
commit
b00efa4f14
22
README.md
22
README.md
|
|
@ -505,6 +505,28 @@ server {
|
|||
}
|
||||
```
|
||||
|
||||
#### Don't add security headers at the proxy
|
||||
|
||||
The app already sets `X-Frame-Options`, `Strict-Transport-Security`, `Content-Security-Policy`,
|
||||
`X-Content-Type-Options`, etc. via [helmet](https://helmetjs.github.io/), and manages them
|
||||
**per route**: widget/kiosk renders and the device preview deliberately remove or relax
|
||||
`X-Frame-Options` so they can be framed, while the dashboard keeps the strict policy.
|
||||
|
||||
A proxy-level header block (nginx `add_header X-Frame-Options DENY;`, a Caddy
|
||||
`header { ... }` snippet, or a "security headers" preset) *adds a second copy* of these
|
||||
headers on top of the app's. Browsers treat conflicting duplicate `X-Frame-Options`
|
||||
values as `deny`, which breaks the dashboard's device Preview (a same-origin iframe of
|
||||
`/player`) and widget previews with console errors like:
|
||||
|
||||
```
|
||||
Refused to display 'https://…' in a frame because it set multiple
|
||||
'X-Frame-Options' headers with conflicting values ('DENY, SAMEORIGIN').
|
||||
Falling back to 'deny'.
|
||||
```
|
||||
|
||||
Let the proxy handle TLS, compression, and body-size limits only, and leave security
|
||||
headers to the app.
|
||||
|
||||
### Updating
|
||||
|
||||
To update a running instance to the latest version:
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ class DeviceInfo(private val context: Context) {
|
|||
// ISP's address as their screen's IP. Needs no permission — read straight off the
|
||||
// interfaces, so it works on Ethernet panels too, not just Wi-Fi.
|
||||
put("local_ip", getLocalIp() ?: JSONObject.NULL)
|
||||
// Both stacks, not one: getLocalIp() filters to Inet4Address, so a v6-only panel
|
||||
// reported NOTHING and the dashboard showed a dash for a screen that had a perfectly
|
||||
// good address. A dual-stack panel now shows both.
|
||||
put("local_ip6", getLocalIp6() ?: JSONObject.NULL)
|
||||
put("wifi_rssi", getWifiRSSI())
|
||||
put("uptime_seconds", getUptimeSeconds())
|
||||
// #74/#75: OS timezone + UTC clock (effective-tz resolution + dashboard skew indicator)
|
||||
|
|
@ -221,6 +225,41 @@ class DeviceInfo(private val context: Context) {
|
|||
found
|
||||
} catch (e: Throwable) { null }
|
||||
|
||||
/**
|
||||
* The panel's own IPv6 address, reported alongside the v4 one rather than instead of it —
|
||||
* a dual-stack screen has both and an operator may need either.
|
||||
*
|
||||
* Deliberately NOT link-local (fe80::/10). Every interface has one, they are the addresses
|
||||
* most likely to be enumerated first, and none of them can be dialled without also knowing
|
||||
* the zone index — so putting one in the dashboard would fill the field with a string that
|
||||
* cannot be pasted anywhere useful and hide the address that can. A global or unique-local
|
||||
* address is the one someone reaching the panel on site actually needs.
|
||||
*
|
||||
* The scope check also drops multicast and the unspecified address; what survives is a
|
||||
* routable unicast address. `hostAddress` can carry a %iface suffix on some builds, so it is
|
||||
* trimmed — the field is for humans and for pasting into a browser.
|
||||
*/
|
||||
private fun getLocalIp6(): String? = try {
|
||||
var found: String? = null
|
||||
val ifaces = java.net.NetworkInterface.getNetworkInterfaces()
|
||||
while (ifaces != null && ifaces.hasMoreElements() && found == null) {
|
||||
val iface = ifaces.nextElement()
|
||||
if (!iface.isUp || iface.isLoopback) continue
|
||||
val addrs = iface.inetAddresses
|
||||
while (addrs.hasMoreElements()) {
|
||||
val addr = addrs.nextElement()
|
||||
if (addr is java.net.Inet6Address &&
|
||||
!addr.isLoopbackAddress && !addr.isLinkLocalAddress &&
|
||||
!addr.isAnyLocalAddress && !addr.isMulticastAddress
|
||||
) {
|
||||
found = addr.hostAddress?.substringBefore('%')
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
} catch (e: Throwable) { null }
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun getWifiRSSI(): Int {
|
||||
return try {
|
||||
|
|
|
|||
|
|
@ -122,9 +122,19 @@ components:
|
|||
local_ip:
|
||||
type: [string, "null"]
|
||||
description: |
|
||||
The device's **own address on its local network** (e.g. `192.168.1.42`), as
|
||||
The device's **own IPv4 address on its local network** (e.g. `192.168.1.42`), as
|
||||
reported by the player itself. This is the one to use to reach a panel directly on
|
||||
site. Null on players that do not report it, or where the platform withholds it.
|
||||
site. Null on players that do not report it, where the platform withholds it, or on
|
||||
a panel with no IPv4 address at all — see `local_ip6`.
|
||||
local_ip6:
|
||||
type: [string, "null"]
|
||||
description: |
|
||||
The device's **own IPv6 address on its local network**, reported alongside
|
||||
`local_ip` rather than instead of it: a dual-stack panel has both and either may be
|
||||
the one you need. Link-local addresses (`fe80::/10`) are deliberately excluded —
|
||||
every interface has one and none can be reached without also knowing the zone
|
||||
index, so this carries a global or unique-local address or nothing. Null on players
|
||||
that do not report it and on IPv4-only panels.
|
||||
|
||||
# --- Latest telemetry --------------------------------------------------------------
|
||||
# Flattened from the most recent telemetry report. All null for a device that has
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ export default {
|
|||
'device.info.status': 'Status',
|
||||
'device.info.ip_address': 'IP-Adresse',
|
||||
'device.info.local_ip': 'Lokale IP',
|
||||
'device.info.local_ip6': 'Lokale IPv6',
|
||||
'device.info.wifi_needs_location': 'Standortberechtigung erforderlich',
|
||||
'device.info.battery': 'Akku',
|
||||
'device.info.storage': 'Speicher',
|
||||
|
|
@ -347,6 +348,9 @@ export default {
|
|||
'device.assign.select_first': 'Erst etwas auswählen',
|
||||
'device.assign.kiosk_widget_name': 'Kiosk: {name}',
|
||||
'device.toast.screenshot_requested': 'Screenshot angefordert',
|
||||
'device.toast.screenshot_unsupported': 'Der Player dieses Displays unterstützt keine Screenshots',
|
||||
'device.toast.screenshot_offline': 'Display ist offline — Screenshot nicht angefordert',
|
||||
'device.toast.screenshot_failed': 'Screenshot-Anfrage fehlgeschlagen — keine Antwort vom Server',
|
||||
'device.toast.renamed': 'Bildschirm umbenannt',
|
||||
'device.toast.removing': 'Wird entfernt...',
|
||||
'device.toast.removed': 'Bildschirm entfernt',
|
||||
|
|
|
|||
|
|
@ -476,6 +476,7 @@ export default {
|
|||
'device.info.status': 'Status',
|
||||
'device.info.ip_address': 'IP Address',
|
||||
'device.info.local_ip': 'Local IP',
|
||||
'device.info.local_ip6': 'Local IPv6',
|
||||
'device.info.wifi_needs_location': 'Needs location permission',
|
||||
'device.info.battery': 'Battery',
|
||||
'device.info.storage': 'Storage',
|
||||
|
|
@ -648,6 +649,9 @@ export default {
|
|||
'device.assign.kiosk_widget_name': 'Kiosk: {name}',
|
||||
// Toasts
|
||||
'device.toast.screenshot_requested': 'Screenshot requested',
|
||||
'device.toast.screenshot_unsupported': 'This display\'s player can\'t take screenshots',
|
||||
'device.toast.screenshot_offline': 'Display is offline — screenshot not requested',
|
||||
'device.toast.screenshot_failed': 'Screenshot request failed — no response from server',
|
||||
'device.toast.renamed': 'Display renamed',
|
||||
'device.toast.removing': 'Removing...',
|
||||
'device.toast.removed': 'Display removed',
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ export default {
|
|||
'device.info.status': 'Estado',
|
||||
'device.info.ip_address': 'Dirección IP',
|
||||
'device.info.local_ip': 'IP local',
|
||||
'device.info.local_ip6': 'IPv6 local',
|
||||
'device.info.wifi_needs_location': 'Requiere permiso de ubicación',
|
||||
'device.info.battery': 'Batería',
|
||||
'device.info.storage': 'Almacenamiento',
|
||||
|
|
@ -377,6 +378,9 @@ export default {
|
|||
'device.assign.select_first': 'Primero selecciona algo',
|
||||
'device.assign.kiosk_widget_name': 'Kiosco: {name}',
|
||||
'device.toast.screenshot_requested': 'Captura solicitada',
|
||||
'device.toast.screenshot_unsupported': 'El reproductor de esta pantalla no admite capturas de pantalla',
|
||||
'device.toast.screenshot_offline': 'La pantalla está desconectada — captura no solicitada',
|
||||
'device.toast.screenshot_failed': 'La solicitud de captura falló — sin respuesta del servidor',
|
||||
'device.toast.renamed': 'Pantalla renombrada',
|
||||
'device.toast.removing': 'Eliminando...',
|
||||
'device.toast.removed': 'Pantalla eliminada',
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ export default {
|
|||
'device.info.status': 'Statut',
|
||||
'device.info.ip_address': 'Adresse IP',
|
||||
'device.info.local_ip': 'IP locale',
|
||||
'device.info.local_ip6': 'IPv6 locale',
|
||||
'device.info.wifi_needs_location': 'Autorisation de localisation requise',
|
||||
'device.info.battery': 'Batterie',
|
||||
'device.info.storage': 'Stockage',
|
||||
|
|
@ -347,6 +348,9 @@ export default {
|
|||
'device.assign.select_first': 'Sélectionnez d\'abord un élément',
|
||||
'device.assign.kiosk_widget_name': 'Kiosque : {name}',
|
||||
'device.toast.screenshot_requested': 'Capture demandée',
|
||||
'device.toast.screenshot_unsupported': 'Le lecteur de cet écran ne prend pas en charge les captures d\'écran',
|
||||
'device.toast.screenshot_offline': 'Écran hors ligne — capture non demandée',
|
||||
'device.toast.screenshot_failed': 'Échec de la demande de capture — pas de réponse du serveur',
|
||||
'device.toast.renamed': 'Écran renommé',
|
||||
'device.toast.removing': 'Suppression...',
|
||||
'device.toast.removed': 'Écran retiré',
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ export default {
|
|||
'device.info.status': 'Stato',
|
||||
'device.info.ip_address': 'Indirizzo IP',
|
||||
'device.info.local_ip': 'IP locale',
|
||||
'device.info.local_ip6': 'IPv6 locale',
|
||||
'device.info.wifi_needs_location': 'Richiede permesso di posizione',
|
||||
'device.info.battery': 'Batteria',
|
||||
'device.info.storage': 'Archiviazione',
|
||||
|
|
@ -371,6 +372,9 @@ export default {
|
|||
'device.assign.kiosk_widget_name': 'Chiosco: {name}',
|
||||
// Toasts
|
||||
'device.toast.screenshot_requested': 'Richiesta screenshot inviata',
|
||||
'device.toast.screenshot_unsupported': 'Il player di questo schermo non supporta gli screenshot',
|
||||
'device.toast.screenshot_offline': 'Lo schermo è offline — screenshot non richiesto',
|
||||
'device.toast.screenshot_failed': 'Richiesta screenshot non riuscita — nessuna risposta dal server',
|
||||
'device.toast.renamed': 'Schermo rinominato',
|
||||
'device.toast.removing': 'Rimozione in corso...',
|
||||
'device.toast.removed': 'Schermo rimosso',
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ export default {
|
|||
'device.info.status': 'Status',
|
||||
'device.info.ip_address': 'Endereço IP',
|
||||
'device.info.local_ip': 'IP local',
|
||||
'device.info.local_ip6': 'IPv6 local',
|
||||
'device.info.wifi_needs_location': 'Requer permissão de localização',
|
||||
'device.info.battery': 'Bateria',
|
||||
'device.info.storage': 'Armazenamento',
|
||||
|
|
@ -347,6 +348,9 @@ export default {
|
|||
'device.assign.select_first': 'Selecione algo primeiro',
|
||||
'device.assign.kiosk_widget_name': 'Quiosque: {name}',
|
||||
'device.toast.screenshot_requested': 'Captura solicitada',
|
||||
'device.toast.screenshot_unsupported': 'O player desta tela não suporta capturas de tela',
|
||||
'device.toast.screenshot_offline': 'A tela está offline — captura não solicitada',
|
||||
'device.toast.screenshot_failed': 'Falha na solicitação de captura — sem resposta do servidor',
|
||||
'device.toast.renamed': 'Tela renomeada',
|
||||
'device.toast.removing': 'Removendo...',
|
||||
'device.toast.removed': 'Tela removida',
|
||||
|
|
|
|||
|
|
@ -112,9 +112,22 @@ function emit(event, data) {
|
|||
if (cbs) cbs.forEach(cb => cb(data));
|
||||
}
|
||||
|
||||
export function requestScreenshot(deviceId) {
|
||||
// Optional callback receives the server-side ack: { delivered, reason, capability }.
|
||||
// reason is 'offline' (no live connection) or 'unsupported' (this player type can't
|
||||
// take screenshots). Callers without a callback keep firing-and-forgetting — the
|
||||
// dashboard grid and the device-detail 5s poll stay silent; only the explicit
|
||||
// Screenshot button asks for the verdict.
|
||||
export function requestScreenshot(deviceId, callback) {
|
||||
console.log('requestScreenshot:', deviceId, 'socket connected:', dashboardSocket?.connected);
|
||||
if (dashboardSocket) dashboardSocket.emit('dashboard:request-screenshot', { device_id: deviceId });
|
||||
if (!dashboardSocket) return;
|
||||
if (typeof callback === 'function') {
|
||||
dashboardSocket.timeout(5000).emit('dashboard:request-screenshot', { device_id: deviceId }, (err, ack) => {
|
||||
if (err) callback({ delivered: false, reason: 'no_ack' });
|
||||
else callback(ack || { delivered: false, reason: 'no_ack' });
|
||||
});
|
||||
} else {
|
||||
dashboardSocket.emit('dashboard:request-screenshot', { device_id: deviceId });
|
||||
}
|
||||
}
|
||||
|
||||
export function startRemote(deviceId) {
|
||||
|
|
|
|||
|
|
@ -366,6 +366,15 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<div class="info-card-label">${t('device.info.local_ip')}</div>
|
||||
<div class="info-card-value small" id="telLocalIp">${device.local_ip || '--'}</div>
|
||||
</div>
|
||||
${device.local_ip6 ? `
|
||||
<div class="info-card">
|
||||
<!-- Rendered only when the panel actually has one. A v6 address is long, and showing an
|
||||
empty row for the overwhelmingly v4 fleet would cost every operator screen space to
|
||||
tell them nothing. A dual-stack panel shows both cards; a v6-only panel used to
|
||||
show a dash here and nothing else, because the player only ever collected v4. -->
|
||||
<div class="info-card-label">${t('device.info.local_ip6')}</div>
|
||||
<div class="info-card-value small" id="telLocalIp6">${device.local_ip6}</div>
|
||||
</div>` : ''}
|
||||
${device.android_version && !device.android_version.startsWith('Web/') ? `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.battery')}</div>
|
||||
|
|
@ -1084,10 +1093,16 @@ function setupActions(device) {
|
|||
|
||||
document.getElementById('devicePreviewBtn')?.addEventListener('click', () => showDevicePreview(device));
|
||||
|
||||
// Screenshot button
|
||||
// Screenshot button — pass a callback so the server's verdict surfaces as a toast
|
||||
// instead of the request silently going nowhere (offline device, or a player type
|
||||
// that can't capture at all, e.g. BrightSign).
|
||||
document.getElementById('screenshotBtn')?.addEventListener('click', () => {
|
||||
requestScreenshot(device.id);
|
||||
showToast(t('device.toast.screenshot_requested'), 'info');
|
||||
requestScreenshot(device.id, (ack) => {
|
||||
if (ack?.delivered) showToast(t('device.toast.screenshot_requested'), 'info');
|
||||
else if (ack?.reason === 'unsupported') showToast(t('device.toast.screenshot_unsupported'), 'warning');
|
||||
else if (ack?.reason === 'offline') showToast(t('device.toast.screenshot_offline'), 'warning');
|
||||
else showToast(t('device.toast.screenshot_failed'), 'error');
|
||||
});
|
||||
});
|
||||
|
||||
// Rename
|
||||
|
|
@ -2070,6 +2085,9 @@ function updateTelemetryDisplay(telemetry) {
|
|||
if (telemetry.storage_free_mb) update('telStorage', t('device.info.size_free', { size: formatBytes(telemetry.storage_free_mb) }));
|
||||
if (telemetry.wifi_ssid !== undefined) update('telWifi', ssidLabel(telemetry.wifi_ssid));
|
||||
if (telemetry.local_ip) update('telLocalIp', telemetry.local_ip);
|
||||
// update() no-ops when the card is absent, which is the case for a v4-only panel — a screen that
|
||||
// acquires a v6 address mid-session picks the card up on the next full render, not this path.
|
||||
if (telemetry.local_ip6) update('telLocalIp6', telemetry.local_ip6);
|
||||
if (telemetry.wifi_rssi) update('telRssi', telemetry.wifi_rssi + ' dBm');
|
||||
if (telemetry.uptime_seconds) update('telUptime', formatUptime(telemetry.uptime_seconds));
|
||||
if (telemetry.ram_free_mb) update('telRam', t('device.info.size_free', { size: formatBytes(telemetry.ram_free_mb) }));
|
||||
|
|
|
|||
|
|
@ -382,6 +382,12 @@ const migrations = [
|
|||
// the PUBLIC address the server sees the connection arrive from — both are useful and they are
|
||||
// not the same thing. A customer reading the public IP as "my screen's IP" prompted this.
|
||||
"ALTER TABLE device_telemetry ADD COLUMN local_ip TEXT",
|
||||
// ...and its IPv6 one, in its own column rather than sharing the above. The player's collector
|
||||
// filtered to Inet4Address, so a v6-only panel reported no address at all and the dashboard
|
||||
// showed a dash for a screen that had a perfectly reachable address. Separate columns because a
|
||||
// dual-stack panel genuinely has both and an operator may need either — collapsing them would
|
||||
// make the field mean "whichever we happened to enumerate first".
|
||||
"ALTER TABLE device_telemetry ADD COLUMN local_ip6 TEXT",
|
||||
// Panel temperature in Celsius. REAL because the sensor reports fractions, and nullable because
|
||||
// only some hardware exposes one — Android and the browser players send nothing and must keep
|
||||
// reading as "no sensor" rather than "0 degrees", which is why every read site treats null as
|
||||
|
|
|
|||
|
|
@ -126,6 +126,26 @@
|
|||
<title>ScreenTinker Player</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
/*
|
||||
* ONE KNOB for every pre-playback screen (setup, pairing, status, the audio prompt).
|
||||
*
|
||||
* A signage panel is read from across a room, so the thing that has to stay constant is
|
||||
* ANGULAR size, not pixel size — and a CSS pixel covers a quarter of the screen area on a 4K
|
||||
* panel that it does on 1080p, a sixteenth on 8K. A 72px pairing code that fills the wall on a
|
||||
* 1080p screen is a smudge on an 8K one, which is exactly the complaint this fixes.
|
||||
*
|
||||
* So every size on those screens is a rem against this root: 1rem = 10px at 1080p, 20px at 4K,
|
||||
* 40px at 8K — the same apparent size at the same viewing distance, at any panel resolution.
|
||||
*
|
||||
* vmin rather than vw, because portrait-mounted panels are common here and vw would render a
|
||||
* 1080x1920 screen at half size. Clamped at both ends so a dashboard preview iframe or a
|
||||
* laptop window stays legible instead of microscopic, and an ultrawide does not get silly.
|
||||
*
|
||||
* Nothing outside these screens uses rem, so this cannot reach playback content — zones,
|
||||
* images and video are laid out in % and px by the layout engine and are untouched.
|
||||
*/
|
||||
html { font-size: clamp(7px, 0.926vmin, 56px); }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: -apple-system, sans-serif; }
|
||||
|
||||
/* Setup Screen */
|
||||
|
|
@ -133,23 +153,25 @@
|
|||
position: fixed; inset: 0; background: #111827; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; z-index: 1000; color: #f1f5f9;
|
||||
}
|
||||
#setupScreen h1 { font-size: 36px; color: #3b82f6; margin-bottom: 8px; }
|
||||
#setupScreen .subtitle { color: #94a3b8; font-size: 16px; margin-bottom: 48px; }
|
||||
#setupScreen .form { width: 400px; max-width: 90vw; }
|
||||
#setupScreen label { display: block; font-size: 14px; color: #94a3b8; margin-bottom: 8px; }
|
||||
#setupScreen input { width: 100%; padding: 12px; background: #0f172a; border: 1px solid #334155;
|
||||
border-radius: 8px; color: #f1f5f9; font-size: 16px; margin-bottom: 24px; outline: none; }
|
||||
#setupScreen h1 { font-size: 3.6rem; color: #3b82f6; margin-bottom: 0.8rem; }
|
||||
#setupScreen .subtitle { color: #94a3b8; font-size: 1.6rem; margin-bottom: 4.8rem; }
|
||||
#setupScreen .form { width: 40rem; max-width: 90vw; }
|
||||
#setupScreen label { display: block; font-size: 1.4rem; color: #94a3b8; margin-bottom: 0.8rem; }
|
||||
#setupScreen input { width: 100%; padding: 1.2rem; background: #0f172a; border: 0.1rem solid #334155;
|
||||
border-radius: 0.8rem; color: #f1f5f9; font-size: 1.6rem; margin-bottom: 2.4rem; outline: none; }
|
||||
#setupScreen input:focus { border-color: #3b82f6; }
|
||||
#setupScreen button { width: 100%; padding: 12px; background: #3b82f6; color: white;
|
||||
border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; }
|
||||
#setupScreen button { width: 100%; padding: 1.2rem; background: #3b82f6; color: white;
|
||||
border: none; border-radius: 0.8rem; font-size: 1.6rem; font-weight: 600; cursor: pointer; }
|
||||
#setupScreen button:hover { background: #2563eb; }
|
||||
#setupScreen button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.pairing-code { font-size: 72px; font-weight: 700; color: #3b82f6; font-family: monospace;
|
||||
letter-spacing: 12px; margin: 24px 0; }
|
||||
.pairing-hint { color: #64748b; font-size: 14px; }
|
||||
.status-msg { color: #94a3b8; font-size: 14px; margin-top: 16px; }
|
||||
.spinner { width: 40px; height: 40px; border: 3px solid #334155; border-top-color: #3b82f6;
|
||||
border-radius: 50%; animation: spin 1s linear infinite; margin: 24px auto; }
|
||||
/* The number someone is squinting at from the far side of a shop. It gets the most room the
|
||||
screen can give it, which is why it is the largest multiple here. */
|
||||
.pairing-code { font-size: 7.2rem; font-weight: 700; color: #3b82f6; font-family: monospace;
|
||||
letter-spacing: 1.2rem; margin: 2.4rem 0; }
|
||||
.pairing-hint { color: #64748b; font-size: 1.4rem; }
|
||||
.status-msg { color: #94a3b8; font-size: 1.4rem; margin-top: 1.6rem; }
|
||||
.spinner { width: 4rem; height: 4rem; border: 0.3rem solid #334155; border-top-color: #3b82f6;
|
||||
border-radius: 50%; animation: spin 1s linear infinite; margin: 2.4rem auto; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Player */
|
||||
|
|
@ -201,8 +223,8 @@
|
|||
position: fixed; inset: 0; background: #000; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; color: #94a3b8; z-index: 500;
|
||||
}
|
||||
#statusOverlay h2 { color: #3b82f6; font-size: 28px; margin-bottom: 8px; }
|
||||
#statusOverlay p { font-size: 16px; }
|
||||
#statusOverlay h2 { color: #3b82f6; font-size: 2.8rem; margin-bottom: 0.8rem; }
|
||||
#statusOverlay p { font-size: 1.6rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -841,8 +863,8 @@
|
|||
if (document.getElementById('enableAudioPrompt')) return;
|
||||
const ov = document.createElement('div');
|
||||
ov.id = 'enableAudioPrompt';
|
||||
ov.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.88);color:#fff;padding:12px 22px;border-radius:8px;cursor:pointer;z-index:10000;font-size:14px;display:flex;gap:10px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,0.4)';
|
||||
ov.innerHTML = '<span style="font-size:20px">🔇</span><span>Tap to enable audio</span>';
|
||||
ov.style.cssText = 'position:fixed;bottom:2.4rem;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.88);color:#fff;padding:1.2rem 2.2rem;border-radius:0.8rem;cursor:pointer;z-index:10000;font-size:1.4rem;display:flex;gap:1rem;align-items:center;box-shadow:0 0.4rem 1.6rem rgba(0,0,0,0.4)';
|
||||
ov.innerHTML = '<span style="font-size:2rem">🔇</span><span>Tap to enable audio</span>';
|
||||
ov.addEventListener('click', () => {
|
||||
unlockAudioContext();
|
||||
tryUnmuteLeader();
|
||||
|
|
@ -1030,9 +1052,9 @@
|
|||
const tapOverlay = document.createElement('div');
|
||||
tapOverlay.style.cssText = 'position:fixed;inset:0;background:#111827;z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer';
|
||||
tapOverlay.innerHTML = `
|
||||
<h1 style="color:#3b82f6;font-size:36px;font-family:sans-serif;margin-bottom:12px">ScreenTinker</h1>
|
||||
<p style="color:#94a3b8;font-size:18px;font-family:sans-serif">Tap anywhere to start</p>
|
||||
<p style="color:#64748b;font-size:13px;font-family:sans-serif;margin-top:24px">Audio requires user interaction</p>
|
||||
<h1 style="color:#3b82f6;font-size:3.6rem;font-family:sans-serif;margin-bottom:1.2rem">ScreenTinker</h1>
|
||||
<p style="color:#94a3b8;font-size:1.8rem;font-family:sans-serif">Tap anywhere to start</p>
|
||||
<p style="color:#64748b;font-size:1.3rem;font-family:sans-serif;margin-top:2.4rem">Audio requires user interaction</p>
|
||||
`;
|
||||
tapOverlay.onclick = () => {
|
||||
unlockAudio();
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ router.get('/', (req, res) => {
|
|||
const devices = db.prepare(`
|
||||
SELECT d.*,
|
||||
t.battery_level, t.battery_charging, t.storage_free_mb, t.storage_total_mb,
|
||||
t.ram_free_mb, t.ram_total_mb, t.wifi_ssid, t.wifi_rssi, t.uptime_seconds, t.local_ip,
|
||||
t.ram_free_mb, t.ram_total_mb, t.wifi_ssid, t.wifi_rssi, t.uptime_seconds, t.local_ip, t.local_ip6,
|
||||
t.cpu_usage,
|
||||
s.filepath as screenshot_path, s.captured_at as screenshot_at,
|
||||
u.email as owner_email, u.name as owner_name
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ test('openapi: the spec version tracks the shipped release', () => {
|
|||
// GET /devices, so both must be documented and must not be described interchangeably.
|
||||
test('openapi: a device documents its WAN and LAN addresses distinctly', () => {
|
||||
const props = spec.components.schemas.Device.properties;
|
||||
for (const field of ['ip_address', 'local_ip']) {
|
||||
for (const field of ['ip_address', 'local_ip', 'local_ip6']) {
|
||||
assert.ok(props[field], `Device.${field} is returned by GET /devices but is not documented`);
|
||||
assert.ok(
|
||||
props[field].type.includes('null'),
|
||||
|
|
@ -88,6 +88,11 @@ test('openapi: a device documents its WAN and LAN addresses distinctly', () => {
|
|||
}
|
||||
assert.match(props.ip_address.description, /WAN|public/i);
|
||||
assert.match(props.local_ip.description, /local network|LAN/i);
|
||||
assert.match(props.local_ip6.description, /local network|LAN/i);
|
||||
// The two LAN fields are a pair, not alternatives — a dual-stack panel reports both, so the
|
||||
// spec must not let an integrator read one as a fallback for the other.
|
||||
assert.match(props.local_ip.description, /IPv4/i);
|
||||
assert.match(props.local_ip6.description, /IPv6/i);
|
||||
});
|
||||
|
||||
// "permission" is a sentinel, not a network name: Android 10+ withholds the SSID without a
|
||||
|
|
|
|||
92
server/test/player-screen-scaling.test.js
Normal file
92
server/test/player-screen-scaling.test.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* The pairing code has to be readable on the panel it is displayed on.
|
||||
*
|
||||
* A CSS pixel covers a quarter of the screen area on a 4K panel that it does on 1080p, and a
|
||||
* sixteenth on 8K. Every size on the player's pre-playback screens was a hard-coded pixel value,
|
||||
* so the 72px pairing code that fills a 1080p screen became a smudge on a 4K wall — reported from
|
||||
* the field, on exactly the screens signage gets installed on.
|
||||
*
|
||||
* The fix is a viewport-proportional root font size with everything on those screens expressed in
|
||||
* rem, so the code holds the same ANGULAR size at any panel resolution. These tests pin the two
|
||||
* properties that actually matter: nothing on those screens is left in px, and the scaling cannot
|
||||
* leak into playback content.
|
||||
*/
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
|
||||
|
||||
const SURFACES = [
|
||||
{ name: 'web player', file: 'server/player/index.html' },
|
||||
{ name: 'tizen player', file: 'tizen/css/style.css' },
|
||||
];
|
||||
|
||||
for (const { name, file } of SURFACES) {
|
||||
test(`${name}: the root font size is viewport-proportional and clamped at both ends`, () => {
|
||||
const src = read(file);
|
||||
const m = src.match(/html\s*\{\s*font-size:\s*clamp\(\s*([\d.]+)px\s*,\s*([\d.]+)vmin\s*,\s*([\d.]+)px\s*\)/);
|
||||
assert.ok(m, `${file} must set a clamped, vmin-based root font size`);
|
||||
const [, min, vmin, max] = m.map(Number);
|
||||
|
||||
// vmin, not vw: a portrait-mounted panel is as common as a landscape one, and vw would render
|
||||
// a 1080x1920 screen at half size.
|
||||
assert.ok(vmin > 0, 'the middle term must actually scale with the viewport');
|
||||
// 1rem should land on 10px at a 1080-tall viewport, so the rem values read as "px at 1080p"
|
||||
// and a reviewer can check them against the design at a glance.
|
||||
assert.ok(Math.abs(vmin * 1080 / 100 - 10) < 0.1, `1rem must be ~10px at 1080p, got ${vmin * 1080 / 100}px`);
|
||||
// The floor keeps a dashboard preview iframe and a laptop window legible rather than
|
||||
// microscopic; the ceiling stops an ultrawide from getting silly.
|
||||
assert.ok(min >= 6 && min <= 10, `floor should keep small viewports readable, got ${min}px`);
|
||||
assert.ok(max >= 32, `ceiling should not cap a genuine 8K panel too early, got ${max}px`);
|
||||
});
|
||||
}
|
||||
|
||||
test('web player: the pairing code scales, and is still the biggest thing on the screen', () => {
|
||||
const src = read('server/player/index.html');
|
||||
const rule = src.slice(src.indexOf('.pairing-code {'), src.indexOf('.pairing-hint'));
|
||||
const size = rule.match(/font-size:\s*([\d.]+)rem/);
|
||||
assert.ok(size, 'the pairing code must be sized in rem, not px');
|
||||
assert.ok(Number(size[1]) >= 7, 'the code is what someone squints at from across a room');
|
||||
assert.match(rule, /letter-spacing:\s*[\d.]+rem/, 'letter-spacing must scale with it or the digits collide');
|
||||
|
||||
const h1 = src.match(/#setupScreen h1 \{ font-size: ([\d.]+)rem/);
|
||||
assert.ok(h1 && Number(size[1]) > Number(h1[1]), 'the code must outrank the product name');
|
||||
});
|
||||
|
||||
test('tizen player: the pairing code scales too', () => {
|
||||
const src = read('tizen/css/style.css');
|
||||
const rule = src.slice(src.indexOf('.code {'), src.indexOf('.hint {'));
|
||||
assert.match(rule, /font-size:\s*[\d.]+rem/, 'the Tizen pairing code must be sized in rem');
|
||||
assert.match(rule, /letter-spacing:\s*[\d.]+rem/);
|
||||
});
|
||||
|
||||
test('no pre-playback screen is left in hard-coded px', () => {
|
||||
const src = read('server/player/index.html');
|
||||
// Everything from the setup screen through the status overlay. A px value surviving in here is
|
||||
// one element that stays put while the rest of the screen grows around it.
|
||||
for (const selector of ['#setupScreen h1', '#setupScreen .subtitle', '.pairing-hint', '.status-msg', '#statusOverlay h2', '#statusOverlay p']) {
|
||||
const at = src.indexOf(selector + ' {');
|
||||
assert.ok(at > 0, `${selector} not found`);
|
||||
const rule = src.slice(at, src.indexOf('}', at));
|
||||
const px = rule.match(/font-size:\s*[\d.]+px/);
|
||||
assert.equal(px, null, `${selector} still has a hard-coded font-size — it will not scale`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the scaling cannot reach playback content', () => {
|
||||
const src = read('server/player/index.html');
|
||||
// The whole safety argument for moving the root font size is that only the pre-playback chrome
|
||||
// uses rem. If a stage/zone/PiP rule starts using rem, resizing a panel would start resizing
|
||||
// CONTENT, which is a different and much worse bug than the one being fixed here.
|
||||
for (const selector of ['#stage', '.zone', '#pip']) {
|
||||
const at = src.indexOf(selector + ' {');
|
||||
if (at < 0) continue;
|
||||
const rule = src.slice(at, src.indexOf('}', at));
|
||||
assert.ok(!/[\d.]rem/.test(rule), `${selector} must not use rem — content sizing must stay independent`);
|
||||
}
|
||||
});
|
||||
92
server/test/telemetry-ipv6.test.js
Normal file
92
server/test/telemetry-ipv6.test.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* A panel's own IPv6 address has to survive the trip from the player to the dashboard.
|
||||
*
|
||||
* It never used to exist: the Android collector filtered to Inet4Address, so a v6-only screen
|
||||
* reported no address at all and the dashboard rendered a dash for a panel that was perfectly
|
||||
* reachable. The fix is two columns rather than one, because a dual-stack panel genuinely has
|
||||
* both and collapsing them would make the field mean "whichever interface enumerated first".
|
||||
*/
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
|
||||
|
||||
test('the Android collector reports BOTH stacks, not whichever it finds first', () => {
|
||||
const src = read('android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt');
|
||||
assert.match(src, /put\("local_ip6"/, 'telemetry must carry local_ip6');
|
||||
assert.match(src, /getLocalIp6/, 'there must be a v6 collector');
|
||||
// The v4 one keeps its filter — this is an addition, not a replacement. A v4 panel must keep
|
||||
// reporting exactly what it reported before.
|
||||
assert.match(src, /addr is java\.net\.Inet4Address/, 'local_ip stays IPv4-only');
|
||||
});
|
||||
|
||||
test('link-local v6 is excluded — it cannot be dialled without a zone index', () => {
|
||||
const src = read('android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt');
|
||||
const fn = src.slice(src.indexOf('private fun getLocalIp6'), src.indexOf('private fun getWifiRSSI'));
|
||||
assert.ok(fn.length > 0, 'getLocalIp6 not found');
|
||||
for (const guard of ['isLinkLocalAddress', 'isLoopbackAddress', 'isAnyLocalAddress', 'isMulticastAddress']) {
|
||||
assert.ok(fn.includes(guard), `getLocalIp6 must skip ${guard} addresses`);
|
||||
}
|
||||
// Every interface has an fe80:: address and it is usually enumerated first, so without this the
|
||||
// field would fill up with strings nobody can paste anywhere and hide the useful address.
|
||||
assert.match(fn, /substringBefore\('%'\)/, 'a %iface suffix must be trimmed before it reaches the UI');
|
||||
});
|
||||
|
||||
test('the column exists, holds a full-length v6 address, and is separate from local_ip', () => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('CREATE TABLE device_telemetry (id INTEGER PRIMARY KEY, device_id TEXT, local_ip TEXT)');
|
||||
// The same statement the migration list runs.
|
||||
db.exec('ALTER TABLE device_telemetry ADD COLUMN local_ip6 TEXT');
|
||||
|
||||
const v6 = '2001:0db8:85a3:0000:0000:8a2e:0370:7334';
|
||||
db.prepare('INSERT INTO device_telemetry (device_id, local_ip, local_ip6) VALUES (?, ?, ?)')
|
||||
.run('d1', '192.168.1.42', v6);
|
||||
const row = db.prepare('SELECT local_ip, local_ip6 FROM device_telemetry WHERE device_id = ?').get('d1');
|
||||
assert.equal(row.local_ip, '192.168.1.42', 'the v4 address is untouched by the addition');
|
||||
assert.equal(row.local_ip6, v6);
|
||||
|
||||
// 45 characters is the longest legitimate IPv6 text form (IPv4-mapped, ::ffff:255.255.255.255).
|
||||
// The write path caps at 45, so a real address must never be truncated by it.
|
||||
assert.ok('::ffff:255.255.255.255'.length <= 45);
|
||||
assert.ok(v6.length <= 45, 'a full uncompressed v6 address fits the cap the writer applies');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('the migration is registered, and does not replace the v4 column', () => {
|
||||
const src = read('server/db/database.js');
|
||||
assert.match(src, /ALTER TABLE device_telemetry ADD COLUMN local_ip6 TEXT/);
|
||||
assert.match(src, /ALTER TABLE device_telemetry ADD COLUMN local_ip TEXT/);
|
||||
});
|
||||
|
||||
test('the server stores what the player sends, capped like its neighbour', () => {
|
||||
const src = read('server/ws/deviceSocket.js');
|
||||
assert.match(src, /local_ip, local_ip6, temperature_c/, 'the INSERT must name the new column');
|
||||
assert.match(
|
||||
src,
|
||||
/typeof telemetry\.local_ip6 === 'string' \? telemetry\.local_ip6\.trim\(\)\.slice\(0, 45\)/,
|
||||
'device-supplied text headed for the dashboard must be trimmed and capped',
|
||||
);
|
||||
});
|
||||
|
||||
test('both addresses reach the dashboard, and the v6 card only appears when there is one', () => {
|
||||
const api = read('server/routes/devices.js');
|
||||
assert.match(api, /t\.local_ip, t\.local_ip6/, 'the device list must select the new column');
|
||||
|
||||
const ui = read('frontend/js/views/device-detail.js');
|
||||
assert.match(ui, /device\.local_ip6 \?/, 'the card must be conditional — no empty row for a v4-only fleet');
|
||||
assert.match(ui, /telLocalIp6/);
|
||||
assert.match(ui, /device\.info\.local_ip6/, 'the label must go through i18n, not be hardcoded');
|
||||
});
|
||||
|
||||
test('every locale has the label — a missing key renders as the raw key', () => {
|
||||
for (const lang of ['en', 'es', 'fr', 'de', 'pt', 'it']) {
|
||||
const src = read(`frontend/js/i18n/${lang}.js`);
|
||||
assert.match(src, /'device\.info\.local_ip6':/, `${lang} is missing device.info.local_ip6`);
|
||||
}
|
||||
});
|
||||
|
|
@ -1170,8 +1170,8 @@ module.exports = function setupDeviceSocket(io) {
|
|||
if (telemetry && deviceExists(device_id)) {
|
||||
db.prepare(`
|
||||
INSERT INTO device_telemetry (device_id, battery_level, battery_charging, storage_free_mb, storage_total_mb,
|
||||
ram_free_mb, ram_total_mb, cpu_usage, wifi_ssid, wifi_rssi, uptime_seconds, local_ip, temperature_c)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ram_free_mb, ram_total_mb, cpu_usage, wifi_ssid, wifi_rssi, uptime_seconds, local_ip, local_ip6, temperature_c)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
device_id,
|
||||
telemetry.battery_level ?? null,
|
||||
|
|
@ -1187,6 +1187,9 @@ module.exports = function setupDeviceSocket(io) {
|
|||
// Device-supplied text headed for a column the dashboard renders: trim and cap it.
|
||||
// 45 chars is the longest legitimate value (a full IPv6 address).
|
||||
typeof telemetry.local_ip === 'string' ? telemetry.local_ip.trim().slice(0, 45) || null : null,
|
||||
// Same treatment for the v6 address. 45 is still the cap: it is the longest legitimate
|
||||
// IPv6 text form (an IPv4-mapped one, `::ffff:255.255.255.255`).
|
||||
typeof telemetry.local_ip6 === 'string' ? telemetry.local_ip6.trim().slice(0, 45) || null : null,
|
||||
// Only a finite number is a reading. A panel with no sensor sends nothing, and NaN or
|
||||
// Infinity from a flaky one must land as "no reading" rather than poisoning the column.
|
||||
typeof telemetry.temperature_c === 'number' && Number.isFinite(telemetry.temperature_c)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
/*
|
||||
* ONE KNOB for every pre-playback screen, matching server/player/index.html.
|
||||
*
|
||||
* A pairing code is read from across a room, so what must stay constant is ANGULAR size, not
|
||||
* pixel size — and a CSS pixel covers a sixteenth of the screen area on an 8K panel that it does
|
||||
* on 1080p. Sizes below are rem against this root: 1rem = 10px at a 1080-tall viewport, and it
|
||||
* scales from there. A Tizen web app is handed a 1920x1080 logical viewport on most panels, where
|
||||
* this is arithmetically identical to the pixel values it replaces — the point is the panels where
|
||||
* it is not.
|
||||
*
|
||||
* vmin rather than vw: portrait-mounted panels are common and vw would halve everything on one.
|
||||
* Only the setup/pairing/toast chrome uses rem; the stage and PiP layers are laid out in %/vw/vh
|
||||
* and are untouched.
|
||||
*/
|
||||
html { font-size: clamp(7px, 0.926vmin, 56px); }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: #000; color: #f1f5f9;
|
||||
|
|
@ -19,39 +35,39 @@ html, body {
|
|||
.card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 18px;
|
||||
padding: 48px 64px;
|
||||
border-radius: 1.8rem;
|
||||
padding: 4.8rem 6.4rem;
|
||||
text-align: center;
|
||||
max-width: 760px;
|
||||
max-width: 76rem;
|
||||
}
|
||||
.card h1 { color: #3b82f6; font-size: 44px; margin-bottom: 6px; }
|
||||
.sub { color: #94a3b8; font-size: 22px; margin-bottom: 36px; }
|
||||
.card label { display: block; text-align: left; color: #94a3b8; font-size: 18px; margin-bottom: 8px; }
|
||||
.card h1 { color: #3b82f6; font-size: 4.4rem; margin-bottom: 0.6rem; }
|
||||
.sub { color: #94a3b8; font-size: 2.2rem; margin-bottom: 3.6rem; }
|
||||
.card label { display: block; text-align: left; color: #94a3b8; font-size: 1.8rem; margin-bottom: 0.8rem; }
|
||||
|
||||
#serverUrl {
|
||||
width: 100%; font-size: 26px; padding: 16px 20px;
|
||||
border-radius: 10px; border: 2px solid #334155;
|
||||
background: #0b1220; color: #f1f5f9; margin-bottom: 24px;
|
||||
width: 100%; font-size: 2.6rem; padding: 1.6rem 2rem;
|
||||
border-radius: 1rem; border: 0.2rem solid #334155;
|
||||
background: #0b1220; color: #f1f5f9; margin-bottom: 2.4rem;
|
||||
}
|
||||
#serverUrl:focus { outline: none; border-color: #3b82f6; }
|
||||
|
||||
button {
|
||||
font-size: 24px; font-weight: bold; color: #fff;
|
||||
background: #3b82f6; border: none; border-radius: 10px;
|
||||
padding: 16px 40px; cursor: pointer;
|
||||
font-size: 2.4rem; font-weight: bold; color: #fff;
|
||||
background: #3b82f6; border: none; border-radius: 1rem;
|
||||
padding: 1.6rem 4rem; cursor: pointer;
|
||||
}
|
||||
button:focus { outline: 3px solid #93c5fd; }
|
||||
button.ghost { background: transparent; color: #64748b; font-size: 18px; margin-top: 24px; padding: 8px; }
|
||||
button:focus { outline: 0.3rem solid #93c5fd; }
|
||||
button.ghost { background: transparent; color: #64748b; font-size: 1.8rem; margin-top: 2.4rem; padding: 0.8rem; }
|
||||
|
||||
.status { color: #64748b; font-size: 18px; margin-top: 20px; min-height: 24px; }
|
||||
.status { color: #64748b; font-size: 1.8rem; margin-top: 2rem; min-height: 2.4rem; }
|
||||
.status.error { color: #ef4444; }
|
||||
|
||||
/* Pairing code */
|
||||
.code {
|
||||
font-size: 96px; font-weight: bold; letter-spacing: 16px;
|
||||
color: #22c55e; margin: 24px 0; font-family: monospace;
|
||||
font-size: 9.6rem; font-weight: bold; letter-spacing: 1.6rem;
|
||||
color: #22c55e; margin: 2.4rem 0; font-family: monospace;
|
||||
}
|
||||
.hint { color: #94a3b8; font-size: 20px; line-height: 1.5; }
|
||||
.hint { color: #94a3b8; font-size: 2rem; line-height: 1.5; }
|
||||
|
||||
/* Playback stage */
|
||||
.stage { background: #000; }
|
||||
|
|
@ -82,8 +98,8 @@ button.ghost { background: transparent; color: #64748b; font-size: 18px; margin-
|
|||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
position: absolute; bottom: 2.4rem; left: 50%; transform: translateX(-50%);
|
||||
background: rgba(17,24,39,0.92); color: #f1f5f9;
|
||||
padding: 12px 24px; border-radius: 10px; font-size: 18px;
|
||||
border: 1px solid #334155;
|
||||
padding: 1.2rem 2.4rem; border-radius: 1rem; font-size: 1.8rem;
|
||||
border: 0.1rem solid #334155;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue