Integrate MauiKit4/MauiMan4 as submodules, rewrite UI to MAUIkit console shell

- Add MauiMan4 + MauiKit4 submodules pinned at v4.0.2
- Wire both into CMake via CMAKE_FIND_PACKAGE_REDIRECTS_DIR build-tree stub
- Create MauiMan4 header shim for build-tree include layout
- Undefine QT_NO_CAST/QT_NO_FOREACH/QT_NO_KEYWORDS for MauiKit4 targets
- Link alakarte against MauiKit4
- Rewrite Main.qml: Maui.ApplicationWindow, dark console shell, blurred
  game backdrop, category rail, search bar, bottom gamepad hint bar
- Rewrite GameCard.qml: cover-art card with scale+glow focus effects
- Rewrite LibraryView.qml: Maui.GridBrowser with gamepad D-pad navigation
- New ConsoleCategoryRail.qml: horizontal pill category nav with L1/R1
- New ConsoleGameDetail.qml: cinematic full-screen game detail overlay
  with cover art, action buttons, fade-in animation, gamepad hints
This commit is contained in:
Marco Allegretti 2026-03-23 10:12:20 +01:00
parent e592e7b093
commit a0b9ea1832
9 changed files with 1083 additions and 2631 deletions

View file

@ -107,6 +107,8 @@ find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS
WindowSystem WindowSystem
) )
find_package(Qt6 COMPONENTS Multimedia QUIET)
find_package(KF6Auth ${KF_MIN_VERSION} QUIET) find_package(KF6Auth ${KF_MIN_VERSION} QUIET)
find_package(KF6StatusNotifierItem ${KF_MIN_VERSION} QUIET) find_package(KF6StatusNotifierItem ${KF_MIN_VERSION} QUIET)
@ -115,6 +117,56 @@ find_package(KF6KirigamiAddons 1.0.0 REQUIRED)
qt_policy(SET QTP0001 NEW) qt_policy(SET QTP0001 NEW)
set(MAUI_MAJOR_VERSION 4)
set(MAUIMAN_VERSION 4.0.2)
set(MAUIKIT_VERSION 4.0.2)
set(CMAKE_FIND_PACKAGE_REDIRECTS_DIR "${CMAKE_BINARY_DIR}/package_redirects")
file(MAKE_DIRECTORY "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}")
set(BUILD_DEMO OFF CACHE BOOL "" FORCE)
set(BUNDLE_LUV_ICONS OFF CACHE BOOL "" FORCE)
add_subdirectory(third-party/mauiman EXCLUDE_FROM_ALL)
set(_mauiman_shim "${CMAKE_BINARY_DIR}/mauiman_include")
file(MAKE_DIRECTORY "${_mauiman_shim}/MauiMan4")
file(COPY "${CMAKE_SOURCE_DIR}/third-party/mauiman/lib/src/modules/"
DESTINATION "${_mauiman_shim}/MauiMan4/"
FILES_MATCHING PATTERN "*.h")
file(COPY "${CMAKE_SOURCE_DIR}/third-party/mauiman/lib/src/mauimanutils.h"
DESTINATION "${_mauiman_shim}/MauiMan4/")
target_include_directories(MauiMan4 PUBLIC
"$<BUILD_INTERFACE:${_mauiman_shim}>")
file(WRITE "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/MauiMan4Config.cmake" [=[
set(MauiMan4_FOUND TRUE)
if(NOT TARGET MauiMan4::MauiMan4)
add_library(MauiMan4::MauiMan4 ALIAS MauiMan4)
endif()
]=])
file(WRITE "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/MauiMan4ConfigVersion.cmake" [=[
set(PACKAGE_VERSION "4.0.2")
set(PACKAGE_VERSION_EXACT TRUE)
set(PACKAGE_VERSION_COMPATIBLE TRUE)
set(PACKAGE_VERSION_UNSUITABLE FALSE)
]=])
add_subdirectory(third-party/mauikit EXCLUDE_FROM_ALL)
foreach(_mauikit_target MauiKit4 MauiKit4plugin)
if(TARGET ${_mauikit_target})
target_compile_options(${_mauikit_target} PRIVATE
"-UQT_NO_CAST_FROM_ASCII"
"-UQT_NO_CAST_TO_ASCII"
"-UQT_NO_CAST_FROM_BYTEARRAY"
"-UQT_NO_URL_CAST_FROM_STRING"
"-UQT_NO_FOREACH"
"-UQT_NO_KEYWORDS")
endif()
endforeach()
add_subdirectory(src) add_subdirectory(src)
add_subdirectory(src/krunner) add_subdirectory(src/krunner)
add_subdirectory(icons) add_subdirectory(icons)

View file

@ -78,6 +78,7 @@ target_link_libraries(alakarte PRIVATE
SDL3::SDL3 SDL3::SDL3
KF6::Kirigami KF6::Kirigami
KF6::I18n KF6::I18n
MauiKit4
KF6::CoreAddons KF6::CoreAddons
KF6::ConfigCore KF6::ConfigCore
KF6::ConfigGui KF6::ConfigGui
@ -104,8 +105,10 @@ ecm_add_qml_module(alakarte URI org.kde.alakarte
QML_FILES QML_FILES
qml/Main.qml qml/Main.qml
qml/LibraryView.qml qml/LibraryView.qml
qml/CouchSidebar.qml
qml/GameCard.qml qml/GameCard.qml
qml/ConsoleGameDetail.qml
qml/ConsoleCategoryRail.qml
qml/CouchSidebar.qml
qml/GameDetailsSheet.qml qml/GameDetailsSheet.qml
qml/DiagnosticsSheet.qml qml/DiagnosticsSheet.qml
qml/SettingsPage.qml qml/SettingsPage.qml

View file

@ -0,0 +1,161 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.mauikit.controls as Maui
import org.kde.alakarte
Item {
id: root
property string currentCategory: "all"
signal categorySelected(string categoryId)
function selectNext() {
if (rail.currentIndex < categoryModel.count - 1) {
rail.currentIndex++
categorySelected(categoryModel.get(rail.currentIndex).categoryId)
}
}
function selectPrevious() {
if (rail.currentIndex > 0) {
rail.currentIndex--
categorySelected(categoryModel.get(rail.currentIndex).categoryId)
}
}
function _platformIcon(name) {
let p = (name || "").toLowerCase()
if (p.includes("steam")) return ":/icons/brand/steam-symbolic.svg"
if (p.includes("itch")) return ":/icons/brand/itchdotio-symbolic.svg"
if (p.includes("retroarch")) return ":/icons/brand/retroarch-symbolic.svg"
if (p.includes("lutris")) return "applications-games"
if (p.includes("heroic")) return "applications-games"
if (p.includes("bottles")) return "application-x-executable"
if (p.includes("flatpak")) return "applications-games"
if (p.includes("desktop")) return "computer"
return "applications-games"
}
GameSortFilterModel {
id: allGames
sourceModel: App.gameModel
showHidden: false
}
ListModel {
id: categoryModel
Component.onCompleted: rebuild()
function rebuild() {
clear()
append({ label: i18n("All"), categoryId: "all", iconSource: "view-list-icons" })
append({ label: i18n("Favorites"), categoryId: "favorites", iconSource: "starred-symbolic" })
let seen = {}
for (let i = 0; i < allGames.count; ++i) {
let g = allGames.get(i)
if (!g) continue
let p = g.platform || ""
if (p && !seen[p]) {
seen[p] = true
append({ label: p, categoryId: p, iconSource: root._platformIcon(p) })
}
}
for (let j = 0; j < count; ++j) {
if (get(j).categoryId === root.currentCategory) {
rail.currentIndex = j
break
}
}
}
}
Connections {
target: App.gameModel
function onCountChanged() { categoryModel.rebuild() }
}
onCurrentCategoryChanged: {
for (let j = 0; j < categoryModel.count; ++j) {
if (categoryModel.get(j).categoryId === currentCategory) {
rail.currentIndex = j
return
}
}
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.55)
ListView {
id: rail
anchors {
fill: parent
leftMargin: 24
rightMargin: 24
}
orientation: ListView.Horizontal
spacing: 8
clip: true
model: categoryModel
keyNavigationEnabled: true
highlightMoveDuration: 200
delegate: QQC2.ItemDelegate {
id: pill
width: implicitContentWidth + 32
height: rail.height
topPadding: 0
bottomPadding: 0
leftPadding: 16
rightPadding: 16
readonly property bool isCurrent: ListView.isCurrentItem
background: Rectangle {
radius: height / 2
color: pill.isCurrent
? Maui.Theme.highlightColor
: (pill.hovered ? Qt.rgba(1, 1, 1, 0.12) : Qt.rgba(1, 1, 1, 0.06))
border.color: pill.isCurrent ? Maui.Theme.highlightColor : Qt.rgba(1, 1, 1, 0.18)
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
}
contentItem: RowLayout {
spacing: 6
Maui.Icon {
source: model.iconSource
Layout.preferredWidth: 16
Layout.preferredHeight: 16
color: "white"
opacity: pill.isCurrent ? 1.0 : 0.75
}
QQC2.Label {
text: model.label
color: "white"
opacity: pill.isCurrent ? 1.0 : 0.75
font.pixelSize: 14
font.weight: pill.isCurrent ? Font.DemiBold : Font.Normal
}
}
onClicked: {
rail.currentIndex = index
root.categorySelected(model.categoryId)
}
}
}
}
}

View file

@ -0,0 +1,379 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtQuick.Effects
import org.mauikit.controls as Maui
import org.kde.alakarte
Item {
id: detailRoot
property var game: null
signal close()
signal launch()
signal editRequested()
signal removeRequested()
visible: game !== null
focus: visible
Keys.onEscapePressed: detailRoot.close()
Connections {
target: GamepadManager
function onBackPressed() {
if (detailRoot.visible) detailRoot.close()
}
function onSelectPressed() {
if (detailRoot.visible) detailRoot.launch()
}
function onDetailsPressed() {
if (detailRoot.visible) detailRoot.editRequested()
}
}
Rectangle {
anchors.fill: parent
color: "#CC000000"
}
Image {
id: artBackdrop
anchors.fill: parent
source: detailRoot.game ? detailRoot.game.coverUrl : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
visible: false
}
MultiEffect {
source: artBackdrop
anchors.fill: artBackdrop
blurEnabled: true
blur: 1.0
blurMax: 80
brightness: -0.2
opacity: 0.45
}
Rectangle {
anchors.fill: parent
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0.82) }
GradientStop { position: 0.55; color: Qt.rgba(0, 0, 0, 0.46) }
GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.10) }
}
}
Rectangle {
anchors.fill: parent
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0.0) }
GradientStop { position: 0.72; color: Qt.rgba(0, 0, 0, 0.0) }
GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.75) }
}
}
RowLayout {
anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
top: parent.top
leftMargin: 64
rightMargin: 48
bottomMargin: 64
topMargin: 48
}
spacing: 48
Item {
Layout.preferredWidth: Math.round(parent.height * 0.42)
Layout.fillHeight: true
Rectangle {
id: coverArt
anchors.centerIn: parent
width: Math.round(parent.width * 0.85)
height: Math.round(width * 1.45)
radius: 12
color: "#1a1a2a"
clip: true
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: Qt.rgba(0, 0, 0, 0.7)
shadowBlur: 0.7
shadowHorizontalOffset: 0
shadowVerticalOffset: 12
}
Image {
anchors.fill: parent
source: detailRoot.game ? detailRoot.game.coverUrl : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
smooth: true
}
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
Item { Layout.fillHeight: true; Layout.preferredHeight: 1 }
QQC2.Label {
Layout.fillWidth: true
text: detailRoot.game ? detailRoot.game.name : ""
color: "white"
font.pixelSize: 52
font.weight: Font.Bold
wrapMode: Text.WordWrap
maximumLineCount: 3
elide: Text.ElideRight
lineHeight: 1.1
style: Text.Raised
styleColor: Qt.rgba(0, 0, 0, 0.5)
}
Item { Layout.preferredHeight: 12 }
RowLayout {
spacing: 12
visible: detailRoot.game && detailRoot.game.platform
Rectangle {
radius: 4
color: Qt.rgba(1, 1, 1, 0.15)
border.color: Qt.rgba(1, 1, 1, 0.3)
border.width: 1
implicitWidth: platformLabel.implicitWidth + 20
implicitHeight: platformLabel.implicitHeight + 8
QQC2.Label {
id: platformLabel
anchors.centerIn: parent
text: detailRoot.game ? (detailRoot.game.platform || "") : ""
color: "white"
font.pixelSize: 13
font.weight: Font.Medium
}
}
Rectangle {
radius: 4
color: "#4caf50"
implicitWidth: runningLabel.implicitWidth + 16
implicitHeight: runningLabel.implicitHeight + 6
visible: detailRoot.game && detailRoot.game.running
QQC2.Label {
id: runningLabel
anchors.centerIn: parent
text: i18n("Running")
color: "white"
font.pixelSize: 12
font.weight: Font.Medium
}
}
}
Item { Layout.preferredHeight: 48 }
RowLayout {
spacing: 16
QQC2.Button {
id: playBtn
text: i18n("Play")
icon.name: "media-playback-start"
focus: true
KeyNavigation.right: favoriteBtn
contentItem: RowLayout {
spacing: 8
Maui.Icon {
source: "media-playback-start"
width: 22; height: 22
color: "white"
}
QQC2.Label {
text: i18n("Play")
color: "white"
font.pixelSize: 17
font.weight: Font.DemiBold
}
}
background: Rectangle {
radius: 8
color: playBtn.activeFocus
? Maui.Theme.highlightColor
: (playBtn.hovered ? Qt.rgba(1,1,1,0.22) : Qt.rgba(1,1,1,0.14))
border.color: playBtn.activeFocus ? Maui.Theme.highlightColor : Qt.rgba(1,1,1,0.28)
border.width: playBtn.activeFocus ? 0 : 1
Behavior on color { ColorAnimation { duration: 100 } }
}
implicitWidth: 160
implicitHeight: 52
onClicked: detailRoot.launch()
Keys.onReturnPressed: detailRoot.launch()
Keys.onEnterPressed: detailRoot.launch()
}
QQC2.Button {
id: favoriteBtn
icon.name: detailRoot.game && detailRoot.game.favorite ? "starred-symbolic" : "non-starred-symbolic"
KeyNavigation.left: playBtn
KeyNavigation.right: editBtn
contentItem: Maui.Icon {
source: detailRoot.game && detailRoot.game.favorite ? "starred-symbolic" : "non-starred-symbolic"
width: 22; height: 22
color: detailRoot.game && detailRoot.game.favorite ? "#f5c518" : "white"
}
background: Rectangle {
radius: 8
color: favoriteBtn.activeFocus
? Qt.rgba(1,1,1,0.22)
: (favoriteBtn.hovered ? Qt.rgba(1,1,1,0.18) : Qt.rgba(1,1,1,0.10))
border.color: favoriteBtn.activeFocus ? "white" : Qt.rgba(1,1,1,0.25)
border.width: 1
Behavior on color { ColorAnimation { duration: 100 } }
}
implicitWidth: 52
implicitHeight: 52
QQC2.ToolTip.visible: hovered || activeFocus
QQC2.ToolTip.text: detailRoot.game && detailRoot.game.favorite
? i18n("Remove from Favorites") : i18n("Add to Favorites")
QQC2.ToolTip.delay: 800
onClicked: if (detailRoot.game) detailRoot.game.favorite = !detailRoot.game.favorite
}
QQC2.Button {
id: editBtn
icon.name: "document-edit"
KeyNavigation.left: favoriteBtn
KeyNavigation.right: closeBtn
contentItem: Maui.Icon {
source: "document-edit"
width: 20; height: 20
color: "white"
}
background: Rectangle {
radius: 8
color: editBtn.activeFocus
? Qt.rgba(1,1,1,0.22)
: (editBtn.hovered ? Qt.rgba(1,1,1,0.18) : Qt.rgba(1,1,1,0.10))
border.color: editBtn.activeFocus ? "white" : Qt.rgba(1,1,1,0.25)
border.width: 1
Behavior on color { ColorAnimation { duration: 100 } }
}
implicitWidth: 52
implicitHeight: 52
QQC2.ToolTip.visible: hovered || activeFocus
QQC2.ToolTip.text: i18n("Edit")
QQC2.ToolTip.delay: 800
onClicked: detailRoot.editRequested()
}
QQC2.Button {
id: closeBtn
icon.name: "window-close"
KeyNavigation.left: editBtn
contentItem: Maui.Icon {
source: "window-close"
width: 20; height: 20
color: "white"
}
background: Rectangle {
radius: 8
color: closeBtn.activeFocus
? Qt.rgba(1,1,1,0.22)
: (closeBtn.hovered ? Qt.rgba(1,1,1,0.18) : Qt.rgba(1,1,1,0.10))
border.color: closeBtn.activeFocus ? "white" : Qt.rgba(1,1,1,0.25)
border.width: 1
Behavior on color { ColorAnimation { duration: 100 } }
}
implicitWidth: 52
implicitHeight: 52
QQC2.ToolTip.visible: hovered || activeFocus
QQC2.ToolTip.text: i18n("Back")
QQC2.ToolTip.delay: 800
onClicked: detailRoot.close()
}
}
Item { Layout.preferredHeight: 32 }
RowLayout {
spacing: 28
Repeater {
model: [
{ icon: ":/icons/gamepad/generic/south.svg", label: i18n("Play") },
{ icon: ":/icons/gamepad/generic/east.svg", label: i18n("Back") },
{ icon: ":/icons/gamepad/generic/north.svg", label: i18n("Edit") }
]
RowLayout {
spacing: 6
visible: GamepadManager.connected
Image {
source: modelData.icon
width: 22; height: 22
fillMode: Image.PreserveAspectFit
smooth: true
}
QQC2.Label {
text: modelData.label
color: Qt.rgba(1, 1, 1, 0.65)
font.pixelSize: 13
}
}
}
}
Item { Layout.fillHeight: true; Layout.preferredHeight: 1 }
}
}
NumberAnimation on opacity {
id: fadeIn
running: detailRoot.visible
from: 0; to: 1
duration: 220
easing.type: Easing.OutCubic
}
onVisibleChanged: {
if (visible) {
Qt.callLater(function() { playBtn.forceActiveFocus() })
}
}
}

View file

@ -1,314 +1,168 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors // SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
import QtQuick import QtQuick
import QtQuick.Controls as QQC2 import QtQuick.Controls as QQC2
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Effects import QtQuick.Effects
import org.kde.kirigami as Kirigami import org.mauikit.controls as Maui
import org.kde.alakarte import org.kde.alakarte
FocusScope { Item {
id: gameCard id: gameCard
property var game property var game
property bool showPlayButton: true signal clicked()
property bool focused: activeFocus signal playClicked()
readonly property bool isTouchDevice: { readonly property bool isCurrent: GridView.isCurrentItem
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 readonly property bool useAnimatedCover: App.config.animatedCovers
&& game && game && game.coverUrl
&& game.coverUrl
&& game.coverUrl.toString().toLowerCase().endsWith(".gif") && game.coverUrl.toString().toLowerCase().endsWith(".gif")
readonly property int coverStatus: useAnimatedCover ? animatedCover.status : staticCover.status readonly property int coverStatus: useAnimatedCover ? animatedCover.status : staticCover.status
signal clicked() width: GridView.view ? GridView.view.cellWidth : 200
signal doubleClicked() height: GridView.view ? GridView.view.cellHeight : 300
signal playClicked()
Kirigami.ShadowedRectangle { Item {
id: cardBackground id: cardContainer
anchors.fill: parent anchors.centerIn: parent
radius: Kirigami.Units.mediumSpacing width: parent.width - 16
height: parent.height - 16
color: Kirigami.Theme.backgroundColor scale: gameCard.isCurrent ? 1.08 : (hoverHandler.hovered ? 1.03 : 1.0)
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 { Behavior on scale {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
layer.enabled: gameCard.isCurrent
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: Qt.rgba(
Maui.Theme.highlightColor.r,
Maui.Theme.highlightColor.g,
Maui.Theme.highlightColor.b, 0.55)
shadowBlur: 0.8
shadowHorizontalOffset: 0
shadowVerticalOffset: 6
} }
Rectangle { Rectangle {
id: coverFrame
anchors.fill: parent anchors.fill: parent
radius: Kirigami.Units.mediumSpacing radius: 8
color: "transparent" color: "#1a1a2a"
border.width: 2 clip: true
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 { Image {
id: staticCover id: staticCover
anchors.fill: parent anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
source: game ? game.coverUrl : "" source: game ? game.coverUrl : ""
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
asynchronous: true asynchronous: true
visible: !gameCard.useAnimatedCover visible: !gameCard.useAnimatedCover
smooth: true smooth: true
mipmap: App.config.highQualityImages mipmap: true
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 { AnimatedImage {
id: animatedCover id: animatedCover
anchors.fill: parent anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
source: game ? game.coverUrl : "" source: game ? game.coverUrl : ""
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
asynchronous: true asynchronous: true
playing: true playing: gameCard.isCurrent
visible: gameCard.useAnimatedCover visible: gameCard.useAnimatedCover
smooth: true 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 Maui.Icon {
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 anchors.centerIn: parent
source: "applications-games" source: "applications-games"
width: parent.width * 0.4 width: parent.width * 0.38
height: width height: width
color: Kirigami.Theme.disabledTextColor color: "#44ffffff"
} visible: gameCard.coverStatus !== Image.Ready
} }
// Gradient overlay for text
Rectangle { 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.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.margins: Kirigami.Units.smallSpacing height: parent.height * 0.42
height: parent.height * 0.4
radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing
gradient: Gradient { gradient: Gradient {
GradientStop { position: 0.0; color: "transparent" } GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 0.5; color: Qt.rgba(0, 0, 0, 0.5) } GradientStop { position: 0.55; color: Qt.rgba(0, 0, 0, 0.62) }
GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.85) } GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.92) }
} }
} }
// 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 { QQC2.Label {
Layout.fillWidth: true anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
margins: 10
}
text: game ? game.name : "" text: game ? game.name : ""
font.bold: true
font.pointSize: Kirigami.Theme.defaultFont.pointSize
color: "white" color: "white"
font.pixelSize: 13
font.weight: Font.Medium
elide: Text.ElideRight elide: Text.ElideRight
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
maximumLineCount: 2 maximumLineCount: 2
opacity: gameCard.isCurrent || hoverHandler.hovered ? 1.0 : 0.85
} }
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 { Rectangle {
anchors.top: parent.top anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.margins: Kirigami.Units.mediumSpacing anchors.margins: 8
width: Kirigami.Units.iconSizes.small width: 9
height: width height: 9
radius: width / 2 radius: 5
color: Kirigami.Theme.positiveTextColor color: "#4caf50"
visible: game && game.running visible: game && game.running
SequentialAnimation on opacity { SequentialAnimation on opacity {
running: game && game.running running: game && game.running
loops: Animation.Infinite loops: Animation.Infinite
NumberAnimation { to: 0.3; duration: 800 } NumberAnimation { to: 0.25; duration: 700 }
NumberAnimation { to: 1.0; duration: 800 } NumberAnimation { to: 1.0; duration: 700 }
}
}
Maui.Icon {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 8
width: 18
height: 18
source: "starred-symbolic"
color: "#f5c518"
visible: game && game.favorite
} }
} }
// Play button overlay
Rectangle { Rectangle {
id: playOverlay anchors.fill: coverFrame
anchors.fill: parent radius: 8
anchors.margins: Kirigami.Units.smallSpacing color: "transparent"
radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing border.color: Maui.Theme.highlightColor
color: Qt.rgba(0, 0, 0, 0.6) border.width: gameCard.isCurrent ? 3 : 0
opacity: (hoverHandler.hovered || gameCard.focused) && showPlayButton ? 1 : 0 opacity: gameCard.isCurrent ? 1.0 : 0.0
visible: opacity > 0 Behavior on opacity { NumberAnimation { duration: 120 } }
Behavior on border.width { NumberAnimation { duration: 120 } }
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.shortDuration }
} }
Kirigami.Icon { HoverHandler { id: hoverHandler }
anchors.centerIn: parent
width: Kirigami.Units.iconSizes.huge
height: width
source: "media-playback-start"
color: "white"
}
}
HoverHandler {
id: hoverHandler
}
TapHandler { TapHandler {
onTapped: { onTapped: gameCard.clicked()
if (App.config.coverLaunchesGame) { onDoubleTapped: gameCard.playClicked()
gameCard.playClicked()
} else {
gameCard.clicked()
}
}
} }
TapHandler {
acceptedButtons: Qt.LeftButton
onDoubleTapped: {
gameCard.doubleClicked()
gameCard.playClicked()
}
}
// Context menu
TapHandler { TapHandler {
acceptedButtons: Qt.RightButton acceptedButtons: Qt.RightButton
onTapped: contextMenu.popup() onTapped: contextMenu.popup()
@ -322,48 +176,23 @@ FocusScope {
icon.name: "media-playback-start" icon.name: "media-playback-start"
onTriggered: gameCard.playClicked() onTriggered: gameCard.playClicked()
} }
QQC2.MenuSeparator {} QQC2.MenuSeparator {}
QQC2.MenuItem { QQC2.MenuItem {
text: game && game.favorite ? i18n("Remove from Favorites") : i18n("Add to Favorites") text: game && game.favorite ? i18n("Remove from Favorites") : i18n("Add to Favorites")
icon.name: game && game.favorite ? "bookmark-remove" : "bookmark-new" icon.name: game && game.favorite ? "bookmark-remove" : "starred-symbolic"
onTriggered: if (game) game.favorite = !game.favorite onTriggered: if (game) game.favorite = !game.favorite
} }
QQC2.MenuItem { QQC2.MenuItem {
text: game && game.hidden ? i18n("Show in Library") : i18n("Hide from Library") text: game && game.hidden ? i18n("Show in Library") : i18n("Hide from Library")
icon.name: game && game.hidden ? "view-visible" : "view-hidden" icon.name: game && game.hidden ? "view-visible" : "view-hidden"
onTriggered: if (game) game.hidden = !game.hidden onTriggered: if (game) game.hidden = !game.hidden
} }
QQC2.MenuSeparator {} QQC2.MenuSeparator {}
QQC2.MenuItem { QQC2.MenuItem {
text: i18n("View Details") text: i18n("View Details")
icon.name: "documentation" icon.name: "documentinfo"
onTriggered: gameCard.clicked() 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
}
} }

