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 SettingsWindow.qml souveraine/SettingsWindow.qml
panelFamilies/SouveraineFamily.qml souveraine/panelFamilies/SouveraineFamily.qml panelFamilies/SouveraineFamily.qml souveraine/panelFamilies/SouveraineFamily.qml
services/Souveraine.qml souveraine/services/Souveraine.qml services/Souveraine.qml souveraine/services/Souveraine.qml
services/AccessoryPresentation.qml souveraine/services/AccessoryPresentation.qml
services/Ai.qml souveraine/services/Ai.qml services/Ai.qml souveraine/services/Ai.qml
services/Cellular.qml souveraine/services/Cellular.qml services/Cellular.qml souveraine/services/Cellular.qml
services/ChargeRate.qml souveraine/services/ChargeRate.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/PowerMenu.qml souveraine/modules/souveraine/windowSheet/PowerMenu.qml
modules/souveraine/windowSheet/PowerOptionRow.qml souveraine/modules/souveraine/windowSheet/PowerOptionRow.qml modules/souveraine/windowSheet/PowerOptionRow.qml souveraine/modules/souveraine/windowSheet/PowerOptionRow.qml
modules/souveraine/windowSheet/qmldir souveraine/modules/souveraine/windowSheet/qmldir 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/Face.qml souveraine/services/Face.qml
services/Lens.qml souveraine/services/Lens.qml services/Lens.qml souveraine/services/Lens.qml
services/FingerprintPreview.qml souveraine/services/FingerprintPreview.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.navigation
import qs.modules.souveraine.selection import qs.modules.souveraine.selection
import qs.modules.souveraine.windowSheet import qs.modules.souveraine.windowSheet
import qs.modules.souveraine.airpods
// The Souveraine panel family one family for both modes. // The Souveraine panel family one family for both modes.
// Starts at exact panel parity with IllogicalImpulseFamily (our overridden // 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 // this hoist, for the adjacent reason nothing may render before the lock
// decision. `BootBloom` is hoisted in shell.qml on exactly this argument. // decision. `BootBloom` is hoisted in shell.qml on exactly this argument.
PanelLoader { component: MediaControls {} } 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. // Desktop notifications belong in the right-hand notification panel.
// Keeping a transient popup surface loaded on desktop let some shell // Keeping a transient popup surface loaded on desktop let some shell
// restarts expose it to Hyprland as a normal app window, which was both // 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(); Session.lock();
return; 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: " console.error("[sessiond-bridge] UNKNOWN authority directive: "
+ JSON.stringify(msg) + JSON.stringify(msg)
+ " — this shell is older than the daemon driving it"); + " — this shell is older than the daemon driving it");

View file

@ -1,5 +1,6 @@
BooruResponseData 1.0 BooruResponseData.qml BooruResponseData 1.0 BooruResponseData.qml
singleton AgentSessions 1.0 AgentSessions.qml singleton AgentSessions 1.0 AgentSessions.qml
singleton AccessoryPresentation 1.0 AccessoryPresentation.qml
singleton Ai 1.0 Ai.qml singleton Ai 1.0 Ai.qml
singleton AppSearch 1.0 AppSearch.qml singleton AppSearch 1.0 AppSearch.qml
singleton Audio 1.0 Audio.qml singleton Audio 1.0 Audio.qml