2026-01-18 12:13:07 +00:00
|
|
|
// 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
|
|
|
|
|
|
2026-01-24 12:27:30 +00:00
|
|
|
readonly property bool isTouchDevice: {
|
|
|
|
|
let w = applicationWindow()
|
|
|
|
|
if (w && w.isTouchDevice !== undefined) return w.isTouchDevice
|
|
|
|
|
return Kirigami.Settings.tabletMode || Kirigami.Settings.isMobile
|
|
|
|
|
}
|
2026-01-18 12:13:07 +00:00
|
|
|
readonly property int adaptiveFocusRingWidth: 1
|
|
|
|
|
readonly property real adaptiveHoverScale: 1.015
|
2026-01-24 12:27:30 +00:00
|
|
|
readonly property real adaptiveFocusScale: 1.03
|
2026-01-18 12:13:07 +00:00
|
|
|
|
|
|
|
|
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 {
|
2026-01-24 12:27:30 +00:00
|
|
|
size: gameCard.focused ? Kirigami.Units.mediumSpacing : (hoverHandler.hovered ? Kirigami.Units.smallSpacing * 1.5 : Kirigami.Units.smallSpacing)
|
|
|
|
|
color: gameCard.focused ? 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 }
|
2026-01-18 12:13:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
border.width: gameCard.focused ? gameCard.adaptiveFocusRingWidth : 0
|
|
|
|
|
border.color: Kirigami.Theme.highlightColor
|
|
|
|
|
|
|
|
|
|
Behavior on border.width {
|
2026-01-24 12:27:30 +00:00
|
|
|
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
|
2026-01-18 12:13:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Behavior on shadow.size {
|
2026-01-24 12:27:30 +00:00
|
|
|
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 }
|
2026-01-18 12:13:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-24 12:27:30 +00:00
|
|
|
scale: gameCard.focused ? gameCard.adaptiveFocusScale : (hoverHandler.hovered ? gameCard.adaptiveHoverScale : 1.0)
|
2026-01-18 12:13:07 +00:00
|
|
|
Behavior on scale {
|
2026-01-24 12:27:30 +00:00
|
|
|
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 }
|
|
|
|
|
}
|
2026-01-18 12:13:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 {
|
|
|
|
|
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 {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|