aboutsummaryrefslogtreecommitdiffstats
path: root/desktop
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-23 19:50:40 +0200
committerDanilo M. <danix@danix.xyz>2026-10-23 19:50:40 +0200
commit923c0563f0389c4011c99b93a3edcfb09f5f3309 (patch)
tree47a82512016b4d97ac48e81c74182d8d1defd0c3 /desktop
parent540aa76b6088025dfca284a731002cec6b6c607c (diff)
downloadquickshell-923c0563f0389c4011c99b93a3edcfb09f5f3309.tar.gz
quickshell-923c0563f0389c4011c99b93a3edcfb09f5f3309.zip
companion: sing when the Sony headphones connect
She now reacts to the headphones connecting with a short singing pose: eyes shut, blindfold gone, mouth wide open, headphones on, a few musical notes. The frames are new klein edits in frames.txt (dress/sing from dress/base, leotard/sing from dress/sing), so a post-self-destruct sing has its own art. Detection is the island's rule (StatusPill.qml): by model prefix, since BlueZ gives no class that says "headphones". The device's BlueZ Name is WH-CH720N, which the alias does not affect. The reaction is a one-shot hold in Mood.js (singHold, 4 s), placed after wave like the other event reactions. A 4 s startup grace keeps a shell start with the headphones already on from counting as a connection, since Bluetooth reads empty for about 2 s.
Diffstat (limited to 'desktop')
-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
6 files changed, 38 insertions, 2 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");