@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-e455955b57c7",
6
- "@domicile-desktop/system-apps": "0.0.0-alpha-e455955b57c7",
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-e455955b57c7"
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
- "answer" | "capturing" | "on" | "requests" | "stopCapturing"
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. Requests of a kind it has no dialog for are refused.
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
- omitScreenCasts
149
- ? sessions.filter(
150
- (session) => session.kind !== CapturingKind.ScreenCast,
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 === undefined ? "Share" : "Cast"}
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 === undefined
211
- ? title(body.multiple, windows.length > 0, screens.length > 0)
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 "Shared live, as it changes.";
326
+ return `${done} live, as it changes.`;
299
327
  } else if (picked.length === 0) {
300
328
  return region
301
- ? "Pick what to share, or drag over the desk for an area."
302
- : "Pick what to share.";
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 "A part of the window, shared as it draws itself.";
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 "Not on screen: shared as it draws itself. Drag over it to pick a part.";
334
+ return `Not on screen: ${done.toLowerCase()} as it draws itself. Drag over it to pick a part.`;
307
335
  } else {
308
- return "Shared live, as it changes.";
336
+ return `${done} live, as it changes.`;
309
337
  }
310
338
  };