Skip to content
65 changes: 65 additions & 0 deletions src/frontend/screens/ConsoleMode/InstallOverlay/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Install overlay
.consoleInstallOverlay {
position: fixed;
inset: 0;
gap: 16px;
backdrop-filter: blur(10px);
z-index: 10;
align-items: center;
animation: overlayFade 220ms ease;
display: flex;
justify-content: center;
}

.consoleModal {
background: color-mix(
in srgb,
var(--body-background, #101014) 90%,
transparent
);
z-index: 20;
gap: 16px;
flex-direction: column;
padding: var(--space-xl);
border-radius: var(--space-lg);
}

@keyframes overlayFade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@keyframes spin {
to {
transform: rotate(360deg);
}
}

.consoleModalTitle {
font-size: 1rem;
color: var(--text-secondary, #b1b1b1);
text-transform: uppercase;
letter-spacing: 0.2em;
margin-bottom: var(--space-md);
}

.consoleModalGameTitle {
font-size: clamp(1.6rem, 2.4vw, 2.4rem);
font-weight: 700;
color: var(--text-title, var(--text-default, #eae8e5));
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
max-width: 80vw;
text-align: center;
}

.consoleInstallButtons {
display: flex;
flex-direction: row;
justify-content: center;
gap: 1rem;
margin-top: var(--space-lg);
}
83 changes: 83 additions & 0 deletions src/frontend/screens/ConsoleMode/InstallOverlay/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useTranslation } from 'react-i18next'

import './index.scss'

import type { GameInfo } from 'common/types'
import { useEffect, useRef } from 'react'
import { install } from 'frontend/helpers'
import { hasProgress } from 'frontend/hooks/hasProgress'

export default function InstallOverlay({
game,
onDismiss
}: {
game: GameInfo
onDismiss: () => void
}) {
const { t } = useTranslation()
const [progress] = hasProgress(game.app_name, game.runner)
const installButtonRef = useRef<HTMLButtonElement | null>(null)

const label: string | null = null

const onOverlayKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onDismiss()
return
}
}

useEffect(() => {
// focus action button
installButtonRef?.current?.focus()

window.addEventListener('keydown', onOverlayKeyDown)
return () => {
window.removeEventListener('keydown', onOverlayKeyDown)
}
}, [])

const installGame = () => {
try {
install({
gameInfo: game,
previousProgress: null,
progress: progress,
installPath: 'default',
isInstalling: false,
t,
showDialogModal: () => null
})

// we're now installing, close modal
onDismiss()
} catch {}

Check failure on line 54 in src/frontend/screens/ConsoleMode/InstallOverlay/index.tsx

View workflow job for this annotation

GitHub Actions / lint

Empty block statement
}

return (
<div className="consoleInstallOverlay" role="status" aria-live="polite">
<div className="consoleModal">
<div className="consoleModalTitle">
{label || t('status.installing', 'Installing')}
</div>
<div className="consoleModalGameTitle">{game.title}</div>

{/* list runner to be used, use InstallModal in the future */}

{/* Confirm or Cancel buttons */}
<div className="consoleInstallButtons">
<button className="consoleChip" onClick={onDismiss}>
{t('button.cancel', 'Cancel')}
</button>
<button
ref={installButtonRef}
className="consoleChip"
onClick={installGame}
>
{t('generic.install', 'Install')}
</button>
</div>
</div>
</div>
)
}
10 changes: 6 additions & 4 deletions src/frontend/screens/ConsoleMode/components/BackHint/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import classNames from 'classnames'
import { useGamepadInfo } from '../../hooks'
import { getBackButtonLabel } from '../../controller'

export default function BackHint({
prefix,
suffix,
gamepadConnected,
backButtonLabel,
active
}: {
prefix: string
suffix: string
gamepadConnected: boolean
backButtonLabel: string
active?: boolean
}) {
const { connected: gamepadConnected, layout: controllerLayout } =
useGamepadInfo()
const backButtonLabel = getBackButtonLabel(controllerLayout)

return (
<div className={classNames('consoleLaunchHint', { active })}>
{prefix} <kbd>{gamepadConnected ? backButtonLabel : 'Esc'}</kbd> {suffix}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Launch overlay
.consoleLaunchOverlay {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: color-mix(
in srgb,
var(--body-background, #101014) 70%,
transparent
);
backdrop-filter: blur(10px);
z-index: 10;
animation: overlayFade 220ms ease;
}

@keyframes overlayFade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

.consoleLaunchSpinner {
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
border: 4px solid
color-mix(in srgb, var(--text-default, #eae8e5) 20%, transparent);
border-top-color: var(--accent, #0080ff);
animation: spin 900ms linear infinite;

&.idle {
border-top-color: var(--status-success, #2bbd60);
animation-duration: 2.4s;
}
}

@keyframes spin {
to {
transform: rotate(360deg);
}
}

.consoleLaunchText {
font-size: 1rem;
color: var(--text-secondary, #b1b1b1);
text-transform: uppercase;
letter-spacing: 0.2em;
}

.consoleLaunchGameTitle {
font-size: clamp(1.6rem, 2.4vw, 2.4rem);
font-weight: 700;
color: var(--text-title, var(--text-default, #eae8e5));
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
max-width: 80vw;
text-align: center;
}

.consoleLaunchHint {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 12px;
font-size: 0.85rem;
color: var(--text-secondary, #b1b1b1);
opacity: 0.75;
transition:
opacity 180ms ease,
color 180ms ease;

kbd {
font-family: var(--primary-font-family);
font-size: 0.8rem;
font-weight: 700;
padding: 3px 8px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, currentColor 40%, transparent);
background: color-mix(in srgb, currentColor 10%, transparent);
}

&.active {
opacity: 1;
color: var(--cancel-button, #b94a3a);
}
}
86 changes: 75 additions & 11 deletions src/frontend/screens/ConsoleMode/components/LaunchOverlay/index.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,87 @@
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'

import './index.scss'

import { hasStatus } from 'frontend/hooks/hasStatus'

import BackHint from '../BackHint'

import type { GameInfo } from 'common/types'
import type { GameInfo, Runner } from 'common/types'
import { useContext, useEffect } from 'react'
import { useCancelOnHold, useGamepadButtonHold } from '../../hooks'
import { BTN_BACK } from '../../controller'
import { launch, sendKill } from 'frontend/helpers'
import ContextProvider from 'frontend/state/ContextProvider'

const CANCEL_HOLD_MS = 3000

export default function LaunchOverlay({
game,
holdStart,
gamepadConnected,
backButtonLabel
onDismiss
}: {
game: GameInfo
holdStart: number | null
gamepadConnected: boolean
backButtonLabel: string
onDismiss: () => void
}) {
const { t } = useTranslation()
const { status, statusContext } = hasStatus(game)

let label: string | null = null

const { showDialogModal } = useContext(ContextProvider)

// Hold-to-cancel for in-flight launches. Triggered by Escape (keyboard) or
// the back button (gamepad); fires `sendKill` after CANCEL_HOLD_MS.
const { holdStart, startHold, stopHold } = useCancelOnHold({
active: !!game,
holdMs: CANCEL_HOLD_MS,
onCancel: () => {
if (game) void sendKill(game.app_name, game.runner)

// prevent UX from hanging in "Launching" mode
onDismiss()
}
})

const onOverlayKeyDown = (e: React.KeyboardEvent) => {}

Check failure on line 45 in src/frontend/screens/ConsoleMode/components/LaunchOverlay/index.tsx

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used
const onOverlayKeyUp = (e: React.KeyboardEvent) => {}

Check failure on line 46 in src/frontend/screens/ConsoleMode/components/LaunchOverlay/index.tsx

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used

// Escape quits when idle; hold it while launching to cancel.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.preventDefault()

if (!e.repeat) startHold()
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Escape') stopHold()
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [startHold, stopHold])

useEffect(() => {
launch({
appName: game.app_name,
t,
runner: game.runner as Runner,
hasUpdate: false,
showDialogModal
}).finally(() => {
onDismiss()
})
}, [])

useGamepadButtonHold(
BTN_BACK,
(held) => (held ? startHold() : stopHold()),
!!game
)

switch (status) {
case 'syncing-saves':
label = t('gamepage:status.syncingSaves', 'Syncing Saves')
Expand All @@ -45,7 +105,13 @@
}

return (
<div className="consoleLaunchOverlay" role="status" aria-live="polite">
<div
onKeyDown={onOverlayKeyDown}
onKeyUp={onOverlayKeyUp}
className="consoleLaunchOverlay"
role="status"
aria-live="polite"
>
<div
className={classNames('consoleLaunchSpinner', {
idle: status === 'playing'
Expand All @@ -58,8 +124,6 @@
<BackHint
prefix={t('console.cancel.hintPrefix', 'Hold')}
suffix={t('console.cancel.hintSuffix', 'for 3s to cancel')}
gamepadConnected={gamepadConnected}
backButtonLabel={backButtonLabel}
active={holdStart != null}
/>
</div>
Expand Down
Loading
Loading