aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--desktop/companion/Companion.qml22
-rw-r--r--desktop/companion/Lines.js1
-rw-r--r--desktop/companion/Mood.js2
-rw-r--r--desktop/companion/README.md5
-rw-r--r--desktop/companion/frames.txt6
-rw-r--r--desktop/companion/test_mood.js4
-rw-r--r--docs/superpowers/specs/2026-10-23-companion-design.md13
7 files changed, 45 insertions, 8 deletions
diff --git a/desktop/companion/Companion.qml b/desktop/companion/Companion.qml
index 6eb7370..f8d50e9 100644
--- a/desktop/companion/Companion.qml
+++ b/desktop/companion/Companion.qml
@@ -12,6 +12,7 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
+import Quickshell.Bluetooth
import Quickshell.Hyprland
import QtQuick
import ".."
@@ -105,6 +106,7 @@ PanelWindow {
property double pulseAt: 0
property double wakeAt: 0
property double admireAt: 0
+ property double singAt: 0
property double lastLineAt: 0
property double lastMonologueAt: 0
property bool monologue: false
@@ -210,6 +212,24 @@ PanelWindow {
}
}
+ // Sony headphones connecting, by the island's rule (island/StatusPill.qml):
+ // recognised by their model prefix, since BlueZ gives no class that says
+ // "headphones". A connection makes her sing for Mood.T.singHold. Bluetooth
+ // reads empty for about 2 s after launch, so a shell start with them
+ // already on is not a connection: btReady waits past that.
+ readonly property bool phonesConnected: (Bluetooth.devices?.values ?? [])
+ .some(d => d.connected && /^(WH|WF)-|^LinkBuds/.test(d.deviceName))
+ property bool btReady: false
+ // shortcut: a genuine connection in the first 4 s after a shell start is
+ // missed along with the startup populate; tighten if that bites.
+ Timer { interval: 4000; running: true; onTriggered: root.btReady = true }
+ onPhonesConnectedChanged: {
+ if (!root.phonesConnected || !root.btReady) return;
+ root.singAt = Date.now();
+ root.now = root.singAt;
+ root.say("headphones");
+ }
+
// A listed wallpaper on an empty workspace: she turns to it and blushes.
// Checked after the events above (switching to an empty workspace,
// closing its last window) and whenever she stops being away: shown,
@@ -265,7 +285,7 @@ PanelWindow {
shovedAt: root.shovedAt,
recording: root.asst.recording, busy: root.asst.busy, repliedAt: root.repliedAt,
clicks: root.clicks, admireAt: root.admireAt, mockAt: root.mockAt, typingAt: root.typingAt, wakeAt: root.wakeAt,
- monologue: root.monologue, idle: root.idle,
+ singAt: root.singAt, monologue: root.monologue, idle: root.idle,
})
readonly property string outfit: Mood.outfit(root.destructAt, root.now, Prefs.companionSkirtless * 1000)
// Anything above the monologue cuts it short.
diff --git a/desktop/companion/Lines.js b/desktop/companion/Lines.js
index 6609315..5e1d2db 100644
--- a/desktop/companion/Lines.js
+++ b/desktop/companion/Lines.js
@@ -23,6 +23,7 @@ var lines = {
mock: ["Typo again?", "Delete, delete, delete.", "Were you aiming for that key?"],
shoved: ["Hey.", "Rude.", "I was standing here."],
ready: ["Answer's ready.", "Your reply came in."],
+ headphones: ["...Good song.", "Music. Finally.", "I'll allow this one.", "Louder."],
click: ["...Yes?", "Can I help you?"],
blush: ["S-stop that.", "...Operator."],
wallpaper: ["...!", "W-what is this?", "I wasn't staring."],
diff --git a/desktop/companion/Mood.js b/desktop/companion/Mood.js
index e7aaea1..2f66873 100644
--- a/desktop/companion/Mood.js
+++ b/desktop/companion/Mood.js
@@ -23,6 +23,7 @@ var T = {
mockCooldown: 20000,
typingHold: 2000,
waveHold: 3000,
+ singHold: 4000, // her singing reaction to the headphones connecting
wakeHold: 1500, // the stretch after idling
lineCooldown: 30000,
monologueCooldown: 3600000,
@@ -67,6 +68,7 @@ function pick(s) {
if (s.recording) return st("listen", "listen");
if (s.busy) return st("think", "think");
if (s.now - s.repliedAt < T.waveHold) return st("wave", "wave");
+ if (s.now - s.singAt < T.singHold) return st("sing", "sing");
if (s.clicks.count > 0 && s.now - s.clicks.at < T.clickHold)
return st("clicks", clickFrame(s.clicks.count));
if (s.now - s.admireAt < T.admireLook + T.admireBlush)
diff --git a/desktop/companion/README.md b/desktop/companion/README.md
index a1ec12d..37aab16 100644
--- a/desktop/companion/README.md
+++ b/desktop/companion/README.md
@@ -72,6 +72,11 @@ frame falls back to its outfit's `base`.
minutes. Checked on `workspacev2` and `closewindow`, and whenever she
stops being away. Her monitor's wallpaper comes from wallp's
`wallp.conf` outputs and `wall_h`/`wall_v`.
+- Sony headphones connecting are the island's rule (`island/StatusPill.qml`:
+ `WH-`/`WF-`/`LinkBuds` by name, since BlueZ has no headphones class): she
+ sings once for `Mood.T.singHold`, 4 s, and says a line if the usual 30 s
+ cooldown allows it. A shell start with them already on is not a connection:
+ Bluetooth reads empty for ~2 s, then populates.
- She only feels the pointer while it is over her: Quickshell has no
global pointer and Hyprland sends no motion events. Hence the 0.6 s
dodge rather than moving away as the pointer approaches.
diff --git a/desktop/companion/frames.txt b/desktop/companion/frames.txt
index a3e97db..64a57fb 100644
--- a/desktop/companion/frames.txt
+++ b/desktop/companion/frames.txt
@@ -34,6 +34,9 @@ EYES = Her black blindfold stays on as one solid band of cloth fully covering bo
# After the self-destruct the blindfold is gone with the skirt: the whole
# leotard set shows her eyes.
LEOTARD = Her skirt is gone, revealing a plain white leotard underneath, and her blindfold is gone too: both eyes uncovered and clearly visible, pale blue-grey, matching her expression. Everything else stays exactly the same: pose, expression, black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips, the small mole under her mouth, chibi proportions, art style and white background.
+# Singing along to whatever is playing: headphones on, eyes shut. The
+# blindfold is gone, but BARE does not fit because it says the eyes show.
+SING = Her black blindfold is gone: nothing over her eyes, no blindfold, no mask; her black hairband stays. Both her eyes are closed, softly shut. Her mouth is open wide as if she is singing loudly. She wears a pair of black over-ear headphones over her head and ears. A couple of small black musical notes float in the air beside her head.
# --- expressions
dress/smile <- dress/base : Change only her expression to a small warm closed-mouth smile; ${POSE} ${EYES} ${KEEP}
@@ -71,6 +74,8 @@ dress/stretch <- dress/base : She stretches both arms up above her head, waking
# and face were right: refs/shoved-7.png, made from dress/base with
# --seed 7 by the earlier standing prompt (see generated.log), kept in refs/.
dress/shoved <- refs/shoved-7 : Keep her face, her scared expression, her uncovered eyes and both raised hands exactly as they are. Change her body and legs: she is in the air, lifted completely off the ground, knocked flying toward the left side of the image by a strong blast from the right; both feet off the ground, legs trailing behind her toward the right side, her body tilted, her hair and skirt blown toward the left. No ground, no blast effect drawn. ${KEEP_BARE}
+# Headphones connecting: a few seconds of her singing along.
+dress/sing <- dress/base : ${SING} ${POSE} ${KEEP_BARE}
# --- the leotard set: the same frames, after the self-destruct
leotard/base <- dress/base : ${LEOTARD}
@@ -102,4 +107,5 @@ leotard/think <- dress/think : ${LEOTARD}
leotard/muse <- dress/muse : ${LEOTARD}
leotard/stretch <- dress/stretch : ${LEOTARD}
leotard/shoved <- dress/shoved : ${LEOTARD}
+leotard/sing <- dress/sing : Her skirt is gone, revealing a plain white leotard underneath. Change nothing else: she keeps her closed eyes, her black over-ear headphones, the musical notes beside her head, her pose, black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips and the small mole under her mouth. Same character, chibi proportions, art style and white background.
leotard/scorched <- leotard/base : Right after an explosion: her clothes lightly scorched and smudged with soot, a few wisps of smoke rising from her, hair slightly messy, a dazed flat expression; standing, arms at her sides. Her eyes stay uncovered, no blindfold. Her top stays black: the black bodice with its chest cutout and the puffy black juliet sleeves, exactly as in the image; the plain white leotard shows only below her waist, where the skirt was. She keeps her black gloves, black thighhigh boots, black hairband, lips and the small mole under her mouth. Same character, chibi proportions, art style and white background.
diff --git a/desktop/companion/test_mood.js b/desktop/companion/test_mood.js
index 105648c..1331f70 100644
--- a/desktop/companion/test_mood.js
+++ b/desktop/companion/test_mood.js
@@ -28,7 +28,7 @@ const NOW = 10000000;
// A snapshot with nothing going on, overridden per case.
const calm = over => Object.assign({
leaving: false, now: NOW, destructAt: 0, dodging: false, shoved: false, shovedAt: 0, recording: false, busy: false,
- repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0, admireAt: 0,
+ repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0, admireAt: 0, singAt: 0,
monologue: false, idle: 0,
}, over);
const state = over => M.pick(calm(over)).state;
@@ -48,6 +48,7 @@ const ladder = [
["listen", { recording: true }],
["think", { busy: true }],
["wave", { repliedAt: NOW - 10 }],
+ ["sing", { singAt: NOW - 10 }],
["clicks", { clicks: { count: 2, at: NOW - 10 } }],
["admire", { admireAt: NOW - 10 }],
["mock", { mockAt: NOW - 10 }],
@@ -79,6 +80,7 @@ assert.deepStrictEqual(M.pick(calm({ idle: 3 })), { state: "lying", frame: "lyin
// Holds end on time.
assert.strictEqual(state({ repliedAt: NOW - M.T.waveHold }), "base");
+assert.strictEqual(state({ singAt: NOW - M.T.singHold }), "base");
assert.strictEqual(state({ typingAt: NOW - M.T.typingHold }), "base");
assert.strictEqual(state({ wakeAt: NOW - M.T.wakeHold }), "base");
assert.strictEqual(state({ clicks: { count: 3, at: NOW - M.T.clickHold } }), "base");
diff --git a/docs/superpowers/specs/2026-10-23-companion-design.md b/docs/superpowers/specs/2026-10-23-companion-design.md
index 03feaff..8a8c426 100644
--- a/docs/superpowers/specs/2026-10-23-companion-design.md
+++ b/docs/superpowers/specs/2026-10-23-companion-design.md
@@ -111,7 +111,7 @@ base, so every frame descends from one reference.
| --- | --- |
| klein edit of `base-sitting.png` | `base`: "make her stand upright, facing the viewer, arms relaxed at her sides" |
| img2img from `base`, strength 0.75 | `smile`, `mad`, `smirk`, `blush1`, `blush2`, `blush3`, `sleepy` |
-| klein edit of `base` | `wave`, `watch`, `point`, `yawn`, `lying`, `listen`, `think`, `muse`, `stretch`, `shoved` |
+| klein edit of `base` | `wave`, `watch`, `point`, `yawn`, `lying`, `listen`, `think`, `muse`, `stretch`, `shoved`, `sing` |
| klein edit of the dress frame | every `leotard/<name>`: "her skirt is gone, revealing a plain white leotard; change nothing else" |
| klein edit of `leotard/base` | `scorched`, leotard only: the moment after the explosion |
@@ -137,11 +137,12 @@ timers.
| 2 | Dodge | pointer rests on her 0.6 s with no click | slides out of the corner | returns after 5 s |
| 3 | Shoved | a balloon is on screen on her monitor | `shoved`, slid left by the stack's width | balloons gone |
| 4 | Assistant | recording / working / reply arrived | `listen` / `think` / `wave` | state ends; wave lasts 3 s |
-| 5 | Clicks | clicks 1-6 of a burst; a burst ends after 1.5 s without one | `smile`, `blush1` to `blush3` | 2 s after the last click |
-| 6 | Mock | BackSpace, at most once in 20 s | `smirk`, `point` | 2.5 s |
-| 7 | Watching | typing pulse in the last 2 s | `watch` | 2 s after typing stops |
-| 8 | Idle | no input for 1 / 3 / 5 min | `sleepy` / `yawn` / `lying` | any input: `stretch`, then base |
-| 9 | Base | nothing else | `base` | |
+| 5 | Singing | Sony headphones connected | `sing` | 4 s |
+| 6 | Clicks | clicks 1-6 of a burst; a burst ends after 1.5 s without one | `smile`, `blush1` to `blush3` | 2 s after the last click |
+| 7 | Mock | BackSpace, at most once in 20 s | `smirk`, `point` | 2.5 s |
+| 8 | Watching | typing pulse in the last 2 s | `watch` | 2 s after typing stops |
+| 9 | Idle | no input for 1 / 3 / 5 min | `sleepy` / `yawn` / `lying` | any input: `stretch`, then base |
+| 10 | Base | nothing else | `base` | |
- **Self-destruct, as in the game.** She glows, says "Self-destruct
sequence initiated.", a white flash covers the sprite for about a