shift-shell/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml
Marco Allegretti bcb985c652 Add quick settings panel and game sources
Slide-out panel with brightness, volume, Wi-Fi, Bluetooth,
and airplane mode. Fully gamepad-navigable; D-pad and stick
input routed to the panel while open. System status bar
(clock, battery, connectivity) added to game center header.

Lutris games discovered from ~/.local/share/lutris/pga.db,
Heroic from ~/.config/heroic/store_cache/ JSON files.
Deduplication extended to cover all launcher sources.

Desktop entries launched via KIO::ApplicationLauncherJob to
expand Exec field codes. Generic commands parsed with
KShell::splitArgs. SQLite uses RAII cleanup guard.

Clamped SDL axis, rumble, and LED values. Renamed rumble
params from frequency to intensity. GamepadManager uses
singleton factory. setPlayerIndex checks SDL return.

FavouritesBar popup uses screen virtualX/Y for multi-monitor.
2026-04-20 14:09:33 +02:00

767 lines
29 KiB
QML

// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import QtQuick.Window
import org.kde.kirigami as Kirigami
import org.kde.plasma.components 3.0 as PC3
import org.kde.plasma.private.mobileshell as MobileShell
import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell
import org.kde.plasma.private.mobileshell.screenbrightnessplugin as ScreenBrightness
import org.kde.layershell 1.0 as LayerShell
import org.kde.plasma.clock
import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio
Window {
id: root
required property var folio
signal gameStarted()
signal dismissRequested()
function requestExitGamingMode() {
exitGamingDialog.active = true
exitGamingDialog.item.open()
}
function launchGame(index) {
GamingShell.GameLauncherProvider.launch(index)
launchFade.restart()
}
function launchGameByStorageId(storageId) {
GamingShell.GameLauncherProvider.launchByStorageId(storageId)
launchFade.restart()
}
width: Screen.width
height: Screen.height
color: "transparent"
flags: Qt.FramelessWindowHint
LayerShell.Window.scope: "gaming-overlay"
LayerShell.Window.layer: LayerShell.Window.LayerTop
LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorBottom
| LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight
LayerShell.Window.exclusionZone: -1
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityOnDemand
// Animate opacity on show/hide
opacity: visible ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad }
}
onVisibleChanged: {
if (visible) {
GamingShell.GameLauncherProvider.filterString = ""
GamingShell.GameLauncherProvider.sourceFilter = ""
GamingShell.GameLauncherProvider.refresh()
if (runningGames.hasTasks) {
runningGames.focusFirstTask()
} else {
grid.forceActiveFocus()
}
}
}
// Translate gamepad buttons to focus navigation
Connections {
target: GamingShell.GamepadManager
enabled: root.visible
function onButtonPressed(button, gamepadIndex) {
// Route to quick settings panel when open
if (quickSettings.opened) {
switch (button) {
case GamingShell.GamepadManager.ButtonDPadUp:
quickSettings.gamepadUp()
return
case GamingShell.GamepadManager.ButtonDPadDown:
quickSettings.gamepadDown()
return
case GamingShell.GamepadManager.ButtonDPadLeft:
quickSettings.gamepadLeft()
return
case GamingShell.GamepadManager.ButtonDPadRight:
quickSettings.gamepadRight()
return
case GamingShell.GamepadManager.ButtonA:
quickSettings.gamepadAccept()
return
case GamingShell.GamepadManager.ButtonB:
case GamingShell.GamepadManager.ButtonBack:
quickSettings.close()
return
}
return // eat all other buttons while panel is open
}
switch (button) {
case GamingShell.GamepadManager.ButtonDPadUp:
if (grid.activeFocus) {
if (grid.currentIndex < grid.columns && runningGames.hasTasks) {
runningGames.focusFirstTask()
} else {
grid.moveCurrentIndexUp()
}
}
break
case GamingShell.GamepadManager.ButtonDPadDown:
if (runningGames.activeFocus) {
grid.forceActiveFocus()
} else if (grid.activeFocus) {
grid.moveCurrentIndexDown()
}
break
case GamingShell.GamepadManager.ButtonDPadLeft:
if (grid.activeFocus) grid.moveCurrentIndexLeft()
break
case GamingShell.GamepadManager.ButtonDPadRight:
if (grid.activeFocus) grid.moveCurrentIndexRight()
break
case GamingShell.GamepadManager.ButtonA:
if (runningGames.activeFocus) {
runningGames.activateCurrent()
} else if (grid.activeFocus && grid.currentItem) {
root.launchGame(grid.currentIndex)
}
break
case GamingShell.GamepadManager.ButtonX:
if (runningGames.activeFocus) {
runningGames.closeCurrent()
}
break
case GamingShell.GamepadManager.ButtonB:
root.dismissRequested()
break
case GamingShell.GamepadManager.ButtonY:
root.requestExitGamingMode()
break
case GamingShell.GamepadManager.ButtonLeftShoulder:
root.cycleSourceFilter(-1)
break
case GamingShell.GamepadManager.ButtonRightShoulder:
root.cycleSourceFilter(1)
break
case GamingShell.GamepadManager.ButtonStart:
if (searchField.activeFocus) {
grid.forceActiveFocus()
} else {
searchField.forceActiveFocus()
}
break
case GamingShell.GamepadManager.ButtonBack:
quickSettings.toggle()
break
}
}
function onAxisChanged(axis, value, gamepadIndex) {
if (axis === GamingShell.GamepadManager.AxisLeftX) {
stickState.leftX = value
} else if (axis === GamingShell.GamepadManager.AxisLeftY) {
stickState.leftY = value
} else if (axis === GamingShell.GamepadManager.AxisRightY) {
stickState.rightY = value
}
}
}
// Left-stick navigation state + repeat timer
QtObject {
id: stickState
property real leftX: 0
property real leftY: 0
property real rightY: 0
readonly property real deadzone: 0.4
}
function navigateByStick() {
// Route stick to quick settings when open
if (quickSettings.opened) {
if (stickState.leftY < -stickState.deadzone) {
quickSettings.gamepadUp()
} else if (stickState.leftY > stickState.deadzone) {
quickSettings.gamepadDown()
}
if (stickState.leftX < -stickState.deadzone) {
quickSettings.gamepadLeft()
} else if (stickState.leftX > stickState.deadzone) {
quickSettings.gamepadRight()
}
return
}
if (stickState.leftY < -stickState.deadzone) {
if (grid.activeFocus) {
if (grid.currentIndex < grid.columns && runningGames.hasTasks) {
runningGames.focusFirstTask()
} else {
grid.moveCurrentIndexUp()
}
}
} else if (stickState.leftY > stickState.deadzone) {
if (runningGames.activeFocus) {
grid.forceActiveFocus()
} else if (grid.activeFocus) {
grid.moveCurrentIndexDown()
}
}
if (stickState.leftX < -stickState.deadzone && grid.activeFocus) {
grid.moveCurrentIndexLeft()
} else if (stickState.leftX > stickState.deadzone && grid.activeFocus) {
grid.moveCurrentIndexRight()
}
}
Timer {
id: stickNavTimer
interval: 150
repeat: true
running: root.visible
&& (Math.abs(stickState.leftX) > stickState.deadzone
|| Math.abs(stickState.leftY) > stickState.deadzone)
onRunningChanged: if (running) root.navigateByStick()
onTriggered: root.navigateByStick()
}
// Right stick: smooth scroll the grid view
Timer {
id: stickScrollTimer
interval: 16 // ~60 Hz for smooth scrolling
repeat: true
running: root.visible && Math.abs(stickState.rightY) > stickState.deadzone
onTriggered: {
// Scale scroll speed with deflection, max ~12px per frame
grid.contentY = Math.max(grid.originY,
Math.min(grid.contentY + stickState.rightY * 12,
grid.contentHeight - grid.height))
}
}
// Cycle through source filter tabs (All → Steam → Desktop → All …)
readonly property var _sourceFilters: ["", "steam", "desktop", "lutris", "heroic"]
function cycleSourceFilter(direction) {
var current = _sourceFilters.indexOf(
GamingShell.GameLauncherProvider.sourceFilter)
if (current < 0) current = 0
var next = (current + direction + _sourceFilters.length)
% _sourceFilters.length
GamingShell.GameLauncherProvider.sourceFilter = _sourceFilters[next]
sourceFilterBar.currentIndex = next
}
Rectangle {
anchors.fill: parent
Kirigami.Theme.inherit: false
Kirigami.Theme.colorSet: Kirigami.Theme.Window
color: Qt.rgba(Kirigami.Theme.backgroundColor.r,
Kirigami.Theme.backgroundColor.g,
Kirigami.Theme.backgroundColor.b, 0.92)
}
FocusScope {
id: contentRoot
anchors.fill: parent
focus: root.visible
// Escape only dismisses the overlay; exiting gaming mode is explicit.
Keys.onEscapePressed: root.dismissRequested()
ColumnLayout {
anchors.fill: parent
anchors.margins: Kirigami.Units.largeSpacing * 2
spacing: Kirigami.Units.largeSpacing
// ---- header ----
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.largeSpacing
Kirigami.Heading {
text: i18n("Game Center")
level: 1
}
Item { Layout.fillWidth: true }
// ---- system status indicators ----
RowLayout {
spacing: Kirigami.Units.smallSpacing
Layout.alignment: Qt.AlignVCenter
Clock { id: wallClock }
PC3.Label {
text: Qt.formatTime(wallClock.dateTime,
MobileShell.ShellUtil.isSystem24HourFormat ? "h:mm" : "h:mm ap")
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.9
opacity: 0.8
}
Kirigami.Icon {
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
source: MobileShell.AudioInfo.icon
visible: MobileShell.AudioInfo.isVisible
opacity: 0.7
}
MobileShell.InternetIndicator {
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
opacity: 0.7
}
MobileShell.BluetoothIndicator {
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
opacity: 0.7
}
MobileShell.BatteryIndicator {
textPixelSize: Kirigami.Units.gridUnit * 0.55
opacity: 0.7
}
}
// ---- quick settings button ----
QQC2.ToolButton {
icon.name: "configure"
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.text: i18n("Quick Settings")
onClicked: quickSettings.open()
}
QQC2.ToolButton {
icon.name: "window-close"
text: i18n("Exit Gaming Mode")
display: QQC2.AbstractButton.TextBesideIcon
Keys.onReturnPressed: clicked()
Keys.onEnterPressed: clicked()
onClicked: root.requestExitGamingMode()
}
}
// ---- running games ----
RunningGamesView {
id: runningGames
Layout.fillWidth: true
onTaskActivated: root.gameStarted()
onMoveDownRequested: grid.forceActiveFocus()
}
// ---- continue playing ----
ColumnLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
visible: recentList.count > 0 && !runningGames.hasTasks
Kirigami.Heading {
level: 2
text: i18n("Continue Playing")
}
ListView {
id: recentList
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 5
orientation: ListView.Horizontal
spacing: Kirigami.Units.largeSpacing
clip: true
model: root.visible ? GamingShell.GameLauncherProvider.recentGames(5) : []
delegate: QQC2.ItemDelegate {
width: Kirigami.Units.gridUnit * 7
height: recentList.height
required property var modelData
readonly property bool hasArt: modelData.artwork && modelData.artwork.length > 0
background: Rectangle {
radius: Kirigami.Units.cornerRadius
color: parent.hovered ? Kirigami.Theme.hoverColor : "transparent"
}
contentItem: ColumnLayout {
spacing: Kirigami.Units.smallSpacing
Image {
Layout.fillWidth: true
Layout.fillHeight: true
source: hasArt ? "file://" + modelData.artwork : ""
fillMode: Image.PreserveAspectCrop
visible: hasArt
asynchronous: true
}
Kirigami.Icon {
Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.large
implicitHeight: Kirigami.Units.iconSizes.large
source: modelData.icon
visible: !hasArt
}
PC3.Label {
Layout.fillWidth: true
text: modelData.name
maximumLineCount: 1
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85
}
}
onClicked: root.launchGameByStorageId(modelData.storageId)
}
}
}
// ---- search + filter ----
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.largeSpacing
Kirigami.SearchField {
id: searchField
Layout.fillWidth: true
placeholderText: i18n("Search games…")
onTextChanged: GamingShell.GameLauncherProvider.filterString = text
Keys.onEscapePressed: {
if (text.length > 0) {
clear()
} else {
root.dismissRequested()
}
}
Keys.onDownPressed: grid.forceActiveFocus()
}
QQC2.TabBar {
id: sourceFilterBar
Layout.alignment: Qt.AlignVCenter
QQC2.TabButton {
text: i18n("All")
width: implicitWidth
onClicked: GamingShell.GameLauncherProvider.sourceFilter = ""
}
QQC2.TabButton {
text: "Steam"
width: implicitWidth
onClicked: GamingShell.GameLauncherProvider.sourceFilter = "steam"
}
QQC2.TabButton {
text: i18n("Desktop")
width: implicitWidth
onClicked: GamingShell.GameLauncherProvider.sourceFilter = "desktop"
}
QQC2.TabButton {
text: "Lutris"
width: implicitWidth
onClicked: GamingShell.GameLauncherProvider.sourceFilter = "lutris"
}
QQC2.TabButton {
text: "Heroic"
width: implicitWidth
onClicked: GamingShell.GameLauncherProvider.sourceFilter = "heroic"
}
}
}
// ---- game grid ----
GridView {
id: grid
Layout.fillWidth: true
Layout.fillHeight: true
model: GamingShell.GameLauncherProvider
readonly property real minCellSize: Kirigami.Units.gridUnit * 8
readonly property int columns: Math.max(2, Math.floor(width / minCellSize))
cellWidth: Math.floor(width / columns)
cellHeight: Math.floor(cellWidth * 1.5) + Kirigami.Units.gridUnit * 2
keyNavigationEnabled: true
highlightMoveDuration: 0
highlight: null
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4
visible: grid.count === 0 && !GamingShell.GameLauncherProvider.loading
icon.name: "games-none"
text: searchField.text.length > 0
? i18n("No games match your search")
: i18n("No games found")
explanation: searchField.text.length > 0
? ""
: i18n("Install games or check that they have the Game category in their .desktop file")
}
onActiveFocusChanged: {
if (activeFocus && count > 0 && currentIndex < 0) {
currentIndex = 0
}
}
Keys.onUpPressed: {
if (runningGames.hasTasks) {
runningGames.focusFirstTask()
}
}
Keys.onReturnPressed: {
if (currentIndex >= 0) {
root.launchGame(currentIndex)
}
}
Keys.onEnterPressed: Keys.onReturnPressed(event)
Keys.onEscapePressed: root.dismissRequested()
delegate: Item {
width: grid.cellWidth
height: grid.cellHeight
required property int index
required property string name
required property string icon
required property string source
required property string artwork
readonly property bool hasArt: artwork.length > 0
QQC2.ItemDelegate {
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
padding: 0
readonly property bool isCurrent: GridView.isCurrentItem && grid.activeFocus
background: Rectangle {
Kirigami.Theme.colorSet: Kirigami.Theme.Button
color: parent.isCurrent
? Kirigami.Theme.highlightColor
: (parent.hovered ? Kirigami.Theme.hoverColor : "transparent")
radius: Kirigami.Units.cornerRadius
}
contentItem: Item {
// ---- cover art tile ----
ColumnLayout {
anchors.fill: parent
spacing: 0
visible: hasArt
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: Kirigami.Units.cornerRadius
clip: true
color: "transparent"
Image {
anchors.fill: parent
source: hasArt ? "file://" + artwork : ""
fillMode: Image.PreserveAspectCrop
smooth: true
asynchronous: true
}
}
// Title beneath artwork
PC3.Label {
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
text: name
maximumLineCount: 1
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
leftPadding: Kirigami.Units.smallSpacing
rightPadding: Kirigami.Units.smallSpacing
color: parent.parent.parent.isCurrent
? Kirigami.Theme.highlightedTextColor
: Kirigami.Theme.textColor
}
}
// ---- fallback icon tile ----
ColumnLayout {
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
visible: !hasArt
spacing: Kirigami.Units.smallSpacing
Item { Layout.fillHeight: true }
Kirigami.Icon {
Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.huge
implicitHeight: Kirigami.Units.iconSizes.huge
source: icon
scale: parent.parent.parent.isCurrent ? 1.08 : 1.0
Behavior on scale {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad }
}
}
PC3.Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
text: name
maximumLineCount: 2
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
color: parent.parent.parent.isCurrent
? Kirigami.Theme.highlightedTextColor
: Kirigami.Theme.textColor
}
// Source badge
PC3.Label {
Layout.alignment: Qt.AlignHCenter
text: source === "steam" ? "Steam"
: source === "flatpak" ? "Flatpak"
: source === "lutris" ? "Lutris"
: source === "heroic" ? "Heroic"
: ""
visible: source !== "desktop"
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75
opacity: 0.6
}
Item { Layout.fillHeight: true }
}
}
onClicked: root.launchGame(index)
}
}
}
// ---- controller status bar ----
RowLayout {
Layout.fillWidth: true
visible: GamingShell.GamepadManager.hasGamepad
spacing: Kirigami.Units.largeSpacing
Kirigami.Icon {
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
source: "input-gaming"
}
Repeater {
model: GamingShell.GamepadManager
RowLayout {
spacing: Kirigami.Units.smallSpacing
required property string name
required property int battery
required property string type
PC3.Label {
text: name
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85
}
PC3.Label {
text: battery >= 0 ? battery + "%" : ""
visible: battery >= 0
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85
opacity: 0.7
}
}
}
Item { Layout.fillWidth: true }
// Gamepad legend
PC3.Label {
text: runningGames.hasTasks
? i18n("A: Select X: Close B: Back Y: Exit ⊞: Settings ☰: Search")
: i18n("A: Select B: Back Y: Exit LB/RB: Filter ⊞: Settings ☰: Search")
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75
opacity: 0.5
}
}
}
}
// Quick settings slide-out panel
GamingQuickSettings {
id: quickSettings
z: 50
}
// Launch transition: brief fade to black, then dismiss
Rectangle {
id: launchCurtain
anchors.fill: parent
color: "black"
opacity: 0
z: 100
Behavior on opacity {
NumberAnimation { duration: 250; easing.type: Easing.InQuad }
}
}
Timer {
id: launchFade
interval: 300
onTriggered: {
launchCurtain.opacity = 0
root.gameStarted()
}
onRunningChanged: {
if (running) {
launchCurtain.opacity = 1
}
}
}
Loader {
id: exitGamingDialog
active: false
anchors.fill: parent
sourceComponent: Kirigami.PromptDialog {
id: theExitDialog
title: i18n("Leave gaming mode?")
subtitle: i18n("Your games will keep running in the background.")
standardButtons: Kirigami.Dialog.NoButton
customFooterActions: [
Kirigami.Action {
text: i18n("Keep Playing")
onTriggered: theExitDialog.close()
},
Kirigami.Action {
text: i18n("Leave")
onTriggered: {
ShellSettings.Settings.gamingModeEnabled = false
theExitDialog.close()
}
}
]
onClosed: exitGamingDialog.active = false
}
}
}