dial: hold the camera to record the screen
Entries may carry a hold verb beside verb; the ring fills while it charges so the commitment is visible while it is still cancellable, and sliding off cancels. Holding the camera starts a recording, tapping or holding it again stops it — the same seat, because the dial is muscle memory. No wf-recorder or wl-screenrec on the device, so it is grim piped into ffmpeg. Half scale: at 1080x2160 grim manages about 3.5 fps and a recording that captures a third of its claimed frames plays back at triple speed. Its own process group, and SIGINT to the group: only ffmpeg can finish the file, so killing the shell alone would orphan a screen capture with no way to stop it. Reachable as a verb too, not only by thumb.
This commit is contained in:
parent
ac35b3436b
commit
ae432e7654
2 changed files with 165 additions and 1 deletions
|
|
@ -70,6 +70,59 @@ Scope {
|
|||
}
|
||||
}
|
||||
|
||||
// ## Screen recording
|
||||
//
|
||||
// Hold the camera to start, tap or hold it again to stop. There is no
|
||||
// wf-recorder or wl-screenrec on this device, so the capture is `grim` in
|
||||
// a loop piped into ffmpeg — measured on blueline 2026-08-06 at 1080x2160,
|
||||
// 41 frames in 3.4s, about 12 fps. Coarse, and honest about it: the right
|
||||
// tool is a compositor-side encoder fed by the capture protocol viewtop
|
||||
// already serves, and this is what ships today with what is installed.
|
||||
//
|
||||
// **Its own process group, and SIGINT to the group.** The pipeline is two
|
||||
// processes and only ffmpeg can finish the file — it writes the mp4 index
|
||||
// on the way out, so a killed ffmpeg leaves an unplayable recording.
|
||||
// Interrupting the group stops the grim loop, ffmpeg sees its input end,
|
||||
// and it closes the file properly. Killing the shell alone would orphan
|
||||
// both halves and leave the phone quietly capturing the screen forever,
|
||||
// which is the failure worth engineering against here.
|
||||
property bool recording: false
|
||||
readonly property string recPid: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine-screenrec.pid"
|
||||
|
||||
function startRecording() {
|
||||
if (scope.recording)
|
||||
return;
|
||||
Haptics.confirm();
|
||||
scope.recording = true;
|
||||
// `setsid` run directly as argv, not through an outer `sh -c`. Wrapped,
|
||||
// the inner script needs a second level of shell quoting and the whole
|
||||
// pipeline silently produced no file while every state flag said it was
|
||||
// recording — the failure looked like a bug in the verb rather than in
|
||||
// the quoting. One shell, one level of quotes.
|
||||
Quickshell.execDetached(["setsid", "sh", "-c",
|
||||
'mkdir -p "$HOME/Videos"; echo $$ > "$1"; '
|
||||
// Half scale: `grim` at the full 1080x2160 managed only ~3.5 fps
|
||||
// on this device (measured 2026-08-06), and a recording that
|
||||
// captures a third of the frames it claims plays back at triple
|
||||
// speed. Halving the pixels roughly doubles the rate and the
|
||||
// framerate below is set to match what it actually achieves.
|
||||
+ 'while :; do grim -s 0.5 -t ppm - || break; done '
|
||||
+ '| ffmpeg -y -loglevel error -f image2pipe -framerate 8 -i - '
|
||||
+ '-c:v libx264 -preset ultrafast -pix_fmt yuv420p '
|
||||
+ '"$HOME/Videos/screenrec-$(date +%Y%m%d-%H%M%S).mp4"',
|
||||
"_", scope.recPid]);
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (!scope.recording)
|
||||
return;
|
||||
Haptics.confirm();
|
||||
scope.recording = false;
|
||||
Quickshell.execDetached(["sh", "-c",
|
||||
'G=$(cat "$1" 2>/dev/null); [ -n "$G" ] && kill -INT -- -"$G" 2>/dev/null; rm -f "$1"',
|
||||
"_", scope.recPid]);
|
||||
}
|
||||
|
||||
// The list. `enabled: false` carries a `reason` so a refusal explains
|
||||
// itself in the middle of the ring rather than greying out silently.
|
||||
readonly property var entries: [
|
||||
|
|
@ -80,11 +133,26 @@ Scope {
|
|||
},
|
||||
{
|
||||
icon: "photo_camera",
|
||||
label: "Screenshot",
|
||||
label: scope.recording ? "Stop recording" : "Screenshot",
|
||||
verb: () => {
|
||||
// While recording, the same seat is the way out. A separate
|
||||
// stop entry would mean the ring changes length mid-session,
|
||||
// and the dial is muscle memory — the thumb goes where the
|
||||
// camera was, so that is where stopping lives.
|
||||
if (scope.recording) {
|
||||
scope.stopRecording();
|
||||
return;
|
||||
}
|
||||
Haptics.confirm();
|
||||
Quickshell.execDetached(["sh", "-c",
|
||||
"grim ~/Pictures/screenshot-$(date +%Y%m%d-%H%M%S).png"]);
|
||||
},
|
||||
hold: () => {
|
||||
if (scope.recording) {
|
||||
scope.stopRecording();
|
||||
return;
|
||||
}
|
||||
scope.startRecording();
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -137,6 +205,22 @@ Scope {
|
|||
scope.dialOpen = !scope.dialOpen;
|
||||
}
|
||||
|
||||
// Recording, reachable without the ring. The hold gesture is the
|
||||
// hand's way in; this is hers, and doctrine §13 is explicit that a
|
||||
// verb only the thumb can reach is a defect. It is also the only way
|
||||
// to test the pipeline without a finger on the glass.
|
||||
function record(): void {
|
||||
scope.startRecording();
|
||||
}
|
||||
|
||||
function stopRecord(): void {
|
||||
scope.stopRecording();
|
||||
}
|
||||
|
||||
function recording(): string {
|
||||
return scope.recording ? "recording" : "idle";
|
||||
}
|
||||
|
||||
// What the ring currently offers, so a caller can see the entries
|
||||
// without opening it. JSON-over-string: quickshell marshals a `var`
|
||||
// return as VOID.
|
||||
|
|
|
|||
|
|
@ -145,6 +145,22 @@ Item {
|
|||
scale: seat.isSelected ? 1.22 : 1.0
|
||||
Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } }
|
||||
|
||||
// The charge. Drawn under the seat and growing past it, so a hold
|
||||
// reads as the entry swelling toward committing rather than as a
|
||||
// separate widget appearing next to it.
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
visible: seat.isSelected && root.holdProgress > 0
|
||||
&& typeof seat.modelData.hold === "function"
|
||||
width: parent.width * (1 + 0.45 * root.holdProgress)
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: "transparent"
|
||||
border.width: 3
|
||||
border.color: Appearance.colors.colPrimary
|
||||
opacity: 0.35 + 0.65 * root.holdProgress
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: width / 2
|
||||
|
|
@ -191,6 +207,58 @@ Item {
|
|||
text: root.entries[root.selected]?.reason ?? ""
|
||||
}
|
||||
|
||||
// ## Holding an entry, for the second verb it carries
|
||||
//
|
||||
// An entry may declare `hold` beside `verb`: tap does one thing, holding
|
||||
// does the heavier one. Screenshot/record is the case that asked for it —
|
||||
// Casey, 2026-08-06: "if I hold on the screenshot button... it'll start
|
||||
// recording a video until I hit the stop button on the radial dial."
|
||||
//
|
||||
// The hold has to *show*, or it is indistinguishable from a tap that has
|
||||
// not been let go of yet, and the user releases before it fires. The ring
|
||||
// below fills as it charges, so the commitment is visible while it is
|
||||
// still cancellable.
|
||||
//
|
||||
// Sliding to another entry cancels: the thumb is already the dial's
|
||||
// selection mechanism, so a hold that survived moving off its entry would
|
||||
// fire the wrong verb — and this ring holds Lock and Kill window.
|
||||
property real holdProgress: 0
|
||||
property bool holdFired: false
|
||||
readonly property int holdMs: 550
|
||||
|
||||
Timer {
|
||||
id: holdCharge
|
||||
interval: 16
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
root.holdProgress += interval / root.holdMs;
|
||||
if (root.holdProgress < 1)
|
||||
return;
|
||||
stop();
|
||||
root.holdProgress = 0;
|
||||
root.holdFired = true;
|
||||
const entry = root.entries[root.selected];
|
||||
if (!entry || entry.enabled === false || typeof entry.hold !== "function")
|
||||
return;
|
||||
// Closes on firing, like a release would: the verb has been
|
||||
// committed, and leaving the ring up over a recording that has
|
||||
// already started reads as "it did not take".
|
||||
root.close();
|
||||
root.invoked(entry);
|
||||
entry.hold();
|
||||
}
|
||||
}
|
||||
|
||||
function beginHold() {
|
||||
root.holdProgress = 0;
|
||||
root.holdFired = false;
|
||||
const entry = root.entries[root.selected];
|
||||
if (entry && entry.enabled !== false && typeof entry.hold === "function")
|
||||
holdCharge.restart();
|
||||
else
|
||||
holdCharge.stop();
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.open
|
||||
|
|
@ -198,12 +266,24 @@ Item {
|
|||
preventStealing: true
|
||||
|
||||
onPositionChanged: mouse => {
|
||||
const before = root.selected;
|
||||
root.selected = root.entryAt(mouse.x, mouse.y);
|
||||
if (root.selected !== before)
|
||||
root.beginHold();
|
||||
}
|
||||
onPressed: mouse => {
|
||||
root.selected = root.entryAt(mouse.x, mouse.y);
|
||||
root.beginHold();
|
||||
}
|
||||
onReleased: mouse => {
|
||||
holdCharge.stop();
|
||||
root.holdProgress = 0;
|
||||
// The hold already ran and already closed the ring. Firing the tap
|
||||
// verb now would take a screenshot every time a recording starts.
|
||||
if (root.holdFired) {
|
||||
root.holdFired = false;
|
||||
return;
|
||||
}
|
||||
const i = root.entryAt(mouse.x, mouse.y);
|
||||
root.close();
|
||||
if (i < 0 || i >= root.entries.length)
|
||||
|
|
|
|||
Loading…
Reference in a new issue