1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 07:39:15 +03:00
This commit is contained in:
Anton Afanasyeu
2026-05-19 19:43:06 +02:00
parent 35ab5622d7
commit a4107797fa
40 changed files with 2602 additions and 254 deletions

View File

@@ -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)

View File

@@ -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";
}
}

View File

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

View File

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

View File

@@ -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) {