Watch
1
0
Fork
You've already forked souveraine
0

shell: stage the AirPods opening card

This commit is contained in:
Fimeg 2026-08-13 15:27:51 -04:00
commit 52640cd650
7 changed files with 295 additions and 0 deletions

View file

@ -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

View file

@ -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
}
}
}
}

View file

@ -0,0 +1 @@
AirPodsSurface 1.0 AirPodsSurface.qml

View file

@ -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

View file

@ -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;
}
}

View file

@ -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");

View file

@ -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