Skip to content

Commit 75938f6

Browse files
committed
Update 2.1.0 - See changelog.txt for details
1 parent 12f293f commit 75938f6

17 files changed

+882
-12
lines changed

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@ StreamLight is a fork of [Moonlight](https://github.com/moonlight-stream/moonlig
1414

1515
StreamLight is currently available for **Windows only**.
1616

17-
## ✨ What's New in Version 2.0.1 - "The Sort Fix" (21/03/2026)
17+
## ✨ What's New in Version 2.1.0 — "The Telemetry Update" (27/03/2026)
1818

19-
### 🔧 Improvements
20-
* **App list sort order fixed**: Desktop now always appears first, Steam Big Picture second, then all other apps in alphabetical order — the previous implementation was being overridden by the app model's insertion logic
19+
### 🚀 New Features
20+
* **Session telemetry reporting** — StreamLight streams real-time client-side metrics to StreamTweak during active sessions: FPS, frame drops, RTT, decode latency, and bitrate are sampled every second and transmitted in periodic batches; StreamTweak uses this data to generate a session quality report visible in the Logs tab (requires StreamTweak 5.2.0 or later on the host PC)
21+
22+
### Previously in 2.0.1 — "The Sort Fix"
23+
24+
* **App list sort order fixed**: Desktop now always appears first, Steam Big Picture second, then all other apps in alphabetical order
2125

22-
### Previously in 2.0.0 - "The Library Update"
26+
### Previously in 2.0.0 "The Library Update"
2327

2428
### 🚀 New Features
2529
* **New FoggyBytes icon** — a new app icon visually unifies StreamLight and StreamTweak across the FoggyBytes suite
@@ -61,7 +65,7 @@ Each game synced from StreamTweak's Game Library displays a store badge (icon +
6165

6266
## 🖥️ Requirements
6367

64-
- [StreamTweak](https://github.com/FoggyBytes/StreamTweak) must be installed and running on the **host PC** (5.0.0+ required for store badges; 4.4.0+ for host metrics in the overlay)
68+
- [StreamTweak](https://github.com/FoggyBytes/StreamTweak) must be installed and running on the **host PC** (5.2.0+ for session quality reports; 5.0.0+ for store badges; 4.4.0+ for host metrics in the overlay)
6569
- Windows 10 or later on the **client PC**
6670
- A Sunshine or Apollo-compatible host
6771

StreamLight.iss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
; =====================================================
2-
; StreamLight v2.0.1 - Installer
2+
; StreamLight v2.1.0 - Installer
33
; A Moonlight fork with StreamTweak integration
44
; =====================================================
55
#define AppName "StreamLight"
6-
#define AppVersion "2.0.1"
6+
#define AppVersion "2.1.0"
77
#define AppPublisher "FoggyBytes"
88
#define AppURL "https://github.com/FoggyBytes/StreamLight"
99
#define AppExeName "StreamLight.exe"

app/SessionTelemetrySampler.cpp

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
#include "SessionTelemetrySampler.h"
2+
3+
#include <QJsonArray>
4+
#include <QJsonDocument>
5+
#include <QJsonObject>
6+
7+
#include "streaming/session.h"
8+
#include "streaming/video/ffmpeg.h"
9+
10+
SessionTelemetrySampler::SessionTelemetrySampler(QObject* parent)
11+
: QObject(parent)
12+
{
13+
m_SampleTimer.setInterval(1000);
14+
m_SampleTimer.setSingleShot(false);
15+
connect(&m_SampleTimer, &QTimer::timeout, this, &SessionTelemetrySampler::onSampleTimer);
16+
17+
m_BatchTimer.setInterval(10000);
18+
m_BatchTimer.setSingleShot(false);
19+
connect(&m_BatchTimer, &QTimer::timeout, this, &SessionTelemetrySampler::onBatchTimer);
20+
}
21+
22+
void SessionTelemetrySampler::start(const QString& hostAddress, int targetFps)
23+
{
24+
m_HostAddress = hostAddress;
25+
m_TargetFps = targetFps;
26+
27+
// Start sampling immediately — no session ID negotiation needed.
28+
// StreamTweak accepts batches whenever a session is active, regardless
29+
// of NIC throttle mode. Telemetry is fully independent of streaming settings.
30+
m_SampleTimer.start();
31+
m_BatchTimer.start();
32+
}
33+
34+
void SessionTelemetrySampler::onSampleTimer()
35+
{
36+
Session* session = Session::get();
37+
if (!session) return;
38+
39+
// Retrieve last-window stats via the thread-safe accessor
40+
SDL_LockMutex(session->decoderLock());
41+
IVideoDecoder* dec = session->videoDecoder();
42+
TelemetryWindowStats ws = {};
43+
if (dec) {
44+
auto* ffDec = dynamic_cast<FFmpegVideoDecoder*>(dec);
45+
if (ffDec)
46+
ws = ffDec->getLastWindowStats();
47+
}
48+
SDL_UnlockMutex(session->decoderLock());
49+
50+
// Skip the sample if the decoder window hasn't been filled yet (first second).
51+
// fpsAvg == 0 means no frames have been rendered; including it would corrupt
52+
// the batch average and force fps_min to 0 for the entire batch.
53+
if (ws.fpsAvg == 0.0) return;
54+
55+
TelemetrySample s;
56+
s.fpsAvg = (float)ws.fpsAvg;
57+
s.fpsMin = s.fpsAvg; // will be refined across batch as running min
58+
s.drops = ws.drops;
59+
s.rttAvg = (float)ws.rttAvgMs;
60+
s.rttMax = s.rttAvg; // will be refined across batch as running max
61+
s.decodeMs = ws.decodeAvgMs;
62+
s.bitrateMbps = ws.bitrateMbps;
63+
64+
// Track running min/max within the current batch
65+
if (s.fpsAvg < m_BatchFpsMin) m_BatchFpsMin = s.fpsAvg;
66+
if (s.rttAvg > m_BatchRttMax) m_BatchRttMax = s.rttAvg;
67+
68+
m_Samples.append(s);
69+
}
70+
71+
void SessionTelemetrySampler::onBatchTimer()
72+
{
73+
sendBatch();
74+
}
75+
76+
void SessionTelemetrySampler::flushAndStop()
77+
{
78+
m_SampleTimer.stop();
79+
m_BatchTimer.stop();
80+
81+
if (m_Samples.isEmpty()) return;
82+
83+
// Apply batch-level min/max before the final send
84+
for (auto& s : m_Samples) {
85+
s.fpsMin = m_BatchFpsMin;
86+
s.rttMax = m_BatchRttMax;
87+
}
88+
89+
// Use synchronous send: exec() has returned and the Qt event loop is no
90+
// longer pumping, so the async socket in sendSessionData() would never
91+
// complete. sendSessionDataSync() blocks until the data is written.
92+
m_Bridge.sendSessionDataSync(m_HostAddress, buildBatchJson());
93+
m_Samples.clear();
94+
}
95+
96+
void SessionTelemetrySampler::sendBatch()
97+
{
98+
if (m_Samples.isEmpty()) return;
99+
100+
// Apply batch-level min/max to each sample's fpsMin and rttMax fields
101+
for (auto& s : m_Samples) {
102+
s.fpsMin = m_BatchFpsMin;
103+
s.rttMax = m_BatchRttMax;
104+
}
105+
106+
QString json = buildBatchJson();
107+
m_Bridge.sendSessionData(m_HostAddress, json);
108+
109+
m_Samples.clear();
110+
m_BatchFpsMin = 9999.0f;
111+
m_BatchRttMax = -1.0f;
112+
}
113+
114+
QString SessionTelemetrySampler::buildBatchJson() const
115+
{
116+
QJsonArray samplesArray;
117+
for (const auto& s : m_Samples) {
118+
QJsonObject obj;
119+
obj[QStringLiteral("fps_avg")] = qRound(s.fpsAvg * 10) / 10.0;
120+
obj[QStringLiteral("fps_min")] = (int)s.fpsMin;
121+
obj[QStringLiteral("drops")] = s.drops;
122+
obj[QStringLiteral("rtt_avg")] = qRound(s.rttAvg * 10) / 10.0;
123+
obj[QStringLiteral("rtt_max")] = qRound(s.rttMax * 10) / 10.0;
124+
obj[QStringLiteral("decode_ms")] = qRound(s.decodeMs * 10) / 10.0;
125+
obj[QStringLiteral("bitrate_mbps")] = qRound(s.bitrateMbps * 10) / 10.0;
126+
samplesArray.append(obj);
127+
}
128+
129+
QJsonObject root;
130+
root[QStringLiteral("target_fps")] = m_TargetFps;
131+
root[QStringLiteral("samples")] = samplesArray;
132+
133+
// Compact serialization — no embedded newlines (required by bridge protocol)
134+
return QString::fromUtf8(
135+
QJsonDocument(root).toJson(QJsonDocument::Compact));
136+
}

app/SessionTelemetrySampler.h

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#pragma once
2+
3+
#include <QObject>
4+
#include <QTimer>
5+
#include <QList>
6+
#include <QString>
7+
8+
#include "StreamTweakBridge.h"
9+
10+
/**
11+
* SessionTelemetrySampler
12+
*
13+
* Collects per-second client-side streaming metrics and sends them to
14+
* StreamTweak every 10 seconds via the SESSIONDATA TCP command.
15+
*
16+
* Lifecycle: created as a Qt child of Session in the Session constructor.
17+
* Call start() after the stream is up, flushAndStop() before decoder teardown.
18+
*/
19+
class SessionTelemetrySampler : public QObject
20+
{
21+
Q_OBJECT
22+
23+
public:
24+
explicit SessionTelemetrySampler(QObject* parent = nullptr);
25+
26+
/**
27+
* Begin sampling. Requests the session ID from StreamTweak, then starts
28+
* the 1s sample timer and the 10s batch timer.
29+
*/
30+
void start(const QString& hostAddress, int targetFps);
31+
32+
/**
33+
* Send any buffered samples as a final batch, then stop all timers.
34+
* Must be called before the video decoder is destroyed.
35+
*/
36+
void flushAndStop();
37+
38+
private slots:
39+
void onSampleTimer();
40+
void onBatchTimer();
41+
42+
private:
43+
struct TelemetrySample {
44+
float fpsAvg;
45+
float fpsMin;
46+
int drops;
47+
float rttAvg;
48+
float rttMax;
49+
float decodeMs;
50+
float bitrateMbps;
51+
};
52+
53+
QString buildBatchJson() const;
54+
void sendBatch();
55+
56+
StreamTweakBridge m_Bridge;
57+
QTimer m_SampleTimer; // fires every 1 s
58+
QTimer m_BatchTimer; // fires every 10 s
59+
60+
QString m_HostAddress;
61+
int m_TargetFps = 0;
62+
63+
QList<TelemetrySample> m_Samples;
64+
65+
// Running min/max within the current batch (reset each flush)
66+
float m_BatchFpsMin = 9999.0f;
67+
float m_BatchRttMax = -1.0f;
68+
};

app/StreamTweakBridge.cpp

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,78 @@ void StreamTweakBridge::requestStats(const QString& hostAddress)
9898
socket->connectToHost(hostAddress, BridgePort);
9999
}
100100

101+
void StreamTweakBridge::requestSessionId(const QString& hostAddress)
102+
{
103+
QTcpSocket* socket = new QTcpSocket();
104+
105+
QObject::connect(socket, &QTcpSocket::disconnected,
106+
socket, &QObject::deleteLater);
107+
108+
QObject::connect(socket, &QAbstractSocket::errorOccurred,
109+
[this, socket](QAbstractSocket::SocketError) {
110+
emit sessionIdReceived(QString());
111+
socket->deleteLater();
112+
});
113+
114+
QObject::connect(socket, &QTcpSocket::connected, [socket]() {
115+
QTextStream stream(socket);
116+
stream << "SESSIONID\n";
117+
stream.flush();
118+
});
119+
120+
QObject::connect(socket, &QTcpSocket::readyRead, [this, socket]() {
121+
QString response = QString::fromUtf8(socket->readAll()).trimmed();
122+
emit sessionIdReceived(response);
123+
socket->disconnectFromHost();
124+
});
125+
126+
socket->connectToHost(hostAddress, BridgePort);
127+
}
128+
129+
void StreamTweakBridge::sendSessionData(const QString& hostAddress, const QString& jsonPayload)
130+
{
131+
// Protocol: send "SESSIONDATA\n" then "<compact-json>\n" on the same connection.
132+
// The server reads two lines: command then payload.
133+
QTcpSocket* socket = new QTcpSocket();
134+
135+
QObject::connect(socket, &QTcpSocket::disconnected,
136+
socket, &QObject::deleteLater);
137+
QObject::connect(socket, &QAbstractSocket::errorOccurred,
138+
socket, &QObject::deleteLater);
139+
140+
QObject::connect(socket, &QTcpSocket::connected, [socket, jsonPayload]() {
141+
QTextStream stream(socket);
142+
stream << "SESSIONDATA\n";
143+
stream << jsonPayload << "\n";
144+
stream.flush();
145+
});
146+
147+
QObject::connect(socket, &QTcpSocket::readyRead, [socket]() {
148+
socket->readAll(); // discard OK/ERR
149+
socket->disconnectFromHost();
150+
});
151+
152+
socket->connectToHost(hostAddress, BridgePort);
153+
}
154+
155+
void StreamTweakBridge::sendSessionDataSync(const QString& hostAddress, const QString& jsonPayload)
156+
{
157+
// Synchronous send: used only for the final flush in flushAndStop(), where
158+
// exec() has already returned and the Qt event loop is not running, so an
159+
// async socket would never complete its connected/readyRead cycle.
160+
QTcpSocket socket;
161+
socket.connectToHost(hostAddress, BridgePort);
162+
if (!socket.waitForConnected(2000))
163+
return;
164+
165+
QTextStream stream(&socket);
166+
stream << "SESSIONDATA\n" << jsonPayload << "\n";
167+
stream.flush();
168+
169+
socket.waitForBytesWritten(2000);
170+
socket.disconnectFromHost();
171+
}
172+
101173
void StreamTweakBridge::requestAppStores(const QString& hostAddress)
102174
{
103175
QTcpSocket* socket = new QTcpSocket();

app/StreamTweakBridge.h

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,33 @@ class StreamTweakBridge : public QObject
5959
*/
6060
void requestAppStores(const QString& hostAddress);
6161

62+
/**
63+
* Asynchronously requests the active session ID from StreamTweak.
64+
* Emits sessionIdReceived(QString) with the ID string, or "NONE" if no
65+
* session is currently active, or "" on connection error.
66+
*/
67+
void requestSessionId(const QString& hostAddress);
68+
69+
/**
70+
* Sends a SESSIONDATA batch to StreamTweak. The payload must be a compact
71+
* JSON string (no embedded newlines). Fire-and-forget; response is discarded.
72+
*/
73+
void sendSessionData(const QString& hostAddress, const QString& jsonPayload);
74+
75+
/**
76+
* Synchronous variant of sendSessionData. Blocks until the data is written
77+
* or the timeout expires. Use only for the final flush at session end, where
78+
* the Qt event loop is not running and async sockets would never fire.
79+
*/
80+
void sendSessionDataSync(const QString& hostAddress, const QString& jsonPayload);
81+
6282
static constexpr quint16 BridgePort = 47998;
6383

6484
signals:
6585
void statusReceived(const QString& status);
6686
void statsReceived(const QString& statsJson);
6787
void appStoresReceived(const QString& storesJson);
88+
void sessionIdReceived(const QString& sessionId);
6889

6990
private:
7091
void sendCommand(const QString& hostAddress, const QString& command);

app/app.pro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ SOURCES += \
190190
gui/appmodel.cpp \
191191
StreamTweakBridge.cpp \
192192
HostMetricsPoller.cpp \
193+
SessionTelemetrySampler.cpp \
193194
streaming/bandwidth.cpp \
194195
streaming/streamutils.cpp \
195196
path.cpp \
@@ -228,6 +229,7 @@ HEADERS += \
228229
gui/appmodel.h \
229230
StreamTweakBridge.h \
230231
HostMetricsPoller.h \
232+
SessionTelemetrySampler.h \
231233
streaming/video/decoder.h \
232234
streaming/bandwidth.h \
233235
streaming/streamutils.h \

app/streaming/bandwidth.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ void BandwidthTracker::AddBytes(size_t bytes) {
2222

2323
// We don't want to average the entire window used for peak,
2424
// so average only the newest 25% of complete buckets
25-
double BandwidthTracker::GetAverageMbps() {
25+
double BandwidthTracker::GetAverageMbps() const {
2626
std::lock_guard<std::mutex> lock(mtx);
2727
auto now = steady_clock::now();
2828
auto ms = duration_cast<milliseconds>(now.time_since_epoch());

0 commit comments

Comments
 (0)