mirror of
git://f0xx.org/android_cast
synced 2026-07-29 04:57:40 +03:00
@@ -27,7 +27,8 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".sender.SenderActivity"
|
android:name=".sender.SenderActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".receiver.ReceiverActivity"
|
android:name=".receiver.ReceiverActivity"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import android.content.Context;
|
|||||||
public class AndroidCastApplication extends Application {
|
public class AndroidCastApplication extends Application {
|
||||||
@Override
|
@Override
|
||||||
protected void attachBaseContext(Context base) {
|
protected void attachBaseContext(Context base) {
|
||||||
super.attachBaseContext(CastLocaleHelper.attach(base));
|
super.attachBaseContext(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ public class ReceiverCastService extends Service {
|
|||||||
private static final String TAG = "ReceiverCastService";
|
private static final String TAG = "ReceiverCastService";
|
||||||
public static final String ACTION_START = "com.foxx.androidcast.RECEIVER_START";
|
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_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_PIN = "pin";
|
||||||
public static final String EXTRA_ALLOW_AUDIO = "allow_audio";
|
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 reconfiguring;
|
||||||
private volatile boolean awaitingKeyframe = true;
|
private volatile boolean awaitingKeyframe = true;
|
||||||
private boolean playbackLaunched;
|
private boolean playbackLaunched;
|
||||||
|
/** True while {@link ReceiverPlaybackActivity} is in the foreground (started/stopped). */
|
||||||
|
private volatile boolean playbackUiVisible;
|
||||||
private int decoderWidth;
|
private int decoderWidth;
|
||||||
private int decoderHeight;
|
private int decoderHeight;
|
||||||
private boolean decoderActive;
|
private boolean decoderActive;
|
||||||
@@ -174,6 +178,13 @@ public class ReceiverCastService extends Service {
|
|||||||
stopSelfSafely();
|
stopSelfSafely();
|
||||||
return START_NOT_STICKY;
|
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())) {
|
if (intent != null && ACTION_START.equals(intent.getAction())) {
|
||||||
String pin = intent.getStringExtra(EXTRA_PIN);
|
String pin = intent.getStringExtra(EXTRA_PIN);
|
||||||
if (pin == null) {
|
if (pin == null) {
|
||||||
@@ -575,6 +586,9 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
/** Opens or refreshes fullscreen UI with robot overlay (listening / waiting for stream). */
|
/** Opens or refreshes fullscreen UI with robot overlay (listening / waiting for stream). */
|
||||||
private void openPlaybackAwaiting(int messageResId) {
|
private void openPlaybackAwaiting(int messageResId) {
|
||||||
|
if (!playbackUiVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
playbackLaunched = true;
|
playbackLaunched = true;
|
||||||
Intent intent = new Intent(this, ReceiverPlaybackActivity.class);
|
Intent intent = new Intent(this, ReceiverPlaybackActivity.class);
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
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) {
|
private void notifyPlaybackDimensions(int width, int height) {
|
||||||
|
broadcastStreamStarted(width, height);
|
||||||
|
if (!playbackUiVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Intent intent = new Intent(this, ReceiverPlaybackActivity.class);
|
Intent intent = new Intent(this, ReceiverPlaybackActivity.class);
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||||
intent.putExtra(ReceiverPlaybackActivity.EXTRA_WIDTH, width);
|
intent.putExtra(ReceiverPlaybackActivity.EXTRA_WIDTH, width);
|
||||||
intent.putExtra(ReceiverPlaybackActivity.EXTRA_HEIGHT, height);
|
intent.putExtra(ReceiverPlaybackActivity.EXTRA_HEIGHT, height);
|
||||||
startActivity(intent);
|
startActivity(intent);
|
||||||
broadcastStreamStarted(width, height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void closePlaybackUi() {
|
private void closePlaybackUi() {
|
||||||
@@ -842,70 +859,78 @@ public class ReceiverCastService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void broadcastStatus(String s) {
|
private void broadcastStatus(String s) {
|
||||||
int n = callbacks.beginBroadcast();
|
broadcastToCallbacks(cb -> {
|
||||||
for (int i = 0; i < n; i++) {
|
|
||||||
try {
|
try {
|
||||||
callbacks.getBroadcastItem(i).onStatus(s);
|
cb.onStatus(s);
|
||||||
} catch (RemoteException ignored) {
|
} catch (RemoteException ignored) {
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
callbacks.finishBroadcast();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void broadcastAuthenticated(String name) {
|
private void broadcastAuthenticated(String name) {
|
||||||
int n = callbacks.beginBroadcast();
|
broadcastToCallbacks(cb -> {
|
||||||
for (int i = 0; i < n; i++) {
|
|
||||||
try {
|
try {
|
||||||
callbacks.getBroadcastItem(i).onAuthenticated(name);
|
cb.onAuthenticated(name);
|
||||||
} catch (RemoteException ignored) {
|
} catch (RemoteException ignored) {
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
callbacks.finishBroadcast();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void broadcastStreamStarted(int w, int h) {
|
private void broadcastStreamStarted(int w, int h) {
|
||||||
int n = callbacks.beginBroadcast();
|
broadcastToCallbacks(cb -> {
|
||||||
for (int i = 0; i < n; i++) {
|
|
||||||
try {
|
try {
|
||||||
callbacks.getBroadcastItem(i).onStreamStarted(w, h);
|
cb.onStreamStarted(w, h);
|
||||||
} catch (RemoteException ignored) {
|
} catch (RemoteException ignored) {
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
callbacks.finishBroadcast();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void broadcastVideoRendering() {
|
private void broadcastVideoRendering() {
|
||||||
int n = callbacks.beginBroadcast();
|
broadcastToCallbacks(cb -> {
|
||||||
for (int i = 0; i < n; i++) {
|
|
||||||
try {
|
try {
|
||||||
callbacks.getBroadcastItem(i).onVideoRendering();
|
cb.onVideoRendering();
|
||||||
} catch (RemoteException ignored) {
|
} catch (RemoteException ignored) {
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
callbacks.finishBroadcast();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void broadcastStreamIdle() {
|
private void broadcastStreamIdle() {
|
||||||
int n = callbacks.beginBroadcast();
|
broadcastToCallbacks(cb -> {
|
||||||
for (int i = 0; i < n; i++) {
|
|
||||||
try {
|
try {
|
||||||
callbacks.getBroadcastItem(i).onStreamIdle();
|
cb.onStreamIdle();
|
||||||
} catch (RemoteException ignored) {
|
} catch (RemoteException ignored) {
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
callbacks.finishBroadcast();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void broadcastDisconnected() {
|
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();
|
int n = callbacks.beginBroadcast();
|
||||||
|
try {
|
||||||
for (int i = 0; i < n; i++) {
|
for (int i = 0; i < n; i++) {
|
||||||
try {
|
try {
|
||||||
callbacks.getBroadcastItem(i).onDisconnected();
|
action.dispatch(callbacks.getBroadcastItem(i));
|
||||||
} catch (RemoteException ignored) {
|
} catch (RemoteException ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
callbacks.finishBroadcast();
|
callbacks.finishBroadcast();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void releaseAll() {
|
private void releaseAll() {
|
||||||
finishSessionStatsAsync();
|
finishSessionStatsAsync();
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
|
|
||||||
private ICastReceiverService receiverService;
|
private ICastReceiverService receiverService;
|
||||||
private boolean bound;
|
private boolean bound;
|
||||||
|
private boolean autoStartListening;
|
||||||
|
private boolean listeningCancelled;
|
||||||
|
|
||||||
private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
|
private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
|
||||||
@Override
|
@Override
|
||||||
@@ -186,7 +188,8 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
applyAspectRatio();
|
applyAspectRatio();
|
||||||
updatePipUi();
|
updatePipUi();
|
||||||
|
|
||||||
if (getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false)) {
|
autoStartListening = getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false);
|
||||||
|
if (autoStartListening) {
|
||||||
startListeningService();
|
startListeningService();
|
||||||
showAwaitingOverlay(R.string.playback_listening);
|
showAwaitingOverlay(R.string.playback_listening);
|
||||||
} else if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) {
|
} else if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) {
|
||||||
@@ -291,11 +294,21 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
@Override
|
@Override
|
||||||
protected void onStart() {
|
protected void onStart() {
|
||||||
super.onStart();
|
super.onStart();
|
||||||
|
setPlaybackUiVisible(true);
|
||||||
bindService(new Intent(this, ReceiverCastService.class), connection, Context.BIND_AUTO_CREATE);
|
bindService(new Intent(this, ReceiverCastService.class), connection, Context.BIND_AUTO_CREATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onStop() {
|
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) {
|
if (bound && receiverService != null) {
|
||||||
try {
|
try {
|
||||||
receiverService.unregisterCallback(statusCallback);
|
receiverService.unregisterCallback(statusCallback);
|
||||||
@@ -311,6 +324,13 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
super.onStop();
|
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() {
|
private void onStreamLost() {
|
||||||
streamRendering = false;
|
streamRendering = false;
|
||||||
showAwaitingOverlay(R.string.playback_stream_lost);
|
showAwaitingOverlay(R.string.playback_stream_lost);
|
||||||
|
|||||||
@@ -955,15 +955,20 @@ public class ScreenCastService extends Service implements
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void broadcastStatus(String s) {
|
private void broadcastStatus(String s) {
|
||||||
|
synchronized (callbacks) {
|
||||||
int n = callbacks.beginBroadcast();
|
int n = callbacks.beginBroadcast();
|
||||||
|
try {
|
||||||
for (int i = 0; i < n; i++) {
|
for (int i = 0; i < n; i++) {
|
||||||
try {
|
try {
|
||||||
callbacks.getBroadcastItem(i).onStatus(s);
|
callbacks.getBroadcastItem(i).onStatus(s);
|
||||||
} catch (RemoteException ignored) {
|
} catch (RemoteException ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
callbacks.finishBroadcast();
|
callbacks.finishBroadcast();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private VideoEncoderSink.Callback buildVideoEncoderCallback(String mime) {
|
private VideoEncoderSink.Callback buildVideoEncoderCallback(String mime) {
|
||||||
return new VideoEncoderSink.Callback() {
|
return new VideoEncoderSink.Callback() {
|
||||||
|
|||||||
@@ -44,7 +44,14 @@ public class SettingsStringArrayAdapter extends ArrayAdapter<String> {
|
|||||||
@Override
|
@Override
|
||||||
@NonNull
|
@NonNull
|
||||||
public View getView(int position, View convertView, @NonNull ViewGroup parent) {
|
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.setText(getItem(position));
|
||||||
tv.setAlpha(isEnabled(position) ? 1f : 0.45f);
|
tv.setAlpha(isEnabled(position) ? 1f : 0.45f);
|
||||||
return tv;
|
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
|
done
|
||||||
|
|
||||||
echo "==> Building debug APK (JAVA_HOME=$JAVA_HOME)"
|
echo "==> Building debug APK (JAVA_HOME=$JAVA_HOME)"
|
||||||
|
./gradlew --stop
|
||||||
./gradlew clean assembleDebug
|
./gradlew clean assembleDebug
|
||||||
|
|
||||||
if [[ "$RUN_UNIT_TESTS" == "1" ]]; then
|
if [[ "$RUN_UNIT_TESTS" == "1" ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user