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
48 changes: 41 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,19 +266,33 @@ The Director Mode exposes an HTTP server and a WebSocket server on your local ne

| Service | Default Port | Configurable in |
|---|---|---|
| **HTTP** (serves the built-in web UI) | `7575` | Settings → Director → Advanced |
| **HTTP** (serves the built-in web UI) | `7575` | Settings → Director → Advanced (`1024`–`65534`) |
| **WebSocket** (bidirectional communication) | `7576` (HTTP port + 1) | Automatic |

### Connecting

1. Open a WebSocket connection to `ws://<mac-ip>:<ws-port>` (e.g. `ws://192.168.1.42:7576`).
2. The server immediately begins sending **state frames** as JSON at ~10 Hz once a script is active.
3. Send **command frames** as JSON to control the teleprompter.
1. Fetch the built-in Director page from `http://<mac-ip>:<http-port>` and extract the current 64-character `AUTH_TOKEN` embedded in its script. The token changes whenever the Director server restarts.
2. Open a WebSocket connection to `ws://<mac-ip>:<ws-port>` (e.g. `ws://192.168.1.42:7576`).
3. Within five seconds, send `{"type":"auth","text":"<token>"}` as the first WebSocket frame. The server closes clients that skip or fail authentication.
4. Send command frames to control the teleprompter. Once a script is active, the server broadcasts state frames as JSON at approximately 10 Hz.

Director Mode is intended for trusted local networks. HTTP and WebSocket traffic is not encrypted, so do not expose either port to the public internet or log/share the token.

### Commands (Client → App)

Send JSON messages over the WebSocket:

#### `auth` — Authenticate the connection

```json
{
"type": "auth",
"text": "<64-character token from the Director page>"
}
```

This must be the first frame on every connection. It does not start a read.

#### `setText` — Start reading a new script

```json
Expand All @@ -300,7 +314,7 @@ Replaces the current text, starts word tracking, and opens the teleprompter over
}
```

Updates the full script text while preserving the read position. `readCharCount` is the number of characters already read (locked). Only text after this offset is replaced. Use this for live editing during a read.
Updates the full script text while preserving the confirmed read position. Set `readCharCount` to the latest `highlightedCharCount` received from Textream; do not calculate this offset independently. Textream clamps it to the Mac’s recognized count and the new script length. Keep the prefix before that offset unchanged and edit only unread text after it.

#### `stop` — Stop the teleprompter

Expand All @@ -325,6 +339,7 @@ The server broadcasts a JSON object on every tick (~100 ms):
"isDone": false,
"isListening": true,
"fontColor": "#F5F5F7",
"cueColor": "#F5F5F7",
"lastSpokenText": "Welcome everyone to today's",
"audioLevels": [0.12, 0.34, 0.08, ...]
}
Expand All @@ -339,6 +354,7 @@ The server broadcasts a JSON object on every tick (~100 ms):
| `isDone` | `bool` | `true` when `highlightedCharCount >= totalCharCount` (finished reading). |
| `isListening` | `bool` | `true` when the microphone is actively listening. |
| `fontColor` | `string` | CSS color of the text in the overlay (user preference). |
| `cueColor` | `string` | CSS color of bracketed stage directions (user preference). |
| `lastSpokenText` | `string` | Last recognized speech fragment. |
| `audioLevels` | `double[]` | Array of audio level samples (0.0–1.0) for waveform visualization. |

Expand All @@ -347,10 +363,28 @@ When the overlay is not active, the server sends a frame with `isActive: false`
### Example: Minimal Python Client

