Skip to content

Commit

Permalink
feat: multiple screen selection (#118)
Browse files Browse the repository at this point in the history
* feat: add public function to capture all screens

* feat: add list_capture_screens to tauri

* feat: add listScreens query to solidquery

* feat: incorporate multiscreen in frontend refactor tabs and select

* fix: return exact display names for screens

* fix: add bounds to screen targets

* refactor: enable vs disabled based on screen and windows length

* fix: add screen id in display and recording

* fix: default screen id

* fix: tab and select navigation

* fix: resolve merge conflicts and redo stuff

* use separate selects

* prepare for merge

- use separate target selects
- display monitor names instead of indexes
- provide proper target to scap

---------

Co-authored-by: Brendan Allan <[email protected]>
  • Loading branch information
5war00p and Brendonovich authored Oct 22, 2024
1 parent 6fef57f commit 1ac5ee4
Show file tree
Hide file tree
Showing 9 changed files with 266 additions and 124 deletions.
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mod upload;
mod web_api;
mod windows;

use cap_media::sources::CaptureScreen;
use audio::AppSounds;
use auth::AuthStore;
use cap_editor::{AudioData, EditorState, ProjectRecordings};
Expand All @@ -35,7 +36,7 @@ use image::{ImageBuffer, Rgba};
use mp4::Mp4Reader;
use num_traits::ToBytes;
use png::{ColorType, Encoder};
use recording::{list_cameras, list_capture_windows, InProgressRecording, FPS};
use recording::{list_cameras, list_capture_windows, list_capture_screens, InProgressRecording, FPS};
use scap::capturer::Capturer;
use scap::frame::Frame;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -2251,6 +2252,7 @@ pub async fn run() {
take_screenshot,
list_cameras,
list_capture_windows,
list_capture_screens,
list_audio_devices,
show_previous_recordings_window,
close_previous_recordings_window,
Expand Down Expand Up @@ -2372,7 +2374,7 @@ pub async fn run() {
camera_ws_port,
camera_feed: None,
start_recording_options: RecordingOptions {
capture_target: ScreenCaptureTarget::Screen,
capture_target: ScreenCaptureTarget::Screen(CaptureScreen { id: 1, name: "Default".to_string() }),
camera_label: None,
audio_input_name: None,
},
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src-tauri/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ use crate::RecordingOptions;
// TODO: Hacky, please fix
pub const FPS: u32 = 30;

#[tauri::command(async)]
#[specta::specta]
pub fn list_capture_screens() -> Vec<CaptureScreen> {
ScreenCaptureSource::list_screens()
}

#[tauri::command(async)]
#[specta::specta]
pub fn list_capture_windows() -> Vec<CaptureWindow> {
Expand Down Expand Up @@ -173,7 +179,8 @@ pub async fn start(
let mut audio_output_path = None;
let mut camera_output_path = None;

let screen_source = ScreenCaptureSource::init(&recording_options.capture_target, None, None);
let screen_source =
ScreenCaptureSource::init(dbg!(&recording_options.capture_target), None, None);
let screen_config = screen_source.info();
let output_config = screen_config.scaled(1920, 30);
let screen_filter = VideoFilter::init("screen", screen_config, output_config)?;
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/src/web_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use tauri_specta::Event;
use crate::auth::{AuthStore, AuthenticationInvalid};

pub fn make_url(pathname: impl AsRef<str>) -> String {
let server_url_base = dotenvy_macro::dotenv!("NEXT_PUBLIC_URL");
let server_url_base = "http://localhost:3000"; //dotenvy_macro::dotenv!("NEXT_PUBLIC_URL");
format!("{server_url_base}{}", pathname.as_ref())
}

Expand Down
251 changes: 140 additions & 111 deletions apps/desktop/src/routes/(window-chrome)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Button, SwitchTab } from "@cap/ui-solid";
import { Select as KSelect } from "@kobalte/core/select";
import { createEventListener } from "@solid-primitives/event-listener";
import { cache, createAsync, redirect, useNavigate } from "@solidjs/router";
import { createMutation, createQuery } from "@tanstack/solid-query";
import { getVersion } from "@tauri-apps/api/app";
Expand All @@ -25,8 +24,14 @@ import {
listAudioDevices,
getPermissions,
createVideoDevicesQuery,
listScreens,
} from "~/utils/queries";
import { type CaptureWindow, commands, events } from "~/utils/tauri";
import {
CaptureScreen,
type CaptureWindow,
commands,
events,
} from "~/utils/tauri";
import {
MenuItem,
MenuItemList,
Expand All @@ -46,13 +51,13 @@ export const route = {
};

export default function () {
const screens = createQuery(() => listScreens);
const { options, setOptions } = createOptionsQuery();
const windows = createQuery(() => listWindows);
const videoDevices = createVideoDevicesQuery();
const audioDevices = createQuery(() => listAudioDevices);
const currentRecording = createCurrentRecordingQuery();

const [windowSelectOpen, setWindowSelectOpen] = createSignal(false);
const permissions = createQuery(() => getPermissions);

const [microphoneSelectOpen, setMicrophoneSelectOpen] = createSignal(false);
Expand All @@ -77,15 +82,7 @@ export default function () {
},
}));

createEventListener(window, "mousedown", (e: MouseEvent) => {
if (windowSelectOpen()) {
const target = e.target as HTMLElement;
if (!target.closest(".KSelect")) {
setWindowSelectOpen(false);
}
}
});

// important for sign in redirect, trust me
createAsync(() => getAuth());

createUpdateCheck();
Expand Down Expand Up @@ -142,12 +139,6 @@ export default function () {
}
};

const selectedWindow = () => {
const d = options.data?.captureTarget;
if (d?.variant !== "window") return;
return windows.data?.find((data) => data.id === d.id);
};

const audioDevice = () =>
audioDevices?.data?.find(
(d) => d.name === options.data?.audioInputName
Expand Down Expand Up @@ -235,98 +226,57 @@ export default function () {
<IconCapSettings class="w-[1.25rem] h-[1.25rem] text-gray-400 hover:text-gray-500" />
</button>
</div>
<KSelect<CaptureWindow | null>
options={windows.data ?? []}
optionValue="id"
optionTextValue="name"
placeholder="Window"
gutter={8}
open={windowSelectOpen()}
onOpenChange={(o: boolean) => {
// prevents tab onChange from interfering with dropdown trigger click
if (o === false && options.data?.captureTarget.variant === "screen")
return;
setWindowSelectOpen(o);
}}
itemComponent={(props: { item: any }) => (
<MenuItem<typeof KSelect.Item> as={KSelect.Item} item={props.item}>
<KSelect.ItemLabel class="flex-1">
{props.item.rawValue?.name}
</KSelect.ItemLabel>
</MenuItem>
)}
value={selectedWindow() ?? null}
onChange={(d: CaptureWindow | null) => {
if (!d || !options.data) return;
setOptions({
...options.data,
captureTarget: { variant: "window", ...d },
});
setWindowSelectOpen(false);
}}
placement="top-end"
>
<SwitchTab
value={options.data?.captureTarget.variant}
disabled={isRecording()}
onChange={(s) => {
if (!options.data) return;
if (options.data?.captureTarget.variant === s) {
setWindowSelectOpen(false);
return;
}
if (s === "screen") {
setOptions({
...options.data,
captureTarget: { variant: "screen" },
});
setWindowSelectOpen(false);
} else if (s === "window") {
if (windowSelectOpen()) {
setWindowSelectOpen(false);
} else {
setWindowSelectOpen(true);
}
}
<div class="flex flex-row items-center rounded-[0.5rem] relative border">
<div
class="w-1/2 absolute flex p-px inset-0 transition-transform peer-focus-visible:outline outline-2 outline-blue-300 outline-offset-2 rounded-[0.6rem] overflow-hidden"
style={{
transform:
options.data?.captureTarget.variant === "window"
? "translateX(100%)"
: undefined,
}}
>
<SwitchTab.List>
<SwitchTab.Trigger value="screen">Screen</SwitchTab.Trigger>
<SwitchTab.Trigger<ValidComponent>
as={(p) => <KSelect.Trigger<ValidComponent> {...p} />}
value="window"
class="w-full text-nowrap overflow-hidden px-2 group KSelect"
>
<KSelect.Value<CaptureWindow> class="flex flex-row items-center justify-center">
{(item) => (
<>
<span class="flex-1 truncate">
{item.selectedOption()?.name ?? "Select Window"}
</span>

<IconCapChevronDown class="size-4 shrink-0 ui-group-expanded:-rotate-180 transform transition-transform" />
</>
)}
</KSelect.Value>
</SwitchTab.Trigger>
</SwitchTab.List>
</SwitchTab>
<KSelect.Portal>
<PopperContent<typeof KSelect.Content>
as={KSelect.Content}
class={topRightAnimateClasses}
>
<Show
when={(windows.data ?? []).length > 0}
fallback={
<div class="p-2 text-gray-500">No windows available</div>
}
>
<KSelect.Listbox class="max-h-52 max-w-64" as={MenuItemList} />
</Show>
</PopperContent>
</KSelect.Portal>
</KSelect>
<div class="bg-gray-100 flex-1" />
</div>
<TargetSelect<CaptureScreen>
options={screens.data ?? []}
onChange={(value) => {
if (!value || !options.data) return;

commands.setRecordingOptions({
...options.data,
captureTarget: { ...value, variant: "screen" },
});
}}
value={
options.data?.captureTarget.variant === "screen"
? options.data.captureTarget
: null
}
placeholder="Screen"
optionsEmptyText="No screens found"
selected={options.data?.captureTarget.variant === "screen"}
/>
<TargetSelect<CaptureWindow>
options={windows.data ?? []}
onChange={(value) => {
if (!options.data) return;

commands.setRecordingOptions({
...options.data,
captureTarget: { ...value, variant: "window" },
});
}}
value={
options.data?.captureTarget.variant === "window"
? options.data.captureTarget
: null
}
placeholder="Window"
optionsEmptyText="No windows found"
selected={options.data?.captureTarget.variant === "window"}
/>
</div>
<div class="flex flex-col gap-[0.25rem] items-stretch">
<label class="text-gray-400 text-[0.875rem]">Camera</label>
<Show when>
Expand Down Expand Up @@ -571,6 +521,7 @@ export default function () {
import * as dialog from "@tauri-apps/plugin-dialog";
import * as updater from "@tauri-apps/plugin-updater";
import { makePersisted } from "@solid-primitives/storage";
import { createEventListener } from "@solid-primitives/event-listener";

let hasChecked = false;
function createUpdateCheck() {
Expand All @@ -597,7 +548,85 @@ function createUpdateCheck() {
});
}

function dbg<T>(v: T) {
console.log(v);
return v;
function TargetSelect<T extends { id: number; name: string }>(props: {
options: Array<T>;
onChange: (value: T) => void;
value: T | null;
selected: boolean;
optionsEmptyText: string;
placeholder: string;
}) {
return (
<KSelect<T | null>
options={props.options ?? []}
optionValue="id"
optionTextValue="name"
gutter={8}
itemComponent={(props) => (
<MenuItem<typeof KSelect.Item> as={KSelect.Item} item={props.item}>
<KSelect.ItemLabel class="flex-1">
{props.item.rawValue?.name}
</KSelect.ItemLabel>
</MenuItem>
)}
placement="bottom"
class="max-w-[50%] w-full z-10"
placeholder={props.placeholder}
onChange={(value) => {
if (!value) return;
props.onChange(value);
}}
value={props.value}
>
<KSelect.Trigger<ValidComponent>
as={
props.options.length === 1
? (p) => (
<button
onClick={() => {
props.onChange(props.options[0]);
}}
data-selected={props.selected}
class={p.class}
>
<span class="truncate">{props.options[0].name}</span>
</button>
)
: undefined
}
class="flex-1 text-gray-400 py-1 z-10 data-[selected='true']:text-gray-500 peer focus:outline-none transition-colors duration-100 w-full text-nowrap overflow-hidden px-2 flex gap-2 items-center justify-center"
data-selected={props.selected}
onClick={(e) => {
if (props.options.length === 1) {
e.preventDefault();
props.onChange(props.options[0]);
}
}}
>
<KSelect.Value<CaptureScreen | undefined> class="truncate">
{(value) => value.selectedOption()?.name}
</KSelect.Value>
{props.options.length > 1 && (
<KSelect.Icon class="ui-expanded:-rotate-180 transition-transform">
<IconCapChevronDown class="size-4 shrink-0 transform transition-transform" />
</KSelect.Icon>
)}
</KSelect.Trigger>
<KSelect.Portal>
<PopperContent<typeof KSelect.Content>
as={KSelect.Content}
class={topRightAnimateClasses}
>
<Show
when={props.options.length > 0}
fallback={
<div class="p-2 text-gray-500">{props.optionsEmptyText}</div>
}
>
<KSelect.Listbox class="max-h-52 max-w-64" as={MenuItemList} />
</Show>
</PopperContent>
</KSelect.Portal>
</KSelect>
);
}
Loading

1 comment on commit 1ac5ee4

@vercel
Copy link

@vercel vercel bot commented on 1ac5ee4 Oct 22, 2024

Choose a reason for hiding this comment

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

Please sign in to comment.