diff --git a/docs/screenshots/per-game-quality-custom.png b/docs/screenshots/per-game-quality-custom.png new file mode 100644 index 000000000..0f91c6190 Binary files /dev/null and b/docs/screenshots/per-game-quality-custom.png differ diff --git a/docs/screenshots/per-game-quality-default.png b/docs/screenshots/per-game-quality-default.png new file mode 100644 index 000000000..8c48b2694 Binary files /dev/null and b/docs/screenshots/per-game-quality-default.png differ diff --git a/locales/en.json b/locales/en.json index 2aa8b9d29..76e85d3a4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -329,7 +329,15 @@ "details": "About this game", "notOwned": "Not owned", "playOn": "Play on {{store}}", - "storeOption": "{{store}}, {{ownership}}" + "storeOption": "{{store}}, {{ownership}}", + "streamProfile": { + "title": "Stream quality for this game", + "description": "Automatically use this profile whenever you launch this game.", + "custom": "Custom", + "global": "Global default", + "useCustom": "Use custom quality", + "useCustomHint": "Keep the global stream settings for every other game." + } }, "streamLoading": { "labels": { diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index 9bd8f9a7f..f19d71a8b 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -22,6 +22,7 @@ import { normalizeRecordingBitrateMbps, normalizeRecordingFps, normalizeRecordingResolution, + normalizeGameStreamProfiles, } from "@shared/gfn"; import type { StatsOverlayPosition } from "@shared/gfn"; @@ -303,6 +304,12 @@ export class SettingsManager { migrated = true; } + const gameStreamProfiles = normalizeGameStreamProfiles(settings.gameStreamProfiles); + if (JSON.stringify(settings.gameStreamProfiles) !== JSON.stringify(gameStreamProfiles)) { + settings.gameStreamProfiles = gameStreamProfiles; + migrated = true; + } + if (typeof settings.steamControllerCompatibilityMode !== "boolean") { settings.steamControllerCompatibilityMode = false; migrated = true; @@ -385,7 +392,15 @@ export class SettingsManager { * Get all current settings */ getAll(): Settings { - return { ...this.settings }; + return { + ...this.settings, + gameStreamProfiles: Object.fromEntries( + Object.entries(this.settings.gameStreamProfiles).map(([gameId, profile]) => [ + gameId, + { ...profile }, + ]), + ), + }; } /** diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 4130c209b..d48cc4bc3 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -25,6 +25,7 @@ import { createDefaultSettings, createPlatformShortcutDefaults, resolveEntitledStreamProfile, + resolveGameStreamProfile, resolveRuntimePlatform, SAFE_FALLBACK_STREAM_PROFILE, } from "@shared/gfn"; @@ -688,11 +689,15 @@ export function App(): JSX.Element { }, }); - const buildCurrentStreamSettings = useCallback((subscriptionOverride?: SubscriptionInfo | null): StreamSettings => { + const buildCurrentStreamSettings = useCallback(( + subscriptionOverride?: SubscriptionInfo | null, + gameId?: string | null, + ): StreamSettings => { const currentSubscription = subscriptionOverride === undefined ? subscriptionInfo : subscriptionOverride; + const requestedProfile = resolveGameStreamProfile(settings, gameId); const entitledProfile = resolveEntitledStreamProfile(currentSubscription?.entitledResolutions ?? [], { - resolution: settings.resolution, - fps: settings.fps, + resolution: requestedProfile.resolution, + fps: requestedProfile.fps, }); const streamProfile = entitledProfile ?? SAFE_FALLBACK_STREAM_PROFILE; const codecProfile = resolveStreamProfileCodec( @@ -704,7 +709,7 @@ export function App(): JSX.Element { return { resolution: streamProfile.resolution, fps: streamProfile.fps, - maxBitrateMbps: settings.maxBitrateMbps, + maxBitrateMbps: requestedProfile.maxBitrateMbps, codec: codecProfile.codec, colorQuality: codecProfile.colorQuality, keyboardLayout: settings.keyboardLayout, @@ -730,6 +735,7 @@ export function App(): JSX.Element { settings.enableCloudGsync, settings.enableL4S, settings.fps, + settings.gameStreamProfiles, settings.gameLanguage, settings.keyboardLayout, settings.launchInConsoleMode, @@ -1020,8 +1026,11 @@ export function App(): JSX.Element { toggleRecording: "", }), [shortcuts]); - const buildSignalingConnectRequest = useCallback((activeSession: SessionInfo): SignalingConnectRequest => { - const streamSettings = buildCurrentStreamSettings(); + const buildSignalingConnectRequest = useCallback(( + activeSession: SessionInfo, + gameId?: string | null, + ): SignalingConnectRequest => { + const streamSettings = buildCurrentStreamSettings(undefined, gameId ?? streamingGameRef.current?.id); return { sessionId: activeSession.sessionId, signalingServer: activeSession.signalingServer, @@ -1616,6 +1625,7 @@ export function App(): JSX.Element { const matchedContext = findGameContextForSession(existingSession); if (matchedContext) { + streamingGameRef.current = matchedContext.game; setStreamingGame(matchedContext.game); setStreamingStore(matchedContext.variant?.store ?? null); } else { @@ -1623,7 +1633,7 @@ export function App(): JSX.Element { } const launchSubscription = await resolveSubscriptionInfoForLaunch(); - const streamSettings = buildCurrentStreamSettings(launchSubscription); + const streamSettings = buildCurrentStreamSettings(launchSubscription, matchedContext?.game.id); const claimed = await window.openNow.claimSession({ token, streamingBaseUrl: effectiveStreamingBaseUrl, @@ -1743,8 +1753,15 @@ export function App(): JSX.Element { throw new Error("The running session is missing a server address, so resume was not possible."); } + const matchedContext = findGameContextForSession(candidate); + if (matchedContext) { + streamingGameRef.current = matchedContext.game; + } const recoverySubscription = await resolveSubscriptionInfoForLaunch(); - const recoveryStreamSettings = buildCurrentStreamSettings(recoverySubscription); + const recoveryStreamSettings = buildCurrentStreamSettings( + recoverySubscription, + matchedContext?.game.id ?? streamingGameRef.current?.id, + ); const claimed = await window.openNow.claimSession({ token, streamingBaseUrl: effectiveStreamingBaseUrl, @@ -1762,7 +1779,6 @@ export function App(): JSX.Element { return false; } - const matchedContext = findGameContextForSession(candidate); if (matchedContext) { setStreamingGame(matchedContext.game); setStreamingStore(matchedContext.variant?.store ?? null); @@ -3014,6 +3030,12 @@ export function App(): JSX.Element { onSelectVariant={(variantId) => { if (detailsGame) handleSelectGameVariant(detailsGame.id, variantId); }} + settings={settings} + entitledResolutions={subscriptionInfo?.entitledResolutions ?? []} + onGameStreamProfilesPreview={(profiles) => previewSetting("gameStreamProfiles", profiles)} + onGameStreamProfilesChange={(profiles) => { + void updateSetting("gameStreamProfiles", profiles); + }} onPlay={(game, variantId) => { handleCloseDetails(); void handleInitiatePlay(game, variantId); diff --git a/opennow-stable/src/renderer/src/components/GameDetailModal.tsx b/opennow-stable/src/renderer/src/components/GameDetailModal.tsx index 0ee37e37f..174becf6d 100644 --- a/opennow-stable/src/renderer/src/components/GameDetailModal.tsx +++ b/opennow-stable/src/renderer/src/components/GameDetailModal.tsx @@ -6,12 +6,13 @@ import { useState, type JSX, } from "react"; -import type { GameInfo } from "@shared/gfn"; +import type { EntitledResolution, GameInfo, GameStreamProfiles, Settings } from "@shared/gfn"; import { getControllerHeroBackgroundCandidates, getPlayerSummary } from "../lib/controllerCatalogUi"; import { getActiveStoreOption, getStoreOptions } from "../lib/gameCardStores"; import { useTranslation } from "../i18n"; import { getStoreDisplayName, getStoreIconComponent } from "./GameCard"; import { ModalSurface } from "./ui/ModalSurface"; +import { GameStreamProfileSection } from "./GameStreamProfileSection"; export interface GameDetailModalProps { open: boolean; @@ -21,6 +22,10 @@ export interface GameDetailModalProps { onExitComplete?: () => void; onPlay: (game: GameInfo, variantId?: string) => void; onSelectVariant: (variantId: string) => void; + settings: Settings; + entitledResolutions: EntitledResolution[]; + onGameStreamProfilesPreview: (profiles: GameStreamProfiles) => void; + onGameStreamProfilesChange: (profiles: GameStreamProfiles) => void; } function GameDetailHero({ game }: { game: GameInfo }): JSX.Element { @@ -102,6 +107,10 @@ export function GameDetailModal({ onExitComplete, onPlay, onSelectVariant, + settings, + entitledResolutions, + onGameStreamProfilesPreview, + onGameStreamProfilesChange, }: GameDetailModalProps): JSX.Element { const { t } = useTranslation(); const generatedId = useId(); @@ -235,6 +244,14 @@ export function GameDetailModal({ )} + +