// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2024 A-La-Karte Contributors import QtQuick import QtQuick.Controls as QQC2 import QtQuick.Layouts import QtQuick.Effects import org.kde.kirigami as Kirigami import org.kde.alakarte FocusScope { id: gameCard property var game property bool showPlayButton: true property bool focused: activeFocus readonly property bool isTouchDevice: { let w = applicationWindow() if (w && w.isTouchDevice !== undefined) return w.isTouchDevice return Kirigami.Settings.tabletMode || Kirigami.Settings.isMobile } readonly property real adaptiveHoverScale: root.isCouchMode ? 1.05 : 1.015 readonly property real adaptiveFocusScale: root.isCouchMode ? 1.12 : 1.03 readonly property int adaptiveFocusRingWidth: root.isCouchMode ? 3 : 1 readonly property bool useAnimatedCover: App.config.animatedCovers && game && game.coverUrl && game.coverUrl.toString().toLowerCase().endsWith(".gif") readonly property int coverStatus: useAnimatedCover ? animatedCover.status : staticCover.status signal clicked() signal doubleClicked() signal playClicked() Kirigami.ShadowedRectangle { id: cardBackground anchors.fill: parent radius: Kirigami.Units.mediumSpacing color: Kirigami.Theme.backgroundColor shadow { size: gameCard.focused ? (root.isCouchMode ? Kirigami.Units.gridUnit * 1.5 : Kirigami.Units.mediumSpacing) : (hoverHandler.hovered ? Kirigami.Units.smallSpacing * 1.5 : Kirigami.Units.smallSpacing) color: gameCard.focused ? (root.isCouchMode ? Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.4) : Qt.rgba(0, 0, 0, 0.34)) : (hoverHandler.hovered ? Qt.rgba(0, 0, 0, 0.24) : Qt.rgba(0, 0, 0, 0.16)) } Behavior on shadow.color { ColorAnimation { duration: Kirigami.Units.shortDuration } } border.width: gameCard.focused ? gameCard.adaptiveFocusRingWidth : 0 border.color: Kirigami.Theme.highlightColor Behavior on border.width { NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } } Behavior on shadow.size { NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } } y: gameCard.focused ? -Kirigami.Units.smallSpacing : (hoverHandler.hovered ? -Kirigami.Units.smallSpacing * 0.5 : 0) Behavior on y { NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } } scale: gameCard.focused ? gameCard.adaptiveFocusScale : (hoverHandler.hovered ? gameCard.adaptiveHoverScale : 1.0) Behavior on scale { NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } } Rectangle { anchors.fill: parent radius: Kirigami.Units.mediumSpacing color: "transparent" border.width: 2 border.color: Kirigami.Theme.highlightColor opacity: 0.0 visible: gameCard.focused SequentialAnimation on opacity { running: gameCard.focused loops: Animation.Infinite NumberAnimation { from: 0.10; to: 0.28; duration: 900; easing.type: Easing.InOutQuad } NumberAnimation { from: 0.28; to: 0.12; duration: 900; easing.type: Easing.InOutQuad } } } // Cover image Image { id: staticCover anchors.fill: parent anchors.margins: Kirigami.Units.smallSpacing source: game ? game.coverUrl : "" fillMode: Image.PreserveAspectCrop asynchronous: true visible: !gameCard.useAnimatedCover smooth: true mipmap: App.config.highQualityImages sourceSize.width: Math.round(width * (App.config.highQualityImages ? 2 : 1)) sourceSize.height: Math.round(height * (App.config.highQualityImages ? 2 : 1)) layer.enabled: true layer.effect: MultiEffect { maskEnabled: true maskSource: ShaderEffectSource { sourceItem: Rectangle { width: staticCover.width height: staticCover.height radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing } } } } AnimatedImage { id: animatedCover anchors.fill: parent anchors.margins: Kirigami.Units.smallSpacing source: game ? game.coverUrl : "" fillMode: Image.PreserveAspectCrop asynchronous: true playing: true visible: gameCard.useAnimatedCover smooth: true mipmap: App.config.highQualityImages sourceSize.width: Math.round(width * (App.config.highQualityImages ? 2 : 1)) sourceSize.height: Math.round(height * (App.config.highQualityImages ? 2 : 1)) layer.enabled: true layer.effect: MultiEffect { maskEnabled: true maskSource: ShaderEffectSource { sourceItem: Rectangle { width: animatedCover.width height: animatedCover.height radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing } } } } // Placeholder when no cover Rectangle { anchors.fill: parent anchors.margins: Kirigami.Units.smallSpacing radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing color: Kirigami.Theme.alternateBackgroundColor visible: gameCard.coverStatus !== Image.Ready Kirigami.Icon { anchors.centerIn: parent source: "applications-games" width: parent.width * 0.4 height: width color: Kirigami.Theme.disabledTextColor } } // Gradient overlay for text Rectangle { opacity: (!root.isCouchMode || gameCard.focused || hoverHandler.hovered) ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration } } anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom anchors.margins: Kirigami.Units.smallSpacing height: parent.height * 0.4 radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing gradient: Gradient { GradientStop { position: 0.0; color: "transparent" } GradientStop { position: 0.5; color: Qt.rgba(0, 0, 0, 0.5) } GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.85) } } } // Game title ColumnLayout { opacity: (!root.isCouchMode || gameCard.focused || hoverHandler.hovered) ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration } } anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom anchors.margins: Kirigami.Units.mediumSpacing spacing: Kirigami.Units.smallSpacing QQC2.Label { Layout.fillWidth: true text: game ? game.name : "" font.bold: true font.pointSize: Kirigami.Theme.defaultFont.pointSize color: "white" elide: Text.ElideRight wrapMode: Text.WordWrap maximumLineCount: 2 } RowLayout { Layout.fillWidth: true spacing: Kirigami.Units.smallSpacing visible: App.config.showPlatformBadges Rectangle { implicitWidth: platformLabel.implicitWidth + Kirigami.Units.largeSpacing implicitHeight: platformLabel.implicitHeight + Kirigami.Units.smallSpacing Layout.preferredWidth: implicitWidth Layout.preferredHeight: implicitHeight radius: Kirigami.Units.smallSpacing color: getPlatformColor(game ? game.platform : "") QQC2.Label { id: platformLabel anchors.centerIn: parent text: getPlatformDisplayName(game ? game.platform : "") font.pointSize: Kirigami.Theme.smallFont.pointSize color: "white" } } Item { Layout.fillWidth: true } } } // Favorite indicator Kirigami.Icon { anchors.top: parent.top anchors.right: parent.right anchors.margins: Kirigami.Units.mediumSpacing width: Kirigami.Units.iconSizes.medium height: width source: "bookmark-new" color: Kirigami.Theme.positiveTextColor visible: game && game.favorite layer.enabled: true layer.effect: MultiEffect { shadowEnabled: true shadowColor: Qt.rgba(0, 0, 0, 0.5) shadowBlur: 0.5 } } // Running indicator Rectangle { anchors.top: parent.top anchors.left: parent.left anchors.margins: Kirigami.Units.mediumSpacing width: Kirigami.Units.iconSizes.small height: width radius: width / 2 color: Kirigami.Theme.positiveTextColor visible: game && game.running SequentialAnimation on opacity { running: game && game.running loops: Animation.Infinite NumberAnimation { to: 0.3; duration: 800 } NumberAnimation { to: 1.0; duration: 800 } } } // Play button overlay Rectangle { id: playOverlay anchors.fill: parent anchors.margins: Kirigami.Units.smallSpacing radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing color: Qt.rgba(0, 0, 0, 0.6) opacity: (hoverHandler.hovered || gameCard.focused) && showPlayButton ? 1 : 0 visible: opacity > 0 Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration } } Kirigami.Icon { anchors.centerIn: parent width: Kirigami.Units.iconSizes.huge height: width source: "media-playback-start" color: "white" } } HoverHandler { id: hoverHandler } TapHandler { onTapped: { if (App.config.coverLaunchesGame) { gameCard.playClicked() } else { gameCard.clicked() } } } TapHandler { acceptedButtons: Qt.LeftButton onDoubleTapped: { gameCard.doubleClicked() gameCard.playClicked() } } // Context menu TapHandler { acceptedButtons: Qt.RightButton onTapped: contextMenu.popup() } QQC2.Menu { id: contextMenu QQC2.MenuItem { text: i18n("Play") icon.name: "media-playback-start" onTriggered: gameCard.playClicked() } QQC2.MenuSeparator {} QQC2.MenuItem { text: game && game.favorite ? i18n("Remove from Favorites") : i18n("Add to Favorites") icon.name: game && game.favorite ? "bookmark-remove" : "bookmark-new" onTriggered: if (game) game.favorite = !game.favorite } QQC2.MenuItem { text: game && game.hidden ? i18n("Show in Library") : i18n("Hide from Library") icon.name: game && game.hidden ? "view-visible" : "view-hidden" onTriggered: if (game) game.hidden = !game.hidden } QQC2.MenuSeparator {} QQC2.MenuItem { text: i18n("View Details") icon.name: "documentation" onTriggered: gameCard.clicked() } } } function getPlatformColor(platform) { if (!platform) return Kirigami.Theme.highlightColor if (platform.includes("Steam")) return "#1b2838" if (platform.includes("Lutris")) return "#ff9800" if (platform.includes("Epic")) return "#0078f2" if (platform.includes("GOG")) return "#86328a" if (platform.includes("Amazon")) return "#ff9900" return Kirigami.Theme.highlightColor } function getPlatformDisplayName(platform) { if (!platform) return "" if (platform.includes("Steam")) return "Steam" if (platform.includes("Lutris")) return "Lutris" if (platform.includes("Epic")) return "Epic" if (platform.includes("GOG")) return "GOG" if (platform.includes("Amazon")) return "Amazon" return platform } }