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
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/general_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<WindowExclusion> {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src-tauri/src/platform/macos/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
});
}
Comment on lines +29 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: run_on_main_thread can fail; logging makes this much easier to debug in the field.

Suggested change
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 set_window_opacity(window: tauri::Window, opacity: f64) {
let opacity = opacity.clamp(0.45, 1.0);
let c_window = window.clone();
if let Err(error) = 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];
}) {
tracing::warn!(?error, "Failed to set window opacity");
}
}


pub fn apply_squircle_corners(window: &tauri::WebviewWindow, radius: f64) {
use cocoa::base::{id, nil};
use cocoa::foundation::NSString;
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src-tauri/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
55 changes: 50 additions & 5 deletions apps/desktop/src-tauri/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ use cap_recording::{feeds, sources::screen_capture::ScreenCaptureTarget};
#[cfg(target_os = "macos")]
const DEFAULT_TRAFFIC_LIGHTS_INSET: LogicalPosition<f64> = 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;

Expand Down Expand Up @@ -937,6 +943,7 @@ pub enum CapWindowId {
Debug,
ScreenshotEditor { id: u32 },
Onboarding,
Teleprompter,
}

impl FromStr for CapWindowId {
Expand All @@ -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-", "")
Expand Down Expand Up @@ -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"),
}
}
}
Expand All @@ -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(),
}
}
Expand Down Expand Up @@ -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),
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3233,6 +3242,38 @@ 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);
}

#[cfg(not(target_os = "macos"))]
if _window.label() == CapWindowId::Teleprompter.to_string()
&& let Err(error) = _window.set_always_on_top(_always_on_top)
{
warn!(?error, "Failed to update teleprompter 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,
Expand Down Expand Up @@ -3305,7 +3346,11 @@ fn content_protection_enabled(app: &AppHandle<Wry>) -> bool {
.unwrap_or(false)
}

fn window_matches_exclusion_list(app: &AppHandle<Wry>, window_title: &str) -> bool {
fn window_capture_excluded(app: &AppHandle<Wry>, 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)))
Expand All @@ -3321,7 +3366,7 @@ fn window_matches_exclusion_list(app: &AppHandle<Wry>, window_title: &str) -> bo
fn should_protect_window(app: &AppHandle<Wry>, 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<Wry>, enabled: bool) {
Expand All @@ -3343,7 +3388,7 @@ pub fn apply_content_protection(app: &AppHandle<Wry>, 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")]
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -248,6 +249,11 @@ function Inner() {
path="/window-capture-occluder"
component={WindowCaptureOccluderPage}
/>
<Route
path="/teleprompter"
info={{ AUTO_SHOW_WINDOW: false }}
component={TeleprompterPage}
/>
</Router>
</CapErrorBoundary>
</>
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/routes/(window-chrome)/new-main/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -2779,6 +2781,16 @@ function Page() {
<IconLucideSquarePlay class="transition-colors text-gray-11 size-4 hover:text-gray-12" />
</button>
</Tooltip>
<Tooltip content={<span>Teleprompter</span>}>
<button
type="button"
onClick={() => void openTeleprompter()}
class="flex justify-center items-center size-5 focus:outline-hidden"
aria-label="Open teleprompter"
>
<IconLucideScanText class="transition-colors text-gray-11 size-4 hover:text-gray-12" />
</button>
</Tooltip>
<ChangelogButton />
{import.meta.env.DEV && (
<button
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/routes/teleprompter-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
advancePlaybackPosition,
calculatePlaybackSpeed,
clamp,
countWords,
} from "./teleprompter-utils";

describe("teleprompter utilities", () => {
it("counts words in pasted scripts", () => {
expect(countWords(" One two\nthree ")).toBe(3);
expect(countWords(" ")).toBe(0);
});

it("clamps a setting to its supported range", () => {
expect(clamp(5, 10, 20)).toBe(10);
expect(clamp(25, 10, 20)).toBe(20);
});

it("calculates a positive scroll speed from reading duration", () => {
expect(calculatePlaybackSpeed(600, 300, 150)).toBe(5);
expect(calculatePlaybackSpeed(-10, 300, 150)).toBe(0);
});

it("retains sub-pixel movement across animation frames", () => {
let position = 0;
for (let frame = 0; frame < 60; frame += 1) {
position = advancePlaybackPosition(position, 100, 10, 1 / 60);
}

expect(position).toBeCloseTo(10);
});
});
30 changes: 30 additions & 0 deletions apps/desktop/src/routes/teleprompter-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export function countWords(text: string) {
return text.trim() ? text.trim().split(/\s+/u).length : 0;
}

export function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), maximum);
}

export function calculatePlaybackSpeed(
maximumScroll: number,
wordCount: number,
wordsPerMinute: number,
) {
const durationSeconds =
(Math.max(1, wordCount) / Math.max(1, wordsPerMinute)) * 60;
return Math.max(0, maximumScroll) / Math.max(1, durationSeconds);
}

export function advancePlaybackPosition(
position: number,
maximumScroll: number,
pixelsPerSecond: number,
elapsedSeconds: number,
) {
return Math.min(
Math.max(0, maximumScroll),
Math.max(0, position) +
Math.max(0, pixelsPerSecond) * Math.max(0, elapsedSeconds),
);
}
Loading
Loading