mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:58:51 +03:00
23
desktop/session-studio/build.gradle
Normal file
23
desktop/session-studio/build.gradle
Normal file
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
id 'application'
|
||||
id 'org.openjfx.javafxplugin' version '0.1.0'
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
javafx {
|
||||
version = '21'
|
||||
modules = ['javafx.controls']
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass = 'com.foxx.androidcast.studio.SessionStudioApp'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.json:json:20240303'
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.foxx.androidcast.studio;
|
||||
|
||||
import javafx.scene.canvas.Canvas;
|
||||
import javafx.scene.canvas.GraphicsContext;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.Font;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Multi-series line chart with hover tooltip for session samples. */
|
||||
public final class SessionChartPane extends StackPane {
|
||||
private static final Color[] SERIES_COLORS = {
|
||||
Color.CORNFLOWERBLUE, Color.ORANGERED, Color.MEDIUMSEAGREEN, Color.GOLD, Color.VIOLET
|
||||
};
|
||||
|
||||
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 = "";
|
||||
|
||||
public SessionChartPane() {
|
||||
getChildren().add(canvas);
|
||||
canvas.widthProperty().bind(widthProperty());
|
||||
canvas.heightProperty().bind(heightProperty().subtract(0));
|
||||
canvas.addEventHandler(MouseEvent.MOUSE_MOVED, this::onMouseMoved);
|
||||
canvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> {
|
||||
hoverText = "";
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
public void setAbsoluteTime(boolean absoluteTime) {
|
||||
this.absoluteTime = absoluteTime;
|
||||
}
|
||||
|
||||
public void render(List<SessionDocument> docs, boolean bitrate, boolean rtt, boolean loss) {
|
||||
sessions.clear();
|
||||
if (docs != null) {
|
||||
sessions.addAll(docs);
|
||||
}
|
||||
showBitrate = bitrate;
|
||||
showRtt = rtt;
|
||||
showLoss = loss;
|
||||
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 long maxTimeMs() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
private void draw() {
|
||||
GraphicsContext g = canvas.getGraphicsContext2D();
|
||||
double w = canvas.getWidth();
|
||||
double h = canvas.getHeight();
|
||||
g.setFill(Color.web("#1a1a1a"));
|
||||
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);
|
||||
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;
|
||||
for (int si = 0; si < sessions.size(); si++) {
|
||||
SessionDocument doc = sessions.get(si);
|
||||
if (doc.samples.isEmpty()) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
g.setStroke(color);
|
||||
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 (first) {
|
||||
g.moveTo(x, y);
|
||||
first = false;
|
||||
} else {
|
||||
g.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
g.stroke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.foxx.androidcast.studio;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Parsed session_*.json (aggregate fields + optional time series). */
|
||||
public final class SessionDocument {
|
||||
public final String path;
|
||||
public final String role;
|
||||
public final String transport;
|
||||
public final long startedMs;
|
||||
public final long durationMs;
|
||||
public final List<SamplePoint> samples = new ArrayList<>();
|
||||
|
||||
private SessionDocument(String path, JSONObject root) {
|
||||
this.path = path;
|
||||
role = root.optString("role", "?");
|
||||
transport = root.optString("transport", "?");
|
||||
startedMs = root.optLong("started_at_epoch_ms", 0L);
|
||||
durationMs = root.optLong("duration_ms", 0L);
|
||||
JSONArray arr = root.optJSONArray("samples");
|
||||
if (arr != null) {
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
JSONObject o = arr.optJSONObject(i);
|
||||
if (o != null) {
|
||||
samples.add(SamplePoint.fromJson(o));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static SessionDocument load(Path file) throws Exception {
|
||||
String text = Files.readString(file, StandardCharsets.UTF_8);
|
||||
JSONObject root = new JSONObject(text);
|
||||
return new SessionDocument(file.getFileName().toString(), root);
|
||||
}
|
||||
|
||||
public String title() {
|
||||
return path + " (" + role + ", " + transport + ")";
|
||||
}
|
||||
|
||||
public static final class SamplePoint {
|
||||
public final long tMs;
|
||||
public final double videoBitrateKbps;
|
||||
public final int rttMs;
|
||||
public final double lossRatio;
|
||||
|
||||
SamplePoint(long tMs, double videoBitrateKbps, int rttMs, double lossRatio) {
|
||||
this.tMs = tMs;
|
||||
this.videoBitrateKbps = videoBitrateKbps;
|
||||
this.rttMs = rttMs;
|
||||
this.lossRatio = lossRatio;
|
||||
}
|
||||
|
||||
static SamplePoint fromJson(JSONObject o) {
|
||||
return new SamplePoint(
|
||||
o.optLong("t_ms", 0L),
|
||||
o.optDouble("video_bitrate_kbps", 0.0),
|
||||
o.optInt("rtt_ms", 0),
|
||||
o.optDouble("loss_ratio", 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.foxx.androidcast.studio;
|
||||
|
||||
import javafx.application.Application;
|
||||
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.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
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+).
|
||||
*/
|
||||
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.");
|
||||
|
||||
@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();
|
||||
});
|
||||
|
||||
ListView<String> sessionList = new ListView<>();
|
||||
sessionList.setPrefHeight(120);
|
||||
sessionList.getSelectionModel().selectedIndexProperty().addListener((o, a, b) -> refresh.run());
|
||||
|
||||
HBox toolbar = new HBox(12, openBtn, absTime, metricBitrate, metricRtt, metricLoss);
|
||||
toolbar.setStyle("-fx-padding: 8;");
|
||||
|
||||
VBox top = new VBox(8, toolbar, sessionList, statusLabel);
|
||||
top.setStyle("-fx-padding: 8;");
|
||||
|
||||
BorderPane root = new BorderPane();
|
||||
root.setTop(top);
|
||||
root.setCenter(chartPane);
|
||||
openBtn.setOnAction(e -> {
|
||||
openFiles(stage);
|
||||
sessionList.getItems().clear();
|
||||
for (SessionDocument doc : sessions) {
|
||||
sessionList.getItems().add(doc.title());
|
||||
}
|
||||
refresh.run();
|
||||
});
|
||||
|
||||
stage.setTitle("Android Cast Session Studio");
|
||||
stage.setScene(new Scene(root, 960, 640));
|
||||
stage.show();
|
||||
}
|
||||
|
||||
private static CheckBox metricToggle(ToggleGroup group, String text, boolean selected) {
|
||||
CheckBox box = new CheckBox(text);
|
||||
box.setSelected(selected);
|
||||
return box;
|
||||
}
|
||||
|
||||
private void openFiles(Stage stage) {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle("Session JSON files");
|
||||
chooser.getExtensionFilters().add(
|
||||
new FileChooser.ExtensionFilter("Session JSON", "*.json"));
|
||||
List<File> files = chooser.showOpenMultipleDialog(stage);
|
||||
if (files == null || files.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
sessions.clear();
|
||||
for (File f : files) {
|
||||
try {
|
||||
sessions.add(SessionDocument.load(Path.of(f.getAbsolutePath())));
|
||||
} catch (Exception ex) {
|
||||
statusLabel.setText("Failed: " + f.getName() + " — " + ex.getMessage());
|
||||
return;
|
||||
}
|
||||
}
|
||||
statusLabel.setText("Loaded " + sessions.size() + " session file(s).");
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
launch(args);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user