mirror of
git://f0xx.org/android_cast
synced 2026-07-29 04:18:09 +03:00
@@ -27,7 +27,8 @@
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -37,7 +38,7 @@
|
||||
<activity
|
||||
android:name=".sender.SenderActivity"
|
||||
android:exported="false"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density" />
|
||||
|
||||
<activity
|
||||
android:name=".receiver.ReceiverActivity"
|
||||
|
||||
@@ -7,7 +7,7 @@ import android.content.Context;
|
||||
public class AndroidCastApplication extends Application {
|
||||
@Override
|
||||
protected void attachBaseContext(Context base) {
|
||||
super.attachBaseContext(CastLocaleHelper.attach(base));
|
||||
super.attachBaseContext(base);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -49,6 +49,8 @@ public class ReceiverCastService extends Service {
|
||||
private static final String TAG = "ReceiverCastService";
|
||||
public static final String ACTION_START = "com.foxx.androidcast.RECEIVER_START";
|
||||
public static final String ACTION_STOP = "com.foxx.androidcast.STOP_RECV";
|
||||
public static final String ACTION_PLAYBACK_UI = "com.foxx.androidcast.RECV_PLAYBACK_UI";
|
||||
public static final String EXTRA_PLAYBACK_UI_VISIBLE = "playback_ui_visible";
|
||||
public static final String EXTRA_PIN = "pin";
|
||||
public static final String EXTRA_ALLOW_AUDIO = "allow_audio";
|
||||
|
||||
@@ -82,6 +84,8 @@ public class ReceiverCastService extends Service {
|
||||
private volatile boolean reconfiguring;
|
||||
private volatile boolean awaitingKeyframe = true;
|
||||
private boolean playbackLaunched;
|
||||
/** True while {@link ReceiverPlaybackActivity} is in the foreground (started/stopped). */
|
||||
private volatile boolean playbackUiVisible;
|
||||
private int decoderWidth;
|
||||
private int decoderHeight;
|
||||
private boolean decoderActive;
|
||||
@@ -174,6 +178,13 @@ public class ReceiverCastService extends Service {
|
||||
stopSelfSafely();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
if (intent != null && ACTION_PLAYBACK_UI.equals(intent.getAction())) {
|
||||
playbackUiVisible = intent.getBooleanExtra(EXTRA_PLAYBACK_UI_VISIBLE, false);
|
||||
if (!playbackUiVisible) {
|
||||
playbackLaunched = false;
|
||||
}
|
||||
return START_STICKY;
|
||||
}
|
||||
if (intent != null && ACTION_START.equals(intent.getAction())) {
|
||||
String pin = intent.getStringExtra(EXTRA_PIN);
|
||||
if (pin == null) {
|
||||
@@ -575,6 +586,9 @@ public class ReceiverCastService extends Service {
|
||||
|
||||
/** Opens or refreshes fullscreen UI with robot overlay (listening / waiting for stream). */
|
||||
private void openPlaybackAwaiting(int messageResId) {
|
||||
if (!playbackUiVisible) {
|
||||
return;
|
||||
}
|
||||
playbackLaunched = true;
|
||||
Intent intent = new Intent(this, ReceiverPlaybackActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
@@ -584,12 +598,15 @@ public class ReceiverCastService extends Service {
|
||||
}
|
||||
|
||||
private void notifyPlaybackDimensions(int width, int height) {
|
||||
broadcastStreamStarted(width, height);
|
||||
if (!playbackUiVisible) {
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(this, ReceiverPlaybackActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
intent.putExtra(ReceiverPlaybackActivity.EXTRA_WIDTH, width);
|
||||
intent.putExtra(ReceiverPlaybackActivity.EXTRA_HEIGHT, height);
|
||||
startActivity(intent);
|
||||
broadcastStreamStarted(width, height);
|
||||
}
|
||||
|
||||
private void closePlaybackUi() {
|
||||
@@ -842,70 +859,78 @@ public class ReceiverCastService extends Service {
|
||||
}
|
||||
|
||||
private void broadcastStatus(String s) {
|
||||
int n = callbacks.beginBroadcast();
|
||||
for (int i = 0; i < n; i++) {
|
||||
broadcastToCallbacks(cb -> {
|
||||
try {
|
||||
callbacks.getBroadcastItem(i).onStatus(s);
|
||||
cb.onStatus(s);
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
}
|
||||
callbacks.finishBroadcast();
|
||||
});
|
||||
}
|
||||
|
||||
private void broadcastAuthenticated(String name) {
|
||||
int n = callbacks.beginBroadcast();
|
||||
for (int i = 0; i < n; i++) {
|
||||
broadcastToCallbacks(cb -> {
|
||||
try {
|
||||
callbacks.getBroadcastItem(i).onAuthenticated(name);
|
||||
cb.onAuthenticated(name);
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
}
|
||||
callbacks.finishBroadcast();
|
||||
});
|
||||
}
|
||||
|
||||
private void broadcastStreamStarted(int w, int h) {
|
||||
int n = callbacks.beginBroadcast();
|
||||
for (int i = 0; i < n; i++) {
|
||||
broadcastToCallbacks(cb -> {
|
||||
try {
|
||||
callbacks.getBroadcastItem(i).onStreamStarted(w, h);
|
||||
cb.onStreamStarted(w, h);
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
}
|
||||
callbacks.finishBroadcast();
|
||||
});
|
||||
}
|
||||
|
||||
private void broadcastVideoRendering() {
|
||||
int n = callbacks.beginBroadcast();
|
||||
for (int i = 0; i < n; i++) {
|
||||
broadcastToCallbacks(cb -> {
|
||||
try {
|
||||
callbacks.getBroadcastItem(i).onVideoRendering();
|
||||
cb.onVideoRendering();
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
}
|
||||
callbacks.finishBroadcast();
|
||||
});
|
||||
}
|
||||
|
||||
private void broadcastStreamIdle() {
|
||||
int n = callbacks.beginBroadcast();
|
||||
for (int i = 0; i < n; i++) {
|
||||
broadcastToCallbacks(cb -> {
|
||||
try {
|
||||
callbacks.getBroadcastItem(i).onStreamIdle();
|
||||
cb.onStreamIdle();
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
}
|
||||
callbacks.finishBroadcast();
|
||||
});
|
||||
}
|
||||
|
||||
private void broadcastDisconnected() {
|
||||
broadcastToCallbacks(cb -> {
|
||||
try {
|
||||
cb.onDisconnected();
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private interface CallbackAction {
|
||||
void dispatch(ICastStatusCallback callback) throws RemoteException;
|
||||
}
|
||||
|
||||
private void broadcastToCallbacks(CallbackAction action) {
|
||||
synchronized (callbacks) {
|
||||
int n = callbacks.beginBroadcast();
|
||||
try {
|
||||
for (int i = 0; i < n; i++) {
|
||||
try {
|
||||
callbacks.getBroadcastItem(i).onDisconnected();
|
||||
action.dispatch(callbacks.getBroadcastItem(i));
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
callbacks.finishBroadcast();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseAll() {
|
||||
finishSessionStatsAsync();
|
||||
|
||||
@@ -71,6 +71,8 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
|
||||
private ICastReceiverService receiverService;
|
||||
private boolean bound;
|
||||
private boolean autoStartListening;
|
||||
private boolean listeningCancelled;
|
||||
|
||||
private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
|
||||
@Override
|
||||
@@ -186,7 +188,8 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
applyAspectRatio();
|
||||
updatePipUi();
|
||||
|
||||
if (getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false)) {
|
||||
autoStartListening = getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false);
|
||||
if (autoStartListening) {
|
||||
startListeningService();
|
||||
showAwaitingOverlay(R.string.playback_listening);
|
||||
} else if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) {
|
||||
@@ -291,11 +294,21 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
setPlaybackUiVisible(true);
|
||||
bindService(new Intent(this, ReceiverCastService.class), connection, Context.BIND_AUTO_CREATE);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
setPlaybackUiVisible(false);
|
||||
if (autoStartListening && !listeningCancelled && !castSessionActive && !streamRendering
|
||||
&& bound && receiverService != null) {
|
||||
try {
|
||||
receiverService.stopReceiving();
|
||||
listeningCancelled = true;
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
}
|
||||
if (bound && receiverService != null) {
|
||||
try {
|
||||
receiverService.unregisterCallback(statusCallback);
|
||||
@@ -311,6 +324,13 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
private void setPlaybackUiVisible(boolean visible) {
|
||||
Intent intent = new Intent(this, ReceiverCastService.class);
|
||||
intent.setAction(ReceiverCastService.ACTION_PLAYBACK_UI);
|
||||
intent.putExtra(ReceiverCastService.EXTRA_PLAYBACK_UI_VISIBLE, visible);
|
||||
startService(intent);
|
||||
}
|
||||
|
||||
private void onStreamLost() {
|
||||
streamRendering = false;
|
||||
showAwaitingOverlay(R.string.playback_stream_lost);
|
||||
|
||||
@@ -955,15 +955,20 @@ public class ScreenCastService extends Service implements
|
||||
}
|
||||
|
||||
private void broadcastStatus(String s) {
|
||||
synchronized (callbacks) {
|
||||
int n = callbacks.beginBroadcast();
|
||||
try {
|
||||
for (int i = 0; i < n; i++) {
|
||||
try {
|
||||
callbacks.getBroadcastItem(i).onStatus(s);
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
callbacks.finishBroadcast();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private VideoEncoderSink.Callback buildVideoEncoderCallback(String mime) {
|
||||
return new VideoEncoderSink.Callback() {
|
||||
|
||||
@@ -44,7 +44,14 @@ public class SettingsStringArrayAdapter extends ArrayAdapter<String> {
|
||||
@Override
|
||||
@NonNull
|
||||
public View getView(int position, View convertView, @NonNull ViewGroup parent) {
|
||||
TextView tv = (TextView) super.getView(position, convertView, parent);
|
||||
// Spinner may recycle dropdown rows (FrameLayout) into the closed row — never cast blindly.
|
||||
TextView tv;
|
||||
if (convertView instanceof TextView) {
|
||||
tv = (TextView) convertView;
|
||||
} else {
|
||||
tv = (TextView) LayoutInflater.from(getContext())
|
||||
.inflate(R.layout.spinner_item_settings, parent, false);
|
||||
}
|
||||
tv.setText(getItem(position));
|
||||
tv.setAlpha(isEnabled(position) ? 1f : 0.45f);
|
||||
return tv;
|
||||
|
||||
21
desktop/README.md
Normal file
21
desktop/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Android Cast desktop
|
||||
|
||||
## Session Studio (`session-studio/`)
|
||||
|
||||
JavaFX desktop app for **session log analysis** (Linux `.jar` or native image later).
|
||||
|
||||
```bash
|
||||
./gradlew :session-studio:run
|
||||
```
|
||||
|
||||
Open `session_*.json` from the device (`files/session_stats/`). New captures include a `samples[]` time series (~2 s) for charts.
|
||||
|
||||
### Roadmap (full mobile parity + analytics)
|
||||
|
||||
| Phase | Scope | Estimate |
|
||||
|-------|--------|----------|
|
||||
| 1 | Session Studio charts (overlay, multi-session, export) | 2–3 months |
|
||||
| 2 | Desktop sender (screen capture) + receiver window | 4–6 months |
|
||||
| 3 | Transport/codecs/FEC parity with mobile | 3–4 months |
|
||||
|
||||
Packaging: prefer **jpackage AppImage** over raw JAR for Linux users; JAR remains fine for developers.
|
||||
23
desktop/session-studio/build.gradle
Normal file
23
desktop/session-studio/build.gradle
Normal file
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
id 'application'
|
||||
id 'org.openjfx.javafxplugin' version '0.1.0'
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
javafx {
|
||||
version = '21'
|
||||
modules = ['javafx.controls']
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass = 'com.foxx.androidcast.studio.SessionStudioApp'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.json:json:20240303'
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.foxx.androidcast.studio;
|
||||
|
||||
import javafx.scene.canvas.Canvas;
|
||||
import javafx.scene.canvas.GraphicsContext;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.Font;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Multi-series line chart with hover tooltip for session samples. */
|
||||
public final class SessionChartPane extends StackPane {
|
||||
private static final Color[] SERIES_COLORS = {
|
||||
Color.CORNFLOWERBLUE, Color.ORANGERED, Color.MEDIUMSEAGREEN, Color.GOLD, Color.VIOLET
|
||||
};
|
||||
|
||||
private final Canvas canvas = new Canvas(800, 480);
|
||||
private final List<SessionDocument> sessions = new ArrayList<>();
|
||||
private boolean absoluteTime = true;
|
||||
private boolean showBitrate = true;
|
||||
private boolean showRtt;
|
||||
private boolean showLoss;
|
||||
private String hoverText = "";
|
||||
|
||||
public SessionChartPane() {
|
||||
getChildren().add(canvas);
|
||||
canvas.widthProperty().bind(widthProperty());
|
||||
canvas.heightProperty().bind(heightProperty().subtract(0));
|
||||
canvas.addEventHandler(MouseEvent.MOUSE_MOVED, this::onMouseMoved);
|
||||
canvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> {
|
||||
hoverText = "";
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
public void setAbsoluteTime(boolean absoluteTime) {
|
||||
this.absoluteTime = absoluteTime;
|
||||
}
|
||||
|
||||
public void render(List<SessionDocument> docs, boolean bitrate, boolean rtt, boolean loss) {
|
||||
sessions.clear();
|
||||
if (docs != null) {
|
||||
sessions.addAll(docs);
|
||||
}
|
||||
showBitrate = bitrate;
|
||||
showRtt = rtt;
|
||||
showLoss = loss;
|
||||
draw();
|
||||
}
|
||||
|
||||
private void onMouseMoved(MouseEvent e) {
|
||||
if (sessions.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
double pad = 48;
|
||||
double w = canvas.getWidth() - pad * 2;
|
||||
double h = canvas.getHeight() - pad * 2;
|
||||
if (w <= 0 || h <= 0) {
|
||||
return;
|
||||
}
|
||||
long tMax = maxTimeMs();
|
||||
if (tMax <= 0) {
|
||||
return;
|
||||
}
|
||||
double relX = (e.getX() - pad) / w;
|
||||
if (relX < 0 || relX > 1) {
|
||||
hoverText = "";
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
long tQuery = (long) (relX * tMax);
|
||||
SessionDocument doc = sessions.get(0);
|
||||
SessionDocument.SamplePoint nearest = null;
|
||||
long best = Long.MAX_VALUE;
|
||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||
long d = Math.abs(p.tMs - tQuery);
|
||||
if (d < best) {
|
||||
best = d;
|
||||
nearest = p;
|
||||
}
|
||||
}
|
||||
if (nearest != null) {
|
||||
hoverText = String.format("t=%d ms bitrate=%.0f kbps rtt=%d ms loss=%.3f",
|
||||
nearest.tMs, nearest.videoBitrateKbps, nearest.rttMs, nearest.lossRatio);
|
||||
}
|
||||
draw();
|
||||
}
|
||||
|
||||
private long maxTimeMs() {
|
||||
long max = 1;
|
||||
for (SessionDocument doc : sessions) {
|
||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||
max = Math.max(max, p.tMs);
|
||||
}
|
||||
if (!absoluteTime && doc.durationMs > max) {
|
||||
max = doc.durationMs;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
private void draw() {
|
||||
GraphicsContext g = canvas.getGraphicsContext2D();
|
||||
double w = canvas.getWidth();
|
||||
double h = canvas.getHeight();
|
||||
g.setFill(Color.web("#1a1a1a"));
|
||||
g.fillRect(0, 0, w, h);
|
||||
if (sessions.isEmpty()) {
|
||||
g.setFill(Color.LIGHTGRAY);
|
||||
g.setFont(Font.font(14));
|
||||
g.fillText("Open session_*.json (enable Grab session stats on device)", 24, 40);
|
||||
return;
|
||||
}
|
||||
double pad = 48;
|
||||
double plotW = w - pad * 2;
|
||||
double plotH = h - pad * 2;
|
||||
long tMax = maxTimeMs();
|
||||
g.setStroke(Color.GRAY);
|
||||
g.strokeRect(pad, pad, plotW, plotH);
|
||||
|
||||
int series = 0;
|
||||
for (int si = 0; si < sessions.size(); si++) {
|
||||
SessionDocument doc = sessions.get(si);
|
||||
if (doc.samples.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
Color color = SERIES_COLORS[si % SERIES_COLORS.length];
|
||||
if (showBitrate) {
|
||||
drawSeries(g, doc, pad, plotW, plotH, tMax, color,
|
||||
p -> p.videoBitrateKbps, 0, 20_000);
|
||||
series++;
|
||||
}
|
||||
if (showRtt) {
|
||||
drawSeries(g, doc, pad, plotW, plotH, tMax, color.deriveColor(0, 1, 1, 0.6),
|
||||
p -> p.rttMs, 0, 500);
|
||||
}
|
||||
if (showLoss) {
|
||||
drawSeries(g, doc, pad, plotW, plotH, tMax, color.deriveColor(30, 1, 1, 0.6),
|
||||
p -> p.lossRatio * 1000, 0, 1000);
|
||||
}
|
||||
}
|
||||
if (!hoverText.isEmpty()) {
|
||||
g.setFill(Color.rgb(0, 0, 0, 0.75));
|
||||
g.fillRoundRect(56, 12, Math.min(plotW, 420), 28, 8, 8);
|
||||
g.setFill(Color.WHITE);
|
||||
g.setFont(Font.font(12));
|
||||
g.fillText(hoverText, 64, 30);
|
||||
}
|
||||
g.setFill(Color.LIGHTGRAY);
|
||||
g.setFont(Font.font(11));
|
||||
g.fillText(absoluteTime ? "X: ms from session start" : "X: relative (multi-session)", pad, h - 12);
|
||||
}
|
||||
|
||||
private interface ValueFn {
|
||||
double value(SessionDocument.SamplePoint p);
|
||||
}
|
||||
|
||||
private void drawSeries(GraphicsContext g, SessionDocument doc, double pad, double plotW, double plotH,
|
||||
long tMax, Color color, ValueFn fn, double minY, double maxY) {
|
||||
if (doc.samples.size() < 2) {
|
||||
return;
|
||||
}
|
||||
g.setStroke(color);
|
||||
g.beginPath();
|
||||
boolean first = true;
|
||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||
double x = pad + (p.tMs / (double) tMax) * plotW;
|
||||
double v = fn.value(p);
|
||||
double y = pad + plotH - ((v - minY) / (maxY - minY)) * plotH;
|
||||
if (first) {
|
||||
g.moveTo(x, y);
|
||||
first = false;
|
||||
} else {
|
||||
g.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
g.stroke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.foxx.androidcast.studio;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Parsed session_*.json (aggregate fields + optional time series). */
|
||||
public final class SessionDocument {
|
||||
public final String path;
|
||||
public final String role;
|
||||
public final String transport;
|
||||
public final long startedMs;
|
||||
public final long durationMs;
|
||||
public final List<SamplePoint> samples = new ArrayList<>();
|
||||
|
||||
private SessionDocument(String path, JSONObject root) {
|
||||
this.path = path;
|
||||
role = root.optString("role", "?");
|
||||
transport = root.optString("transport", "?");
|
||||
startedMs = root.optLong("started_at_epoch_ms", 0L);
|
||||
durationMs = root.optLong("duration_ms", 0L);
|
||||
JSONArray arr = root.optJSONArray("samples");
|
||||
if (arr != null) {
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
JSONObject o = arr.optJSONObject(i);
|
||||
if (o != null) {
|
||||
samples.add(SamplePoint.fromJson(o));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static SessionDocument load(Path file) throws Exception {
|
||||
String text = Files.readString(file, StandardCharsets.UTF_8);
|
||||
JSONObject root = new JSONObject(text);
|
||||
return new SessionDocument(file.getFileName().toString(), root);
|
||||
}
|
||||
|
||||
public String title() {
|
||||
return path + " (" + role + ", " + transport + ")";
|
||||
}
|
||||
|
||||
public static final class SamplePoint {
|
||||
public final long tMs;
|
||||
public final double videoBitrateKbps;
|
||||
public final int rttMs;
|
||||
public final double lossRatio;
|
||||
|
||||
SamplePoint(long tMs, double videoBitrateKbps, int rttMs, double lossRatio) {
|
||||
this.tMs = tMs;
|
||||
this.videoBitrateKbps = videoBitrateKbps;
|
||||
this.rttMs = rttMs;
|
||||
this.lossRatio = lossRatio;
|
||||
}
|
||||
|
||||
static SamplePoint fromJson(JSONObject o) {
|
||||
return new SamplePoint(
|
||||
o.optLong("t_ms", 0L),
|
||||
o.optDouble("video_bitrate_kbps", 0.0),
|
||||
o.optInt("rtt_ms", 0),
|
||||
o.optDouble("loss_ratio", 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.foxx.androidcast.studio;
|
||||
|
||||
import javafx.application.Application;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.CheckBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.control.ToggleGroup;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Linux desktop session log viewer (foundation for full Android Cast parity on desktop).
|
||||
* Open one or more session_*.json files; charts use {@code samples[]} when present (schema v1+).
|
||||
*/
|
||||
public class SessionStudioApp extends Application {
|
||||
|
||||
private final List<SessionDocument> sessions = new ArrayList<>();
|
||||
private final SessionChartPane chartPane = new SessionChartPane();
|
||||
private final Label statusLabel = new Label("Open session_*.json files from the device.");
|
||||
|
||||
@Override
|
||||
public void start(Stage stage) {
|
||||
Button openBtn = new Button("Open sessions…");
|
||||
|
||||
CheckBox absTime = new CheckBox("Absolute time (single session)");
|
||||
absTime.setSelected(true);
|
||||
absTime.setOnAction(e -> chartPane.setAbsoluteTime(absTime.isSelected()));
|
||||
|
||||
ToggleGroup metrics = new ToggleGroup();
|
||||
CheckBox metricBitrate = metricToggle(metrics, "Video bitrate", true);
|
||||
CheckBox metricRtt = metricToggle(metrics, "RTT", false);
|
||||
CheckBox metricLoss = metricToggle(metrics, "Loss ratio", false);
|
||||
Runnable refresh = () -> chartPane.render(sessions,
|
||||
metricBitrate.isSelected(), metricRtt.isSelected(), metricLoss.isSelected());
|
||||
metricBitrate.setOnAction(e -> refresh.run());
|
||||
metricRtt.setOnAction(e -> refresh.run());
|
||||
metricLoss.setOnAction(e -> refresh.run());
|
||||
absTime.setOnAction(e -> {
|
||||
chartPane.setAbsoluteTime(absTime.isSelected());
|
||||
refresh.run();
|
||||
});
|
||||
|
||||
ListView<String> sessionList = new ListView<>();
|
||||
sessionList.setPrefHeight(120);
|
||||
sessionList.getSelectionModel().selectedIndexProperty().addListener((o, a, b) -> refresh.run());
|
||||
|
||||
HBox toolbar = new HBox(12, openBtn, absTime, metricBitrate, metricRtt, metricLoss);
|
||||
toolbar.setStyle("-fx-padding: 8;");
|
||||
|
||||
VBox top = new VBox(8, toolbar, sessionList, statusLabel);
|
||||
top.setStyle("-fx-padding: 8;");
|
||||
|
||||
BorderPane root = new BorderPane();
|
||||
root.setTop(top);
|
||||
root.setCenter(chartPane);
|
||||
openBtn.setOnAction(e -> {
|
||||
openFiles(stage);
|
||||
sessionList.getItems().clear();
|
||||
for (SessionDocument doc : sessions) {
|
||||
sessionList.getItems().add(doc.title());
|
||||
}
|
||||
refresh.run();
|
||||
});
|
||||
|
||||
stage.setTitle("Android Cast Session Studio");
|
||||
stage.setScene(new Scene(root, 960, 640));
|
||||
stage.show();
|
||||
}
|
||||
|
||||
private static CheckBox metricToggle(ToggleGroup group, String text, boolean selected) {
|
||||
CheckBox box = new CheckBox(text);
|
||||
box.setSelected(selected);
|
||||
return box;
|
||||
}
|
||||
|
||||
private void openFiles(Stage stage) {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle("Session JSON files");
|
||||
chooser.getExtensionFilters().add(
|
||||
new FileChooser.ExtensionFilter("Session JSON", "*.json"));
|
||||
List<File> files = chooser.showOpenMultipleDialog(stage);
|
||||
if (files == null || files.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
sessions.clear();
|
||||
for (File f : files) {
|
||||
try {
|
||||
sessions.add(SessionDocument.load(Path.of(f.getAbsolutePath())));
|
||||
} catch (Exception ex) {
|
||||
statusLabel.setText("Failed: " + f.getName() + " — " + ex.getMessage());
|
||||
return;
|
||||
}
|
||||
}
|
||||
statusLabel.setText("Loaded " + sessions.size() + " session file(s).");
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
launch(args);
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,7 @@ for ARCH_ABI in ${ARCH_ABIS}; do
|
||||
done
|
||||
|
||||
echo "==> Building debug APK (JAVA_HOME=$JAVA_HOME)"
|
||||
./gradlew --stop
|
||||
./gradlew clean assembleDebug
|
||||
|
||||
if [[ "$RUN_UNIT_TESTS" == "1" ]]; then
|
||||
|
||||
Reference in New Issue
Block a user