@domicile-desktop/component-library 0.0.0-alpha-e455955b57c7 → 0.0.0-alpha-cd52ce30fbe6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"dependencies": {
|
|
3
3
|
"@base-ui/react": "^1.8.0",
|
|
4
4
|
"@cprussin/option-result": "^2.0.1",
|
|
5
|
-
"@domicile-desktop/sdk": "0.0.0-alpha-
|
|
6
|
-
"@domicile-desktop/system-apps": "0.0.0-alpha-
|
|
5
|
+
"@domicile-desktop/sdk": "0.0.0-alpha-cd52ce30fbe6",
|
|
6
|
+
"@domicile-desktop/system-apps": "0.0.0-alpha-cd52ce30fbe6",
|
|
7
7
|
"@pandacss/dev": "^1.12.1",
|
|
8
8
|
"@phosphor-icons/react": "^2.1.10"
|
|
9
9
|
},
|
|
@@ -118,5 +118,5 @@
|
|
|
118
118
|
"test:unit": "bun test"
|
|
119
119
|
},
|
|
120
120
|
"type": "module",
|
|
121
|
-
"version": "0.0.0-alpha-
|
|
121
|
+
"version": "0.0.0-alpha-cd52ce30fbe6"
|
|
122
122
|
}
|
|
@@ -102,6 +102,9 @@ const summary = (session: Capturing, name: string): string => {
|
|
|
102
102
|
return `Input capture: ${name} — ${deviceNames(session.devices).join(", ")}`;
|
|
103
103
|
case CapturingKind.ScreenCast:
|
|
104
104
|
return `Sharing: ${name} — ${session.sources.map((source) => sourceName(source)).join(", ")}`;
|
|
105
|
+
// `<PortalDialogs />` gives the shell's recording its own toolbar.
|
|
106
|
+
case CapturingKind.Recording:
|
|
107
|
+
return `Recording: ${session.sources.map((source) => sourceName(source)).join(", ")}`;
|
|
105
108
|
case CapturingKind.Unknown:
|
|
106
109
|
return `${session.wireKind}: ${name}`;
|
|
107
110
|
}
|
|
@@ -34,6 +34,8 @@ import { InputCaptureDialog } from "./InputCaptureDialog";
|
|
|
34
34
|
import { LauncherDialog } from "./LauncherDialog";
|
|
35
35
|
import { PickColorDialog } from "./PickColorDialog";
|
|
36
36
|
import { PrintDialog } from "./PrintDialog";
|
|
37
|
+
import type { Recording } from "./RecordingToolbar";
|
|
38
|
+
import { RecordingToolbar } from "./RecordingToolbar";
|
|
37
39
|
import { RemoteDesktopDialog } from "./RemoteDesktopDialog";
|
|
38
40
|
import { ScreenCastDialog } from "./ScreenCastDialog";
|
|
39
41
|
import { ScreenshotDialog } from "./ScreenshotDialog";
|
|
@@ -57,7 +59,14 @@ export type PortalDialogsDesktop = DialogDesktop &
|
|
|
57
59
|
readonly browser: Pick<Browser, "windows">;
|
|
58
60
|
readonly portals: Pick<
|
|
59
61
|
Portals,
|
|
60
|
-
|
|
62
|
+
| "answer"
|
|
63
|
+
| "capturing"
|
|
64
|
+
| "discardRecording"
|
|
65
|
+
| "on"
|
|
66
|
+
| "pauseRecording"
|
|
67
|
+
| "requests"
|
|
68
|
+
| "resumeRecording"
|
|
69
|
+
| "stopCapturing"
|
|
61
70
|
>;
|
|
62
71
|
};
|
|
63
72
|
|
|
@@ -96,7 +105,9 @@ type Props = {
|
|
|
96
105
|
* Every dialog applications ask for through `xdg-desktop-portal`, one at a
|
|
97
106
|
* time, oldest first, with a screenshot, color pick or screen cast stacked
|
|
98
107
|
* over it so the user can capture it, and an indicator for each session that
|
|
99
|
-
* controls or captures input.
|
|
108
|
+
* controls or captures input. The shell's own screen recording gets a toolbar
|
|
109
|
+
* instead, left out of the recording. Requests of a kind it has no dialog for
|
|
110
|
+
* are refused.
|
|
100
111
|
* Inhibitors are not questions, so it leaves them be. It also fires the
|
|
101
112
|
* chords applications hold through the GlobalShortcuts portal. See
|
|
102
113
|
* docs/PORTALS.md.
|
|
@@ -139,22 +150,40 @@ export const PortalDialogs = memo(
|
|
|
139
150
|
}
|
|
140
151
|
}, [portals, requests]);
|
|
141
152
|
|
|
153
|
+
const recordings = sessions.filter(isRecording);
|
|
142
154
|
return (
|
|
143
155
|
<>
|
|
144
156
|
<CapturingIndicator
|
|
145
157
|
apps={apps}
|
|
146
158
|
screen={screen}
|
|
147
|
-
sessions={
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
: sessions
|
|
153
|
-
}
|
|
159
|
+
sessions={sessions.filter(
|
|
160
|
+
(session) =>
|
|
161
|
+
!isRecording(session) &&
|
|
162
|
+
!(omitScreenCasts && session.kind === CapturingKind.ScreenCast),
|
|
163
|
+
)}
|
|
154
164
|
stop={(id) => {
|
|
155
165
|
portals.stopCapturing(id);
|
|
156
166
|
}}
|
|
157
167
|
/>
|
|
168
|
+
{recordings.map((recording) => (
|
|
169
|
+
<RecordingToolbar
|
|
170
|
+
discard={() => {
|
|
171
|
+
portals.discardRecording(recording.id);
|
|
172
|
+
}}
|
|
173
|
+
key={recording.id}
|
|
174
|
+
pause={() => {
|
|
175
|
+
portals.pauseRecording(recording.id);
|
|
176
|
+
}}
|
|
177
|
+
recording={recording}
|
|
178
|
+
resume={() => {
|
|
179
|
+
portals.resumeRecording(recording.id);
|
|
180
|
+
}}
|
|
181
|
+
screen={screen}
|
|
182
|
+
stop={() => {
|
|
183
|
+
portals.stopCapturing(recording.id);
|
|
184
|
+
}}
|
|
185
|
+
/>
|
|
186
|
+
))}
|
|
158
187
|
{stacked(
|
|
159
188
|
requests.filter(
|
|
160
189
|
(request) =>
|
|
@@ -186,6 +215,9 @@ export const PortalDialogs = memo(
|
|
|
186
215
|
/** The windows this page draws. */
|
|
187
216
|
const drawnOnPage = () => drawnWindows(document);
|
|
188
217
|
|
|
218
|
+
const isRecording = (session: Capturing): session is Recording =>
|
|
219
|
+
session.kind === CapturingKind.Recording;
|
|
220
|
+
|
|
189
221
|
/** The dialog for `request`'s kind. */
|
|
190
222
|
const Dialog = ({
|
|
191
223
|
answer,
|
|
@@ -319,6 +351,7 @@ const Dialog = ({
|
|
|
319
351
|
browserWindows={browserWindows}
|
|
320
352
|
castTo={claim?.kind === CastClaimKind.Pick ? claim.to : undefined}
|
|
321
353
|
previewWindow={domicile.capture.preview}
|
|
354
|
+
record={request.appId === SHELL_APP_ID}
|
|
322
355
|
screen={screen}
|
|
323
356
|
shownWindows={shownWindows}
|
|
324
357
|
/>
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { Capturing, CapturingKind } from "@domicile-desktop/sdk/portal";
|
|
2
|
+
import { hideFromCaptures } from "@domicile-desktop/sdk/portal";
|
|
3
|
+
import { PauseIcon } from "@phosphor-icons/react/dist/ssr/Pause";
|
|
4
|
+
import { PlayIcon } from "@phosphor-icons/react/dist/ssr/Play";
|
|
5
|
+
import { StopIcon } from "@phosphor-icons/react/dist/ssr/Stop";
|
|
6
|
+
import { TrashIcon } from "@phosphor-icons/react/dist/ssr/Trash";
|
|
7
|
+
import { useEffect, useRef, useState } from "react";
|
|
8
|
+
import { css } from "../../styled-system/css";
|
|
9
|
+
import { hstack } from "../../styled-system/patterns";
|
|
10
|
+
import { Button } from "../Button/Button";
|
|
11
|
+
import { useScreenRegion } from "../Screen/DisplayProvider";
|
|
12
|
+
|
|
13
|
+
/** The shell's own screen recording. */
|
|
14
|
+
export type Recording = Extract<Capturing, { kind: CapturingKind.Recording }>;
|
|
15
|
+
|
|
16
|
+
type Props = {
|
|
17
|
+
recording: Recording;
|
|
18
|
+
pause: () => void;
|
|
19
|
+
resume: () => void;
|
|
20
|
+
stop: () => void;
|
|
21
|
+
discard: () => void;
|
|
22
|
+
screen: string | undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A floating toolbar over the bottom of the screen while the shell records:
|
|
27
|
+
* how long it has recorded, and buttons that pause or resume, stop and
|
|
28
|
+
* discard it. It shows on screen and is left out of the recording, and of
|
|
29
|
+
* every other capture of the desk.
|
|
30
|
+
*/
|
|
31
|
+
export const RecordingToolbar = ({
|
|
32
|
+
discard,
|
|
33
|
+
pause,
|
|
34
|
+
recording,
|
|
35
|
+
resume,
|
|
36
|
+
screen,
|
|
37
|
+
stop,
|
|
38
|
+
}: Props) => {
|
|
39
|
+
const region = useScreenRegion(screen);
|
|
40
|
+
const toolbar = useRef<HTMLDivElement>(null);
|
|
41
|
+
const paused = recording.runningSinceMs === undefined;
|
|
42
|
+
const elapsed = useElapsed(recording);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (toolbar.current === null) {
|
|
46
|
+
throw new Error("the recording toolbar is not drawn");
|
|
47
|
+
}
|
|
48
|
+
hideFromCaptures(toolbar.current).catch((error: unknown) => {
|
|
49
|
+
// biome-ignore lint/suspicious/noConsole: the toolbar would show in the recording
|
|
50
|
+
console.error(
|
|
51
|
+
"Failed to hide the recording toolbar from captures",
|
|
52
|
+
error,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className={regionStyles} style={region}>
|
|
59
|
+
<div
|
|
60
|
+
aria-label="Screen recording"
|
|
61
|
+
className={toolbarStyles}
|
|
62
|
+
ref={toolbar}
|
|
63
|
+
role="toolbar"
|
|
64
|
+
>
|
|
65
|
+
<span
|
|
66
|
+
aria-hidden
|
|
67
|
+
className={css(dotStyles, paused && pausedDotStyles)}
|
|
68
|
+
/>
|
|
69
|
+
<span aria-label={paused ? "Paused at" : "Recorded"} role="timer">
|
|
70
|
+
{clock(elapsed)}
|
|
71
|
+
</span>
|
|
72
|
+
<Button
|
|
73
|
+
label={paused ? "Resume" : "Pause"}
|
|
74
|
+
onClick={paused ? resume : pause}
|
|
75
|
+
rounded
|
|
76
|
+
size="sm"
|
|
77
|
+
variant="ghost"
|
|
78
|
+
>
|
|
79
|
+
{paused ? <PlayIcon weight="fill" /> : <PauseIcon weight="fill" />}
|
|
80
|
+
</Button>
|
|
81
|
+
<Button
|
|
82
|
+
beforeIcon={<StopIcon weight="fill" />}
|
|
83
|
+
onClick={stop}
|
|
84
|
+
rounded
|
|
85
|
+
size="sm"
|
|
86
|
+
variant="danger"
|
|
87
|
+
>
|
|
88
|
+
Stop
|
|
89
|
+
</Button>
|
|
90
|
+
<Button
|
|
91
|
+
label="Discard"
|
|
92
|
+
onClick={discard}
|
|
93
|
+
rounded
|
|
94
|
+
size="sm"
|
|
95
|
+
variant="ghost"
|
|
96
|
+
>
|
|
97
|
+
<TrashIcon />
|
|
98
|
+
</Button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** How long `recording` has recorded, in milliseconds, kept up to date. */
|
|
105
|
+
const useElapsed = (recording: Recording): number => {
|
|
106
|
+
const [now, setNow] = useState(Date.now);
|
|
107
|
+
const { recordedMs, runningSinceMs } = recording;
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (runningSinceMs === undefined) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
setNow(Date.now());
|
|
113
|
+
const ticking = setInterval(() => {
|
|
114
|
+
setNow(Date.now());
|
|
115
|
+
}, TICK_MS);
|
|
116
|
+
return () => {
|
|
117
|
+
clearInterval(ticking);
|
|
118
|
+
};
|
|
119
|
+
}, [runningSinceMs]);
|
|
120
|
+
return runningSinceMs === undefined
|
|
121
|
+
? recordedMs
|
|
122
|
+
: recordedMs + Math.max(0, now - runningSinceMs);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Often enough that the seconds never lag by more than this. */
|
|
126
|
+
const TICK_MS = 250;
|
|
127
|
+
|
|
128
|
+
/** `ms` as minutes and seconds, or hours, minutes and seconds past an hour. */
|
|
129
|
+
export const clock = (ms: number): string => {
|
|
130
|
+
const seconds = Math.floor(ms / 1000);
|
|
131
|
+
const [hours, minutes] = [
|
|
132
|
+
Math.floor(seconds / 3600),
|
|
133
|
+
Math.floor(seconds / 60) % 60,
|
|
134
|
+
];
|
|
135
|
+
const ss = String(seconds % 60).padStart(2, "0");
|
|
136
|
+
return hours > 0
|
|
137
|
+
? `${String(hours)}:${String(minutes).padStart(2, "0")}:${ss}`
|
|
138
|
+
: `${String(minutes)}:${ss}`;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Spans the screen so the toolbar can sit at its foot; takes no clicks
|
|
142
|
+
// itself.
|
|
143
|
+
const regionStyles = css({
|
|
144
|
+
inset: 0,
|
|
145
|
+
pointerEvents: "none",
|
|
146
|
+
position: "fixed",
|
|
147
|
+
zIndex: "toast",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Its own stacking context and group, or Element Capture will not leave it
|
|
151
|
+
// out.
|
|
152
|
+
const toolbarStyles = hstack({
|
|
153
|
+
backgroundColor: "card",
|
|
154
|
+
border: "1px solid {colors.border}",
|
|
155
|
+
borderRadius: "full",
|
|
156
|
+
boxShadow: "{shadows.lifted}",
|
|
157
|
+
color: "foreground",
|
|
158
|
+
fontSize: "sm",
|
|
159
|
+
fontVariantNumeric: "tabular-nums",
|
|
160
|
+
gap: 2,
|
|
161
|
+
insetBlockEnd: 6,
|
|
162
|
+
insetInlineStart: "50%",
|
|
163
|
+
isolation: "isolate",
|
|
164
|
+
paddingBlock: 1,
|
|
165
|
+
paddingInline: 2,
|
|
166
|
+
paddingInlineStart: 4,
|
|
167
|
+
pointerEvents: "auto",
|
|
168
|
+
position: "absolute",
|
|
169
|
+
transform: "translateX(-50%)",
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const dotStyles = css.raw({
|
|
173
|
+
backgroundColor: "danger",
|
|
174
|
+
blockSize: 2.5,
|
|
175
|
+
borderRadius: "full",
|
|
176
|
+
flexShrink: 0,
|
|
177
|
+
inlineSize: 2.5,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const pausedDotStyles = css.raw({ backgroundColor: "muted" });
|
|
@@ -43,6 +43,11 @@ type Props = {
|
|
|
43
43
|
screen: string | undefined;
|
|
44
44
|
/** Draws a window that is not on screen. */
|
|
45
45
|
previewWindow: DrawPreview;
|
|
46
|
+
/**
|
|
47
|
+
* Whether the shell asks, for its own screen recording: the dialog then
|
|
48
|
+
* records rather than shares, and names nobody.
|
|
49
|
+
*/
|
|
50
|
+
record?: boolean | undefined;
|
|
46
51
|
/** The windows the shell draws on screen, read once as the dialog opens. */
|
|
47
52
|
shownWindows: () => readonly ShownWindow[];
|
|
48
53
|
};
|
|
@@ -58,7 +63,8 @@ type Offered = Choice & { source: CastSource };
|
|
|
58
63
|
* application asks for more, or, when it may have a region, an area dragged
|
|
59
64
|
* over the desk shot as the screenshot picker's is. Without a desk shot, the
|
|
60
65
|
* region is drawn over the page instead. Dismissing it cancels. For a cast to a
|
|
61
|
-
* TV, it is titled for the TV rather than for who asks.
|
|
66
|
+
* TV, it is titled for the TV rather than for who asks. For the shell's own
|
|
67
|
+
* screen recording, it offers to record instead.
|
|
62
68
|
*/
|
|
63
69
|
export const ScreenCastDialog = ({
|
|
64
70
|
answer,
|
|
@@ -68,6 +74,7 @@ export const ScreenCastDialog = ({
|
|
|
68
74
|
browserWindows,
|
|
69
75
|
castTo,
|
|
70
76
|
previewWindow,
|
|
77
|
+
record = false,
|
|
71
78
|
screen,
|
|
72
79
|
shownWindows,
|
|
73
80
|
}: Props) => {
|
|
@@ -194,7 +201,7 @@ export const ScreenCastDialog = ({
|
|
|
194
201
|
);
|
|
195
202
|
}}
|
|
196
203
|
>
|
|
197
|
-
{castTo
|
|
204
|
+
{action(castTo, record)}
|
|
198
205
|
</Button>
|
|
199
206
|
</>
|
|
200
207
|
}
|
|
@@ -207,20 +214,19 @@ export const ScreenCastDialog = ({
|
|
|
207
214
|
screen={screen}
|
|
208
215
|
size="xl"
|
|
209
216
|
title={
|
|
210
|
-
castTo
|
|
211
|
-
|
|
212
|
-
: `Cast to ${castTo}`
|
|
217
|
+
heading(castTo, record) ??
|
|
218
|
+
title(body.multiple, windows.length > 0, screens.length > 0)
|
|
213
219
|
}
|
|
214
220
|
>
|
|
215
|
-
{castTo === undefined && (
|
|
221
|
+
{castTo === undefined && !record && (
|
|
216
222
|
<p className={askerStyles}>{`${asker} wants to record`}</p>
|
|
217
223
|
)}
|
|
218
224
|
<SourcePicker
|
|
219
|
-
caption={caption(offered, picked, dragged, part, body.region)}
|
|
225
|
+
caption={caption(offered, picked, dragged, part, body.region, record)}
|
|
220
226
|
desk={desk}
|
|
221
227
|
dragged={dragged}
|
|
222
228
|
draggedPart={part?.area}
|
|
223
|
-
label="What to share"
|
|
229
|
+
label={record ? "What to record" : "What to share"}
|
|
224
230
|
multiple={body.multiple}
|
|
225
231
|
onDrag={
|
|
226
232
|
body.region
|
|
@@ -256,6 +262,25 @@ export const ScreenCastDialog = ({
|
|
|
256
262
|
|
|
257
263
|
const askerStyles = css({ color: "muted", fontSize: "sm", margin: 0 });
|
|
258
264
|
|
|
265
|
+
/** The confirm button's label: a cast to a TV, a recording or a share. */
|
|
266
|
+
const action = (castTo: string | undefined, record: boolean): string => {
|
|
267
|
+
if (castTo !== undefined) {
|
|
268
|
+
return "Cast";
|
|
269
|
+
}
|
|
270
|
+
return record ? "Record" : "Share";
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/** The title of a cast to a TV or a recording; others are titled by asker. */
|
|
274
|
+
const heading = (
|
|
275
|
+
castTo: string | undefined,
|
|
276
|
+
record: boolean,
|
|
277
|
+
): string | undefined => {
|
|
278
|
+
if (castTo !== undefined) {
|
|
279
|
+
return `Cast to ${castTo}`;
|
|
280
|
+
}
|
|
281
|
+
return record ? "Record your screen" : undefined;
|
|
282
|
+
};
|
|
283
|
+
|
|
259
284
|
/** The dialog's title for what it offers. */
|
|
260
285
|
const title = (
|
|
261
286
|
multiple: boolean,
|
|
@@ -286,6 +311,7 @@ const partOf = (source: CastSource, area: ShotRect): CastSource => {
|
|
|
286
311
|
/**
|
|
287
312
|
* What the desk says of the sources `picked` from `offered`, of the area
|
|
288
313
|
* `dragged` or of a window's `part`, and whether a `region` may be dragged.
|
|
314
|
+
* Said of a recording when `record` is set.
|
|
289
315
|
*/
|
|
290
316
|
const caption = (
|
|
291
317
|
offered: readonly Offered[],
|
|
@@ -293,18 +319,20 @@ const caption = (
|
|
|
293
319
|
dragged: ShotRect | undefined,
|
|
294
320
|
part: { index: number; area: ShotRect } | undefined,
|
|
295
321
|
region: boolean,
|
|
322
|
+
record: boolean,
|
|
296
323
|
): string => {
|
|
324
|
+
const [verb, done] = record ? ["record", "Recorded"] : ["share", "Shared"];
|
|
297
325
|
if (dragged !== undefined) {
|
|
298
|
-
return
|
|
326
|
+
return `${done} live, as it changes.`;
|
|
299
327
|
} else if (picked.length === 0) {
|
|
300
328
|
return region
|
|
301
|
-
?
|
|
302
|
-
:
|
|
329
|
+
? `Pick what to ${verb}, or drag over the desk for an area.`
|
|
330
|
+
: `Pick what to ${verb}.`;
|
|
303
331
|
} else if (part !== undefined) {
|
|
304
|
-
return
|
|
332
|
+
return `A part of the window, ${done.toLowerCase()} as it draws itself.`;
|
|
305
333
|
} else if (picked.some((index) => offered[index]?.area === undefined)) {
|
|
306
|
-
return
|
|
334
|
+
return `Not on screen: ${done.toLowerCase()} as it draws itself. Drag over it to pick a part.`;
|
|
307
335
|
} else {
|
|
308
|
-
return
|
|
336
|
+
return `${done} live, as it changes.`;
|
|
309
337
|
}
|
|
310
338
|
};
|