diff --git a/surfaces/quickshell/deploy.sh b/surfaces/quickshell/deploy.sh index 365fcf6..ee2f8b9 100755 --- a/surfaces/quickshell/deploy.sh +++ b/surfaces/quickshell/deploy.sh @@ -62,6 +62,7 @@ GlobalStates.qml souveraine/GlobalStates.qml SettingsWindow.qml souveraine/SettingsWindow.qml panelFamilies/SouveraineFamily.qml souveraine/panelFamilies/SouveraineFamily.qml services/Souveraine.qml souveraine/services/Souveraine.qml +services/AccessoryPresentation.qml souveraine/services/AccessoryPresentation.qml services/Ai.qml souveraine/services/Ai.qml services/Cellular.qml souveraine/services/Cellular.qml services/ChargeRate.qml souveraine/services/ChargeRate.qml @@ -85,6 +86,8 @@ modules/souveraine/windowSheet/SheetButton.qml souveraine/modules/souveraine/win modules/souveraine/windowSheet/PowerMenu.qml souveraine/modules/souveraine/windowSheet/PowerMenu.qml modules/souveraine/windowSheet/PowerOptionRow.qml souveraine/modules/souveraine/windowSheet/PowerOptionRow.qml modules/souveraine/windowSheet/qmldir souveraine/modules/souveraine/windowSheet/qmldir +modules/souveraine/airpods/AirPodsSurface.qml souveraine/modules/souveraine/airpods/AirPodsSurface.qml +modules/souveraine/airpods/qmldir souveraine/modules/souveraine/airpods/qmldir services/Face.qml souveraine/services/Face.qml services/Lens.qml souveraine/services/Lens.qml services/FingerprintPreview.qml souveraine/services/FingerprintPreview.qml diff --git a/surfaces/quickshell/modules/souveraine/airpods/AirPodsSurface.qml b/surfaces/quickshell/modules/souveraine/airpods/AirPodsSurface.qml new file mode 100644 index 0000000..90f72ce --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/airpods/AirPodsSurface.qml @@ -0,0 +1,243 @@ +// The admitted AirPods opening card. +// +// It is a projection, not an event source. sessiond supplies the admitted +// presentation through AccessoryPresentation; this surface never wakes the +// display, asks for input, or renders Personal-class fields while locked. +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import qs +import qs.services +import qs.modules.common + +Scope { + id: scope + + property int dismissedGeneration: 0 + readonly property bool canShow: AccessoryPresentation.generation > dismissedGeneration + && GlobalStates.displayActive + && !GlobalStates.screenLockSecure + readonly property var focusedScreen: Quickshell.screens.find( + screen => screen.name === Hyprland.focusedMonitor?.name) ?? null + + Connections { + target: AccessoryPresentation + function onGenerationChanged() { + // A new admitted edge supersedes the old card. It is not a request + // to wake glass: an inactive display leaves it waiting for nothing. + if (GlobalStates.displayActive && !GlobalStates.screenLockSecure) + closeTimer.restart(); + } + } + + Timer { + id: closeTimer + interval: 6200 + repeat: false + onTriggered: scope.dismissedGeneration = AccessoryPresentation.generation + } + + Variants { + model: scope.focusedScreen ? [scope.focusedScreen] : [] + + PanelWindow { + id: win + required property var modelData + screen: modelData + + anchors { top: true; left: true; right: true; bottom: true } + color: "transparent" + visible: scope.canShow || card.opacity > 0 + WlrLayershell.namespace: "souveraine:airpods" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + exclusionMode: ExclusionMode.Ignore + + Item { + id: card + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + width: Math.min(parent.width - 32, 368) + height: 316 + opacity: scope.canShow ? 1 : 0 + scale: scope.canShow ? 1 : 0.94 + + Behavior on opacity { NumberAnimation { duration: 240; easing.type: Easing.OutCubic } } + Behavior on scale { NumberAnimation { duration: 300; easing.type: Easing.OutBack } } + + Rectangle { + anchors.fill: parent + radius: 36 + color: "#e81a1c21" + border.width: 1 + border.color: "#3ffffffF" + } + + Rectangle { + anchors.fill: parent + anchors.margins: 1 + radius: 35 + gradient: Gradient { + GradientStop { position: 0; color: "#f425282f" } + GradientStop { position: 1; color: "#f1101115" } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 25 + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Text { + text: AccessoryPresentation.label + color: "#f7f7f8" + font.pixelSize: 21 + font.weight: Font.DemiBold + } + Item { Layout.fillWidth: true } + Text { + text: AccessoryPresentation.charging ? "charging" : "nearby" + color: "#aeb5bf" + font.pixelSize: 13 + } + } + + Item { + id: stage + Layout.fillWidth: true + Layout.preferredHeight: 178 + Layout.topMargin: 7 + + property real open: 0 + property int seenGeneration: -1 + onVisibleChanged: if (visible) reveal.restart() + onSeenGenerationChanged: reveal.restart() + + Connections { + target: AccessoryPresentation + function onGenerationChanged() { + stage.seenGeneration = AccessoryPresentation.generation; + stage.open = 0; + reveal.restart(); + } + } + + SequentialAnimation { + id: reveal + NumberAnimation { target: stage; property: "open"; to: 1; duration: 720; easing.type: Easing.OutCubic } + } + + // A quiet pool of light beneath the case makes the + // device feel present without importing artwork. + Rectangle { + width: 238; height: 48; radius: height / 2 + anchors.horizontalCenter: parent.horizontalCenter + y: 126 + color: "#263a8dff" + opacity: 0.35 * stage.open + scale: 0.72 + 0.28 * stage.open + Behavior on opacity { NumberAnimation { duration: 420 } } + } + + Item { + id: caseArt + width: 206; height: 154 + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + + // Buds rise after the lid clears them. + Item { + id: leftBud + x: 39; y: 47 - 20 * stage.open + opacity: Math.max(0, (stage.open - 0.22) / 0.78) + scale: 0.80 + 0.20 * stage.open + Rectangle { width: 39; height: 66; radius: 19; color: "#f4f6f8" } + Rectangle { width: 14; height: 43; radius: 7; color: "#f4f6f8"; anchors.horizontalCenter: parent.horizontalCenter; anchors.top: parent.top; anchors.topMargin: 45 } + Rectangle { width: 12; height: 4; radius: 2; color: "#c9d0d8"; anchors.horizontalCenter: parent.horizontalCenter; anchors.top: parent.top; anchors.topMargin: 54 } + } + Item { + id: rightBud + x: 128; y: 47 - 20 * stage.open + opacity: Math.max(0, (stage.open - 0.22) / 0.78) + scale: 0.80 + 0.20 * stage.open + Rectangle { width: 39; height: 66; radius: 19; color: "#f4f6f8" } + Rectangle { width: 14; height: 43; radius: 7; color: "#f4f6f8"; anchors.horizontalCenter: parent.horizontalCenter; anchors.top: parent.top; anchors.topMargin: 45 } + Rectangle { width: 12; height: 4; radius: 2; color: "#c9d0d8"; anchors.horizontalCenter: parent.horizontalCenter; anchors.top: parent.top; anchors.topMargin: 54 } + } + + // Case body. The lid rotates up from its hinge as + // the admitted event lands, then waits in that pose. + Rectangle { + id: caseBody + width: 174; height: 94; radius: 43 + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + color: "#f2f4f6" + border.width: 1 + border.color: "#ffffff" + Rectangle { + width: 7; height: 7; radius: 4 + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + color: "#58d979" + opacity: 0.45 + 0.55 * stage.open + } + } + Rectangle { + id: lid + width: 174; height: 69; radius: 40 + anchors.horizontalCenter: parent.horizontalCenter + y: 58 + color: "#fafbfd" + border.width: 1 + border.color: "#ffffff" + transform: Rotation { + origin.x: lid.width / 2 + origin.y: lid.height + axis.x: 1; axis.y: 0; axis.z: 0 + angle: -73 * stage.open + } + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 10 + ChargePill { Layout.fillWidth: true; label: "Left"; level: AccessoryPresentation.leftCharge } + ChargePill { Layout.fillWidth: true; label: "Case"; level: AccessoryPresentation.caseCharge } + ChargePill { Layout.fillWidth: true; label: "Right"; level: AccessoryPresentation.rightCharge } + } + } + } + } + } + + component ChargePill: Rectangle { + required property string label + required property int level + implicitHeight: 45 + radius: 15 + color: "#1bffffff" + border.width: 1 + border.color: "#24ffffff" + + Column { + anchors.centerIn: parent + spacing: 1 + Text { anchors.horizontalCenter: parent.horizontalCenter; text: label; color: "#aeb5bf"; font.pixelSize: 11 } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: level >= 0 ? level + "%" : "—" + color: level >= 0 ? "#f6f7f8" : "#7e8793" + font.pixelSize: 15 + font.weight: Font.DemiBold + } + } + } +} diff --git a/surfaces/quickshell/modules/souveraine/airpods/qmldir b/surfaces/quickshell/modules/souveraine/airpods/qmldir new file mode 100644 index 0000000..7d547f5 --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/airpods/qmldir @@ -0,0 +1 @@ +AirPodsSurface 1.0 AirPodsSurface.qml diff --git a/surfaces/quickshell/panelFamilies/SouveraineFamily.qml b/surfaces/quickshell/panelFamilies/SouveraineFamily.qml index 583e488..35a405a 100644 --- a/surfaces/quickshell/panelFamilies/SouveraineFamily.qml +++ b/surfaces/quickshell/panelFamilies/SouveraineFamily.qml @@ -30,6 +30,7 @@ import qs.modules.souveraine.dial import qs.modules.souveraine.navigation import qs.modules.souveraine.selection import qs.modules.souveraine.windowSheet +import qs.modules.souveraine.airpods // The Souveraine panel family — one family for both modes. // Starts at exact panel parity with IllogicalImpulseFamily (our overridden @@ -65,6 +66,9 @@ Scope { // this hoist, for the adjacent reason — nothing may render before the lock // decision. `BootBloom` is hoisted in shell.qml on exactly this argument. PanelLoader { component: MediaControls {} } + // TASK-74. Its sole event source is sessiond's admitted accessory + // directive; it cannot wake the display or bypass lock content policy. + PanelLoader { component: AirPodsSurface {} } // Desktop notifications belong in the right-hand notification panel. // Keeping a transient popup surface loaded on desktop let some shell // restarts expose it to Hyprland as a normal app window, which was both diff --git a/surfaces/quickshell/services/AccessoryPresentation.qml b/surfaces/quickshell/services/AccessoryPresentation.qml new file mode 100644 index 0000000..0a95e0f --- /dev/null +++ b/surfaces/quickshell/services/AccessoryPresentation.qml @@ -0,0 +1,36 @@ +// The shell's projection of an accessory event already admitted by sessiond. +// +// This is deliberately a view-state holder, not a Bluetooth service. LibrePods +// cannot import it, and there is no Quickshell IPC target here: an accessory +// reaches this state only when SessiondBridge receives sessiond's directive. +pragma Singleton + +import QtQuick + +QtObject { + id: root + + // Incremented for every admitted presentation. A surface keys its local + // animation and timeout to this edge, so reconnect updates do not grow a + // pile of cards. + property int generation: 0 + property string accessoryId: "" + property string label: "AirPods" + property int leftCharge: -1 + property int rightCharge: -1 + property int caseCharge: -1 + property bool charging: false + + function present(event) { + // The daemon has already checked producer admission and payload class. + // Keep this copy bounded: QML needs display fields, never pairing keys, + // advertisement bytes, or arbitrary producer metadata. + root.accessoryId = String(event.accessory_id ?? ""); + root.label = String(event.label ?? "AirPods"); + root.leftCharge = Number(event.left_charge ?? -1); + root.rightCharge = Number(event.right_charge ?? -1); + root.caseCharge = Number(event.case_charge ?? -1); + root.charging = event.charging === true; + root.generation += 1; + } +} diff --git a/surfaces/quickshell/services/SessiondBridge.qml b/surfaces/quickshell/services/SessiondBridge.qml index 2aea3da..bfd4021 100644 --- a/surfaces/quickshell/services/SessiondBridge.qml +++ b/surfaces/quickshell/services/SessiondBridge.qml @@ -442,6 +442,13 @@ Singleton { Session.lock(); return; } + if (msg.directive === "accessory_presentation") { + // sessiond sends this only after producer admission and content + // classification. The shell projects it; it does not reinterpret + // a Bluetooth observation into an authority decision. + AccessoryPresentation.present(msg.presentation ?? {}); + return; + } console.error("[sessiond-bridge] UNKNOWN authority directive: " + JSON.stringify(msg) + " — this shell is older than the daemon driving it"); diff --git a/surfaces/quickshell/services/qmldir b/surfaces/quickshell/services/qmldir index 1882c7d..4797d4d 100644 --- a/surfaces/quickshell/services/qmldir +++ b/surfaces/quickshell/services/qmldir @@ -1,5 +1,6 @@ BooruResponseData 1.0 BooruResponseData.qml singleton AgentSessions 1.0 AgentSessions.qml +singleton AccessoryPresentation 1.0 AccessoryPresentation.qml singleton Ai 1.0 Ai.qml singleton AppSearch 1.0 AppSearch.qml singleton Audio 1.0 Audio.qml