Compare commits
3 Commits
32df106ab3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a152edfb4 | |||
| e769d50a68 | |||
| 39850225fc |
@@ -5,13 +5,19 @@ project(nomad-shell VERSION 0.1 LANGUAGES CXX)
|
|||||||
set(CMAKE_CXX_STANDARD 17)
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
find_package(Qt6 REQUIRED COMPONENTS Quick)
|
find_package(Qt6 REQUIRED COMPONENTS Quick Sql)
|
||||||
|
|
||||||
qt_standard_project_setup()
|
qt_standard_project_setup()
|
||||||
|
|
||||||
qt_add_executable(nomad-shell
|
qt_add_executable(nomad-shell
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/input/InputManager.cpp
|
src/input/InputManager.cpp
|
||||||
|
src/library/LibraryDatabase.cpp
|
||||||
|
src/library/GameRecord.h
|
||||||
|
src/library/GameRepository.h
|
||||||
|
src/library/GameRepository.cpp
|
||||||
|
src/library/GameLibraryModel.h
|
||||||
|
src/library/GameLibraryModel.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
qt_add_qml_module(nomad-shell
|
qt_add_qml_module(nomad-shell
|
||||||
@@ -20,8 +26,9 @@ qt_add_qml_module(nomad-shell
|
|||||||
QML_FILES
|
QML_FILES
|
||||||
qml/Main.qml
|
qml/Main.qml
|
||||||
qml/NavigationManager.qml
|
qml/NavigationManager.qml
|
||||||
|
qml/GameCard.qml
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(nomad-shell
|
target_link_libraries(nomad-shell
|
||||||
PRIVATE Qt6::Quick
|
PRIVATE Qt6::Quick Qt6::Sql
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property bool highlighted: false
|
||||||
|
property string title: ""
|
||||||
|
property url artwork
|
||||||
|
|
||||||
|
width: 120
|
||||||
|
height: 180
|
||||||
|
|
||||||
|
scale: highlighted ? 1.10 : 1.0
|
||||||
|
|
||||||
|
Behavior on scale {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: 120
|
||||||
|
easing.type: Easing.OutCubic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
color: "white"
|
||||||
|
|
||||||
|
Image {
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
source: root.artwork
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+393
-41
@@ -3,6 +3,10 @@ import QtQuick.Window
|
|||||||
import NomadInput 1.0
|
import NomadInput 1.0
|
||||||
|
|
||||||
QtObject {
|
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.
|
// 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
|
// I'll burn that bridge when I get to it
|
||||||
property Window topWindow: Window {
|
property Window topWindow: Window {
|
||||||
@@ -12,35 +16,314 @@ QtObject {
|
|||||||
title: "nomad-top" // this is necesarry for Sway, which auto-assigns the windows
|
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.
|
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 {
|
Rectangle {
|
||||||
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
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
color: "#181818"
|
color: "black"
|
||||||
|
|
||||||
Column {
|
Item {
|
||||||
anchors.centerIn: parent
|
id: heroArea
|
||||||
spacing: 20
|
|
||||||
|
|
||||||
Text {
|
anchors.fill: parent
|
||||||
text: "nomad top screen"
|
|
||||||
color: "white"
|
property bool heroAActive: true
|
||||||
font.pixelSize: 50
|
property url requestedHero: ""
|
||||||
|
|
||||||
|
property bool logoAActive: true
|
||||||
|
property url requestedLogo: ""
|
||||||
|
|
||||||
|
readonly property int fadeTime: 200
|
||||||
|
|
||||||
|
function showHero(newSource) {
|
||||||
|
requestedHero = newSource
|
||||||
|
heroUpdateTimer.restart()
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
function loadRequestedHero() {
|
||||||
text: "last input: " + topWindow.lastInputText
|
transitionToA.stop()
|
||||||
color: "white"
|
transitionToB.stop()
|
||||||
font.pixelSize: 30
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,17 +363,6 @@ QtObject {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
color: "#181818"
|
color: "#181818"
|
||||||
|
|
||||||
// top text where a menu dock will probably be eventually
|
|
||||||
Text {
|
|
||||||
anchors.top: parent.top
|
|
||||||
anchors.topMargin: 30
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
|
|
||||||
text: "top placeholder"
|
|
||||||
color: "white"
|
|
||||||
font.pixelSize: 24
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is the game carousel. mostly self explanatory, but I wanna point out that the local index for this listView does probably still matter.
|
// this is the game carousel. mostly self explanatory, but I wanna point out that the local index for this listView does probably still matter.
|
||||||
// I got rid of dedicated menu indexing for the dock, but I think it still matters here, for the sake of auto-scrolling
|
// I got rid of dedicated menu indexing for the dock, but I think it still matters here, for the sake of auto-scrolling
|
||||||
ListView {
|
ListView {
|
||||||
@@ -117,17 +389,47 @@ QtObject {
|
|||||||
leftMargin: 16
|
leftMargin: 16
|
||||||
rightMargin: 16
|
rightMargin: 16
|
||||||
|
|
||||||
model: 20 // this is a placeholder for now, I don't know how I really want to handle max game carousel size yet. another time.
|
model: gameLibraryModel
|
||||||
|
|
||||||
// provides the smoothing animation for game selection
|
// provides the smoothing animation for game selection
|
||||||
|
// TODO: tune this to make it as nice as possible !!!
|
||||||
NumberAnimation {
|
NumberAnimation {
|
||||||
id: scrollAnimation
|
id: scrollAnimation
|
||||||
target: gamesList
|
target: gamesList
|
||||||
property: "contentX"
|
property: "contentX"
|
||||||
duration: 200
|
duration: 300
|
||||||
easing.type: Easing.OutCubic
|
easing.type: Easing.OutCubic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectCentermostGame() {
|
||||||
|
let viewCenter = gamesList.width / 2
|
||||||
|
|
||||||
|
let bestItem = null
|
||||||
|
let bestDistance = Infinity
|
||||||
|
|
||||||
|
for (let i = 0; i < gamesList.count; i++) {
|
||||||
|
let item = gamesList.itemAtIndex(i)
|
||||||
|
|
||||||
|
// I think offscreen items do not exist
|
||||||
|
if (!item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let itemCenter = item.x - gamesList.contentX + item.width / 2
|
||||||
|
|
||||||
|
let distance = Math.abs(itemCenter - viewCenter)
|
||||||
|
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance
|
||||||
|
bestItem = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestItem) {
|
||||||
|
navigationManager.focusItem(bestItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// used to scroll to a new game on the game list
|
// used to scroll to a new game on the game list
|
||||||
function scrollTo(targetX) {
|
function scrollTo(targetX) {
|
||||||
if (Math.abs(targetX - gamesList.contentX) < 0.5) {
|
if (Math.abs(targetX - gamesList.contentX) < 0.5) {
|
||||||
@@ -220,18 +522,20 @@ QtObject {
|
|||||||
// after flicking, we re-snap the carousel wherever it landed
|
// after flicking, we re-snap the carousel wherever it landed
|
||||||
onMovementEnded: {
|
onMovementEnded: {
|
||||||
gamesList.scrollTo(gamesList.snappedContentX(gamesList.contentX))
|
gamesList.scrollTo(gamesList.snappedContentX(gamesList.contentX))
|
||||||
|
gamesList.selectCentermostGame()
|
||||||
}
|
}
|
||||||
|
|
||||||
delegate: Rectangle { // TODO: design a real game card QML to replace this placeholder
|
delegate: GameCard {
|
||||||
id: gameTile
|
id: gameTile
|
||||||
|
|
||||||
width: 120
|
title: model.title
|
||||||
height: 180
|
artwork: model.gridArtwork
|
||||||
|
|
||||||
color: navigationManager.currentItem === gameTile ? "blue" : "white"
|
highlighted: navigationManager.currentItem === gameTile && !gamesList.moving
|
||||||
|
|
||||||
function onNavigationFocused() {
|
function onNavigationFocused() {
|
||||||
gamesList.currentIndex = index
|
gamesList.currentIndex = index
|
||||||
|
root.selectedGame = gameLibraryModel.gameById(model.gameId)
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
@@ -295,6 +599,54 @@ QtObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: selectedGameTitle
|
||||||
|
|
||||||
|
text: gamesList.currentItem ? gamesList.currentItem.title : ""
|
||||||
|
opacity: (navigationManager.currentItem === gamesList.currentItem && !gamesList.moving) ? 1 : 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: 300
|
||||||
|
easing.type: Easing.OutCubic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
color: "white"
|
||||||
|
font.pixelSize: 18
|
||||||
|
|
||||||
|
elide: Text.ElideRight
|
||||||
|
horizontalAlignment: Text. AlignHCenter
|
||||||
|
|
||||||
|
width: Math.min(implicitWidth, bottomRoot.width - 32)
|
||||||
|
|
||||||
|
x: { // we wanna make sure that it centers above the game, except when it's at rest and it's close enough to the edge of the screen that it wont fit
|
||||||
|
if (!gamesList.currentItem) {
|
||||||
|
return 16
|
||||||
|
}
|
||||||
|
|
||||||
|
let cardCenter = gamesList.x + gamesList.currentItem.x - gamesList.contentX + gamesList.currentItem.width / 2
|
||||||
|
|
||||||
|
let desiredX = cardCenter - width / 2
|
||||||
|
|
||||||
|
let minimumX = 16
|
||||||
|
let maximumX = bottomRoot.width - width - 16
|
||||||
|
return Math.max(minimumX, Math.min(desiredX, maximumX))
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on x {
|
||||||
|
enabled: !gamesList.moving && !scrollAnimation.running
|
||||||
|
|
||||||
|
NumberAnimation {
|
||||||
|
duration: 100
|
||||||
|
easing.type: Easing.OutCubic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
anchors.top: gamesList.bottom
|
||||||
|
anchors.topMargin: 10
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#include <QDebug>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include "GameLibraryModel.h"
|
||||||
|
|
||||||
|
GameLibraryModel::GameLibraryModel(GameRepository &repository, QObject *parent) : QAbstractListModel(parent), m_repository(repository) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
int GameLibraryModel::rowCount(const QModelIndex &parent) const {
|
||||||
|
// our database is flat, so return 0 if it's asking for children
|
||||||
|
if (parent.isValid()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return m_games.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetches artwork based on the game's UUID
|
||||||
|
QUrl GameLibraryModel::getArtworkUrl(const QString &gameId, const QString &artworkType) const {
|
||||||
|
QString dataDirectory = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||||
|
|
||||||
|
QDir artworkDirectory(QDir(dataDirectory).filePath("artwork/" + gameId));
|
||||||
|
|
||||||
|
const QStringList extensions = {
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"webp",
|
||||||
|
"gif"
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const QString &extension : extensions) {
|
||||||
|
QString artworkPath = artworkDirectory.filePath(artworkType + "." + extension);
|
||||||
|
|
||||||
|
if (QFileInfo::exists(artworkPath)) {
|
||||||
|
return QUrl::fromLocalFile(artworkPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index.row() < 0 || index.row() >= m_games.size()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const GameRecord &game = m_games.at(index.row());
|
||||||
|
|
||||||
|
switch (role) {
|
||||||
|
case GameIdRole:
|
||||||
|
return game.id;
|
||||||
|
|
||||||
|
case TitleRole:
|
||||||
|
return game.title;
|
||||||
|
|
||||||
|
case PlatformRole:
|
||||||
|
return game.platform;
|
||||||
|
|
||||||
|
case RunnerRole:
|
||||||
|
return game.runner;
|
||||||
|
|
||||||
|
case SourceRole:
|
||||||
|
return game.source;
|
||||||
|
|
||||||
|
case GridArtworkRole:
|
||||||
|
return getArtworkUrl(game.id, "grid");
|
||||||
|
|
||||||
|
case HeroArtworkRole:
|
||||||
|
return getArtworkUrl(game.id, "hero");
|
||||||
|
|
||||||
|
case LogoArtworkRole:
|
||||||
|
return getArtworkUrl(game.id, "logo");
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<int, QByteArray> GameLibraryModel::roleNames() const {
|
||||||
|
return {
|
||||||
|
{ GameIdRole, "gameId" },
|
||||||
|
{ TitleRole, "title" },
|
||||||
|
{ PlatformRole, "platform" },
|
||||||
|
{ RunnerRole, "runner" },
|
||||||
|
{ SourceRole, "source" },
|
||||||
|
{ GridArtworkRole, "gridArtwork" },
|
||||||
|
{ HeroArtworkRole, "heroArtwork" },
|
||||||
|
{ LogoArtworkRole, "logoArtwork" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload all games from the repository safely
|
||||||
|
bool GameLibraryModel::refresh() {
|
||||||
|
QString error;
|
||||||
|
|
||||||
|
QList<GameRecord> games = m_repository.allGames(&error);
|
||||||
|
|
||||||
|
if (!error.isEmpty()) {
|
||||||
|
qWarning() << "could not load game library:" << error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
beginResetModel();
|
||||||
|
|
||||||
|
m_games = games;
|
||||||
|
|
||||||
|
endResetModel();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QList>
|
||||||
|
#include <QUrl>
|
||||||
|
#include "GameRecord.h"
|
||||||
|
#include "GameRepository.h"
|
||||||
|
|
||||||
|
class GameLibraryModel : public QAbstractListModel {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
// lays out the individual bits of data that this can expose
|
||||||
|
enum Role {
|
||||||
|
GameIdRole = Qt::UserRole + 1,
|
||||||
|
TitleRole,
|
||||||
|
PlatformRole,
|
||||||
|
RunnerRole,
|
||||||
|
SourceRole,
|
||||||
|
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;
|
||||||
|
|
||||||
|
QHash<int, QByteArray> roleNames() const override;
|
||||||
|
|
||||||
|
bool refresh();
|
||||||
|
|
||||||
|
private:
|
||||||
|
GameRepository &m_repository;
|
||||||
|
QList<GameRecord> m_games;
|
||||||
|
QUrl getArtworkUrl(const QString &gameId, const QString &artworkType) const;
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
struct GameRecord
|
||||||
|
{
|
||||||
|
QString id;
|
||||||
|
QString title;
|
||||||
|
QString platform;
|
||||||
|
QString runner;
|
||||||
|
QString source;
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#include <QSqlError>
|
||||||
|
#include <QSqlQuery>
|
||||||
|
#include "GameRepository.h"
|
||||||
|
|
||||||
|
// GameRepository is here to handle queries against the games stored in nomad's library database
|
||||||
|
// We don't wanna let QML touch our database directly, so database logic goes here
|
||||||
|
|
||||||
|
// store a handle to the database connection we got
|
||||||
|
GameRepository::GameRepository(QSqlDatabase database) : m_database(database) {}
|
||||||
|
|
||||||
|
QList<GameRecord> GameRepository::allGames(QString *error) const {
|
||||||
|
// this list will be populated with a bunch of GameRecords for each row of the query
|
||||||
|
QList<GameRecord> games;
|
||||||
|
|
||||||
|
QSqlQuery query(m_database);
|
||||||
|
|
||||||
|
// fetches every game in the library. for now, it's sorted by title's alphabetically.
|
||||||
|
// I'll probably keep it this way and do all UI sorting and organization in-house
|
||||||
|
if (!query.exec(R"(
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
platform,
|
||||||
|
runner,
|
||||||
|
source
|
||||||
|
FROM games
|
||||||
|
ORDER BY title COLLATE NOCASE;
|
||||||
|
)")) {
|
||||||
|
if (error) {
|
||||||
|
*error = query.lastError().text();
|
||||||
|
}
|
||||||
|
|
||||||
|
// return empty list if it fails
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
// step through each row and convert it to a GameRecord, adding it to the list
|
||||||
|
while (query.next()) {
|
||||||
|
GameRecord game;
|
||||||
|
|
||||||
|
// Columns are numbered in the same order as the SELECT statement above, starting at 0
|
||||||
|
game.id = query.value(0).toString();
|
||||||
|
game.title = query.value(1).toString();
|
||||||
|
game.platform = query.value(2).toString();
|
||||||
|
game.runner = query.value(3).toString();
|
||||||
|
game.source = query.value(4).toString();
|
||||||
|
|
||||||
|
games.append(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QList>
|
||||||
|
#include <QSqlDatabase>
|
||||||
|
#include <QString>
|
||||||
|
#include "GameRecord.h"
|
||||||
|
|
||||||
|
class GameRepository {
|
||||||
|
public:
|
||||||
|
explicit GameRepository(QSqlDatabase database);
|
||||||
|
|
||||||
|
QList<GameRecord> allGames(QString *error = nullptr) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QSqlDatabase m_database;
|
||||||
|
};
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
#include <QDir>
|
||||||
|
#include <QSqlError>
|
||||||
|
#include <QSqlQuery>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QVariant>
|
||||||
|
#include "LibraryDatabase.h"
|
||||||
|
|
||||||
|
LibraryDatabase::~LibraryDatabase() {
|
||||||
|
// close our database when LibraryDatabase is destroyed
|
||||||
|
if (m_database.isOpen()) {
|
||||||
|
m_database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool LibraryDatabase::open() {
|
||||||
|
// ask Qt for whatever platform-specific appdata directory we've got access to
|
||||||
|
QString dataDirectory = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||||
|
|
||||||
|
if (dataDirectory.isEmpty()) {
|
||||||
|
m_lastError = "could not find application directory";
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QDir directory;
|
||||||
|
|
||||||
|
// ensure the directory exists before trying to open the database
|
||||||
|
if (!directory.mkpath(dataDirectory)) {
|
||||||
|
m_lastError = "could not create application data directory: " + dataDirectory;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// just put the database at the root of our appdata directory
|
||||||
|
m_databasePath = dataDirectory + "/library.db";
|
||||||
|
|
||||||
|
// create a named Qt SQL connection with SQLITE
|
||||||
|
m_database = QSqlDatabase::addDatabase("QSQLITE", "nomad-library");
|
||||||
|
|
||||||
|
// tell the SQLite connection where it's database is
|
||||||
|
m_database.setDatabaseName(m_databasePath);
|
||||||
|
|
||||||
|
if (!m_database.open()) {
|
||||||
|
m_lastError = "could not open library database: " + m_database.lastError().text();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure we've got a schema that this version of nomad understands
|
||||||
|
if (!initializeSchema()) {
|
||||||
|
m_database.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool LibraryDatabase::initializeSchema() {
|
||||||
|
// newest database schema this version of nomad knows how to use
|
||||||
|
constexpr int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
// run queries against nomad's database connection
|
||||||
|
QSqlQuery query(m_database);
|
||||||
|
|
||||||
|
// SQLite's user_version is an application-controlled integer. we'll use it to
|
||||||
|
// keep track of which version of our schema the database is currently on
|
||||||
|
if (!query.exec("PRAGMA user_version;")) {
|
||||||
|
m_lastError = "could not read database schema version: " + query.lastError().text();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// move to the first row returned by the query to read it's value, and set it if it's
|
||||||
|
// read correctly
|
||||||
|
if (!query.next()) {
|
||||||
|
m_lastError = "database didn't return a schema version.";
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int schemaVersion =
|
||||||
|
query.value(0).toInt();
|
||||||
|
|
||||||
|
|
||||||
|
// SQLite inits user_version to 0, so version 0 means this database hasn't had the
|
||||||
|
// nomad initial schema created
|
||||||
|
if (schemaVersion == 0) {
|
||||||
|
// make schema creation atomic; if any of it fails, make sure the database is unchanged.
|
||||||
|
if (!m_database.transaction()) {
|
||||||
|
m_lastError = "could not begin database initialization transaction.";
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial games table
|
||||||
|
if (!query.exec(R"(
|
||||||
|
CREATE TABLE games (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
runner TEXT,
|
||||||
|
source TEXT NOT NULL
|
||||||
|
);
|
||||||
|
)")) {
|
||||||
|
m_lastError = "couldn't create games table: " + query.lastError().text();
|
||||||
|
|
||||||
|
m_database.rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// schema creation successful, attempt to mark this database as version 1
|
||||||
|
if (!query.exec("PRAGMA user_version = 1;")) {
|
||||||
|
m_lastError = "could not set database schema version: " + query.lastError().text();
|
||||||
|
|
||||||
|
m_database.rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// permanently apply this transaction, or attempt to
|
||||||
|
if (!m_database.commit()) {
|
||||||
|
m_lastError = "could not commit database initialization.";
|
||||||
|
|
||||||
|
m_database.rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
schemaVersion = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't attempt to use a database if it's created with a newer version.
|
||||||
|
if (schemaVersion > CurrentSchemaVersion) {
|
||||||
|
m_lastError = "library database schema is newer than this version of nomad supports.";
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSqlDatabase LibraryDatabase::database() const {
|
||||||
|
return m_database;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool LibraryDatabase::isOpen() const {
|
||||||
|
return m_database.isOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
QString LibraryDatabase::databasePath() const {
|
||||||
|
return m_databasePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
QString LibraryDatabase::lastError() const {
|
||||||
|
return m_lastError;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QSqlDatabase>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class LibraryDatabase {
|
||||||
|
public:
|
||||||
|
LibraryDatabase() = default;
|
||||||
|
~LibraryDatabase();
|
||||||
|
|
||||||
|
bool open();
|
||||||
|
bool isOpen() const;
|
||||||
|
|
||||||
|
QSqlDatabase database() const;
|
||||||
|
QString databasePath() const;
|
||||||
|
QString lastError() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool initializeSchema();
|
||||||
|
|
||||||
|
QSqlDatabase m_database;
|
||||||
|
QString m_databasePath;
|
||||||
|
QString m_lastError;
|
||||||
|
};
|
||||||
@@ -2,13 +2,35 @@
|
|||||||
#include <QQmlApplicationEngine>
|
#include <QQmlApplicationEngine>
|
||||||
#include <QQmlContext>
|
#include <QQmlContext>
|
||||||
#include <QtQml>
|
#include <QtQml>
|
||||||
|
#include <QDebug>
|
||||||
#include "input/InputManager.h"
|
#include "input/InputManager.h"
|
||||||
|
#include "library/LibraryDatabase.h"
|
||||||
|
#include "library/GameRepository.h"
|
||||||
|
#include "library/GameLibraryModel.h"
|
||||||
|
|
||||||
int main(int argc, char *argv[])
|
int main(int argc, char *argv[])
|
||||||
{
|
{
|
||||||
// creates our Qt GUI app
|
// creates our Qt GUI app
|
||||||
QGuiApplication app(argc, argv);
|
QGuiApplication app(argc, argv);
|
||||||
|
|
||||||
|
// opens nomad's persistent library database, ensures ready schema, etc
|
||||||
|
LibraryDatabase libraryDatabase;
|
||||||
|
|
||||||
|
if (!libraryDatabase.open()) {
|
||||||
|
qCritical() << "Failed to open nomad library:" << libraryDatabase.lastError();
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameRepository hadnles reading our game records from the database, while
|
||||||
|
// GameLibraryModel exposes those records in a QML-friendly list model
|
||||||
|
GameRepository gameRepository(libraryDatabase.database());
|
||||||
|
GameLibraryModel gameLibraryModel(gameRepository);
|
||||||
|
|
||||||
|
// database diagnostics
|
||||||
|
qDebug() << "Library database:" << libraryDatabase.databasePath();
|
||||||
|
qDebug() << "Games loaded:" << gameLibraryModel.rowCount();
|
||||||
|
|
||||||
// creats InputManager and installs it as an application-wide event filter so it can observe input events before normal delivery
|
// creats InputManager and installs it as an application-wide event filter so it can observe input events before normal delivery
|
||||||
InputManager inputManager;
|
InputManager inputManager;
|
||||||
app.installEventFilter(&inputManager);
|
app.installEventFilter(&inputManager);
|
||||||
@@ -22,6 +44,7 @@ int main(int argc, char *argv[])
|
|||||||
QQmlApplicationEngine engine;
|
QQmlApplicationEngine engine;
|
||||||
// make our InputManager instance available to QML for signals
|
// make our InputManager instance available to QML for signals
|
||||||
engine.rootContext()->setContextProperty("inputManager", &inputManager);
|
engine.rootContext()->setContextProperty("inputManager", &inputManager);
|
||||||
|
engine.rootContext()->setContextProperty("gameLibraryModel", &gameLibraryModel);
|
||||||
|
|
||||||
// load our Main.qml into the engine to start things off
|
// load our Main.qml into the engine to start things off
|
||||||
engine.loadFromModule("nomad", "Main");
|
engine.loadFromModule("nomad", "Main");
|
||||||
|
|||||||
Reference in New Issue
Block a user