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 @@
})();
-
+
+
+
+