feat: flow session reliability, keyboard UX, and audio pre-roll fix
Renew voice sessions while the host app stays foreground, auto-start flow from the keyboard with a Start action, and prevent leading audio loss via pre-roll buffering and faster signal polling.
This commit is contained in:
@@ -66,6 +66,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
private var flowSessionMonitorTask: Task<Void, Never>?
|
||||
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
|
||||
private var isAwaitingFlowResult = false
|
||||
private var lastFlowAutoStartAttempt: TimeInterval = 0
|
||||
private static let flowAutoStartCooldown: TimeInterval = 20
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
@@ -121,6 +123,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state.endRecording = { [weak self] in self?.pressEnded() }
|
||||
state.tapMic = { [weak self] in self?.toggleRecording() }
|
||||
state.openSettings = { [weak self] in self?.openHostApp() }
|
||||
state.startFlowSession = { [weak self] in self?.beginFlowStart() }
|
||||
state.setMode = { [weak self] m in self?.persistMode(m) }
|
||||
state.setLocale = { [weak self] l in self?.persistLocale(l) }
|
||||
state.setEngineMode = { [weak self] m in self?.persistEngineMode(m) }
|
||||
@@ -199,6 +202,24 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
}
|
||||
wasFlowSessionActive = active
|
||||
|
||||
if !active {
|
||||
maybeAutoStartFlowSession()
|
||||
}
|
||||
}
|
||||
|
||||
/// When the host session is down, proactively jump to the app to start it.
|
||||
private func maybeAutoStartFlowSession() {
|
||||
guard !FlowSessionBridge.isSessionActive() else { return }
|
||||
guard !isPendingFlowStart, !isFlowRecording, !isAwaitingFlowResult else { return }
|
||||
guard state.mode != .off else { return }
|
||||
guard hasFullAccess, AppGroup.isAvailable else { return }
|
||||
guard case .idle = state.phase else { return }
|
||||
|
||||
let now = Date().timeIntervalSince1970
|
||||
guard now - lastFlowAutoStartAttempt >= Self.flowAutoStartCooldown else { return }
|
||||
lastFlowAutoStartAttempt = now
|
||||
beginFlowStart()
|
||||
}
|
||||
|
||||
private func showFlowSessionExpiredHint() {
|
||||
@@ -303,6 +324,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
|
||||
private func beginFlowStart() {
|
||||
guard !isPendingFlowStart else { return }
|
||||
isPendingFlowStart = true
|
||||
isFlowRecording = false
|
||||
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
|
||||
|
||||
@@ -19,7 +19,13 @@ import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
private enum KeyboardLayoutMetrics {
|
||||
static let sideActionButtonSize: CGFloat = 44
|
||||
static let sideActionButtonSize: CGFloat = 53
|
||||
static let sideActionIconSize: CGFloat = 19
|
||||
static let sideSpaceBarWidth: CGFloat = 19
|
||||
static let micFlankMinSpacing: CGFloat = 36
|
||||
static let sideActionStackSpacing: CGFloat = 16
|
||||
/// Outer inset for delete / return·space from screen edges (8 pt → 24 pt, +200%).
|
||||
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
|
||||
}
|
||||
|
||||
public struct KeyboardRootView: View {
|
||||
@@ -36,7 +42,6 @@ public struct KeyboardRootView: View {
|
||||
/// it up.
|
||||
static let totalHeight: CGFloat = 280
|
||||
private static let topBarHeight: CGFloat = 38
|
||||
private static let sideActionStackSpacing: CGFloat = 10
|
||||
|
||||
private var palette: ThemePalette {
|
||||
colorScheme == .dark ? Palette.dark : Palette.light
|
||||
@@ -52,14 +57,16 @@ public struct KeyboardRootView: View {
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
// Let the system UI chrome show through by drawing no background
|
||||
// of our own.
|
||||
.background(Color.clear)
|
||||
.background(keyboardBackground)
|
||||
.frame(height: Self.totalHeight)
|
||||
// Feed the resolved palette to all nested chips/buttons.
|
||||
.environment(\.themePalette, palette)
|
||||
}
|
||||
|
||||
private var keyboardBackground: Color {
|
||||
colorScheme == .dark ? palette.background : Color.clear
|
||||
}
|
||||
|
||||
// MARK: - Top bar
|
||||
|
||||
private var topBar: some View {
|
||||
@@ -99,7 +106,8 @@ public struct KeyboardRootView: View {
|
||||
phase: state.phase,
|
||||
transcript: state.lastTranscript,
|
||||
flowSessionActive: state.flowSessionActive,
|
||||
openSettings: state.openSettings
|
||||
openSettings: state.openSettings,
|
||||
startFlowSession: state.startFlowSession
|
||||
)
|
||||
.frame(height: 22)
|
||||
|
||||
@@ -117,11 +125,13 @@ public struct KeyboardRootView: View {
|
||||
/// HStack vertical alignment keeps delete, mic centre, and the gap
|
||||
/// between return/space on one horizontal axis.
|
||||
private var micActionRow: some View {
|
||||
HStack(alignment: .center, spacing: 20) {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
CircularToolbarButton(systemName: "delete.left", label: "delete") {
|
||||
state.deleteBackward()
|
||||
}
|
||||
|
||||
Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing)
|
||||
|
||||
RecordButton(
|
||||
phase: buttonPhase,
|
||||
level: state.level,
|
||||
@@ -130,7 +140,9 @@ public struct KeyboardRootView: View {
|
||||
)
|
||||
.frame(width: 132, height: 132)
|
||||
|
||||
VStack(spacing: Self.sideActionStackSpacing) {
|
||||
Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing)
|
||||
|
||||
VStack(spacing: KeyboardLayoutMetrics.sideActionStackSpacing) {
|
||||
CircularToolbarButton(systemName: "return", label: "newline") {
|
||||
state.insertNewline()
|
||||
}
|
||||
@@ -139,6 +151,7 @@ public struct KeyboardRootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, KeyboardLayoutMetrics.sideActionHorizontalInset)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
@@ -191,6 +204,7 @@ private struct TranscriptLine: View {
|
||||
let transcript: String
|
||||
let flowSessionActive: Bool
|
||||
let openSettings: () -> Void
|
||||
let startFlowSession: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -201,9 +215,18 @@ private struct TranscriptLine: View {
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
} else {
|
||||
ExtL10n.text("keyboard.flow.sessionInactive")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
HStack(spacing: 6) {
|
||||
ExtL10n.text("keyboard.flow.sessionInactive")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Button(action: startFlowSession) {
|
||||
ExtL10n.text("keyboard.flow.start")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(ExtL10n.text("keyboard.flow.startA11y"))
|
||||
}
|
||||
}
|
||||
case .requestingPermissions:
|
||||
HStack(spacing: 6) {
|
||||
@@ -267,6 +290,7 @@ private struct TranscriptLine: View {
|
||||
// MARK: - Circular toolbar button
|
||||
|
||||
private struct CircularToolbarButton: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let systemName: String?
|
||||
@@ -294,20 +318,26 @@ private struct CircularToolbarButton: View {
|
||||
if spaceStyle {
|
||||
Capsule()
|
||||
.fill(palette.textPrimary)
|
||||
.frame(width: 16, height: 3)
|
||||
.frame(width: KeyboardLayoutMetrics.sideSpaceBarWidth, height: 3)
|
||||
} else if let systemName {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.font(.system(size: KeyboardLayoutMetrics.sideActionIconSize, weight: .medium))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
.frame(width: KeyboardLayoutMetrics.sideActionButtonSize, height: KeyboardLayoutMetrics.sideActionButtonSize)
|
||||
.background(palette.surfaceElevated, in: Circle())
|
||||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||
.background(sideButtonFill, in: Circle())
|
||||
.overlay(Circle().stroke(palette.dividerStrong, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(label))
|
||||
}
|
||||
|
||||
private var sideButtonFill: Color {
|
||||
colorScheme == .dark
|
||||
? Color(red: 0.20, green: 0.20, blue: 0.22)
|
||||
: palette.surfaceElevated
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status badge
|
||||
|
||||
@@ -27,12 +27,12 @@
|
||||
"common.newline" = "Return";
|
||||
|
||||
/* Privacy footnote (onboarding welcome page) */
|
||||
"privacy.audio.title" = "Audio stays on device";
|
||||
"privacy.audio.body" = "Transcribed locally with Apple’s speech engine.";
|
||||
"privacy.network.title" = "Only the polished text is sent";
|
||||
"privacy.network.body" = "Sent to your chosen LLM to add structure & punctuation.";
|
||||
"privacy.audio.title" = "On-device transcription";
|
||||
"privacy.audio.body" = "Powered by the on-device iOS speech engine.";
|
||||
"privacy.network.title" = "Automatic text polish";
|
||||
"privacy.network.body" = "AI formats and polishes your text automatically.";
|
||||
"privacy.universal.title" = "Works everywhere";
|
||||
"privacy.universal.body" = "WeChat, Notes, Mail, ChatGPT, Claude, Cursor — anywhere a keyboard appears.";
|
||||
"privacy.universal.body" = "Chat, email, vibe coding — just speak.";
|
||||
|
||||
/* Home */
|
||||
"home.status.ready" = "Ready";
|
||||
@@ -127,7 +127,9 @@
|
||||
"keyboard.tapToTalkA11y" = "Tap to talk";
|
||||
|
||||
/* Flow session (keyboard) */
|
||||
"keyboard.flow.sessionInactive" = "Open OSGKeyboard to start voice session";
|
||||
"keyboard.flow.sessionInactive" = "Voice session off";
|
||||
"keyboard.flow.start" = "Start";
|
||||
"keyboard.flow.startA11y" = "Start voice session";
|
||||
"keyboard.flow.sessionExpired" = "Voice session ended. Open OSGKeyboard to restart.";
|
||||
"keyboard.flow.startingSession" = "Starting voice session…";
|
||||
"keyboard.flow.transcribing" = "Transcribing…";
|
||||
|
||||
@@ -27,12 +27,12 @@
|
||||
"common.newline" = "换行";
|
||||
|
||||
/* Privacy footnote (onboarding welcome page) */
|
||||
"privacy.audio.title" = "音频不出本机";
|
||||
"privacy.audio.body" = "由 Apple 端侧引擎转录。";
|
||||
"privacy.network.title" = "仅发送润色后文字";
|
||||
"privacy.network.body" = "仅向所选 LLM 发送润色后文字,用于整理结构和标点。";
|
||||
"privacy.audio.title" = "支持本地转写";
|
||||
"privacy.audio.body" = "支持 iOS 本地引擎转录";
|
||||
"privacy.network.title" = "文字自动润色";
|
||||
"privacy.network.body" = "通过 AI 自动对文字进行排版润色";
|
||||
"privacy.universal.title" = "处处可用";
|
||||
"privacy.universal.body" = "微信、备忘录、邮件、ChatGPT、Claude、Cursor — 任何键盘出现的地方。";
|
||||
"privacy.universal.body" = "聊天、邮件、Vibe Coding 动嘴就行";
|
||||
|
||||
/* Home */
|
||||
"home.status.ready" = "就绪";
|
||||
@@ -127,7 +127,9 @@
|
||||
"keyboard.tapToTalkA11y" = "点按说话";
|
||||
|
||||
/* Flow session (keyboard) */
|
||||
"keyboard.flow.sessionInactive" = "请打开 OSGKeyboard 启动语音会话";
|
||||
"keyboard.flow.sessionInactive" = "语音会话未启动";
|
||||
"keyboard.flow.start" = "启动";
|
||||
"keyboard.flow.startA11y" = "启动语音会话";
|
||||
"keyboard.flow.sessionExpired" = "语音会话已结束,请打开 OSGKeyboard 重新启动";
|
||||
"keyboard.flow.startingSession" = "正在启动语音会话…";
|
||||
"keyboard.flow.transcribing" = "识别中…";
|
||||
|
||||
Reference in New Issue
Block a user