1
0
mirror of git://f0xx.org/ac/ac-session-studio synced 2026-07-29 04:18:47 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:21:12 +02:00
commit 850c6cabe8
10 changed files with 1094 additions and 0 deletions

6
README.md Normal file
View File

@@ -0,0 +1,6 @@
# ac-session-studio
Desktop session chart analyzer (JavaFX).
- **Remote:** git://f0xx.org/ac/ac-session-studio
- **Build:** ./gradlew test (JDK 21)

23
build.gradle Normal file
View 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'
}

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

7
settings.gradle Normal file
View File

@@ -0,0 +1,7 @@
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = "ac-session-studio"

View File

@@ -0,0 +1,89 @@
package com.foxx.androidcast.studio;
/*********************************************************************
* ChartTicks.java
* Created at: Tue 19 May 2026 19:43:06 +0200
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 85 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 d89df04075ef5ccfa62dfa6d80a875cab5ff4ef3892cd22157c246e5128e723d
**********************************************************************/
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

@@ -0,0 +1,448 @@
package com.foxx.androidcast.studio;
/*********************************************************************
* SessionChartPane.java
* Created at: Mon 18 May 2026 18:26:28 +0200
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* class SessionChartPane
* Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (3 commits, 444 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 b481c013163b99f872c4174d6b6ad9505f01dfeae596bbb601e448983be17d8a
**********************************************************************/
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-session line chart: zoom, grid, legend, hover marker + tooltip. */
public final class SessionChartPane extends StackPane {
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 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());
canvas.addEventHandler(MouseEvent.MOUSE_MOVED, this::onMouseMoved);
canvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> clearHover());
canvas.addEventHandler(ScrollEvent.SCROLL, this::onScroll);
}
public void render(List<SessionDocument> docs, Metric metric) {
sessions.clear();
if (docs != null) {
sessions.addAll(docs);
}
this.metric = metric != null ? metric : Metric.BITRATE;
resetView();
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 dataMaxTimeMs() {
long max = 1;
for (SessionDocument doc : sessions) {
for (SessionDocument.SamplePoint p : doc.samples) {
max = Math.max(max, p.tMs);
}
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("#121212"));
g.fillRect(0, 0, w, h);
if (sessions.isEmpty()) {
g.setFill(Color.LIGHTGRAY);
g.setFont(Font.font(14));
g.fillText("Open session_send_* / session_recv_* JSON (Grab session stats on device)", 24, 40);
return;
}
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.size() < 2) {
continue;
}
drawSessionLine(g, doc, SESSION_COLORS[si % SESSION_COLORS.length]);
}
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);
}
}
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) {
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;
} else {
g.lineTo(x, y);
}
}
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

@@ -0,0 +1,105 @@
package com.foxx.androidcast.studio;
/*********************************************************************
* SessionDocument.java
* Created at: Mon 18 May 2026 18:26:28 +0200
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* class SessionDocument
* Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (3 commits, 101 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 56deae476a4589f4802783a7f6f20383897b4fc6d04c153734fa13ffdfe519c1
**********************************************************************/
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 direction;
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;
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);
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 + " (" + 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 {
public final long tMs;
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,
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) {
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),
o.optDouble("encode_fps", 0.0),
o.optInt("congestion_level", 0));
}
}
}

View File

@@ -0,0 +1,158 @@
package com.foxx.androidcast.studio;
/*********************************************************************
* SessionStudioApp.java
* Created at: Mon 18 May 2026 18:26:28 +0200
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* class SessionStudioApp
* Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (3 commits, 154 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 92d8059db3ad304cc533ac1c765606f42a06a50f482508fe3d2abbb422292c94
**********************************************************************/
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.SelectionMode;
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;
/**
* 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_send_* / session_recv_* JSON from the device.");
@Override
public void start(Stage stage) {
Button openBtn = new Button("Open sessions…");
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.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
sessionList.setPrefHeight(100);
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;");
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();
root.setTop(top);
root.setCenter(chartPane);
openBtn.setOnAction(e -> {
openFiles(stage);
sessionList.getItems().clear();
for (SessionDocument doc : sessions) {
sessionList.getItems().add(doc.path);
}
if (!sessions.isEmpty()) {
sessionList.getSelectionModel().selectAll();
}
refresh.run();
});
stage.setTitle("Android Cast Session Studio");
stage.setScene(new Scene(root, 960, 640));
stage.show();
}
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) {
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). Select rows to overlay.");
}
public static void main(String[] args) {
launch(args);
}
}