mirror of
git://f0xx.org/ac/ac-session-studio
synced 2026-07-29 02:18:33 +03:00
initial
This commit is contained in:
6
README.md
Normal file
6
README.md
Normal 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
23
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'
|
||||
}
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
251
gradlew
vendored
Executable 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
7
settings.gradle
Normal file
@@ -0,0 +1,7 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
rootProject.name = "ac-session-studio"
|
||||
89
src/main/java/com/foxx/androidcast/studio/ChartTicks.java
Normal file
89
src/main/java/com/foxx/androidcast/studio/ChartTicks.java
Normal 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";
|
||||
}
|
||||
}
|
||||
448
src/main/java/com/foxx/androidcast/studio/SessionChartPane.java
Normal file
448
src/main/java/com/foxx/androidcast/studio/SessionChartPane.java
Normal 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);
|
||||
}
|
||||
}
|
||||
105
src/main/java/com/foxx/androidcast/studio/SessionDocument.java
Normal file
105
src/main/java/com/foxx/androidcast/studio/SessionDocument.java
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
158
src/main/java/com/foxx/androidcast/studio/SessionStudioApp.java
Normal file
158
src/main/java/com/foxx/androidcast/studio/SessionStudioApp.java
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user