diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f91f5f80..91ab2b09 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,11 @@ { "name": "desktop", "private": true, +<<<<<<< HEAD "version": "0.1.9", +======= + "version": "0.2.0", +>>>>>>> main "scripts": { "dev": "tauri dev", "build": "tsc && next build", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index cfef6e0f..759d8f04 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -2320,6 +2320,15 @@ dependencies = [ "serde", ] +[[package]] +name = "infer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f178e61cdbfe084aa75a2f4f7a25a5bb09701a47ae1753608f194b15783c937a" +dependencies = [ + "cfb", +] + [[package]] name = "infer" version = "0.13.0" @@ -4738,6 +4747,7 @@ dependencies = [ "http", "ignore", "indexmap 1.9.3", + "infer 0.9.0", "minisign-verify", "nix 0.26.4", "notify-rust", @@ -4747,6 +4757,7 @@ dependencies = [ "os_info", "os_pipe", "percent-encoding", + "png", "rand 0.8.5", "raw-window-handle 0.5.2", "regex", @@ -4950,7 +4961,7 @@ dependencies = [ "glob", "heck 0.4.1", "html5ever", - "infer", + "infer 0.13.0", "json-patch", "kuchikiki", "log", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 523243ad..a495a64a 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -14,7 +14,7 @@ tauri-build = { version = "1.5.1", features = [] } ffmpeg-sidecar = "0.5.1" [dependencies] -tauri = { version = "1.6.1", features = [ "updater", "macos-private-api", "window-set-position", "fs-write-file", "fs-remove-file", "fs-read-file", "fs-rename-file", "fs-exists", "fs-remove-dir", "fs-read-dir", "fs-copy-file", "fs-create-dir", "window-set-ignore-cursor-events", "window-unminimize", "window-minimize", "window-close", "window-show", "window-start-dragging", "window-hide", "window-unmaximize", "window-maximize", "window-set-always-on-top", "shell-open", "devtools", "os-all", "http-all"] } +tauri = { version = "1.6.1", features = [ "system-tray", "updater", "macos-private-api", "window-set-position", "fs-write-file", "fs-remove-file", "fs-read-file", "fs-rename-file", "fs-exists", "fs-remove-dir", "fs-read-dir", "fs-copy-file", "fs-create-dir", "window-set-ignore-cursor-events", "window-unminimize", "window-minimize", "window-close", "window-show", "window-start-dragging", "window-hide", "window-unmaximize", "window-maximize", "window-set-always-on-top", "shell-open", "devtools", "os-all", "http-all", "icon-png"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tauri-plugin-context-menu = "0.7.0" diff --git a/apps/desktop/src-tauri/icons/tray-default-icon.png b/apps/desktop/src-tauri/icons/tray-default-icon.png new file mode 100644 index 00000000..4823afb2 Binary files /dev/null and b/apps/desktop/src-tauri/icons/tray-default-icon.png differ diff --git a/apps/desktop/src-tauri/icons/tray-stop-icon.png b/apps/desktop/src-tauri/icons/tray-stop-icon.png new file mode 100644 index 00000000..acc363e9 Binary files /dev/null and b/apps/desktop/src-tauri/icons/tray-stop-icon.png differ diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index cd455978..91bb5c6d 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,11 +1,14 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use std::collections::LinkedList; use std::sync::{Arc}; use std::path::PathBuf; +use cpal::Devices; +use regex::Regex; use tokio::sync::Mutex; use std::sync::atomic::{AtomicBool}; -use std::env; -use tauri::{command, Manager, Window}; +use std::{vec}; +use tauri::{command, CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTraySubmenu, Window}; use window_vibrancy::{apply_blur, apply_vibrancy, NSVisualEffectMaterial}; use window_shadows::set_shadow; use tauri_plugin_positioner::{WindowExt, Position}; @@ -156,6 +159,39 @@ fn main() { (0, 0) }; + #[derive(serde::Deserialize, PartialEq)] + enum DeviceKind { + #[serde(alias="videoinput")] + Video, + #[serde(alias="audioinput")] + Audio + } + + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct MediaDevice { + id: String, + kind: DeviceKind, + label: String + } + + fn create_tray_menu(submenus: Option>) -> SystemTrayMenu { + let mut tray_menu = SystemTrayMenu::new(); + + if let Some(items) = submenus { + for submenu in items { + tray_menu = tray_menu.add_submenu(submenu); + } + tray_menu = tray_menu.add_native_item(tauri::SystemTrayMenuItem::Separator); + } + + tray_menu + .add_item(CustomMenuItem::new("show-window".to_string(), "Show Cap")) + .add_item(CustomMenuItem::new("quit".to_string(), "Quit").accelerator("CmdOrControl+Q")) + } + + let tray = SystemTray::new().with_menu(create_tray_menu(None)).with_menu_on_left_click(false).with_title("Cap"); + tauri::Builder::default() .plugin(tauri_plugin_oauth::init()) .plugin(tauri_plugin_positioner::init()) @@ -188,6 +224,67 @@ fn main() { app.manage(Arc::new(Mutex::new(recording_state))); + let tray_handle = app.tray_handle(); + app.listen_global("toggle-recording", move|event| { + let is_recording: bool = serde_json::from_str(event.payload().expect("Error while openning event payload")).expect("Error while deserializing recording state from event payload"); + + let icon_bytes = if is_recording { + include_bytes!("../icons/tray-stop-icon.png").to_vec() + } else { + include_bytes!("../icons/tray-default-icon.png").to_vec() + }; + + tray_handle.set_icon(tauri::Icon::Raw(icon_bytes)).expect("Error while setting tray icon"); + }); + + let tray_handle = app.tray_handle(); + app.listen_global("media-devices-set", move|event| { + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct Payload { + media_devices: Vec, + selected_video: Option, + selected_audio: Option + } + let payload: Payload = serde_json::from_str(event.payload().expect("Error wile openning event payload")).expect("Error while deserializing media devices from event payload"); + + fn create_submenu_items(devices: &Vec, selected_device: &Option, kind: DeviceKind) -> SystemTrayMenu { + let id_prefix = if kind == DeviceKind::Video { + "video" + } else { + "audio" + }; + let mut none_item = CustomMenuItem::new(format!("in_{}_none", id_prefix), "None"); + if selected_device.is_none() { + none_item = none_item.selected(); + } + let initial = SystemTrayMenu::new().add_item(none_item); + devices + .iter() + .filter(|device| device.kind == kind) + .fold(initial, |tray_items, device| { + let mut menu_item = CustomMenuItem::new(format!("in_{}_{}", id_prefix, device.id), &device.label); + + if let Some(selected) = selected_device { + if selected.label == device.label { + menu_item = menu_item.selected(); + } + } + + tray_items.add_item(menu_item) + }) + } + + let new_menu = create_tray_menu(Some( + vec![ + SystemTraySubmenu::new("Camera", create_submenu_items(&payload.media_devices, &payload.selected_video, DeviceKind::Video)), + SystemTraySubmenu::new("Microphone", create_submenu_items(&payload.media_devices, &payload.selected_audio, DeviceKind::Audio)) + ] + )); + + tray_handle.set_menu(new_menu).expect("Error while updating the tray menu items"); + }); + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -205,6 +302,46 @@ fn main() { reset_camera_permissions, ]) .plugin(tauri_plugin_context_menu::init()) + .system_tray(tray) + .on_system_tray_event(move |app, event| match event { + SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() { + "show-window" => { + let window = app.get_window("main").expect("Error while trying to get the main window."); + window.show().expect("Error while trying to show main window"); + window.set_focus().expect("Error while trying to set focus on main window"); + } + "quit" => { + app.exit(0); + } + item_id => { + if !item_id.starts_with("in") { + return; + } + let pattern = Regex::new(r"^in_(video|audio)_").expect("Failed to create regex for checking tray item events"); + + if pattern.is_match(item_id) { + #[derive(Clone, serde::Serialize)] + struct SetDevicePayload { + #[serde(rename(serialize="type"))] + device_type: String, + id: Option + } + + let device_id = pattern.replace_all(item_id, "").into_owned(); + let kind = if item_id.contains("video") { "videoinput" } else { "audioinput" }; + + app.emit_all("tray-set-device-id", SetDevicePayload { + device_type: kind.to_string(), + id: if device_id == "none" { None } else { Some(device_id) } + }).expect("Failed to emit tray set media device event to windows"); + } + } + }, + SystemTrayEvent::LeftClick { position: _, size: _, .. } => { + app.emit_all("tray-on-left-click", Some(())).expect("Failed to emit tray left click event to windows"); + }, + _ => {} + }) .run(tauri::generate_context!()) .expect("Error while running tauri application"); -} \ No newline at end of file +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index d6a59709..f286655c 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -8,7 +8,11 @@ }, "package": { "productName": "Cap", +<<<<<<< HEAD "version": "0.1.9" +======= + "version": "0.2.0" +>>>>>>> main }, "tauri": { "allowlist": { @@ -99,6 +103,10 @@ "alwaysOnTop": false, "center": true } - ] + ], + "systemTray": { + "iconPath": "icons/tray-default-icon.png", + "iconAsTemplate": true + } } } diff --git a/apps/desktop/src/components/windows/Camera.tsx b/apps/desktop/src/components/windows/Camera.tsx index 833d1d2b..a0de0e25 100644 --- a/apps/desktop/src/components/windows/Camera.tsx +++ b/apps/desktop/src/components/windows/Camera.tsx @@ -15,7 +15,7 @@ export const Camera = () => { const video = videoRef.current; const constraints = { video: { - deviceId: selectedVideoDevice.deviceId, + deviceId: selectedVideoDevice.id, }, }; diff --git a/apps/desktop/src/components/windows/inner/Recorder.tsx b/apps/desktop/src/components/windows/inner/Recorder.tsx index bf0c494e..cb34f1c4 100644 --- a/apps/desktop/src/components/windows/inner/Recorder.tsx +++ b/apps/desktop/src/components/windows/inner/Recorder.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { useMediaDevices } from "@/utils/recording/MediaDeviceContext"; +import { Device, useMediaDevices } from "@/utils/recording/MediaDeviceContext"; import { Video } from "@/components/icons/Video"; import { Microphone } from "@/components/icons/Microphone"; import { Screen } from "@/components/icons/Screen"; @@ -9,7 +9,7 @@ import { Window } from "@/components/icons/Window"; import { ActionButton } from "@/components/recording/ActionButton"; import { Button } from "@cap/ui"; import { Logo } from "@/components/icons/Logo"; -import { emit } from "@tauri-apps/api/event"; +import { emit, listen, UnlistenFn } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/tauri"; import { getLatestVideoId, saveLatestVideoId } from "@/utils/database/utils"; import { openLinkInBrowser } from "@/utils/helpers"; @@ -17,6 +17,7 @@ import toast, { Toaster } from "react-hot-toast"; import { authFetch } from "@/utils/auth/helpers"; import { appDataDir, join } from "@tauri-apps/api/path"; import { open } from "@tauri-apps/api/shell"; +import * as Tauri from "@tauri-apps/api"; declare global { interface Window { @@ -42,52 +43,49 @@ export const Recorder = () => { const [canStopRecording, setCanStopRecording] = useState(false); const [hasStartedRecording, setHasStartedRecording] = useState(false); - const handleContextClick = async (option: string) => { + const handleContextClick = async (option: "video" | "audio") => { const { showMenu } = await import("tauri-plugin-context-menu"); + const deviceKind = option === "video" ? "videoinput" : "audioinput"; + const isSelected = (device: Device | null) => { + if (device === null) { + return deviceKind === "videoinput" + ? selectedVideoDevice === null + : selectedAudioDevice === null; + } + + return deviceKind === "videoinput" + ? device.index === selectedVideoDevice?.index + : device.index === selectedAudioDevice?.index; + } + const select = async (device: Device | null) => { + // if (isSelected(device)) { + // return + // } + emit("change-device", { type: deviceKind, device: device }).catch((error) => { + console.log("Failed to emit change-device event:", error); + }); + } - const filteredDevices = devices - .filter((device) => - option === "video" - ? device.kind === "videoinput" - : device.kind === "audioinput" - ) - .map((device) => ({ - label: device.label, - disabled: - option === "video" - ? device.index === selectedVideoDevice?.index - : device.index === selectedAudioDevice?.index, - event: async () => { - try { - await emit("change-device", { type: option, device }); - } catch (error) { - console.error("Failed to emit change-device event:", error); - } - }, - })); - - filteredDevices.push({ - label: "None", - disabled: false, - event: async () => { - try { - await emit("change-device", { - type: option, - device: { - label: "None", - index: -1, - kind: option === "video" ? "videoinput" : "audioinput", - }, - }); - } catch (error) { - console.error("Failed to emit change-device event:", error); - } - }, - }); + const devicesOfKind = devices.filter((device) => device.kind === deviceKind); + const menuItems = [ + { + label: "None", + checked: isSelected(null), + event: async() => select(null) + }, + ...devicesOfKind.map((device) => ( + { + label: device.label, + checked: isSelected(device), + event: async() => select(device) + } + )) + ] + await showMenu({ - items: [...filteredDevices], - ...(filteredDevices.length === 0 && { + items: [...menuItems], + ...(devicesOfKind.length === 0 && { items: [ { label: "Nothing found.", @@ -137,6 +135,32 @@ export const Recorder = () => { return data; }; + useEffect(() => { + let unlistenFn: UnlistenFn | null = null; + + const setupListener = async () => { + unlistenFn = await listen("tray-on-left-click", (_) => { + if (isRecording) { + handleStopAllRecordings(); + } + + const currentWindow = Tauri.window.getCurrent(); + if (!currentWindow.isVisible) { + currentWindow.show(); + } + currentWindow.setFocus(); + }); + }; + + setupListener(); + + return () => { + if (unlistenFn) { + unlistenFn(); + } + }; + }, [isRecording, canStopRecording]); + const startDualRecording = async (videoData: { id: string; user_id: string; @@ -154,6 +178,13 @@ export const Recorder = () => { if (window.fathom !== undefined) { window.fathom.trackEvent("start_recording"); } + Tauri.window.getAll().forEach((window) => { + if (window.label !== "camera") { + window.hide(); + } + }); + + emit("toggle-recording", true); await invoke("start_dual_recording", { options: { user_id: videoData.user_id, @@ -229,6 +260,8 @@ export const Recorder = () => { setIsRecording(false); setHasStartedRecording(false); setStoppingRecording(false); + setCanStopRecording(false); + emit("toggle-recording", false); } catch (error) { console.error("Error stopping recording:", error); } @@ -343,7 +376,7 @@ export const Recorder = () => { width="full" handler={() => handleContextClick("video")} icon={