mirror of
git://f0xx.org/android_cast
synced 2026-07-29 07:39:15 +03:00
snap
This commit is contained in:
@@ -8,7 +8,7 @@ JavaFX desktop app for **session log analysis** (Linux `.jar` or native image la
|
||||
./gradlew :session-studio:run
|
||||
```
|
||||
|
||||
Open `session_*.json` from the device (`files/session_stats/`). New captures include a `samples[]` time series (~2 s) for charts.
|
||||
Open `session_send_*` / `session_recv_*` JSON from the device (`files/session_stats/`). Legacy `session_*.json` still loads. Charts use `samples[]` (~2 s): bitrate, loss %, encode FPS.
|
||||
|
||||
### Roadmap (full mobile parity + analytics)
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.foxx.androidcast.studio;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Nice axis tick steps for charts. */
|
||||
final class ChartTicks {
|
||||
private ChartTicks() {}
|
||||
|
||||
static final class Tick {
|
||||
final double value;
|
||||
final String label;
|
||||
|
||||
Tick(double value, String label) {
|
||||
this.value = value;
|
||||
this.label = label;
|
||||
}
|
||||
}
|
||||
|
||||
static List<Tick> linear(double min, double max, int maxTicks) {
|
||||
List<Tick> out = new ArrayList<>();
|
||||
if (!Double.isFinite(min) || !Double.isFinite(max) || max <= min) {
|
||||
return out;
|
||||
}
|
||||
double step = niceStep(max - min, maxTicks);
|
||||
double start = Math.ceil(min / step) * step;
|
||||
for (double v = start; v <= max + step * 0.001; v += step) {
|
||||
out.add(new Tick(v, formatValue(v, step)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static List<Tick> timeMs(long minMs, long maxMs, int maxTicks) {
|
||||
List<Tick> out = new ArrayList<>();
|
||||
if (maxMs <= minMs) {
|
||||
return out;
|
||||
}
|
||||
double range = maxMs - minMs;
|
||||
double step = niceStep(range, maxTicks);
|
||||
double start = Math.ceil(minMs / step) * step;
|
||||
for (double v = start; v <= maxMs + step * 0.001; v += step) {
|
||||
long ms = Math.round(v);
|
||||
out.add(new Tick(ms, formatTimeMs(ms)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static double niceStep(double range, int maxTicks) {
|
||||
double raw = range / Math.max(2, maxTicks);
|
||||
if (raw <= 0) {
|
||||
return 1;
|
||||
}
|
||||
double mag = Math.pow(10, Math.floor(Math.log10(raw)));
|
||||
double norm = raw / mag;
|
||||
double nice = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
|
||||
return nice * mag;
|
||||
}
|
||||
|
||||
private static String formatValue(double v, double step) {
|
||||
if (step >= 1) {
|
||||
return String.format(Locale.US, "%.0f", v);
|
||||
}
|
||||
if (step >= 0.1) {
|
||||
return String.format(Locale.US, "%.1f", v);
|
||||
}
|
||||
return String.format(Locale.US, "%.2f", v);
|
||||
}
|
||||
|
||||
private static String formatTimeMs(long ms) {
|
||||
if (ms >= 60_000) {
|
||||
return String.format(Locale.US, "%.1fm", ms / 60_000.0);
|
||||
}
|
||||
if (ms >= 1000) {
|
||||
return String.format(Locale.US, "%.1fs", ms / 1000.0);
|
||||
}
|
||||
return ms + "ms";
|
||||
}
|
||||
}
|
||||
@@ -3,172 +3,354 @@ package com.foxx.androidcast.studio;
|
||||
import javafx.scene.canvas.Canvas;
|
||||
import javafx.scene.canvas.GraphicsContext;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.input.ScrollEvent;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.FontWeight;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Multi-series line chart with hover tooltip for session samples. */
|
||||
/** Multi-session line chart: zoom, grid, legend, hover marker + tooltip. */
|
||||
public final class SessionChartPane extends StackPane {
|
||||
private static final Color[] SERIES_COLORS = {
|
||||
Color.CORNFLOWERBLUE, Color.ORANGERED, Color.MEDIUMSEAGREEN, Color.GOLD, Color.VIOLET
|
||||
public enum Metric {
|
||||
BITRATE("Video bitrate (kbps)"),
|
||||
LOSS("Packet loss (%)"),
|
||||
ENCODE_FPS("Encode FPS");
|
||||
|
||||
final String axisLabel;
|
||||
|
||||
Metric(String axisLabel) {
|
||||
this.axisLabel = axisLabel;
|
||||
}
|
||||
}
|
||||
|
||||
private static final Color[] SESSION_COLORS = {
|
||||
Color.web("#64B5F6"), Color.web("#FF8A65"), Color.web("#81C784"),
|
||||
Color.web("#FFD54F"), Color.web("#BA68C8"), Color.web("#4DD0E1")
|
||||
};
|
||||
|
||||
private static final double PAD_LEFT = 64;
|
||||
private static final double PAD_RIGHT = 16;
|
||||
private static final double PAD_TOP = 16;
|
||||
private static final double PAD_BOTTOM = 40;
|
||||
private static final Color GRID_COLOR = Color.web("#3a3a3a");
|
||||
private static final Color AXIS_COLOR = Color.web("#aaaaaa");
|
||||
|
||||
private final Canvas canvas = new Canvas(800, 480);
|
||||
private final List<SessionDocument> sessions = new ArrayList<>();
|
||||
private boolean absoluteTime = true;
|
||||
private boolean showBitrate = true;
|
||||
private boolean showRtt;
|
||||
private boolean showLoss;
|
||||
private String hoverText = "";
|
||||
private Metric metric = Metric.BITRATE;
|
||||
|
||||
private double viewMinT;
|
||||
private double viewMaxT = 1;
|
||||
private double viewMinY;
|
||||
private double viewMaxY = 1;
|
||||
private boolean viewInitialized;
|
||||
|
||||
private double hoverMouseX = -1;
|
||||
private double hoverMouseY = -1;
|
||||
private int hoverSessionIndex = -1;
|
||||
private SessionDocument.SamplePoint hoverPoint;
|
||||
private double hoverPlotX;
|
||||
private double hoverPlotY;
|
||||
|
||||
public SessionChartPane() {
|
||||
getChildren().add(canvas);
|
||||
canvas.widthProperty().bind(widthProperty());
|
||||
canvas.heightProperty().bind(heightProperty().subtract(0));
|
||||
canvas.heightProperty().bind(heightProperty());
|
||||
canvas.addEventHandler(MouseEvent.MOUSE_MOVED, this::onMouseMoved);
|
||||
canvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> {
|
||||
hoverText = "";
|
||||
draw();
|
||||
});
|
||||
canvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> clearHover());
|
||||
canvas.addEventHandler(ScrollEvent.SCROLL, this::onScroll);
|
||||
}
|
||||
|
||||
public void setAbsoluteTime(boolean absoluteTime) {
|
||||
this.absoluteTime = absoluteTime;
|
||||
}
|
||||
|
||||
public void render(List<SessionDocument> docs, boolean bitrate, boolean rtt, boolean loss) {
|
||||
public void render(List<SessionDocument> docs, Metric metric) {
|
||||
sessions.clear();
|
||||
if (docs != null) {
|
||||
sessions.addAll(docs);
|
||||
}
|
||||
showBitrate = bitrate;
|
||||
showRtt = rtt;
|
||||
showLoss = loss;
|
||||
this.metric = metric != null ? metric : Metric.BITRATE;
|
||||
resetView();
|
||||
draw();
|
||||
}
|
||||
|
||||
private void onMouseMoved(MouseEvent e) {
|
||||
if (sessions.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
double pad = 48;
|
||||
double w = canvas.getWidth() - pad * 2;
|
||||
double h = canvas.getHeight() - pad * 2;
|
||||
if (w <= 0 || h <= 0) {
|
||||
return;
|
||||
}
|
||||
long tMax = maxTimeMs();
|
||||
if (tMax <= 0) {
|
||||
return;
|
||||
}
|
||||
double relX = (e.getX() - pad) / w;
|
||||
if (relX < 0 || relX > 1) {
|
||||
hoverText = "";
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
long tQuery = (long) (relX * tMax);
|
||||
SessionDocument doc = sessions.get(0);
|
||||
SessionDocument.SamplePoint nearest = null;
|
||||
long best = Long.MAX_VALUE;
|
||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||
long d = Math.abs(p.tMs - tQuery);
|
||||
if (d < best) {
|
||||
best = d;
|
||||
nearest = p;
|
||||
}
|
||||
}
|
||||
if (nearest != null) {
|
||||
hoverText = String.format("t=%d ms bitrate=%.0f kbps rtt=%d ms loss=%.3f",
|
||||
nearest.tMs, nearest.videoBitrateKbps, nearest.rttMs, nearest.lossRatio);
|
||||
}
|
||||
draw();
|
||||
private void resetView() {
|
||||
viewMinT = 0;
|
||||
viewMaxT = Math.max(1, dataMaxTimeMs());
|
||||
double[] y = dataYRange();
|
||||
viewMinY = y[0];
|
||||
viewMaxY = y[1];
|
||||
viewInitialized = sessions.stream().anyMatch(d -> d.samples.size() >= 2);
|
||||
}
|
||||
|
||||
private long maxTimeMs() {
|
||||
private long dataMaxTimeMs() {
|
||||
long max = 1;
|
||||
for (SessionDocument doc : sessions) {
|
||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||
max = Math.max(max, p.tMs);
|
||||
}
|
||||
if (!absoluteTime && doc.durationMs > max) {
|
||||
max = doc.durationMs;
|
||||
}
|
||||
max = Math.max(max, doc.durationMs);
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
private double[] dataYRange() {
|
||||
double min = Double.POSITIVE_INFINITY;
|
||||
double max = Double.NEGATIVE_INFINITY;
|
||||
for (SessionDocument doc : sessions) {
|
||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||
double v = metricValue(p);
|
||||
if (Double.isFinite(v)) {
|
||||
min = Math.min(min, v);
|
||||
max = Math.max(max, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!Double.isFinite(min)) {
|
||||
min = 0;
|
||||
max = 1;
|
||||
}
|
||||
if (max <= min) {
|
||||
max = min + 1;
|
||||
}
|
||||
double pad = (max - min) * 0.06;
|
||||
return new double[] {min - pad, max + pad};
|
||||
}
|
||||
|
||||
private void onScroll(ScrollEvent e) {
|
||||
if (!viewInitialized) {
|
||||
return;
|
||||
}
|
||||
double plotW = plotWidth();
|
||||
double plotH = plotHeight();
|
||||
if (plotW <= 0 || plotH <= 0) {
|
||||
return;
|
||||
}
|
||||
double factor = e.getDeltaY() > 0 ? 0.85 : 1.0 / 0.85;
|
||||
double mx = e.getX();
|
||||
double my = e.getY();
|
||||
if (mx >= PAD_LEFT && mx <= PAD_LEFT + plotW && my >= PAD_TOP && my <= PAD_TOP + plotH) {
|
||||
double relX = (mx - PAD_LEFT) / plotW;
|
||||
double relY = 1.0 - (my - PAD_TOP) / plotH;
|
||||
double tAnchor = viewMinT + relX * (viewMaxT - viewMinT);
|
||||
double yAnchor = viewMinY + relY * (viewMaxY - viewMinY);
|
||||
zoomTime(tAnchor, factor);
|
||||
zoomY(yAnchor, factor);
|
||||
} else {
|
||||
zoomTime((viewMinT + viewMaxT) / 2, factor);
|
||||
zoomY((viewMinY + viewMaxY) / 2, factor);
|
||||
}
|
||||
e.consume();
|
||||
draw();
|
||||
}
|
||||
|
||||
private void zoomTime(double anchor, double factor) {
|
||||
double span = viewMaxT - viewMinT;
|
||||
if (span <= 0) {
|
||||
return;
|
||||
}
|
||||
double newSpan = Math.max(50, span * factor);
|
||||
viewMinT = anchor - (anchor - viewMinT) * (newSpan / span);
|
||||
viewMaxT = viewMinT + newSpan;
|
||||
}
|
||||
|
||||
private void zoomY(double anchor, double factor) {
|
||||
double span = viewMaxY - viewMinY;
|
||||
if (span <= 0) {
|
||||
return;
|
||||
}
|
||||
double newSpan = Math.max(1, span * factor);
|
||||
viewMinY = anchor - (anchor - viewMinY) * (newSpan / span);
|
||||
viewMaxY = viewMinY + newSpan;
|
||||
}
|
||||
|
||||
private void onMouseMoved(MouseEvent e) {
|
||||
hoverMouseX = e.getX();
|
||||
hoverMouseY = e.getY();
|
||||
updateHoverHit();
|
||||
draw();
|
||||
}
|
||||
|
||||
private void clearHover() {
|
||||
hoverMouseX = -1;
|
||||
hoverMouseY = -1;
|
||||
hoverSessionIndex = -1;
|
||||
hoverPoint = null;
|
||||
draw();
|
||||
}
|
||||
|
||||
private void updateHoverHit() {
|
||||
hoverSessionIndex = -1;
|
||||
hoverPoint = null;
|
||||
double plotW = plotWidth();
|
||||
double plotH = plotHeight();
|
||||
if (plotW <= 0 || plotH <= 0 || sessions.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (hoverMouseX < PAD_LEFT || hoverMouseX > PAD_LEFT + plotW
|
||||
|| hoverMouseY < PAD_TOP || hoverMouseY > PAD_TOP + plotH) {
|
||||
return;
|
||||
}
|
||||
double bestDist = 24;
|
||||
for (int si = 0; si < sessions.size(); si++) {
|
||||
SessionDocument doc = sessions.get(si);
|
||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||
if (p.tMs < viewMinT || p.tMs > viewMaxT) {
|
||||
continue;
|
||||
}
|
||||
double v = metricValue(p);
|
||||
if (v < viewMinY || v > viewMaxY) {
|
||||
continue;
|
||||
}
|
||||
double x = toScreenX(p.tMs);
|
||||
double y = toScreenY(v);
|
||||
double d = Math.hypot(hoverMouseX - x, hoverMouseY - y);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
hoverSessionIndex = si;
|
||||
hoverPoint = p;
|
||||
hoverPlotX = x;
|
||||
hoverPlotY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private double plotWidth() {
|
||||
return Math.max(0, canvas.getWidth() - PAD_LEFT - PAD_RIGHT);
|
||||
}
|
||||
|
||||
private double plotHeight() {
|
||||
return Math.max(0, canvas.getHeight() - PAD_TOP - PAD_BOTTOM);
|
||||
}
|
||||
|
||||
private double toScreenX(double tMs) {
|
||||
double plotW = plotWidth();
|
||||
double span = viewMaxT - viewMinT;
|
||||
if (span <= 0) {
|
||||
return PAD_LEFT;
|
||||
}
|
||||
return PAD_LEFT + ((tMs - viewMinT) / span) * plotW;
|
||||
}
|
||||
|
||||
private double toScreenY(double v) {
|
||||
double plotH = plotHeight();
|
||||
double span = viewMaxY - viewMinY;
|
||||
if (span <= 0) {
|
||||
return PAD_TOP + plotH;
|
||||
}
|
||||
return PAD_TOP + plotH - ((v - viewMinY) / span) * plotH;
|
||||
}
|
||||
|
||||
private double metricValue(SessionDocument.SamplePoint p) {
|
||||
switch (metric) {
|
||||
case LOSS:
|
||||
return p.lossRatio * 100.0;
|
||||
case ENCODE_FPS:
|
||||
return p.encodeFps;
|
||||
case BITRATE:
|
||||
default:
|
||||
return p.videoBitrateKbps;
|
||||
}
|
||||
}
|
||||
|
||||
private void draw() {
|
||||
GraphicsContext g = canvas.getGraphicsContext2D();
|
||||
double w = canvas.getWidth();
|
||||
double h = canvas.getHeight();
|
||||
g.setFill(Color.web("#1a1a1a"));
|
||||
g.setFill(Color.web("#121212"));
|
||||
g.fillRect(0, 0, w, h);
|
||||
|
||||
if (sessions.isEmpty()) {
|
||||
g.setFill(Color.LIGHTGRAY);
|
||||
g.setFont(Font.font(14));
|
||||
g.fillText("Open session_*.json (enable Grab session stats on device)", 24, 40);
|
||||
g.fillText("Open session_send_* / session_recv_* JSON (Grab session stats on device)", 24, 40);
|
||||
return;
|
||||
}
|
||||
double pad = 48;
|
||||
double plotW = w - pad * 2;
|
||||
double plotH = h - pad * 2;
|
||||
long tMax = maxTimeMs();
|
||||
g.setStroke(Color.GRAY);
|
||||
g.strokeRect(pad, pad, plotW, plotH);
|
||||
|
||||
int series = 0;
|
||||
double plotW = plotWidth();
|
||||
double plotH = plotHeight();
|
||||
if (plotW <= 1 || plotH <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<ChartTicks.Tick> xTicks = ChartTicks.timeMs(
|
||||
(long) viewMinT, (long) viewMaxT, 8);
|
||||
List<ChartTicks.Tick> yTicks = ChartTicks.linear(viewMinY, viewMaxY, 6);
|
||||
|
||||
g.setStroke(GRID_COLOR);
|
||||
g.setLineWidth(1);
|
||||
for (ChartTicks.Tick t : xTicks) {
|
||||
double x = toScreenX(t.value);
|
||||
g.strokeLine(x, PAD_TOP, x, PAD_TOP + plotH);
|
||||
}
|
||||
for (ChartTicks.Tick t : yTicks) {
|
||||
double y = toScreenY(t.value);
|
||||
g.strokeLine(PAD_LEFT, y, PAD_LEFT + plotW, y);
|
||||
}
|
||||
|
||||
g.setStroke(AXIS_COLOR);
|
||||
g.setLineWidth(1.5);
|
||||
g.strokeLine(PAD_LEFT, PAD_TOP + plotH, PAD_LEFT + plotW, PAD_TOP + plotH);
|
||||
g.strokeLine(PAD_LEFT, PAD_TOP, PAD_LEFT, PAD_TOP + plotH);
|
||||
|
||||
g.setFill(AXIS_COLOR);
|
||||
g.setFont(Font.font(10));
|
||||
for (ChartTicks.Tick t : xTicks) {
|
||||
double x = toScreenX(t.value);
|
||||
g.fillText(t.label, x - 16, PAD_TOP + plotH + 14);
|
||||
}
|
||||
for (ChartTicks.Tick t : yTicks) {
|
||||
double y = toScreenY(t.value);
|
||||
g.fillText(t.label, 4, y + 4);
|
||||
}
|
||||
|
||||
g.setFill(Color.web("#cccccc"));
|
||||
g.setFont(Font.font(11));
|
||||
g.fillText("Time", PAD_LEFT + plotW / 2 - 16, h - 4);
|
||||
g.save();
|
||||
g.translate(12, PAD_TOP + plotH / 2);
|
||||
g.rotate(-90);
|
||||
g.fillText(metric.axisLabel, 0, 0);
|
||||
g.restore();
|
||||
|
||||
for (int si = 0; si < sessions.size(); si++) {
|
||||
SessionDocument doc = sessions.get(si);
|
||||
if (doc.samples.isEmpty()) {
|
||||
if (doc.samples.size() < 2) {
|
||||
continue;
|
||||
}
|
||||
Color color = SERIES_COLORS[si % SERIES_COLORS.length];
|
||||
if (showBitrate) {
|
||||
drawSeries(g, doc, pad, plotW, plotH, tMax, color,
|
||||
p -> p.videoBitrateKbps, 0, 20_000);
|
||||
series++;
|
||||
}
|
||||
if (showRtt) {
|
||||
drawSeries(g, doc, pad, plotW, plotH, tMax, color.deriveColor(0, 1, 1, 0.6),
|
||||
p -> p.rttMs, 0, 500);
|
||||
}
|
||||
if (showLoss) {
|
||||
drawSeries(g, doc, pad, plotW, plotH, tMax, color.deriveColor(30, 1, 1, 0.6),
|
||||
p -> p.lossRatio * 1000, 0, 1000);
|
||||
}
|
||||
drawSessionLine(g, doc, SESSION_COLORS[si % SESSION_COLORS.length]);
|
||||
}
|
||||
if (!hoverText.isEmpty()) {
|
||||
g.setFill(Color.rgb(0, 0, 0, 0.75));
|
||||
g.fillRoundRect(56, 12, Math.min(plotW, 420), 28, 8, 8);
|
||||
g.setFill(Color.WHITE);
|
||||
g.setFont(Font.font(12));
|
||||
g.fillText(hoverText, 64, 30);
|
||||
|
||||
drawLegend(g, plotW);
|
||||
|
||||
if (hoverPoint != null && hoverSessionIndex >= 0) {
|
||||
Color c = SESSION_COLORS[hoverSessionIndex % SESSION_COLORS.length];
|
||||
g.setFill(c);
|
||||
g.fillOval(hoverPlotX - 5, hoverPlotY - 5, 10, 10);
|
||||
g.setStroke(Color.WHITE);
|
||||
g.setLineWidth(1.5);
|
||||
g.strokeOval(hoverPlotX - 5, hoverPlotY - 5, 10, 10);
|
||||
drawHoverPopup(g, c);
|
||||
}
|
||||
g.setFill(Color.LIGHTGRAY);
|
||||
g.setFont(Font.font(11));
|
||||
g.fillText(absoluteTime ? "X: ms from session start" : "X: relative (multi-session)", pad, h - 12);
|
||||
}
|
||||
|
||||
private interface ValueFn {
|
||||
double value(SessionDocument.SamplePoint p);
|
||||
}
|
||||
|
||||
private void drawSeries(GraphicsContext g, SessionDocument doc, double pad, double plotW, double plotH,
|
||||
long tMax, Color color, ValueFn fn, double minY, double maxY) {
|
||||
if (doc.samples.size() < 2) {
|
||||
return;
|
||||
}
|
||||
private void drawSessionLine(GraphicsContext g, SessionDocument doc, Color color) {
|
||||
g.setStroke(color);
|
||||
g.setLineWidth(2);
|
||||
g.beginPath();
|
||||
boolean first = true;
|
||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||
double x = pad + (p.tMs / (double) tMax) * plotW;
|
||||
double v = fn.value(p);
|
||||
double y = pad + plotH - ((v - minY) / (maxY - minY)) * plotH;
|
||||
if (p.tMs < viewMinT || p.tMs > viewMaxT) {
|
||||
continue;
|
||||
}
|
||||
double v = metricValue(p);
|
||||
if (v < viewMinY || v > viewMaxY) {
|
||||
continue;
|
||||
}
|
||||
double x = toScreenX(p.tMs);
|
||||
double y = toScreenY(v);
|
||||
if (first) {
|
||||
g.moveTo(x, y);
|
||||
first = false;
|
||||
@@ -178,4 +360,78 @@ public final class SessionChartPane extends StackPane {
|
||||
}
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
private void drawLegend(GraphicsContext g, double plotW) {
|
||||
double x0 = PAD_LEFT + plotW - 8;
|
||||
double y = PAD_TOP + 8;
|
||||
double rowH = 18;
|
||||
double boxW = 14;
|
||||
int rows = 0;
|
||||
for (SessionDocument doc : sessions) {
|
||||
if (!doc.samples.isEmpty()) {
|
||||
rows++;
|
||||
}
|
||||
}
|
||||
if (rows == 0) {
|
||||
return;
|
||||
}
|
||||
double legendW = 220;
|
||||
double legendH = rows * rowH + 12;
|
||||
x0 -= legendW;
|
||||
g.setFill(Color.rgb(0, 0, 0, 0.72));
|
||||
g.fillRoundRect(x0, y, legendW, legendH, 6, 6);
|
||||
g.setStroke(Color.web("#888888"));
|
||||
g.strokeRoundRect(x0, y, legendW, legendH, 6, 6);
|
||||
|
||||
int row = 0;
|
||||
for (int si = 0; si < sessions.size(); si++) {
|
||||
SessionDocument doc = sessions.get(si);
|
||||
if (doc.samples.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
Color c = SESSION_COLORS[si % SESSION_COLORS.length];
|
||||
double ry = y + 8 + row * rowH;
|
||||
g.setFill(c);
|
||||
g.fillRoundRect(x0 + 8, ry, boxW, boxW - 2, 2, 2);
|
||||
g.setStroke(Color.web("#bbbbbb"));
|
||||
g.strokeRoundRect(x0 + 8, ry, boxW, boxW - 2, 2, 2);
|
||||
boolean bold = si == hoverSessionIndex;
|
||||
g.setFill(Color.WHITE);
|
||||
g.setFont(Font.font("System", bold ? FontWeight.BOLD : FontWeight.NORMAL, 11));
|
||||
String name = doc.path;
|
||||
if (name.length() > 26) {
|
||||
name = "…" + name.substring(name.length() - 25);
|
||||
}
|
||||
g.fillText(name, x0 + 8 + boxW + 6, ry + 11);
|
||||
row++;
|
||||
}
|
||||
}
|
||||
|
||||
private void drawHoverPopup(GraphicsContext g, Color accent) {
|
||||
SessionDocument doc = sessions.get(hoverSessionIndex);
|
||||
String text = String.format(Locale.US,
|
||||
"t=%d ms bitrate=%.0f kbps loss=%.2f%% fps=%.1f",
|
||||
hoverPoint.tMs,
|
||||
hoverPoint.videoBitrateKbps,
|
||||
hoverPoint.lossRatio * 100.0,
|
||||
hoverPoint.encodeFps);
|
||||
double tw = Math.min(plotWidth() - 20, g.getFont().getSize() * 0.55 * text.length() + 20);
|
||||
double th = 28;
|
||||
double px = hoverPlotX + 12;
|
||||
double py = hoverPlotY - th - 12;
|
||||
if (px + tw > PAD_LEFT + plotWidth()) {
|
||||
px = hoverPlotX - tw - 12;
|
||||
}
|
||||
if (py < PAD_TOP) {
|
||||
py = hoverPlotY + 12;
|
||||
}
|
||||
g.setFill(Color.rgb(0, 0, 0, 0.88));
|
||||
g.fillRoundRect(px, py, tw, th, 6, 6);
|
||||
g.setStroke(accent);
|
||||
g.setLineWidth(1);
|
||||
g.strokeRoundRect(px, py, tw, th, 6, 6);
|
||||
g.setFill(Color.WHITE);
|
||||
g.setFont(Font.font(11));
|
||||
g.fillText(text, px + 8, py + 18);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@ import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Parsed session_*.json (aggregate fields + optional time series). */
|
||||
/** Parsed session JSON (aggregate fields + optional time series). */
|
||||
public final class SessionDocument {
|
||||
public final String path;
|
||||
public final String direction;
|
||||
public final String role;
|
||||
public final String transport;
|
||||
public final long startedMs;
|
||||
@@ -20,7 +21,8 @@ public final class SessionDocument {
|
||||
|
||||
private SessionDocument(String path, JSONObject root) {
|
||||
this.path = path;
|
||||
role = root.optString("role", "?");
|
||||
direction = inferDirection(path, root);
|
||||
role = root.optString("role", "recv".equals(direction) ? "receiver" : "sender");
|
||||
transport = root.optString("transport", "?");
|
||||
startedMs = root.optLong("started_at_epoch_ms", 0L);
|
||||
durationMs = root.optLong("duration_ms", 0L);
|
||||
@@ -42,7 +44,23 @@ public final class SessionDocument {
|
||||
}
|
||||
|
||||
public String title() {
|
||||
return path + " (" + role + ", " + transport + ")";
|
||||
return path + " (" + direction + ", " + transport + ")";
|
||||
}
|
||||
|
||||
private static String inferDirection(String fileName, JSONObject root) {
|
||||
String d = root.optString("direction", "");
|
||||
if ("send".equals(d) || "recv".equals(d)) {
|
||||
return d;
|
||||
}
|
||||
if (fileName != null) {
|
||||
if (fileName.startsWith("session_send_")) {
|
||||
return "send";
|
||||
}
|
||||
if (fileName.startsWith("session_recv_")) {
|
||||
return "recv";
|
||||
}
|
||||
}
|
||||
return "receiver".equals(root.optString("role", "")) ? "recv" : "send";
|
||||
}
|
||||
|
||||
public static final class SamplePoint {
|
||||
@@ -50,12 +68,17 @@ public final class SessionDocument {
|
||||
public final double videoBitrateKbps;
|
||||
public final int rttMs;
|
||||
public final double lossRatio;
|
||||
public final double encodeFps;
|
||||
public final int congestionLevel;
|
||||
|
||||
SamplePoint(long tMs, double videoBitrateKbps, int rttMs, double lossRatio) {
|
||||
SamplePoint(long tMs, double videoBitrateKbps, int rttMs, double lossRatio,
|
||||
double encodeFps, int congestionLevel) {
|
||||
this.tMs = tMs;
|
||||
this.videoBitrateKbps = videoBitrateKbps;
|
||||
this.rttMs = rttMs;
|
||||
this.lossRatio = lossRatio;
|
||||
this.encodeFps = encodeFps;
|
||||
this.congestionLevel = congestionLevel;
|
||||
}
|
||||
|
||||
static SamplePoint fromJson(JSONObject o) {
|
||||
@@ -63,7 +86,9 @@ public final class SessionDocument {
|
||||
o.optLong("t_ms", 0L),
|
||||
o.optDouble("video_bitrate_kbps", 0.0),
|
||||
o.optInt("rtt_ms", 0),
|
||||
o.optDouble("loss_ratio", 0.0));
|
||||
o.optDouble("loss_ratio", 0.0),
|
||||
o.optDouble("encode_fps", 0.0),
|
||||
o.optInt("congestion_level", 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package com.foxx.androidcast.studio;
|
||||
|
||||
import javafx.application.Application;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.CheckBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.control.ToggleGroup;
|
||||
import javafx.scene.control.SelectionMode;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
@@ -19,45 +20,71 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Linux desktop session log viewer (foundation for full Android Cast parity on desktop).
|
||||
* Open one or more session_*.json files; charts use {@code samples[]} when present (schema v1+).
|
||||
* Desktop session log viewer — open session_send_* / session_recv_* JSON, overlay charts.
|
||||
*/
|
||||
public class SessionStudioApp extends Application {
|
||||
|
||||
private final List<SessionDocument> sessions = new ArrayList<>();
|
||||
private final SessionChartPane chartPane = new SessionChartPane();
|
||||
private final Label statusLabel = new Label("Open session_*.json files from the device.");
|
||||
private final Label statusLabel = new Label("Open session_send_* / session_recv_* JSON from the device.");
|
||||
|
||||
@Override
|
||||
public void start(Stage stage) {
|
||||
Button openBtn = new Button("Open sessions…");
|
||||
|
||||
CheckBox absTime = new CheckBox("Absolute time (single session)");
|
||||
absTime.setSelected(true);
|
||||
absTime.setOnAction(e -> chartPane.setAbsoluteTime(absTime.isSelected()));
|
||||
|
||||
ToggleGroup metrics = new ToggleGroup();
|
||||
CheckBox metricBitrate = metricToggle(metrics, "Video bitrate", true);
|
||||
CheckBox metricRtt = metricToggle(metrics, "RTT", false);
|
||||
CheckBox metricLoss = metricToggle(metrics, "Loss ratio", false);
|
||||
Runnable refresh = () -> chartPane.render(sessions,
|
||||
metricBitrate.isSelected(), metricRtt.isSelected(), metricLoss.isSelected());
|
||||
metricBitrate.setOnAction(e -> refresh.run());
|
||||
metricRtt.setOnAction(e -> refresh.run());
|
||||
metricLoss.setOnAction(e -> refresh.run());
|
||||
absTime.setOnAction(e -> {
|
||||
chartPane.setAbsoluteTime(absTime.isSelected());
|
||||
refresh.run();
|
||||
});
|
||||
CheckBox metricBitrate = new CheckBox("Video bitrate");
|
||||
metricBitrate.setSelected(true);
|
||||
CheckBox metricLoss = new CheckBox("Packet loss %");
|
||||
CheckBox metricFps = new CheckBox("Encode FPS");
|
||||
|
||||
ListView<String> sessionList = new ListView<>();
|
||||
sessionList.setPrefHeight(120);
|
||||
sessionList.getSelectionModel().selectedIndexProperty().addListener((o, a, b) -> refresh.run());
|
||||
sessionList.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
sessionList.setPrefHeight(100);
|
||||
|
||||
HBox toolbar = new HBox(12, openBtn, absTime, metricBitrate, metricRtt, metricLoss);
|
||||
Runnable refresh = () -> {
|
||||
List<SessionDocument> selected = selectedSessions(sessionList);
|
||||
SessionChartPane.Metric metric = SessionChartPane.Metric.BITRATE;
|
||||
if (metricLoss.isSelected()) {
|
||||
metric = SessionChartPane.Metric.LOSS;
|
||||
} else if (metricFps.isSelected()) {
|
||||
metric = SessionChartPane.Metric.ENCODE_FPS;
|
||||
} else if (metricBitrate.isSelected()) {
|
||||
metric = SessionChartPane.Metric.BITRATE;
|
||||
}
|
||||
chartPane.render(selected, metric);
|
||||
};
|
||||
|
||||
metricBitrate.setOnAction(e -> {
|
||||
if (metricBitrate.isSelected()) {
|
||||
metricLoss.setSelected(false);
|
||||
metricFps.setSelected(false);
|
||||
}
|
||||
refresh.run();
|
||||
});
|
||||
metricLoss.setOnAction(e -> {
|
||||
if (metricLoss.isSelected()) {
|
||||
metricBitrate.setSelected(false);
|
||||
metricFps.setSelected(false);
|
||||
}
|
||||
refresh.run();
|
||||
});
|
||||
metricFps.setOnAction(e -> {
|
||||
if (metricFps.isSelected()) {
|
||||
metricBitrate.setSelected(false);
|
||||
metricLoss.setSelected(false);
|
||||
}
|
||||
refresh.run();
|
||||
});
|
||||
sessionList.getSelectionModel().getSelectedIndices()
|
||||
.addListener((ListChangeListener<Integer>) c -> refresh.run());
|
||||
|
||||
HBox toolbar = new HBox(12, openBtn, metricBitrate, metricLoss, metricFps);
|
||||
toolbar.setStyle("-fx-padding: 8;");
|
||||
|
||||
VBox top = new VBox(8, toolbar, sessionList, statusLabel);
|
||||
Label hint = new Label("Scroll wheel over chart to zoom X+Y. Hover a line for values. RTT removed — use loss % or bitrate.");
|
||||
hint.setStyle("-fx-text-fill: #888; -fx-font-size: 11;");
|
||||
|
||||
VBox top = new VBox(8, toolbar, hint, sessionList, statusLabel);
|
||||
top.setStyle("-fx-padding: 8;");
|
||||
|
||||
BorderPane root = new BorderPane();
|
||||
@@ -67,7 +94,10 @@ public class SessionStudioApp extends Application {
|
||||
openFiles(stage);
|
||||
sessionList.getItems().clear();
|
||||
for (SessionDocument doc : sessions) {
|
||||
sessionList.getItems().add(doc.title());
|
||||
sessionList.getItems().add(doc.path);
|
||||
}
|
||||
if (!sessions.isEmpty()) {
|
||||
sessionList.getSelectionModel().selectAll();
|
||||
}
|
||||
refresh.run();
|
||||
});
|
||||
@@ -77,10 +107,17 @@ public class SessionStudioApp extends Application {
|
||||
stage.show();
|
||||
}
|
||||
|
||||
private static CheckBox metricToggle(ToggleGroup group, String text, boolean selected) {
|
||||
CheckBox box = new CheckBox(text);
|
||||
box.setSelected(selected);
|
||||
return box;
|
||||
private List<SessionDocument> selectedSessions(ListView<String> list) {
|
||||
List<SessionDocument> out = new ArrayList<>();
|
||||
for (int idx : list.getSelectionModel().getSelectedIndices()) {
|
||||
if (idx >= 0 && idx < sessions.size()) {
|
||||
out.add(sessions.get(idx));
|
||||
}
|
||||
}
|
||||
if (out.isEmpty() && !sessions.isEmpty()) {
|
||||
out.addAll(sessions);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void openFiles(Stage stage) {
|
||||
@@ -101,7 +138,7 @@ public class SessionStudioApp extends Application {
|
||||
return;
|
||||
}
|
||||
}
|
||||
statusLabel.setText("Loaded " + sessions.size() + " session file(s).");
|
||||
statusLabel.setText("Loaded " + sessions.size() + " session file(s). Select rows to overlay.");
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
Reference in New Issue
Block a user