Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,14 @@ returns silence instead, yap notices inside a second and restarts the mic raw.
argument.

`transcription` is automatic transcription of recordings, on by default.
Dictation ignores it, since the hotkey always transcribes. Turn it off to use
yap as a plain recorder: `on_stop` then fires when the recording stops rather
than after the transcript. Nothing is lost either way. Turn it back on, restart,
and yap works through every session under `recordings_dir` that has no
transcript yet, firing `on_stop` again for each. Anything you put somewhere else
with `yap record --out` is left alone.
Dictation ignores it, since the hotkey always transcribes. When one finishes
while the daemon is running, a banner drops under the menu bar with an Open
button that reveals the transcript in Finder. Turn it off to use yap as a plain
recorder: `on_stop` then fires when the recording stops rather than after the
transcript. Nothing is lost either way. Turn it back on, restart, and yap works
through every session under `recordings_dir` that has no transcript yet, firing
`on_stop` again for each. Anything you put somewhere else with
`yap record --out` is left alone.

## Models

Expand Down
13 changes: 12 additions & 1 deletion Sources/yap/Transcription/TranscriptionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ actor TranscriptionCoordinator {
private var transcriber: (any Transcriber)?
private let ownsTranscriber: Bool
private var statusHandler: (@Sendable (Bool) -> Void)?
private var transcriptReadyHandler: (@Sendable (URL) -> Void)?
/// Built once. `ISO8601DateFormatter` is expensive to construct and this
/// one is only ever touched from the actor.
private let iso = ISO8601DateFormatter()
Expand All @@ -41,6 +42,12 @@ actor TranscriptionCoordinator {
statusHandler = handler
}

/// Called with the session directory after transcript.json/.md are written.
/// Unset (the one-shot `yap record` case), a plain notification fires instead.
func setTranscriptReadyHandler(_ handler: @escaping @Sendable (URL) -> Void) {
transcriptReadyHandler = handler
}

/// Queue a finished session. With transcription disabled in config, the
/// on_stop hook still fires — it just gets an untranscribed folder.
func enqueue(_ sessionDir: URL) {
Expand Down Expand Up @@ -158,7 +165,11 @@ actor TranscriptionCoordinator {
publish(true)
do {
try await transcribe(dir)
notifyUser(title: "yap — transcript ready", body: dir.lastPathComponent)
if let transcriptReadyHandler {
transcriptReadyHandler(dir)
} else {
notifyUser(title: "yap — transcript ready", body: dir.lastPathComponent)
}
runHook(for: dir)
} catch {
log(dir, "transcription failed: \(error)")
Expand Down
84 changes: 71 additions & 13 deletions Sources/yap/UI/PromptPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ func retirePrompt() {
PromptPanel.retireCurrent()
}

/// One-way announcement in the same pill as the meeting prompt: icon, text,
/// a single action button, gone by itself in a few seconds. If a prompt is
/// on screen it falls back to a notification rather than covering it.
@MainActor
func showToast(
title: String,
body: String,
button: String,
onAccept: @escaping @MainActor () -> Void
) {
PromptPanel.presentToast(title: title, body: body, button: button, onAccept: onAccept)
}

/// A borderless capsule panel centred under the menu bar, clear of whatever
/// notch widget lives up there.
///
Expand All @@ -36,9 +49,15 @@ func retirePrompt() {
@MainActor
final class PromptPanel: NSPanel {
private static var current: PromptPanel?
/// Announcements live in the same top-centre spot, but in their own slot:
/// a toast must not be mistaken for a prompt by `retireCurrent()`.
private static var currentToast: PromptPanel?
/// Long enough to catch someone settling into a call, short enough that a
/// missed prompt doesn't linger for the rest of the meeting.
private static let autoDismissAfter: TimeInterval = 120
/// A toast says something already true and needs no answer, so it only has
/// to survive long enough to be read.
private static let toastDismissAfter: TimeInterval = 6
/// Clearance below the menu bar. Enough to clear a notch widget hanging
/// off the menu bar without looking detached from it.
private static let topGap: CGFloat = 12
Expand All @@ -48,6 +67,7 @@ final class PromptPanel: NSPanel {
private let onAccept: @MainActor () -> Void
private let onDismiss: @MainActor () -> Void
private var autoDismiss: Timer?
private let dismissAfter: TimeInterval

static func present(
title: String,
Expand All @@ -56,9 +76,12 @@ final class PromptPanel: NSPanel {
onDismiss: @escaping @MainActor () -> Void,
onAccept: @escaping @MainActor () -> Void
) {
// Both live top-centre, so a question evicts an announcement.
currentToast?.close()
current?.close()
let panel = PromptPanel(
heading: title, body: body, button: button, onDismiss: onDismiss, onAccept: onAccept
heading: title, body: body, button: button,
onDismiss: onDismiss, onAccept: onAccept, toast: false
)
current = panel
panel.appear()
Expand All @@ -68,15 +91,39 @@ final class PromptPanel: NSPanel {
current?.fadeOut()
}

static func presentToast(
title: String,
body: String,
button: String,
onAccept: @escaping @MainActor () -> Void
) {
// A live prompt is a question; never cover it with an announcement.
// The announcement still reaches the user, just as a notification.
guard current == nil else {
notifyUser(title: title, body: body)
return
}
currentToast?.close()
let panel = PromptPanel(
heading: title, body: body, button: button,
onDismiss: {}, onAccept: onAccept, toast: true
)
currentToast = panel
panel.appear()
NSSound(named: "Glass")?.play()
}

private init(
heading: String,
body: String,
button: String,
onDismiss: @escaping @MainActor () -> Void,
onAccept: @escaping @MainActor () -> Void
onAccept: @escaping @MainActor () -> Void,
toast: Bool
) {
self.onAccept = onAccept
self.onDismiss = onDismiss
self.dismissAfter = toast ? Self.toastDismissAfter : Self.autoDismissAfter
super.init(
contentRect: NSRect(x: 0, y: 0, width: 100, height: 100),
styleMask: [.borderless, .nonactivatingPanel],
Expand All @@ -96,7 +143,7 @@ final class PromptPanel: NSPanel {
isReleasedWhenClosed = false
animationBehavior = .none

let content = contentStack(heading: heading, body: body, button: button)
let content = contentStack(heading: heading, body: body, button: button, toast: toast)
let background = PillView()
background.material = .hudWindow
background.blendingMode = .behindWindow
Expand All @@ -119,7 +166,9 @@ final class PromptPanel: NSPanel {

// MARK: - Layout

private func contentStack(heading: String, body: String, button: String) -> NSStackView {
private func contentStack(
heading: String, body: String, button: String, toast: Bool
) -> NSStackView {
let text = NSStackView(views: [
Self.label(heading, font: .systemFont(ofSize: 13, weight: .semibold), color: .labelColor),
Self.label(body, font: .systemFont(ofSize: 11), color: .secondaryLabelColor),
Expand All @@ -128,13 +177,6 @@ final class PromptPanel: NSPanel {
text.alignment = .leading
text.spacing = 1

let dismiss = CapsuleButton(
title: "Dismiss",
fill: NSColor.labelColor.withAlphaComponent(0.10),
textColor: .labelColor,
target: self,
action: #selector(dismissClicked)
)
let accept = CapsuleButton(
title: button,
fill: .controlAccentColor,
Expand All @@ -143,7 +185,22 @@ final class PromptPanel: NSPanel {
action: #selector(acceptClicked)
)

let stack = NSStackView(views: [Self.icon(diameter: 34), text, dismiss, accept])
// An announcement has nothing to decline: it is already true, and it
// leaves on its own. Only a question gets a Dismiss.
var views: [NSView] = [Self.icon(diameter: 34), text]
if !toast {
views.append(
CapsuleButton(
title: "Dismiss",
fill: NSColor.labelColor.withAlphaComponent(0.10),
textColor: .labelColor,
target: self,
action: #selector(dismissClicked)
))
}
views.append(accept)

let stack = NSStackView(views: views)
stack.translatesAutoresizingMaskIntoConstraints = false
stack.orientation = .horizontal
stack.alignment = .centerY
Expand Down Expand Up @@ -225,7 +282,7 @@ final class PromptPanel: NSPanel {
}

autoDismiss = Timer.scheduledTimer(
withTimeInterval: Self.autoDismissAfter,
withTimeInterval: dismissAfter,
repeats: false
) { [weak self] _ in
MainActor.assumeIsolated { self?.fadeOut() }
Expand All @@ -247,6 +304,7 @@ final class PromptPanel: NSPanel {
autoDismiss?.invalidate()
autoDismiss = nil
if Self.current === self { Self.current = nil }
if Self.currentToast === self { Self.currentToast = nil }
super.close()
}

Expand Down
15 changes: 15 additions & 0 deletions Sources/yap/Yap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,21 @@ final class Daemon: NSObject, NSApplicationDelegate {
await coordinator.setStatusHandler { [weak self] busy in
Task { @MainActor in self?.show(busy: busy) }
}
await coordinator.setTranscriptReadyHandler { dir in
// Fires on the coordinator's executor; hop to the main actor
// for AppKit.
Task { @MainActor in
showToast(
title: "Transcript ready",
body: dir.lastPathComponent,
button: "Open"
) {
NSWorkspace.shared.activateFileViewerSelecting(
[dir.appendingPathComponent("transcript.md")]
)
}
}
}
await coordinator.resumePending(root: root)
}

Expand Down
Loading