mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-18 09:53:05 +08:00
[feat] add live video settings to iOS controls
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000016 /* RemoteVideoSettings.swift */; };
|
||||
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000001 /* CrossDeskMobileApp.swift */; };
|
||||
100000000000000000000002 /* RemoteSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000002 /* RemoteSessionModel.swift */; };
|
||||
100000000000000000000003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000003 /* ContentView.swift */; };
|
||||
@@ -27,6 +28,7 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
200000000000000000000016 /* RemoteVideoSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteVideoSettings.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000001 /* CrossDeskMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossDeskMobileApp.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000002 /* RemoteSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionModel.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000003 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
@@ -92,6 +94,7 @@
|
||||
200000000000000000000013 /* ThumbnailFileStore.swift */,
|
||||
200000000000000000000012 /* PrivacyPreferences.swift */,
|
||||
200000000000000000000002 /* RemoteSessionModel.swift */,
|
||||
200000000000000000000016 /* RemoteVideoSettings.swift */,
|
||||
200000000000000000000003 /* ContentView.swift */,
|
||||
20000000000000000000000E /* ThirdPartyLicensesView.swift */,
|
||||
20000000000000000000000F /* ThirdPartyLicenses.json */,
|
||||
@@ -255,6 +258,7 @@
|
||||
files = (
|
||||
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */,
|
||||
100000000000000000000002 /* RemoteSessionModel.swift in Sources */,
|
||||
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */,
|
||||
100000000000000000000003 /* ContentView.swift in Sources */,
|
||||
100000000000000000000009 /* ThirdPartyLicensesView.swift in Sources */,
|
||||
100000000000000000000004 /* RemoteSessionView.swift in Sources */,
|
||||
|
||||
@@ -63,7 +63,14 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
|
||||
didReceiveHostName:(NSString *)hostName
|
||||
appVersion:(NSString *)appVersion
|
||||
displayNames:(NSArray<NSString *> *)displayNames
|
||||
displaySizes:(NSArray<NSValue *> *)displaySizes;
|
||||
displaySizes:(NSArray<NSValue *> *)displaySizes
|
||||
supportsVideoSettings:(BOOL)supportsVideoSettings;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceiveVideoSettingsQuality:(NSInteger)quality
|
||||
frameRate:(NSInteger)frameRate
|
||||
preference:(NSInteger)preference
|
||||
requestID:(uint32_t)requestID
|
||||
accepted:(BOOL)accepted;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)presence
|
||||
snapshot:(BOOL)snapshot;
|
||||
@@ -110,6 +117,11 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
|
||||
- (void)configureWithSignalHost:(NSString *)host
|
||||
signalPort:(NSInteger)signalPort;
|
||||
- (void)setVideoAdaptationPolicy:(CrossDeskVideoAdaptationPolicy)policy;
|
||||
- (void)sendVideoSettingsWithQuality:(NSInteger)quality
|
||||
frameRate:(NSInteger)frameRate
|
||||
preference:(NSInteger)preference
|
||||
requestID:(uint32_t)requestID
|
||||
NS_SWIFT_NAME(sendVideoSettings(quality:frameRate:preference:requestID:));
|
||||
- (void)requestPresenceForRemoteIDs:(NSArray<NSString *> *)remoteIDs
|
||||
subscribe:(BOOL)subscribe
|
||||
NS_SWIFT_NAME(requestPresence(remoteIDs:subscribe:));
|
||||
|
||||
@@ -327,6 +327,8 @@ void DispatchMain(dispatch_block_t block) {
|
||||
width:(NSInteger)width
|
||||
height:(NSInteger)height;
|
||||
- (void)resetVideoDelivery;
|
||||
- (void)notifyVideoSettings:(crossdesk::VideoSettings)settings
|
||||
generation:(uint64_t)generation;
|
||||
- (void)handleAudio:(const char *)data size:(size_t)size;
|
||||
- (void)handleData:(const char *)data
|
||||
size:(size_t)size
|
||||
@@ -651,6 +653,46 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
_presenceGeneration.fetch_add(1);
|
||||
}
|
||||
|
||||
- (void)sendVideoSettingsWithQuality:(NSInteger)quality
|
||||
frameRate:(NSInteger)frameRate
|
||||
preference:(NSInteger)preference
|
||||
requestID:(uint32_t)requestID {
|
||||
if (quality < 0 || quality > 2 || (frameRate != 30 && frameRate != 60) ||
|
||||
preference < 0 || preference > 2) return;
|
||||
const uint64_t generation = _activeControllerGeneration.load();
|
||||
dispatch_async(_rtcQueue, ^{
|
||||
if (![self isControllerGenerationActive:generation] ||
|
||||
!self->_state->controller_peer) return;
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::video_settings;
|
||||
action.vs = {static_cast<int>(quality), static_cast<int>(frameRate),
|
||||
static_cast<int>(preference), requestID, false};
|
||||
const auto message = action.to_json();
|
||||
if (SendReliableDataFrame(self->_state->controller_peer, message.data(),
|
||||
message.size(), kControlStream) != 0) {
|
||||
[self notifyVideoSettings:action.vs generation:generation];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)notifyVideoSettings:(crossdesk::VideoSettings)settings
|
||||
generation:(uint64_t)generation {
|
||||
DispatchMain(^{
|
||||
if (![self isControllerGenerationActive:generation]) return;
|
||||
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||
if ([delegate respondsToSelector:
|
||||
@selector(rtcBridge:didReceiveVideoSettingsQuality:frameRate:
|
||||
preference:requestID:accepted:)]) {
|
||||
[delegate rtcBridge:self
|
||||
didReceiveVideoSettingsQuality:settings.quality
|
||||
frameRate:settings.frame_rate
|
||||
preference:settings.preference
|
||||
requestID:settings.request_id
|
||||
accepted:settings.accepted];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)requestPresenceForRemoteIDs:(NSArray<NSString *> *)remoteIDs
|
||||
subscribe:(BOOL)subscribe {
|
||||
NSArray<NSString *> *owned_ids = [remoteIDs copy];
|
||||
@@ -1560,10 +1602,13 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
const std::string source(sourceID, sourceIDSize);
|
||||
if (source == kControlStream) {
|
||||
RemoteAction action{};
|
||||
if (!action.from_json(std::string(data, size)) ||
|
||||
action.type != ControlType::host_infomation) {
|
||||
if (!action.from_json(std::string(data, size))) return;
|
||||
if (action.type == ControlType::video_settings_status) {
|
||||
[self notifyVideoSettings:action.vs generation:generation];
|
||||
return;
|
||||
}
|
||||
if (action.type != ControlType::host_infomation) return;
|
||||
const BOOL supports_video_settings = action.i.supports_video_settings;
|
||||
NSString *host_name = [[NSString alloc]
|
||||
initWithBytes:action.i.host_name
|
||||
length:action.i.host_name_size
|
||||
@@ -1594,12 +1639,13 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||
if ([delegate respondsToSelector:
|
||||
@selector(rtcBridge:didReceiveHostName:appVersion:displayNames:
|
||||
displaySizes:)]) {
|
||||
displaySizes:supportsVideoSettings:)]) {
|
||||
[delegate rtcBridge:self
|
||||
didReceiveHostName:host_name ?: @""
|
||||
appVersion:app_version ?: @""
|
||||
displayNames:display_names
|
||||
displaySizes:display_sizes];
|
||||
displaySizes:display_sizes
|
||||
supportsVideoSettings:supports_video_settings];
|
||||
}
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -308,6 +308,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
@Published var videoAdaptationPolicy = VideoAdaptationPolicy.saved {
|
||||
didSet { videoAdaptationPolicy.save() }
|
||||
}
|
||||
@Published private(set) var videoSettings = RemoteVideoSettingsState()
|
||||
@Published var remoteID = ""
|
||||
@Published var password = ""
|
||||
@Published private(set) var signalStatus = "尚未同意联网"
|
||||
@@ -358,6 +359,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
private var remoteCursorSequence: UInt32?
|
||||
private var videoWasBackgrounded = false
|
||||
private var videoRecoveryTask: Task<Void, Never>?
|
||||
private var videoSettingsTimeoutTask: Task<Void, Never>?
|
||||
private var pendingPresenceRemoteID: String?
|
||||
private var presenceProbeGeneration: UInt64 = 0
|
||||
private var signalConnected = false
|
||||
@@ -529,6 +531,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
private func beginRemoteConnection(_ identifier: String) {
|
||||
resetRemoteVersionCheck()
|
||||
cancelVideoRecovery()
|
||||
resetVideoSettings()
|
||||
cancelPresenceProbe()
|
||||
activeRemoteID = identifier
|
||||
activeDisplayName = ""
|
||||
@@ -621,6 +624,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
|
||||
private func resetConnection() {
|
||||
resetRemoteVersionCheck()
|
||||
resetVideoSettings()
|
||||
connectionFailureMessage = nil
|
||||
cancelVideoRecovery()
|
||||
let wasCheckingPresence = pendingPresenceRemoteID != nil
|
||||
@@ -908,6 +912,61 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
bridge.switch(toDisplay: index)
|
||||
}
|
||||
|
||||
var canChangeVideoSettings: Bool {
|
||||
isConnected && videoSettings.supported
|
||||
}
|
||||
|
||||
var videoSettingsFeedback: String {
|
||||
if !canChangeVideoSettings {
|
||||
return "连接建立后可调整,需被控端支持。"
|
||||
}
|
||||
if videoSettings.failed { return "调整失败,请重试。" }
|
||||
return videoSettings.pending ? "正在应用…" : ""
|
||||
}
|
||||
|
||||
func updateVideoSettings(quality: Int? = nil, frameRate: Int? = nil,
|
||||
preference: Int? = nil) {
|
||||
guard hasNetworkConsent, canChangeVideoSettings else { return }
|
||||
var settings = videoSettings.selection
|
||||
if let quality { settings.quality = quality }
|
||||
if let frameRate { settings.frameRate = frameRate }
|
||||
if let preference { settings.preference = preference }
|
||||
guard let requestID = videoSettings.begin(settings) else { return }
|
||||
videoSettingsTimeoutTask?.cancel()
|
||||
bridge.sendVideoSettings(quality: settings.quality,
|
||||
frameRate: settings.frameRate,
|
||||
preference: settings.preference,
|
||||
requestID: requestID)
|
||||
videoSettingsTimeoutTask = Task { @MainActor [weak self] in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 5_000_000_000)
|
||||
} catch { return }
|
||||
self?.videoSettings.expire(requestID: requestID)
|
||||
}
|
||||
}
|
||||
|
||||
private func resetVideoSettings() {
|
||||
videoSettingsTimeoutTask?.cancel()
|
||||
videoSettingsTimeoutTask = nil
|
||||
videoSettings = RemoteVideoSettingsState(
|
||||
preference: videoAdaptationPolicy.bridgeValue.rawValue)
|
||||
}
|
||||
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||||
didReceiveVideoSettingsQuality quality: Int,
|
||||
frameRate: Int, preference: Int,
|
||||
requestID: UInt32, accepted: Bool) {
|
||||
guard hasNetworkConsent, isConnected else { return }
|
||||
videoSettings.receive(RemoteVideoSettings(quality: quality,
|
||||
frameRate: frameRate,
|
||||
preference: preference),
|
||||
requestID: requestID, accepted: accepted)
|
||||
if !videoSettings.pending {
|
||||
videoSettingsTimeoutTask?.cancel()
|
||||
videoSettingsTimeoutTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func resetRemoteCursorState() {
|
||||
remoteCursorVisible = true
|
||||
remoteCursorShape = 0
|
||||
@@ -1003,6 +1062,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
case 1:
|
||||
connectionStatus = "已连接"
|
||||
sessionVisible = true
|
||||
if videoSettings.requestID == 0 { updateVideoSettings() }
|
||||
checkRemoteVersionIfNeeded()
|
||||
recordSuccessfulConnectionIfNeeded()
|
||||
audioPlayer.setEnabled(audioEnabled)
|
||||
@@ -1094,7 +1154,16 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
didReceiveHostName hostName: String,
|
||||
appVersion: String,
|
||||
displayNames remoteDisplayNames: [String],
|
||||
displaySizes remoteDisplaySizes: [NSValue]) {
|
||||
displaySizes remoteDisplaySizes: [NSValue],
|
||||
supportsVideoSettings: Bool) {
|
||||
let previouslySupported = videoSettings.supported
|
||||
videoSettings.setSupported(supportsVideoSettings)
|
||||
if !supportsVideoSettings {
|
||||
videoSettingsTimeoutTask?.cancel()
|
||||
videoSettingsTimeoutTask = nil
|
||||
} else if !previouslySupported {
|
||||
updateVideoSettings()
|
||||
}
|
||||
remoteHostInfoReceived = true
|
||||
remoteAppVersion = appVersion
|
||||
checkRemoteVersionIfNeeded()
|
||||
|
||||
@@ -277,7 +277,7 @@ struct RemoteSessionView: View {
|
||||
)
|
||||
let panelSize = CGSize(
|
||||
width: min(340, max(280, containerSize.width - 160)),
|
||||
height: min(250, max(220, containerSize.height - 48))
|
||||
height: min(310, max(0, containerSize.height - 16))
|
||||
)
|
||||
let panelCenter = floatingPanelCenter(
|
||||
orbCenter: orbCenter,
|
||||
@@ -1075,6 +1075,7 @@ private struct CrossDeskStatusOrb: View {
|
||||
|
||||
private struct FloatingSessionMenu: View {
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
@State private var showingVideoSettings = false
|
||||
let showKeyboard: () -> Void
|
||||
let chooseFile: () -> Void
|
||||
let close: () -> Void
|
||||
@@ -1086,15 +1087,28 @@ private struct FloatingSessionMenu: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 9) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(session.isConnected ? Color.green : Color.orange)
|
||||
.frame(width: 9, height: 9)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(session.connectionStatus)
|
||||
if showingVideoSettings {
|
||||
Button {
|
||||
showingVideoSettings = false
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.frame(width: 28, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("返回控制栏")
|
||||
Text("画面设置")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(statusDetail)
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(session.isConnected ? Color.green : Color.orange)
|
||||
.frame(width: 9, height: 9)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(session.connectionStatus)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(statusDetail)
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button(action: close) {
|
||||
@@ -1103,65 +1117,84 @@ private struct FloatingSessionMenu: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("关闭控制栏")
|
||||
}
|
||||
|
||||
LazyVGrid(columns: columns, spacing: 7) {
|
||||
FloatingControlButton(title: "键盘", symbol: "keyboard",
|
||||
action: showKeyboard)
|
||||
if showingVideoSettings, !session.videoSettingsFeedback.isEmpty {
|
||||
Text(session.videoSettingsFeedback)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Menu {
|
||||
ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in
|
||||
Button {
|
||||
session.selectDisplay(index)
|
||||
} label: {
|
||||
if index == session.selectedDisplay {
|
||||
Label(name, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(name)
|
||||
ScrollView {
|
||||
if showingVideoSettings {
|
||||
FloatingVideoSettings(session: session)
|
||||
} else {
|
||||
VStack(spacing: 9) {
|
||||
LazyVGrid(columns: columns, spacing: 7) {
|
||||
FloatingControlButton(title: "键盘", symbol: "keyboard",
|
||||
action: showKeyboard)
|
||||
|
||||
Menu {
|
||||
ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in
|
||||
Button {
|
||||
session.selectDisplay(index)
|
||||
} label: {
|
||||
if index == session.selectedDisplay {
|
||||
Label(name, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
FloatingControlLabel(title: "显示器", symbol: "display")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
FloatingControlButton(title: "画面设置", symbol: "slider.horizontal.3") {
|
||||
showingVideoSettings = true
|
||||
}
|
||||
FloatingControlButton(
|
||||
title: session.audioEnabled ? "声音" : "静音",
|
||||
symbol: session.audioEnabled ? "speaker.wave.2" : "speaker.slash",
|
||||
action: session.toggleAudio
|
||||
)
|
||||
FloatingControlButton(
|
||||
title: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标",
|
||||
symbol: "computermouse",
|
||||
action: {
|
||||
session.mouseControlMode = session.mouseControlMode == .relative
|
||||
? .absolute : .relative
|
||||
}
|
||||
)
|
||||
FloatingControlButton(title: "发送文件",
|
||||
symbol: "folder.badge.plus",
|
||||
action: chooseFile)
|
||||
FloatingControlButton(title: "Ctrl+Alt+Del",
|
||||
symbol: "lock.trianglebadge.exclamationmark",
|
||||
action: session.bridge.sendSecureAttentionSequence)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
FloatingControlLabel(title: "显示器", symbol: "display")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
FloatingControlButton(
|
||||
title: session.audioEnabled ? "声音" : "静音",
|
||||
symbol: session.audioEnabled ? "speaker.wave.2" : "speaker.slash",
|
||||
action: session.toggleAudio
|
||||
)
|
||||
FloatingControlButton(
|
||||
title: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标",
|
||||
symbol: "computermouse",
|
||||
action: {
|
||||
session.mouseControlMode = session.mouseControlMode == .relative
|
||||
? .absolute : .relative
|
||||
}
|
||||
)
|
||||
FloatingControlButton(title: "发送文件",
|
||||
symbol: "folder.badge.plus",
|
||||
action: chooseFile)
|
||||
FloatingControlButton(title: "Ctrl+Alt+Del",
|
||||
symbol: "lock.trianglebadge.exclamationmark",
|
||||
action: session.bridge.sendSecureAttentionSequence)
|
||||
}
|
||||
|
||||
if !session.transferStatus.isEmpty || !session.clipboardStatus.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
Text(!session.transferStatus.isEmpty
|
||||
? session.transferStatus : session.clipboardStatus)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
if session.transferProgress > 0 && session.transferProgress < 1 {
|
||||
ProgressView(value: session.transferProgress)
|
||||
.frame(width: 60)
|
||||
}
|
||||
if let file = session.receivedFileURL {
|
||||
ShareLink(item: file) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
if !session.transferStatus.isEmpty || !session.clipboardStatus.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
Text(!session.transferStatus.isEmpty
|
||||
? session.transferStatus : session.clipboardStatus)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
if session.transferProgress > 0 && session.transferProgress < 1 {
|
||||
ProgressView(value: session.transferProgress)
|
||||
.frame(width: 60)
|
||||
}
|
||||
if let file = session.receivedFileURL {
|
||||
ShareLink(item: file) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1201,6 +1234,48 @@ private struct FloatingSessionMenu: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct FloatingVideoSettings: View {
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
settingRow("画面质量", options: ["低", "中", "高"],
|
||||
values: [0, 1, 2], selection: Binding(
|
||||
get: { session.videoSettings.selection.quality },
|
||||
set: { session.updateVideoSettings(quality: $0) }
|
||||
))
|
||||
settingRow("画面采集帧率", options: ["30 fps", "60 fps"],
|
||||
values: [30, 60], selection: Binding(
|
||||
get: { session.videoSettings.selection.frameRate },
|
||||
set: { session.updateVideoSettings(frameRate: $0) }
|
||||
))
|
||||
settingRow("画面偏好", options: VideoAdaptationPolicy.allCases.map(\.title),
|
||||
values: VideoAdaptationPolicy.allCases.map { $0.bridgeValue.rawValue },
|
||||
selection: Binding(
|
||||
get: { session.videoSettings.selection.preference },
|
||||
set: { session.updateVideoSettings(preference: $0) }
|
||||
))
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private func settingRow(_ title: String, options: [String], values: [Int],
|
||||
selection: Binding<Int>) -> some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Picker(title, selection: selection) {
|
||||
ForEach(options.indices, id: \.self) { index in
|
||||
Text(options[index]).tag(values[index])
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(!session.canChangeVideoSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FloatingControlButton: View {
|
||||
let title: String
|
||||
let symbol: String
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Values match crossdesk::VideoSettings on the reliable control stream.
|
||||
struct RemoteVideoSettings: Equatable {
|
||||
var quality = 2
|
||||
var frameRate = 60
|
||||
var preference = 1
|
||||
|
||||
var isValid: Bool {
|
||||
(0...2).contains(quality) && [30, 60].contains(frameRate) &&
|
||||
(0...2).contains(preference)
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteVideoSettingsState {
|
||||
private(set) var supported = false
|
||||
private(set) var selection: RemoteVideoSettings
|
||||
private(set) var applied: RemoteVideoSettings
|
||||
private(set) var requestID: UInt32 = 0
|
||||
private(set) var pending = false
|
||||
private(set) var failed = false
|
||||
private var appliedRequestID: UInt32 = 0
|
||||
|
||||
init(preference: Int = 1) {
|
||||
let settings = RemoteVideoSettings(preference: preference)
|
||||
selection = settings
|
||||
applied = settings
|
||||
}
|
||||
|
||||
mutating func setSupported(_ value: Bool) {
|
||||
supported = value
|
||||
if !value {
|
||||
selection = applied
|
||||
pending = false
|
||||
failed = false
|
||||
}
|
||||
}
|
||||
|
||||
mutating func begin(_ settings: RemoteVideoSettings) -> UInt32? {
|
||||
guard supported, settings.isValid else { return nil }
|
||||
requestID &+= 1
|
||||
selection = settings
|
||||
pending = true
|
||||
failed = false
|
||||
return requestID
|
||||
}
|
||||
|
||||
mutating func receive(_ settings: RemoteVideoSettings,
|
||||
requestID responseID: UInt32, accepted: Bool) {
|
||||
guard supported, settings.isValid,
|
||||
Int32(bitPattern: requestID &- responseID) >= 0 else { return }
|
||||
// A superseded success still establishes the rollback value for a
|
||||
// newer request. A stale response must never replace its selection.
|
||||
if accepted, Int32(bitPattern: responseID &- appliedRequestID) >= 0 {
|
||||
applied = settings
|
||||
appliedRequestID = responseID
|
||||
}
|
||||
guard responseID == requestID else { return }
|
||||
pending = false
|
||||
failed = !accepted
|
||||
selection = applied
|
||||
}
|
||||
|
||||
mutating func expire(requestID expiredID: UInt32) {
|
||||
guard pending, expiredID == requestID else { return }
|
||||
selection = applied
|
||||
pending = false
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Exercise the production live-video settings state with the Apple Swift runtime."""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == "darwin", "requires the Apple Swift runtime")
|
||||
class VideoSettingsTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temp = tempfile.TemporaryDirectory(prefix="crossdesk-video-settings-tests-")
|
||||
cls.addClassCleanup(cls.temp.cleanup)
|
||||
root = Path(cls.temp.name)
|
||||
harness = root / "VideoSettingsTests.swift"
|
||||
harness.write_text(r'''
|
||||
func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
if !condition() { fatalError(message) }
|
||||
}
|
||||
|
||||
@main
|
||||
enum VideoSettingsTests {
|
||||
static func main() {
|
||||
let test = CommandLine.arguments[1]
|
||||
let initial = RemoteVideoSettings(preference: 0)
|
||||
let first = RemoteVideoSettings(quality: 1, frameRate: 30, preference: 2)
|
||||
let second = RemoteVideoSettings(quality: 0, frameRate: 60, preference: 1)
|
||||
var state = RemoteVideoSettingsState(preference: 0)
|
||||
expect(state.selection == initial, "Session starts with the saved preference")
|
||||
expect(state.begin(first) == nil, "Old hosts cannot receive live settings")
|
||||
state.setSupported(true)
|
||||
|
||||
if test == "invalid" {
|
||||
for quality in -1...3 {
|
||||
for frameRate in [0, 24, 30, 60, 120] {
|
||||
for preference in -1...3 {
|
||||
let settings = RemoteVideoSettings(quality: quality,
|
||||
frameRate: frameRate, preference: preference)
|
||||
let valid = (0...2).contains(quality) &&
|
||||
[30, 60].contains(frameRate) && (0...2).contains(preference)
|
||||
expect((state.begin(settings) != nil) == valid,
|
||||
"Only desktop protocol values can be submitted")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let firstID = state.begin(first)!
|
||||
expect(state.pending && state.selection == first, "Selection is optimistic")
|
||||
expect(state.applied == initial, "Submission is not confirmation")
|
||||
|
||||
if test == "accepted" {
|
||||
state.receive(first, requestID: firstID, accepted: true)
|
||||
expect(!state.pending && !state.failed && state.selection == first,
|
||||
"An accepted response commits the selection")
|
||||
state.expire(requestID: firstID)
|
||||
expect(!state.failed, "A completed request cannot time out")
|
||||
} else if test == "rejected" {
|
||||
state.receive(first, requestID: firstID, accepted: false)
|
||||
expect(state.failed && !state.pending && state.selection == initial,
|
||||
"Rejection rolls back the optimistic selection")
|
||||
expect(state.begin(first) != nil && !state.failed && state.pending,
|
||||
"A failed selection can be retried")
|
||||
} else if test == "superseded" {
|
||||
let secondID = state.begin(second)!
|
||||
state.receive(first, requestID: firstID, accepted: true)
|
||||
state.expire(requestID: firstID)
|
||||
expect(state.selection == second && state.pending,
|
||||
"An old reply or timeout cannot overwrite a newer selection")
|
||||
state.receive(second, requestID: secondID, accepted: false)
|
||||
expect(state.selection == first && state.failed,
|
||||
"Rollback uses the most recently acknowledged settings")
|
||||
} else if test == "out_of_order" {
|
||||
let secondID = state.begin(second)!
|
||||
state.receive(second, requestID: secondID, accepted: true)
|
||||
state.receive(first, requestID: firstID, accepted: true)
|
||||
expect(state.selection == second && state.applied == second && !state.failed,
|
||||
"A stale success cannot revert a newer success")
|
||||
state.receive(first, requestID: secondID + 1, accepted: true)
|
||||
expect(state.applied == second, "Unsent request IDs are ignored")
|
||||
} else if test == "timeout" {
|
||||
state.expire(requestID: firstID)
|
||||
expect(state.failed && !state.pending && state.selection == initial,
|
||||
"Timeout restores the last applied settings")
|
||||
state.receive(first, requestID: firstID, accepted: true)
|
||||
expect(state.selection == first && !state.failed,
|
||||
"A late confirmation reconciles the actual remote state")
|
||||
} else if test == "unsupported" {
|
||||
state.setSupported(false)
|
||||
state.receive(first, requestID: firstID, accepted: true)
|
||||
state.expire(requestID: firstID)
|
||||
expect(!state.pending && !state.failed && state.selection == initial,
|
||||
"Losing capability clears pending feedback and ignores replies")
|
||||
expect(state.begin(second) == nil, "Unavailable controls cannot send")
|
||||
state = RemoteVideoSettingsState(preference: 2)
|
||||
expect(!state.supported && state.requestID == 0 &&
|
||||
state.selection == RemoteVideoSettings(preference: 2),
|
||||
"A new session resets capability, request IDs and settings")
|
||||
} else {
|
||||
fatalError("Unknown test")
|
||||
}
|
||||
}
|
||||
}
|
||||
''')
|
||||
source = Path(__file__).resolve().parents[1] / "CrossDeskMobile/RemoteVideoSettings.swift"
|
||||
cls.binary = root / "video-settings-tests"
|
||||
subprocess.run(["xcrun", "swiftc", str(source), str(harness),
|
||||
"-o", str(cls.binary)], check=True, capture_output=True, text=True)
|
||||
|
||||
def test_protocol_values(self):
|
||||
self.run_case("invalid")
|
||||
|
||||
def test_acceptance(self):
|
||||
self.run_case("accepted")
|
||||
|
||||
def test_rejection_and_retry(self):
|
||||
self.run_case("rejected")
|
||||
|
||||
def test_rapid_changes(self):
|
||||
self.run_case("superseded")
|
||||
|
||||
def test_out_of_order_responses(self):
|
||||
self.run_case("out_of_order")
|
||||
|
||||
def test_timeout_and_late_confirmation(self):
|
||||
self.run_case("timeout")
|
||||
|
||||
def test_unsupported_and_new_session(self):
|
||||
self.run_case("unsupported")
|
||||
|
||||
def run_case(self, name):
|
||||
subprocess.run([str(self.binary), name], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user