mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
feat(x): popup countdown line, arrival chime, faster call-end
- 2px progress line drains toward auto-dismiss (now 30s); hovering the popup freezes both the line and the dismissal. The renderer owns the countdown; main keeps only a 3-min crash-safety fallback - soft synthesized arrival chime (WebAudio — no asset, no spawn) - close button in the top-left corner, hover-revealed - call-end grace 3s → 1s with a 1s detector poll: notes flow starts within ~2s of hang-up
This commit is contained in:
parent
786382c840
commit
827bd4df5e
3 changed files with 72 additions and 10 deletions
|
|
@ -18,7 +18,10 @@ import type { DetectedMeeting } from "@x/core/dist/meetings/detector.js";
|
||||||
// `transparent: true` reliably and paint a grey backing slab instead.
|
// `transparent: true` reliably and paint a grey backing slab instead.
|
||||||
const POPUP_WIDTH = 376;
|
const POPUP_WIDTH = 376;
|
||||||
const POPUP_HEIGHT = 48;
|
const POPUP_HEIGHT = 48;
|
||||||
const AUTO_DISMISS_MS = 45_000;
|
// The popup renderer owns the real 45s countdown (it pauses on hover and
|
||||||
|
// draws the progress line); this is only a crash-safety net so a wedged
|
||||||
|
// renderer can't leave the popup on screen forever.
|
||||||
|
const FALLBACK_DISMISS_MS = 180_000;
|
||||||
|
|
||||||
// Display names, Granola-style ("Chrome", not "Google Chrome").
|
// Display names, Granola-style ("Chrome", not "Google Chrome").
|
||||||
const SHORT_APP_NAMES: Record<string, string> = {
|
const SHORT_APP_NAMES: Record<string, string> = {
|
||||||
|
|
@ -153,5 +156,5 @@ export function showMeetingPopup(meeting: DetectedMeeting): void {
|
||||||
win.loadURL("http://localhost:5173/#meeting-detected");
|
win.loadURL("http://localhost:5173/#meeting-detected");
|
||||||
}
|
}
|
||||||
|
|
||||||
dismissTimer = setTimeout(() => closeMeetingPopup(), AUTO_DISMISS_MS);
|
dismissTimer = setTimeout(() => closeMeetingPopup(), FALLBACK_DISMISS_MS);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
// How long the popup stays up before dismissing itself. The countdown (and
|
||||||
|
// its progress line) pauses while the pointer is over the popup — main only
|
||||||
|
// keeps a much longer crash-safety fallback.
|
||||||
|
const AUTO_DISMISS_MS = 30_000
|
||||||
|
|
||||||
type PopupPayload = {
|
type PopupPayload = {
|
||||||
title: string
|
title: string
|
||||||
message: string
|
message: string
|
||||||
|
|
@ -50,13 +55,57 @@ export function MeetingDetectedPopup() {
|
||||||
return cleanup
|
return cleanup
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const act = (action: 'take-notes' | 'dismiss') => {
|
const act = useCallback((action: 'take-notes' | 'dismiss') => {
|
||||||
if (isPreview) {
|
if (isPreview) {
|
||||||
console.log(`[preview] action: ${action}`)
|
console.log(`[preview] action: ${action}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void window.ipc.invoke('meetingDetect:action', { action }).catch(() => {})
|
void window.ipc.invoke('meetingDetect:action', { action }).catch(() => {})
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
|
// Subtle arrival chime, synthesized (no asset, no process spawn): a soft
|
||||||
|
// sine ding gliding up a fourth, ~0.35s, low gain. Once per popup.
|
||||||
|
const chimedRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!payload || chimedRef.current || isPreview) return
|
||||||
|
chimedRef.current = true
|
||||||
|
try {
|
||||||
|
const ctx = new AudioContext()
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
const gain = ctx.createGain()
|
||||||
|
osc.type = 'sine'
|
||||||
|
osc.frequency.setValueAtTime(880, ctx.currentTime)
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(1174.66, ctx.currentTime + 0.08)
|
||||||
|
gain.gain.setValueAtTime(0.0001, ctx.currentTime)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.06, ctx.currentTime + 0.02)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.35)
|
||||||
|
osc.connect(gain)
|
||||||
|
gain.connect(ctx.destination)
|
||||||
|
osc.start()
|
||||||
|
osc.stop(ctx.currentTime + 0.4)
|
||||||
|
osc.onended = () => void ctx.close()
|
||||||
|
} catch { /* audio is best-effort */ }
|
||||||
|
}, [payload])
|
||||||
|
|
||||||
|
// Auto-dismiss countdown, rendered as the progress line at the bottom.
|
||||||
|
// Hovering pauses it (elapsed only accrues while the pointer is away).
|
||||||
|
const [remainingFrac, setRemainingFrac] = useState(1)
|
||||||
|
const hoveredRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
const TICK_MS = 100
|
||||||
|
let elapsed = 0
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (hoveredRef.current) return
|
||||||
|
elapsed += TICK_MS
|
||||||
|
const frac = Math.max(0, 1 - elapsed / AUTO_DISMISS_MS)
|
||||||
|
setRemainingFrac(frac)
|
||||||
|
if (frac <= 0) {
|
||||||
|
clearInterval(timer)
|
||||||
|
act('dismiss')
|
||||||
|
}
|
||||||
|
}, TICK_MS)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [act])
|
||||||
|
|
||||||
// The Electron window IS the card (376×48, card background, native macOS
|
// The Electron window IS the card (376×48, card background, native macOS
|
||||||
// rounded corners + shadow — no transparency, which panels don't honor).
|
// rounded corners + shadow — no transparency, which panels don't honor).
|
||||||
|
|
@ -69,14 +118,16 @@ export function MeetingDetectedPopup() {
|
||||||
isPreview ? 'rounded-xl shadow-[0_8px_28px_rgba(0,0,0,0.45)]' : 'h-screen w-screen'
|
isPreview ? 'rounded-xl shadow-[0_8px_28px_rgba(0,0,0,0.45)]' : 'h-screen w-screen'
|
||||||
}`}
|
}`}
|
||||||
style={isPreview ? { width: 376, height: 48 } : undefined}
|
style={isPreview ? { width: 376, height: 48 } : undefined}
|
||||||
|
onMouseEnter={() => { hoveredRef.current = true }}
|
||||||
|
onMouseLeave={() => { hoveredRef.current = false }}
|
||||||
>
|
>
|
||||||
{/* Close — slides in over the left edge on hover */}
|
{/* Close — top-left corner, revealed on hover */}
|
||||||
<button
|
<button
|
||||||
onClick={() => act('dismiss')}
|
onClick={() => act('dismiss')}
|
||||||
className="absolute left-1.5 top-1/2 -translate-y-1/2 z-10 flex size-6 items-center justify-center rounded-full bg-neutral-800 border border-neutral-600 text-neutral-200 shadow-md hover:bg-neutral-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="absolute left-1 top-1 z-10 flex size-5 items-center justify-center rounded-full bg-neutral-800 border border-neutral-600 text-neutral-200 shadow-md hover:bg-neutral-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
>
|
>
|
||||||
<X className="size-3.5" strokeWidth={2.5} />
|
<X className="size-3" strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 transition-[padding] group-hover:pl-6">
|
<div className="flex-1 min-w-0 transition-[padding] group-hover:pl-6">
|
||||||
|
|
@ -96,6 +147,12 @@ export function MeetingDetectedPopup() {
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[13px] font-semibold text-white">Take notes</span>
|
<span className="text-[13px] font-semibold text-white">Take notes</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Time-left line: drains toward dismissal, frozen while hovered */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 h-[2px] bg-white/25 transition-[width] duration-100 ease-linear"
|
||||||
|
style={{ width: `${remainingFrac * 100}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@ import { isNotificationCategoryEnabled } from "../config/notification_config.js"
|
||||||
* means the call ended.
|
* means the call ended.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 2_000;
|
// 1s so call-end lands within ~2s of hang-up; all tick work is in-memory
|
||||||
|
// (the helper pushes owner updates, nothing is spawned).
|
||||||
|
const POLL_INTERVAL_MS = 1_000;
|
||||||
// Mic must be in use continuously this long before we prompt — filters out
|
// Mic must be in use continuously this long before we prompt — filters out
|
||||||
// Siri, dictation bursts, and app mic-permission probes.
|
// Siri, dictation bursts, and app mic-permission probes.
|
||||||
const MIC_DEBOUNCE_MS = 5_000;
|
const MIC_DEBOUNCE_MS = 5_000;
|
||||||
|
|
@ -43,7 +45,7 @@ const CALENDAR_MERGE_LEAD_MS = 15 * 60_000;
|
||||||
// While recording, the meeting app must be off the mic this long before we
|
// While recording, the meeting app must be off the mic this long before we
|
||||||
// call the meeting over. Kept short so notes arrive right after hang-up;
|
// call the meeting over. Kept short so notes arrive right after hang-up;
|
||||||
// only guards against sub-poll device churn, not full reconnects.
|
// only guards against sub-poll device churn, not full reconnects.
|
||||||
const CALL_END_GRACE_MS = 3_000;
|
const CALL_END_GRACE_MS = 1_000;
|
||||||
const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync");
|
const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync");
|
||||||
const HELPER_MAX_RESTARTS = 3;
|
const HELPER_MAX_RESTARTS = 3;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue