From 6a152edfb4c1c39a97303b593f37139706745993 Mon Sep 17 00:00:00 2001 From: cybrneko Date: Sat, 15 Aug 2026 03:46:53 -0500 Subject: [PATCH] top screen hero and logo art --- shell/qml/Main.qml | 332 +++++++++++++++++++++++-- shell/src/library/GameLibraryModel.cpp | 38 ++- shell/src/library/GameLibraryModel.h | 8 +- 3 files changed, 347 insertions(+), 31 deletions(-) diff --git a/shell/qml/Main.qml b/shell/qml/Main.qml index d8c7582..77420cb 100644 --- a/shell/qml/Main.qml +++ b/shell/qml/Main.qml @@ -3,6 +3,10 @@ import QtQuick.Window import NomadInput 1.0 QtObject { + id: root + + property var selectedGame: ({}) + // this is our top screen, for now, though in the future I would like to have it configurable which screen does which. // I'll burn that bridge when I get to it property Window topWindow: Window { @@ -12,35 +16,314 @@ QtObject { title: "nomad-top" // this is necesarry for Sway, which auto-assigns the windows property string lastInputText: "none" // I've never really integrated NomadInput into this debug section in the top half and now I'm too lazy to. it's just a placeholder anyway. - Connections { - target: inputManager - - function onActionPressed(action) { - topWindow.lastInputText = inputManager.actionName(action) + " pressed" - } - function onActionReleased(action) { - topWindow.lastInputText = inputManager.actionName(action) + " released" - } - } - - Rectangle { // just a placeholder for the top screen until we do something better + Rectangle { anchors.fill: parent - color: "#181818" + color: "black" - Column { - anchors.centerIn: parent - spacing: 20 + Item { + id: heroArea - Text { - text: "nomad top screen" - color: "white" - font.pixelSize: 50 + anchors.fill: parent + + property bool heroAActive: true + property url requestedHero: "" + + property bool logoAActive: true + property url requestedLogo: "" + + readonly property int fadeTime: 200 + + function showHero(newSource) { + requestedHero = newSource + heroUpdateTimer.restart() } - Text { - text: "last input: " + topWindow.lastInputText - color: "white" - font.pixelSize: 30 + function loadRequestedHero() { + transitionToA.stop() + transitionToB.stop() + + if (heroA.opacity >= heroB.opacity) { + heroAActive = true + heroA.opacity = 1 + heroB.opacity = 0 + + heroB.source = requestedHero + + // If this source was already loaded, status won't change, + // so onStatusChanged won't fire. Start the transition ourselves. + if (heroB.status === Image.Ready + && heroB.source === requestedHero) { + transitionToB.restart() + } + } else { + heroAActive = false + heroA.opacity = 0 + heroB.opacity = 1 + + heroA.source = requestedHero + + if (heroA.status === Image.Ready + && heroA.source === requestedHero) { + transitionToA.restart() + } + } + } + + Timer { + id: heroUpdateTimer + interval: 75 + + onTriggered: { + heroArea.loadRequestedHero() + } + } + + Connections { + target: root + + function onSelectedGameChanged() { + heroArea.showHero(root.selectedGame.heroArtwork) + heroArea.showLogo(root.selectedGame.logoArtwork) + } + } + + Image { + id: heroA + + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + + asynchronous: true + cache: true + + opacity: 1 + + onStatusChanged: { + if (status === Image.Ready && source === heroArea.requestedHero && !heroArea.heroAActive) { + transitionToA.restart() + } + } + } + + Image { + id: heroB + + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + + opacity: 0 + + asynchronous: true + cache: true + + onStatusChanged: { + if (status === Image.Ready && source === heroArea.requestedHero && heroArea.heroAActive) { + transitionToB.restart() + } + } + } + + ParallelAnimation { + id: transitionToB + + NumberAnimation { + target: heroA + property: "opacity" + to: 0 + duration: heroArea.fadeTime + easing.type: Easing.OutCubic + } + + NumberAnimation { + target: heroB + property: "opacity" + to: 1 + duration: heroArea.fadeTime + easing.type: Easing.OutCubic + } + + onFinished: { + if (heroB.source === heroArea.requestedHero) { + heroArea.heroAActive = false + heroA.opacity = 0 + heroB.opacity = 1 + } + } + } + + ParallelAnimation { + id: transitionToA + + NumberAnimation { + target: heroB + property: "opacity" + to: 0 + duration: heroArea.fadeTime + easing.type: Easing.OutCubic + } + + NumberAnimation { + target: heroA + property: "opacity" + to: 1 + duration: heroArea.fadeTime + easing.type: Easing.OutCubic + } + + onFinished: { + if (heroA.source === heroArea.requestedHero) { + heroArea.heroAActive = true + heroA.opacity = 1 + heroB.opacity = 0 + } + } + } + + Image { + id: preloadPrevious + visible: false + asynchronous: true + cache: true + } + + Image { + id: preloadNext + visible: false + asynchronous: true + cache: true + } + + function showLogo(newSource) { + requestedLogo = newSource + + transitionLogoToA.stop() + transitionLogoToB.stop() + + if (logoAActive) { + logoB.source = requestedLogo + + if (logoB.status === Image.Ready + && logoB.source === requestedLogo) { + transitionLogoToB.restart() + } + } else { + logoA.source = requestedLogo + + if (logoA.status === Image.Ready + && logoA.source === requestedLogo) { + transitionLogoToA.restart() + } + } + } + + Image { + id: logoA + + z: 10 + anchors.centerIn: parent + + width: 400 + height: 240 + + fillMode: Image.PreserveAspectFit + + asynchronous: true + cache: true + + opacity: 1 + + onStatusChanged: { + if ( + status === Image.Ready + && source === heroArea.requestedLogo + && !heroArea.logoAActive + ) { + transitionLogoToA.restart() + } + } + } + + Image { + id: logoB + + z: 10 + anchors.centerIn: parent + + width: 400 + height: 240 + + fillMode: Image.PreserveAspectFit + + asynchronous: true + cache: true + + opacity: 0 + + onStatusChanged: { + if ( + status === Image.Ready + && source === heroArea.requestedLogo + && heroArea.logoAActive + ) { + transitionLogoToB.restart() + } + } + } + + ParallelAnimation { + id: transitionLogoToB + + NumberAnimation { + target: logoA + property: "opacity" + to: 0 + duration: heroArea.fadeTime + easing.type: Easing.OutCubic + } + + NumberAnimation { + target: logoB + property: "opacity" + to: 1 + duration: heroArea.fadeTime + easing.type: Easing.OutCubic + } + + onFinished: { + if (logoB.source === heroArea.requestedLogo) { + heroArea.logoAActive = false + logoA.opacity = 0 + logoB.opacity = 1 + } + } + } + + ParallelAnimation { + id: transitionLogoToA + + NumberAnimation { + target: logoB + property: "opacity" + to: 0 + duration: heroArea.fadeTime + easing.type: Easing.OutCubic + } + + NumberAnimation { + target: logoA + property: "opacity" + to: 1 + duration: heroArea.fadeTime + easing.type: Easing.OutCubic + } + + onFinished: { + if (logoA.source === heroArea.requestedLogo) { + heroArea.logoAActive = true + logoA.opacity = 1 + logoB.opacity = 0 + } + } } } } @@ -252,6 +535,7 @@ QtObject { function onNavigationFocused() { gamesList.currentIndex = index + root.selectedGame = gameLibraryModel.gameById(model.gameId) } Component.onCompleted: { diff --git a/shell/src/library/GameLibraryModel.cpp b/shell/src/library/GameLibraryModel.cpp index 30723be..72f0bd2 100644 --- a/shell/src/library/GameLibraryModel.cpp +++ b/shell/src/library/GameLibraryModel.cpp @@ -18,7 +18,7 @@ int GameLibraryModel::rowCount(const QModelIndex &parent) const { } // fetches artwork based on the game's UUID -QUrl GameLibraryModel::gridArtworkUrl(const QString &gameId) const { +QUrl GameLibraryModel::getArtworkUrl(const QString &gameId, const QString &artworkType) const { QString dataDirectory = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QDir artworkDirectory(QDir(dataDirectory).filePath("artwork/" + gameId)); @@ -27,11 +27,12 @@ QUrl GameLibraryModel::gridArtworkUrl(const QString &gameId) const { "png", "jpg", "jpeg", - "webp" + "webp", + "gif" }; for (const QString &extension : extensions) { - QString artworkPath = artworkDirectory.filePath("grid." + extension); + QString artworkPath = artworkDirectory.filePath(artworkType + "." + extension); if (QFileInfo::exists(artworkPath)) { return QUrl::fromLocalFile(artworkPath); @@ -41,6 +42,25 @@ QUrl GameLibraryModel::gridArtworkUrl(const QString &gameId) const { return {}; } +QVariantMap GameLibraryModel::gameById(const QString &gameId) const { + for (const GameRecord &game : m_games) { + if (game.id == gameId) { + return { + { "gameId", game.id }, + { "title", game.title }, + { "platform", game.platform }, + { "runner", game.runner }, + { "source", game.source }, + { "gridArtwork", getArtworkUrl(game.id, "grid") }, + { "heroArtwork", getArtworkUrl(game.id, "hero") }, + { "logoArtwork", getArtworkUrl(game.id, "logo") } + }; + } + } + + return {}; +} + // returns whatever data is requested QVariant GameLibraryModel::data(const QModelIndex &index, int role) const { if (!index.isValid()) { @@ -70,7 +90,13 @@ QVariant GameLibraryModel::data(const QModelIndex &index, int role) const { return game.source; case GridArtworkRole: - return gridArtworkUrl(game.id); + return getArtworkUrl(game.id, "grid"); + + case HeroArtworkRole: + return getArtworkUrl(game.id, "hero"); + + case LogoArtworkRole: + return getArtworkUrl(game.id, "logo"); default: return {}; @@ -84,7 +110,9 @@ QHash GameLibraryModel::roleNames() const { { PlatformRole, "platform" }, { RunnerRole, "runner" }, { SourceRole, "source" }, - { GridArtworkRole, "gridArtwork" } + { GridArtworkRole, "gridArtwork" }, + { HeroArtworkRole, "heroArtwork" }, + { LogoArtworkRole, "logoArtwork" } }; } diff --git a/shell/src/library/GameLibraryModel.h b/shell/src/library/GameLibraryModel.h index c63db7e..bed7d88 100644 --- a/shell/src/library/GameLibraryModel.h +++ b/shell/src/library/GameLibraryModel.h @@ -17,12 +17,16 @@ public: PlatformRole, RunnerRole, SourceRole, - GridArtworkRole + GridArtworkRole, + HeroArtworkRole, + LogoArtworkRole }; // make sure we get a GameRepository explicit GameLibraryModel(GameRepository &repository, QObject *parent = nullptr); + Q_INVOKABLE QVariantMap gameById(const QString &gameId) const; + int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; @@ -34,5 +38,5 @@ public: private: GameRepository &m_repository; QList m_games; - QUrl gridArtworkUrl(const QString &gameId) const; + QUrl getArtworkUrl(const QString &gameId, const QString &artworkType) const; }; \ No newline at end of file