```python
import asyncio, json, websockets
import asyncio, json, re, urllib.request
import websockets

HOST = "192.168.1.42"
HTTP_PORT = 7575

def director_token():
with urllib.request.urlopen(f"http://{HOST}:{HTTP_PORT}", timeout=3) as response:
html = response.read().decode("utf-8")
match = re.search(r"AUTH_TOKEN='([0-9a-f]{64})'", html)
if not match:
raise RuntimeError("Director token not found")
return match.group(1)

async def director():
async with websockets.connect("ws://192.168.1.42:7576") as ws:
async with websockets.connect(f"ws://{HOST}:{HTTP_PORT + 1}") as ws:
# Authenticate before sending any commands.
await ws.send(json.dumps({
"type": "auth",
"text": director_token()
}))

# Send a script
await ws.send(json.dumps({
"type": "setText",
Expand Down
15 changes: 11 additions & 4 deletions Textream/Textream/DirectorServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ class DirectorServer {

<script>
const WSP=\(wsPort),host=location.hostname,AUTH_TOKEN='\(authToken)';
let ws,rt,isActive=false,isRunning=false,lastReadCount=0;
let ws,rt,isActive=false,isRunning=false,lastReadCount=0,lockedReadText='';

/* ---- connection ---- */
function connect(){
Expand Down Expand Up @@ -511,13 +511,18 @@ class DirectorServer {
}

/* ---- read boundary ---- */
function normalizeDirectorText(text){
return text.replace(/\\r\\n?/g,'\\n')
.replace(/[^\\S\\n]+/g,' ')
.replace(/ *\\n(?:[^\\S\\n]*\\n)* */g,'\\n')
.trim();
}
function getText(el){
return (el.innerText||el.textContent||'').replace(/\\n/g,' ');
return el.innerText||el.textContent||'';
}
function getFullText(){
const readEl=document.getElementById('read-text');
const editEl=document.getElementById('edit-text');
return getText(readEl)+getText(editEl);
return normalizeDirectorText(lockedReadText+getText(editEl));
}

function updateReadBoundary(charCount){
Expand All @@ -532,6 +537,7 @@ class DirectorServer {
const editEl=document.getElementById('edit-text');
const divider=document.getElementById('read-divider');

lockedReadText=readPart;
readEl.textContent=readPart;
divider.classList.toggle('visible',readPart.length>0);

Expand Down Expand Up @@ -588,6 +594,7 @@ class DirectorServer {
isRunning=false;
isActive=false;
lastReadCount=0;
lockedReadText='';
updateGoButton();
}

Expand Down
87 changes: 77 additions & 10 deletions Textream/Textream/MarqueeTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

import SwiftUI

private let paragraphDividerExtraSpacing: CGFloat = 4
private let paragraphDividerExtraSpacing: CGFloat = 14
private let paragraphDividerDotSize: CGFloat = 3
private let paragraphDividerDotSpacing: CGFloat = 5
private let paragraphDividerDotOpacity: Double = 0.16

// MARK: - CJK-aware word splitting

Expand Down Expand Up @@ -132,6 +135,8 @@ struct SpeechScrollView: View {

var isListening: Bool = true
var readingPosition: ReadingPosition = .centered
/// Optional preview-only transition. Live overlays keep their existing behavior.
var readingPositionTransitionDuration: Double? = nil
var paragraphBreakBeforeWordIndices: Set<Int> = []
@State private var scrollOffset: CGFloat = 0
@State private var manualOffset: CGFloat = 0
Expand All @@ -144,6 +149,22 @@ struct SpeechScrollView: View {
@State private var hasAppliedTrackingTarget = false
@State private var anchoredLayoutWidth: CGFloat = 0
@State private var anchoredParagraphBreakBeforeWordIndices: Set<Int> = []
@State private var isAnimatingReadingPositionChange = false
@State private var readingPositionAnimationGeneration = 0

private var readingPositionTransitionAnimation: Animation? {
guard let duration = readingPositionTransitionDuration, duration > 0 else {
return nil
}
return .smooth(duration: duration, extraBounce: 0)
}

private var scrollOffsetAnimation: Animation? {
if isAnimatingReadingPositionChange {
return readingPositionTransitionAnimation
}
return smoothScroll ? .linear(duration: 0.06) : .easeOut(duration: 0.5)
}

var body: some View {
GeometryReader { geo in
Expand Down Expand Up @@ -195,7 +216,7 @@ struct SpeechScrollView: View {
}
}
.offset(y: scrollOffset + manualOffset)
.animation(smoothScroll ? .linear(duration: 0.06) : .easeOut(duration: 0.5), value: scrollOffset)
.animation(scrollOffsetAnimation, value: scrollOffset)
.animation(.easeOut(duration: 0.15), value: manualOffset)
.onChange(of: geo.size.height) { _, newHeight in
containerHeight = newHeight
Expand Down Expand Up @@ -241,9 +262,29 @@ struct SpeechScrollView: View {
stableLineAdvance = nil
allowsNextBackwardTrackingUpdate = false
hasAppliedTrackingTarget = false
scrollOffset = initialScrollOffset(containerHeight: containerHeight)
DispatchQueue.main.async {
recalculateTracking(containerHeight: containerHeight)

if let transitionDuration = readingPositionTransitionDuration {
captureStableLineMetrics(from: wordYPositions)
readingPositionAnimationGeneration &+= 1
let generation = readingPositionAnimationGeneration
isAnimatingReadingPositionChange = transitionDuration > 0

if let animation = readingPositionTransitionAnimation {
withAnimation(animation) {
recalculateTracking(containerHeight: containerHeight)
}
DispatchQueue.main.asyncAfter(deadline: .now() + transitionDuration) {
guard generation == readingPositionAnimationGeneration else { return }
isAnimatingReadingPositionChange = false
}
} else {
recalculateTracking(containerHeight: containerHeight)
}
} else {
Comment on lines +266 to +283
scrollOffset = initialScrollOffset(containerHeight: containerHeight)
DispatchQueue.main.async {
recalculateTracking(containerHeight: containerHeight)
}
}
}
.onAppear {
Expand Down Expand Up @@ -313,6 +354,7 @@ struct SpeechScrollView: View {
stops: readingPosition == .nearTop
? [
.init(color: .white, location: 0),
.init(color: .white, location: 0.05),
.init(color: .white, location: 0.95),
.init(color: .clear, location: 1.0)
]
Expand All @@ -325,6 +367,7 @@ struct SpeechScrollView: View {
startPoint: .top,
endPoint: .bottom
)
.animation(readingPositionTransitionAnimation, value: readingPosition)
)
}

Expand Down Expand Up @@ -401,6 +444,16 @@ struct SpeechScrollView: View {
private var topReadingAnchor: CGFloat {
let lineHeight = ceil(font.ascender - font.descender + font.leading)
let fallbackAdvance = lineHeight + (lineHeight / font.pointSize > 1.5 ? 2 : 8)

// The live notch briefly returns to offset zero before measuring Near Top,
// so it always captures the document's first two rows. The animated
// preview cannot do that without visibly jumping through the beginning.
// Use the equivalent row geometry directly instead of treating the
// first currently rendered (and possibly culled) row as line zero.
if readingPositionTransitionDuration != nil {
return lineHeight * 0.5 + fallbackAdvance
}

return (stableTopLineCenter ?? lineHeight * 0.5)
+ (stableLineAdvance ?? fallbackAdvance)
}
Expand Down Expand Up @@ -554,7 +607,8 @@ struct WordFlowLayout: View {
let paragraphKey = paragraphBreakBeforeWordIndices.sorted()
.map(String.init)
.joined(separator: ",")
let key = "\(words.count)|\(words.first ?? "")|\(words.last ?? "")|\(font.pointSize)|\(Int(containerWidth))|\(paragraphKey)"
let key = "\(words.count)|\(words.first ?? "")|\(words.last ?? "")|"
+ "\(font.fontName)|\(font.pointSize)|\(Int(containerWidth))|\(paragraphKey)"
if key == Self._cacheKey {
return (Self._cachedItems, Self._cachedLines, Self._cachedParagraphPrefixCounts)
}
Expand Down Expand Up @@ -675,12 +729,25 @@ struct WordFlowLayout: View {
.padding(.top, beginsParagraph ? paragraphDividerExtraSpacing : 0)
.overlay(alignment: .top) {
if beginsParagraph {
Rectangle()
.fill(Color.white.opacity(0.2))
.frame(height: 1)
HStack(spacing: paragraphDividerDotSpacing) {
ForEach(0..<3, id: \.self) { _ in
Circle()
.fill(Color.white.opacity(paragraphDividerDotOpacity))
.frame(
width: paragraphDividerDotSize,
height: paragraphDividerDotSize
)
}
}
.offset(
y: (paragraphDividerExtraSpacing - lineSpacing) * 0.5
y: (
paragraphDividerExtraSpacing
- lineSpacing
- paragraphDividerDotSize
) * 0.5
)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
}
.environment(\.layoutDirection, rtl ? .rightToLeft : .leftToRight)
Expand Down
14 changes: 14 additions & 0 deletions Textream/Textream/NotchOverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,8 @@ struct NotchOverlayView: View {

private let topInset: CGFloat = 16
private let collapsedInset: CGFloat = 8
private let compactControlTopSpacing: CGFloat = 8
private let compactControlFadeHeight: CGFloat = 20

// macOS notch dimensions (approximate)
private let notchHeight: CGFloat = 37
Expand Down Expand Up @@ -937,6 +939,17 @@ struct NotchOverlayView: View {
)
.padding(.horizontal, 12)
.padding(.top, 6)
.mask {
VStack(spacing: 0) {
Color.white
LinearGradient(
colors: [.white, .clear],
startPoint: .top,
endPoint: .bottom
)
.frame(height: compactControlFadeHeight)
}
}
.transition(.move(edge: .top).combined(with: .opacity))

Group {
Expand Down Expand Up @@ -1063,6 +1076,7 @@ struct NotchOverlayView: View {
}
.frame(height: 24)
.padding(.horizontal, 12)
.padding(.top, compactControlTopSpacing)
.padding(.bottom, 2)

// Keep the resize handle in the layout at all times to avoid hover-driven shifts.
Expand Down
4 changes: 2 additions & 2 deletions Textream/Textream/NotchSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -542,9 +542,9 @@ class NotchSettings {
self.fullscreenScreenID = UInt32(savedFullscreenScreenID)
self.browserServerEnabled = UserDefaults.standard.object(forKey: "browserServerEnabled") as? Bool ?? false
let savedPort = UserDefaults.standard.integer(forKey: "browserServerPort")
self.browserServerPort = savedPort > 0 ? UInt16(savedPort) : 7373
self.browserServerPort = (1024..<Int(UInt16.max)).contains(savedPort) ? UInt16(savedPort) : 7373
self.directorModeEnabled = UserDefaults.standard.object(forKey: "directorModeEnabled") as? Bool ?? false
let savedDirectorPort = UserDefaults.standard.integer(forKey: "directorServerPort")
self.directorServerPort = savedDirectorPort > 0 ? UInt16(savedDirectorPort) : 7575
self.directorServerPort = (1024..<Int(UInt16.max)).contains(savedDirectorPort) ? UInt16(savedDirectorPort) : 7575
}
}
Loading