From e298fa98b24b65c37ac1c335366dd3e9e0b3ded8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:03:48 +0300 Subject: [PATCH 1/5] feat: add desktop teleprompter --- .../desktop/src-tauri/src/general_settings.rs | 1 + apps/desktop/src-tauri/src/lib.rs | 2 + .../src-tauri/src/platform/macos/mod.rs | 15 + apps/desktop/src-tauri/src/recording.rs | 12 + apps/desktop/src-tauri/src/windows.rs | 48 +- apps/desktop/src/app.tsx | 6 + .../routes/(window-chrome)/new-main/index.tsx | 12 + .../src/routes/teleprompter-utils.test.ts | 33 ++ apps/desktop/src/routes/teleprompter-utils.ts | 30 ++ apps/desktop/src/routes/teleprompter.tsx | 473 ++++++++++++++++++ apps/desktop/src/store.ts | 22 + apps/desktop/src/styles/theme.css | 9 + .../src/utils/macos-window-material.ts | 10 +- apps/desktop/src/utils/tauri.ts | 6 + apps/desktop/src/utils/teleprompter.ts | 57 +++ 15 files changed, 729 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/routes/teleprompter-utils.test.ts create mode 100644 apps/desktop/src/routes/teleprompter-utils.ts create mode 100644 apps/desktop/src/routes/teleprompter.tsx create mode 100644 apps/desktop/src/utils/teleprompter.ts diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 313a89dfe7b..7d6e422a385 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -110,6 +110,7 @@ const DEFAULT_EXCLUDED_WINDOW_TITLES: &[&str] = &[ "Cap Capture Area", "Cap Mode Selection", "Cap Recordings Overlay", + "Cap Teleprompter", ]; pub fn default_excluded_windows() -> Vec { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 3f85c7c3b18..9498ef264cf 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -4911,6 +4911,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { clip_thumbnails::get_clip_thumbnail, windows::position_traffic_lights, windows::set_theme, + windows::set_teleprompter_window_level, + windows::set_teleprompter_window_opacity, windows::apply_macos_liquid_glass_background, global_message_dialog, show_window, diff --git a/apps/desktop/src-tauri/src/platform/macos/mod.rs b/apps/desktop/src-tauri/src/platform/macos/mod.rs index e50f93de056..31d87787a3f 100644 --- a/apps/desktop/src-tauri/src/platform/macos/mod.rs +++ b/apps/desktop/src-tauri/src/platform/macos/mod.rs @@ -26,6 +26,21 @@ pub fn set_window_level(window: tauri::Window, level: objc2_app_kit::NSWindowLev }); } +pub fn set_window_opacity(window: tauri::Window, opacity: f64) { + let opacity = opacity.clamp(0.45, 1.0); + let c_window = window.clone(); + _ = window.run_on_main_thread(move || unsafe { + use cocoa::base::id; + use objc::{msg_send, sel, sel_impl}; + + let Ok(ns_win) = c_window.ns_window() else { + return; + }; + let ns_win = ns_win as id; + let _: () = msg_send![ns_win, setAlphaValue: opacity]; + }); +} + pub fn apply_squircle_corners(window: &tauri::WebviewWindow, radius: f64) { use cocoa::base::{id, nil}; use cocoa::foundation::NSString; diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index f71312bd1eb..aae64c21611 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -57,6 +57,8 @@ use crate::camera::{CameraPreviewManager, CameraPreviewShape}; use crate::general_settings; use crate::permissions; use crate::web_api::AuthedApiError; +#[cfg(target_os = "macos")] +use crate::window_exclusion::WindowExclusion; use crate::{ App, CameraWindowOperationLock, CurrentRecordingChanged, EditorRecordingAdded, FinalizingRecordings, MutableState, NewStudioRecordingAdded, RecordingStarted, RecordingState, @@ -1866,6 +1868,16 @@ pub async fn start_recording( window_exclusions }; + let teleprompter_exclusion = WindowExclusion { + bundle_identifier: None, + owner_name: None, + window_title: Some(CapWindowId::Teleprompter.title()), + }; + let mut window_exclusions = window_exclusions; + if !window_exclusions.contains(&teleprompter_exclusion) { + window_exclusions.push(teleprompter_exclusion); + } + let mut excluded_window_ids = crate::window_exclusion::resolve_window_ids(&window_exclusions); crate::window_exclusion::append_matching_webview_window_ids( diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 42ba03a5849..cfa82b4baac 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -45,6 +45,12 @@ use cap_recording::{feeds, sources::screen_capture::ScreenCaptureTarget}; #[cfg(target_os = "macos")] const DEFAULT_TRAFFIC_LIGHTS_INSET: LogicalPosition = LogicalPosition::new(12.0, 12.0); +#[cfg(target_os = "macos")] +const MAIN_PANEL_LEVEL: i32 = 100; + +#[cfg(target_os = "macos")] +const TELEPROMPTER_PANEL_LEVEL: objc2_app_kit::NSWindowLevel = MAIN_PANEL_LEVEL as isize + 1; + const DEFAULT_FALLBACK_DISPLAY_WIDTH: f64 = 1920.0; const DEFAULT_FALLBACK_DISPLAY_HEIGHT: f64 = 1080.0; @@ -937,6 +943,7 @@ pub enum CapWindowId { Debug, ScreenshotEditor { id: u32 }, Onboarding, + Teleprompter, } impl FromStr for CapWindowId { @@ -955,6 +962,7 @@ impl FromStr for CapWindowId { "mode-select" => Self::ModeSelect, "debug" => Self::Debug, "onboarding" => Self::Onboarding, + "teleprompter" => Self::Teleprompter, s if s.starts_with("editor-") => Self::Editor { id: s .replace("editor-", "") @@ -1005,6 +1013,7 @@ impl std::fmt::Display for CapWindowId { Self::Debug => write!(f, "debug"), Self::ScreenshotEditor { id } => write!(f, "screenshot-editor-{id}"), Self::Onboarding => write!(f, "onboarding"), + Self::Teleprompter => write!(f, "teleprompter"), } } } @@ -1027,6 +1036,7 @@ impl CapWindowId { Self::Camera => "Cap Camera".to_string(), Self::RecordingsOverlay => "Cap Recordings Overlay".to_string(), Self::TargetSelectOverlay { .. } => "Cap Target Select".to_string(), + Self::Teleprompter => "Cap Teleprompter".to_string(), _ => "Cap".to_string(), } } @@ -1087,6 +1097,7 @@ impl CapWindowId { | Self::RecordingControls | Self::TargetSelectOverlay { .. } => None, Self::Settings => Some(Some(LogicalPosition::new(22.0, 22.0))), + Self::Teleprompter => Some(Some(LogicalPosition::new(14.0, 14.0))), _ => Some(None), } } @@ -1620,8 +1631,6 @@ impl ShowCapWindow { use tauri_nspanel::panel_delegate; use crate::panel_manager::try_to_panel; - const MAIN_PANEL_LEVEL: i32 = 100; - let delegate = panel_delegate!(MainPanelDelegate { window_did_become_key, window_did_resign_key @@ -3233,6 +3242,31 @@ pub fn position_traffic_lights(_window: tauri::Window, _controls_inset: Option<( ); } +#[tauri::command] +#[specta::specta] +#[instrument(skip(_window))] +pub fn set_teleprompter_window_level(_window: tauri::Window, _always_on_top: bool) { + #[cfg(target_os = "macos")] + if _window.label() == CapWindowId::Teleprompter.to_string() { + let level = if _always_on_top { + TELEPROMPTER_PANEL_LEVEL + } else { + objc2_app_kit::NSNormalWindowLevel + }; + crate::platform::set_window_level(_window, level); + } +} + +#[tauri::command] +#[specta::specta] +#[instrument(skip(_window))] +pub fn set_teleprompter_window_opacity(_window: tauri::Window, _opacity: f64) { + #[cfg(target_os = "macos")] + if _window.label() == CapWindowId::Teleprompter.to_string() { + crate::platform::set_window_opacity(_window, _opacity); + } +} + #[cfg(target_os = "macos")] fn position_traffic_lights_impl( window: &tauri::Window, @@ -3305,7 +3339,11 @@ fn content_protection_enabled(app: &AppHandle) -> bool { .unwrap_or(false) } -fn window_matches_exclusion_list(app: &AppHandle, window_title: &str) -> bool { +fn window_capture_excluded(app: &AppHandle, window_title: &str) -> bool { + if window_title == CapWindowId::Teleprompter.title() { + return true; + } + let matches = |list: &[WindowExclusion]| { list.iter() .any(|entry| entry.matches(None, None, Some(window_title))) @@ -3321,7 +3359,7 @@ fn window_matches_exclusion_list(app: &AppHandle, window_title: &str) -> bo fn should_protect_window(app: &AppHandle, window_title: &str) -> bool { content_protection_enabled(app) && !capture_exclusion_hides_ui() - && window_matches_exclusion_list(app, window_title) + && window_capture_excluded(app, window_title) } pub fn apply_content_protection(app: &AppHandle, enabled: bool) { @@ -3343,7 +3381,7 @@ pub fn apply_content_protection(app: &AppHandle, enabled: bool) { } let title = id.title(); - let should_protect = enabled && window_matches_exclusion_list(app, &title); + let should_protect = enabled && window_capture_excluded(app, &title); let _ = window.set_content_protected(should_protect); #[cfg(target_os = "windows")] diff --git a/apps/desktop/src/app.tsx b/apps/desktop/src/app.tsx index d55ade5dd38..be3971b4e4b 100644 --- a/apps/desktop/src/app.tsx +++ b/apps/desktop/src/app.tsx @@ -95,6 +95,7 @@ const TargetSelectOverlayPage = lazy( const WindowCaptureOccluderPage = lazy( () => import("./routes/window-capture-occluder"), ); +const TeleprompterPage = lazy(() => import("./routes/teleprompter")); const queryClient = new QueryClient({ defaultOptions: { @@ -248,6 +249,11 @@ function Inner() { path="/window-capture-occluder" component={WindowCaptureOccluderPage} /> + diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index 89b80c5cb20..2e58859121d 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -79,6 +79,7 @@ import { type UpdateCheckResult, type UploadProgress, } from "~/utils/tauri"; +import { openTeleprompter } from "~/utils/teleprompter"; import IconCapLogoFull from "~icons/cap/logo-full"; import IconCapLogoFullDark from "~icons/cap/logo-full-dark"; import IconLucideAppWindowMac from "~icons/lucide/app-window-mac"; @@ -87,6 +88,7 @@ import IconLucideBug from "~icons/lucide/bug"; import IconLucideCircleHelp from "~icons/lucide/circle-help"; import IconLucideImage from "~icons/lucide/image"; import IconLucideImport from "~icons/lucide/import"; +import IconLucideScanText from "~icons/lucide/scan-text"; import IconLucideSearch from "~icons/lucide/search"; import IconLucideSettings from "~icons/lucide/settings"; import IconLucideSquarePlay from "~icons/lucide/square-play"; @@ -2779,6 +2781,16 @@ function Page() { + Teleprompter}> + + {import.meta.env.DEV && ( + ); +} + +function SettingToggle(props: { + label: string; + active: boolean; + onChange: (active: boolean) => void; + children: JSX.Element; +}) { + return ( +
+ + {props.children} + {props.label} + + +
+ ); +} + +export default function Teleprompter() { + const currentWindow = getCurrentWebviewWindow(); + const platform = ostype(); + const isMacOS = platform === "macos"; + const isWindows = platform === "windows"; + const isLinux = platform === "linux"; + const [state, setState] = createSignal(DEFAULT_STATE); + const [isLoaded, setIsLoaded] = createSignal(false); + const [isPlaying, setIsPlaying] = createSignal(false); + const [settingsOpen, setSettingsOpen] = createSignal(false); + let scrollElement: HTMLDivElement | undefined; + let editorElement: HTMLTextAreaElement | undefined; + let saveTimer: ReturnType | undefined; + let unlistenTitlebar: UnlistenFn | undefined; + let resizeObserver: ResizeObserver | undefined; + let playbackFrame = 0; + let playbackPosition = 0; + let playbackTimestamp: number | undefined; + + const hasScript = createMemo(() => state().script.trim().length > 0); + const wordCount = createMemo(() => countWords(state().script)); + const spacerHeight = createMemo( + () => + `calc((100vh - 5rem) / 2 - ${state().fontSize * state().lineHeight * 0.5}px)`, + ); + + onMount(() => { + document.documentElement.setAttribute("data-transparent-window", "true"); + document.body.style.background = "transparent"; + + void Promise.allSettled([ + applyMacOSWindowMaterial("teleprompter"), + initializeTitlebar().then((unlisten) => { + unlistenTitlebar = unlisten; + }), + ]) + .then(async () => { + await currentWindow.setAlwaysOnTop(true); + await commands.setTeleprompterWindowLevel(true); + await currentWindow.show(); + await currentWindow.setFocus(); + }) + .catch((error) => { + console.error("Failed to show teleprompter window:", error); + }); + + void teleprompterStore + .get() + .then((saved) => { + if (saved) setState({ ...DEFAULT_STATE, ...saved }); + setIsLoaded(true); + requestAnimationFrame(resizeEditor); + }) + .catch((error) => { + console.error("Failed to load teleprompter settings:", error); + setIsLoaded(true); + requestAnimationFrame(resizeEditor); + }); + + if (scrollElement) { + resizeObserver = new ResizeObserver(resizeEditor); + resizeObserver.observe(scrollElement); + } + }); + + createEffect(() => { + if (!isLoaded()) return; + const nextState = state(); + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + void teleprompterStore.set(nextState); + }, 250); + }); + + createEffect(() => { + if (!isLoaded() || !isMacOS) return; + const opacity = clamp(state().windowOpacityPercent, 45, 100) / 100; + void commands.setTeleprompterWindowOpacity(opacity).catch((error) => { + console.error("Failed to update teleprompter window opacity:", error); + }); + }); + + onCleanup(() => { + clearTimeout(saveTimer); + cancelAnimationFrame(playbackFrame); + resizeObserver?.disconnect(); + unlistenTitlebar?.(); + }); + + function resizeEditor() { + const element = editorElement; + if (!element) return; + element.style.height = "0px"; + element.style.height = `${element.scrollHeight}px`; + } + + function stopPlayback() { + cancelAnimationFrame(playbackFrame); + playbackFrame = 0; + playbackTimestamp = undefined; + setIsPlaying(false); + } + + function animatePlayback(timestamp: number) { + const element = scrollElement; + if (!element) { + stopPlayback(); + return; + } + + const maximumScroll = Math.max( + 0, + element.scrollHeight - element.clientHeight, + ); + if (maximumScroll <= 1) { + stopPlayback(); + return; + } + + const elapsedSeconds = + playbackTimestamp === undefined + ? 0 + : Math.min((timestamp - playbackTimestamp) / 1000, 0.05); + playbackTimestamp = timestamp; + playbackPosition = advancePlaybackPosition( + playbackPosition, + maximumScroll, + calculatePlaybackSpeed( + maximumScroll, + wordCount(), + state().wordsPerMinute, + ), + elapsedSeconds, + ); + element.scrollTop = playbackPosition; + + if (playbackPosition >= maximumScroll - 0.5) { + stopPlayback(); + return; + } + + playbackFrame = requestAnimationFrame(animatePlayback); + } + + function updateScript(value: string) { + setState((current) => ({ ...current, script: value })); + if (!value.trim()) stopPlayback(); + requestAnimationFrame(resizeEditor); + } + + function togglePlayback() { + if (isPlaying()) { + stopPlayback(); + return; + } + + resizeEditor(); + const element = scrollElement; + if (!element || !hasScript()) return; + const maximumScroll = Math.max( + 0, + element.scrollHeight - element.clientHeight, + ); + if (maximumScroll <= 1) return; + if (element.scrollTop >= maximumScroll - 1) element.scrollTop = 0; + playbackPosition = element.scrollTop; + setSettingsOpen(false); + setIsPlaying(true); + playbackTimestamp = undefined; + playbackFrame = requestAnimationFrame(animatePlayback); + } + + function changeFontSize(delta: number) { + setState((current) => ({ + ...current, + fontSize: clamp(current.fontSize + delta, 22, 52), + })); + requestAnimationFrame(resizeEditor); + } + + return ( +
{ + if (event.key === "Escape") setSettingsOpen(false); + }} + class={cx( + "cap-window-shell relative flex h-screen w-screen flex-col overflow-hidden text-gray-12", + !isMacOS && + "rounded-2xl border border-gray-5 bg-gray-1/90 shadow-2xl backdrop-blur-2xl", + )} + style={{ + opacity: isMacOS + ? "1" + : `${clamp(state().windowOpacityPercent, 45, 100) / 100}`, + }} + > +
+ + + +
+ + + {isLinux + ? "This window may appear in recordings on Linux" + : "This window is hidden from Cap recordings"} + +
+ + + +
+ +
+ +
+ + +
+
+
editorElement?.focus()} + class={cx( + "relative z-10 h-full w-full overflow-y-auto overscroll-contain scrollbar-none", + state().mirror && "scale-x-[-1]", + )} + style={{ + "mask-image": + "linear-gradient(to bottom, rgba(0, 0, 0, 0.4) 0%, black 34%, black 66%, rgba(0, 0, 0, 0.4) 100%)", + "-webkit-mask-image": + "linear-gradient(to bottom, rgba(0, 0, 0, 0.4) 0%, black 34%, black 66%, rgba(0, 0, 0, 0.4) 100%)", + }} + > +