diff --git a/app/src/main/java/com/foxx/androidcast/AppPreferences.java b/app/src/main/java/com/foxx/androidcast/AppPreferences.java index bd6caa0..be5818f 100644 --- a/app/src/main/java/com/foxx/androidcast/AppPreferences.java +++ b/app/src/main/java/com/foxx/androidcast/AppPreferences.java @@ -58,6 +58,8 @@ public final class AppPreferences { private static final String KEY_DEV_COMMERCIAL_ADS = "dev_commercial_ads"; private static final String KEY_DEV_COMMERCIAL_IAP = "dev_commercial_iap"; private static final String KEY_DEV_COMMERCIAL_PLAY_STORE = "dev_commercial_play_store"; + private static final String KEY_DEV_DIAG_PING_PONG = "dev_diag_ping_pong"; + private static final String KEY_DEV_DIAG_PING_RESPONSE_MODE = "dev_diag_ping_response_mode"; /** Minimum interval between automatic OTA checks on app launch. */ public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L; @@ -386,6 +388,36 @@ public final class AppPreferences { prefs(context).edit().putBoolean(KEY_DEV_COMMERCIAL_PLAY_STORE, enabled).apply(); } + /** Developer: ~1 Hz diagnostic cast ping-pong during sessions (default off). */ + public static boolean isDevDiagPingPongEnabled(Context context) { + return prefs(context).getBoolean(KEY_DEV_DIAG_PING_PONG, false); + } + + public static void setDevDiagPingPongEnabled(Context context, boolean enabled) { + prefs(context).edit().putBoolean(KEY_DEV_DIAG_PING_PONG, enabled).apply(); + } + + /** + * How this device responds to peer diag probes: + * 0 mirror-as-is, 1 mirror-smart (default), 2 reply-with. + */ + public static byte getDevDiagPingResponseMode(Context context) { + int mode = prefs(context).getInt(KEY_DEV_DIAG_PING_RESPONSE_MODE, + com.foxx.androidcast.network.diag.CastDiagPing.DIR_MIRROR_SMART); + if (mode < 0 || mode > 2) { + return com.foxx.androidcast.network.diag.CastDiagPing.DIR_MIRROR_SMART; + } + return (byte) mode; + } + + public static void setDevDiagPingResponseMode(Context context, byte mode) { + int v = mode; + if (v < 0 || v > 2) { + v = com.foxx.androidcast.network.diag.CastDiagPing.DIR_MIRROR_SMART; + } + prefs(context).edit().putInt(KEY_DEV_DIAG_PING_RESPONSE_MODE, v).apply(); + } + public static CastSettings loadReceiverDefaults(Context context) { CastSettings s = new CastSettings(); applyStoredCastSettings(context, PREFIX_RECEIVER, s); diff --git a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java index f994290..52249d6 100644 --- a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java +++ b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java @@ -36,6 +36,7 @@ import com.foxx.androidcast.receiver.av.ReceiverAvRuntime; import com.foxx.androidcast.receiver.av.ReceiverVideoPreset; import com.foxx.androidcast.sender.SenderResolutionUiMode; import com.foxx.androidcast.dev.DevDiagnosticsController; +import com.foxx.androidcast.network.diag.CastDiagPing; import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity; import com.google.android.material.tabs.TabLayout; import com.google.android.material.textfield.TextInputEditText; @@ -99,6 +100,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity { }); bindAvPresets(); + bindDiagPingSection(); bindSenderResolutionUiModeSection(); bindWiredDisplaySection(); bindCommercialSection(); @@ -126,6 +128,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity { findViewById(R.id.check_show_codec_priority_scores), findViewById(R.id.check_grab_session_stats), findViewById(R.id.check_receiver_adaptive_display)); + bindDiagPingSection(); refreshOtaUi( findViewById(R.id.text_ota_installed_version), findViewById(R.id.edit_ota_manifest_url), @@ -136,6 +139,46 @@ public class DeveloperSettingsActivity extends AppCompatActivity { bindCommercialSection(); } + private void bindDiagPingSection() { + CheckBox enabled = findViewById(R.id.check_dev_diag_ping_pong); + bindDeveloperCheckbox(enabled, AppPreferences.isDevDiagPingPongEnabled(this), + AppPreferences::setDevDiagPingPongEnabled); + Spinner spinner = findViewById(R.id.spinner_dev_diag_ping_response_mode); + if (spinner == null) { + return; + } + final byte[] modes = { + CastDiagPing.DIR_MIRROR_AS_IS, + CastDiagPing.DIR_MIRROR_SMART, + CastDiagPing.DIR_REPLY_WITH + }; + String[] labels = { + getString(R.string.dev_diag_ping_response_mirror), + getString(R.string.dev_diag_ping_response_smart), + getString(R.string.dev_diag_ping_response_reply) + }; + ArrayAdapter adapter = new ArrayAdapter<>(this, + android.R.layout.simple_spinner_item, labels); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(adapter); + spinner.setOnItemSelectedListener(null); + byte current = AppPreferences.getDevDiagPingResponseMode(this); + int selection = 1; + for (int i = 0; i < modes.length; i++) { + if (modes[i] == current) { + selection = i; + break; + } + } + spinner.setSelection(selection, false); + spinner.setOnItemSelectedListener(simpleSelection(() -> { + int pos = spinner.getSelectedItemPosition(); + if (pos >= 0 && pos < modes.length) { + AppPreferences.setDevDiagPingResponseMode(this, modes[pos]); + } + })); + } + private void bindSenderResolutionUiModeSection() { Spinner spinner = findViewById(R.id.spinner_dev_sender_resolution_ui_mode); if (spinner == null) { diff --git a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java index 1f6096f..4e4c0ca 100644 --- a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java +++ b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java @@ -17,6 +17,7 @@ import com.foxx.androidcast.media.CodecNegotiator; import com.foxx.androidcast.media.codec.CodecSessionRegistry; import com.foxx.androidcast.media.codec.PassthroughCodecPolicy; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; +import com.foxx.androidcast.network.diag.DiagPingMetrics; import com.foxx.androidcast.receiver.StreamMetrics; import java.util.ArrayList; @@ -140,6 +141,12 @@ public final class CastDiagnosticsFormatter { html.append("BW in: ").append(colored(bwInKbps > 0 ? bwInKbps + " kbps" : "n/a", suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.7f)); html.append(" RTT: ").append(colored(rttMs > 0 ? rttMs + " ms" : "n/a", rttMs > 350)); + DiagPingMetrics.Snapshot diagPing = metrics.getDiagPing(); + if (diagPing.enabled && diagPing.pongsRx > 0) { + int pingRtt = diagPing.avgRttMs > 0 ? diagPing.avgRttMs : diagPing.lastRttMs; + html.append(" Diag ping: ") + .append(colored(pingRtt + " ms avg (" + diagPing.pongsRx + ")", pingRtt > 350)); + } html.append("
"); html.append("Suggested BW: ").append(colored( diff --git a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java index e4a656b..e4a088f 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java @@ -15,6 +15,7 @@ import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.network.control.NetworkControlAdvice; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; +import com.foxx.androidcast.network.diag.CastDiagPing; import android.media.MediaFormat; @@ -46,6 +47,11 @@ public final class CastProtocol { public static final byte MSG_NACK = 16; /** Receiver capability/settings (same payload as {@link #MSG_CAST_SETTINGS}). */ public static final byte MSG_RECEIVER_CAST_SETTINGS = 17; + /** + * Diagnostic ping-pong (developer). Payload: {@link com.foxx.androidcast.network.diag.CastDiagPing}. + * Peers on older builds ignore unknown types. + */ + public static final byte MSG_DIAG_PING = 18; /** UDP outer wrapper: payload is {@link CastPacketFramer} wire bytes. */ public static final byte MSG_WIRE_PACKET = 127; @@ -419,6 +425,14 @@ public final class CastProtocol { return c; } + public static byte[] diagPingPayload(CastDiagPing.Packet packet) throws IOException { + return CastDiagPing.encode(packet); + } + + public static CastDiagPing.Packet parseDiagPing(byte[] payload) throws IOException { + return CastDiagPing.decode(payload); + } + public static byte[] audioConsentPayload(boolean accept) { return new byte[]{(byte) (accept ? 1 : 0)}; } diff --git a/app/src/main/java/com/foxx/androidcast/network/control/NetworkFeedbackManager.java b/app/src/main/java/com/foxx/androidcast/network/control/NetworkFeedbackManager.java index dbf14a1..386c0fc 100644 --- a/app/src/main/java/com/foxx/androidcast/network/control/NetworkFeedbackManager.java +++ b/app/src/main/java/com/foxx/androidcast/network/control/NetworkFeedbackManager.java @@ -15,6 +15,7 @@ import android.util.Log; import com.foxx.androidcast.network.CastProtocol; import com.foxx.androidcast.network.CastSession; +import com.foxx.androidcast.network.diag.DiagPingSession; import java.io.IOException; @@ -25,6 +26,7 @@ public class NetworkFeedbackManager { private final NetworkControlPlane plane; private final CastSession session; private final boolean isSender; + private DiagPingSession diagPing; public NetworkFeedbackManager(CastSession session, boolean isSender, NetworkControlPlane plane) { this.session = session; @@ -40,9 +42,27 @@ public class NetworkFeedbackManager { return plane; } + public void configureDiagPing(boolean enabled, byte responseMode, DiagPingSession.SampleListener listener) { + if (!enabled) { + if (diagPing != null) { + diagPing.setEnabled(false); + } + return; + } + diagPing = new DiagPingSession(session, responseMode, listener); + diagPing.setEnabled(true); + } + + public DiagPingSession getDiagPingSession() { + return diagPing; + } + /** Call on each stream loop iteration (~500ms). */ public void tick() { long now = System.currentTimeMillis(); + if (diagPing != null) { + diagPing.tick(now); + } NetworkControlAdvice advice = plane.tick(now); if (advice.sendStats) { sendStats(plane.buildLocalStats(now)); @@ -53,6 +73,10 @@ public class NetworkFeedbackManager { } public boolean handleMessage(CastProtocol.Message msg) { + if (diagPing != null && diagPing.handleMessage(msg)) { + applyDiagPingRtt(); + return true; + } try { switch (msg.type) { case CastProtocol.MSG_NETWORK_STATS: @@ -73,6 +97,15 @@ public class NetworkFeedbackManager { } } + private void applyDiagPingRtt() { + if (diagPing == null || !(plane instanceof PassThroughNetworkControlPlane)) { + return; + } + PassThroughNetworkControlPlane pass = (PassThroughNetworkControlPlane) plane; + int rtt = diagPing.metrics().preferredRttMs(pass.getDiagPingRttMs()); + pass.setDiagPingRttMs(rtt); + } + private void sendStats(NetworkStatsSnapshot stats) { try { session.send(CastProtocol.MSG_NETWORK_STATS, CastProtocol.networkStatsPayload(stats)); diff --git a/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java b/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java index 543a55a..e1fe875 100644 --- a/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java +++ b/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java @@ -29,6 +29,7 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane { private long sendWindowBytes; private long sendWindowStartMs; private StatsEnricher statsEnricher; + private volatile int diagPingRttMs; /** Optional hook to attach encode/decode metrics before stats are sent. */ public interface StatsEnricher { @@ -39,6 +40,16 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane { this.statsEnricher = enricher; } + public void setDiagPingRttMs(int rttMs) { + if (rttMs > 0 && rttMs < 30_000) { + diagPingRttMs = rttMs; + } + } + + public int getDiagPingRttMs() { + return diagPingRttMs; + } + @Override public NetworkControlAdvice tick(long nowMs) { outbound.sendStats = nowMs - lastStatsMs >= STATS_INTERVAL_MS; @@ -90,7 +101,11 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane { NetworkStatsSnapshot s = new NetworkStatsSnapshot(); s.sequence = ++sequence; s.timestampMs = nowMs; - s.rttMs = lastPeer != null ? lastPeer.rttMs : 0; + if (diagPingRttMs > 0) { + s.rttMs = diagPingRttMs; + } else { + s.rttMs = lastPeer != null ? lastPeer.rttMs : 0; + } s.lossRatio = 0f; s.sendBitrateKbps = estimateSendKbps(nowMs); s.recvBitrateKbps = estimateRecvKbps(nowMs); diff --git a/app/src/main/java/com/foxx/androidcast/network/diag/CastDiagPing.java b/app/src/main/java/com/foxx/androidcast/network/diag/CastDiagPing.java new file mode 100644 index 0000000..221c11b --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/diag/CastDiagPing.java @@ -0,0 +1,104 @@ +package com.foxx.androidcast.network.diag; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +/** + * Diagnostic cast ping-pong wire format (optional; {@link com.foxx.androidcast.network.CastProtocol#MSG_DIAG_PING}). + *

+ * v1 layout (big-endian): + * magic(4) version(1) flags(1) direction(1) reserved(1) + * timestamp_ms(8) seq(8) payload_size(4) [payload] + * When {@link #FLAG_PONG} and direction {@link #DIR_REPLY_WITH}: reply_for_seq(8) reply_for_timestamp_ms(8) + */ +public final class CastDiagPing { + public static final int MAGIC = 0x50494E47; // "PING" + public static final byte VERSION = 1; + + /** Set on pong packets. */ + public static final byte FLAG_PONG = 0x01; + + /** Echo probe fields unchanged (dummy pong). */ + public static final byte DIR_MIRROR_AS_IS = 0; + /** Pong uses local wall-clock timestamp; same seq as probe. */ + public static final byte DIR_MIRROR_SMART = 1; + /** Pong includes reply-for fields plus local timestamp and seq. */ + public static final byte DIR_REPLY_WITH = 2; + + private CastDiagPing() {} + + public static final class Packet { + public int magic; + public byte version; + public byte flags; + public byte direction; + public long timestampMs; + public long seq; + public byte[] payload = new byte[0]; + /** Valid when {@link #FLAG_PONG} and {@link #DIR_REPLY_WITH}. */ + public long replyForSeq; + public long replyForTimestampMs; + + public boolean isPong() { + return (flags & FLAG_PONG) != 0; + } + + public boolean isValidMagic() { + return magic == MAGIC && version == VERSION; + } + } + + public static byte[] encode(Packet p) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(32); + DataOutputStream out = new DataOutputStream(bos); + out.writeInt(p.magic != 0 ? p.magic : MAGIC); + out.writeByte(VERSION); + out.writeByte(p.flags); + out.writeByte(p.direction); + out.writeByte(0); + out.writeLong(p.timestampMs); + out.writeLong(p.seq); + byte[] body = p.payload != null ? p.payload : new byte[0]; + out.writeInt(body.length); + if (body.length > 0) { + out.write(body); + } + if (p.isPong() && p.direction == DIR_REPLY_WITH) { + out.writeLong(p.replyForSeq); + out.writeLong(p.replyForTimestampMs); + } + out.flush(); + return bos.toByteArray(); + } + + public static Packet decode(byte[] raw) throws IOException { + if (raw == null || raw.length < 28) { + throw new IOException("Diag ping too short"); + } + DataInputStream in = new DataInputStream(new ByteArrayInputStream(raw)); + Packet p = new Packet(); + p.magic = in.readInt(); + p.version = in.readByte(); + p.flags = in.readByte(); + p.direction = in.readByte(); + in.readByte(); // reserved + p.timestampMs = in.readLong(); + p.seq = in.readLong(); + int payloadSize = in.readInt(); + if (payloadSize < 0 || payloadSize > 64 * 1024) { + throw new IOException("Invalid diag ping payload size: " + payloadSize); + } + if (payloadSize > 0) { + p.payload = new byte[payloadSize]; + in.readFully(p.payload); + } + if (p.isPong() && p.direction == DIR_REPLY_WITH && in.available() >= 16) { + p.replyForSeq = in.readLong(); + p.replyForTimestampMs = in.readLong(); + } + return p; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/diag/DiagPingMetrics.java b/app/src/main/java/com/foxx/androidcast/network/diag/DiagPingMetrics.java new file mode 100644 index 0000000..93f9ad2 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/diag/DiagPingMetrics.java @@ -0,0 +1,86 @@ +package com.foxx.androidcast.network.diag; + +/** + * Rolling diagnostic ping RTT samples for overlays and bandwidth hints. + */ +public final class DiagPingMetrics { + private static final int WINDOW = 16; + + private final long[] rttMs = new long[WINDOW]; + private final long[] seq = new long[WINDOW]; + private int count; + private int index; + private long probesSent; + private long pongsRx; + private long lastRttMs; + private long lastSeq; + + public synchronized void recordProbeSent(long seqNum) { + probesSent++; + lastSeq = seqNum; + } + + public synchronized void recordPong(long rtt, long pongSeq) { + if (rtt <= 0 || rtt > 30_000) { + return; + } + pongsRx++; + lastRttMs = rtt; + lastSeq = pongSeq; + rttMs[index] = rtt; + seq[index] = pongSeq; + index = (index + 1) % WINDOW; + if (count < WINDOW) { + count++; + } + } + + public synchronized Snapshot snapshot() { + Snapshot s = new Snapshot(); + s.enabled = true; + s.probesSent = probesSent; + s.pongsRx = pongsRx; + s.lastRttMs = (int) lastRttMs; + s.lastSeq = lastSeq; + if (count == 0) { + return s; + } + long sum = 0; + for (int i = 0; i < count; i++) { + sum += rttMs[i]; + } + s.avgRttMs = (int) (sum / count); + return s; + } + + public synchronized int preferredRttMs(int fallbackRtt) { + Snapshot s = snapshot(); + if (s.avgRttMs > 0) { + return s.avgRttMs; + } + if (s.lastRttMs > 0) { + return s.lastRttMs; + } + return fallbackRtt; + } + + public synchronized void reset() { + count = 0; + index = 0; + probesSent = 0; + pongsRx = 0; + lastRttMs = 0; + lastSeq = 0; + } + + public static final class Snapshot { + public boolean enabled; + public long probesSent; + public long pongsRx; + public int lastRttMs; + public int avgRttMs; + public long lastSeq; + + public static final Snapshot EMPTY = new Snapshot(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/diag/DiagPingSession.java b/app/src/main/java/com/foxx/androidcast/network/diag/DiagPingSession.java new file mode 100644 index 0000000..edbaf69 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/diag/DiagPingSession.java @@ -0,0 +1,158 @@ +package com.foxx.androidcast.network.diag; + +import android.util.Log; + +import com.foxx.androidcast.network.CastProtocol; +import com.foxx.androidcast.network.CastSession; + +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Optional ~1 Hz diagnostic ping-pong during an active cast (developer setting). + * Unknown {@link CastProtocol#MSG_DIAG_PING} on old peers is ignored by their handlers. + */ +public final class DiagPingSession { + private static final String TAG = "DiagPing"; + private static final long PROBE_INTERVAL_MS = 1_000L; + + public interface SampleListener { + void onDiagPingSample(DiagPingMetrics.Snapshot snapshot); + } + + private final CastSession session; + private final byte localResponseMode; + private final DiagPingMetrics metrics = new DiagPingMetrics(); + private final ConcurrentHashMap pendingProbeSentMs = new ConcurrentHashMap<>(); + private final SampleListener listener; + + private volatile boolean enabled; + private long nextSeq; + private long lastProbeMs; + + public DiagPingSession(CastSession session, byte localResponseMode, SampleListener listener) { + this.session = session; + this.localResponseMode = localResponseMode; + this.listener = listener; + } + + public void setEnabled(boolean on) { + enabled = on; + if (!on) { + reset(); + } + } + + public boolean isEnabled() { + return enabled; + } + + public DiagPingMetrics metrics() { + return metrics; + } + + public void reset() { + nextSeq = 0; + lastProbeMs = 0; + pendingProbeSentMs.clear(); + metrics.reset(); + } + + public void tick(long nowMs) { + if (!enabled) { + return; + } + if (lastProbeMs == 0 || nowMs - lastProbeMs >= PROBE_INTERVAL_MS) { + sendProbe(nowMs); + lastProbeMs = nowMs; + } + } + + /** @return true if consumed (including invalid magic). */ + public boolean handleMessage(CastProtocol.Message msg) { + if (msg.type != CastProtocol.MSG_DIAG_PING) { + return false; + } + try { + CastDiagPing.Packet p = CastProtocol.parseDiagPing(msg.payload); + if (!p.isValidMagic()) { + Log.w(TAG, "Ignoring diag ping with bad magic/version"); + return true; + } + if (p.isPong()) { + onPong(p); + } else { + onProbe(p); + } + return true; + } catch (IOException e) { + Log.w(TAG, "Diag ping parse failed", e); + return true; + } + } + + private void onProbe(CastDiagPing.Packet probe) throws IOException { + CastDiagPing.Packet pong = new CastDiagPing.Packet(); + pong.flags = CastDiagPing.FLAG_PONG; + pong.direction = probe.direction; + pong.payload = probe.payload != null ? probe.payload : new byte[0]; + switch (probe.direction) { + case CastDiagPing.DIR_MIRROR_AS_IS: + pong.timestampMs = probe.timestampMs; + pong.seq = probe.seq; + break; + case CastDiagPing.DIR_MIRROR_SMART: + pong.timestampMs = System.currentTimeMillis(); + pong.seq = probe.seq; + break; + case CastDiagPing.DIR_REPLY_WITH: + pong.timestampMs = System.currentTimeMillis(); + pong.seq = nextSeq++; + pong.replyForSeq = probe.seq; + pong.replyForTimestampMs = probe.timestampMs; + break; + default: + pong.direction = CastDiagPing.DIR_MIRROR_SMART; + pong.timestampMs = System.currentTimeMillis(); + pong.seq = probe.seq; + break; + } + session.send(CastProtocol.MSG_DIAG_PING, CastProtocol.diagPingPayload(pong)); + } + + private void onPong(CastDiagPing.Packet pong) { + long now = System.currentTimeMillis(); + long rtt; + if (pong.direction == CastDiagPing.DIR_REPLY_WITH) { + Long sentAt = pendingProbeSentMs.remove(pong.replyForSeq); + rtt = sentAt != null ? now - sentAt : now - pong.replyForTimestampMs; + } else { + Long sentAt = pendingProbeSentMs.remove(pong.seq); + rtt = sentAt != null ? now - sentAt : now - pong.timestampMs; + } + metrics.recordPong(rtt, pong.seq); + notifyListener(); + } + + private void sendProbe(long nowMs) { + try { + long seq = nextSeq++; + pendingProbeSentMs.put(seq, nowMs); + metrics.recordProbeSent(seq); + CastDiagPing.Packet probe = new CastDiagPing.Packet(); + probe.timestampMs = nowMs; + probe.seq = seq; + probe.direction = localResponseMode; + probe.payload = new byte[0]; + session.send(CastProtocol.MSG_DIAG_PING, CastProtocol.diagPingPayload(probe)); + } catch (IOException e) { + Log.w(TAG, "Send diag probe failed", e); + } + } + + private void notifyListener() { + if (listener != null) { + listener.onDiagPingSample(metrics.snapshot()); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java index abee8ea..509b1bc 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -406,6 +406,11 @@ public class ReceiverCastService extends Service { public void onPeerNetworkStats(NetworkStatsSnapshot stats) { mainHandler.post(() -> streamMetrics.onPeerStats(stats)); } + + @Override + public void onDiagPingSample(com.foxx.androidcast.network.diag.DiagPingMetrics.Snapshot snapshot) { + mainHandler.post(() -> streamMetrics.onDiagPingSample(snapshot)); + } }, this::enrichReceiverStats); session.start(); updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase())); diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java index 9be72a0..543ebce 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java @@ -15,6 +15,7 @@ import android.util.Log; import android.content.Context; +import com.foxx.androidcast.AppPreferences; import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.DeviceInfo; @@ -32,6 +33,7 @@ import com.foxx.androidcast.network.quic.QuicCronetCastTransport; import com.foxx.androidcast.network.transport.TransportLossStats; import com.foxx.androidcast.network.control.NetworkFeedbackManager; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; +import com.foxx.androidcast.network.diag.DiagPingMetrics; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; import java.io.IOException; @@ -71,6 +73,10 @@ public class ReceiverSession { void onNetworkAdviceApplied(); void onPeerNetworkStats(NetworkStatsSnapshot stats); + + /** Diagnostic ping-pong sample (developer setting). */ + default void onDiagPingSample(DiagPingMetrics.Snapshot snapshot) { + } } private final Context appContext; @@ -174,8 +180,14 @@ public class ReceiverSession { listener.onCastSessionStarting(); networkFeedback = new NetworkFeedbackManager(session, false); if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) { - ((PassThroughNetworkControlPlane) networkFeedback.getPlane()) - .setStatsEnricher(statsEnricher); + PassThroughNetworkControlPlane plane = + (PassThroughNetworkControlPlane) networkFeedback.getPlane(); + plane.setStatsEnricher(statsEnricher); + if (AppPreferences.isDevDiagPingPongEnabled(appContext)) { + networkFeedback.configureDiagPing(true, + AppPreferences.getDevDiagPingResponseMode(appContext), + listener::onDiagPingSample); + } } session.acceptClient(); CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings); diff --git a/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java b/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java index 7a6c4f5..9d82302 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/StreamMetrics.java @@ -12,6 +12,7 @@ package com.foxx.androidcast.receiver; * Digest: SHA256 d8a6a1d9253073899f2be582d7334435d8b5e69563656098aef7749f038234ab **********************************************************************/ import com.foxx.androidcast.network.control.NetworkStatsSnapshot; +import com.foxx.androidcast.network.diag.DiagPingMetrics; /** Receiver-side counters for diagnostics overlay and bandwidth estimates. */ public final class StreamMetrics { @@ -39,6 +40,7 @@ public final class StreamMetrics { private int peerRecvKbps; private int peerSendKbps; private NetworkStatsSnapshot lastPeer; + private DiagPingMetrics.Snapshot diagPing = DiagPingMetrics.Snapshot.EMPTY; public synchronized void reset() { videoPackets = 0; @@ -65,6 +67,20 @@ public final class StreamMetrics { peerRecvKbps = 0; peerSendKbps = 0; lastPeer = null; + diagPing = DiagPingMetrics.Snapshot.EMPTY; + } + + public synchronized void onDiagPingSample(DiagPingMetrics.Snapshot sample) { + diagPing = sample != null ? sample : DiagPingMetrics.Snapshot.EMPTY; + if (diagPing.avgRttMs > 0) { + peerRttMs = diagPing.avgRttMs; + } else if (diagPing.lastRttMs > 0) { + peerRttMs = diagPing.lastRttMs; + } + } + + public synchronized DiagPingMetrics.Snapshot getDiagPing() { + return diagPing; } public synchronized void onVideoPacket(boolean keyFrame, int bytes) { diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java index fc75a83..5559664 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -278,6 +278,11 @@ public class ScreenCastService extends Service implements PassThroughNetworkControlPlane plane = (PassThroughNetworkControlPlane) networkFeedback.getPlane(); plane.setStatsEnricher(this::enrichSenderStats); + if (AppPreferences.isDevDiagPingPongEnabled(this)) { + networkFeedback.configureDiagPing(true, + AppPreferences.getDevDiagPingResponseMode(this), + snapshot -> senderMetrics.onDiagPingSample(snapshot)); + } if (peers.size() > 1) { fanoutPump = new CastFanoutPump(); fanoutPump.start(multiCoordinator.getSessions(), () -> stopping.set(true), plane); diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderStreamMetrics.java b/app/src/main/java/com/foxx/androidcast/sender/SenderStreamMetrics.java index c6f1f0a..11af25c 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderStreamMetrics.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderStreamMetrics.java @@ -11,6 +11,8 @@ package com.foxx.androidcast.sender; * - Cursor Agent (project assistant) * Digest: SHA256 65d468449f4cd236559457f2519730c3c07688bfe3082a961c081d6bd629d7f0 **********************************************************************/ +import com.foxx.androidcast.network.diag.DiagPingMetrics; + /** Sender-side encode/send counters for notification diagnostics. */ public final class SenderStreamMetrics { private long encodedFrames; @@ -20,6 +22,16 @@ public final class SenderStreamMetrics { private float lastSecondFps; private int lastSendKbps; private int peerRttMs; + private DiagPingMetrics.Snapshot diagPing = DiagPingMetrics.Snapshot.EMPTY; + + public synchronized void onDiagPingSample(DiagPingMetrics.Snapshot sample) { + diagPing = sample != null ? sample : DiagPingMetrics.Snapshot.EMPTY; + if (diagPing.avgRttMs > 0) { + peerRttMs = diagPing.avgRttMs; + } else if (diagPing.lastRttMs > 0) { + peerRttMs = diagPing.lastRttMs; + } + } public synchronized void onEncodedFrame(boolean keyFrame) { long now = System.currentTimeMillis(); @@ -66,6 +78,9 @@ public final class SenderStreamMetrics { if (peerRttMs > 0) { sb.append(" · rtt ").append(peerRttMs).append("ms"); } + if (diagPing.enabled && diagPing.pongsRx > 0) { + sb.append(" · ping ").append(diagPing.avgRttMs > 0 ? diagPing.avgRttMs : diagPing.lastRttMs).append("ms"); + } if (pump != null) { int dropped = pump.getDroppedPFrames(); int q = pump.getQueueDepth(); diff --git a/app/src/main/res/layout/activity_dev_settings_panel.xml b/app/src/main/res/layout/activity_dev_settings_panel.xml index 3dbe4b4..3c2790d 100644 --- a/app/src/main/res/layout/activity_dev_settings_panel.xml +++ b/app/src/main/res/layout/activity_dev_settings_panel.xml @@ -30,6 +30,33 @@ android:layout_marginTop="12dp" android:text="@string/dev_grab_session_stats" /> + + + + + + + + Show debug overlay on receiver Show codec priority scores in settings Grab anonymous session stats + Diagnostic cast ping-pong (~1/s) + Ping response mode (this device) + Mirror as-is (dummy pong) + Mirror smart (local timestamp) + Reply-with (seq + timestamps) + Requires both peers on a build that supports MSG_DIAG_PING. Old peers ignore unknown packets. Session statistics Charts from session_send_* / session_recv_* JSON (enable grab in developer settings, then cast). Open statistics analyzer diff --git a/app/src/test/java/com/foxx/androidcast/network/diag/CastDiagPingTest.java b/app/src/test/java/com/foxx/androidcast/network/diag/CastDiagPingTest.java new file mode 100644 index 0000000..a1f2f89 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/network/diag/CastDiagPingTest.java @@ -0,0 +1,43 @@ +package com.foxx.androidcast.network.diag; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class CastDiagPingTest { + + @Test + public void roundTrip_probe() throws Exception { + CastDiagPing.Packet in = new CastDiagPing.Packet(); + in.timestampMs = 1_700_000_000_123L; + in.seq = 42; + in.direction = CastDiagPing.DIR_MIRROR_SMART; + in.payload = new byte[0]; + byte[] wire = CastDiagPing.encode(in); + CastDiagPing.Packet out = CastDiagPing.decode(wire); + assertTrue(out.isValidMagic()); + assertEquals(in.timestampMs, out.timestampMs); + assertEquals(in.seq, out.seq); + assertEquals(in.direction, out.direction); + assertEquals(0, out.payload.length); + } + + @Test + public void roundTrip_replyWithPong() throws Exception { + CastDiagPing.Packet in = new CastDiagPing.Packet(); + in.flags = CastDiagPing.FLAG_PONG; + in.direction = CastDiagPing.DIR_REPLY_WITH; + in.timestampMs = 1_700_000_000_500L; + in.seq = 7; + in.replyForSeq = 6; + in.replyForTimestampMs = 1_700_000_000_400L; + byte[] wire = CastDiagPing.encode(in); + CastDiagPing.Packet out = CastDiagPing.decode(wire); + assertTrue(out.isPong()); + assertEquals(CastDiagPing.DIR_REPLY_WITH, out.direction); + assertEquals(in.replyForSeq, out.replyForSeq); + assertEquals(in.replyForTimestampMs, out.replyForTimestampMs); + } +} diff --git a/examples/app_hub/DEPLOY.md b/examples/app_hub/DEPLOY.md index 7961597..fcee911 100644 --- a/examples/app_hub/DEPLOY.md +++ b/examples/app_hub/DEPLOY.md @@ -16,13 +16,22 @@ Browser → apps.f0xx.org (Gentoo FE, TLS) On FE, hub and (if you fix it) crashes should both target **`http://artc0.intra.raptor.org:80`**, same as `location /`. -## 1. `index.html` +## 1. `index.html` + hub assets ```bash cp .../android_cast/examples/app_hub/index.html \ .../htdocs/apps/app/androidcast_project/index.html +cp .../android_cast/examples/app_hub/hub.css \ + .../htdocs/apps/app/androidcast_project/hub.css +cp .../android_cast/examples/app_hub/assets/overtime-01-alphabet.svg \ + .../htdocs/apps/app/androidcast_project/assets/ +# GA4 (optional): set measurementId in analytics.config.js before copy +cp .../android_cast/examples/app_hub/assets/analytics.config.js \ + .../htdocs/apps/app/androidcast_project/assets/ ``` +Crash console ships `analytics.js` via `deploy-to-be-mount.sh` (under `crashes/assets/js/`). + ## 2. BE nginx Edit **`/etc/nginx/conf.d/apps.conf`** on Alpine (sshfs: `tmp/BE_alpine/etc/nginx/...`). diff --git a/examples/app_hub/README.md b/examples/app_hub/README.md index 0615c26..f342629 100644 --- a/examples/app_hub/README.md +++ b/examples/app_hub/README.md @@ -19,3 +19,12 @@ Deploy copies/symlink under `.../androidcast_project/` on the VM. Background wat See **[DEPLOY.md](DEPLOY.md)** — edit `listen 80` in `tmp/BE_alpine/etc/nginx/conf.d/apps.conf` using `examples/crash_reporter/backend/nginx.apps-port80.fragment`. + +## Google Analytics (optional) + +Shared loader: `../crash_reporter/backend/public/assets/js/analytics.js` + +1. Hub: copy `assets/analytics.config.example.js` → `assets/analytics.config.js` and set `measurementId: 'G-…'`. +2. Crash console: in `config/config.php` set `analytics.enabled` and `analytics.measurement_id`. + +Tracks hub page views, nav card clicks, and crash-console views (`console_view` by `data-view`). diff --git a/examples/app_hub/assets/analytics.config.example.js b/examples/app_hub/assets/analytics.config.example.js new file mode 100644 index 0000000..fc113cd --- /dev/null +++ b/examples/app_hub/assets/analytics.config.example.js @@ -0,0 +1,7 @@ +/** Copy to analytics.config.js on deploy and set your GA4 measurement ID. */ +window.ANDROIDCAST_ANALYTICS = { + measurementId: 'G-XXXXXXXXXX', + platform: 'hub', + basePath: '/app/androidcast_project', + debug: false +}; diff --git a/examples/app_hub/assets/analytics.config.js b/examples/app_hub/assets/analytics.config.js new file mode 100644 index 0000000..ac7f58f --- /dev/null +++ b/examples/app_hub/assets/analytics.config.js @@ -0,0 +1,7 @@ +/** Hub GA4 config — set measurementId to enable (see analytics.config.example.js). */ +window.ANDROIDCAST_ANALYTICS = { + measurementId: '', + platform: 'hub', + basePath: '/app/androidcast_project', + debug: false +}; diff --git a/examples/app_hub/assets/build_lcd_alphabet.py b/examples/app_hub/assets/build_lcd_alphabet.py new file mode 100644 index 0000000..ac52270 --- /dev/null +++ b/examples/app_hub/assets/build_lcd_alphabet.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""Build full Overtime and Quartz 7-segment LCD SVG symbol sets.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +# Segment indices: 0=top, 1=UL, 2=UR, 3=mid, 4=LL, 5=LR, 6=bottom +PATTERNS: dict[str, list[int]] = { + " ": [], + "0": [0, 1, 2, 4, 5, 6], + "1": [2, 5], + "2": [0, 1, 3, 5, 6], + "3": [0, 2, 3, 5, 6], + "4": [1, 2, 3, 5], + "5": [0, 2, 3, 5, 6], + "6": [0, 2, 3, 4, 5, 6], + "7": [0, 1, 2], + "8": [0, 1, 2, 3, 4, 5, 6], + "9": [0, 1, 2, 3, 5, 6], + "A": [0, 1, 2, 3, 4, 5], + "B": [0, 1, 2, 3, 5, 6], + "C": [0, 1, 4, 6], + "D": [1, 2, 3, 5, 6], + "E": [0, 3, 4, 5, 6], + "F": [0, 3, 4, 5], + "G": [0, 2, 4, 5, 6], + "H": [1, 2, 3, 4, 5], + "I": [0, 6], + "J": [1, 2, 4, 5, 6], + "K": [1, 3, 4, 5], + "L": [4, 5, 6], + "M": [0, 1, 2, 4, 5], + "N": [1, 2, 4, 5], + "O": [0, 1, 2, 4, 5, 6], + "P": [0, 1, 2, 3, 4], + "Q": [0, 1, 2, 4, 5, 6], + "R": [0, 1, 2, 3, 4, 5], + "S": [0, 2, 3, 5, 6], + "T": [0, 3, 6], + "U": [1, 2, 4, 5, 6], + "V": [1, 4, 5], + "W": [1, 2, 4, 5, 6], + "X": [1, 2, 4, 5], + "Y": [1, 2, 3], + "Z": [0, 1, 4, 6], +} + +EXTRA_RECTS: dict[str, list[tuple]] = { + "M": [ + ("7.1", "5", "2.6", "13.2", 'transform="rotate(28 8.4 11.6)"'), + ("14.3", "5", "2.6", "13.2", 'transform="rotate(-28 15.6 11.6)"'), + ], + "N": [ + ("6.5", "6", "2.4", "12", 'transform="rotate(32 7.7 12)"'), + ("14.8", "14", "2.4", "12", 'transform="rotate(32 16 20)"'), + ], + "K": [ + ("10", "4", "2.2", "11", 'transform="rotate(-30 11.1 9.5)"'), + ("10", "17", "2.2", "11", 'transform="rotate(30 11.1 22.5)"'), + ], + "V": [ + ("7", "17", "2.2", "12", 'transform="rotate(24 8.1 23)"'), + ("12", "17", "2.2", "12", 'transform="rotate(-24 13.1 23)"'), + ], + "W": [ + ("5", "17", "2", "11", 'transform="rotate(18 6 22.5)"'), + ("9.5", "17", "2", "11", 'transform="rotate(-18 10.5 22.5)"'), + ("14", "17", "2", "11", 'transform="rotate(18 15 22.5)"'), + ], + "X": [ + ("7", "5", "2.2", "11", 'transform="rotate(32 8.1 10.5)"'), + ("11", "16", "2.2", "11", 'transform="rotate(-32 12.1 21.5)"'), + ], + "Q": [("14", "18", "2.2", "10", 'transform="rotate(38 15.1 23)"')], + "R": [("12", "17", "2.2", "10", 'transform="rotate(38 13.1 22)"')], + "/": [ + ("13", "4", "2.2", "10", 'transform="rotate(52 14.1 9)"'), + ("5", "18", "2.2", "10", 'transform="rotate(52 6.1 23)"'), + ], + ":": [("4", "10", "4", "4"), ("4", "20", "4", "4")], + "+": [("6", "8", "4", "16"), ("2", "14", "12", "4")], + "-": [("2", "14", "12", "4")], +} + +# Hand-tuned Overtime Bold shapes (from hub digit geometry). +# Hand-tuned glyphs from hub (do not regenerate numerics). +OVERTIME_VERBATIM: dict[str, tuple[int, str]] = { + "0": ( + 20, + '' + '' + '', + ), + "1": (20, ''), + "2": ( + 20, + '' + '' + '', + ), + "3": ( + 20, + '' + '' + '', + ), + "4": ( + 20, + '' + '', + ), + "5": ( + 20, + '' + '' + '', + ), + "6": ( + 20, + '' + '' + '', + ), + "7": ( + 20, + '' + '', + ), + "8": ( + 20, + '' + '' + '' + '', + ), + "9": ( + 20, + '' + '' + '', + ), + ":": (12, ''), + "+": (16, ''), + "-": (16, ''), + " ": (8, ""), + "/": ( + 14, + '' + '', + ), +} + +OVERTIME_CUSTOM: dict[str, tuple[int, str]] = { + "G": ( + 20, + '' + '' + '', + ), + "M": ( + 24, + '' + '' + '', + ), + "T": ( + 20, + '', + ), + "Y": ( + 20, + '' + '', + ), +} + + +@dataclass(frozen=True) +class FontStyle: + prefix: str + outfile: str + comment: str + h_pad: float + top_y: float + mid_y: float + bot_y: float + seg_h: float + v_w: float + v_h: float + left_x: float + right_x: float + seg_w: float + + +FONTS: dict[str, FontStyle] = { + "overtime": FontStyle( + prefix="ot", + outfile="overtime-01-alphabet.svg", + comment="Overtime Bold style 7-segment LCD (full alphabet)", + h_pad=3.0, + top_y=1.0, + mid_y=15.0, + bot_y=28.0, + seg_h=3.0, + v_w=3.0, + v_h=11.0, + left_x=1.0, + right_x=16.0, + seg_w=14.0, + ), + "quartz": FontStyle( + prefix="qz", + outfile="quartz-alphabet.svg", + comment="Quartz (TS) style 7-segment LCD (full alphabet)", + h_pad=0.0, + top_y=0.0, + mid_y=0.0, + bot_y=0.0, + seg_h=0.0, + v_w=0.0, + v_h=0.0, + left_x=0.0, + right_x=0.0, + seg_w=0.0, + ), +} + + +def _rect(x, y, width, height, extra: str = "") -> str: + attrs = f'x="{x}" y="{y}" width="{width}" height="{height}" rx="1"' + if extra: + return f"" + return f"" + + +def _seg_defs(style: FontStyle, w: float, h: float) -> list[tuple[float, float, float, float]]: + if style.prefix == "qz": + h_pad = max(1.0, w * 0.12) + top_y = max(1.0, h * 0.04) + mid_y = h * 0.47 + bot_y = h * 0.86 + seg_w = max(6.0, w - h_pad * 2) + seg_h = max(2.0, h * 0.09) + v_w = max(2.0, w * 0.14) + v_h = max(6.0, h * 0.33) + left_x = max(1.0, h_pad * 0.45) + right_x = max(left_x + v_w + 1, w - left_x - v_w) + upper_y = top_y + seg_h + lower_y = mid_y + seg_h * 0.6 + return [ + (h_pad, top_y, seg_w, seg_h), + (left_x, upper_y, v_w, v_h), + (right_x, upper_y, v_w, v_h), + (h_pad, mid_y, seg_w, seg_h), + (left_x, lower_y, v_w, v_h), + (right_x, lower_y, v_w, v_h), + (h_pad, bot_y, seg_w, seg_h), + ] + + upper_y = style.top_y + style.seg_h + lower_y = 17.0 + return [ + (style.h_pad, style.top_y, style.seg_w, style.seg_h), + (style.left_x, upper_y, style.v_w, style.v_h), + (style.right_x, upper_y, style.v_w, style.v_h), + (style.h_pad, style.mid_y, style.seg_w, style.seg_h), + (style.left_x, lower_y, style.v_w, style.v_h), + (style.right_x, lower_y, style.v_w, style.v_h), + (style.h_pad, style.bot_y, style.seg_w, style.seg_h), + ] + + +def _symbol_id(prefix: str, ch: str) -> str: + if ch == " ": + return f"{prefix}-space" + if ch == ":": + return f"{prefix}-colon" + if ch == "+": + return f"{prefix}-plus" + if ch == "-": + return f"{prefix}-minus" + if ch == "/": + return f"{prefix}-slash" + if len(ch) == 1 and "0" <= ch <= "9": + return f"{prefix}-{ch}" + if len(ch) == 1 and "A" <= ch <= "Z": + return f"{prefix}-{ch}" + return f"{prefix}-u{ord(ch):04x}" + + +def _symbol_body(style: FontStyle, ch: str, width: int) -> str: + if style.prefix == "ot" and ch in OVERTIME_VERBATIM: + _, body = OVERTIME_VERBATIM[ch] + return body + if style.prefix == "ot" and ch in OVERTIME_CUSTOM: + _, body = OVERTIME_CUSTOM[ch] + return body + + h = 32.0 + w = float(width) + segs = PATTERNS.get(ch, []) + parts: list[str] = [] + defs = _seg_defs(style, w, h) + for idx in segs: + if idx < 7: + x, y, rw, rh = defs[idx] + parts.append(_rect(f"{x:.2f}", f"{y:.2f}", f"{rw:.2f}", f"{rh:.2f}")) + for item in EXTRA_RECTS.get(ch, []): + if len(item) == 4: + parts.append(_rect(*item)) + else: + parts.append(_rect(item[0], item[1], item[2], item[3], item[4])) + return "".join(parts) + + +def _char_width(ch: str) -> int: + if ch in "MWKNQRX": + return 24 + if ch == ":": + return 12 + if ch in "+-": + return 16 + if ch == "/": + return 14 + if ch == " ": + return 8 + if ch in OVERTIME_VERBATIM: + return OVERTIME_VERBATIM[ch][0] + if ch in OVERTIME_CUSTOM: + return OVERTIME_CUSTOM[ch][0] + return 20 + + +def build_font(style: FontStyle, out_dir: Path) -> None: + chars = ( + [" "] + [str(d) for d in range(10)] + [chr(c) for c in range(ord("A"), ord("Z") + 1)] + + [":", "+", "-", "/"] + ) + lines = [ + '") + out_path = out_dir / style.outfile + out_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"Wrote {out_path} ({len(chars)} symbols)") + + +def main() -> None: + out_dir = Path(__file__).resolve().parent + for style in FONTS.values(): + build_font(style, out_dir) + + +if __name__ == "__main__": + main() diff --git a/examples/app_hub/assets/build_quartz_alphabet.py b/examples/app_hub/assets/build_quartz_alphabet.py new file mode 100644 index 0000000..f8397cd --- /dev/null +++ b/examples/app_hub/assets/build_quartz_alphabet.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Generate Quartz-style 7-segment LCD symbol set for hub clock rows.""" + +from __future__ import annotations + +# Segment indices: 0=top, 1=upper-left, 2=upper-right, 3=mid, 4=lower-left, 5=lower-right, 6=bottom +PATTERNS: dict[str, list[int]] = { + " ": [], + "0": [0, 1, 2, 4, 5, 6], + "1": [2, 5], + "2": [0, 1, 3, 4, 6], + "3": [0, 1, 2, 3, 6], + "4": [1, 2, 3, 5], + "5": [0, 2, 3, 5, 6], + "6": [0, 2, 3, 4, 5, 6], + "7": [0, 1, 2], + "8": [0, 1, 2, 3, 4, 5, 6], + "9": [0, 1, 2, 3, 5, 6], + "A": [0, 1, 2, 3, 4, 5], + "B": [0, 1, 2, 3, 5, 6], + "C": [0, 1, 4, 6], + "D": [1, 2, 3, 5, 6], + "E": [0, 3, 4, 5, 6], + "F": [0, 3, 4, 5], + "G": [0, 2, 4, 5, 6], + "H": [1, 2, 3, 4, 5], + "I": [0, 6], + "J": [1, 2, 4, 5, 6], + "K": [1, 3, 4, 5], + "L": [4, 5, 6], + "M": [0, 1, 2, 4, 5], + "N": [1, 2, 4, 5], + "O": [0, 1, 2, 4, 5, 6], + "P": [0, 1, 2, 3, 4], + "Q": [0, 1, 2, 4, 5, 6], + "R": [0, 1, 2, 3, 4, 5], + "S": [0, 2, 3, 5, 6], + "T": [0, 3, 6], + "U": [1, 2, 4, 5, 6], + "V": [1, 4, 5], + "W": [1, 2, 4, 5, 6], + "X": [1, 2, 4, 5], + "Y": [2, 3, 5, 6], + "Z": [0, 1, 4, 6], + ":": [10, 11], + "+": [12, 13], + "-": [14], + "/": [15, 16], +} + +EXTRA_RECTS: dict[str, list[tuple]] = { + "M": [ + ("7.1", "5", "2.6", "13.2", 'transform="rotate(28 8.4 11.6)"'), + ("14.3", "5", "2.6", "13.2", 'transform="rotate(-28 15.6 11.6)"'), + ], + "N": [ + ("6.5", "6", "2.4", "12", 'transform="rotate(32 7.7 12)"'), + ("14.8", "14", "2.4", "12", 'transform="rotate(32 16 20)"'), + ], + "K": [ + ("10", "4", "2.2", "11", 'transform="rotate(-30 11.1 9.5)"'), + ("10", "17", "2.2", "11", 'transform="rotate(30 11.1 22.5)"'), + ], + "V": [ + ("7", "17", "2.2", "12", 'transform="rotate(24 8.1 23)"'), + ("12", "17", "2.2", "12", 'transform="rotate(-24 13.1 23)"'), + ], + "W": [ + ("5", "17", "2", "11", 'transform="rotate(18 6 22.5)"'), + ("9.5", "17", "2", "11", 'transform="rotate(-18 10.5 22.5)"'), + ("14", "17", "2", "11", 'transform="rotate(18 15 22.5)"'), + ], + "X": [ + ("7", "5", "2.2", "11", 'transform="rotate(32 8.1 10.5)"'), + ("11", "16", "2.2", "11", 'transform="rotate(-32 12.1 21.5)"'), + ], + "Q": [ + ("14", "18", "2.2", "10", 'transform="rotate(38 15.1 23)"'), + ], + "R": [ + ("12", "17", "2.2", "10", 'transform="rotate(38 13.1 22)"'), + ], + "/": [ + ("13", "4", "2.2", "10", 'transform="rotate(52 14.1 9)"'), + ("5", "18", "2.2", "10", 'transform="rotate(52 6.1 23)"'), + ], + ":": [ + ("4", "10", "4", "4"), + ("4", "20", "4", "4"), + ], + "+": [ + ("6", "8", "4", "16"), + ("2", "14", "12", "4"), + ], + "-": [ + ("2", "14", "12", "4"), + ], +} + + +def _seg_defs(w: float, h: float) -> list[tuple[float, float, float, float]]: + h_pad = max(1.0, w * 0.12) + top_y = max(1.0, h * 0.04) + mid_y = h * 0.47 + bot_y = h * 0.86 + seg_w = max(6.0, w - h_pad * 2) + seg_h = max(2.0, h * 0.09) + v_w = max(2.0, w * 0.14) + v_h = max(6.0, h * 0.33) + left_x = max(1.0, h_pad * 0.45) + right_x = max(left_x + v_w + 1, w - left_x - v_w) + upper_y = top_y + seg_h + lower_y = mid_y + seg_h * 0.6 + return [ + (h_pad, top_y, seg_w, seg_h), + (left_x, upper_y, v_w, v_h), + (right_x, upper_y, v_w, v_h), + (h_pad, mid_y, seg_w, seg_h), + (left_x, lower_y, v_w, v_h), + (right_x, lower_y, v_w, v_h), + (h_pad, bot_y, seg_w, seg_h), + ] + + +def _rect(x, y, width, height, extra: str = "") -> str: + attrs = f'x="{x}" y="{y}" width="{width}" height="{height}" rx="1"' + if extra: + return f"" + return f"" + + +def symbol_body(ch: str, width: int = 20) -> str: + h = 32.0 + w = float(width) + segs = PATTERNS.get(ch, []) + parts: list[str] = [] + defs = _seg_defs(w, h) + for idx in segs: + if idx < 7: + x, y, rw, rh = defs[idx] + parts.append(_rect(f"{x:.2f}", f"{y:.2f}", f"{rw:.2f}", f"{rh:.2f}")) + for item in EXTRA_RECTS.get(ch, []): + if len(item) == 4: + parts.append(_rect(*item)) + else: + parts.append(_rect(item[0], item[1], item[2], item[3], item[4])) + return "".join(parts) + + +def symbol_id(ch: str) -> str: + if ch == " ": + return "qz-space" + if ch == ":": + return "qz-colon" + if ch == "+": + return "qz-plus" + if ch == "-": + return "qz-minus" + if ch == "/": + return "qz-slash" + if len(ch) == 1 and "0" <= ch <= "9": + return f"qz-{ch}" + if len(ch) == 1 and "A" <= ch <= "Z": + return f"qz-{ch}" + return f"qz-u{ord(ch):04x}" + + +def main() -> None: + chars = ( + [" "] + [str(d) for d in range(10)] + [chr(c) for c in range(ord("A"), ord("Z") + 1)] + + [":", "+", "-", "/"] + ) + wide = set("MWKNQRX") + lines = [ + '") + out = "\n".join(lines) + "\n" + path = __file__.replace("build_quartz_alphabet.py", "quartz-alphabet.svg") + with open(path, "w", encoding="utf-8") as fh: + fh.write(out) + print(f"Wrote {path} ({len(chars)} symbols)") + + +if __name__ == "__main__": + main() diff --git a/examples/app_hub/assets/overtime-01-alphabet.svg b/examples/app_hub/assets/overtime-01-alphabet.svg index 1bb41c0..be1bddc 100644 --- a/examples/app_hub/assets/overtime-01-alphabet.svg +++ b/examples/app_hub/assets/overtime-01-alphabet.svg @@ -1,9 +1,10 @@ diff --git a/examples/app_hub/assets/quartz-alphabet.svg b/examples/app_hub/assets/quartz-alphabet.svg new file mode 100644 index 0000000..321f1fd --- /dev/null +++ b/examples/app_hub/assets/quartz-alphabet.svg @@ -0,0 +1,46 @@ + diff --git a/examples/app_hub/hub.css b/examples/app_hub/hub.css index 41ba0a1..19cd534 100644 --- a/examples/app_hub/hub.css +++ b/examples/app_hub/hub.css @@ -188,11 +188,12 @@ } .hub-clock-widget { + --cw-lcd-x: 24px; position: fixed; right: 24px; top: 24px; left: auto; - width: 420px; + width: 567px; z-index: 3; border: 1px solid rgba(255, 255, 255, 0.72); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.48); @@ -204,7 +205,7 @@ cursor: move; user-select: none; touch-action: none; - padding: 60px; + padding: 40px; box-sizing: border-box; display: flex; flex-direction: column; @@ -246,7 +247,9 @@ .cw-primary-block { padding: 0; display: grid; - grid-template-rows: repeat(3, minmax(0, 1fr)); + grid-template-rows: auto auto auto; + align-content: start; + row-gap: 0; overflow: hidden; background: rgba(0, 180, 80, 0.35); } @@ -263,7 +266,7 @@ align-items: end; overflow: hidden; container-type: inline-size; - padding: 16px 0 6px; + padding: 12px 0 4px; } .cw-line-time .cw-time-main { @@ -273,15 +276,19 @@ .cw-row-tz, .cw-row-date { display: flex; - align-items: center; + align-items: flex-end; justify-content: center; + min-height: 0; + line-height: 1; + padding: 0; } .cw-time-main { - display: inline-flex; - align-items: flex-end; + display: grid; + grid-template-columns: auto auto auto auto; + align-items: end; justify-content: center; - gap: 2px; + column-gap: 6px; line-height: 1; font-weight: 700; width: fit-content; @@ -296,54 +303,57 @@ .ot-lcd-line { display: inline-flex; align-items: flex-end; - gap: 4px; -} - -.ot-lcd-bold .ot-glyph-wrap--bold { - transform: scale(1.08, 1.12); - transform-origin: bottom center; + gap: 2px; } #cw-hm-lcd .ot-glyph { - height: 136px; + height: calc(var(--cw-lcd-x) * 2); width: auto; max-width: 100%; } +#cw-colon-lcd .ot-glyph, +.ot-lcd-sec .ot-glyph, +.ot-lcd-tz .ot-glyph { + height: var(--cw-lcd-x); + width: auto; +} + @container (max-width: 300px) { - #cw-hm-lcd .ot-glyph { - height: 108px; - } - - .ot-lcd-sec .ot-glyph { - height: 26px; - } - - .ot-lcd-tz .ot-glyph { - height: 24px; + .hub-clock-widget { + --cw-lcd-x: 20px; } } .ot-lcd-sec { - margin-left: 8px; + margin-left: 0; } .ot-lcd-sec .ot-glyph-wrap:first-child { - margin-right: 8px; -} - -.ot-lcd-sec .ot-glyph { - height: 32px; - width: auto; -} - -.ot-lcd-tz .ot-glyph { - height: 30px; - width: auto; + margin-right: 0; } .ot-lcd-tz { - margin-left: 14px; + margin-left: calc(var(--cw-lcd-x) * 2); +} + +.ot-lcd-subline { + display: inline-flex; + align-items: flex-end; + justify-content: center; + flex-wrap: wrap; + gap: 2px; + line-height: 1; +} + +.cw-line-region.ot-lcd-subline .ot-glyph { + height: calc(var(--cw-lcd-x) * 0.5); + width: auto; +} + +.cw-line-date.ot-lcd-subline .ot-glyph { + height: var(--cw-lcd-x); + width: auto; } .ot-glyph-wrap { @@ -362,6 +372,23 @@ filter: drop-shadow(0 0 5px rgba(190, 240, 150, 0.38)); } +.ot-fallback-char { + display: inline-flex; + align-items: flex-end; + justify-content: center; + min-width: 0.62em; + color: #d8f6b8; + font-family: "Consolas", "Menlo", "Monaco", monospace; + font-size: 1em; + line-height: 1; + letter-spacing: 0.02em; + text-shadow: 0 0 5px rgba(190, 240, 150, 0.42); +} + +.ot-fallback-char--bold { + font-weight: 700; +} + .ot-glyph .ot-grid rect { fill: rgba(20, 30, 24, 0.85); filter: drop-shadow(0 0 1.4px rgba(135, 205, 255, 0.5)); @@ -377,7 +404,7 @@ .cw-line-region { color: #b8c2d4; - font-size: 10px; + font-size: 12px; letter-spacing: 0.16em; font-weight: 300; text-transform: uppercase; @@ -385,7 +412,7 @@ .cw-line-date { color: #c5d0e2; - font-size: 10px; + font-size: 24px; letter-spacing: 0.12em; font-weight: 400; text-transform: uppercase; @@ -752,7 +779,7 @@ left: auto; right: 10px; top: 10px; - width: min(94vw, 420px); + width: min(94vw, 567px); min-width: 0; } .hub-cards { diff --git a/examples/app_hub/index.html b/examples/app_hub/index.html index 031b8b2..305ec26 100644 --- a/examples/app_hub/index.html +++ b/examples/app_hub/index.html @@ -11,33 +11,32 @@ })(); - + + +

+
-
-
-
+
+
+
- - - + + + +
-
EUROPE/WARSAW
-
WED 27 MAY 2026
+
EUROPE/WARSAW
+
WED 27 MAY 2026
+ +
-
-
-
-
-
-
-