View file

@ -1,11 +1,10 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors // SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
import QtQuick import QtQuick
import QtQuick.Controls as QQC2 import QtQuick.Controls as QQC2
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Effects import org.mauikit.controls as Maui
import org.kde.kirigami as Kirigami
import org.kde.alakarte import org.kde.alakarte
import "components" import "components"
@ -13,320 +12,124 @@ FocusScope {
id: libraryRoot id: libraryRoot
property string filterSource: "all" property string filterSource: "all"
property bool searchActive: false property string filterText: ""
property int focusedIndex: -1
property int adaptiveCardSize: App.config.gridSize
property bool isTouchDevice: false
signal gameSelected(var game) signal gameSelected(var game)
signal gameFocused(var game)
signal gameLaunched(var game) signal gameLaunched(var game)
readonly property int gameCount: proxyModel.count readonly property int gameCount: proxyModel.count
property url focusedCoverUrl: ""
readonly property bool anyMenuOpen: searchHeader.anyMenuOpen
function closeCurrentMenu() {
searchHeader.closeCurrentMenu()
}
function focusSearch() {
searchField.forceActiveFocus()
}
function clearSearch() {
searchField.text = ""
proxyModel.filterText = ""
}
function restoreFocus() { function restoreFocus() {
let w = applicationWindow() grid.forceActiveFocus()
if (w && w.hasOwnProperty("pendingSidebarOpen") && w.pendingSidebarOpen) {
w.pendingSidebarOpen = false
if (w.globalDrawer && typeof w.globalDrawer.open === "function") {
w.globalDrawer.open()
return
}
}
if (libraryRoot.searchActive) {
libraryRoot.focusSearch()
} else {
if (libraryRoot.focusedIndex >= 0 && libraryRoot.focusedIndex < proxyModel.count) {
gameGrid.currentIndex = libraryRoot.focusedIndex
}
gameGrid.forceActiveFocus()
}
} }
function launchFocusedGame() { function focusedGame() {
if (!gameGrid || !proxyModel) return if (grid.currentIndex < 0) return null
if (gameGrid.currentIndex < 0 && proxyModel.count > 0) { return proxyModel.get(grid.currentIndex)
gameGrid.currentIndex = 0
libraryRoot.focusedIndex = 0
}
let game = proxyModel.get(gameGrid.currentIndex)
if (game) {
libraryRoot.gameLaunched(game)
}
} }
function openDetailsForFocusedGame() { function selectFocused() {
if (!gameGrid || !proxyModel) return let g = focusedGame()
if (gameGrid.currentIndex < 0 && proxyModel.count > 0) { if (g) libraryRoot.gameSelected(g)
gameGrid.currentIndex = 0
libraryRoot.focusedIndex = 0
}
let game = proxyModel.get(gameGrid.currentIndex)
if (game) {
libraryRoot.gameSelected(game)
}
} }
onSearchActiveChanged: { function launchFocused() {
if (!libraryRoot.searchActive) { let g = focusedGame()
libraryRoot.clearSearch() if (g) libraryRoot.gameLaunched(g)
Qt.callLater(function() {
gameGrid.forceActiveFocus()
})
}
} }
Item { GameSortFilterModel {
anchors.fill: parent
anchors.margins: 0
Item {
anchors.fill: parent
visible: libraryRoot.gameCount > 0
Image {
id: backgroundCoverA
anchors.fill: parent
source: ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
visible: source.toString().length > 0
smooth: true
mipmap: App.config.highQualityImages
opacity: 0.0
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutCubic }
}
layer.enabled: true
layer.effect: MultiEffect {
blurEnabled: true
blur: 0.9
blurMax: 64
}
}
Image {
id: backgroundCoverB
anchors.fill: parent
source: ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
visible: source.toString().length > 0
smooth: true
mipmap: App.config.highQualityImages
opacity: 0.0
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutCubic }
}
layer.enabled: true
layer.effect: MultiEffect {
blurEnabled: true
blur: 0.9
blurMax: 64
}
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.55)
}
}
ColumnLayout {
anchors.fill: parent
spacing: Kirigami.Units.smallSpacing
SearchHeader {
id: searchHeader
Layout.fillWidth: true
visible: libraryRoot.searchActive
// In Couch Mode, position it absolute over the grid instead of shifting the grid
// to mimic a console drop-down search bar
y: root.isCouchMode ? Kirigami.Units.gridUnit * 2 : 0
z: 100
searchField: searchField
onSearchChanged: function(text) {
proxyModel.filterText = text
}
onSortChanged: function(mode) {
proxyModel.sortMode = mode
}
Kirigami.SearchField {
id: searchField
Layout.fillWidth: true
placeholderText: i18n("Search games...")
onTextChanged: proxyModel.filterText = text
Keys.onEscapePressed: {
text = ""
let w = applicationWindow()
if (w && w.hasOwnProperty("searchActive")) {
w.searchActive = false
} else {
libraryRoot.searchActive = false
}
libraryRoot.restoreFocus()
}
Keys.onDownPressed: gameGrid.forceActiveFocus()
Connections {
target: GamepadManager
function onNavigateDown() {
let w = applicationWindow()
if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
if (!searchField.activeFocus) return
gameGrid.forceActiveFocus()
}
}
}
}
GameGridView {
id: gameGrid
Layout.fillWidth: true
Layout.fillHeight: true
cardSize: libraryRoot.adaptiveCardSize
onCurrentIndexChanged: {
if (gameGrid.activeFocus) {
libraryRoot.focusedIndex = currentIndex
}
let game = proxyModel.get(currentIndex)
let url = (game && game.coverUrl) ? game.coverUrl : ""
if (url === libraryRoot.focusedCoverUrl) return
if (backgroundCoverA.opacity > 0.1) {
backgroundCoverB.source = url
backgroundCoverB.opacity = 0.0
backgroundCoverA.opacity = 0.0
Qt.callLater(function() { backgroundCoverB.opacity = 0.22 })
} else {
backgroundCoverA.source = url
backgroundCoverA.opacity = 0.0
backgroundCoverB.opacity = 0.0
Qt.callLater(function() { backgroundCoverA.opacity = 0.22 })
}
libraryRoot.focusedCoverUrl = url
}
model: GameSortFilterModel {
id: proxyModel id: proxyModel
sourceModel: App.gameModel sourceModel: App.gameModel
showHidden: libraryRoot.filterSource === "hidden" showHidden: false
favoritesOnly: libraryRoot.filterSource === "favorites" favoritesOnly: libraryRoot.filterSource === "favorites"
filterSource: { filterSource: {
if (libraryRoot.filterSource === "all") return "" if (libraryRoot.filterSource === "all") return ""
if (libraryRoot.filterSource === "favorites") return "" if (libraryRoot.filterSource === "favorites") return ""
if (libraryRoot.filterSource === "hidden") return ""
return libraryRoot.filterSource return libraryRoot.filterSource
} }
filterText: libraryRoot.filterText
} }
delegate: Item { Maui.GridBrowser {
width: gameGrid.cellWidth id: grid
height: gameGrid.cellHeight anchors.fill: parent
focus: true
function clicked() { model: proxyModel
gameGrid.currentIndex = index itemSize: Math.max(160, Math.min(App.config.gridSize, 280))
libraryRoot.focusedIndex = index itemHeight: Math.round(grid.itemSize * 1.5)
card.clicked() adaptContent: true
enableLassoSelection: false
topPadding: 16
bottomPadding: 24
leftPadding: 24
rightPadding: 24
holder.visible: proxyModel.count === 0 && !App.importing
holder.emoji: libraryRoot.filterSource === "favorites" ? "starred-symbolic"
: "applications-games"
holder.title: libraryRoot.filterSource === "favorites"
? i18n("No favorites yet")
: i18n("Your library is empty")
holder.body: libraryRoot.filterSource === "favorites"
? i18n("Mark games as favorites to see them here")
: i18n("Import games to get started")
onCurrentIndexChanged: {
let g = proxyModel.get(currentIndex)
if (g) libraryRoot.gameFocused(g)
} }
function play() { delegate: GameCard {
gameGrid.currentIndex = index
libraryRoot.focusedIndex = index
card.playClicked()
}
GameCard {
id: card
width: gameGrid.cardSize
height: Math.round(gameGrid.cardSize * 1.4)
anchors.centerIn: parent
game: model.gameObject game: model.gameObject
focused: gameGrid.currentIndex === index && gameGrid.activeFocus
onClicked: libraryRoot.gameSelected(model.gameObject) onClicked: {
onDoubleClicked: libraryRoot.gameLaunched(model.gameObject) grid.currentIndex = index
onPlayClicked: libraryRoot.gameLaunched(model.gameObject) libraryRoot.gameSelected(model.gameObject)
}
Keys.onReturnPressed: libraryRoot.gameSelected(model.gameObject) onPlayClicked: {
Keys.onEnterPressed: libraryRoot.gameSelected(model.gameObject) grid.currentIndex = index
Keys.onSpacePressed: libraryRoot.gameLaunched(model.gameObject) libraryRoot.gameLaunched(model.gameObject)
} }
} }
Keys.onPressed: function(event) { Keys.onReturnPressed: libraryRoot.selectFocused()
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { Keys.onEnterPressed: libraryRoot.selectFocused()
if (currentIndex < 0 && proxyModel.count > 0) { Keys.onSpacePressed: libraryRoot.selectFocused()
currentIndex = 0
libraryRoot.focusedIndex = 0
}
let game = proxyModel.get(currentIndex)
if (game) {
libraryRoot.gameSelected(game)
}
event.accepted = true
} else if (event.key === Qt.Key_Space) {
if (currentIndex < 0 && proxyModel.count > 0) {
currentIndex = 0
libraryRoot.focusedIndex = 0
}
let game = proxyModel.get(currentIndex)
if (game) {
libraryRoot.gameLaunched(game)
}
event.accepted = true
}
}
EmptyState {
anchors.centerIn: parent
visible: proxyModel.count === 0 && !App.importing
icon: proxyModel.filterText.length > 0 ? "edit-find" : (libraryRoot.filterSource === "favorites" ? "bookmark-new" : (libraryRoot.filterSource === "hidden" ? "view-hidden" : "applications-games"))
title: proxyModel.filterText.length > 0 ?
i18n("No games found") : (libraryRoot.filterSource === "favorites" ? i18n("No favorites yet") : (libraryRoot.filterSource === "hidden" ? i18n("No hidden games") : i18n("Your library is empty")))
description: proxyModel.filterText.length > 0 ?
i18n("Try adjusting your search") : (libraryRoot.filterSource === "favorites" ? i18n("Mark games as favorites to see them here") : (libraryRoot.filterSource === "hidden" ? i18n("Hidden games will appear here") : i18n("Import games to get started")))
actionText: (proxyModel.filterText.length > 0 || libraryRoot.filterSource === "favorites" || libraryRoot.filterSource === "hidden") ? "" : i18n("Import Games")
onActionTriggered: App.importAllGames()
}
QQC2.BusyIndicator { QQC2.BusyIndicator {
anchors.centerIn: parent anchors.centerIn: parent
running: App.importing running: App.importing
visible: App.importing visible: App.importing
} }
Connections {
target: GamepadManager
function onSelectPressed() {
if (!grid.activeFocus) return
libraryRoot.selectFocused()
}
function onNavigateUp() {
if (!grid.activeFocus) return
if (grid.currentIndex <= 0) return
let cols = Math.max(1, Math.floor(grid.width / grid.cellWidth))
grid.currentIndex = Math.max(0, grid.currentIndex - cols)
}
function onNavigateDown() {
if (!grid.activeFocus) return
let cols = Math.max(1, Math.floor(grid.width / grid.cellWidth))
grid.currentIndex = Math.min(proxyModel.count - 1, grid.currentIndex + cols)
}
function onNavigateLeft() {
if (!grid.activeFocus) return
grid.currentIndex = Math.max(0, grid.currentIndex - 1)
}
function onNavigateRight() {
if (!grid.activeFocus) return
grid.currentIndex = Math.min(proxyModel.count - 1, grid.currentIndex + 1)
} }
} }
} }

File diff suppressed because it is too large Load diff

1
third-party/mauikit vendored Submodule

@ -0,0 +1 @@
Subproject commit 32dbb346861fa89a1df9dfb82e54ff952754b232

1
third-party/mauiman vendored Submodule

@ -0,0 +1 @@
Subproject commit 6ad438d97e000158aa0eda13ab669c208e804b0a