652 lines
23 KiB
QML
652 lines
23 KiB
QML
import QtQuick
|
|
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 {
|
|
width: 960 // this resolution size is for development. I'd like to have a cleaner way to develop for 1080p without getting a dedicated monitor for testing nomad on my dev computer
|
|
height: 540
|
|
visible: true
|
|
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.
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: "black"
|
|
|
|
Item {
|
|
id: heroArea
|
|
|
|
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()
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
property Window bottomWindow: Window {
|
|
width: 620 // again, half-resolution
|
|
height: 540
|
|
visible: true
|
|
title: "nomad-bottom" // window titling for Sway auto-assign
|
|
|
|
NavigationManager {
|
|
id: navigationManager
|
|
rootItem: bottomRoot
|
|
}
|
|
|
|
Connections {
|
|
target: inputManager
|
|
|
|
// you don't wanna know what this used to look like :dread:
|
|
// just checks if our navigation is directional, and, if so, moves the selection in that direction
|
|
function onActionPressed(action) {
|
|
switch (action) {
|
|
case InputManager.Up:
|
|
case InputManager.Down:
|
|
case InputManager.Left:
|
|
case InputManager.Right:
|
|
navigationManager.move(action)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
Rectangle {
|
|
id: bottomRoot
|
|
anchors.fill: parent
|
|
color: "#181818"
|
|
|
|
// 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
|
|
ListView {
|
|
id: gamesList
|
|
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
// these properties are primarily for the gamesList snapping
|
|
readonly property int cardWidth: 120
|
|
readonly property int cardSpacing: 16
|
|
readonly property int cardPitch: cardWidth + cardSpacing
|
|
readonly property int safeLeft: 90
|
|
readonly property int safeRight: width - 90
|
|
readonly property int edgePeek: 98
|
|
readonly property int snapOffset: cardWidth - edgePeek
|
|
|
|
property real scrollTarget: contentX
|
|
|
|
height: 200
|
|
orientation: ListView.Horizontal
|
|
spacing: 16
|
|
leftMargin: 16
|
|
rightMargin: 16
|
|
|
|
model: gameLibraryModel
|
|
|
|
// provides the smoothing animation for game selection
|
|
// TODO: tune this to make it as nice as possible !!!
|
|
NumberAnimation {
|
|
id: scrollAnimation
|
|
target: gamesList
|
|
property: "contentX"
|
|
duration: 300
|
|
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
|
|
function scrollTo(targetX) {
|
|
if (Math.abs(targetX - gamesList.contentX) < 0.5) {
|
|
return
|
|
}
|
|
|
|
scrollAnimation.stop()
|
|
scrollAnimation.from = gamesList.contentX
|
|
scrollAnimation.to = targetX
|
|
scrollAnimation.start()
|
|
}
|
|
|
|
// provides snapping logic so that the games list is snapped to a card pitch
|
|
function snappedContentX(snapTargetX) {
|
|
// set up a relative coord for our snapping logic, so we line up right
|
|
let relativeTarget = snapTargetX - gamesList.originX
|
|
|
|
// finds the nearest point on the snap grid
|
|
// it's pretty obtuse, so as an explanation: we subtract snapOffset first so the grid becomes a
|
|
// simple 0, cardPitch, 2*cardPitch, etc grid. Divide by cardPitch to get the nearest grid index,
|
|
// round it, then multiply by cardPitch to convert that index back to a position. Finally we
|
|
// wanna add snapOffset (and originX) back to line it up with our true grid.
|
|
let snappedTargetX = gamesList.originX + Math.round((relativeTarget - gamesList.snapOffset) / gamesList.cardPitch) * gamesList.cardPitch + gamesList.snapOffset
|
|
|
|
// calculate the furthest possible ends of the carousel
|
|
// Math.max is for clamping it to make sure even if there are very few games on the board that it
|
|
// wont return something stupid
|
|
let minimum = gamesList.originX - gamesList.leftMargin
|
|
let maximum = gamesList.originX + Math.max(0, gamesList.contentWidth + gamesList.leftMargin + gamesList.rightMargin - gamesList.width) - gamesList.leftMargin
|
|
|
|
// this section of code is here to make sure that when the game carousel is flicked all the way
|
|
// to the leftmost or rightmost ends, it doesn't snap and cutoff the leftmost or rightmost
|
|
// card in a way that feels janky. it works by calculating the distance from the current target
|
|
// to the very edges, and if it's closer to one of the edges, just snap to the edge.
|
|
let bestTarget = snappedTargetX
|
|
let bestDistance = Math.abs(snapTargetX - snappedTargetX)
|
|
|
|
let distanceToMinimum = Math.abs(snapTargetX - minimum)
|
|
|
|
if (distanceToMinimum < bestDistance) {
|
|
bestTarget = minimum
|
|
bestDistance = distanceToMinimum
|
|
}
|
|
|
|
let distanceToMaximum = Math.abs(snapTargetX - maximum)
|
|
|
|
if (distanceToMaximum < bestDistance) {
|
|
bestTarget = maximum
|
|
}
|
|
|
|
return Math.max(minimum, Math.min(bestTarget, maximum))
|
|
}
|
|
|
|
// this is a function to make sure the games list scrolls, but in a predictable, nice-feeling way.
|
|
// specifically, it scrolls but makes sure the games list is snapped to a specific card pitch.
|
|
function ensureCurrentInSafeZone() {
|
|
if (!gamesList.currentItem) {
|
|
return
|
|
}
|
|
|
|
let item = gamesList.currentItem
|
|
|
|
let itemLeft = item.x - gamesList.contentX
|
|
let itemRight = itemLeft + item.width
|
|
|
|
let targetContentX = gamesList.contentX
|
|
|
|
if (itemLeft < gamesList.safeLeft) {
|
|
targetContentX -= gamesList.safeLeft - itemLeft
|
|
} else if (itemRight > gamesList.safeRight) {
|
|
targetContentX += itemRight - gamesList.safeRight
|
|
} else {
|
|
return
|
|
}
|
|
|
|
gamesList.scrollTo(gamesList.snappedContentX(targetContentX))
|
|
}
|
|
|
|
Component.onCompleted: { // on startup, this should select the first game in the list.
|
|
if (currentItem) {
|
|
navigationManager.focusItem(currentItem)
|
|
}
|
|
}
|
|
|
|
// every time the index changes, we wanna re-calculate the game carousel's position
|
|
onCurrentIndexChanged: {
|
|
ensureCurrentInSafeZone()
|
|
}
|
|
|
|
// after flicking, we re-snap the carousel wherever it landed
|
|
onMovementEnded: {
|
|
gamesList.scrollTo(gamesList.snappedContentX(gamesList.contentX))
|
|
gamesList.selectCentermostGame()
|
|
}
|
|
|
|
delegate: GameCard {
|
|
id: gameTile
|
|
|
|
title: model.title
|
|
artwork: model.gridArtwork
|
|
|
|
highlighted: navigationManager.currentItem === gameTile && !gamesList.moving
|
|
|
|
function onNavigationFocused() {
|
|
gamesList.currentIndex = index
|
|
root.selectedGame = gameLibraryModel.gameById(model.gameId)
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
navigationManager.registerItem(gameTile)
|
|
}
|
|
|
|
Component.onDestruction: {
|
|
navigationManager.unregisterItem(gameTile)
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: {
|
|
navigationManager.focusItem(gameTile)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Row {
|
|
id: bottomMenu
|
|
|
|
anchors.bottom: parent.bottom
|
|
anchors.bottomMargin: 30
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
|
|
spacing: 16
|
|
|
|
Repeater {
|
|
id: dockRepeater
|
|
model: 5
|
|
|
|
Rectangle {
|
|
id: dockButton
|
|
|
|
width: 60
|
|
height: 60
|
|
|
|
color: navigationManager.currentItem === dockButton ? "blue" : "white"
|
|
|
|
function onNavigationFocused() {
|
|
// there used to be code here when my navigation was worse. I bet I'll need it eventually tho
|
|
// this entire section is a placeholder dock button anyway though.
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
navigationManager.registerItem(dockButton)
|
|
}
|
|
|
|
Component.onDestruction: {
|
|
navigationManager.unregisterItem(dockButton)
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
|
|
onClicked: {
|
|
navigationManager.focusItem(dockButton)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
} |