Skip to content

Commit 2517094

Browse files
committed
add cancel turn
1 parent 8e6cada commit 2517094

File tree

3 files changed

+95
-26
lines changed

3 files changed

+95
-26
lines changed

app/globals.css

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,16 @@
2323
color: white;
2424
border: none;
2525
border-radius: 50px;
26-
padding: 25px 50px;
27-
font-size: 18px;
26+
height: 40px;
27+
padding: 0 30px;
28+
font-size: 16px;
2829
font-weight: 500;
2930
cursor: pointer;
3031
transition: all 0.3s ease;
3132
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
33+
display: flex;
34+
align-items: center;
35+
justify-content: center;
3236
}
3337

3438
.ptt-button:hover {

app/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ function ControlBar(props: { onConnectButtonClicked: () => void }) {
160160
animate={{ opacity: 1, top: 0 }}
161161
exit={{ opacity: 0, top: "-10px" }}
162162
transition={{ duration: 0.4, ease: [0.09, 1.04, 0.245, 1.055] }}
163-
className="flex h-8 absolute left-1/2 -translate-x-1/2 justify-center items-center gap-4"
163+
className="flex h-8 absolute left-1/2 -translate-x-1/2 justify-center items-center gap-3"
164164
>
165165
<VoiceAssistantControlBar controls={{ leave: false }} />
166166
<PushToTalkButton />

components/PushToTalkButton.tsx

Lines changed: 88 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,87 +3,152 @@ import {
33
useParticipants,
44
} from "@livekit/components-react";
55
import { motion } from "framer-motion";
6-
import { useCallback, useEffect, useRef, useState } from "react";
6+
import { useCallback, useEffect, useRef, useState, MouseEvent } from "react";
77

88
export function PushToTalkButton() {
99
const { localParticipant } = useLocalParticipant();
1010
const participants = useParticipants();
1111
const [isPressed, setIsPressed] = useState(false);
12-
const lastReleaseTime = useRef(0);
12+
const [isOutside, setIsOutside] = useState(false);
13+
const lastActionTime = useRef(0);
1314

14-
// Find agent participant that supports PTT
15+
// find agent participant that supports PTT
1516
const agent = participants.find(
16-
(p) => p.attributes?.["supports-ptt"] === "1"
17+
(p) => p.attributes?.["push-to-talk"] === "1"
1718
);
1819

1920
useEffect(() => {
20-
// start with microphone enabled for PTT agents
21+
// start with microphone disabled for PTT agents
2122
if (agent && localParticipant) {
2223
localParticipant.setMicrophoneEnabled(false);
2324
}
2425
}, [localParticipant, agent]);
2526

26-
const handlePushStart = useCallback(async () => {
27+
// when user presses the button
28+
const handleMouseDown = useCallback(async (e: MouseEvent<HTMLButtonElement>) => {
29+
e.preventDefault(); // prevent default browser behavior
30+
2731
if (!agent || !localParticipant) return;
2832

33+
console.log("starting turn");
2934
try {
3035
await localParticipant.setMicrophoneEnabled(true);
3136
await localParticipant.performRpc({
3237
destinationIdentity: agent.identity,
33-
method: "ptt.start",
38+
method: "start_turn",
39+
payload: "",
3440
});
3541
setIsPressed(true);
42+
setIsOutside(false);
3643
} catch (error) {
37-
console.error("Failed to send PTT push:", error);
44+
console.error("Failed to start turn:", error);
3845
}
3946
}, [agent, localParticipant]);
4047

41-
const handlePushEnd = useCallback(async () => {
48+
// when mouse leaves the button area while pressed
49+
const handleMouseLeave = useCallback(() => {
50+
if (isPressed) {
51+
console.log("mouse left button while pressed");
52+
setIsOutside(true);
53+
}
54+
}, [isPressed]);
55+
56+
// when mouse re-enters the button area while pressed
57+
const handleMouseEnter = useCallback(() => {
58+
if (isPressed && isOutside) {
59+
console.log("mouse re-entered button while pressed");
60+
setIsOutside(false);
61+
}
62+
}, [isPressed, isOutside]);
63+
64+
// shared function to end turn with specified method
65+
const endTurnWithMethod = useCallback(async (method: string) => {
4266
if (!agent || !localParticipant || !isPressed) return;
4367

44-
// Prevent multiple releases within 100ms
68+
// Prevent multiple actions within 100ms
4569
const now = Date.now();
46-
if (now - lastReleaseTime.current < 100) {
47-
return;
48-
}
49-
lastReleaseTime.current = now;
70+
if (now - lastActionTime.current < 100) return;
71+
lastActionTime.current = now;
5072

73+
console.log(`ending turn with method: ${method}`);
5174
try {
5275
await localParticipant.setMicrophoneEnabled(false);
5376
await localParticipant.performRpc({
5477
destinationIdentity: agent.identity,
55-
method: "ptt.end",
78+
method: method,
79+
payload: "",
5680
});
5781
} catch (error) {
58-
console.error("Failed to send PTT release:", error);
82+
console.error(`Failed to ${method}:`, error);
5983
} finally {
6084
setIsPressed(false);
85+
setIsOutside(false);
6186
}
6287
}, [agent, localParticipant, isPressed]);
6388

64-
// Clean up pressed state when component unmounts
89+
// when user releases the mouse anywhere
90+
const handleMouseUp = useCallback((e: MouseEvent) => {
91+
e.preventDefault(); // prevent default browser behavior
92+
93+
if (!isPressed) return;
94+
95+
// if mouse is outside the button on release, cancel the turn
96+
// otherwise, end the turn normally
97+
const method = isOutside ? "cancel_turn" : "end_turn";
98+
endTurnWithMethod(method);
99+
}, [isPressed, isOutside, endTurnWithMethod]);
100+
101+
// ensure turn is ended when component unmounts
65102
useEffect(() => {
66103
return () => {
67104
if (isPressed) {
68-
handlePushEnd();
105+
endTurnWithMethod("end_turn");
69106
}
70107
};
71-
}, [isPressed, handlePushEnd]);
108+
}, [isPressed, endTurnWithMethod]);
109+
110+
// add global mouse-up handler to catch events outside the button
111+
useEffect(() => {
112+
if (isPressed) {
113+
const handleGlobalMouseUp = (e: MouseEvent) => {
114+
handleMouseUp(e);
115+
};
116+
117+
window.addEventListener('mouseup', handleGlobalMouseUp as any);
118+
return () => {
119+
window.removeEventListener('mouseup', handleGlobalMouseUp as any);
120+
};
121+
}
122+
}, [isPressed, handleMouseUp]);
72123

73124
if (!agent) return null;
74125

75126
return (
76127
<motion.button
77128
className="ptt-button"
78-
onMouseDown={handlePushStart}
79-
onMouseUp={handlePushEnd}
129+
onMouseDown={handleMouseDown}
130+
onMouseLeave={handleMouseLeave}
131+
onMouseEnter={handleMouseEnter}
132+
// we handle mouseup at the window level to catch all cases
133+
// onTouchStart/End would be implemented similarly
80134
initial={false}
81135
animate={{
82-
backgroundColor: isPressed ? "#004085" : "#007bff",
136+
backgroundColor: isPressed
137+
? isOutside
138+
? "#d9534f" // red when outside and pressed (about to cancel)
139+
: "#004085" // blue when speaking normally
140+
: "#007bff", // default blue
83141
scale: isPressed ? 0.95 : 1,
142+
boxShadow: isOutside && isPressed
143+
? "0 0 0 3px rgba(217, 83, 79, 0.5)"
144+
: "0 4px 6px rgba(0, 0, 0, 0.1)",
84145
}}
85146
>
86-
{isPressed ? "Speaking..." : "Press to Talk"}
147+
{isPressed
148+
? isOutside
149+
? "Release to Cancel"
150+
: "Speaking..."
151+
: "Press to Talk"}
87152
</motion.button>
88153
);
89154
}

0 commit comments

Comments
 (0)