Skip to content

Commit 12b31ce

Browse files
committed
chore(Textream): Update project workspace and several Swift files
1 parent 74fe102 commit 12b31ce

6 files changed

Lines changed: 261 additions & 52 deletions

File tree

Textream/Textream/ExternalDisplayController.swift

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,11 @@ struct ExternalDisplayView: View {
207207

208208
private var prompterView: some View {
209209
GeometryReader { geo in
210-
let fontSize = max(24, min(42, geo.size.width / 28))
210+
let fontSize = max(48, min(96, geo.size.width / 14))
211211
let hPad = max(40, geo.size.width * 0.08)
212212

213213
VStack(spacing: 0) {
214-
Spacer().frame(height: 40)
214+
Spacer().frame(height: 20)
215215

216216
SpeechScrollView(
217217
words: words,
@@ -260,15 +260,21 @@ struct ExternalDisplayView: View {
260260
}
261261

262262
if listeningMode != .classic {
263-
if speechRecognizer.isListening {
264-
Image(systemName: "mic.fill")
265-
.font(.system(size: 16, weight: .bold))
266-
.foregroundStyle(.yellow.opacity(0.8))
267-
} else {
268-
Image(systemName: "mic.slash.fill")
263+
Button {
264+
if speechRecognizer.isListening {
265+
speechRecognizer.stop()
266+
} else {
267+
speechRecognizer.resume()
268+
}
269+
} label: {
270+
Image(systemName: speechRecognizer.isListening ? "mic.fill" : "mic.slash.fill")
269271
.font(.system(size: 16, weight: .bold))
270-
.foregroundStyle(.white.opacity(0.4))
272+
.foregroundStyle(speechRecognizer.isListening ? .yellow.opacity(0.8) : .white.opacity(0.4))
273+
.frame(width: 40, height: 40)
274+
.background(.white.opacity(0.15))
275+
.clipShape(Circle())
271276
}
277+
.buttonStyle(.plain)
272278
}
273279
}
274280
.padding(.horizontal, hPad)

Textream/Textream/MarqueeTextView.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,11 @@ struct SpeechScrollView: View {
7979
.animation(.easeOut(duration: 0.15), value: manualOffset)
8080
.onChange(of: geo.size.height) { _, newHeight in
8181
containerHeight = newHeight
82-
if isListening {
82+
if highlightedCharCount == 0 && smoothWordProgress == 0 {
83+
// Initial state: center first line on screen
84+
let lineHeight = font.pointSize * 1.4
85+
scrollOffset = newHeight * 0.5 - lineHeight * 0.5
86+
} else if isListening {
8387
recalcCenter(containerHeight: newHeight)
8488
}
8589
}

Textream/Textream/NotchOverlayController.swift

Lines changed: 130 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ class NotchOverlayController: NSObject {
4949
private var mouseTrackingTimer: AnyCancellable?
5050
private var cursorTrackingTimer: AnyCancellable?
5151
private var currentScreenID: UInt32 = 0
52-
private var statusItem: NSStatusItem?
52+
private var stopButtonPanel: NSPanel?
53+
private var escMonitor: Any?
5354

5455
func show(text: String, hasNextPage: Bool = false, onComplete: (() -> Void)? = nil) {
5556
self.onComplete = onComplete
@@ -80,17 +81,33 @@ class NotchOverlayController: NSObject {
8081

8182
let screenFrame = screen.frame
8283

83-
if settings.overlayMode == .floating && settings.followCursorWhenUndocked {
84+
if settings.overlayMode == .fullscreen {
85+
let fsScreen: NSScreen
86+
if settings.fullscreenScreenID != 0,
87+
let match = NSScreen.screens.first(where: { $0.displayID == settings.fullscreenScreenID }) {
88+
fsScreen = match
89+
} else {
90+
fsScreen = screen
91+
}
92+
showFullscreen(settings: settings, screen: fsScreen)
93+
} else if settings.overlayMode == .floating && settings.followCursorWhenUndocked {
8494
showFollowCursor(settings: settings, screen: screen)
8595
} else {
8696
switch settings.overlayMode {
8797
case .pinned:
8898
showPinned(settings: settings, screen: screen)
8999
case .floating:
90100
showFloating(settings: settings, screenFrame: screenFrame)
101+
case .fullscreen:
102+
break // handled above
91103
}
92104
}
93105

106+
// Show floating stop button only in follow-cursor mode (panel ignores mouse events)
107+
if settings.overlayMode == .floating && settings.followCursorWhenUndocked {
108+
showStopButton(on: screen)
109+
}
110+
94111
// Word tracking & silence-paused need the microphone; classic does not
95112
if settings.listeningMode != .classic {
96113
speechRecognizer.start(with: text)
@@ -157,8 +174,17 @@ class NotchOverlayController: NSObject {
157174
let cursorOffset: CGFloat = 8
158175
let x = mouse.x + cursorOffset
159176
let h = panel.frame.height
160-
let y = mouse.y - h
177+
var y = mouse.y - h
161178
let w = panel.frame.width
179+
180+
// Keep panel below the menu bar so the status bar stop button stays visible
181+
if let screen = NSScreen.screens.first(where: { NSMouseInRect(mouse, $0.frame, false) }) {
182+
let menuBarBottom = screen.visibleFrame.maxY
183+
if y + h > menuBarBottom {
184+
y = menuBarBottom - h
185+
}
186+
}
187+
162188
panel.setFrame(NSRect(x: x, y: y, width: w, height: h), display: false)
163189
}
164190

@@ -272,7 +298,44 @@ class NotchOverlayController: NSObject {
272298
self.panel = panel
273299

274300
startCursorTracking()
275-
showStatusItem()
301+
}
302+
303+
private func showFullscreen(settings: NotchSettings, screen: NSScreen) {
304+
let screenFrame = screen.frame
305+
306+
let fullscreenView = ExternalDisplayView(
307+
content: overlayContent,
308+
speechRecognizer: speechRecognizer,
309+
mirrorAxis: nil
310+
)
311+
let contentView = NSHostingView(rootView: fullscreenView)
312+
313+
let panel = NSPanel(
314+
contentRect: screenFrame,
315+
styleMask: [.borderless, .nonactivatingPanel],
316+
backing: .buffered,
317+
defer: false
318+
)
319+
panel.isOpaque = true
320+
panel.backgroundColor = .black
321+
panel.hasShadow = false
322+
panel.level = .screenSaver
323+
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
324+
panel.ignoresMouseEvents = false
325+
panel.sharingType = settings.hideFromScreenShare ? .none : .readOnly
326+
panel.contentView = contentView
327+
panel.setFrame(screenFrame, display: true)
328+
panel.orderFrontRegardless()
329+
self.panel = panel
330+
331+
// ESC key to stop the prompter
332+
escMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
333+
if event.keyCode == 53 { // ESC
334+
self?.dismiss()
335+
return nil
336+
}
337+
return event
338+
}
276339
}
277340

278341
private func showFloating(settings: NotchSettings, screenFrame: CGRect) {
@@ -318,7 +381,8 @@ class NotchOverlayController: NSObject {
318381
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
319382
self?.stopMouseTracking()
320383
self?.stopCursorTracking()
321-
self?.removeStatusItem()
384+
self?.removeStopButton()
385+
self?.removeEscMonitor()
322386
self?.panel?.orderOut(nil)
323387
self?.panel = nil
324388
self?.frameTracker = nil
@@ -330,7 +394,8 @@ class NotchOverlayController: NSObject {
330394
private func forceClose() {
331395
stopMouseTracking()
332396
stopCursorTracking()
333-
removeStatusItem()
397+
removeStopButton()
398+
removeEscMonitor()
334399
cancellables.removeAll()
335400
speechRecognizer.forceStop()
336401
speechRecognizer.recognizedCharCount = 0
@@ -362,7 +427,8 @@ class NotchOverlayController: NSObject {
362427
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
363428
self.stopMouseTracking()
364429
self.stopCursorTracking()
365-
self.removeStatusItem()
430+
self.removeStopButton()
431+
self.removeEscMonitor()
366432
self.cancellables.removeAll()
367433
self.panel?.orderOut(nil)
368434
self.panel = nil
@@ -378,27 +444,69 @@ class NotchOverlayController: NSObject {
378444
panel != nil
379445
}
380446

381-
// MARK: - Status Bar Item (for follow-cursor mode)
447+
// MARK: - Floating Stop Button
448+
449+
private func showStopButton(on screen: NSScreen) {
450+
guard stopButtonPanel == nil else { return }
451+
452+
let buttonSize: CGFloat = 36
453+
let margin: CGFloat = 8
454+
let screenFrame = screen.frame
455+
let visibleFrame = screen.visibleFrame
456+
let menuBarBottom = visibleFrame.maxY
457+
let x = screenFrame.midX - buttonSize / 2
458+
let y = menuBarBottom - buttonSize - margin
459+
460+
let stopView = NSHostingView(rootView: StopButtonView {
461+
self.dismiss()
462+
})
463+
464+
let panel = NSPanel(
465+
contentRect: NSRect(x: x, y: y, width: buttonSize, height: buttonSize),
466+
styleMask: [.borderless, .nonactivatingPanel],
467+
backing: .buffered,
468+
defer: false
469+
)
470+
panel.isOpaque = false
471+
panel.backgroundColor = .clear
472+
panel.hasShadow = true
473+
panel.level = .screenSaver
474+
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
475+
panel.ignoresMouseEvents = false
476+
panel.sharingType = .none
477+
panel.contentView = stopView
478+
panel.orderFrontRegardless()
479+
stopButtonPanel = panel
480+
}
382481

383-
private func showStatusItem() {
384-
guard statusItem == nil else { return }
385-
print("[Textream] Showing status bar item for follow-cursor mode")
386-
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
387-
item.button?.title = "■ Stop Prompter"
388-
item.button?.target = self
389-
item.button?.action = #selector(statusItemStop)
390-
statusItem = item
482+
private func removeStopButton() {
483+
stopButtonPanel?.orderOut(nil)
484+
stopButtonPanel = nil
391485
}
392486

393-
private func removeStatusItem() {
394-
if let statusItem {
395-
NSStatusBar.system.removeStatusItem(statusItem)
487+
private func removeEscMonitor() {
488+
if let escMonitor {
489+
NSEvent.removeMonitor(escMonitor)
396490
}
397-
statusItem = nil
491+
escMonitor = nil
398492
}
493+
}
494+
495+
// MARK: - Floating Stop Button View
399496

400-
@objc private func statusItemStop() {
401-
dismiss()
497+
struct StopButtonView: View {
498+
let onStop: () -> Void
499+
500+
var body: some View {
501+
Button(action: onStop) {
502+
Image(systemName: "stop.fill")
503+
.font(.system(size: 14, weight: .bold))
504+
.foregroundStyle(.white)
505+
.frame(width: 36, height: 36)
506+
.background(.red.opacity(0.85))
507+
.clipShape(Circle())
508+
}
509+
.buttonStyle(.plain)
402510
}
403511
}
404512

Textream/Textream/NotchSettings.swift

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,28 +120,31 @@ enum FontColorPreset: String, CaseIterable, Identifiable {
120120
// MARK: - Overlay Mode
121121

122122
enum OverlayMode: String, CaseIterable, Identifiable {
123-
case pinned, floating
123+
case pinned, floating, fullscreen
124124

125125
var id: String { rawValue }
126126

127127
var label: String {
128128
switch self {
129-
case .pinned: return "Pinned to Notch"
130-
case .floating: return "Floating Window"
129+
case .pinned: return "Pinned to Notch"
130+
case .floating: return "Floating Window"
131+
case .fullscreen: return "Fullscreen"
131132
}
132133
}
133134

134135
var description: String {
135136
switch self {
136-
case .pinned: return "Anchored below the notch at the top of your screen."
137-
case .floating: return "A draggable window you can place anywhere. Always on top."
137+
case .pinned: return "Anchored below the notch at the top of your screen."
138+
case .floating: return "A draggable window you can place anywhere. Always on top."
139+
case .fullscreen: return "Fullscreen teleprompter on the selected display. Press Esc to stop."
138140
}
139141
}
140142

141143
var icon: String {
142144
switch self {
143-
case .pinned: return "rectangle.topthird.inset.filled"
144-
case .floating: return "macwindow.on.rectangle"
145+
case .pinned: return "rectangle.topthird.inset.filled"
146+
case .floating: return "macwindow.on.rectangle"
147+
case .fullscreen: return "rectangle.fill"
145148
}
146149
}
147150
}
@@ -340,6 +343,10 @@ class NotchSettings {
340343
didSet { UserDefaults.standard.set(hideFromScreenShare, forKey: "hideFromScreenShare") }
341344
}
342345

346+
var fullscreenScreenID: UInt32 {
347+
didSet { UserDefaults.standard.set(Int(fullscreenScreenID), forKey: "fullscreenScreenID") }
348+
}
349+
343350
var font: NSFont {
344351
fontFamilyPreset.font(size: fontSizePreset.pointSize)
345352
}
@@ -378,5 +385,7 @@ class NotchSettings {
378385
let savedSpeed = UserDefaults.standard.double(forKey: "scrollSpeed")
379386
self.scrollSpeed = savedSpeed > 0 ? savedSpeed : 3
380387
self.hideFromScreenShare = UserDefaults.standard.object(forKey: "hideFromScreenShare") as? Bool ?? true
388+
let savedFullscreenScreenID = UserDefaults.standard.integer(forKey: "fullscreenScreenID")
389+
self.fullscreenScreenID = UInt32(savedFullscreenScreenID)
381390
}
382391
}

0 commit comments

Comments
 (0)