commit 850c6cabe8dc65877d7a29deab484d9c58c25789 Author: Anton Afanasyeu Date: Tue Jun 23 12:21:12 2026 +0200 initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..7678f98 --- /dev/null +++ b/README.md @@ -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) diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..22baf2c --- /dev/null +++ b/build.gradle @@ -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' +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..8bc664e --- /dev/null +++ b/settings.gradle @@ -0,0 +1,7 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} +rootProject.name = "ac-session-studio" diff --git a/src/main/java/com/foxx/androidcast/studio/ChartTicks.java b/src/main/java/com/foxx/androidcast/studio/ChartTicks.java new file mode 100644 index 0000000..0ac9ed5 --- /dev/null +++ b/src/main/java/com/foxx/androidcast/studio/ChartTicks.java @@ -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 + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (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 linear(double min, double max, int maxTicks) { + List 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 timeMs(long minMs, long maxMs, int maxTicks) { + List 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"; + } +} diff --git a/src/main/java/com/foxx/androidcast/studio/SessionChartPane.java b/src/main/java/com/foxx/androidcast/studio/SessionChartPane.java new file mode 100644 index 0000000..ac42693 --- /dev/null +++ b/src/main/java/com/foxx/androidcast/studio/SessionChartPane.java @@ -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 + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * class SessionChartPane + * Contributors: + * - Anton Afanasyeu (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 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 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 xTicks = ChartTicks.timeMs( + (long) viewMinT, (long) viewMaxT, 8); + List 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); + } +} diff --git a/src/main/java/com/foxx/androidcast/studio/SessionDocument.java b/src/main/java/com/foxx/androidcast/studio/SessionDocument.java new file mode 100644 index 0000000..1722f76 --- /dev/null +++ b/src/main/java/com/foxx/androidcast/studio/SessionDocument.java @@ -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 + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * class SessionDocument + * Contributors: + * - Anton Afanasyeu (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 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)); + } + } +} diff --git a/src/main/java/com/foxx/androidcast/studio/SessionStudioApp.java b/src/main/java/com/foxx/androidcast/studio/SessionStudioApp.java new file mode 100644 index 0000000..6684cee --- /dev/null +++ b/src/main/java/com/foxx/androidcast/studio/SessionStudioApp.java @@ -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 + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * class SessionStudioApp + * Contributors: + * - Anton Afanasyeu (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 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 sessionList = new ListView<>(); + sessionList.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + sessionList.setPrefHeight(100); + + Runnable refresh = () -> { + List 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) 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 selectedSessions(ListView list) { + List 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 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); + } +}