shell: stage the AirPods opening card
This commit is contained in:
parent
41c858a5fc
commit
52640cd650
7 changed files with 295 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
surfaces/quickshell/modules/souveraine/airpods/qmldir
Normal file
1
surfaces/quickshell/modules/souveraine/airpods/qmldir
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
AirPodsSurface 1.0 AirPodsSurface.qml
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
36
surfaces/quickshell/services/AccessoryPresentation.qml
Normal file
36
surfaces/quickshell/services/AccessoryPresentation.qml
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue