aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/companion/Companion.qml
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/companion/Companion.qml')
-rw-r--r--desktop/companion/Companion.qml166
1 files changed, 147 insertions, 19 deletions
diff --git a/desktop/companion/Companion.qml b/desktop/companion/Companion.qml
index 3102c59..dfdc474 100644
--- a/desktop/companion/Companion.qml
+++ b/desktop/companion/Companion.qml
@@ -12,9 +12,11 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
+import Quickshell.Bluetooth
import Quickshell.Hyprland
import QtQuick
import ".."
+import "../modules/sound"
import "../NotifyConfig.js" as NC
import "Mood.js" as Mood
import "Lines.js" as Lines
@@ -33,30 +35,72 @@ PanelWindow {
// SUPER+\ (the IPC below) and the Companion settings page, through Prefs,
// so it survives a restart.
readonly property bool present: Prefs.ready && Prefs.companion
+ // A hide plays a goodbye first: the wave frames, then the drop.
+ // Re-showing her cancels it and greets as usual.
+ property bool leaving: false
+ property int waveIndex: 0
+ readonly property var waveCycle: ["wave", "wave-l", "wave", "wave-r"]
onPresentChanged: {
if (root.present) {
+ root.leaving = false;
root.destructAt = 0; // the skirt is back
root.now = Date.now();
greet.restart();
} else {
root.stopMonologue();
+ // Nothing to wave at when she is already out of view.
+ if (root.fullscreen !== 2 && !root.hopping) {
+ root.leaving = true;
+ root.waveIndex = 0;
+ root.now = Date.now();
+ // A line one hide in four, like the monologue's roll, and not
+ // forced, so the usual 30 s line cooldown still applies. A
+ // silent wave is only as long as it takes to read nothing.
+ const spoke = Math.random() < 0.25 && root.say("bye");
+ goodbyeTimer.interval = spoke ? Mood.T.goodbye : Mood.T.goodbyeSilent;
+ goodbyeTimer.start();
+ }
}
}
- // The monitor she is on trails the focused one: a change plays her exit
- // here first, and only then moves the window (settled, below).
+ // Where she goes: the focused monitor's corner, or seated on the
+ // assistant's tall card. here and sitting are where the window is now
+ // and trail the goal: a change plays her exit first, and only then
+ // moves the window (settled, below).
readonly property string focused: Hyprland.focusedMonitor?.name ?? ""
+ readonly property bool seated: root.asst.open && root.asst.tall
+ readonly property string goal: root.seated ? root.asst.monitor : root.focused
property string here: ""
+ property bool sitting: false
property bool hopping: false
- onFocusedChanged: {
- if (root.here === "") root.here = root.focused;
- else if (root.focused !== root.here) root.hopping = true;
+ // No focused monitor (outputs gone over a suspend) is not a destination.
+ // Already out of view, there is no exit to wait for: settled would never
+ // fire, hopping would stick and hide her for good, so move at once.
+ // Mid-hop, settled takes the latest goal; moving now would jump the
+ // window before the exit has played (hopping counts as away).
+ function move() {
+ if (root.hopping || root.goal === "" || (root.goal === root.here && root.seated === root.sitting)) return;
+ if (root.here === "" || root.away) {
+ root.here = root.goal;
+ root.sitting = root.seated;
+ } else {
+ root.hopping = true;
+ }
}
- Component.onCompleted: root.here = root.focused
+ onGoalChanged: root.move()
+ onSeatedChanged: root.move()
+ Component.onCompleted: root.move()
screen: Quickshell.screens.find(s => s.name === root.here) ?? Quickshell.screens[0]
anchors { right: true; bottom: true }
- margins { right: 24; bottom: 16 }
+ // Seated, her right edge is 90 px right of the screen's centre, which
+ // puts her in the gap in the card's header, and her feet 35 px below the
+ // card's top. The right edge rather than her centre, because frames
+ // differ in width and the window would shift with every frame.
+ margins {
+ right: root.sitting ? Math.round(root.asst.screenObj.width / 2 - 90) : 24
+ bottom: root.sitting ? root.asst.screenObj.height - root.asst.cardTop - 35 : 16
+ }
implicitWidth: 760
implicitHeight: 260
color: "transparent"
@@ -83,6 +127,9 @@ PanelWindow {
property double pulseAt: 0
property double wakeAt: 0
property double admireAt: 0
+ property double singAt: 0
+ property double djAt: 0
+ property string groove: ""
property double lastLineAt: 0
property double lastMonologueAt: 0
property bool monologue: false
@@ -122,7 +169,9 @@ PanelWindow {
root.wakeAt = t;
root.say("wake");
} else if (root.idle === 1 && root.wasIdle === 0) {
- if (Mood.monologue("bored", root.lastMonologueAt, t, Math.random())) root.startMonologue();
+ root.pickGroove();
+ if (Player.playing) root.say("groove");
+ else if (Mood.monologue("bored", root.lastMonologueAt, t, Math.random())) root.startMonologue();
else root.say("bored");
}
root.wasIdle = root.idle;
@@ -188,6 +237,46 @@ 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");
+ }
+
+ // What is playing, from the sound module's player (it already drops the
+ // playerctld and browser-integration duplicates). A new artist+title
+ // while playing is a track start or change: she DJs for Mood.T.djHold
+ // and says the artist's line if she knows them. Paused reads "", and
+ // resuming the same track matches lastTrack, so it does not DJ again.
+ readonly property string track: Player.playing ? Player.artist + "\n" + Player.title : ""
+ property string lastTrack: ""
+ onTrackChanged: {
+ if (root.track === "" || root.track === root.lastTrack) return;
+ root.lastTrack = root.track;
+ root.djAt = Date.now();
+ root.now = root.djAt;
+ root.say("artist:" + Player.artist);
+ }
+ // Music starting while she is already bored needs a groove too.
+ readonly property bool music: Player.playing
+ onMusicChanged: if (root.music && root.idle === 1) root.pickGroove()
+ function pickGroove() {
+ const g = ["whistle", "headbang", "airguitar"];
+ root.groove = g[Math.floor(Math.random() * g.length)];
+ }
+
// 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,
@@ -199,7 +288,7 @@ PanelWindow {
interval: 100
onTriggered: {
const t = Date.now();
- if (root.away || root.here !== root.focused || !Mood.admireAllowed(root.admireAt, t)) return;
+ if (root.away || root.sitting || root.here !== root.focused || !Mood.admireAllowed(root.admireAt, t)) return;
if ((Hyprland.focusedWorkspace?.toplevels.values.length ?? 1) !== 0) return;
if (!Admired.listed(Admired.on(root.here))) return;
root.admireAt = t;
@@ -222,8 +311,10 @@ PanelWindow {
Timer { id: remap; interval: 400; onTriggered: root.remapping = false }
// Balloons stack bottom-right of their screen. When that is her corner,
- // any balloon on screen shoves her left past the stack.
+ // any balloon on screen shoves her left past the stack. Seated on the
+ // assistant's card she is nowhere near them.
readonly property bool shoved: {
+ if (root.sitting) return false;
const place = Notify.config.balloons;
if (place.screen !== root.here || place.corner !== "bottom-right") return false;
return (Notify.queue || []).some(n => NC.shown(Notify.config, n, Notify.dnd,
@@ -238,10 +329,12 @@ PanelWindow {
// --- state ----------------------------------------------------------
readonly property var mood: Mood.pick({
+ leaving: root.leaving,
now: root.now, destructAt: root.destructAt, dodging: root.dodging, shoved: root.shoved,
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,
+ singAt: root.singAt, djAt: root.djAt, music: root.music, groove: root.groove,
monologue: root.monologue, idle: root.idle,
})
readonly property string outfit: Mood.outfit(root.destructAt, root.now, Prefs.companionSkirtless * 1000)
@@ -249,19 +342,21 @@ PanelWindow {
onMoodChanged: if (root.monologue && root.mood.state !== "muse") root.stopMonologue()
// Hidden: no sprite, no bubble. Away adds the dodge, which hides the
- // sprite but lets its line show.
- readonly property bool hidden: !root.present || root.fullscreen === 2 || root.hopping
+ // sprite but lets its line show. While waving goodbye she is still on
+ // screen, so a hidden present does not hide her until the wave is done.
+ readonly property bool hidden: (!root.present && !root.leaving) || root.fullscreen === 2 || root.hopping
readonly property bool away: root.hidden || root.dodging || root.remapping
// --- speech ---------------------------------------------------------
function say(kind, force) {
const t = Date.now();
- if (root.hidden || (!force && !Mood.canSpeak(root.lastLineAt, t))) return;
- const line = Lines.pick(kind, Math.random());
- if (line === "") return;
+ if (root.hidden || (!force && !Mood.canSpeak(root.lastLineAt, t))) return false;
+ const line = Lines.pick(kind, Math.random(), Prefs.companionApps, Prefs.companionArtists);
+ if (line === "") return false;
root.lastLineAt = t;
bubble.show(line, 3000);
+ return true;
}
// The opening monologue: about 15 characters a second, one sentence
@@ -353,18 +448,51 @@ PanelWindow {
}
}
+ // The goodbye: she waves, then leaves. With a line she holds long enough
+ // to read it. The hand sways side to side while it lasts.
+ Timer { id: goodbyeTimer; onTriggered: root.leaving = false }
+ Timer {
+ id: waveFlip
+ interval: 250
+ repeat: true
+ running: root.leaving
+ onTriggered: root.waveIndex = (root.waveIndex + 1) % root.waveCycle.length
+ }
+
+ // Frames Mood names that are really a loop: the frame names its cycle
+ // and how fast it turns. Everything else shows as named.
+ readonly property var cycles: ({
+ dj: { frames: ["dj", "dj-scratch"], ms: 300 },
+ headbang: { frames: ["headbang-up", "headbang-down"], ms: 220 },
+ })
+ property int cycleIndex: 0
+ readonly property var cycle: root.cycles[root.mood.frame] ?? null
+ onCycleChanged: root.cycleIndex = 0
+ Timer {
+ interval: root.cycle?.ms ?? 1000
+ repeat: true
+ running: root.cycle !== null && !root.away
+ onTriggered: root.cycleIndex = (root.cycleIndex + 1) % root.cycle.frames.length
+ }
+ readonly property string standing: root.leaving ? root.waveCycle[root.waveIndex]
+ : root.cycle ? root.cycle.frames[root.cycleIndex % root.cycle.frames.length] : root.mood.frame
+ // Seated, the sitting version of it (Mood.seatFrame).
+ readonly property string frame: root.sitting ? Mood.seatFrame(root.standing) : root.standing
+
// qs -p <desktop> ipc call companion toggle -> SUPER+\
IpcHandler {
target: "companion"
function toggle() { Prefs.setCompanion(!Prefs.companion); }
- function show() { Prefs.setCompanion(true); }
+ function open() { Prefs.setCompanion(true); }
function hide() { Prefs.setCompanion(false); }
}
- // The exit has played on the old monitor: move, and the entrance plays.
+ // The exit has played: move, and the entrance plays. The goal may have
+ // changed during the exit; the latest one wins.
function settled(out) {
if (out && root.hopping) {
- root.here = root.focused;
+ root.here = root.goal;
+ root.sitting = root.seated;
root.hopping = false;
}
}
@@ -388,7 +516,7 @@ PanelWindow {
id: sprite
x: seat.x
y: seat.y
- source: `file://${root.art}/${root.outfit}/${root.mood.frame}.png`
+ source: `file://${root.art}/${root.outfit}/${root.frame}.png`
fallback: `file://${root.art}/${root.outfit}/base.png`
out: root.away
trembling: root.mood.state === "admire" && root.mood.frame === "blush3"