feat(flow): add Picture in Picture keep-alive mode
- Add FlowKeepAliveMode (Dynamic Island vs PiP) with iCloud sync - Settings: keep-alive picker; inactivity timeout only for Live Activity - PiP: waveform sample-buffer controller, mic released between utterances - PiP sessions have no idle expiry; user closing PiP ends the session - FlowSessionManager branches hostReady, session start, and utterance paths - UIBackgroundModes picture-in-picture; bilingual strings and tests Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
@@ -50,6 +50,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let settingsCloudPayloadV2 = "config.settings.cloudPayload.v2"
|
||||
/// When true, the host app auto-returns to the source app after a cold-start handoff.
|
||||
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
|
||||
/// Raw `FlowKeepAliveMode` value; mutually exclusive PiP vs Live Activity path.
|
||||
public static let flowKeepAliveMode = "config.flowKeepAliveMode"
|
||||
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
|
||||
public static let flowInactivityDuration = "config.flowInactivityDuration"
|
||||
/// One-shot: remap previous product defaults (30m / 10m) → 5m.
|
||||
@@ -88,6 +90,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public var settingsICloudSyncEnabled: Bool
|
||||
/// Auto-return to the host app after `startflow` cold start (default on).
|
||||
public var flowSkipAppSwitch: Bool
|
||||
/// PiP vs Live Activity keep-alive strategy (mutually exclusive).
|
||||
public var flowKeepAliveMode: FlowKeepAliveMode
|
||||
/// Idle timeout before the Flow session ends; resets on each utterance.
|
||||
public var flowInactivityDuration: FlowInactivityDuration
|
||||
/// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
|
||||
@@ -262,6 +266,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
}
|
||||
return defaults.bool(forKey: Keys.flowSkipAppSwitch)
|
||||
}(),
|
||||
flowKeepAliveMode: FlowKeepAliveMode.fromStored(
|
||||
defaults.string(forKey: Keys.flowKeepAliveMode)
|
||||
),
|
||||
flowInactivityDuration: FlowInactivityDuration.fromStored(
|
||||
defaults.string(forKey: Keys.flowInactivityDuration)
|
||||
),
|
||||
@@ -370,6 +377,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
|
||||
defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled)
|
||||
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
|
||||
defaults.set(flowKeepAliveMode.rawValue, forKey: Keys.flowKeepAliveMode)
|
||||
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
|
||||
defaults.set(localASRCustomLanguageModelEnabled, forKey: Keys.localASRCustomLanguageModelEnabled)
|
||||
defaults.set(personalDictionaryICloudSyncEnabled, forKey: Keys.personalDictionaryICloudSyncEnabled)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// FlowKeepAliveMode.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// User-selectable Flow session keep-alive strategy (mutually exclusive).
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum FlowKeepAliveMode: String, CaseIterable, Identifiable, Sendable, Codable {
|
||||
/// Continuous audio capture + Live Activity (current default behaviour).
|
||||
case liveActivity = "liveActivity"
|
||||
/// Picture-in-picture waveform keep-alive; mic released between utterances.
|
||||
case pictureInPicture = "pictureInPicture"
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
/// Existing installs keep the Live Activity / continuous-capture path.
|
||||
public static let `default`: FlowKeepAliveMode = .liveActivity
|
||||
|
||||
public var labelKey: String {
|
||||
switch self {
|
||||
case .liveActivity: return "settings.flow.keepAlive.liveActivity"
|
||||
case .pictureInPicture: return "settings.flow.keepAlive.pictureInPicture"
|
||||
}
|
||||
}
|
||||
|
||||
public var subtitleKey: String {
|
||||
switch self {
|
||||
case .liveActivity: return "settings.flow.keepAlive.liveActivity.subtitle"
|
||||
case .pictureInPicture: return "settings.flow.keepAlive.pictureInPicture.subtitle"
|
||||
}
|
||||
}
|
||||
|
||||
public static func fromStored(_ raw: String?) -> FlowKeepAliveMode {
|
||||
guard let raw, let value = FlowKeepAliveMode(rawValue: raw) else {
|
||||
return .default
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -237,7 +237,22 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Idle window before an active Flow session expires; resets on each utterance.
|
||||
/// PiP vs Live Activity keep-alive (mutually exclusive).
|
||||
@Published public var flowKeepAliveMode: FlowKeepAliveMode {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, flowKeepAliveMode != configuration.flowKeepAliveMode else { return }
|
||||
configuration.flowKeepAliveMode = flowKeepAliveMode
|
||||
if flowKeepAliveMode == .pictureInPicture {
|
||||
configuration.flowSkipAppSwitch = true
|
||||
if flowSkipAppSwitch != true {
|
||||
flowSkipAppSwitch = true
|
||||
}
|
||||
}
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
/// Idle window before an active Flow session expires; Live Activity mode only.
|
||||
@Published public var flowInactivityDuration: FlowInactivityDuration {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration,
|
||||
@@ -347,6 +362,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
polishIntensity = configuration.polishIntensity
|
||||
llmThinkingEnabled = configuration.llmThinkingEnabled
|
||||
flowSkipAppSwitch = configuration.flowSkipAppSwitch
|
||||
flowKeepAliveMode = configuration.flowKeepAliveMode
|
||||
flowInactivityDuration = configuration.flowInactivityDuration
|
||||
localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled
|
||||
isSyncingProviderAPIKey = true
|
||||
@@ -433,6 +449,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
polishIntensity = fresh.polishIntensity
|
||||
llmThinkingEnabled = fresh.llmThinkingEnabled
|
||||
flowSkipAppSwitch = fresh.flowSkipAppSwitch
|
||||
flowKeepAliveMode = fresh.flowKeepAliveMode
|
||||
flowInactivityDuration = fresh.flowInactivityDuration
|
||||
localASRCustomLanguageModelEnabled = fresh.localASRCustomLanguageModelEnabled
|
||||
isSyncingProviderAPIKey = true
|
||||
|
||||
@@ -29,6 +29,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
public var polishIntensity: SyncedField<PolishIntensity>
|
||||
public var llmThinkingEnabled: SyncedField<Bool>
|
||||
public var flowSkipAppSwitch: SyncedField<Bool>
|
||||
public var flowKeepAliveMode: SyncedField<FlowKeepAliveMode>
|
||||
public var flowInactivityDuration: SyncedField<FlowInactivityDuration>
|
||||
|
||||
public init(
|
||||
@@ -50,6 +51,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
polishIntensity: SyncedField<PolishIntensity>,
|
||||
llmThinkingEnabled: SyncedField<Bool>,
|
||||
flowSkipAppSwitch: SyncedField<Bool>,
|
||||
flowKeepAliveMode: SyncedField<FlowKeepAliveMode>,
|
||||
flowInactivityDuration: SyncedField<FlowInactivityDuration>
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
@@ -70,6 +72,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
self.polishIntensity = polishIntensity
|
||||
self.llmThinkingEnabled = llmThinkingEnabled
|
||||
self.flowSkipAppSwitch = flowSkipAppSwitch
|
||||
self.flowKeepAliveMode = flowKeepAliveMode
|
||||
self.flowInactivityDuration = flowInactivityDuration
|
||||
}
|
||||
|
||||
@@ -92,6 +95,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
case polishIntensity
|
||||
case llmThinkingEnabled
|
||||
case flowSkipAppSwitch
|
||||
case flowKeepAliveMode
|
||||
case flowInactivityDuration
|
||||
}
|
||||
|
||||
@@ -124,6 +128,14 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
forKey: .llmThinkingEnabled
|
||||
) ?? SyncedField(value: false, updatedAt: polishIntensity.updatedAt, deviceID: polishIntensity.deviceID)
|
||||
flowSkipAppSwitch = try container.decode(SyncedField<Bool>.self, forKey: .flowSkipAppSwitch)
|
||||
flowKeepAliveMode = try container.decodeIfPresent(
|
||||
SyncedField<FlowKeepAliveMode>.self,
|
||||
forKey: .flowKeepAliveMode
|
||||
) ?? SyncedField(
|
||||
value: .liveActivity,
|
||||
updatedAt: flowSkipAppSwitch.updatedAt,
|
||||
deviceID: flowSkipAppSwitch.deviceID
|
||||
)
|
||||
flowInactivityDuration = try container.decode(
|
||||
SyncedField<FlowInactivityDuration>.self,
|
||||
forKey: .flowInactivityDuration
|
||||
@@ -171,6 +183,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
polishIntensity.updatedAt,
|
||||
llmThinkingEnabled.updatedAt,
|
||||
flowSkipAppSwitch.updatedAt,
|
||||
flowKeepAliveMode.updatedAt,
|
||||
flowInactivityDuration.updatedAt,
|
||||
].max() ?? .distantPast
|
||||
}
|
||||
@@ -208,6 +221,7 @@ public extension SyncedAppSettingsV2 {
|
||||
polishIntensity: field(configuration.polishIntensity),
|
||||
llmThinkingEnabled: field(configuration.llmThinkingEnabled),
|
||||
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
|
||||
flowKeepAliveMode: field(configuration.flowKeepAliveMode),
|
||||
flowInactivityDuration: field(configuration.flowInactivityDuration)
|
||||
)
|
||||
}
|
||||
@@ -237,6 +251,7 @@ public extension SyncedAppSettingsV2 {
|
||||
polishIntensity: field(legacy.polishIntensity),
|
||||
llmThinkingEnabled: field(false),
|
||||
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
|
||||
flowKeepAliveMode: field(.liveActivity),
|
||||
flowInactivityDuration: field(legacy.flowInactivityDuration)
|
||||
)
|
||||
}
|
||||
@@ -269,6 +284,7 @@ public extension SyncedAppSettingsV2 {
|
||||
polishIntensity: .merge(local: local.polishIntensity, remote: remote.polishIntensity),
|
||||
llmThinkingEnabled: .merge(local: local.llmThinkingEnabled, remote: remote.llmThinkingEnabled),
|
||||
flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch),
|
||||
flowKeepAliveMode: .merge(local: local.flowKeepAliveMode, remote: remote.flowKeepAliveMode),
|
||||
flowInactivityDuration: .merge(
|
||||
local: local.flowInactivityDuration,
|
||||
remote: remote.flowInactivityDuration
|
||||
@@ -294,6 +310,7 @@ public extension SyncedAppSettingsV2 {
|
||||
configuration.polishIntensity = polishIntensity.value
|
||||
configuration.llmThinkingEnabled = llmThinkingEnabled.value
|
||||
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
|
||||
configuration.flowKeepAliveMode = flowKeepAliveMode.value
|
||||
configuration.flowInactivityDuration = flowInactivityDuration.value
|
||||
}
|
||||
|
||||
@@ -321,6 +338,7 @@ public extension SyncedAppSettingsV2 {
|
||||
patch(©.polishIntensity, value: configuration.polishIntensity)
|
||||
patch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
patch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
patch(©.flowKeepAliveMode, value: configuration.flowKeepAliveMode)
|
||||
patch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||
return copy
|
||||
}
|
||||
@@ -351,6 +369,7 @@ public extension SyncedAppSettingsV2 {
|
||||
touch(©.polishIntensity, value: configuration.polishIntensity)
|
||||
touch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
touch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
touch(©.flowKeepAliveMode, value: configuration.flowKeepAliveMode)
|
||||
touch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||
return copy
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user