forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPushToTalkButtonTrigger.swift
More file actions
125 lines (113 loc) · 5.1 KB
/
Copy pathPushToTalkButtonTrigger.swift
File metadata and controls
125 lines (113 loc) · 5.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import Foundation
import VoiceTurnDomain
/// Push-to-talk driven by a click instead of a held shortcut.
///
/// This is a trigger, not a capture path: it decides which existing
/// `PushToTalkManager` transition a click means and then takes it, so a button
/// enters the exact same state machine, microphone lifecycle, and transcript
/// writer the keyboard shortcut does.
///
/// A click carries no "hold", so it maps onto the hands-free lane the
/// double-tap shortcut already drives: the first click starts locked listening
/// and the next click commits the same turn.
/// What a click on a visible push-to-talk button must do. Derived from the
/// authoritative reducer phase — never a second lifecycle state of its own.
enum PushToTalkClickAction: Equatable {
/// Start a hands-free (locked) turn, barging into any active response.
case beginHandsFree
/// Commit the turn that is already capturing.
case finalize
/// The turn is already committing; a click must neither restart nor re-commit it.
case ignore
}
/// How a push-to-talk button renders. One projection of the same reducer phase
/// the floating bar reads, so a composer icon can never disagree with the bar.
enum PushToTalkButtonState: Equatable {
case idle
case listening
case committing
/// A new turn is barred by the free-tier monthly chat limit. Still clickable:
/// the click must surface the usage-limit popup rather than silently no-op.
case blocked
}
extension PushToTalkManager {
// MARK: - Click policy
/// The action a push-to-talk button click resolves to for a given
/// authoritative phase. Any capturing phase commits; a phase that is already
/// committing is inert; everything else opens a fresh hands-free turn.
nonisolated static func clickAction(phase: VoiceTurnPhase?) -> PushToTalkClickAction {
switch phase {
case .recording, .lockedRecording, .pendingLockDecision:
return .finalize
case .finalizing:
return .ignore
case .idle, .awaitingResponse, .awaitingTools, .awaitingJournal, .playing, .terminal, .none:
return .beginHandsFree
}
}
/// Whether entering locked listening may lock the turn that already exists.
/// `.lock` is only a valid transition out of a capturing phase; every other
/// active turn — response, tools, journal, playback — must be superseded by a
/// fresh locked turn instead. Publishing `.lock` into one of those is an
/// invalid transition that would leave the manager opening a microphone for a
/// turn that never entered a recording phase. A click reaches those phases far
/// more often than a double-tap does: the mic button sits in the composer the
/// user is already looking at while the previous answer is still in flight.
nonisolated static func locksExistingTurn(phase: VoiceTurnPhase?) -> Bool {
switch phase {
case .recording, .pendingLockDecision:
return true
case .lockedRecording, .finalizing, .awaitingResponse, .awaitingTools, .awaitingJournal,
.playing, .idle, .terminal, .none:
return false
}
}
/// How a push-to-talk button must render for a given phase. The blocked
/// treatment only ever replaces the idle one: a turn that is already
/// capturing must stay stoppable even if the limit is reached mid-turn.
nonisolated static func buttonState(
phase: VoiceTurnPhase?,
isUsageLimitBlocked: Bool
) -> PushToTalkButtonState {
switch clickAction(phase: phase) {
case .finalize:
return .listening
case .ignore:
return .committing
case .beginHandsFree:
return isUsageLimitBlocked ? .blocked : .idle
}
}
// MARK: - Button surface
/// Read-only projection of the same rule `isBlockedByUsageLimit()` enforces,
/// so a button can render the blocked treatment without posting the popup.
/// Posting stays a side effect of actually attempting a turn.
var isPushToTalkUsageLimitBlocked: Bool {
!APIKeyService.isByokActive && FloatingBarUsageLimiter.shared.isLimitReached
}
/// How a visible push-to-talk button must render right now.
var pushToTalkButtonState: PushToTalkButtonState {
Self.buttonState(phase: phase, isUsageLimitBlocked: isPushToTalkUsageLimitBlocked)
}
/// Entry point for a visible push-to-talk button (chat composer, ask bar).
///
/// The first click enters locked listening exactly as a double-tap does; the
/// next click finalizes that turn exactly as a tap while locked does. A
/// blocked click still reaches `enterLockedListening()`, whose usage-limit
/// gate posts the same popup the shortcut does — silence is never an option.
func togglePushToTalkFromButton() {
switch Self.clickAction(phase: phase) {
case .finalize:
finalize()
case .ignore:
log("PushToTalkManager: button click ignored — turn is already committing")
case .beginHandsFree:
// Reveal the bar only when a turn can actually start, so a blocked click
// lands on the usage-limit popup instead of an empty listening surface.
if !isPushToTalkUsageLimitBlocked, !FloatingControlBarManager.shared.isVisible {
FloatingControlBarManager.shared.show()
}
enterLockedListening()
}
}
}