diff --git a/src/provider/geforce_now/session_client.zig b/src/provider/geforce_now/session_client.zig index 6fad6bf..176ff00 100644 --- a/src/provider/geforce_now/session_client.zig +++ b/src/provider/geforce_now/session_client.zig @@ -33,6 +33,7 @@ pub const Client = struct { last_response_retryable: ?bool = null, stream_width: u16 = fallback_stream_width, stream_height: u16 = fallback_stream_height, + requested_frames_per_second: u16 = preferred_frames_per_second, stream_frames_per_second: u16 = preferred_frames_per_second, pub fn create( @@ -48,6 +49,9 @@ pub const Client = struct { .client_id = undefined, }; uuid.generate(&client.client_id); + const configured_fps = c.go_handheld_ui_frames_per_second(client.ui); + if (configured_fps > 0) client.requested_frames_per_second = @intCast(configured_fps); + client.stream_frames_per_second = client.requested_frames_per_second; return client; } @@ -348,7 +352,7 @@ pub const Client = struct { ) !void { self.stream_width = fallback_stream_width; self.stream_height = fallback_stream_height; - self.stream_frames_per_second = preferred_frames_per_second; + self.stream_frames_per_second = self.requested_frames_per_second; const summary = self.fetchSubscriptionStatus(provider_url, bearer) catch |err| { if (err == error.Cancelled) return err; std.debug.print("GeForce NOW membership check unavailable: {s}\n", .{@errorName(err)}); @@ -369,7 +373,7 @@ pub const Client = struct { if (summary.bestForDisplayWithin( display_width, display_height, - preferred_frames_per_second, + self.requested_frames_per_second, maximum_stream_width, maximum_stream_height, )) |selected| { @@ -730,3 +734,25 @@ test "membership and region discovery requests preserve cancellation" { try std.testing.expectEqual(@as(usize, 1), http.requests); try std.testing.expectEqual(@as(usize, 1), http.polls); } + +test "stream mode selection keeps the configured frame rate" { + const http = @import("gfn_http_fake"); + http.reset(); + http.cancel_on_poll = std.math.maxInt(usize); + var auth = http.authClient(auth_client.Client); + var client = Client{ + .allocator = std.testing.allocator, + .auth = &auth, + .ui = @ptrCast(auth.ui), + .client_id = [_]u8{0} ** 37, + .requested_frames_per_second = 60, + }; + http.replyJson( + \\{"features":{"resolutions":[ + \\ {"widthInPixels":1024,"heightInPixels":768,"framesPerSecond":30,"isEntitled":true}, + \\ {"widthInPixels":1024,"heightInPixels":768,"framesPerSecond":60,"isEntitled":true} + \\]}} + ); + try client.selectStreamMode("https://example.invalid/", auth.bearer().?, 640, 480); + try std.testing.expectEqual(@as(u16, 60), client.stream_frames_per_second); +} diff --git a/src/provider/geforce_now/webrtc_session.zig b/src/provider/geforce_now/webrtc_session.zig index 788b6c5..4b16833 100644 --- a/src/provider/geforce_now/webrtc_session.zig +++ b/src/provider/geforce_now/webrtc_session.zig @@ -18,7 +18,8 @@ const c = @cImport({ const event_capacity = 64; const maximum_tracks = 8; const maximum_channels = 8; -const maximum_bitrate_kbps = 6000; +const minimum_bitrate_kbps = 4000; +const maximum_bitrate_kbps = 20000; const EarlyIceCandidates = struct { messages: std.BoundedArray(signaling_protocol.DecodedMessage, 32) = .{}, @@ -352,10 +353,19 @@ pub const Session = struct { pub fn requestBitrate(self: *Session) void { if (self.bitrate_requested or self.video_track < 0 or c.go_video_pipeline_has_media(self.video) == 0) return; - if (c.rtcRequestBitrate(self.video_track, maximum_bitrate_kbps * 1000) >= 0) + const ceiling_bps = @as(u64, self.bitrateCeilingKbps()) * 1000; + if (c.rtcRequestBitrate(self.video_track, @intCast(ceiling_bps)) >= 0) self.bitrate_requested = true; } + fn bitrateCeilingKbps(self: *const Session) u32 { + return std.math.clamp( + c.go_handheld_ui_max_bitrate_kbps(self.ui), + minimum_bitrate_kbps, + maximum_bitrate_kbps, + ); + } + pub fn destroy(self: *Session) void { self.shutting_down.store(true, .release); if (self.peer >= 0) { @@ -432,7 +442,7 @@ pub const Session = struct { self.width, self.height, self.frames_per_second, - maximum_bitrate_kbps, + self.bitrateCeilingKbps(), ); defer self.allocator.free(nvst); try self.signaling.?.sendAnswer(narrowed_sdp, nvst); diff --git a/src/ui/handheld_ui.h b/src/ui/handheld_ui.h index d1f49c5..b1025d7 100644 --- a/src/ui/handheld_ui.h +++ b/src/ui/handheld_ui.h @@ -56,6 +56,8 @@ int go_handheld_ui_pick_title(GoHandheldUi* ui, const GoCatalogTitle* titles, in int go_handheld_ui_cancelled(const GoHandheldUi* ui); unsigned int go_handheld_ui_stream_width(const GoHandheldUi* ui); unsigned int go_handheld_ui_stream_height(const GoHandheldUi* ui); +unsigned int go_handheld_ui_frames_per_second(const GoHandheldUi* ui); +unsigned int go_handheld_ui_max_bitrate_kbps(const GoHandheldUi* ui); void go_handheld_ui_draw_stream_controls(GoHandheldUi* ui, int mouse_mode, float pointer_x, float pointer_y, int source_width, int source_height, int show_hint); diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig index 9e38a0e..9f13109 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -747,3 +747,11 @@ pub export fn go_handheld_ui_stream_width(ui: ?*const Ui) c_uint { pub export fn go_handheld_ui_stream_height(ui: ?*const Ui) c_uint { return (ui orelse return 480).stream_height; } + +pub export fn go_handheld_ui_frames_per_second(ui: ?*const Ui) c_uint { + return (ui orelse return 30).settings.frames_per_second; +} + +pub export fn go_handheld_ui_max_bitrate_kbps(ui: ?*const Ui) c_uint { + return (ui orelse return 6000).settings.max_bitrate_kbps; +} diff --git a/src/ui/persistent_settings.zig b/src/ui/persistent_settings.zig index 32eef01..4a5ac87 100644 --- a/src/ui/persistent_settings.zig +++ b/src/ui/persistent_settings.zig @@ -2,6 +2,8 @@ const std = @import("std"); pub const max_games = 1024; pub const product_id_capacity = 64; +pub const frames_per_second_choices = [_]u16{ 30, 60 }; +pub const bitrate_choices_kbps = [_]u32{ 4000, 6000, 8000, 12000, 16000, 20000 }; pub const FaceButtonMode = enum { system, @@ -18,6 +20,8 @@ pub const Store = struct { path_length: usize = 0, face_buttons: FaceButtonMode = .system, artwork_enabled: bool = true, + frames_per_second: u16 = 30, + max_bitrate_kbps: u32 = 6000, games: [max_games]GameSettings = [_]GameSettings{.{}} ** max_games, game_count: usize = 0, @@ -70,6 +74,8 @@ pub const Store = struct { try writer.writeAll("version\t1\n"); try writer.print("face_buttons\t{s}\n", .{@tagName(self.face_buttons)}); try writer.print("artwork\t{d}\n", .{@intFromBool(self.artwork_enabled)}); + try writer.print("frames_per_second\t{d}\n", .{self.frames_per_second}); + try writer.print("max_bitrate_kbps\t{d}\n", .{self.max_bitrate_kbps}); for (self.games[0..self.game_count]) |*entry| { try writer.print("game\t{s}\t{d}\n", .{ productId(entry), @@ -118,6 +124,16 @@ pub const Store = struct { } else if (std.mem.eql(u8, kind, "artwork")) { const value = fields.next() orelse continue; self.artwork_enabled = std.mem.eql(u8, value, "1"); + } else if (std.mem.eql(u8, kind, "frames_per_second")) { + const value = fields.next() orelse continue; + const parsed = std.fmt.parseUnsigned(u16, value, 10) catch continue; + if (isChoice(u16, &frames_per_second_choices, parsed)) + self.frames_per_second = parsed; + } else if (std.mem.eql(u8, kind, "max_bitrate_kbps")) { + const value = fields.next() orelse continue; + const parsed = std.fmt.parseUnsigned(u32, value, 10) catch continue; + if (isChoice(u32, &bitrate_choices_kbps, parsed)) + self.max_bitrate_kbps = parsed; } else if (std.mem.eql(u8, kind, "game")) { const id = fields.next() orelse continue; const favorite = fields.next() orelse continue; @@ -141,6 +157,10 @@ pub fn validProductId(value: []const u8) bool { return true; } +fn isChoice(comptime T: type, choices: []const T, value: T) bool { + return std.mem.indexOfScalar(T, choices, value) != null; +} + test "settings round trip through the file format" { var store = Store{}; store.face_buttons = .swapped; @@ -169,3 +189,18 @@ test "settings reject unknown versions and unsafe ids" { try std.testing.expectError(error.UnsupportedSettings, store.parse("artwork\t0\nversion\t1\n")); try std.testing.expect(store.game("bad\tid") == null); } + +test "settings keep stream quality within the supported choices" { + var store = Store{}; + try store.parse("version\t1\nframes_per_second\t60\nmax_bitrate_kbps\t12000\n"); + try std.testing.expectEqual(@as(u16, 60), store.frames_per_second); + try std.testing.expectEqual(@as(u32, 12000), store.max_bitrate_kbps); + + var invalid = Store{}; + try invalid.parse("version\t1\nframes_per_second\t1\nmax_bitrate_kbps\t0\n"); + try std.testing.expectEqual(@as(u16, 30), invalid.frames_per_second); + try std.testing.expectEqual(@as(u32, 6000), invalid.max_bitrate_kbps); + + try invalid.parse("version\t1\nmax_bitrate_kbps\t4294967295\n"); + try std.testing.expectEqual(@as(u32, 6000), invalid.max_bitrate_kbps); +} diff --git a/src/ui/settings_view.zig b/src/ui/settings_view.zig index 74eb580..c772bcc 100644 --- a/src/ui/settings_view.zig +++ b/src/ui/settings_view.zig @@ -23,10 +23,21 @@ const StopRequested = ?*const fn (?*anyopaque) callconv(.c) c_int; const Row = enum { face_buttons, artwork, + framerate, + bitrate, service, sign_out, }; +const bitrate_choices = settings.bitrate_choices_kbps; + +fn nextBitrate(current: u32) u32 { + for (bitrate_choices, 0..) |value, index| { + if (value == current) return bitrate_choices[(index + 1) % bitrate_choices.len]; + } + return bitrate_choices[0]; +} + const Action = enum { none, switch_provider, @@ -86,7 +97,7 @@ pub fn run( repeat.begin(.down, c.SDL_GetTicks()); }, c.SDL_CONTROLLER_BUTTON_DPAD_LEFT, c.SDL_CONTROLLER_BUTTON_DPAD_RIGHT => { - if (selected == .face_buttons or selected == .artwork) + if (selected != .service and selected != .sign_out) _ = activate(selected, controller, store); }, else => {}, @@ -130,6 +141,8 @@ fn activate( ); }, .artwork => store.artwork_enabled = !store.artwork_enabled, + .framerate => store.frames_per_second = if (store.frames_per_second >= 60) 30 else 60, + .bitrate => store.max_bitrate_kbps = nextBitrate(store.max_bitrate_kbps), .service => return .switch_provider, .sign_out => return .sign_out, } @@ -165,7 +178,9 @@ fn previousRow(row: Row) Row { return switch (row) { .face_buttons => .sign_out, .artwork => .face_buttons, - .service => .artwork, + .framerate => .artwork, + .bitrate => .framerate, + .service => .bitrate, .sign_out => .service, }; } @@ -173,7 +188,9 @@ fn previousRow(row: Row) Row { fn nextRow(row: Row) Row { return switch (row) { .face_buttons => .artwork, - .artwork => .service, + .artwork => .framerate, + .framerate => .bitrate, + .bitrate => .service, .service => .sign_out, .sign_out => .face_buttons, }; @@ -204,13 +221,20 @@ fn draw( _ = c.SDL_RenderFillRect(renderer, &footer); font.text(renderer, 18, 14, 4, "SETTINGS", style.bright()); + var fps_buffer: [16]u8 = undefined; + const fps_text = std.fmt.bufPrintZ(&fps_buffer, "{d} FPS", .{store.frames_per_second}) catch "30 FPS"; + var bitrate_buffer: [16]u8 = undefined; + const bitrate_text = std.fmt.bufPrintZ(&bitrate_buffer, "{d} MBPS", .{store.max_bitrate_kbps / 1000}) catch "6 MBPS"; + drawRow(renderer, 82, "FACE BUTTONS", if (store.face_buttons == .system) "SYSTEM" else "SWAPPED", selected == .face_buttons); - drawRow(renderer, 130, "GAME ARTWORK", if (store.artwork_enabled) "ON" else "OFF", selected == .artwork); - drawRow(renderer, 178, "STREAMING SERVICE", if (provider == .xbox) "XBOX" else "GEFORCE NOW", selected == .service); - drawRow(renderer, 226, "ACCOUNT", "SIGN OUT", selected == .sign_out); + drawRow(renderer, 128, "GAME ARTWORK", if (store.artwork_enabled) "ON" else "OFF", selected == .artwork); + drawRow(renderer, 174, "TARGET FRAMERATE", fps_text, selected == .framerate); + drawRow(renderer, 220, "MAX BITRATE", bitrate_text, selected == .bitrate); + drawRow(renderer, 266, "STREAMING SERVICE", if (provider == .xbox) "XBOX" else "GEFORCE NOW", selected == .service); + drawRow(renderer, 312, "ACCOUNT", "SIGN OUT", selected == .sign_out); drawMappingExplanation(renderer, store.face_buttons); - font.text(renderer, 18, 340, 2, "USE SWAPPED ONLY IF BUTTONS ARE REVERSED", style.muted()); + font.text(renderer, 18, 366, 2, "FRAMERATE AND BITRATE APPLY ON NEXT STREAM", style.muted()); const prompts = [_]controls.Prompt{ controls.Prompt.one(controls.face(store.face_buttons, .a), "CHANGE"), controls.Prompt.one(.dpad, "MOVE"),