From ff963fb7c3b91d6d7df22e2c6aa8687fb18bcdf4 Mon Sep 17 00:00:00 2001 From: Betafer Date: Fri, 21 Aug 2026 01:01:44 +0200 Subject: [PATCH] feat: add live session diagnostics --- TargetBridge-Receiver/TBReceiverC/Makefile | 11 ++- .../TBReceiverC/src/decoder.c | 52 ++++++++++++- .../TBReceiverC/src/decoder.h | 3 + .../TBReceiverC/src/display.c | 9 +++ .../TBReceiverC/src/display.h | 3 + TargetBridge-Receiver/TBReceiverC/src/main.c | 40 +++++++++- TargetBridge-Receiver/TBReceiverC/src/proto.h | 2 + .../TBReceiverC/src/session_metrics.c | 29 +++++++ .../TBReceiverC/src/session_metrics.h | 20 +++++ .../TBReceiverC/tests/test_session_metrics.c | 44 +++++++++++ .../TBDisplaySenderAutomation.swift | 1 + .../TBDisplaySenderContentView.swift | 76 +++++++++++++++---- .../TBDisplaySenderLocalization.swift | 75 ++++++++++++++++++ .../TBDisplaySenderService.swift | 74 +++++++++++++++++- .../TBMonitorProtocolTests.swift | 22 ++++++ .../TBDisplayShared/TBMonitorProtocol.swift | 11 +++ TargetBridge-Shared/Languages/de.json | 19 +++++ TargetBridge-Shared/Languages/en.json | 19 +++++ TargetBridge-Shared/Languages/fr.json | 19 +++++ TargetBridge-Shared/Languages/it.json | 19 +++++ TargetBridge-Shared/Languages/zh.json | 19 +++++ 21 files changed, 541 insertions(+), 26 deletions(-) create mode 100644 TargetBridge-Receiver/TBReceiverC/src/session_metrics.c create mode 100644 TargetBridge-Receiver/TBReceiverC/src/session_metrics.h create mode 100644 TargetBridge-Receiver/TBReceiverC/tests/test_session_metrics.c diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index 26ca207..b5cdac5 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -34,7 +34,7 @@ ifeq ($(UNAME_S),Darwin) LDFLAGS += -framework ApplicationServices -framework AppKit -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework VideoToolbox -framework CoreMedia -framework CoreVideo -framework IOSurface -framework CoreServices -framework CoreAudio -framework SystemConfiguration endif -C_SRC = src/main.c src/net.c src/decoder.c src/display.c src/input_queue.c src/receiver_profile.c src/tb_i18n.c +C_SRC = src/main.c src/net.c src/decoder.c src/display.c src/input_queue.c src/receiver_profile.c src/session_metrics.c src/tb_i18n.c OBJC_SRC = src/tb_gesture_bridge.m src/tb_display_tweaks.m OBJ = $(C_SRC:.c=.o) $(OBJC_SRC:.m=.o) BIN = tbreceiver @@ -51,7 +51,7 @@ $(BIN): $(OBJ) $(CC) $(CFLAGS) -fobjc-arc -c -o $@ $< clean: - rm -f $(OBJ) $(BIN) $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) $(TEST_RECEIVER_PROFILE_BIN) + rm -f $(OBJ) $(BIN) $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) $(TEST_RECEIVER_PROFILE_BIN) $(TEST_SESSION_METRICS_BIN) # Unit tests for the packet parser. net.c is pure POSIX, so this needs no # ffmpeg/SDL/pkgconf — it runs anywhere, including CI, with no hardware. @@ -63,6 +63,7 @@ endif TEST_BIN = test_net_parser TEST_INPUT_QUEUE_BIN = test_input_queue TEST_RECEIVER_PROFILE_BIN = test_receiver_profile +TEST_SESSION_METRICS_BIN = test_session_metrics test_net_parser: tests/test_net_parser.c src/net.c src/net.h src/proto.h $(CC) $(TEST_CFLAGS) -Isrc tests/test_net_parser.c src/net.c $(TEST_LDFLAGS) -o $@ @@ -73,9 +74,13 @@ $(TEST_INPUT_QUEUE_BIN): tests/test_input_queue.c src/input_queue.c src/input_qu $(TEST_RECEIVER_PROFILE_BIN): tests/test_receiver_profile.c src/receiver_profile.c src/receiver_profile.h $(CC) $(TEST_CFLAGS) -Isrc tests/test_receiver_profile.c src/receiver_profile.c -o $@ -test: $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) $(TEST_RECEIVER_PROFILE_BIN) +$(TEST_SESSION_METRICS_BIN): tests/test_session_metrics.c src/session_metrics.c src/session_metrics.h + $(CC) $(TEST_CFLAGS) -Isrc tests/test_session_metrics.c src/session_metrics.c -o $@ + +test: $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) $(TEST_RECEIVER_PROFILE_BIN) $(TEST_SESSION_METRICS_BIN) ./$(TEST_BIN) ./$(TEST_INPUT_QUEUE_BIN) ./$(TEST_RECEIVER_PROFILE_BIN) + ./$(TEST_SESSION_METRICS_BIN) .PHONY: all clean test diff --git a/TargetBridge-Receiver/TBReceiverC/src/decoder.c b/TargetBridge-Receiver/TBReceiverC/src/decoder.c index dd078d0..fd8ab39 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/decoder.c +++ b/TargetBridge-Receiver/TBReceiverC/src/decoder.c @@ -36,6 +36,7 @@ struct tb_decoder { struct SwsContext *sws; enum AVPixelFormat hw_pix_fmt; int using_software_decode; + uint64_t error_count; uint8_t *extradata; int extradata_size; @@ -130,6 +131,29 @@ int tb_dec_supports_hevc_hwdecode(void) { return status >= 0 ? 1 : 0; } +const char *tb_dec_backend_name(const struct tb_decoder *d) { + if (!d || !d->opened) return "pending"; + if (d->using_software_decode || !d->hw_dev) return "software"; +#if defined(__APPLE__) + return "VideoToolbox"; +#else + return "hardware"; +#endif +} + +const char *tb_dec_codec_name(const struct tb_decoder *d) { + if (!d || !d->opened) return "pending"; + switch (d->codec_id) { + case AV_CODEC_ID_H264: return "H.264"; + case AV_CODEC_ID_HEVC: return "HEVC"; + default: return "unknown"; + } +} + +uint64_t tb_dec_error_count(const struct tb_decoder *d) { + return d ? d->error_count : 0; +} + void tb_dec_reset(struct tb_decoder *d) { if (!d) return; if (d->ctx) avcodec_free_context(&d->ctx); @@ -137,6 +161,7 @@ void tb_dec_reset(struct tb_decoder *d) { d->extradata = NULL; d->extradata_size = 0; d->opened = 0; + d->error_count = 0; /* hw_dev, hw_frame, sw_frame, pkt stay allocated for reuse. */ } @@ -289,6 +314,7 @@ int tb_dec_set_param_sets(struct tb_decoder *d, const uint8_t *payload, size_t l d->extradata = prev_ed; d->extradata_size = prev_sz; fprintf(stderr, "[dec] build_extradata failed\n"); + d->error_count++; return -1; } @@ -303,7 +329,11 @@ int tb_dec_set_param_sets(struct tb_decoder *d, const uint8_t *payload, size_t l av_free(prev_ed); fprintf(stderr, "[dec] param sets changed, opening decoder\n"); - return open_decoder(d); + if (open_decoder(d) < 0) { + d->error_count++; + return -1; + } + return 0; } /* Convert AVCC (length-prefixed) frame to Annex B (start codes) in scratch. */ @@ -340,11 +370,17 @@ static int avcc_to_annexb(struct tb_decoder *d, } int tb_dec_feed_frame(struct tb_decoder *d, const uint8_t *avcc, size_t len) { - if (!d->opened) return -1; + if (!d->opened) { + d->error_count++; + return -1; + } uint8_t *anb = NULL; size_t anb_len = 0; - if (avcc_to_annexb(d, avcc, len, &anb, &anb_len) < 0) return -1; + if (avcc_to_annexb(d, avcc, len, &anb, &anb_len) < 0) { + d->error_count++; + return -1; + } d->pkt->data = anb; d->pkt->size = (int)anb_len; @@ -352,13 +388,18 @@ int tb_dec_feed_frame(struct tb_decoder *d, const uint8_t *avcc, size_t len) { int r = avcodec_send_packet(d->ctx, d->pkt); if (r < 0 && r != AVERROR(EAGAIN)) { fprintf(stderr, "[dec] send_packet=%d\n", r); + d->error_count++; return -1; } while (1) { r = avcodec_receive_frame(d->ctx, d->hw_frame); if (r == AVERROR(EAGAIN) || r == AVERROR_EOF) return 0; - if (r < 0) { fprintf(stderr, "[dec] recv_frame=%d\n", r); return -1; } + if (r < 0) { + fprintf(stderr, "[dec] recv_frame=%d\n", r); + d->error_count++; + return -1; + } #if defined(__APPLE__) /* Zero-copy fast path: VideoToolbox decoded frames carry a @@ -389,6 +430,7 @@ int tb_dec_feed_frame(struct tb_decoder *d, const uint8_t *avcc, size_t len) { d->sw_frame->format = AV_PIX_FMT_NV12; if (av_hwframe_transfer_data(d->sw_frame, d->hw_frame, 0) < 0) { fprintf(stderr, "[dec] hwframe_transfer failed\n"); + d->error_count++; av_frame_unref(d->hw_frame); continue; } @@ -410,9 +452,11 @@ int tb_dec_feed_frame(struct tb_decoder *d, const uint8_t *avcc, size_t len) { d->nv12_frame->height = out->height; if (!d->sws || av_frame_get_buffer(d->nv12_frame, 32) < 0) { fprintf(stderr, "[dec] could not allocate NV12 software frame\n"); + d->error_count++; } else if (sws_scale(d->sws, (const uint8_t * const *)out->data, out->linesize, 0, out->height, d->nv12_frame->data, d->nv12_frame->linesize) <= 0) { fprintf(stderr, "[dec] software frame conversion failed\n"); + d->error_count++; } else { d->cb(d->nv12_frame->data[0], d->nv12_frame->linesize[0], d->nv12_frame->data[1], d->nv12_frame->linesize[1], diff --git a/TargetBridge-Receiver/TBReceiverC/src/decoder.h b/TargetBridge-Receiver/TBReceiverC/src/decoder.h index b7c2f12..56c7128 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/decoder.h +++ b/TargetBridge-Receiver/TBReceiverC/src/decoder.h @@ -21,6 +21,9 @@ struct tb_decoder; struct tb_decoder *tb_dec_create(tb_frame_cb cb, void *ud); void tb_dec_destroy(struct tb_decoder *d); int tb_dec_supports_hevc_hwdecode(void); +const char *tb_dec_backend_name(const struct tb_decoder *d); +const char *tb_dec_codec_name(const struct tb_decoder *d); +uint64_t tb_dec_error_count(const struct tb_decoder *d); /* Reset decoder state on client disconnect. * Forces re-open on next param sets so a stale FFmpeg context doesn't diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.c b/TargetBridge-Receiver/TBReceiverC/src/display.c index 80bdf1d..bbf5e22 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.c +++ b/TargetBridge-Receiver/TBReceiverC/src/display.c @@ -33,6 +33,7 @@ struct tb_display { SDL_Renderer *ren; SDL_Texture *tex; SDL_Texture *status_tex; + char renderer_name[64]; int tex_w, tex_h; int quit; int preferred_fullscreen; @@ -590,6 +591,9 @@ struct tb_display *tb_disp_create(int fullscreen) { SDL_RendererInfo info; if (SDL_GetRendererInfo(d->ren, &info) == 0) { fprintf(stderr, "[disp] renderer = %s\n", info.name); + snprintf(d->renderer_name, sizeof(d->renderer_name), "%s", info.name ? info.name : "unknown"); + } else { + snprintf(d->renderer_name, sizeof(d->renderer_name), "%s", "unknown"); } int win_w = 0, win_h = 0, out_w = 0, out_h = 0; @@ -623,6 +627,11 @@ struct tb_display *tb_disp_create(int fullscreen) { return d; } +const char *tb_disp_renderer_name(struct tb_display *d) { + if (!d || d->renderer_name[0] == '\0') return "unknown"; + return d->renderer_name; +} + void tb_disp_destroy(struct tb_display *d) { if (!d) return; if (d->system_cursor_hidden) { diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.h b/TargetBridge-Receiver/TBReceiverC/src/display.h index 901a1e0..a5f1ad2 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.h +++ b/TargetBridge-Receiver/TBReceiverC/src/display.h @@ -65,6 +65,9 @@ int tb_disp_pop_input_event(struct tb_display *d, struct tb_input_event /* Query active display/window/drawable information for UI/debug metadata. */ int tb_disp_get_info(struct tb_display *d, struct tb_display_info *info); +/* Actual SDL renderer selected at runtime (for example opengl or metal). */ +const char *tb_disp_renderer_name(struct tb_display *d); + /* Render a simple launcher/status UI before the video stream starts. */ void tb_disp_render_status(struct tb_display *d, const char *ip, diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index f334413..2c0c372 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/main.c +++ b/TargetBridge-Receiver/TBReceiverC/src/main.c @@ -17,6 +17,7 @@ #include "decoder.h" #include "display.h" #include "receiver_profile.h" +#include "session_metrics.h" #include "proto.h" #include "tb_gesture_bridge.h" #include "tb_display_tweaks.h" @@ -65,6 +66,7 @@ struct app { uint64_t frames; uint64_t last_fps_tick_ms; uint64_t last_fps_count; + int raw_stream_active; uint64_t last_ip_check_ms; /* Last display-tweak state reported to the sender, so changes made on this * Mac (Control Center, System Settings) propagate back and the sender's @@ -1091,16 +1093,18 @@ static void on_packet(uint8_t type, const uint8_t *payload, size_t len, void *ud break; case TB_PKT_PARAM_SETS: a->session_active = 1; + a->raw_stream_active = 0; /* tb_dec_set_param_sets is now a no-op if the sets are unchanged, * so we don't spam a log line per keyframe. */ - tb_dec_set_param_sets(a->dec, payload, len); + (void)tb_dec_set_param_sets(a->dec, payload, len); break; case TB_PKT_FRAME: a->session_active = 1; - tb_dec_feed_frame(a->dec, payload, len); + (void)tb_dec_feed_frame(a->dec, payload, len); break; case TB_PKT_RAW_FRAME: a->session_active = 1; + a->raw_stream_active = 1; handle_raw_frame(a, payload, len); break; case TB_PKT_CURSOR: @@ -1261,6 +1265,32 @@ static int send_all(int fd, const uint8_t *buf, size_t len) { return 0; } +static void tb_receiver_send_session_metrics(struct app *a, uint64_t receiver_fps) { + if (!a || a->client_fd < 0 || !a->session_active) return; + + const char *renderer = tb_disp_renderer_name(a->disp); + const char *decoder = a->raw_stream_active ? "none" : tb_dec_backend_name(a->dec); + const char *codec = a->raw_stream_active ? "NV12 RAW" : tb_dec_codec_name(a->dec); + + char json[512]; + int len = tb_session_metrics_json( + json, + sizeof(json), + receiver_fps, + a->frames, + tb_dec_error_count(a->dec), + renderer, + decoder, + codec); + if (len < 0) return; + + uint8_t pkt[4 + 1 + sizeof(json)]; + write_be32(pkt, (uint32_t)(1 + len)); + pkt[4] = TB_PKT_SESSION_METRICS; + memcpy(pkt + 5, json, (size_t)len); + (void)send_all(a->client_fd, pkt, 5 + (size_t)len); +} + static void tb_receiver_send_input_event(struct app *a, const char *kind, int has_dx, int dx, @@ -1803,6 +1833,8 @@ static void close_client(struct app *a) { a->session_active = 0; a->close_requested = 0; a->have_video_frame = 0; + a->raw_stream_active = 0; + a->last_fps_count = a->frames; snprintf(a->input_control_mode, sizeof(a->input_control_mode), "off"); SDL_EnableScreenSaver(); tb_receiver_refresh_input_capture(a); @@ -2038,6 +2070,9 @@ int main(int argc, char **argv) { a.client_fd = c; a.have_video_frame = 0; a.session_active = 0; + a.raw_stream_active = 0; + a.last_fps_count = a.frames; + a.last_fps_tick_ms = t; a.reported_night_shift = -1; /* force one report per session */ a.reported_true_tone = -1; a.last_recv_ms = t; @@ -2166,6 +2201,7 @@ int main(int argc, char **argv) { a.last_fps_count = a.frames; a.last_fps_tick_ms = t; if (df > 0) fprintf(stderr, "[main] %llu fps\n", (unsigned long long)df); + tb_receiver_send_session_metrics(&a, df); } /* Yield when idle or when a nonblocking active socket had no data, diff --git a/TargetBridge-Receiver/TBReceiverC/src/proto.h b/TargetBridge-Receiver/TBReceiverC/src/proto.h index 649c829..515ead6 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/proto.h +++ b/TargetBridge-Receiver/TBReceiverC/src/proto.h @@ -29,6 +29,7 @@ * type 0x35 = brightness update (JSON) * type 0x36 = clipboard update (JSON) * type 0x37 = volume update (JSON) + * type 0x39 = receiver session metrics (JSON, receiver → sender) * * Compatible with the new TBDisplaySender Swift app. */ @@ -59,6 +60,7 @@ * Both are private CoreBrightness features, so the receiver reports whether it * can honour them in its display profile. */ #define TB_PKT_DISPLAY_TWEAKS 0x38 +#define TB_PKT_SESSION_METRICS 0x39 #define TB_PKT_TEST_DATA 0x40 #define TB_HDR_BYTES 5 /* 4 length + 1 type */ diff --git a/TargetBridge-Receiver/TBReceiverC/src/session_metrics.c b/TargetBridge-Receiver/TBReceiverC/src/session_metrics.c new file mode 100644 index 0000000..e4b7356 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/session_metrics.c @@ -0,0 +1,29 @@ +#include "session_metrics.h" + +#include + +int tb_session_metrics_json(char *dst, + size_t dst_size, + uint64_t receiver_fps, + uint64_t rendered_frames, + uint64_t decode_errors, + const char *renderer, + const char *decoder, + const char *codec) { + if (!dst || dst_size == 0) return -1; + + int len = snprintf( + dst, + dst_size, + "{\"receiverFPS\":%llu,\"renderedFrames\":%llu,\"decodeErrors\":%llu," + "\"renderer\":\"%s\",\"decoder\":\"%s\",\"codec\":\"%s\"}", + (unsigned long long)receiver_fps, + (unsigned long long)rendered_frames, + (unsigned long long)decode_errors, + renderer ? renderer : "unknown", + decoder ? decoder : "unknown", + codec ? codec : "unknown"); + + if (len <= 0 || (size_t)len >= dst_size) return -1; + return len; +} diff --git a/TargetBridge-Receiver/TBReceiverC/src/session_metrics.h b/TargetBridge-Receiver/TBReceiverC/src/session_metrics.h new file mode 100644 index 0000000..c380bb5 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/session_metrics.h @@ -0,0 +1,20 @@ +#ifndef TB_SESSION_METRICS_H +#define TB_SESSION_METRICS_H + +#include +#include + +/* Build the compact JSON payload carried by TB_PKT_SESSION_METRICS. + * Returns the byte length (excluding the trailing NUL), or -1 when the + * destination is too small. String values must be stable backend names; the + * receiver only supplies its own fixed labels, never user-provided text. */ +int tb_session_metrics_json(char *dst, + size_t dst_size, + uint64_t receiver_fps, + uint64_t rendered_frames, + uint64_t decode_errors, + const char *renderer, + const char *decoder, + const char *codec); + +#endif /* TB_SESSION_METRICS_H */ diff --git a/TargetBridge-Receiver/TBReceiverC/tests/test_session_metrics.c b/TargetBridge-Receiver/TBReceiverC/tests/test_session_metrics.c new file mode 100644 index 0000000..93f2970 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/tests/test_session_metrics.c @@ -0,0 +1,44 @@ +#include "session_metrics.h" + +#include +#include + +static int checks; +static int failures; + +#define CHECK(condition) do { \ + checks++; \ + if (!(condition)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #condition); \ + } \ +} while (0) + +int main(void) { + char json[512]; + int len = tb_session_metrics_json( + json, sizeof(json), 59, 123456, 2, "metal", "VideoToolbox", "HEVC"); + const char *expected = + "{\"receiverFPS\":59,\"renderedFrames\":123456,\"decodeErrors\":2," + "\"renderer\":\"metal\",\"decoder\":\"VideoToolbox\",\"codec\":\"HEVC\"}"; + + CHECK(len == (int)strlen(expected)); + CHECK(strcmp(json, expected) == 0); + CHECK(json[len] == '\0'); + + char small[8]; + CHECK(tb_session_metrics_json( + small, sizeof(small), 60, 1, 0, "opengl", "software", "H.264") == -1); + CHECK(tb_session_metrics_json(NULL, 0, 0, 0, 0, NULL, NULL, NULL) == -1); + + len = tb_session_metrics_json(json, sizeof(json), 0, 0, 0, NULL, NULL, NULL); + CHECK(len > 0); + CHECK(strstr(json, "\"renderer\":\"unknown\"") != NULL); + + if (failures == 0) { + printf("session metrics: %d checks passed\n", checks); + return 0; + } + fprintf(stderr, "session metrics: %d/%d checks failed\n", failures, checks); + return 1; +} diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift index 2b2eab7..fd0fa3f 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift @@ -400,6 +400,7 @@ enum TBSenderAutomation { session.localInterfaceIP = measurement.candidate.localIP session.receiverIP = measurement.candidate.receiverIP session.receiverSupportsHEVCDecodeHint = receiver.supportsHEVCDecode + session.adoptConnectionMeasurement(measurement) } private static func waitForConnection(_ session: TBDisplaySenderSession) async -> Bool { diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift index de97d34..b04e315 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift @@ -256,8 +256,10 @@ private struct TBDisplaySenderSessionCard: View { LazyVGrid(columns: summaryColumns, alignment: .leading, spacing: 12) { summaryTile( title: transportTitle, - value: session.transportKind.title(service.language), - subtitle: service.interfaceDisplayText(for: session.localInterfaceIP) + value: session.activeConnectionPathKind.map { + TBDisplaySenderL10n.connectionPathTitle($0, language: service.language) + } ?? session.transportKind.title(service.language), + subtitle: "\(service.interfaceDisplayText(for: session.localInterfaceIP)) · \(TBDisplaySenderL10n.measuredSpeed(throughputGbps: session.measuredThroughputGbps, latencyMilliseconds: session.measuredLatencyMilliseconds, language: service.language))" ) summaryTile( @@ -274,8 +276,12 @@ private struct TBDisplaySenderSessionCard: View { summaryTile( title: fpsTitle, - value: "\(session.senderFPS)", - subtitle: session.isStreaming ? liveSubtitle : idleSubtitle, + value: session.liveMetrics.receiverRenderer.isEmpty + ? "\(session.senderFPS) FPS" + : "\(session.liveMetrics.receiverFPS) FPS", + subtitle: session.isStreaming + ? TBDisplaySenderL10n.frameRate(session.liveMetrics, language: service.language) + : idleSubtitle, accent: session.isStreaming ? .green : .secondary ) } @@ -290,7 +296,30 @@ private struct TBDisplaySenderSessionCard: View { infoRow(TBDisplaySenderL10n.receiverLabel(service.language), session.receiverPanelText) infoRow(TBDisplaySenderL10n.virtualDisplayLabel(service.language), session.virtualDisplayText) infoRow(TBDisplaySenderL10n.streamLabel(service.language), session.streamResolutionText) - infoRow(TBDisplaySenderL10n.fpsLabel(service.language), "\(session.senderFPS)") + infoRow( + TBDisplaySenderL10n.text("sender.diagnostics.active_path", service.language), + "\(TBDisplaySenderL10n.connectionPathTitle(session.activeConnectionPathKind, language: service.language)) · \(service.interfaceDisplayText(for: session.localInterfaceIP))" + ) + infoRow( + TBDisplaySenderL10n.text("sender.diagnostics.measured_capacity", service.language), + TBDisplaySenderL10n.measuredSpeed( + throughputGbps: session.measuredThroughputGbps, + latencyMilliseconds: session.measuredLatencyMilliseconds, + language: service.language + ) + ) + infoRow( + TBDisplaySenderL10n.text("sender.diagnostics.video_engine", service.language), + TBDisplaySenderL10n.videoPath(session.liveMetrics, language: service.language) + ) + infoRow( + TBDisplaySenderL10n.text("sender.diagnostics.frame_rate", service.language), + TBDisplaySenderL10n.frameRate(session.liveMetrics, language: service.language) + ) + infoRow( + TBDisplaySenderL10n.text("sender.diagnostics.frame_drops", service.language), + TBDisplaySenderL10n.frameDrops(session.liveMetrics, language: service.language) + ) } } } @@ -467,16 +496,6 @@ private struct TBDisplaySenderSessionCard: View { } } - private var liveSubtitle: String { - switch service.language { - case .italian: return "Frame in invio" - case .english: return "Frames currently sending" - case .german: return "Frames werden gesendet" - case .french: return "Images en cours d’envoi" - case .chinese: return "正在发送画面帧" - } - } - private var idleSubtitle: String { switch service.language { case .italian: return "Nessuno stream attivo" @@ -794,6 +813,33 @@ private struct TBDisplaySenderSessionSettingsSheet: View { Divider().overlay(Color.white.opacity(0.08)) VStack(alignment: .leading, spacing: 8) { + Text(TBDisplaySenderL10n.text("sender.diagnostics.session_title", service.language)) + .font(.subheadline.weight(.semibold)) + infoRow( + TBDisplaySenderL10n.text("sender.diagnostics.active_path", service.language), + "\(TBDisplaySenderL10n.connectionPathTitle(session.activeConnectionPathKind, language: service.language)) · \(service.interfaceDisplayText(for: session.localInterfaceIP))" + ) + infoRow( + TBDisplaySenderL10n.text("sender.diagnostics.measured_capacity", service.language), + TBDisplaySenderL10n.measuredSpeed( + throughputGbps: session.measuredThroughputGbps, + latencyMilliseconds: session.measuredLatencyMilliseconds, + language: service.language + ) + ) + infoRow( + TBDisplaySenderL10n.text("sender.diagnostics.video_engine", service.language), + TBDisplaySenderL10n.videoPath(session.liveMetrics, language: service.language) + ) + infoRow( + TBDisplaySenderL10n.text("sender.diagnostics.frame_rate", service.language), + TBDisplaySenderL10n.frameRate(session.liveMetrics, language: service.language) + ) + infoRow( + TBDisplaySenderL10n.text("sender.diagnostics.frame_drops", service.language), + TBDisplaySenderL10n.frameDrops(session.liveMetrics, language: service.language) + ) + Divider().overlay(Color.white.opacity(0.08)) infoRow("Capture", session.captureDisplayText) infoRow("State", session.displayStateText) } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift index 2f5fe16..a03844f 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift @@ -488,6 +488,81 @@ enum TBDisplaySenderL10n { ]) } + static func connectionPathTitle( + _ kind: TBConnectionPathKind?, + language: TBDisplaySenderLanguage + ) -> String { + guard let kind else { return text("sender.diagnostics.not_available", language) } + return text("sender.path.\(kind.rawValue)", language) + } + + static func measuredSpeed( + throughputGbps: Double?, + latencyMilliseconds: Double?, + language: TBDisplaySenderLanguage + ) -> String { + guard let throughputGbps else { + return text("sender.diagnostics.not_measured", language) + } + let values = [ + "throughput": String(format: "%.2f", throughputGbps), + "latency": latencyMilliseconds.map { String(format: "%.2f", $0) } ?? "" + ] + return text( + latencyMilliseconds == nil + ? "sender.diagnostics.speed_no_latency" + : "sender.diagnostics.speed_value", + language, + values + ) + } + + @MainActor + static func videoPath(_ metrics: TBSessionLiveMetrics, language: TBDisplaySenderLanguage) -> String { + guard !metrics.receiverRenderer.isEmpty else { + return text("sender.diagnostics.not_available", language) + } + let renderer: String + switch metrics.receiverRenderer.lowercased() { + case "opengl": renderer = "OpenGL" + case "metal": renderer = "Metal" + default: renderer = metrics.receiverRenderer + } + return text("sender.diagnostics.video_value", language, [ + "codec": metrics.receiverCodec.isEmpty ? "—" : metrics.receiverCodec, + "renderer": renderer, + "decoder": metrics.receiverDecoder.isEmpty ? "—" : metrics.receiverDecoder + ]) + } + + @MainActor + static func frameRate(_ metrics: TBSessionLiveMetrics, language: TBDisplaySenderLanguage) -> String { + if metrics.receiverRenderer.isEmpty { + return text("sender.diagnostics.fps_sender_only", language, [ + "sender": "\(metrics.senderFPS)" + ]) + } + return text("sender.diagnostics.fps_value", language, [ + "sender": "\(metrics.senderFPS)", + "receiver": "\(metrics.receiverFPS)" + ]) + } + + @MainActor + static func frameDrops(_ metrics: TBSessionLiveMetrics, language: TBDisplaySenderLanguage) -> String { + if metrics.receiverRenderer.isEmpty { + return text("sender.diagnostics.drops_sender_only", language, [ + "pacing": "\(metrics.pacingDrops)", + "backlog": "\(metrics.backlogDrops)" + ]) + } + return text("sender.diagnostics.drops_value", language, [ + "pacing": "\(metrics.pacingDrops)", + "backlog": "\(metrics.backlogDrops)", + "decode": "\(metrics.decodeErrors)" + ]) + } + static func missingScreenRecordingPermission(language: TBDisplaySenderLanguage) -> String { text("sender.error.screen_recording_permission", language) } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 4479346..347cee2 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -912,12 +912,32 @@ private final class TBVideoPipeline: @unchecked Sendable { } } -/// Live, frequently-updating session readouts (currently just the FPS counter), +/// Live, frequently-updating session readouts, /// split out of `TBDisplaySenderSession` so their ~1 Hz changes only invalidate /// the small subview that displays them rather than the whole session card. @MainActor final class TBSessionLiveMetrics: ObservableObject { @Published var senderFPS = 0 + @Published var receiverFPS = 0 + @Published var renderedFrames: UInt64 = 0 + @Published var decodeErrors: UInt64 = 0 + @Published var receiverRenderer = "" + @Published var receiverDecoder = "" + @Published var receiverCodec = "" + @Published var pacingDrops = 0 + @Published var backlogDrops = 0 + + func reset() { + senderFPS = 0 + receiverFPS = 0 + renderedFrames = 0 + decodeErrors = 0 + receiverRenderer = "" + receiverDecoder = "" + receiverCodec = "" + pacingDrops = 0 + backlogDrops = 0 + } } @MainActor @@ -1069,6 +1089,11 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } @Published var isCableTesting = false @Published var cableTestResult: Double? = nil + @Published private(set) var activeConnectionPathKind: TBConnectionPathKind? + @Published private(set) var measuredThroughputGbps: Double? + @Published private(set) var measuredLatencyMilliseconds: Double? + private var measuredLocalIP = "" + private var measuredReceiverIP = "" private var isCableTestConnection = false @Published var receiverIP: String = UserDefaults.standard.string(forKey: receiverIPDefaultsKey) ?? "" { didSet { @@ -1474,6 +1499,23 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u // Bridge peer leaves via the wrong link and times out. let interfaces = TBConnectionDiagnostics.currentIPv4Interfaces() connectInterfaceName = TBConnectionDiagnostics.interfaceName(forLocalIP: localInterfaceIP, in: interfaces) + if let connectInterfaceName { + activeConnectionPathKind = TBConnectionDiagnostics.pathKind( + for: TBConnectionDiagnostics.LocalInterface( + name: connectInterfaceName, + ip: localInterfaceIP + ), + hardwareKinds: TBConnectionDiagnostics.hardwarePathKinds() + ) + } else { + activeConnectionPathKind = nil + } + if measuredLocalIP != localInterfaceIP || measuredReceiverIP != receiverIP { + measuredThroughputGbps = nil + measuredLatencyMilliseconds = nil + measuredLocalIP = "" + measuredReceiverIP = "" + } let scopedHost = TBConnectionDiagnostics.scopedReceiverHost( receiverIP: receiverIP, localIP: localInterfaceIP, @@ -1545,10 +1587,22 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u guard !isCableTesting, !isConnected, !receiverIP.isEmpty else { return } isCableTesting = true cableTestResult = nil + measuredThroughputGbps = nil + measuredLatencyMilliseconds = nil + measuredLocalIP = "" + measuredReceiverIP = "" isCableTestConnection = true connect() } + func adoptConnectionMeasurement(_ measurement: TBConnectionMeasurement) { + activeConnectionPathKind = measurement.candidate.kind + measuredThroughputGbps = measurement.throughputGbps + measuredLatencyMilliseconds = measurement.connectLatencyMilliseconds + measuredLocalIP = measurement.candidate.localIP + measuredReceiverIP = measurement.candidate.receiverIP + } + private func performCableTest() async throws -> Double { guard let conn = connection else { throw NSError(domain: "TBDisplaySenderService", code: -1, userInfo: [NSLocalizedDescriptionKey: "No connection"]) @@ -2050,6 +2104,15 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u if let tweaks = TBMonitorProtocol.decodeJSON(TBMonitorDisplayTweaks.self, from: payload) { applyReportedDisplayTweaks(tweaks) } + case .sessionMetrics: + if let metrics = TBMonitorProtocol.decodeJSON(TBMonitorSessionMetrics.self, from: payload) { + liveMetrics.receiverFPS = max(0, metrics.receiverFPS) + liveMetrics.renderedFrames = metrics.renderedFrames + liveMetrics.decodeErrors = metrics.decodeErrors + liveMetrics.receiverRenderer = metrics.renderer + liveMetrics.receiverDecoder = metrics.decoder + liveMetrics.receiverCodec = metrics.codec + } case .clipboard: if let clipboard = TBMonitorProtocol.decodeJSON(TBMonitorClipboard.self, from: payload) { let pasteboard = NSPasteboard.general @@ -2433,6 +2496,10 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u do { let rate = try await self.performCableTest() self.cableTestResult = rate + self.measuredThroughputGbps = rate + self.measuredLatencyMilliseconds = nil + self.measuredLocalIP = self.localInterfaceIP + self.measuredReceiverIP = self.receiverIP } catch { NSLog("TargetBridge: cable test failed: \(error)") self.stop(resetStatusTo: .connectionFailed(error.localizedDescription)) @@ -3391,7 +3458,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u pipeline?.stop() pipeline = nil isStreaming = false - liveMetrics.senderFPS = 0 + liveMetrics.reset() senderFPS = 0 sentSnapshot = 0 cursorDisplayID = kCGNullDirectDisplay @@ -3424,6 +3491,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u let fps = total - sentSnapshot liveMetrics.senderFPS = fps senderFPS = fps + let diagnostics = pipeline?.diagnosticsSnapshot() + liveMetrics.pacingDrops = diagnostics?.droppedPacing ?? 0 + liveMetrics.backlogDrops = (diagnostics?.droppedPre ?? 0) + (diagnostics?.droppedPost ?? 0) sentSnapshot = total } } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift index 011d57a..2563a89 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift @@ -207,6 +207,28 @@ final class TBMonitorProtocolTests: XCTestCase { XCTAssertEqual(TBMonitorProtocol.decodeJSON(TBMonitorHeartbeat.self, from: payload)?.sequence, 42) } + func testSessionMetricsPacketRoundTrip() throws { + XCTAssertEqual(TBMonitorPacketType.sessionMetrics.rawValue, 0x39) + let metrics = TBMonitorSessionMetrics( + receiverFPS: 59, + renderedFrames: 123_456, + decodeErrors: 2, + renderer: "metal", + decoder: "VideoToolbox", + codec: "HEVC" + ) + guard var buffer = TBMonitorProtocol.makeJSONPacket(type: .sessionMetrics, value: metrics), + let (type, payload) = try TBMonitorProtocol.drainPacket(from: &buffer) + else { + XCTFail("session metrics did not encode and drain") + return + } + + XCTAssertEqual(type, .sessionMetrics) + XCTAssertEqual(TBMonitorProtocol.decodeJSON(TBMonitorSessionMetrics.self, from: payload), metrics) + XCTAssertTrue(buffer.isEmpty) + } + func testCursorPayloadPreservesLargeCursorPreference() throws { let cursor = TBMonitorCursor( x: 120, diff --git a/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift b/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift index 78dce26..fe5c58f 100644 --- a/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift +++ b/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift @@ -19,6 +19,8 @@ enum TBMonitorPacketType: UInt8 { case volume = 0x37 /// Night Shift / True Tone on the receiver's panel. case displayTweaks = 0x38 + /// Once-per-second receiver telemetry. Older senders skip unknown packets. + case sessionMetrics = 0x39 case testData = 0x40 } @@ -110,6 +112,15 @@ struct TBMonitorDisplayTweaks: Codable { var trueTone: Bool } +struct TBMonitorSessionMetrics: Codable, Equatable { + var receiverFPS: Int + var renderedFrames: UInt64 + var decodeErrors: UInt64 + var renderer: String + var decoder: String + var codec: String +} + struct TBMonitorClipboard: Codable { var text: String } diff --git a/TargetBridge-Shared/Languages/de.json b/TargetBridge-Shared/Languages/de.json index 0075cd5..6fc764f 100644 --- a/TargetBridge-Shared/Languages/de.json +++ b/TargetBridge-Shared/Languages/de.json @@ -99,6 +99,25 @@ "sender.diagnostics.cable": "Kabel / Verbindung", "sender.diagnostics.cable_pending": "Führe den Kabeltest aus, bevor du dich auf 5K verlässt.", "sender.diagnostics.cable_ready": "Letzter Kabeltest: %{rate} Gbit/s.", + "sender.diagnostics.session_title": "Live-Sitzung", + "sender.diagnostics.active_path": "Verwendete Verbindung", + "sender.diagnostics.measured_capacity": "Gemessene Geschwindigkeit", + "sender.diagnostics.video_engine": "Video", + "sender.diagnostics.frame_rate": "Bildrate", + "sender.diagnostics.frame_drops": "Verworfene Bilder / Fehler", + "sender.diagnostics.not_available": "Nicht verfügbar", + "sender.diagnostics.not_measured": "Noch nicht gemessen", + "sender.diagnostics.speed_value": "%{throughput} Gbit/s · %{latency} ms", + "sender.diagnostics.speed_no_latency": "%{throughput} Gbit/s", + "sender.diagnostics.video_value": "%{codec} · %{renderer} · %{decoder}", + "sender.diagnostics.fps_value": "Haupt-Mac %{sender} FPS · Anzeige-Mac %{receiver} FPS", + "sender.diagnostics.fps_sender_only": "Haupt-Mac %{sender} FPS · Anzeige-Mac nicht verfügbar", + "sender.diagnostics.drops_value": "Taktung %{pacing} · Warteschlange %{backlog} · Dekodierung %{decode}", + "sender.diagnostics.drops_sender_only": "Taktung %{pacing} · Warteschlange %{backlog} · Anzeige-Mac nicht verfügbar", + "sender.path.thunderbolt": "Thunderbolt", + "sender.path.usb": "Direktes USB", + "sender.path.ethernet": "Ethernet", + "sender.path.wifi": "WLAN", "sender.diagnostics.sender_input_monitoring": "Eingabeüberwachung Sender", "sender.diagnostics.sender_accessibility": "Bedienungshilfen Sender", "sender.diagnostics.receiver_input_monitoring": "Eingabeüberwachung Receiver", diff --git a/TargetBridge-Shared/Languages/en.json b/TargetBridge-Shared/Languages/en.json index e38314e..4922aac 100644 --- a/TargetBridge-Shared/Languages/en.json +++ b/TargetBridge-Shared/Languages/en.json @@ -99,6 +99,25 @@ "sender.diagnostics.cable": "Cable / link", "sender.diagnostics.cable_pending": "Run Cable Test to measure this link before relying on 5K.", "sender.diagnostics.cable_ready": "Latest cable test: %{rate} Gbit/s.", + "sender.diagnostics.session_title": "Live session", + "sender.diagnostics.active_path": "Connection in use", + "sender.diagnostics.measured_capacity": "Measured speed", + "sender.diagnostics.video_engine": "Video", + "sender.diagnostics.frame_rate": "Frame rate", + "sender.diagnostics.frame_drops": "Discarded frames / errors", + "sender.diagnostics.not_available": "Not available", + "sender.diagnostics.not_measured": "Not measured yet", + "sender.diagnostics.speed_value": "%{throughput} Gbit/s · %{latency} ms", + "sender.diagnostics.speed_no_latency": "%{throughput} Gbit/s", + "sender.diagnostics.video_value": "%{codec} · %{renderer} · %{decoder}", + "sender.diagnostics.fps_value": "Main Mac %{sender} FPS · display Mac %{receiver} FPS", + "sender.diagnostics.fps_sender_only": "Main Mac %{sender} FPS · display Mac unavailable", + "sender.diagnostics.drops_value": "Pacing %{pacing} · queue %{backlog} · decode %{decode}", + "sender.diagnostics.drops_sender_only": "Pacing %{pacing} · queue %{backlog} · display Mac unavailable", + "sender.path.thunderbolt": "Thunderbolt", + "sender.path.usb": "Direct USB", + "sender.path.ethernet": "Ethernet", + "sender.path.wifi": "Wi-Fi", "sender.diagnostics.sender_input_monitoring": "Sender Input Monitoring", "sender.diagnostics.sender_accessibility": "Sender Accessibility", "sender.diagnostics.receiver_input_monitoring": "Receiver Input Monitoring", diff --git a/TargetBridge-Shared/Languages/fr.json b/TargetBridge-Shared/Languages/fr.json index e18d886..a00c16a 100644 --- a/TargetBridge-Shared/Languages/fr.json +++ b/TargetBridge-Shared/Languages/fr.json @@ -99,6 +99,25 @@ "sender.diagnostics.cable": "Câble / liaison", "sender.diagnostics.cable_pending": "Lancez le test du câble pour mesurer cette liaison avant d’utiliser la 5K.", "sender.diagnostics.cable_ready": "Dernier test du câble : %{rate} Gbit/s.", + "sender.diagnostics.session_title": "Session en direct", + "sender.diagnostics.active_path": "Connexion utilisée", + "sender.diagnostics.measured_capacity": "Vitesse mesurée", + "sender.diagnostics.video_engine": "Vidéo", + "sender.diagnostics.frame_rate": "Fréquence d’images", + "sender.diagnostics.frame_drops": "Images écartées / erreurs", + "sender.diagnostics.not_available": "Non disponible", + "sender.diagnostics.not_measured": "Pas encore mesurée", + "sender.diagnostics.speed_value": "%{throughput} Gbit/s · %{latency} ms", + "sender.diagnostics.speed_no_latency": "%{throughput} Gbit/s", + "sender.diagnostics.video_value": "%{codec} · %{renderer} · %{decoder}", + "sender.diagnostics.fps_value": "Mac principal %{sender} IPS · Mac-écran %{receiver} IPS", + "sender.diagnostics.fps_sender_only": "Mac principal %{sender} IPS · Mac-écran indisponible", + "sender.diagnostics.drops_value": "Cadence %{pacing} · file %{backlog} · décodage %{decode}", + "sender.diagnostics.drops_sender_only": "Cadence %{pacing} · file %{backlog} · Mac-écran indisponible", + "sender.path.thunderbolt": "Thunderbolt", + "sender.path.usb": "USB direct", + "sender.path.ethernet": "Ethernet", + "sender.path.wifi": "Wi-Fi", "sender.diagnostics.sender_input_monitoring": "Surveillance des entrées du sender", "sender.diagnostics.sender_accessibility": "Accessibilité du sender", "sender.diagnostics.receiver_input_monitoring": "Surveillance des entrées du receiver", diff --git a/TargetBridge-Shared/Languages/it.json b/TargetBridge-Shared/Languages/it.json index 37bc70f..4f003ff 100644 --- a/TargetBridge-Shared/Languages/it.json +++ b/TargetBridge-Shared/Languages/it.json @@ -99,6 +99,25 @@ "sender.diagnostics.cable": "Cavo / collegamento", "sender.diagnostics.cable_pending": "Esegui il Test Cavo prima di affidarti al 5K.", "sender.diagnostics.cable_ready": "Ultimo Test Cavo: %{rate} Gbit/s.", + "sender.diagnostics.session_title": "Sessione in tempo reale", + "sender.diagnostics.active_path": "Collegamento utilizzato", + "sender.diagnostics.measured_capacity": "Velocità misurata", + "sender.diagnostics.video_engine": "Video", + "sender.diagnostics.frame_rate": "Fluidità", + "sender.diagnostics.frame_drops": "Frame scartati / errori", + "sender.diagnostics.not_available": "Non disponibile", + "sender.diagnostics.not_measured": "Non ancora misurata", + "sender.diagnostics.speed_value": "%{throughput} Gbit/s · %{latency} ms", + "sender.diagnostics.speed_no_latency": "%{throughput} Gbit/s", + "sender.diagnostics.video_value": "%{codec} · %{renderer} · %{decoder}", + "sender.diagnostics.fps_value": "Mac principale %{sender} FPS · Mac-schermo %{receiver} FPS", + "sender.diagnostics.fps_sender_only": "Mac principale %{sender} FPS · Mac-schermo non disponibile", + "sender.diagnostics.drops_value": "Ritmo %{pacing} · coda %{backlog} · decodifica %{decode}", + "sender.diagnostics.drops_sender_only": "Ritmo %{pacing} · coda %{backlog} · Mac-schermo non disponibile", + "sender.path.thunderbolt": "Thunderbolt", + "sender.path.usb": "USB diretto", + "sender.path.ethernet": "Ethernet", + "sender.path.wifi": "Wi-Fi", "sender.diagnostics.sender_input_monitoring": "Monitoraggio input sender", "sender.diagnostics.sender_accessibility": "Accessibilita sender", "sender.diagnostics.receiver_input_monitoring": "Monitoraggio input receiver", diff --git a/TargetBridge-Shared/Languages/zh.json b/TargetBridge-Shared/Languages/zh.json index 6cc9928..c1827f7 100644 --- a/TargetBridge-Shared/Languages/zh.json +++ b/TargetBridge-Shared/Languages/zh.json @@ -99,6 +99,25 @@ "sender.diagnostics.cable": "线缆 / 链路", "sender.diagnostics.cable_pending": "在依赖 5K 前,请运行线缆测试以测量此链路。", "sender.diagnostics.cable_ready": "最近一次线缆测试:%{rate} Gbit/s。", + "sender.diagnostics.session_title": "实时会话", + "sender.diagnostics.active_path": "当前连接", + "sender.diagnostics.measured_capacity": "实测速度", + "sender.diagnostics.video_engine": "视频", + "sender.diagnostics.frame_rate": "帧率", + "sender.diagnostics.frame_drops": "丢弃帧 / 错误", + "sender.diagnostics.not_available": "不可用", + "sender.diagnostics.not_measured": "尚未测量", + "sender.diagnostics.speed_value": "%{throughput} Gbit/s · %{latency} ms", + "sender.diagnostics.speed_no_latency": "%{throughput} Gbit/s", + "sender.diagnostics.video_value": "%{codec} · %{renderer} · %{decoder}", + "sender.diagnostics.fps_value": "主 Mac %{sender} FPS · 显示 Mac %{receiver} FPS", + "sender.diagnostics.fps_sender_only": "主 Mac %{sender} FPS · 显示 Mac 不可用", + "sender.diagnostics.drops_value": "节奏 %{pacing} · 队列 %{backlog} · 解码 %{decode}", + "sender.diagnostics.drops_sender_only": "节奏 %{pacing} · 队列 %{backlog} · 显示 Mac 不可用", + "sender.path.thunderbolt": "Thunderbolt", + "sender.path.usb": "USB 直连", + "sender.path.ethernet": "以太网", + "sender.path.wifi": "Wi-Fi", "sender.diagnostics.sender_input_monitoring": "Sender 输入监控", "sender.diagnostics.sender_accessibility": "Sender 辅助功能", "sender.diagnostics.receiver_input_monitoring": "Receiver 输入监控",