diff options
Diffstat (limited to 'desktop/companion/Companion.qml')
| -rw-r--r-- | desktop/companion/Companion.qml | 166 |
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" |
