diff --git a/opennow-stable/src/main/gfn/auth.ts b/opennow-stable/src/main/gfn/auth.ts index d400145..03f6331 100644 --- a/opennow-stable/src/main/gfn/auth.ts +++ b/opennow-stable/src/main/gfn/auth.ts @@ -369,6 +369,12 @@ function mergeTokenSnapshot(base: AuthTokens, refreshed: TokenResponse): AuthTok }; } +function gravatarUrl(email: string, size = 80): string { + const normalized = email.trim().toLowerCase(); + const hash = createHash("md5").update(normalized).digest("hex"); + return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon`; +} + async function fetchUserInfo(tokens: AuthTokens): Promise { const jwtToken = tokens.idToken ?? tokens.accessToken; const parsed = parseJwtPayload<{ @@ -380,13 +386,18 @@ async function fetchUserInfo(tokens: AuthTokens): Promise { }>(jwtToken); if (parsed?.sub) { - return { - userId: parsed.sub, - displayName: parsed.preferred_username ?? parsed.email?.split("@")[0] ?? "User", - email: parsed.email, - avatarUrl: parsed.picture, - membershipTier: parsed.gfn_tier ?? "FREE", - }; + const emailFromToken = parsed.email; + const pictureFromToken = parsed.picture; + if (emailFromToken || pictureFromToken) { + const avatar = pictureFromToken ?? (emailFromToken ? gravatarUrl(emailFromToken) : undefined); + return { + userId: parsed.sub, + displayName: parsed.preferred_username ?? emailFromToken?.split("@")[0] ?? "User", + email: emailFromToken, + avatarUrl: avatar, + membershipTier: parsed.gfn_tier ?? "FREE", + }; + } } const response = await fetch(USERINFO_ENDPOINT, { @@ -409,11 +420,14 @@ async function fetchUserInfo(tokens: AuthTokens): Promise { picture?: string; }; + const email = payload.email; + const avatar = payload.picture ?? (email ? gravatarUrl(email) : undefined); + return { userId: payload.sub, - displayName: payload.preferred_username ?? payload.email?.split("@")[0] ?? "User", - email: payload.email, - avatarUrl: payload.picture, + displayName: payload.preferred_username ?? email?.split("@")[0] ?? "User", + email, + avatarUrl: avatar, membershipTier: "FREE", }; } @@ -623,6 +637,7 @@ export class AuthService { const initialTokens = await exchangeAuthorizationCode(code, verifier, port); const user = await fetchUserInfo(initialTokens); + console.debug("auth: fetched user info during login", { userId: user.userId, email: user.email, avatarUrl: user.avatarUrl }); let tokens = initialTokens; try { tokens = await this.ensureClientToken(initialTokens, user.userId); @@ -851,6 +866,7 @@ export class AuthService { let user = this.session?.user; try { user = await fetchUserInfo(refreshedTokens); + console.debug("auth: fetched user info on token refresh", { userId: user.userId, email: user.email, avatarUrl: user.avatarUrl }); } catch (error) { console.warn("Token refresh succeeded but user info refresh failed. Keeping cached user:", error); } diff --git a/opennow-stable/src/main/gfn/cloudmatch.ts b/opennow-stable/src/main/gfn/cloudmatch.ts index bc6fb90..54a925b 100644 --- a/opennow-stable/src/main/gfn/cloudmatch.ts +++ b/opennow-stable/src/main/gfn/cloudmatch.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import dns from "node:dns"; import type { ActiveSessionInfo, @@ -23,7 +24,36 @@ const GFN_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 NVIDIACEFClient/HEAD/debb5919f6 GFN-PC/2.0.80.173"; const GFN_CLIENT_VERSION = "2.0.80.173"; -function normalizeIceServers(response: CloudMatchResponse): IceServer[] { +async function resolveHostnameWithFallback(hostname: string): Promise { + // Try system resolver first, then fall back to Cloudflare (1.1.1.1) and Google (8.8.8.8) + try { + const r = await dns.promises.lookup(hostname); + if (r && (r as any).address) return (r as any).address; + } catch { + // ignore and try custom resolvers + } + + const fallbackServers = ["1.1.1.1", "8.8.8.8"]; + for (const server of fallbackServers) { + try { + const resolver = new dns.Resolver(); + resolver.setServers([server]); + const addrs: string[] = await new Promise((resolve, reject) => { + resolver.resolve4(hostname, (err, addresses) => { + if (err) reject(err); + else resolve(addresses); + }); + }); + if (addrs && addrs.length > 0) return addrs[0]; + } catch { + // try next fallback + } + } + + return null; +} + +async function normalizeIceServers(response: CloudMatchResponse): Promise { const raw = response.session.iceServerConfiguration?.iceServers ?? []; const servers = raw .map((entry) => { @@ -37,13 +67,67 @@ function normalizeIceServers(response: CloudMatchResponse): IceServer[] { .filter((entry) => entry.urls.length > 0); if (servers.length > 0) { - return servers; + // Attempt to resolve any hostnames in STUN/TURN URLs to IPs to avoid relying on the + // renderer's DNS resolution. This makes it possible to try alternate DNS servers + // when the system resolver fails. + const resolvedServers: IceServer[] = []; + for (const s of servers) { + const resolvedUrls: string[] = []; + for (const u of s.urls) { + try { + const m = u.match(/^([a-zA-Z0-9+.-]+):([^/]+)/); + if (m) { + const scheme = m[1]; + const hostPort = m[2]; + const host = hostPort.split(":")[0]; + const portPart = hostPort.includes(":") ? ":" + hostPort.split(":").slice(1).join(":") : ""; + + // Helper to bracket IPv6 literals when necessary + const bracketIfIpv6 = (h: string) => { + if (h.startsWith("[") && h.endsWith("]")) return h; + // Heuristic: contains ':' and is not an IPv4 dotted-quad + if (h.includes(":") && !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(h)) { + return `[${h}]`; + } + return h; + }; + + // If host already looks like an IPv4 or bracketed IPv6, keep original URL + if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || /^\[[0-9a-fA-F:]+\]$/.test(host)) { + resolvedUrls.push(u); + } else { + const ip = await resolveHostnameWithFallback(host); + const finalHost = ip ?? host; + const maybeBracketted = bracketIfIpv6(finalHost); + resolvedUrls.push(`${scheme}:${maybeBracketted}${portPart}`); + } + } else { + resolvedUrls.push(u); + } + } catch { + resolvedUrls.push(u); + } + } + resolvedServers.push({ urls: resolvedUrls, username: s.username, credential: s.credential }); + } + + return resolvedServers; + } + + // Default fallbacks — try to resolve known STUN hostnames to IPs as well + const defaults = ["s1.stun.gamestream.nvidia.com:19308", "stun.l.google.com:19302", "stun1.l.google.com:19302"]; + const out: IceServer[] = []; + for (const d of defaults) { + const parts = d.split(":"); + const host = parts[0]; + const port = parts.length > 1 ? `:${parts.slice(1).join(":")}` : ""; + const ip = await resolveHostnameWithFallback(host); + const bracketIfIpv6 = (h: string) => (h.includes(":") && !h.startsWith("[") ? `[${h}]` : h); + if (ip) out.push({ urls: [`stun:${bracketIfIpv6(ip)}${port}`] }); + else out.push({ urls: [`stun:${bracketIfIpv6(host)}${port}`] }); } - return [ - { urls: ["stun:s1.stun.gamestream.nvidia.com:19308"] }, - { urls: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"] }, - ]; + return out; } /** @@ -498,7 +582,7 @@ function extractSeatSetupStep(payload: CloudMatchResponse): number | undefined { return undefined; } -function toSessionInfo(zone: string, streamingBaseUrl: string, payload: CloudMatchResponse): SessionInfo { +async function toSessionInfo(zone: string, streamingBaseUrl: string, payload: CloudMatchResponse): Promise { if (payload.requestStatus.statusCode !== 1) { // Use SessionError for parsing error responses const errorJson = JSON.stringify(payload); @@ -538,7 +622,7 @@ function toSessionInfo(zone: string, streamingBaseUrl: string, payload: CloudMat signalingServer: signaling.signalingServer, signalingUrl: signaling.signalingUrl, gpuType: payload.session.gpuType, - iceServers: normalizeIceServers(payload), + iceServers: await normalizeIceServers(payload), mediaConnectionInfo: signaling.mediaConnectionInfo, }; } @@ -570,7 +654,7 @@ export async function createSession(input: SessionCreateRequest): Promise { @@ -623,7 +707,7 @@ export async function pollSession(input: SessionPollRequest): Promise { @@ -719,21 +803,23 @@ export async function getActiveSessions( // Extract appId from sessionRequestData const appId = s.sessionRequestData?.appId ? Number(s.sessionRequestData.appId) : 0; - // Get server IP from sessionControlInfo - const serverIp = s.sessionControlInfo?.ip; - - // Build signaling URL from connection info + // Prefer the real server IP from connectionInfo[usage=14] — this is the actual game server, + // not the zone load balancer. sessionControlInfo.ip is the zone LB hostname and cannot + // accept claim (PUT) requests, which causes HTTP 400. const connInfo = s.connectionInfo?.find((conn) => conn.usage === 14 && conn.ip); - const connIp = connInfo?.ip; - const signalingUrl = Array.isArray(connIp) - ? connIp.map((ip: string) => `wss://${ip}:443/nvst/`) - : typeof connIp === "string" - ? [`wss://${connIp}:443/nvst/`] - : Array.isArray(serverIp) - ? serverIp.map((ip: string) => `wss://${ip}:443/nvst/`) - : typeof serverIp === "string" - ? [`wss://${serverIp}:443/nvst/`] - : undefined; + const rawConnIp = connInfo?.ip as string | string[] | undefined; + const connIp = Array.isArray(rawConnIp) ? rawConnIp[0] : rawConnIp; + + const rawControlIp = s.sessionControlInfo?.ip as string | string[] | undefined; + const controlIp = Array.isArray(rawControlIp) ? rawControlIp[0] : rawControlIp; + + const serverIp = connIp ?? controlIp; + + const signalingUrl = connIp + ? `wss://${connIp}:443/nvst/` + : controlIp + ? `wss://${controlIp}:443/nvst/` + : undefined; // Extract resolution and fps from monitor settings const monitorSettings = s.monitorSettings?.[0]; @@ -748,7 +834,7 @@ export async function getActiveSessions( gpuType: s.gpuType, status: s.status, serverIp, - signalingUrl: Array.isArray(signalingUrl) ? signalingUrl[0] : signalingUrl, + signalingUrl, resolution, fps, }; @@ -761,37 +847,22 @@ export async function getActiveSessions( * Build claim/resume request payload */ function buildClaimRequestBody(sessionId: string, appId: string, settings: StreamSettings): unknown { - const { width, height } = parseResolution(settings.resolution); - const cq = settings.colorQuality; - const chromaFormat = colorQualityChromaFormat(cq); - // Claim/resume uses SDR mode (matching Rust: hdr_enabled defaults false for claims). - // HDR is only negotiated on the initial session create. - const hdrEnabled = false; + // For RESUME claims, we must NOT attempt to renegotiate streaming parameters. + // The session is already configured on the server side. Sending different fps, resolution, + // codec, etc. causes HTTP 400 from the server because those parameters are immutable for + // an already-streaming session. Only send the action and minimal required fields. const deviceId = crypto.randomUUID(); const subSessionId = crypto.randomUUID(); const timezoneMs = timezoneOffsetMs(); - // Build HDR capabilities if enabled - const hdrCapabilities = hdrEnabled - ? { - version: 1, - hdrEdrSupportedFlagsInUint32: 3, // 1=HDR10, 2=EDR, 3=both - staticMetadataDescriptorId: 0, - displayData: { - maxLuminance: 1000, - minLuminance: 0.01, - maxFrameAverageLuminance: 500, - }, - } - : null; - return { action: 2, data: "RESUME", sessionRequestData: { + // Minimal fields required for resume - NO streaming parameter renegotiation audioMode: 2, remoteControllersBitmap: 0, - sdrHdrMode: hdrEnabled ? 1 : 0, + sdrHdrMode: 0, networkTestSessionId: null, availableSupportedControllers: [], clientVersion: "30.0", @@ -804,50 +875,31 @@ function buildClaimRequestBody(sessionId: string, appId: string, settings: Strea { key: "GSStreamerType", value: "WebRTC" }, { key: "networkType", value: "Unknown" }, { key: "ClientImeSupport", value: "0" }, - { - key: "clientPhysicalResolution", - value: JSON.stringify({ horizontalPixels: width, verticalPixels: height }), - }, - { key: "surroundAudioInfo", value: "2" }, ], surroundAudioInfo: 0, clientTimezoneOffset: timezoneMs, clientIdentification: "GFN-PC", parentSessionId: null, - appId, + appId: parseInt(appId, 10), streamerVersion: 1, - clientRequestMonitorSettings: [ - { - widthInPixels: width, - heightInPixels: height, - framesPerSecond: settings.fps, - sdrHdrMode: hdrEnabled ? 1 : 0, - displayData: { - desiredContentMaxLuminance: hdrEnabled ? 1000 : 0, - desiredContentMinLuminance: 0, - desiredContentMaxFrameAverageLuminance: hdrEnabled ? 500 : 0, - }, - dpi: 0, - }, - ], appLaunchMode: 1, sdkVersion: "1.0", enhancedStreamMode: 1, useOps: true, - clientDisplayHdrCapabilities: hdrCapabilities, + clientDisplayHdrCapabilities: null, accountLinked: true, partnerCustomData: "", enablePersistingInGameSettings: true, secureRTSPSupported: false, userAge: 26, requestedStreamingFeatures: { - reflex: settings.fps >= 120, + reflex: false, bitDepth: 0, cloudGsync: false, enabledL4S: false, profile: 0, fallbackToLogicalResolution: false, - chromaFormat, + chromaFormat: 0, prefilterMode: 0, hudStreamingMode: 0, }, @@ -880,7 +932,69 @@ export async function claimSession(input: SessionClaimRequest): Promise 3 && sessionData.status !== 6) { break; } } diff --git a/opennow-stable/src/main/gfn/games.ts b/opennow-stable/src/main/gfn/games.ts index 4a75322..eed1f21 100644 --- a/opennow-stable/src/main/gfn/games.ts +++ b/opennow-stable/src/main/gfn/games.ts @@ -1,4 +1,5 @@ import type { GameInfo, GameVariant } from "@shared/gfn"; +import { cacheManager } from "../services/cacheManager"; const GRAPHQL_URL = "https://games.geforce.com/graphql"; const PANELS_QUERY_HASH = "f8e26265a5db5c20e1334a6872cf04b6e3970507697f6ae55a6ddefa5420daf0"; @@ -39,6 +40,11 @@ interface AppData { title: string; description?: string; longDescription?: string; + features?: unknown[]; + gameFeatures?: unknown[]; + appFeatures?: unknown[]; + genres?: unknown[]; + tags?: unknown[]; images?: { GAME_BOX_ART?: string; TV_BANNER?: string; @@ -145,7 +151,10 @@ function appToGame(app: AppData): GameInfo { uuid: app.id, launchAppId, title: app.title, - description: app.description ?? app.longDescription, + description: app.description, + longDescription: app.longDescription, + featureLabels: extractFeatureLabels(app), + genres: extractGenres(app), imageUrl: imageUrl ? optimizeImage(imageUrl) : undefined, playType: app.gfn?.playType, membershipTierLabel: app.gfn?.minimumMembershipTierLabel, @@ -154,15 +163,118 @@ function appToGame(app: AppData): GameInfo { }; } +function parseFeatureLabel(value: unknown): string | null { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (value && typeof value === "object") { + const candidate = value as Record; + const keys = ["name", "label", "title", "displayName"]; + for (const key of keys) { + const raw = candidate[key]; + if (typeof raw === "string") { + const trimmed = raw.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + } + } + return null; +} + +function extractFeatureLabels(app: AppData): string[] { + const buckets: unknown[] = [ + app.features, + app.gameFeatures, + app.appFeatures, + app.genres, + app.tags, + ]; + + const labels: string[] = []; + for (const bucket of buckets) { + if (!Array.isArray(bucket)) { + continue; + } + for (const entry of bucket) { + const label = parseFeatureLabel(entry); + if (label) { + labels.push(label); + } + } + } + + return [...new Set(labels)]; +} + +function extractGenres(app: AppData): string[] { + if (!Array.isArray(app.genres)) { + return []; + } + + const genres: string[] = []; + for (const entry of app.genres) { + const genre = parseFeatureLabel(entry); + if (genre) { + genres.push(genre); + } + } + + return [...new Set(genres)]; +} + +function appToVariants(app: AppData): GameVariant[] { + return app.variants?.map((variant) => ({ + id: variant.id, + store: variant.appStore, + supportedControls: variant.supportedControls ?? [], + })) ?? []; +} + +function mergeAppMetaIntoGame(game: GameInfo, app: AppData): GameInfo { + const metadataVariants = appToVariants(app); + const variants = metadataVariants.length > 0 ? metadataVariants : game.variants; + const selectedVariantId = game.id.split(":")[1]; + const selectedVariantIndex = Math.max(0, variants.findIndex((variant) => variant.id === selectedVariantId)); + const imageUrl = + app.images?.GAME_BOX_ART ?? app.images?.TV_BANNER ?? app.images?.HERO_IMAGE ?? undefined; + + const description = app.description ?? game.description; + const longDescription = app.longDescription ?? game.longDescription; + const featureLabels = extractFeatureLabels(app); + const genres = extractGenres(app); + + return { + ...game, + title: app.title || game.title, + description, + longDescription, + featureLabels, + genres, + imageUrl: imageUrl ? optimizeImage(imageUrl) : game.imageUrl, + playType: app.gfn?.playType ?? game.playType, + membershipTierLabel: app.gfn?.minimumMembershipTierLabel ?? game.membershipTierLabel, + selectedVariantIndex, + variants, + }; +} + async function fetchAppMetaData( token: string, - appIdOrUuid: string, + appIds: string[], vpcId: string, ): Promise { + const normalizedIds = [...new Set(appIds.map((id) => id.trim()).filter((id) => id.length > 0))]; + if (normalizedIds.length === 0) { + return { data: { apps: { items: [] } } }; + } + const variables = JSON.stringify({ vpcId, locale: DEFAULT_LOCALE, - appIds: [appIdOrUuid], + appIds: normalizedIds, }); const extensions = JSON.stringify({ @@ -178,32 +290,86 @@ async function fetchAppMetaData( variables, }); - const response = await fetch(`${GRAPHQL_URL}?${params.toString()}`, { - headers: { - Accept: "application/json, text/plain, */*", - "Content-Type": "application/graphql", - Origin: "https://play.geforcenow.com", - Referer: "https://play.geforcenow.com/", - Authorization: `GFNJWT ${token}`, - "nv-client-id": LCARS_CLIENT_ID, - "nv-client-type": "NATIVE", - "nv-client-version": GFN_CLIENT_VERSION, - "nv-client-streamer": "NVIDIA-CLASSIC", - "nv-device-os": "WINDOWS", - "nv-device-type": "DESKTOP", - "nv-device-make": "UNKNOWN", - "nv-device-model": "UNKNOWN", - "nv-browser-type": "CHROME", - "User-Agent": GFN_USER_AGENT, - }, - }); + try { + const response = await fetch(`${GRAPHQL_URL}?${params.toString()}`, { + headers: { + Accept: "application/json, text/plain, */*", + "Content-Type": "application/graphql", + Origin: "https://play.geforcenow.com", + Referer: "https://play.geforcenow.com/", + Authorization: `GFNJWT ${token}`, + "nv-client-id": LCARS_CLIENT_ID, + "nv-client-type": "NATIVE", + "nv-client-version": GFN_CLIENT_VERSION, + "nv-client-streamer": "NVIDIA-CLASSIC", + "nv-device-os": "WINDOWS", + "nv-device-type": "DESKTOP", + "nv-device-make": "UNKNOWN", + "nv-device-model": "UNKNOWN", + "nv-browser-type": "CHROME", + "User-Agent": GFN_USER_AGENT, + }, + }); - if (!response.ok) { - const text = await response.text(); - throw new Error(`App metadata failed (${response.status}): ${text.slice(0, 400)}`); + if (!response.ok) { + const text = await response.text(); + console.error(`[GFN Metadata] fetchAppMetaData failed (${response.status}):`, text.slice(0, 400)); + throw new Error(`App metadata failed (${response.status}): ${text.slice(0, 400)}`); + } + + return (await response.json()) as AppMetaDataResponse; + } catch (error) { + console.error("[GFN Metadata] fetchAppMetaData error:", error); + throw error; + } +} + +async function enrichGamesWithMetadata(token: string, vpcId: string, games: GameInfo[]): Promise { + const uuids = [...new Set(games.map((game) => game.uuid).filter((uuid): uuid is string => !!uuid))]; + + if (uuids.length === 0) { + return games; } - return (await response.json()) as AppMetaDataResponse; + const chunkSize = 40; + const appById = new Map(); + const startTime = Date.now(); + + try { + for (let index = 0; index < uuids.length; index += chunkSize) { + const chunk = uuids.slice(index, index + chunkSize); + const payload = await fetchAppMetaData(token, chunk, vpcId); + if (payload.errors?.length) { + console.error("[GFN Metadata] GraphQL errors:", payload.errors); + throw new Error(payload.errors.map((error) => error.message).join(", ")); + } + + const items = payload.data?.apps.items ?? []; + for (const app of items) { + appById.set(app.id, app); + } + } + + let enrichedCount = 0; + const enrichedGames = games.map((game) => { + if (!game.uuid) { + return game; + } + const metadata = appById.get(game.uuid); + if (!metadata) { + return game; + } + enrichedCount += 1; + return mergeAppMetaIntoGame(game, metadata); + }); + + const elapsed = Date.now() - startTime; + console.log(`[GFN Metadata] Enriched ${enrichedCount}/${games.length} games in ${elapsed}ms`); + return enrichedGames; + } catch (error) { + console.error("[GFN Metadata] Enrichment error:", error); + throw error; + } } async function fetchPanels( @@ -276,25 +442,66 @@ function flattenPanels(payload: GraphQlResponse): GameInfo[] { } } + console.log(`[GFN Metadata] flattenPanels: Extracted ${games.length} games from panels`); return games; } export async function fetchMainGames(token: string, providerStreamingBaseUrl?: string): Promise { + const cached = await cacheManager.loadFromCache("games:main"); + if (cached) { + return cached.data; + } + + const games = await fetchMainGamesUncached(token, providerStreamingBaseUrl); + await cacheManager.saveToCache("games:main", games); + return games; +} + +async function fetchMainGamesUncached(token: string, providerStreamingBaseUrl?: string): Promise { const vpcId = await getVpcId(token, providerStreamingBaseUrl); const payload = await fetchPanels(token, ["MAIN"], vpcId); - return flattenPanels(payload); + const games = flattenPanels(payload); + const gfnEnriched = await enrichGamesWithMetadata(token, vpcId, games); + return gfnEnriched; } export async function fetchLibraryGames( token: string, providerStreamingBaseUrl?: string, +): Promise { + const cached = await cacheManager.loadFromCache("games:library"); + if (cached) { + return cached.data; + } + + const games = await fetchLibraryGamesUncached(token, providerStreamingBaseUrl); + await cacheManager.saveToCache("games:library", games); + return games; +} + +async function fetchLibraryGamesUncached( + token: string, + providerStreamingBaseUrl?: string, ): Promise { const vpcId = await getVpcId(token, providerStreamingBaseUrl); const payload = await fetchPanels(token, ["LIBRARY"], vpcId); - return flattenPanels(payload); + const games = flattenPanels(payload); + const gfnEnriched = await enrichGamesWithMetadata(token, vpcId, games); + return gfnEnriched; } export async function fetchPublicGames(): Promise { + const cached = await cacheManager.loadFromCache("games:public"); + if (cached) { + return cached.data; + } + + const games = await fetchPublicGamesUncached(); + await cacheManager.saveToCache("games:public", games); + return games; +} + +async function fetchPublicGamesUncached(): Promise { const response = await fetch( "https://static.nvidiagrid.net/supported-public-game-list/locales/gfnpc-en-US.json", { @@ -309,7 +516,7 @@ export async function fetchPublicGames(): Promise { } const payload = (await response.json()) as RawPublicGame[]; - return payload + const games = payload .filter((item) => item.status === "AVAILABLE" && item.title) .map((item) => { const id = String(item.id ?? item.title ?? "unknown"); @@ -328,6 +535,9 @@ export async function fetchPublicGames(): Promise { imageUrl, } as GameInfo; }); + + return games; + } export async function resolveLaunchAppId( @@ -340,7 +550,7 @@ export async function resolveLaunchAppId( } const vpcId = await getVpcId(token, providerStreamingBaseUrl); - const payload = await fetchAppMetaData(token, appIdOrUuid, vpcId); + const payload = await fetchAppMetaData(token, [appIdOrUuid], vpcId); if (payload.errors?.length) { throw new Error(payload.errors.map((error) => error.message).join(", ")); @@ -365,3 +575,9 @@ export async function resolveLaunchAppId( return isNumericId(app.id) ? app.id : null; } + +export { + fetchMainGamesUncached, + fetchLibraryGamesUncached, + fetchPublicGamesUncached, +}; diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index ba65e9e..b3c3e40 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -1,10 +1,11 @@ import { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, session } from "electron"; import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve, relative } from "node:path"; import { existsSync, readFileSync, createWriteStream } from "node:fs"; -import { copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile, realpath } from "node:fs/promises"; import * as net from "node:net"; -import { randomUUID } from "node:crypto"; +import { randomUUID, createHash } from "node:crypto"; +import { spawn } from "node:child_process"; // Keyboard shortcuts reference (matching Rust implementation): // Screenshot keybind - configurable, handled in renderer @@ -14,6 +15,14 @@ import { randomUUID } from "node:crypto"; import { IPC_CHANNELS } from "@shared/ipc"; import { initLogCapture, exportLogs } from "@shared/logger"; +import { cacheManager } from "./services/cacheManager"; +import { refreshScheduler } from "./services/refreshScheduler"; +import { cacheEventBus } from "./services/cacheEventBus"; +import { + fetchMainGamesUncached, + fetchLibraryGamesUncached, + fetchPublicGamesUncached, +} from "./gfn/games"; import type { MainToRendererSignalingEvent, AuthLoginRequest, @@ -334,6 +343,72 @@ function getRecordingsDirectory(): string { return join(app.getPath("pictures"), "OpenNOW", "Recordings"); } +function getThumbnailCacheDirectory(): string { + return join(app.getPath("userData"), "media-thumbs"); +} + +async function ensureThumbnailCacheDirectory(): Promise { + const dir = getThumbnailCacheDirectory(); + await mkdir(dir, { recursive: true }); + return dir; +} + +function md5(input: string): string { + return createHash("md5").update(input).digest("hex"); +} + +async function generateVideoThumbnail(sourcePath: string, outPath: string): Promise { + return new Promise((resolve) => { + // Try to run ffmpeg to extract a frame at 1s. + const args = ["-y", "-ss", "1", "-i", sourcePath, "-frames:v", "1", "-q:v", "2", outPath]; + const child = spawn("ffmpeg", args, { stdio: "ignore" }); + child.on("error", () => resolve(false)); + child.on("close", (code) => { + resolve(code === 0); + }); + }); +} + +async function ensureThumbnailForMedia(filePath: string): Promise { + try { + const stats = await stat(filePath); + const key = md5(`${filePath}|${stats.mtimeMs}`); + const cacheDir = await ensureThumbnailCacheDirectory(); + const outPath = join(cacheDir, `${key}.jpg`); + // If cached, return + try { + await stat(outPath); + return outPath; + } catch { + // not exists + } + + const lower = filePath.toLowerCase(); + if (lower.endsWith(".mp4") || lower.endsWith(".webm") || lower.endsWith(".mkv") || lower.endsWith(".mov")) { + const ok = await generateVideoThumbnail(filePath, outPath); + if (ok) return outPath; + // generation failed + return null; + } + + // For images, copy into cache (no re-encoding) + if (lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".webp")) { + try { + const buf = await readFile(filePath); + await writeFile(outPath, buf); + return outPath; + } catch { + return null; + } + } + + return null; + } catch (err) { + console.warn("ensureThumbnailForMedia error:", err); + return null; + } +} + async function ensureRecordingsDirectory(): Promise { const dir = getRecordingsDirectory(); await mkdir(dir, { recursive: true }); @@ -455,6 +530,15 @@ async function createMainWindow(): Promise { await mainWindow.loadFile(join(__dirname, "../../dist/index.html")); } + // Apply full screen on startup if the user has configured it. + if (settings.autoFullScreen) { + try { + mainWindow.setFullScreen(true); + } catch (err) { + console.warn("Failed to apply autoFullScreen on startup:", err); + } + } + mainWindow.on("closed", () => { mainWindow = null; }); @@ -547,6 +631,7 @@ function registerIpcHandlers(): void { const token = await resolveJwt(payload?.token); const streamingBaseUrl = payload?.providerStreamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; + refreshScheduler.updateAuthContext(token, streamingBaseUrl); return fetchMainGames(token, streamingBaseUrl); }); @@ -554,6 +639,7 @@ function registerIpcHandlers(): void { const token = await resolveJwt(payload?.token); const streamingBaseUrl = payload?.providerStreamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; + refreshScheduler.updateAuthContext(token, streamingBaseUrl); return fetchLibraryGames(token, streamingBaseUrl); }); @@ -691,6 +777,16 @@ function registerIpcHandlers(): void { } }); + ipcMain.handle(IPC_CHANNELS.SET_FULLSCREEN, async (_event, value: boolean) => { + if (mainWindow && !mainWindow.isDestroyed()) { + try { + mainWindow.setFullScreen(Boolean(value)); + } catch (err) { + console.warn("Failed to set fullscreen:", err); + } + } + }); + // Toggle pointer lock via IPC (F8 shortcut) ipcMain.handle(IPC_CHANNELS.TOGGLE_POINTER_LOCK, async () => { if (mainWindow && !mainWindow.isDestroyed()) { @@ -705,6 +801,17 @@ function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.SETTINGS_SET, async (_event: Electron.IpcMainInvokeEvent, key: K, value: Settings[K]) => { settingsManager.set(key, value); + // React to certain setting changes immediately in main process + try { + if (key === "autoFullScreen") { + if (mainWindow && !mainWindow.isDestroyed()) { + const should = Boolean(value as unknown as boolean); + mainWindow.setFullScreen(should); + } + } + } catch (err) { + console.warn("Failed to apply setting change in main process:", err); + } }); ipcMain.handle(IPC_CHANNELS.SETTINGS_RESET, async (): Promise => { @@ -724,6 +831,33 @@ function registerIpcHandlers(): void { return listScreenshots(); }); + // Media: per-game listing (screenshots + recordings). Best-effort title matching. + ipcMain.handle(IPC_CHANNELS.MEDIA_LIST_BY_GAME, async (_event, payload: { gameTitle?: string } = {}) => { + const title = (payload?.gameTitle || "").trim().toLowerCase(); + const screenshots = await listScreenshots(); + const recordings = await listRecordings(); + + const normalize = (s?: string) => (s || "").replace(/[^a-z0-9]+/gi, "").toLowerCase(); + const needle = normalize(title); + + const matchedScreens = screenshots.filter((s) => { + if (!needle) return true; + const candidate = normalize(s.fileName) + normalize(s.filePath || ""); + return candidate.includes(needle); + }); + + const matchedRecordings = recordings.filter((r) => { + if (!needle) return true; + const candidate = normalize(r.gameTitle ?? r.fileName ?? ""); + return candidate.includes(needle); + }); + + return { + screenshots: matchedScreens, + videos: matchedRecordings, + }; + }); + ipcMain.handle(IPC_CHANNELS.SCREENSHOT_DELETE, async (_event, input: ScreenshotDeleteRequest): Promise => { return deleteScreenshot(input); }); @@ -852,6 +986,83 @@ function registerIpcHandlers(): void { shell.showItemInFolder(join(dir, id)); }); + // Return a thumbnail data URL for a given media file path (images or companion thumbs for videos). + ipcMain.handle(IPC_CHANNELS.MEDIA_THUMBNAIL, async (_event, payload: { filePath: string }): Promise => { + const rawFp = payload?.filePath; + if (typeof rawFp !== "string") return null; + if (rawFp.length > 4096) return null; + try { + const allowedRoot = resolve(join(app.getPath("pictures"), "OpenNOW")); + const fpResolved = resolve(rawFp); + const allowedRootReal = await realpath(allowedRoot).catch(() => allowedRoot); + const fpReal = await realpath(fpResolved).catch(() => fpResolved); + const rel = relative(allowedRootReal, fpReal); + if (rel.startsWith("..")) return null; + + const lower = fpReal.toLowerCase(); + if (lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".webp")) { + const buf = await readFile(fpReal); + const extMatch = /\.([^.]+)$/.exec(fpReal); + const ext = (extMatch?.[1] || "png").toLowerCase(); + const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png"; + return `data:${mime};base64,${buf.toString("base64")}`; + } + + if (lower.endsWith(".mp4") || lower.endsWith(".webm") || lower.endsWith(".mkv") || lower.endsWith(".mov")) { + // Prefer an existing companion thumb next to the video + const stem = fpReal.replace(/\.(mp4|webm|mkv|mov)$/i, ""); + const thumbPath = `${stem}-thumb.jpg`; + try { + const b = await readFile(thumbPath); + return `data:image/jpeg;base64,${b.toString("base64")}`; + } catch { + // Try generating a cached thumbnail via ffmpeg + } + + const gen = await ensureThumbnailForMedia(fpReal); + if (gen) { + try { + const b2 = await readFile(gen); + return `data:image/jpeg;base64,${b2.toString("base64")}`; + } catch { + return null; + } + } + return null; + } + + return null; + } catch (err) { + console.warn("MEDIA_THUMBNAIL error:", err); + return null; + } + }); + + ipcMain.handle(IPC_CHANNELS.MEDIA_SHOW_IN_FOLDER, async (_event, payload: { filePath: string }): Promise => { + const rawFp = payload?.filePath; + if (typeof rawFp !== "string") return; + try { + const allowedRoot = resolve(join(app.getPath("pictures"), "OpenNOW")); + const fpResolved = resolve(rawFp); + const allowedRootReal = await realpath(allowedRoot).catch(() => allowedRoot); + const fpReal = await realpath(fpResolved).catch(() => fpResolved); + const rel = relative(allowedRootReal, fpReal); + if (rel.startsWith("..")) return; + shell.showItemInFolder(fpReal); + } catch { + return; + } + }); + + ipcMain.handle(IPC_CHANNELS.CACHE_REFRESH_MANUAL, async (): Promise => { + await refreshScheduler.manualRefresh(); + }); + + ipcMain.handle(IPC_CHANNELS.CACHE_DELETE_ALL, async (): Promise => { + await cacheManager.deleteAll(); + console.log("[IPC] Cache deletion completed successfully"); + }); + // TCP-based ping function - more accurate than HTTP as it only measures connection time async function tcpPing(hostname: string, port: number, timeoutMs: number = 3000): Promise { return new Promise((resolve) => { @@ -936,6 +1147,8 @@ app.whenReady().then(async () => { // Initialize log capture first to capture all console output initLogCapture("main"); + await cacheManager.initialize(); + authService = new AuthService(join(app.getPath("userData"), "auth-state.json")); await authService.initialize(); @@ -990,6 +1203,33 @@ app.whenReady().then(async () => { }); registerIpcHandlers(); + + refreshScheduler.initialize( + fetchMainGamesUncached, + fetchLibraryGamesUncached, + fetchPublicGamesUncached, + ); + + cacheEventBus.on("cache:refresh-start", () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.CACHE_STATUS_UPDATE, { event: "refresh-start" }); + } + }); + + cacheEventBus.on("cache:refresh-success", () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.CACHE_STATUS_UPDATE, { event: "refresh-success" }); + } + }); + + cacheEventBus.on("cache:refresh-error", (details: { key: string; error: string }) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.CACHE_STATUS_UPDATE, { event: "refresh-error", ...details }); + } + }); + + refreshScheduler.start(); + await createMainWindow(); app.on("activate", async () => { @@ -1006,6 +1246,7 @@ app.on("window-all-closed", () => { }); app.on("before-quit", () => { + refreshScheduler.stop(); signalingClient?.disconnect(); signalingClient = null; signalingClientKey = null; diff --git a/opennow-stable/src/main/services/cacheEventBus.ts b/opennow-stable/src/main/services/cacheEventBus.ts new file mode 100644 index 0000000..1f61d48 --- /dev/null +++ b/opennow-stable/src/main/services/cacheEventBus.ts @@ -0,0 +1,17 @@ +import { EventEmitter } from "node:events"; + +interface CacheRefreshErrorEvent { + key: string; + error: string; +} + +class CacheEventBus extends EventEmitter { + emit(event: "cache:refresh-start"): boolean; + emit(event: "cache:refresh-success"): boolean; + emit(event: "cache:refresh-error", details: CacheRefreshErrorEvent): boolean; + emit(event: string, ...args: unknown[]): boolean { + return super.emit(event, ...args); + } +} + +export const cacheEventBus = new CacheEventBus(); diff --git a/opennow-stable/src/main/services/cacheManager.ts b/opennow-stable/src/main/services/cacheManager.ts new file mode 100644 index 0000000..2356f93 --- /dev/null +++ b/opennow-stable/src/main/services/cacheManager.ts @@ -0,0 +1,175 @@ +import { app } from "electron"; +import { mkdir, readFile, writeFile, unlink, readdir, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +interface CacheMetadata { + timestamp: number; + expiresAt: number; +} + +interface CachedData { + data: T; + metadata: CacheMetadata; +} + +const CACHE_DIRECTORY = "gfn-cache"; +const CACHE_TTL_MS = 12 * 60 * 60 * 1000; + +const THUMBNAILS_DIRECTORY = "media-thumbs"; + +class CacheManager { + private cacheDir: string; + private initialized: boolean = false; + + constructor() { + this.cacheDir = join(app.getPath("userData"), CACHE_DIRECTORY); + } + + async initialize(): Promise { + if (this.initialized) return; + try { + await mkdir(this.cacheDir, { recursive: true }); + this.initialized = true; + console.log(`[CACHE] Initialized cache directory: ${this.cacheDir}`); + } catch (error) { + console.error(`[CACHE] Failed to initialize cache directory:`, error); + throw error; + } + } + + private getCacheFilePath(key: string): string { + const sanitized = key.replace(/[^a-z0-9-]/gi, "_"); + return join(this.cacheDir, `${sanitized}.json`); + } + + async loadFromCache(key: string): Promise | null> { + if (!this.initialized) { + console.warn(`[CACHE] Cache not initialized, skipping load for key: ${key}`); + return null; + } + + const filePath = this.getCacheFilePath(key); + + if (!existsSync(filePath)) { + console.log(`[CACHE] Cache miss (file not found): ${key}`); + return null; + } + + try { + const content = await readFile(filePath, "utf-8"); + const parsed = JSON.parse(content) as CachedData; + + if (!parsed.metadata || typeof parsed.metadata.expiresAt !== "number") { + console.warn(`[CACHE] Cache corrupted (invalid metadata): ${key}`); + await this.invalidateCache(key); + return null; + } + + const now = Date.now(); + if (now > parsed.metadata.expiresAt) { + console.log(`[CACHE] Cache expired: ${key} (expired ${Math.round((now - parsed.metadata.expiresAt) / 1000)}s ago)`); + return null; + } + + const ageSeconds = Math.round((now - parsed.metadata.timestamp) / 1000); + console.log(`[CACHE] Cache hit: ${key} (age: ${ageSeconds}s)`); + return parsed; + } catch (error) { + console.error(`[CACHE] Error reading cache file: ${key}`, error); + try { + await this.invalidateCache(key); + } catch (deleteError) { + console.error(`[CACHE] Failed to delete corrupted cache file: ${key}`, deleteError); + } + return null; + } + } + + async saveToCache(key: string, data: T): Promise { + if (!this.initialized) { + console.warn(`[CACHE] Cache not initialized, skipping save for key: ${key}`); + return; + } + + const filePath = this.getCacheFilePath(key); + const now = Date.now(); + const cached: CachedData = { + data, + metadata: { + timestamp: now, + expiresAt: now + CACHE_TTL_MS, + }, + }; + + try { + await writeFile(filePath, JSON.stringify(cached, null, 2), "utf-8"); + console.log(`[CACHE] Saved to cache: ${key}`); + } catch (error) { + console.error(`[CACHE] Error writing cache file: ${key}`, error); + throw error; + } + } + + async invalidateCache(key: string): Promise { + const filePath = this.getCacheFilePath(key); + + if (!existsSync(filePath)) { + console.log(`[CACHE] Cache already invalid or missing: ${key}`); + return; + } + + try { + await unlink(filePath); + console.log(`[CACHE] Invalidated cache: ${key}`); + } catch (error) { + console.error(`[CACHE] Error deleting cache file: ${key}`, error); + throw error; + } + } + + async deleteAll(): Promise { + if (!this.initialized) { + console.warn(`[CACHE] Cache not initialized, skipping deleteAll`); + return; + } + + try { + const files = await readdir(this.cacheDir); + for (const file of files) { + const filePath = join(this.cacheDir, file); + try { + await unlink(filePath); + console.log(`[CACHE] Deleted cache file: ${file}`); + } catch (err) { + console.error(`[CACHE] Error deleting cache file: ${file}`, err); + } + } + console.log(`[CACHE] Cleared all cache files in ${this.cacheDir}`); + + // Also remove the thumbnail cache directory created by main process + const thumbsDir = join(app.getPath("userData"), THUMBNAILS_DIRECTORY); + try { + await rm(thumbsDir, { recursive: true, force: true }); + console.log(`[CACHE] Removed thumbnail cache directory: ${thumbsDir}`); + } catch (err) { + // Non-fatal: log and continue + console.warn(`[CACHE] Failed to remove thumbnail cache directory: ${thumbsDir}`, err); + } + } catch (error) { + console.error(`[CACHE] Error clearing all cache:`, error); + throw error; + } + } + + isExpired(timestamp: number): boolean { + const ageMs = Date.now() - timestamp; + return ageMs > CACHE_TTL_MS; + } + + getCacheTtlMs(): number { + return CACHE_TTL_MS; + } +} + +export const cacheManager = new CacheManager(); diff --git a/opennow-stable/src/main/services/refreshScheduler.ts b/opennow-stable/src/main/services/refreshScheduler.ts new file mode 100644 index 0000000..5c5fb9b --- /dev/null +++ b/opennow-stable/src/main/services/refreshScheduler.ts @@ -0,0 +1,145 @@ +import type { GameInfo } from "@shared/gfn"; +import { cacheEventBus } from "./cacheEventBus"; + +export interface RefreshAuthContext { + token: string; + providerStreamingBaseUrl?: string; +} + +type FetchFunction = (token: string, providerStreamingBaseUrl?: string) => Promise; +type PublicFetchFunction = () => Promise; + +class RefreshScheduler { + private refreshTimer: ReturnType | null = null; + private isRefreshing: boolean = false; + private authContext: RefreshAuthContext | null = null; + private fetchMainGames: FetchFunction | null = null; + private fetchLibraryGames: FetchFunction | null = null; + private fetchPublicGames: PublicFetchFunction | null = null; + private refreshIntervalMs: number = 12 * 60 * 60 * 1000; + + initialize( + fetchMainGames: FetchFunction, + fetchLibraryGames: FetchFunction, + fetchPublicGames: PublicFetchFunction, + ): void { + this.fetchMainGames = fetchMainGames; + this.fetchLibraryGames = fetchLibraryGames; + this.fetchPublicGames = fetchPublicGames; + console.log(`[CACHE] RefreshScheduler initialized (interval: ${this.refreshIntervalMs / 60000} minutes)`); + } + + updateAuthContext(token: string, providerStreamingBaseUrl?: string): void { + this.authContext = { token, providerStreamingBaseUrl }; + console.log(`[CACHE] Auth context updated for refresh scheduler`); + } + + start(): void { + if (this.refreshTimer) { + console.warn(`[CACHE] RefreshScheduler already started`); + return; + } + + if (!this.fetchMainGames || !this.fetchLibraryGames || !this.fetchPublicGames) { + console.error(`[CACHE] Cannot start RefreshScheduler: fetch functions not initialized`); + return; + } + + console.log(`[CACHE] Starting RefreshScheduler`); + this.performRefresh(); + this.refreshTimer = setInterval(() => { + void this.performRefresh(); + }, this.refreshIntervalMs); + } + + stop(): void { + if (!this.refreshTimer) { + console.log(`[CACHE] RefreshScheduler already stopped`); + return; + } + + clearInterval(this.refreshTimer); + this.refreshTimer = null; + console.log(`[CACHE] RefreshScheduler stopped`); + } + + async performRefresh(): Promise { + if (this.isRefreshing) { + console.log(`[CACHE] Refresh already in progress, skipping`); + return; + } + + if (!this.authContext) { + console.log(`[CACHE] Auth context not available, skipping refresh`); + return; + } + + if (!this.fetchMainGames || !this.fetchLibraryGames || !this.fetchPublicGames) { + console.error(`[CACHE] Fetch functions not available`); + return; + } + + this.isRefreshing = true; + const startTime = Date.now(); + console.log(`[CACHE] Refresh cycle started`); + + try { + cacheEventBus.emit("cache:refresh-start"); + + const results = await Promise.allSettled([ + this.fetchMainGames(this.authContext.token, this.authContext.providerStreamingBaseUrl), + this.fetchLibraryGames(this.authContext.token, this.authContext.providerStreamingBaseUrl), + this.fetchPublicGames(), + ]); + + let hasErrors = false; + for (let i = 0; i < results.length; i++) { + const result = results[i]; + const name = ["main", "library", "public"][i]; + + if (result.status === "rejected") { + hasErrors = true; + console.error(`[CACHE] Refresh failed for ${name} games:`, result.reason); + cacheEventBus.emit("cache:refresh-error", { + key: `games:${name}`, + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + }); + } + } + + const duration = Date.now() - startTime; + console.log(`[CACHE] Refresh cycle completed in ${duration}ms`); + + if (!hasErrors) { + cacheEventBus.emit("cache:refresh-success"); + } + } catch (error) { + console.error(`[CACHE] Refresh cycle error:`, error); + cacheEventBus.emit("cache:refresh-error", { + key: "refresh-cycle", + error: error instanceof Error ? error.message : "Unknown error", + }); + } finally { + this.isRefreshing = false; + } + } + + async manualRefresh(): Promise { + console.log(`[CACHE] Manual refresh requested`); + await this.performRefresh(); + } + + setRefreshInterval(intervalMs: number): void { + console.log(`[CACHE] Refresh interval updated: ${this.refreshIntervalMs}ms -> ${intervalMs}ms`); + this.refreshIntervalMs = intervalMs; + + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = setInterval(() => { + void this.performRefresh(); + }, this.refreshIntervalMs); + } + } +} + +export const refreshScheduler = new RefreshScheduler(); diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index 14a887e..bc5ffbf 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -1,14 +1,16 @@ import { app } from "electron"; import { join } from "node:path"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; -import type { VideoCodec, ColorQuality, VideoAccelerationPreference, MicrophoneMode, GameLanguage } from "@shared/gfn"; +import type { VideoCodec, ColorQuality, VideoAccelerationPreference, MicrophoneMode, GameLanguage, AspectRatio } from "@shared/gfn"; export interface Settings { /** Video resolution (e.g., "1920x1080") */ resolution: string; + /** Aspect ratio (16:9, 16:10, 21:9, 32:9) */ + aspectRatio: AspectRatio; /** Target FPS (30, 60, 120, etc.) */ fps: number; - /** Maximum bitrate in Mbps (200 = unlimited) */ + /** Maximum bitrate in Mbps (cap at 150) */ maxBitrateMbps: number; /** Preferred video codec */ codec: VideoCodec; @@ -46,6 +48,17 @@ export interface Settings { microphoneDeviceId: string; /** Hide stream buttons (mic/fullscreen/end-session) while streaming */ hideStreamButtons: boolean; + /** Enable controller-first media bar layout for library browsing */ + controllerMode: boolean; + /** Play subtle sounds in controller library mode */ + controllerUiSounds: boolean; + /** Enable animated background visuals for controller-mode loading screens */ + controllerBackgroundAnimations: boolean; + /** Auto-load controller library at startup when controller mode is enabled */ + autoLoadControllerLibrary: boolean; + /** Automatically enter fullscreen when controller-mode triggers it */ + autoFullScreen: boolean; + favoriteGameIds: string[]; /** Window width */ windowWidth: number; /** Window height */ @@ -62,6 +75,7 @@ const LEGACY_ANTI_AFK_SHORTCUTS = new Set(["META+SHIFT+F10", "CMD+SHIFT+F10", "C const DEFAULT_SETTINGS: Settings = { resolution: "1920x1080", + aspectRatio: "16:9", fps: 60, maxBitrateMbps: 75, codec: "H264", @@ -80,6 +94,12 @@ const DEFAULT_SETTINGS: Settings = { microphoneMode: "disabled", microphoneDeviceId: "", hideStreamButtons: false, + controllerMode: false, + controllerUiSounds: false, + controllerBackgroundAnimations: false, + autoLoadControllerLibrary: false, + autoFullScreen: false, + favoriteGameIds: [], sessionClockShowEveryMinutes: 60, sessionClockShowDurationSeconds: 30, windowWidth: 1400, diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index e5decf8..b856a47 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -30,12 +30,10 @@ import type { RecordingAbortRequest, RecordingEntry, RecordingDeleteRequest, + MediaListingResult, } from "@shared/gfn"; -// Extend the OpenNowApi interface for internal preload use -type PreloadApi = OpenNowApi; - -const api: PreloadApi = { +const api: OpenNowApi = { getAuthSession: (input: AuthSessionRequest = {}) => ipcRenderer.invoke(IPC_CHANNELS.AUTH_GET_SESSION, input), getLoginProviders: () => ipcRenderer.invoke(IPC_CHANNELS.AUTH_GET_PROVIDERS), getRegions: (input: RegionsFetchRequest = {}) => ipcRenderer.invoke(IPC_CHANNELS.AUTH_GET_REGIONS, input), @@ -82,6 +80,7 @@ const api: PreloadApi = { }; }, toggleFullscreen: () => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_FULLSCREEN), + setFullscreen: (v: boolean) => ipcRenderer.invoke(IPC_CHANNELS.SET_FULLSCREEN, v), togglePointerLock: () => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_POINTER_LOCK), getSettings: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_GET), setSetting: (key: K, value: Settings[K]) => @@ -114,6 +113,13 @@ const api: PreloadApi = { ipcRenderer.invoke(IPC_CHANNELS.RECORDING_DELETE, input), showRecordingInFolder: (id: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RECORDING_SHOW_IN_FOLDER, id), + listMediaByGame: (input: { gameTitle?: string } = {}): Promise => + ipcRenderer.invoke(IPC_CHANNELS.MEDIA_LIST_BY_GAME, input), + getMediaThumbnail: (input: { filePath: string }) => ipcRenderer.invoke(IPC_CHANNELS.MEDIA_THUMBNAIL, input), + showMediaInFolder: (input: { filePath: string }): Promise => + ipcRenderer.invoke(IPC_CHANNELS.MEDIA_SHOW_IN_FOLDER, input), + deleteCache: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.CACHE_DELETE_ALL), }; contextBridge.exposeInMainWorld("openNow", api); diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index fee612a..2f4d417 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -23,19 +23,44 @@ import { } from "./gfn/webrtcClient"; import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut } from "./shortcuts"; import { useControllerNavigation } from "./controllerNavigation"; +import { usePlaytime } from "./utils/usePlaytime"; // UI Components import { LoginScreen } from "./components/LoginScreen"; import { Navbar } from "./components/Navbar"; import { HomePage } from "./components/HomePage"; import { LibraryPage } from "./components/LibraryPage"; +import { ControllerLibraryPage } from "./components/ControllerLibraryPage"; import { SettingsPage } from "./components/SettingsPage"; import { StreamLoading } from "./components/StreamLoading"; +import { ControllerStreamLoading } from "./components/ControllerStreamLoading"; import { StreamView } from "./components/StreamView"; const codecOptions: VideoCodec[] = ["H264", "H265", "AV1"]; -const resolutionOptions = ["1280x720", "1920x1080", "2560x1440", "3840x2160", "2560x1080", "3440x1440"]; +const allResolutionOptions = ["1280x720", "1280x800", "1440x900", "1680x1050", "1920x1080", "1920x1200", "2560x1080", "2560x1440", "2560x1600", "3440x1440", "3840x2160", "3840x2400"]; const fpsOptions = [30, 60, 120, 144, 240]; +const aspectRatioOptions = ["16:9", "16:10", "21:9", "32:9"] as const; + +const RESOLUTION_TO_ASPECT_RATIO: Record = { + "1280x720": "16:9", + "1280x800": "16:10", + "1440x900": "16:10", + "1680x1050": "16:10", + "1920x1080": "16:9", + "1920x1200": "16:10", + "2560x1080": "21:9", + "2560x1440": "16:9", + "2560x1600": "16:10", + "3440x1440": "21:9", + "3840x2160": "16:9", + "3840x2400": "16:10", + "5120x1440": "32:9", +}; + +const getResolutionsByAspectRatio = (aspectRatio: string): string[] => { + return allResolutionOptions.filter(res => RESOLUTION_TO_ASPECT_RATIO[res] === aspectRatio); +}; +const resolutionOptions = getResolutionsByAspectRatio("16:9"); const SESSION_READY_POLL_INTERVAL_MS = 2000; const SESSION_READY_TIMEOUT_MS = 180000; const VARIANT_SELECTION_LOCALSTORAGE_KEY = "opennow.variantByGameId"; @@ -77,6 +102,23 @@ function sleep(ms: number): Promise { return new Promise((resolve) => window.setTimeout(resolve, ms)); } +async function waitFor( + predicate: () => boolean, + { timeout = 1000, interval = 50 }: { timeout?: number; interval?: number } = {}, +): Promise { + const start = Date.now(); + while (true) { + try { + if (predicate()) return true; + } catch { + // ignore predicate errors + } + if (Date.now() - start >= timeout) return false; + // eslint-disable-next-line no-await-in-loop + await sleep(interval); + } +} + function isSessionReadyForConnect(status: number): boolean { return status === 2 || status === 3; } @@ -298,6 +340,7 @@ function toLaunchErrorState(error: unknown, stage: StreamLoadingStatus): LaunchE } export function App(): JSX.Element { + // Auth State const [authSession, setAuthSession] = useState(null); const [providers, setProviders] = useState([]); @@ -326,6 +369,7 @@ export function App(): JSX.Element { // Settings State const [settings, setSettings] = useState({ resolution: "1920x1080", + aspectRatio: "16:9", fps: 60, maxBitrateMbps: 75, codec: "H264", @@ -344,6 +388,12 @@ export function App(): JSX.Element { microphoneMode: "disabled", microphoneDeviceId: "", hideStreamButtons: false, + controllerMode: false, + controllerUiSounds: false, + controllerBackgroundAnimations: false, + autoLoadControllerLibrary: false, + autoFullScreen: false, + favoriteGameIds: [], sessionClockShowEveryMinutes: 60, sessionClockShowDurationSeconds: 30, windowWidth: 1400, @@ -375,33 +425,184 @@ export function App(): JSX.Element { const [sessionElapsedSeconds, setSessionElapsedSeconds] = useState(0); const [streamWarning, setStreamWarning] = useState(null); + const { playtime, startSession: startPlaytimeSession, endSession: endPlaytimeSession } = usePlaytime(); + + const controllerOverlayOpenRef = useRef(false); + const handleControllerPageNavigate = useCallback((direction: "prev" | "next"): void => { + if (controllerOverlayOpenRef.current) { + window.dispatchEvent(new CustomEvent("opennow:controller-shoulder", { detail: { direction } })); + return; + } if (!authSession || streamStatus !== "idle") { return; } + + if (settings.controllerMode && currentPage === "library") { + window.dispatchEvent(new CustomEvent("opennow:controller-shoulder", { detail: { direction } })); + return; + } + const currentIndex = APP_PAGE_ORDER.indexOf(currentPage); const step = direction === "next" ? 1 : -1; const nextIndex = (currentIndex + step + APP_PAGE_ORDER.length) % APP_PAGE_ORDER.length; setCurrentPage(APP_PAGE_ORDER[nextIndex]); - }, [authSession, currentPage, streamStatus]); + }, [authSession, currentPage, settings.controllerMode, streamStatus]); const handleControllerBackAction = useCallback((): boolean => { + // Prefer to let the controller library handle Back (e.g. closing submenus + // inside the XMB) before falling back to global navigation. + const cancelEvent = new CustomEvent("opennow:controller-cancel", { cancelable: true }); + window.dispatchEvent(cancelEvent); + if (cancelEvent.defaultPrevented) { + return true; + } + + if (controllerOverlayOpenRef.current) { + setControllerOverlayOpen(false); + return true; + } + if (!authSession || streamStatus !== "idle") { return false; } + + if (settings.controllerMode && currentPage === "settings") { + setCurrentPage("library"); + return true; + } + if (currentPage !== "home") { setCurrentPage("home"); return true; } return false; - }, [authSession, currentPage, streamStatus]); + }, [authSession, currentPage, settings.controllerMode, streamStatus]); + + const handleControllerDirectionInput = useCallback((direction: "up" | "down" | "left" | "right"): boolean => { + if (controllerOverlayOpenRef.current) { + window.dispatchEvent(new CustomEvent("opennow:controller-direction", { detail: { direction } })); + return true; + } + if (!authSession || streamStatus !== "idle") { + return false; + } + if (settings.controllerMode && currentPage === "library") { + window.dispatchEvent(new CustomEvent("opennow:controller-direction", { detail: { direction } })); + return true; + } + if (settings.controllerMode && currentPage === "settings") { + window.dispatchEvent(new CustomEvent("opennow:controller-direction", { detail: { direction } })); + return true; + } + return false; + }, [authSession, currentPage, settings.controllerMode, streamStatus]); + + const handleControllerActivateInput = useCallback((): boolean => { + if (controllerOverlayOpenRef.current) { + window.dispatchEvent(new CustomEvent("opennow:controller-activate")); + return true; + } + if (!authSession || streamStatus !== "idle") { + return false; + } + if (settings.controllerMode && currentPage === "library") { + window.dispatchEvent(new CustomEvent("opennow:controller-activate")); + return true; + } + if (settings.controllerMode && currentPage === "settings") { + window.dispatchEvent(new CustomEvent("opennow:controller-activate")); + return true; + } + return false; + }, [authSession, currentPage, settings.controllerMode, streamStatus]); + + const handleControllerSecondaryActivateInput = useCallback((): boolean => { + if (controllerOverlayOpenRef.current) { + window.dispatchEvent(new CustomEvent("opennow:controller-secondary-activate")); + return true; + } + if (!authSession || streamStatus !== "idle") { + return false; + } + if (settings.controllerMode && currentPage === "library") { + window.dispatchEvent(new CustomEvent("opennow:controller-secondary-activate")); + return true; + } + if (settings.controllerMode && currentPage === "settings") { + window.dispatchEvent(new CustomEvent("opennow:controller-secondary-activate")); + return true; + } + return false; + }, [authSession, currentPage, settings.controllerMode, streamStatus]); + + const handleControllerTertiaryActivateInput = useCallback((): boolean => { + if (controllerOverlayOpenRef.current) { + window.dispatchEvent(new CustomEvent("opennow:controller-tertiary-activate")); + return true; + } + if (!authSession || streamStatus !== "idle") { + return false; + } + if (settings.controllerMode && currentPage === "library") { + window.dispatchEvent(new CustomEvent("opennow:controller-tertiary-activate")); + return true; + } + if (settings.controllerMode && currentPage === "settings") { + window.dispatchEvent(new CustomEvent("opennow:controller-tertiary-activate")); + return true; + } + return false; + }, [authSession, currentPage, settings.controllerMode, streamStatus]); + + const [controllerOverlayOpen, setControllerOverlayOpen] = useState(false); + const [isSwitchingGame, setIsSwitchingGame] = useState(false); + const [switchingPhase, setSwitchingPhase] = useState(null); + const [pendingSwitchGameTitle, setPendingSwitchGameTitle] = useState(null); + const [pendingSwitchGameCover, setPendingSwitchGameCover] = useState(null); const controllerConnected = useControllerNavigation({ - enabled: streamStatus !== "streaming" || exitPrompt.open, + enabled: streamStatus !== "streaming" || exitPrompt.open || controllerOverlayOpen, onNavigatePage: handleControllerPageNavigate, onBackAction: handleControllerBackAction, + onDirectionInput: handleControllerDirectionInput, + onActivateInput: handleControllerActivateInput, + onSecondaryActivateInput: handleControllerSecondaryActivateInput, + onTertiaryActivateInput: handleControllerTertiaryActivateInput, }); + useEffect(() => { + let raf = 0; + const prev = { pressed: false }; + const tick = () => { + try { + if (streamStatus !== "streaming") { + prev.pressed = false; + raf = window.requestAnimationFrame(tick); + return; + } + const pads = navigator.getGamepads ? navigator.getGamepads() : []; + const pad = Array.from(pads).find((p) => p && p.connected) ?? null; + if (!pad) { + prev.pressed = false; + raf = window.requestAnimationFrame(tick); + return; + } + // Meta/Home button only: button 16 (standard) + const metaPressed = Boolean(pad.buttons[16]?.pressed); + if (metaPressed && !prev.pressed) { + setControllerOverlayOpen((v) => !v); + } + prev.pressed = metaPressed; + } catch { + // ignore + } + raf = window.requestAnimationFrame(tick); + }; + raf = window.requestAnimationFrame(tick); + return () => { if (raf) window.cancelAnimationFrame(raf); }; + }, [streamStatus]); + // Refs const videoRef = useRef(null); const audioRef = useRef(null); @@ -410,8 +611,48 @@ export function App(): JSX.Element { const hasInitializedRef = useRef(false); const regionsRequestRef = useRef(0); const launchInFlightRef = useRef(false); + const streamStatusRef = useRef(streamStatus); const exitPromptResolverRef = useRef<((confirmed: boolean) => void) | null>(null); + useEffect(() => { + controllerOverlayOpenRef.current = controllerOverlayOpen; + if (clientRef.current) { + clientRef.current.inputPaused = controllerOverlayOpen; + } + }, [controllerOverlayOpen]); + + useEffect(() => { + if (!controllerOverlayOpen) return; + const overlay = document.querySelector(".controller-overlay"); + if (!overlay) return; + const selector = [ + "button", + "a[href]", + "input:not([type='hidden'])", + "select", + "textarea", + "[role='button']", + "[tabindex]:not([tabindex='-1'])", + ].join(","); + const candidates = Array.from(overlay.querySelectorAll(selector)) as HTMLElement[]; + const first = candidates.find((el) => { + const style = window.getComputedStyle(el); + if (style.visibility === "hidden" || style.display === "none") return false; + if ((el as HTMLButtonElement | HTMLInputElement | any).disabled) return false; + return el.tabIndex >= 0; + }); + if (first) { + document.querySelectorAll(".controller-focus").forEach((n) => n.classList.remove("controller-focus")); + first.classList.add("controller-focus"); + try { + first.focus({ preventScroll: true }); + } catch { + /* ignore */ + } + first.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + }, [controllerOverlayOpen]); + const applyVariantSelections = useCallback((catalog: GameInfo[]): void => { setVariantByGameId((prev) => mergeVariantSelections(prev, catalog)); }, []); @@ -444,6 +685,28 @@ export function App(): JSX.Element { sessionRef.current = session; }, [session]); + // Keep a ref copy of `streamStatus` so async callbacks can observe latest value + useEffect(() => { + streamStatusRef.current = streamStatus; + }, [streamStatus]); + + // Broadcast minimal session/loading state for UI overlays (controller + other listeners) + useEffect(() => { + const detail = { + status: streamStatus, + queuePosition, + launchError: launchError ? { title: launchError.title, description: launchError.description, stage: launchError.stage, codeLabel: launchError.codeLabel } : null, + gameTitle: streamingGame?.title ?? null, + gameCover: streamingGame?.imageUrl ?? null, + platformStore: streamingStore ?? null, + }; + try { + window.dispatchEvent(new CustomEvent("opennow:session-update", { detail })); + } catch { + // ignore + } + }, [streamStatus, queuePosition, launchError, streamingGame, streamingStore]); + useEffect(() => { document.body.classList.toggle("controller-mode", controllerConnected); return () => { @@ -451,6 +714,42 @@ export function App(): JSX.Element { }; }, [controllerConnected]); + useEffect(() => { + if (!controllerConnected) { + document.body.classList.remove("controller-hide-cursor"); + return; + } + + const IDLE_MS = 1300; + let timeoutId: number | null = null; + + const hideCursor = () => { + document.body.classList.add("controller-hide-cursor"); + }; + + const showCursor = () => { + document.body.classList.remove("controller-hide-cursor"); + if (timeoutId != null) { + window.clearTimeout(timeoutId); + } + timeoutId = window.setTimeout(hideCursor, IDLE_MS) as unknown as number; + }; + + const onMouseMove = (): void => showCursor(); + + // Start visible then hide after timeout + showCursor(); + document.addEventListener("mousemove", onMouseMove, { passive: true }); + + return () => { + if (timeoutId != null) { + window.clearTimeout(timeoutId); + } + document.removeEventListener("mousemove", onMouseMove); + document.body.classList.remove("controller-hide-cursor"); + }; + }, [controllerConnected]); + // Derived state const selectedProvider = useMemo(() => { return providers.find((p) => p.idpId === providerIdpId) ?? authSession?.provider ?? null; @@ -627,6 +926,14 @@ export function App(): JSX.Element { void initialize(); }, []); + // Auto-load controller library at startup if enabled + useEffect(() => { + if (isInitializing || !authSession || !settings.controllerMode || !settings.autoLoadControllerLibrary || currentPage !== "home") { + return; + } + setCurrentPage("library"); + }, [isInitializing, authSession, settings.controllerMode, settings.autoLoadControllerLibrary, currentPage]); + const shortcuts = useMemo(() => { const parseWithFallback = (value: string, fallback: string) => { const parsed = normalizeShortcut(value); @@ -897,6 +1204,14 @@ export function App(): JSX.Element { }); setLaunchError(null); setStreamStatus("streaming"); + // Auto-enter fullscreen on stream start if user enabled it + try { + if ((settings as any).autoFullScreen) { + void (window as any).openNow?.setFullscreen?.(true); + } + } catch (err) { + console.warn("Failed to auto-fullscreen on stream start:", err); + } } } else if (event.type === "remote-ice") { await clientRef.current?.addRemoteCandidate(event.candidate); @@ -951,6 +1266,13 @@ export function App(): JSX.Element { void updateSetting("mouseSensitivity", value); }, [updateSetting]); + const handleToggleFavoriteGame = useCallback((gameId: string): void => { + const favorites = settings.favoriteGameIds; + const exists = favorites.includes(gameId); + const next = exists ? favorites.filter((id) => id !== gameId) : [...favorites, gameId]; + void updateSetting("favoriteGameIds", next); + }, [settings.favoriteGameIds, updateSetting]); + const handleMouseAccelerationChange = useCallback((value: number) => { void updateSetting("mouseAcceleration", value); }, [updateSetting]); @@ -1116,10 +1438,17 @@ export function App(): JSX.Element { }, [authSession, effectiveStreamingBaseUrl, settings]); // Play game handler - const handlePlayGame = useCallback(async (game: GameInfo) => { + const handlePlayGame = useCallback(async (game: GameInfo, options?: { bypassGuards?: boolean }) => { if (!selectedProvider) return; - if (launchInFlightRef.current || streamStatus !== "idle") { + console.log("handlePlayGame entry", { + title: game.title, + launchInFlight: launchInFlightRef.current, + streamStatus, + bypass: options?.bypassGuards ?? false, + }); + + if (!options?.bypassGuards && (launchInFlightRef.current || streamStatus !== "idle")) { console.warn("Ignoring play request: launch already in progress or stream not idle", { inFlight: launchInFlightRef.current, streamStatus, @@ -1142,6 +1471,7 @@ export function App(): JSX.Element { const selectedVariant = getSelectedVariant(game, selectedVariantId); setStreamingGame(game); setStreamingStore(selectedVariant?.store ?? null); + startPlaytimeSession(game.id); updateLoadingStep("queue"); setQueuePosition(undefined); @@ -1379,12 +1709,57 @@ export function App(): JSX.Element { clientRef.current?.dispose(); clientRef.current = null; setNavbarActiveSession(null); + if (streamingGame) endPlaytimeSession(streamingGame.id); resetLaunchRuntime(); void refreshNavbarActiveSession(); } catch (error) { console.error("Stop failed:", error); } - }, [authSession, refreshNavbarActiveSession, resetLaunchRuntime, resolveExitPrompt]); + }, [authSession, endPlaytimeSession, refreshNavbarActiveSession, resetLaunchRuntime, resolveExitPrompt, streamingGame]); + + const handleSwitchGame = useCallback(async (game: GameInfo) => { + setControllerOverlayOpen(false); + setPendingSwitchGameTitle(game.title ?? null); + setPendingSwitchGameCover(game.imageUrl ?? null); + setIsSwitchingGame(true); + setSwitchingPhase("cleaning"); + // allow overlay to paint + await new Promise((resolve) => window.setTimeout(resolve, 140)); + try { + await handleStopStream(); + } catch (e) { + console.error("Error while cleaning up stream during switch:", e); + } + setSwitchingPhase("creating"); + // ensure runtime flags/state reflect the stopped session before launching again + launchInFlightRef.current = false; + setStreamStatus("idle"); + // give the render loop a frame so React state updates propagate + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + // small pause so user sees transition + await new Promise((resolve) => window.setTimeout(resolve, 120)); + + // Wait until the play guard conditions are satisfied (defensive against races) + const ready = await waitFor(() => !launchInFlightRef.current && streamStatusRef.current === "idle", { timeout: 1000, interval: 50 }); + if (!ready) { + console.warn("Switch flow: runtime not ready for new launch after cleanup", { + launchInFlight: launchInFlightRef.current, + streamStatus: streamStatusRef.current, + }); + // Do a small additional delay before aborting the automatic start to avoid nav-to-dashboard + await sleep(250); + } + + try { + await handlePlayGame(game, { bypassGuards: true }); + } catch (e) { + console.error("Error while starting new stream during switch:", e); + } + setIsSwitchingGame(false); + setSwitchingPhase(null); + setPendingSwitchGameTitle(null); + setPendingSwitchGameCover(null); + }, [handleStopStream, handlePlayGame]); const handleDismissLaunchError = useCallback(async () => { await window.openNow.disconnectSignaling().catch(() => {}); @@ -1627,7 +2002,7 @@ export function App(): JSX.Element { ); } - const showLaunchOverlay = streamStatus !== "idle" || launchError !== null; + const showLaunchOverlay = streamStatus !== "idle" || launchError !== null || isSwitchingGame; const consumedHours = streamStatus === "streaming" ? Math.floor(sessionElapsedSeconds / 60) / 60 @@ -1641,6 +2016,7 @@ export function App(): JSX.Element { <> {streamStatus !== "idle" && ( )} - {streamStatus !== "streaming" && ( + {isSwitchingGame && settings.controllerMode && ( + + )} + {isSwitchingGame && !settings.controllerMode && ( + { + if (launchError) { + void handleDismissLaunchError(); + return; + } + void handlePromptedStopStream(); + }} + /> + )} + {streamStatus === "streaming" && controllerOverlayOpen && ( +
+ setCurrentPage("settings")} + currentStreamingGame={streamingGame} + onResumeGame={() => setControllerOverlayOpen(false)} + onCloseGame={async () => { + setControllerOverlayOpen(false); + // allow overlay close animation to play + await sleep(300); + await releasePointerLockIfNeeded(); + await handleStopStream(); + }} + pendingSwitchGameCover={pendingSwitchGameCover} + userName={authSession?.user.displayName} + userAvatarUrl={authSession?.user.avatarUrl} + remainingPlaytimeText={remainingPlaytimeText} + playtimeData={playtime} + sessionElapsedSeconds={sessionElapsedSeconds} + settings={{ + resolution: settings.resolution, + fps: settings.fps, + codec: settings.codec, + controllerUiSounds: settings.controllerUiSounds, + controllerBackgroundAnimations: settings.controllerBackgroundAnimations, + autoLoadControllerLibrary: settings.autoLoadControllerLibrary, + autoFullScreen: settings.autoFullScreen, + aspectRatio: settings.aspectRatio, + maxBitrateMbps: settings.maxBitrateMbps, + }} + resolutionOptions={getResolutionsByAspectRatio(settings.aspectRatio)} + fpsOptions={fpsOptions} + codecOptions={codecOptions} + aspectRatioOptions={aspectRatioOptions as unknown as string[]} + onSettingChange={updateSetting} + /> +
+ )} + {streamStatus !== "idle" && streamStatus !== "streaming" && settings.controllerMode && ( + + )} + {streamStatus !== "idle" && streamStatus !== "streaming" && !settings.controllerMode && ( )} - { - void handleResumeFromNavbar(); - }} - onLogout={handleLogout} - /> + {!(settings.controllerMode && currentPage === "library") && ( + { + void handleResumeFromNavbar(); + }} + onLogout={handleLogout} + /> + )}
{currentPage === "home" && ( @@ -1777,17 +2252,58 @@ export function App(): JSX.Element { )} {currentPage === "library" && ( - + settings.controllerMode ? ( + setCurrentPage("settings")} + currentStreamingGame={streamingGame} + onResumeGame={handlePlayGame} + onCloseGame={handlePromptedStopStream} + pendingSwitchGameCover={pendingSwitchGameCover} + userName={authSession?.user.displayName} + userAvatarUrl={authSession?.user.avatarUrl} + remainingPlaytimeText={remainingPlaytimeText} + playtimeData={playtime} + sessionElapsedSeconds={sessionElapsedSeconds} + settings={{ + resolution: settings.resolution, + fps: settings.fps, + codec: settings.codec, + controllerUiSounds: settings.controllerUiSounds, + controllerBackgroundAnimations: settings.controllerBackgroundAnimations, + autoLoadControllerLibrary: settings.autoLoadControllerLibrary, + autoFullScreen: settings.autoFullScreen, + aspectRatio: settings.aspectRatio, + maxBitrateMbps: settings.maxBitrateMbps, + }} + resolutionOptions={getResolutionsByAspectRatio(settings.aspectRatio)} + fpsOptions={fpsOptions} + codecOptions={codecOptions} + aspectRatioOptions={aspectRatioOptions as unknown as string[]} + onSettingChange={updateSetting} + /> + ) : ( + + ) )} {currentPage === "settings" && ( @@ -1798,7 +2314,7 @@ export function App(): JSX.Element { /> )}
- {controllerConnected && ( + {controllerConnected && !(settings.controllerMode && currentPage === "library") && (
D-pad Navigate A Select diff --git a/opennow-stable/src/renderer/src/assets/opennow-logo.png b/opennow-stable/src/renderer/src/assets/opennow-logo.png new file mode 100644 index 0000000..7e946b4 Binary files /dev/null and b/opennow-stable/src/renderer/src/assets/opennow-logo.png differ diff --git a/opennow-stable/src/renderer/src/components/ControllerButtons.tsx b/opennow-stable/src/renderer/src/components/ControllerButtons.tsx new file mode 100644 index 0000000..d6087dd --- /dev/null +++ b/opennow-stable/src/renderer/src/components/ControllerButtons.tsx @@ -0,0 +1,107 @@ +import type { JSX } from "react"; + +interface ButtonProps { + className?: string; + size?: number; +} + +export function ButtonA({ className, size = 18 }: ButtonProps): JSX.Element { + return ( + + + A + + ); +} + +export function ButtonB({ className, size = 18 }: ButtonProps): JSX.Element { + return ( + + + B + + ); +} + +export function ButtonX({ className, size = 18 }: ButtonProps): JSX.Element { + return ( + + + X + + ); +} + +export function ButtonY({ className, size = 18 }: ButtonProps): JSX.Element { + return ( + + + Y + + ); +} + +// PlayStation-style icons +export function ButtonPSCross({ className, size = 18 }: ButtonProps): JSX.Element { + return ( + + + + + ); +} + +export function ButtonPSCircle({ className, size = 18 }: ButtonProps): JSX.Element { + return ( + + + + + ); +} + +export function ButtonPSSquare({ className, size = 18 }: ButtonProps): JSX.Element { + return ( + + + + + ); +} + +export function ButtonPSTriangle({ className, size = 18 }: ButtonProps): JSX.Element { + return ( + + + + + ); +} diff --git a/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx new file mode 100644 index 0000000..9429720 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/ControllerLibraryPage.tsx @@ -0,0 +1,1174 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { JSX } from "react"; +import type { GameInfo, MediaListingEntry, Settings } from "@shared/gfn"; +import { Star, Clock, Calendar, Repeat2 } from "lucide-react"; +import { ButtonA, ButtonB, ButtonX, ButtonY, ButtonPSCross, ButtonPSCircle, ButtonPSSquare, ButtonPSTriangle } from "./ControllerButtons"; +import { getStoreDisplayName } from "./GameCard"; +import { type PlaytimeStore, formatPlaytime, formatLastPlayed } from "../utils/usePlaytime"; + +interface ControllerLibraryPageProps { + games: GameInfo[]; + isLoading: boolean; + selectedGameId: string; + uiSoundsEnabled: boolean; + selectedVariantByGameId: Record; + favoriteGameIds: string[]; + userName?: string; + userAvatarUrl?: string; + remainingPlaytimeText?: string; + playtimeData?: PlaytimeStore; + onSelectGame: (id: string) => void; + onSelectGameVariant: (gameId: string, variantId: string) => void; + onToggleFavoriteGame: (gameId: string) => void; + onPlayGame: (game: GameInfo) => void; + onOpenSettings?: () => void; + currentStreamingGame?: GameInfo | null; + onResumeGame?: (game: GameInfo) => void; + onCloseGame?: () => void; + pendingSwitchGameCover?: string | null; + settings?: { + resolution?: string; + fps?: number; + codec?: string; + controllerUiSounds?: boolean; + controllerBackgroundAnimations?: boolean; + autoLoadControllerLibrary?: boolean; + autoFullScreen?: boolean; + aspectRatio?: string; + maxBitrateMbps?: number; + }; + resolutionOptions?: string[]; + fpsOptions?: number[]; + codecOptions?: string[]; + aspectRatioOptions?: string[]; + onSettingChange?: (key: K, value: Settings[K]) => void; + sessionElapsedSeconds?: number; +} + +type Direction = "up" | "down" | "left" | "right"; +type TopCategory = "current" | "all" | "settings" | "media" | "favorites" | `genre:${string}`; +type SoundKind = "move" | "confirm"; +type SettingsSubcategory = "root" | "Network" | "Audio" | "Video" | "System"; +type MediaSubcategory = "root" | "Videos" | "Screenshots"; + +const CATEGORY_STEP_PX = 160; +const CATEGORY_ACTIVE_HALF_WIDTH_PX = 60; +const GAME_ACTIVE_CENTER_OFFSET_X_PX = 320; + +function sanitizeGenreName(raw: string): string { + return raw + .replace(/_/g, " ") + .replace(/\b\w/g, (ch) => ch.toUpperCase()); +} + +function getCategoryLabel(categoryId: string, currentGameTitle?: string): { label: string } { + if (categoryId === "current") return { label: currentGameTitle || "Current" }; + if (categoryId === "all") return { label: "All" }; + if (categoryId === "settings") return { label: "Settings" }; + if (categoryId === "media") return { label: "Media" }; + if (categoryId === "favorites") return { label: "Favorites" }; + const genreName = sanitizeGenreName(categoryId.slice(6)); + const shorthand: Record = { + "massively multiplayer online battle arena": "MOBA", + "massively multiplayer online": "MMO", + "multiplayer online battle arena": "MOBA", + "first person shooter": "FPS", + "role playing game": "RPG", + "real time strategy": "RTS", + "simulation": "Sim", + "virtual reality": "VR", + "third person shooter": "TPS", + }; + const normalized = genreName.toLowerCase(); + const display = shorthand[normalized] ?? genreName; + return { label: display }; +} + + +export function ControllerLibraryPage({ + games, + isLoading, + selectedGameId, + selectedVariantByGameId, + uiSoundsEnabled, + favoriteGameIds, + onSelectGame, + onSelectGameVariant, + onToggleFavoriteGame, + onPlayGame, + onOpenSettings, + currentStreamingGame, + onResumeGame, + onCloseGame, + pendingSwitchGameCover, + userName = "Player One", + userAvatarUrl, + remainingPlaytimeText = "--", + playtimeData = {}, + settings = {}, + resolutionOptions = [], + fpsOptions = [], + codecOptions = [], + aspectRatioOptions = [], + onSettingChange, + sessionElapsedSeconds = 0, +}: ControllerLibraryPageProps): JSX.Element { + const initialCategoryIndex = (() => { + const hasFavorites = Array.isArray(favoriteGameIds) && favoriteGameIds.length > 0; + if (currentStreamingGame) { + // TOP_CATEGORIES: current, settings, all, favorites, ...genres + return hasFavorites ? 3 : 0; + } + // TOP_CATEGORIES without `current`: settings, all, favorites, ...genres + return hasFavorites ? 2 : 1; + })(); + const [categoryIndex, setCategoryIndex] = useState(initialCategoryIndex); + const audioContextRef = useRef(null); + const itemsContainerRef = useRef(null); + const currentPosterImgRef = useRef(null); + const [metaMaxWidth, setMetaMaxWidth] = useState(null); + const posterObserverRef = useRef(null); + const attachPosterRef = (el: HTMLImageElement | null) => { + if (posterObserverRef.current) { + try { posterObserverRef.current.disconnect(); } catch {} + posterObserverRef.current = null; + } + currentPosterImgRef.current = el; + const update = () => setMetaMaxWidth(currentPosterImgRef.current?.clientWidth ?? null); + if (el) { + if (typeof ResizeObserver !== "undefined") { + const ro = new ResizeObserver(update); + posterObserverRef.current = ro; + try { ro.observe(el); } catch {} + } + update(); + } else { + setMetaMaxWidth(null); + } + }; + const [listTranslateY, setListTranslateY] = useState(0); + const favoriteGameIdSet = useMemo(() => new Set(favoriteGameIds), [favoriteGameIds]); + const [time, setTime] = useState(new Date()); + const [selectedSettingIndex, setSelectedSettingIndex] = useState(0); + const [microphoneDevices, setMicrophoneDevices] = useState<{ deviceId: string; label: string }[]>([]); + const [settingsSubcategory, setSettingsSubcategory] = useState("root"); + const [lastRootSettingIndex, setLastRootSettingIndex] = useState(0); + const [mediaSubcategory, setMediaSubcategory] = useState("root"); + const [lastRootMediaIndex, setLastRootMediaIndex] = useState(0); + const [selectedMediaIndex, setSelectedMediaIndex] = useState(0); + const [mediaLoading, setMediaLoading] = useState(false); + const [mediaError, setMediaError] = useState(null); + const [mediaVideos, setMediaVideos] = useState([]); + const [mediaScreenshots, setMediaScreenshots] = useState([]); + const [mediaThumbById, setMediaThumbById] = useState>({}); + const [controllerType, setControllerType] = useState<"ps" | "xbox" | "nintendo" | "generic">("generic"); + const [editingBandwidth, setEditingBandwidth] = useState(false); + + useEffect(() => { + const timer = setInterval(() => setTime(new Date()), 1000); + return () => clearInterval(timer); + }, []); + + // poster measurement handled by `attachPosterRef` callback ref + + useEffect(() => { + const detectTypeFromGamepad = (g: Gamepad | null): "ps" | "xbox" | "nintendo" | "generic" => { + if (!g || !g.id) return "generic"; + const id = g.id.toLowerCase(); + if (id.includes("wireless controller") || id.includes("dualshock") || id.includes("dualsense") || id.includes("054c")) return "ps"; + if (id.includes("xbox") || id.includes("x-input") || id.includes("xinput") || id.includes("xusb")) return "xbox"; + if (id.includes("nintendo") || id.includes("pro controller") || id.includes("joy-con") || id.includes("joycon")) return "nintendo"; + return "generic"; + }; + + const updateFromConnected = () => { + try { + const pads = navigator.getGamepads ? navigator.getGamepads() : []; + for (const p of pads) { + if (p && p.connected) { + setControllerType(detectTypeFromGamepad(p)); + return; + } + } + setControllerType("generic"); + } catch { + setControllerType("generic"); + } + }; + + window.addEventListener("gamepadconnected", updateFromConnected); + window.addEventListener("gamepaddisconnected", updateFromConnected); + updateFromConnected(); + return () => { + window.removeEventListener("gamepadconnected", updateFromConnected); + window.removeEventListener("gamepaddisconnected", updateFromConnected); + }; + }, []); + + const formatTime = (date: Date) => { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + const formatElapsed = (totalSeconds: number) => { + const safe = Math.max(0, Math.floor(totalSeconds)); + const hours = Math.floor(safe / 3600); + const minutes = Math.floor((safe % 3600) / 60); + const seconds = safe % 60; + if (hours > 0) return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + const playUiSound = useCallback((kind: SoundKind): void => { + if (!uiSoundsEnabled) return; + const audioContext = audioContextRef.current ?? new AudioContext(); + audioContextRef.current = audioContext; + if (audioContext.state === "suspended") void audioContext.resume(); + + const now = audioContext.currentTime; + const oscillator = audioContext.createOscillator(); + const gain = audioContext.createGain(); + + const profile: Record = { + move: { start: 720, end: 680, duration: 0.04, volume: 0.02, type: "triangle" }, + confirm: { start: 640, end: 860, duration: 0.1, volume: 0.04, type: "sine" }, + }; + + const active = profile[kind]; + oscillator.type = active.type; + oscillator.frequency.setValueAtTime(active.start, now); + oscillator.frequency.exponentialRampToValueAtTime(active.end, now + active.duration); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(active.volume, now + 0.012); + gain.gain.exponentialRampToValueAtTime(0.0001, now + active.duration); + + oscillator.connect(gain); + gain.connect(audioContext.destination); + oscillator.start(now); + oscillator.stop(now + active.duration + 0.01); + }, [uiSoundsEnabled]); + + const allGenres = useMemo(() => { + const genreSet = new Set(); + for (const game of games) { + if (game.genres && Array.isArray(game.genres)) { + for (const genre of game.genres) genreSet.add(genre); + } + } + return Array.from(genreSet).sort(); + }, [games]); + + const TOP_CATEGORIES = useMemo(() => { + const categories: Array<{ id: TopCategory; label: string }> = []; + if (currentStreamingGame) { + categories.push({ id: "current", label: currentStreamingGame.title || "Current Game" }); + } + categories.push({ id: "settings", label: "Settings" }); + categories.push({ id: "all", label: "All" }); + categories.push({ id: "favorites", label: "Favorites" }); + for (const genre of allGenres) categories.push({ id: `genre:${genre}`, label: sanitizeGenreName(genre) }); + return categories; + }, [allGenres, currentStreamingGame]); + + const topCategory = (TOP_CATEGORIES[categoryIndex]?.id ?? "all") as unknown as string; + + useEffect(() => { + if (TOP_CATEGORIES.length === 0) return; + setCategoryIndex((prev) => Math.max(0, Math.min(prev, TOP_CATEGORIES.length - 1))); + }, [TOP_CATEGORIES.length]); + + const settingsBySubcategory = useMemo(() => { + const micLabel = (() => { + const id = (settings as any).microphoneDeviceId as string | undefined; + if (!id) return "Default"; + const found = microphoneDevices.find(d => d.deviceId === id); + return found?.label ?? id; + })(); + + return { + root: [ + { id: "network", label: "Network", value: "" }, + { id: "audio", label: "Audio", value: "" }, + { id: "video", label: "Video", value: "" }, + { id: "system", label: "System", value: "" }, + { id: "exit", label: "Exit Controller Mode", value: "" }, + ], + Network: [ + { id: "bandwidth", label: "Max Bitrate", value: `${(settings.maxBitrateMbps ?? 75)} Mbps` }, + ], + Video: [ + { id: "resolution", label: "Resolution", value: settings.resolution || "1920x1080" }, + { id: "aspectRatio", label: "Aspect Ratio", value: settings.aspectRatio || "16:9" }, + { id: "fps", label: "Frame Rate", value: `${settings.fps || 60} FPS` }, + { id: "codec", label: "Video Codec", value: settings.codec || "H264" }, + ], + Audio: [ + { id: "microphone", label: "Microphone", value: micLabel }, + { id: "sounds", label: "UI Sounds", value: settings.controllerUiSounds ? "On" : "Off" }, + ], + System: [ + { id: "autoFullScreen", label: "Auto Full Screen", value: (settings as any).autoFullScreen ? "On" : "Off" }, + { id: "autoLoad", label: "Auto-Load Library", value: (settings as any).autoLoadControllerLibrary ? "On" : "Off" }, + { id: "backgroundAnimations", label: "Background Animations", value: ((settings as any).controllerBackgroundAnimations ? "On" : "Off") }, + ], + } as Record>; + }, [settings, microphoneDevices]); + + const currentGameItems = useMemo(() => [ + { id: "resume", label: "Resume Game", value: "" }, + { id: "closeGame", label: "Close Game", value: "" }, + ], []); + + const mediaRootItems = useMemo(() => [ + { id: "videos", label: "Videos", value: "" }, + { id: "screenshots", label: "Screenshots", value: "" }, + ], []); + + const mediaAssetItems = useMemo(() => { + if (mediaSubcategory === "Videos") return mediaVideos; + if (mediaSubcategory === "Screenshots") return mediaScreenshots; + return []; + }, [mediaSubcategory, mediaVideos, mediaScreenshots]); + + const displayItems = useMemo(() => { + if (topCategory === "current") return currentGameItems; + if (topCategory === "settings") return settingsBySubcategory[settingsSubcategory] ?? []; + if (topCategory === "media" && mediaSubcategory === "root") return mediaRootItems; + return []; + }, [topCategory, currentGameItems, settingsBySubcategory, settingsSubcategory, mediaSubcategory, mediaRootItems]); + + useEffect(() => { + let mounted = true; + if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return; + navigator.mediaDevices.enumerateDevices().then(devs => { + if (!mounted) return; + const mics = devs + .filter(d => d.kind === "audioinput") + .map(d => ({ deviceId: d.deviceId, label: d.label || "Microphone" })); + // Ensure there's at least a default entry + if (mics.length === 0) mics.push({ deviceId: "", label: "Default" }); + setMicrophoneDevices(mics); + }).catch(() => { + if (!mounted) return; + setMicrophoneDevices([{ deviceId: "", label: "Default" }]); + }); + return () => { mounted = false; }; + }, []); + + useEffect(() => { + if (topCategory !== "media" || mediaSubcategory === "root") return; + if (typeof window.openNow?.listMediaByGame !== "function") { + setMediaVideos([]); + setMediaScreenshots([]); + setMediaThumbById({}); + setMediaError("Media API unavailable"); + setMediaLoading(false); + return; + } + + let cancelled = false; + const loadMedia = async () => { + try { + setMediaLoading(true); + setMediaError(null); + const listing = await window.openNow.listMediaByGame({}); + if (cancelled) return; + + const videos = [...(listing.videos ?? [])].sort((a, b) => b.createdAtMs - a.createdAtMs); + const screenshots = [...(listing.screenshots ?? [])].sort((a, b) => b.createdAtMs - a.createdAtMs); + + setMediaVideos(videos); + setMediaScreenshots(screenshots); + + const allItems = [...videos, ...screenshots]; + const thumbEntries = await Promise.all( + allItems.map(async (item): Promise<[string, string | null]> => { + if (item.thumbnailDataUrl) return [item.id, item.thumbnailDataUrl]; + if (item.dataUrl) return [item.id, item.dataUrl]; + if (typeof window.openNow?.getMediaThumbnail === "function") { + const generated = await window.openNow.getMediaThumbnail({ filePath: item.filePath }); + return [item.id, generated]; + } + return [item.id, null]; + }), + ); + + if (cancelled) return; + const thumbMap: Record = {}; + for (const [id, url] of thumbEntries) { + if (url) thumbMap[id] = url; + } + setMediaThumbById(thumbMap); + } catch { + if (cancelled) return; + setMediaError("Failed to load media"); + } finally { + if (!cancelled) setMediaLoading(false); + } + }; + + void loadMedia(); + return () => { + cancelled = true; + }; + }, [topCategory, mediaSubcategory]); + + const categorizedGames = useMemo(() => { + if (topCategory === "settings") return []; + if (topCategory === "favorites") return games.filter((game) => favoriteGameIdSet.has(game.id)); + if (topCategory.startsWith("genre:")) { + const genreName = topCategory.slice(6); + return games.filter((game) => game.genres?.includes(genreName)); + } + return games; + }, [games, favoriteGameIdSet, topCategory]); + + const selectedIndex = useMemo(() => { + const index = categorizedGames.findIndex((game) => game.id === selectedGameId); + return index >= 0 ? index : 0; + }, [categorizedGames, selectedGameId]); + + const selectedGame = useMemo(() => categorizedGames[selectedIndex] ?? null, [categorizedGames, selectedIndex]); + + const selectedVariantId = useMemo(() => { + if (!selectedGame) return ""; + const current = selectedVariantByGameId[selectedGame.id]; + return current ?? selectedGame.variants[0]?.id ?? ""; + }, [selectedGame, selectedVariantByGameId]); + + + + useEffect(() => { + const container = itemsContainerRef.current; + if (!container) return; + const children = Array.from(container.children) as HTMLElement[]; + if (children.length === 0 || selectedIndex >= children.length) return; + let offset = 0; + for (let i = 0; i < selectedIndex; i++) { + const childStyle = window.getComputedStyle(children[i]); + offset += children[i].offsetHeight + parseFloat(childStyle.marginBottom); + } + offset += children[selectedIndex].offsetHeight / 2; + setListTranslateY(-offset); + }, [selectedIndex, categorizedGames]); + + const throttledOnSelectGame = useCallback((id: string) => onSelectGame(id), [onSelectGame]); + + const toggleFavoriteForSelected = useCallback(() => { + if (selectedGame) { + onToggleFavoriteGame(selectedGame.id); + playUiSound("confirm"); + } + }, [onToggleFavoriteGame, playUiSound, selectedGame]); + + useEffect(() => { + const applyDirection = (direction: Direction): void => { + // When editing the bandwidth slider, use left/right to adjust value + if (topCategory === "settings" && settingsSubcategory !== "root" && editingBandwidth) { + const step = 5; // Mbps per left/right press + const current = settings.maxBitrateMbps ?? 75; + if (direction === "left") { + const next = Math.max(5, current - step); + onSettingChange && onSettingChange("maxBitrateMbps" as any, next as any); + playUiSound("move"); + return; + } + if (direction === "right") { + const next = Math.min(150, current + step); + onSettingChange && onSettingChange("maxBitrateMbps" as any, next as any); + playUiSound("move"); + return; + } + } + if (isLoading && topCategory !== "settings" && topCategory !== "current") return; + if (direction === "left") { + playUiSound("move"); + // Cycle main categories (settings always resets to root) + setCategoryIndex((prev) => (prev - 1 + TOP_CATEGORIES.length) % TOP_CATEGORIES.length); + setSelectedSettingIndex(0); + setSettingsSubcategory("root"); + setSelectedMediaIndex(0); + setMediaSubcategory("root"); + return; + } + if (direction === "right") { + playUiSound("move"); + // Cycle main categories (settings always resets to root) + setCategoryIndex((prev) => (prev + 1) % TOP_CATEGORIES.length); + setSelectedSettingIndex(0); + setSettingsSubcategory("root"); + setSelectedMediaIndex(0); + setMediaSubcategory("root"); + return; + } + if (topCategory === "current" || topCategory === "settings") { + if (direction === "up") { + const nextIndex = Math.max(0, selectedSettingIndex - 1); + if (nextIndex !== selectedSettingIndex) { + playUiSound("move"); + setSelectedSettingIndex(nextIndex); + } + return; + } + if (direction === "down") { + const nextIndex = Math.min(displayItems.length - 1, selectedSettingIndex + 1); + if (nextIndex !== selectedSettingIndex) { + playUiSound("move"); + setSelectedSettingIndex(nextIndex); + } + return; + } + return; + } + if (topCategory === "media") { + const itemCount = mediaSubcategory === "root" ? displayItems.length : mediaAssetItems.length; + if (itemCount === 0) return; + if (direction === "up") { + const nextIndex = Math.max(0, selectedMediaIndex - 1); + if (nextIndex !== selectedMediaIndex) { + playUiSound("move"); + setSelectedMediaIndex(nextIndex); + } + return; + } + if (direction === "down") { + const nextIndex = Math.min(itemCount - 1, selectedMediaIndex + 1); + if (nextIndex !== selectedMediaIndex) { + playUiSound("move"); + setSelectedMediaIndex(nextIndex); + } + return; + } + return; + } + if (categorizedGames.length === 0) return; + if (direction === "up") { + const nextIndex = Math.max(0, selectedIndex - 1); + if (nextIndex !== selectedIndex) { + playUiSound("move"); + throttledOnSelectGame(categorizedGames[nextIndex].id); + } + return; + } + if (direction === "down") { + const nextIndex = Math.min(categorizedGames.length - 1, selectedIndex + 1); + if (nextIndex !== selectedIndex) { + playUiSound("move"); + throttledOnSelectGame(categorizedGames[nextIndex].id); + } + return; + } + }; + + const handler = (e: any) => { + if (e.detail?.direction) applyDirection(e.detail.direction); + }; + + const activateHandler = () => { + // If currently editing bandwidth, A confirms and exits edit mode + if (topCategory === "settings" && settingsSubcategory !== "root" && editingBandwidth) { + setEditingBandwidth(false); + playUiSound("confirm"); + return; + } + if (topCategory === "current") { + const item = displayItems[selectedSettingIndex]; + if (item?.id === "resume" && currentStreamingGame && onResumeGame) { + onResumeGame(currentStreamingGame); + playUiSound("confirm"); + return; + } + if (item?.id === "closeGame" && onCloseGame) { + onCloseGame(); + playUiSound("confirm"); + return; + } + return; + } + if (topCategory === "settings") { + const setting = displayItems[selectedSettingIndex]; + // Enter subcategory if at root and selecting network/audio/system + if (settingsSubcategory === "root" && setting && (setting.id === "network" || setting.id === "audio" || setting.id === "video" || setting.id === "system")) { + setLastRootSettingIndex(selectedSettingIndex); + if (setting.id === "network") setSettingsSubcategory("Network"); + if (setting.id === "audio") setSettingsSubcategory("Audio"); + if (setting.id === "video") setSettingsSubcategory("Video"); + if (setting.id === "system") setSettingsSubcategory("System"); + setSelectedSettingIndex(0); + playUiSound("confirm"); + return; + } + // In subcategory, A toggles values like X does + if (settingsSubcategory !== "root") { + secondaryActivateHandler(); + return; + } + if (setting?.id === "exit" && onSettingChange) { + onSettingChange("controllerMode" as any, false as any); + playUiSound("confirm"); + const nextSettingsIndex = currentStreamingGame ? 0 : 1; + setCategoryIndex(nextSettingsIndex); + setSelectedSettingIndex(0); + return; + } + playUiSound("confirm"); + } else if (topCategory === "media") { + const item = displayItems[selectedMediaIndex]; + if (mediaSubcategory === "root" && item && (item.id === "videos" || item.id === "screenshots")) { + setLastRootMediaIndex(selectedMediaIndex); + setMediaSubcategory(item.id === "videos" ? "Videos" : "Screenshots"); + setSelectedMediaIndex(0); + playUiSound("confirm"); + return; + } + + if (mediaSubcategory !== "root") { + const selectedMedia = mediaAssetItems[selectedMediaIndex]; + if (selectedMedia && typeof window.openNow?.showMediaInFolder === "function") { + void window.openNow.showMediaInFolder({ filePath: selectedMedia.filePath }); + playUiSound("confirm"); + return; + } + } + + playUiSound("confirm"); + } else if (selectedGame) { + onPlayGame(selectedGame); + playUiSound("confirm"); + } + }; + + const secondaryActivateHandler = () => { + if (topCategory === "current") { + // X button does nothing on current game menu items + return; + } + if (topCategory === "settings") { + // X button cycles through setting values (no-op for Exit or subcategory items at root) + const setting = displayItems[selectedSettingIndex]; + if (!setting || !onSettingChange) return; + if (setting.id === "exit") return; + // Skip X cycling for subcategory items at root + if (settingsSubcategory === "root" && (setting.id === "network" || setting.id === "audio" || setting.id === "video" || setting.id === "system")) return; + + // Microphone device cycling + if (setting.id === "microphone") { + const current = (settings as any).microphoneDeviceId as string | undefined; + const list = microphoneDevices.length > 0 ? microphoneDevices : [{ deviceId: "", label: "Default" }]; + const ids = list.map(d => d.deviceId); + const curIdx = ids.indexOf(current ?? ""); + const nextIdx = (curIdx + 1) % ids.length; + onSettingChange("microphoneDeviceId" as any, ids[nextIdx] as any); + playUiSound("move"); + return; + } + + if (setting.id === "aspectRatio" && aspectRatioOptions.length > 0) { + const currentIdx = aspectRatioOptions.indexOf(settings.aspectRatio || "16:9"); + const nextIdx = (currentIdx + 1) % aspectRatioOptions.length; + onSettingChange("aspectRatio", aspectRatioOptions[nextIdx] as any); + playUiSound("move"); + } else if (setting.id === "resolution" && resolutionOptions.length > 0) { + const currentIdx = resolutionOptions.indexOf(settings.resolution || "1920x1080"); + const nextIdx = (currentIdx + 1) % resolutionOptions.length; + onSettingChange("resolution", resolutionOptions[nextIdx]); + playUiSound("move"); + } else if (setting.id === "fps" && fpsOptions.length > 0) { + const currentIdx = fpsOptions.indexOf(settings.fps || 60); + const nextIdx = (currentIdx + 1) % fpsOptions.length; + onSettingChange("fps", fpsOptions[nextIdx]); + playUiSound("move"); + } else if (setting.id === "codec" && codecOptions.length > 0) { + const currentIdx = codecOptions.indexOf(settings.codec || "H264"); + const nextIdx = (currentIdx + 1) % codecOptions.length; + onSettingChange("codec", codecOptions[nextIdx] as any); + playUiSound("move"); + } else if (setting.id === "sounds") { + onSettingChange("controllerUiSounds", !(settings.controllerUiSounds || false)); + playUiSound("move"); + } else if (setting.id === "autoLoad") { + onSettingChange("autoLoadControllerLibrary", !((settings as any).autoLoadControllerLibrary || false)); + playUiSound("move"); + } else if (setting.id === "autoFullScreen") { + onSettingChange("autoFullScreen" as any, !((settings as any).autoFullScreen || false)); + playUiSound("move"); + } else if (setting.id === "backgroundAnimations") { + onSettingChange("controllerBackgroundAnimations" as any, !((settings as any).controllerBackgroundAnimations || false)); + playUiSound("move"); + } + else if (setting.id === "bandwidth") { + // Enter bandwidth edit mode so d-pad left/right adjust value + setEditingBandwidth(true); + playUiSound("move"); + } + return; + } + if (selectedGame && selectedGame.variants.length > 1) { + const idx = selectedGame.variants.findIndex(v => v.id === selectedVariantId); + const next = selectedGame.variants[(idx + 1) % selectedGame.variants.length]; + onSelectGameVariant(selectedGame.id, next.id); + playUiSound("move"); + } + }; + + const tertiaryActivateHandler = () => { + if (topCategory !== "settings" && topCategory !== "current") { + toggleFavoriteForSelected(); + } + }; + + const cancelHandler = (e: Event) => { + // Circle/B button goes back from subcategory to root. + // Prevent default to signal the App-level back handler that we've handled it. + if (topCategory === "settings" && settingsSubcategory !== "root") { + if (editingBandwidth) { + setEditingBandwidth(false); + playUiSound("move"); + e.preventDefault(); + return; + } + setSettingsSubcategory("root"); + setSelectedSettingIndex(lastRootSettingIndex); + playUiSound("move"); + e.preventDefault(); + return; + } + if (topCategory === "media" && mediaSubcategory !== "root") { + setMediaSubcategory("root"); + setSelectedMediaIndex(lastRootMediaIndex); + playUiSound("move"); + e.preventDefault(); + } + }; + + const kbdHandler = (e: KeyboardEvent) => { + if (e.repeat) return; + if (e.key === "ArrowLeft") applyDirection("left"); + else if (e.key === "ArrowRight") applyDirection("right"); + else if (e.key === "ArrowUp") applyDirection("up"); + else if (e.key === "ArrowDown") applyDirection("down"); + else if (e.key === "Enter") activateHandler(); + else if (e.key.toLowerCase() === "x") secondaryActivateHandler(); + else if (e.key.toLowerCase() === "y") tertiaryActivateHandler(); + else if (e.key.toLowerCase() === "c" || e.key.toLowerCase() === "b") cancelHandler(e); + else if (e.key === "Escape") { + if (topCategory === "current" || topCategory === "settings") { + setCategoryIndex((prev) => (prev - 1 + TOP_CATEGORIES.length) % TOP_CATEGORIES.length); + } else { + onOpenSettings?.(); + } + } + }; + + window.addEventListener("opennow:controller-direction", handler); + window.addEventListener("opennow:controller-activate", activateHandler); + window.addEventListener("opennow:controller-secondary-activate", secondaryActivateHandler); + window.addEventListener("opennow:controller-tertiary-activate", tertiaryActivateHandler); + window.addEventListener("opennow:controller-cancel", cancelHandler); + window.addEventListener("keydown", kbdHandler); + return () => { + window.removeEventListener("opennow:controller-direction", handler); + window.removeEventListener("opennow:controller-activate", activateHandler); + window.removeEventListener("opennow:controller-secondary-activate", secondaryActivateHandler); + window.removeEventListener("opennow:controller-tertiary-activate", tertiaryActivateHandler); + window.removeEventListener("opennow:controller-cancel", cancelHandler); + window.removeEventListener("keydown", kbdHandler); + }; + }, [isLoading, TOP_CATEGORIES.length, categorizedGames, selectedIndex, selectedGame, selectedVariantId, onPlayGame, onSelectGameVariant, onOpenSettings, playUiSound, throttledOnSelectGame, toggleFavoriteForSelected, topCategory, selectedSettingIndex, selectedMediaIndex, displayItems, mediaAssetItems.length, mediaSubcategory, settings, settingsBySubcategory, settingsSubcategory, lastRootSettingIndex, lastRootMediaIndex, onSettingChange, resolutionOptions, fpsOptions, codecOptions, aspectRatioOptions, currentStreamingGame, onResumeGame, onCloseGame, editingBandwidth]); + + if (isLoading && topCategory !== "settings" && topCategory !== "current" && topCategory !== "media") return
Loading...
; + + return ( +
+
+
+
+
+ +
+
+
{formatTime(time)}
+
{remainingPlaytimeText} left
+
+
+ {userAvatarUrl ? ( + {userName} + ) : ( +
+ )} +
{userName}
+
+
+ +
+
+ {/* Use import.meta URL to avoid needing image module typings */} + OpenNow +
+
+ +
+ +
+ {TOP_CATEGORIES.map((cat, idx) => { + const isActive = idx === categoryIndex; + // Use the label already populated on TOP_CATEGORIES so "current" + // shows the streaming game's title when available. + const label = cat.label; + return ( +
+
{label}
+
+ ); + })} +
+ + {topCategory !== "settings" && topCategory !== "current" && topCategory !== "media" && ( +
+ {categorizedGames.map((game, idx) => { + const isActive = idx === selectedIndex; + const record = playtimeData[game.id]; + const totalSecs = record?.totalSeconds ?? 0; + const lastPlayedAt = record?.lastPlayedAt ?? null; + const sessionCount = record?.sessionCount ?? 0; + const playtimeLabel = formatPlaytime(totalSecs); + const lastPlayedLabel = formatLastPlayed(lastPlayedAt); + const genres = game.genres?.slice(0, 2) ?? []; + const tierLabel = game.membershipTierLabel; + + return ( +
+ {favoriteGameIdSet.has(game.id) && ( + + )} +
+ +
+
+
{game.title}
+ +
+ {(() => { + const vId = selectedVariantByGameId[game.id] || game.variants[0]?.id; + const variant = game.variants.find(v => v.id === vId) || game.variants[0]; + const storeName = getStoreDisplayName(variant?.store || ""); + return storeName ? ( + {storeName} + ) : null; + })()} + + + + {playtimeLabel} + + + + + {lastPlayedLabel} + +
+ + {isActive && ( +
+ {sessionCount > 0 && ( + + + {sessionCount === 1 ? "1 session" : `${sessionCount} sessions`} + + )} + {genres.map((g) => ( + {sanitizeGenreName(g)} + ))} + {tierLabel && ( + {tierLabel} + )} +
+ )} +
+
+ ); + })} +
+ )} + + {(topCategory === "settings" || topCategory === "current" || (topCategory === "media" && mediaSubcategory === "root")) && ( +
+ {displayItems.map((item, idx) => { + const isActive = idx === (topCategory === "media" ? selectedMediaIndex : selectedSettingIndex); + const isSubcategoryItem = settingsSubcategory === "root" && (item.id === "network" || item.id === "audio" || item.id === "video" || item.id === "system"); + const isMediaSubcategoryItem = topCategory === "media" && mediaSubcategory === "root" && (item.id === "videos" || item.id === "screenshots"); + return ( +
+
+
{item.label}
+ {item.value && ( +
+ {item.id === 'bandwidth' && settingsSubcategory !== 'root' ? ( +
+ onSettingChange && onSettingChange("maxBitrateMbps" as any, Number(e.target.value) as any)} + aria-label="Bandwidth Limit (Mbps)" + style={editingBandwidth ? {outline: '2px solid rgba(255,255,255,0.2)'} : undefined} + /> + {`${settings.maxBitrateMbps ?? 75} Mbps`}{editingBandwidth ? ' • Editing' : ''} +
+ ) : ( + {item.value} + )} +
+ )} +
+
+ ); + })} +
+ )} + + {topCategory === "media" && mediaSubcategory !== "root" && ( +
+ {mediaLoading && ( +
+
+
Loading {mediaSubcategory}...
+
+
+ )} + + {!mediaLoading && mediaError && ( +
+
+
{mediaError}
+
+
+ )} + + {!mediaLoading && !mediaError && mediaAssetItems.length === 0 && ( +
+
+
No {mediaSubcategory.toLowerCase()} found
+
+
+ )} + + {!mediaLoading && !mediaError && mediaAssetItems.map((item, idx) => { + const isActive = idx === selectedMediaIndex; + const thumb = mediaThumbById[item.id]; + const dateLabel = new Date(item.createdAtMs).toLocaleDateString(); + const durationMs = item.durationMs ?? 0; + const hasDuration = durationMs > 0; + const durationLabel = hasDuration ? `${Math.max(1, Math.round(durationMs / 1000))}s` : "Screenshot"; + + return ( +
+
+ {thumb ? :
} +
+
+
{item.gameTitle || item.fileName}
+
+ {durationLabel} + {dateLabel} +
+
+
+ ); + })} +
+ )} + +
+ {topCategory === "current" && ( +
+
+ {currentStreamingGame?.title +
+
+
{currentStreamingGame?.title ?? "Current Game"}
+
+ {(() => { + const cs = currentStreamingGame; + if (!cs) return null; + const vId = selectedVariantByGameId[cs.id] || cs.variants[0]?.id; + const variant = cs.variants.find(v => v.id === vId) || cs.variants[0]; + const storeName = getStoreDisplayName(variant?.store || ""); + const record = (playtimeData ?? {})[cs.id]; + const totalSecs = record?.totalSeconds ?? 0; + const lastPlayed = record?.lastPlayedAt ?? null; + const sessionCount = record?.sessionCount ?? 0; + const playtimeLabel = formatPlaytime(totalSecs); + const lastPlayedLabel = formatLastPlayed(lastPlayed); + const genres = cs.genres?.slice(0, 2) ?? []; + const tier = cs.membershipTierLabel; + return ( + <> + {storeName && {storeName}} + + + {formatElapsed(sessionElapsedSeconds)} + + + + {playtimeLabel} + + + + {lastPlayedLabel} + + {sessionCount > 0 && ( + + + {sessionCount === 1 ? "1 session" : `${sessionCount} sessions`} + + )} + {genres.map(g => ( + {sanitizeGenreName(g)} + ))} + {tier && {tier}} + + ); + })()} +
+
+
+ )} +
+ +
+ {topCategory === "current" ? ( + <> +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Select +
+ + ) : topCategory === "settings" ? ( + <> + {settingsSubcategory === "root" ? ( + <> +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Enter +
+ + ) : ( + <> +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Back +
+
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Toggle +
+ + )} + + ) : topCategory === "media" ? ( + <> + {mediaSubcategory === "root" ? ( +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Enter +
+ ) : ( + <> +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Open Folder +
+
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Back +
+ + )} + + ) : ( + <> + { + (() => { + const Primary = controllerType === "ps" ? ButtonPSCross : ButtonA; + const Left = controllerType === "ps" ? ButtonPSSquare : ButtonX; + const Top = controllerType === "ps" ? ButtonPSTriangle : ButtonY; + return ( + <> +
Start
+
Store
+
Favorite
+ + ); + })() + } + + )} +
+
+ ); +} diff --git a/opennow-stable/src/renderer/src/components/ControllerStreamLoading.tsx b/opennow-stable/src/renderer/src/components/ControllerStreamLoading.tsx new file mode 100644 index 0000000..244b558 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/ControllerStreamLoading.tsx @@ -0,0 +1,160 @@ +import { Loader2, Zap } from "lucide-react"; +import type { JSX } from "react"; +import { formatPlaytime } from "../utils/usePlaytime"; +import type { PlaytimeStore } from "../utils/usePlaytime"; + +export interface ControllerStreamLoadingProps { + gameTitle: string; + gamePoster?: string; + gameDescription?: string; + status: "queue" | "setup" | "starting" | "connecting"; + queuePosition?: number; + playtimeData?: PlaytimeStore; + gameId?: string; + enableBackgroundAnimations?: boolean; +} + +function getStatusMessage( + status: ControllerStreamLoadingProps["status"], + queuePosition?: number, +): string { + switch (status) { + case "queue": + return queuePosition ? `Position #${queuePosition} in queue` : "Waiting in queue..."; + case "setup": + return "Setting up your gaming rig..."; + case "starting": + return "Starting stream..."; + case "connecting": + return "Connecting to server..."; + default: + return "Loading..."; + } +} + +function getStatusPhase( + status: ControllerStreamLoadingProps["status"], +): "queue" | "setup" | "launching" { + switch (status) { + case "queue": + return "queue"; + case "setup": + return "setup"; + case "starting": + case "connecting": + return "launching"; + default: + return "queue"; + } +} + +export function ControllerStreamLoading({ + gameTitle, + gamePoster, + gameDescription, + status, + queuePosition, + playtimeData = {}, + gameId, + enableBackgroundAnimations = false, +}: ControllerStreamLoadingProps): JSX.Element { + const statusMessage = getStatusMessage(status, queuePosition); + const statusPhase = getStatusPhase(status); + const playtimeRecord = gameId ? playtimeData[gameId] : undefined; + const totalSecs = playtimeRecord?.totalSeconds ?? 0; + const playtimeLabel = formatPlaytime(totalSecs); + + return ( +
+ {enableBackgroundAnimations && ( +
+
+
+
+
+
+ )} + {/* Fade-to-black backdrop */} +
+ + {/* Content fade-in layer */} +
+
+ {/* Left side: Game Poster */} +
+ {gamePoster ? ( + {gameTitle} + ) : ( +
+ +
+ )} +
+ + {/* Right side: Game Info and Status */} +
+ {/* Game Title */} +
+

{gameTitle}

+
+ + {/* Game Description */} + {gameDescription && ( +
+

{gameDescription}

+
+ )} + + {/* Playtime */} + {playtimeLabel !== "0h" && ( +
+ Playtime: + {playtimeLabel} +
+ )} + + {/* Network Status Section */} +
+
{statusMessage}
+ + {/* Status Progress Indicator */} +
+
+ + Queue +
+ +
+ +
+ + Setup +
+ +
+ +
+ + Launching +
+
+ + {/* Loading Spinner */} +
+ +
+
+
+
+
+
+ ); +} diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index 25696aa..e46ca9b 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { Globe, Save, Check, Search, X, Loader, Zap, Mic, FileDown, Wifi } from "lucide-react"; +import { Globe, Save, Check, Search, X, Loader, Zap, Mic, FileDown, Wifi, Trash2 } from "lucide-react"; import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import type { JSX } from "react"; @@ -41,14 +41,32 @@ interface FpsPreset { value: number; } +interface AspectRatioPreset { + value: string; + label: string; +} + +const STATIC_ASPECT_RATIO_PRESETS: AspectRatioPreset[] = [ + { value: "16:9", label: "16:9 (Widescreen)" }, + { value: "16:10", label: "16:10 (Widescreen)" }, + { value: "21:9", label: "21:9 (Ultrawide)" }, + { value: "32:9", label: "32:9 (Super Ultrawide)" }, +]; + const STATIC_RESOLUTION_PRESETS: ResolutionPreset[] = [ - { value: "1280x720", label: "720p" }, - { value: "1920x1080", label: "1080p" }, - { value: "2560x1440", label: "1440p" }, - { value: "3840x2160", label: "4K" }, - { value: "2560x1080", label: "Ultrawide 1080p" }, - { value: "3440x1440", label: "Ultrawide 1440p" }, - { value: "5120x1440", label: "Super Ultrawide" }, + { value: "1280x720", label: "720p (16:9)" }, + { value: "1280x800", label: "720p (16:10)" }, + { value: "1440x900", label: "WXGA (16:10)" }, + { value: "1680x1050", label: "WSXGA (16:10)" }, + { value: "1920x1080", label: "1080p (16:9)" }, + { value: "1920x1200", label: "1200p (16:10)" }, + { value: "2560x1080", label: "Ultrawide 1080p (21:9)" }, + { value: "2560x1440", label: "1440p (16:9)" }, + { value: "2560x1600", label: "1600p (16:10)" }, + { value: "3440x1440", label: "Ultrawide 1440p (21:9)" }, + { value: "3840x2160", label: "4K (16:9)" }, + { value: "3840x2400", label: "4K (16:10)" }, + { value: "5120x1440", label: "Super Ultrawide (32:9)" }, ]; const STATIC_FPS_PRESETS: FpsPreset[] = [ @@ -1092,6 +1110,24 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag
+ {/* Aspect Ratio — static chips */} +
+ +
+ {STATIC_ASPECT_RATIO_PRESETS.map((preset) => ( + + ))} +
+
+ {/* Resolution — dynamic or static chips */}
+ +
+ + +
+ + {settings.controllerMode && ( +
+ +
+ +
+
+ )} + +
+ + +
+ +
+ + +
+ +
+ + +
@@ -1777,6 +1887,32 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag Export Logs
+ +
+ + +
diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index b2cb738..5962b52 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { createPortal } from "react-dom"; import type { JSX } from "react"; import { Maximize, Minimize, Gamepad2, Loader2, LogOut, Clock3, AlertTriangle, Mic, MicOff, Camera, ChevronLeft, ChevronRight, Save, Trash2, X, Circle, Square, Video, FolderOpen } from "lucide-react"; import SideBar from "./SideBar"; @@ -61,6 +62,7 @@ interface StreamViewProps { onRecordingShortcutChange: (value: string) => void; remainingPlaytimeText: string; micTrack?: MediaStreamTrack | null; + className?: string; } function getRttColor(rttMs: number): string { @@ -274,6 +276,7 @@ export function StreamView({ remainingPlaytimeText, micTrack, hideStreamButtons = false, + className, }: StreamViewProps): JSX.Element { const [isFullscreen, setIsFullscreen] = useState(false); const [showHints, setShowHints] = useState(true); @@ -947,7 +950,7 @@ export function StreamView({ }, [captureScreenshot, handleToggleSideBar, isMacClient, shortcuts.screenshot, shortcuts.recording, toggleRecording]); return ( -
+
-
+
, + document.body, )} {/* Fullscreen toggle */} diff --git a/opennow-stable/src/renderer/src/controllerNavigation.ts b/opennow-stable/src/renderer/src/controllerNavigation.ts index ef58f84..b9016e5 100644 --- a/opennow-stable/src/renderer/src/controllerNavigation.ts +++ b/opennow-stable/src/renderer/src/controllerNavigation.ts @@ -7,6 +7,10 @@ interface UseControllerNavigationOptions { enabled: boolean; onNavigatePage?: (direction: PageDirection) => void; onBackAction?: () => boolean; + onDirectionInput?: (direction: Direction) => boolean; + onActivateInput?: () => boolean; + onSecondaryActivateInput?: () => boolean; + onTertiaryActivateInput?: () => boolean; } const INTERACTIVE_SELECTOR = [ @@ -42,6 +46,9 @@ function isElementDisabled(el: HTMLElement): boolean { } function getFocusScopeRoot(): ParentNode { + const overlay = document.querySelector(".controller-overlay"); + if (overlay) return overlay as ParentNode; + const exitDialog = document.querySelector(".sv-exit"); if (exitDialog) return exitDialog; @@ -252,6 +259,10 @@ export function useControllerNavigation({ enabled, onNavigatePage, onBackAction, + onDirectionInput, + onActivateInput, + onSecondaryActivateInput, + onTertiaryActivateInput, }: UseControllerNavigationOptions): boolean { const [controllerConnected, setControllerConnected] = useState(false); const connectedRef = useRef(false); @@ -266,6 +277,8 @@ export function useControllerNavigation({ const actionStateRef = useRef({ a: false, + x: false, + y: false, b: false, lb: false, rb: false, @@ -299,7 +312,7 @@ export function useControllerNavigation({ for (const state of Object.values(directionStateRef.current)) { state.pressed = false; } - actionStateRef.current = { a: false, b: false, lb: false, rb: false }; + actionStateRef.current = { a: false, x: false, y: false, b: false, lb: false, rb: false }; frameRef.current = window.requestAnimationFrame(tick); return; } @@ -311,6 +324,8 @@ export function useControllerNavigation({ const left = Boolean(pad.buttons[14]?.pressed || pad.axes[0] < -0.55); const right = Boolean(pad.buttons[15]?.pressed || pad.axes[0] > 0.55); const a = Boolean(pad.buttons[0]?.pressed); + const x = Boolean(pad.buttons[2]?.pressed); + const y = Boolean(pad.buttons[3]?.pressed); const b = Boolean(pad.buttons[1]?.pressed); const lb = Boolean(pad.buttons[4]?.pressed); const rb = Boolean(pad.buttons[5]?.pressed); @@ -326,12 +341,18 @@ export function useControllerNavigation({ if (!state.pressed) { state.pressed = true; state.nextRepeatAt = now + DIRECTION_INITIAL_REPEAT_MS; + if (onDirectionInput?.(direction)) { + return; + } moveFocus(direction); return; } if (now >= state.nextRepeatAt) { state.nextRepeatAt = now + DIRECTION_REPEAT_MS; + if (onDirectionInput?.(direction)) { + return; + } moveFocus(direction); } }; @@ -342,8 +363,19 @@ export function useControllerNavigation({ handleDirection("right", right); if (a && !actionStateRef.current.a) { + if (onActivateInput?.()) { + actionStateRef.current = { a, x, y, b, lb, rb }; + frameRef.current = window.requestAnimationFrame(tick); + return; + } activateFocusedElement(); } + if (x && !actionStateRef.current.x) { + onSecondaryActivateInput?.(); + } + if (y && !actionStateRef.current.y) { + onTertiaryActivateInput?.(); + } if (b && !actionStateRef.current.b) { triggerBackAction(onBackAction); } @@ -354,7 +386,7 @@ export function useControllerNavigation({ onNavigatePage?.("next"); } - actionStateRef.current = { a, b, lb, rb }; + actionStateRef.current = { a, x, y, b, lb, rb }; frameRef.current = window.requestAnimationFrame(tick); }; @@ -368,7 +400,7 @@ export function useControllerNavigation({ node.classList.remove("controller-focus"); }); }; - }, [enabled, onBackAction, onNavigatePage]); + }, [enabled, onActivateInput, onBackAction, onDirectionInput, onNavigatePage, onSecondaryActivateInput, onTertiaryActivateInput]); return controllerConnected; } diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index fcd3e28..475d535 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -442,6 +442,7 @@ export class GfnWebRtcClient { private audioContext: AudioContext | null = null; private inputReady = false; + public inputPaused = false; private inputProtocolVersion = 2; private heartbeatTimer: number | null = null; private mouseFlushTimer: number | null = null; @@ -1559,6 +1560,7 @@ export class GfnWebRtcClient { private gamepadSendCount = 0; private pollGamepads(): void { + if (this.inputPaused) return; const gamepads = navigator.getGamepads(); if (!gamepads) { return; @@ -2209,6 +2211,7 @@ export class GfnWebRtcClient { }; const onPointerMove = (event: PointerEvent) => { + if (this.inputPaused) return; if (event.pointerType && event.pointerType !== "mouse") { return; } @@ -2225,10 +2228,12 @@ export class GfnWebRtcClient { }; const onMouseMove = (event: MouseEvent) => { + if (this.inputPaused) return; queueMouseMovement(event.movementX, event.movementY, event.timeStamp); }; const onKeyDown = (event: KeyboardEvent) => { + if (this.inputPaused) return; if (!this.inputReady) { return; } @@ -2284,6 +2289,7 @@ export class GfnWebRtcClient { }; const onKeyUp = (event: KeyboardEvent) => { + if (this.inputPaused) return; if (!this.inputReady) { return; } @@ -2327,6 +2333,7 @@ export class GfnWebRtcClient { }; const onMouseDown = (event: MouseEvent) => { + if (this.inputPaused) return; if (!this.inputReady) { return; } @@ -2345,6 +2352,7 @@ export class GfnWebRtcClient { }; const onMouseUp = (event: MouseEvent) => { + if (this.inputPaused) return; if (!this.inputReady) { return; } @@ -2479,13 +2487,26 @@ export class GfnWebRtcClient { } this.clearEscapeHoldTimer(); this.releasePressedKeys("window blur"); + // Pause all input while window is not focused so no new events + // (keyboard/gamepad/mouse) are registered or forwarded to the stream. + this.inputPaused = true; }; const onVisibilityChange = () => { if (document.visibilityState !== "visible") { this.clearEscapeHoldTimer(); this.releasePressedKeys(`visibility ${document.visibilityState}`); + this.inputPaused = true; + return; } + + // Document is visible again — resume input + this.inputPaused = false; + }; + + const onWindowFocus = () => { + // Resume input when window regains focus + this.inputPaused = false; }; // Try to lock keyboard (Escape, F11, etc.) when in fullscreen. @@ -2523,6 +2544,7 @@ export class GfnWebRtcClient { document.addEventListener("fullscreenchange", onFullscreenChange); window.addEventListener("blur", onWindowBlur); document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("focus", onWindowFocus); // If already in fullscreen, try to lock keyboard immediately if (document.fullscreenElement) { @@ -2546,6 +2568,7 @@ export class GfnWebRtcClient { this.inputCleanup.push(() => document.removeEventListener("fullscreenchange", onFullscreenChange)); this.inputCleanup.push(() => window.removeEventListener("blur", onWindowBlur)); this.inputCleanup.push(() => document.removeEventListener("visibilitychange", onVisibilityChange)); + this.inputCleanup.push(() => window.removeEventListener("focus", onWindowFocus)); this.inputCleanup.push(() => { if (this.pointerLockEscapeTimer !== null) { window.clearTimeout(this.pointerLockEscapeTimer); diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 962f3ee..6adebbb 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -1,9 +1,3 @@ -/* ============================================================ - OpenNOW — Unified Design System - Aesthetic: Premium cinematic dark gaming platform - Obsidian-charcoal base, warm neutral grays, green accents - ============================================================ */ - /* ---------- Design Tokens ---------- */ :root { color-scheme: dark; @@ -92,38 +86,191 @@ body, } /* ---------- Scrollbar ---------- */ -::-webkit-scrollbar { width: 5px; height: 5px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--panel-border-solid); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: var(--ink-muted); } +::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--panel-border-solid); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--ink-muted); +} /* ---------- Animations ---------- */ -@keyframes spin { to { transform: rotate(360deg); } } +@keyframes spin { + to { + transform: rotate(360deg); + } +} + + + + + + @keyframes float-a { - 0%, 100% { transform: translate(0, 0) scale(1); } - 50% { transform: translate(-40px, 30px) scale(1.15); } + + 0%, + 100% { + transform: translate(0, 0) scale(1); + } + + 50% { + transform: translate(-40px, 30px) scale(1.15); + } } + @keyframes float-b { - 0%, 100% { transform: translate(0, 0) scale(1); } - 50% { transform: translate(30px, -25px) scale(1.08); } + + 0%, + 100% { + transform: translate(0, 0) scale(1); + } + + 50% { + transform: translate(30px, -25px) scale(1.08); + } } + @keyframes float-c { - 0%, 100% { transform: translate(0, 0) scale(1); } - 50% { transform: translate(-20px, -30px) scale(1.12); } + + 0%, + 100% { + transform: translate(0, 0) scale(1); + } + + 50% { + transform: translate(-20px, -30px) scale(1.12); + } } + @keyframes fade-in { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } } + @keyframes fade-in-down { - from { opacity: 0; transform: translateY(-6px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(-6px); + } + + to { + opacity: 1; + transform: translateY(0); + } } + @keyframes pulse-glow { - 0%, 100% { transform: scale(1); opacity: 1; } - 50% { transform: scale(1.08); opacity: 0.85; } + + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + + 50% { + transform: scale(1.08); + opacity: 0.85; + } +} + +@keyframes float-subtle { + + 0%, + 100% { + transform: translateY(0) rotate(0); + } + + 33% { + transform: translateY(-3px) rotate(0.1deg); + } + + 66% { + transform: translateY(1px) rotate(-0.1deg); + } +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%) skewX(-15deg); + } + + 100% { + transform: translateX(200%) skewX(-15deg); + } +} + + +@keyframes bg-pan { + 0% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } + + 100% { + background-position: 0% 50%; + } +} + +@keyframes xmb-ribbon-pan { + 0% { + background-position: 0% 18%, 12% 52%, 88% 78%; + } + + 50% { + background-position: 100% 34%, 82% 46%, 18% 68%; + } + + 100% { + background-position: 0% 18%, 12% 52%, 88% 78%; + } +} + +@keyframes xmb-ribbon-float { + 0% { + transform: translate3d(-1%, -0.6%, 0) scale(1.05) rotate(-4deg); + } + + 50% { + transform: translate3d(1%, 0.8%, 0) scale(1.08) rotate(-1.5deg); + } + + 100% { + transform: translate3d(-0.8%, 1%, 0) scale(1.06) rotate(-3deg); + } } +@keyframes xmb-ribbon-swell { + 0%, + 100% { + transform: translate3d(0, 0, 0) scale(1) rotate(0deg); + opacity: 0.74; + } + + 50% { + transform: translate3d(1%, -1.2%, 0) scale(1.05) rotate(2deg); + opacity: 0.9; + } +} /* ====================================================== APP SHELL @@ -174,7 +321,9 @@ body, ====================================================== */ .navbar { position: fixed; - top: 0; left: 0; right: 0; + top: 0; + left: 0; + right: 0; height: var(--navbar-h); display: flex; align-items: center; @@ -193,10 +342,13 @@ body, } .navbar-brand { - width: 28px; height: 28px; + width: 28px; + height: 28px; border-radius: 7px; background: linear-gradient(135deg, var(--accent), var(--accent-press)); - display: flex; align-items: center; justify-content: center; + display: flex; + align-items: center; + justify-content: center; color: var(--accent-on); box-shadow: 0 2px 12px var(--accent-glow); flex-shrink: 0; @@ -283,20 +435,32 @@ body, } .navbar-link { - display: flex; align-items: center; gap: 6px; + display: flex; + align-items: center; + gap: 6px; padding: 6px 12px; border-radius: 6px; border: none; background: transparent; color: var(--ink-muted); - font-size: 0.8rem; font-weight: 500; + font-size: 0.8rem; + font-weight: 500; font-family: inherit; cursor: pointer; transition: color var(--t-fast), background var(--t-fast); outline: none; } -.navbar-link:hover { color: var(--ink-soft); background: rgba(255, 255, 255, 0.04); } -.navbar-link.active { color: var(--ink); background: rgba(255, 255, 255, 0.07); font-weight: 600; } + +.navbar-link:hover { + color: var(--ink-soft); + background: rgba(255, 255, 255, 0.04); +} + +.navbar-link.active { + color: var(--ink); + background: rgba(255, 255, 255, 0.07); + font-weight: 600; +} .navbar-right { display: flex; @@ -386,50 +550,98 @@ body, } .navbar-user { - display: flex; align-items: center; gap: 7px; + display: flex; + align-items: center; + gap: 7px; padding: 4px 8px; border-radius: 6px; } + .navbar-avatar { - width: 24px; height: 24px; border-radius: 5px; + width: 24px; + height: 24px; + border-radius: 5px; object-fit: cover; } + .navbar-avatar-fallback { - width: 24px; height: 24px; border-radius: 5px; + width: 24px; + height: 24px; + border-radius: 5px; background: var(--chip); - display: flex; align-items: center; justify-content: center; + display: flex; + align-items: center; + justify-content: center; color: var(--ink-muted); } + .navbar-user-info { - display: flex; flex-direction: column; gap: 0; line-height: 1.2; + display: flex; + flex-direction: column; + gap: 0; + line-height: 1.2; } + .navbar-username { - font-size: 0.78rem; font-weight: 500; color: var(--ink-soft); - max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 0.78rem; + font-weight: 500; + color: var(--ink-soft); + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } + .navbar-tier { - font-size: 0.6rem; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.06em; line-height: 1; + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + line-height: 1; +} + +.navbar-tier.tier-ultimate { + color: #ffd700; } -.navbar-tier.tier-ultimate { color: #ffd700; } -.navbar-tier.tier-priority { color: #cdaf95; } -.navbar-tier.tier-free { color: var(--ink-muted); } + +.navbar-tier.tier-priority { + color: #cdaf95; +} + +.navbar-tier.tier-free { + color: var(--ink-muted); +} + .navbar-logout { - display: flex; align-items: center; justify-content: center; - width: 32px; height: 32px; - border-radius: 6px; border: none; background: transparent; - color: var(--ink-muted); cursor: pointer; - transition: color var(--t-fast), background var(--t-fast); outline: none; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--ink-muted); + cursor: pointer; + transition: color var(--t-fast), background var(--t-fast); + outline: none; font-family: inherit; } -.navbar-logout:hover { background: var(--error-bg); color: var(--error); } + +.navbar-logout:hover { + background: var(--error-bg); + color: var(--error); +} .navbar-guest { - display: flex; align-items: center; gap: 5px; + display: flex; + align-items: center; + gap: 5px; padding: 5px 10px; background: var(--chip); border-radius: 6px; - color: var(--ink-muted); font-size: 0.78rem; + color: var(--ink-muted); + font-size: 0.78rem; } .navbar-modal-backdrop { @@ -634,12 +846,14 @@ body.controller-mode { border-color: color-mix(in srgb, var(--accent) 82%, var(--panel-border)) !important; box-shadow: 0 0 0 2px rgba(88, 217, 138, 0.38), - 0 0 0 6px rgba(88, 217, 138, 0.12), - 0 12px 28px rgba(0, 0, 0, 0.36) !important; + 0 0 0 8px rgba(88, 217, 138, 0.10), + 0 18px 36px rgba(0, 0, 0, 0.48) !important; + transform-origin: center center; transition: - transform 110ms ease, - border-color 110ms ease, - box-shadow 110ms ease !important; + transform 160ms cubic-bezier(.2, .9, .3, 1), + box-shadow 200ms ease, + border-color 160ms ease; + will-change: transform, box-shadow, border-color; } .controller-mode .main-content { @@ -737,6 +951,12 @@ body.controller-mode { font-size: 0.87rem; } +/* Hide the mouse cursor when controller mode is active and the user is idle */ +body.controller-mode.controller-hide-cursor, +body.controller-mode.controller-hide-cursor * { + cursor: none !important; +} + .controller-mode .region-dropdown-item { padding: 12px 14px; } @@ -788,6 +1008,39 @@ body.controller-mode { z-index: 1400; } +.controller-overlay { + position: fixed; + inset: 0; + z-index: 2400 !important; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55) !important; + -webkit-backdrop-filter: blur(16px) brightness(0.45); + backdrop-filter: blur(16px) brightness(0.45); + pointer-events: auto; +} + +.controller-overlay > .xmb-wrapper { + position: absolute !important; + inset: 0 !important; + z-index: 2401 !important; + width: 100% !important; + height: 100% !important; + background: transparent !important; + box-shadow: none !important; +} + +/* Hide XMB background gradients/layers when used as an in-stream overlay */ +.controller-overlay .xmb-bg-layer, +.controller-overlay .xmb-bg-gradient { + display: none !important; +} +.controller-overlay .xmb-bg-gradient::before, +.controller-overlay .xmb-bg-gradient::after { + display: none !important; +} + @media (max-width: 760px) { .controller-hint { max-width: calc(100vw - 16px); @@ -799,48 +1052,65 @@ body.controller-mode { } + /* ====================================================== LOGIN SCREEN ====================================================== */ .login-screen { - position: fixed; inset: 0; + position: fixed; + inset: 0; display: flex; - align-items: center; justify-content: center; + align-items: center; + justify-content: center; overflow: hidden; background: var(--bg-a); } /* Animated background */ .login-bg { - position: absolute; inset: 0; - overflow: hidden; pointer-events: none; + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; } + .login-bg-orb { position: absolute; border-radius: 50%; filter: blur(40px); opacity: 0.35; } + .login-bg-orb--1 { - width: 500px; height: 500px; - top: -180px; right: -80px; + width: 500px; + height: 500px; + top: -180px; + right: -80px; background: radial-gradient(circle, var(--accent) 0%, transparent 70%); animation: float-a 22s ease-in-out infinite; } + .login-bg-orb--2 { - width: 400px; height: 400px; - bottom: -120px; left: -80px; + width: 400px; + height: 400px; + bottom: -120px; + left: -80px; background: radial-gradient(circle, #4a90d9 0%, transparent 70%); animation: float-b 28s ease-in-out infinite; } + .login-bg-orb--3 { - width: 300px; height: 300px; - top: 40%; left: 50%; + width: 300px; + height: 300px; + top: 40%; + left: 50%; background: radial-gradient(circle, rgba(88, 217, 138, 0.5) 0%, transparent 70%); animation: float-c 18s ease-in-out infinite; } + .login-bg-noise { - position: absolute; inset: 0; + position: absolute; + inset: 0; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E"); background-repeat: repeat; background-size: 256px 256px; @@ -849,23 +1119,33 @@ body.controller-mode { /* Login content */ .login-content { - position: relative; z-index: 1; - display: flex; flex-direction: column; - align-items: center; gap: 28px; + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 28px; animation: fade-in 500ms var(--ease); } .login-brand { - display: flex; align-items: center; gap: 10px; + display: flex; + align-items: center; + gap: 10px; } + .login-brand-mark { - width: 36px; height: 36px; + width: 36px; + height: 36px; border-radius: 9px; background: linear-gradient(135deg, var(--accent), var(--accent-press)); - display: flex; align-items: center; justify-content: center; + display: flex; + align-items: center; + justify-content: center; color: var(--accent-on); box-shadow: 0 4px 20px var(--accent-glow); } + .login-brand-name { font-size: 1.4rem; font-weight: 700; @@ -875,7 +1155,8 @@ body.controller-mode { /* Login card */ .login-card { - width: 100%; max-width: 380px; + width: 100%; + max-width: 380px; padding: 32px; background: rgba(21, 21, 24, 0.92); border: 1px solid var(--panel-border); @@ -884,27 +1165,41 @@ body.controller-mode { } .login-card-header { - text-align: center; margin-bottom: 28px; + text-align: center; + margin-bottom: 28px; } + .login-card-header h1 { - font-size: 1.35rem; font-weight: 700; - color: var(--ink); margin: 0 0 6px 0; + font-size: 1.35rem; + font-weight: 700; + color: var(--ink); + margin: 0 0 6px 0; } + .login-card-header p { - font-size: 0.85rem; color: var(--ink-muted); margin: 0; + font-size: 0.85rem; + color: var(--ink-muted); + margin: 0; } /* Error */ .login-error { - display: flex; align-items: center; gap: 8px; - padding: 10px 14px; margin-bottom: 20px; + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + margin-bottom: 20px; background: var(--error-bg); border: 1px solid var(--error-border); border-radius: var(--r-sm); - color: var(--error); font-size: 0.8rem; font-weight: 500; + color: var(--error); + font-size: 0.8rem; + font-weight: 500; } + .login-error-dot { - width: 6px; height: 6px; + width: 6px; + height: 6px; background: var(--error); border-radius: 50%; flex-shrink: 0; @@ -935,89 +1230,165 @@ body.controller-mode { /* Field */ .login-field { - position: relative; margin-bottom: 20px; + position: relative; + margin-bottom: 20px; } + .login-label { - display: block; font-size: 0.75rem; font-weight: 600; - color: var(--ink-muted); text-transform: uppercase; - letter-spacing: 0.06em; margin-bottom: 6px; + display: block; + font-size: 0.75rem; + font-weight: 600; + color: var(--ink-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 6px; } /* Custom select */ .login-select { width: 100%; - display: flex; align-items: center; justify-content: space-between; + display: flex; + align-items: center; + justify-content: space-between; padding: 11px 14px; background: var(--bg-a); border: 1px solid var(--panel-border-solid); border-radius: var(--r-sm); - color: var(--ink); font-size: 0.88rem; font-weight: 500; + color: var(--ink); + font-size: 0.88rem; + font-weight: 500; font-family: inherit; cursor: pointer; transition: border-color var(--t-normal), box-shadow var(--t-normal); outline: none; } -.login-select:hover:not(:disabled) { border-color: #444; } -.login-select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-surface); } -.login-select.open { border-color: var(--accent); border-radius: var(--r-sm) var(--r-sm) 0 0; } -.login-select:disabled { opacity: 0.5; cursor: not-allowed; } -.login-select-text { - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} -.login-select-chevron { - color: var(--ink-muted); flex-shrink: 0; - transition: transform var(--t-normal); + +.login-select:hover:not(:disabled) { + border-color: #444; } -.login-select-chevron.rotated { transform: rotate(180deg); color: var(--accent); } -/* Dropdown */ -.login-dropdown { - position: absolute; top: 100%; left: 0; right: 0; - max-height: 200px; overflow-y: auto; - background: var(--bg-a); - border: 1px solid var(--accent); border-top: none; - border-radius: 0 0 var(--r-sm) var(--r-sm); - z-index: 10; - box-shadow: 0 10px 36px rgba(0, 0, 0, 0.5); - animation: fade-in-down 120ms var(--ease); +.login-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-surface); } + +.login-select.open { + border-color: var(--accent); + border-radius: var(--r-sm) var(--r-sm) 0 0; +} + +.login-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.login-select-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.login-select-chevron { + color: var(--ink-muted); + flex-shrink: 0; + transition: transform var(--t-normal); +} + +.login-select-chevron.rotated { + transform: rotate(180deg); + color: var(--accent); +} + +/* Dropdown */ +.login-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: 200px; + overflow-y: auto; + background: var(--bg-a); + border: 1px solid var(--accent); + border-top: none; + border-radius: 0 0 var(--r-sm) var(--r-sm); + z-index: 10; + box-shadow: 0 10px 36px rgba(0, 0, 0, 0.5); + animation: fade-in-down 120ms var(--ease); +} + .login-dropdown-item { width: 100%; - display: flex; align-items: center; justify-content: space-between; + display: flex; + align-items: center; + justify-content: space-between; padding: 10px 14px; - background: transparent; border: none; - color: var(--ink); font-size: 0.85rem; font-weight: 500; + background: transparent; + border: none; + color: var(--ink); + font-size: 0.85rem; + font-weight: 500; font-family: inherit; - cursor: pointer; transition: background var(--t-fast); + cursor: pointer; + transition: background var(--t-fast); text-align: left; } -.login-dropdown-item:hover { background: var(--accent-surface); } -.login-dropdown-item.selected { background: var(--accent-surface-strong); color: var(--accent); } + +.login-dropdown-item:hover { + background: var(--accent-surface); +} + +.login-dropdown-item.selected { + background: var(--accent-surface-strong); + color: var(--accent); +} /* Login button */ .login-button { width: 100%; - display: flex; align-items: center; justify-content: center; gap: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; padding: 13px 20px; background: linear-gradient(135deg, var(--accent), var(--accent-press)); - border: none; border-radius: var(--r-sm); - color: var(--accent-on); font-size: 0.92rem; font-weight: 700; + border: none; + border-radius: var(--r-sm); + color: var(--accent-on); + font-size: 0.92rem; + font-weight: 700; font-family: inherit; cursor: pointer; transition: transform var(--t-normal), box-shadow var(--t-normal), opacity var(--t-normal); box-shadow: 0 4px 16px var(--accent-glow); } + .login-button:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 6px 24px rgba(88, 217, 138, 0.35); } -.login-button:active:not(:disabled) { transform: translateY(0); } -.login-button:focus { outline: none; box-shadow: 0 0 0 3px var(--accent-glow), 0 4px 16px var(--accent-glow); } -.login-button:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } -.login-button.loading { cursor: wait; } + +.login-button:active:not(:disabled) { + transform: translateY(0); +} + +.login-button:focus { + outline: none; + box-shadow: 0 0 0 3px var(--accent-glow), 0 4px 16px var(--accent-glow); +} + +.login-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.login-button.loading { + cursor: wait; +} .login-spinner { - width: 16px; height: 16px; + width: 16px; + height: 16px; border: 2px solid rgba(6, 35, 21, 0.3); border-top-color: var(--accent-on); border-radius: 50%; @@ -1026,7 +1397,9 @@ body.controller-mode { } .login-footer { - font-size: 0.75rem; color: var(--ink-muted); margin: 0; + font-size: 0.75rem; + color: var(--ink-muted); + margin: 0; } @@ -1061,32 +1434,53 @@ body.controller-mode { } .home-tab { - display: flex; align-items: center; gap: 5px; + display: flex; + align-items: center; + gap: 5px; padding: 6px 12px; border-radius: 6px; border: none; background: transparent; color: var(--ink-muted); - font-size: 0.8rem; font-weight: 600; + font-size: 0.8rem; + font-weight: 600; font-family: inherit; cursor: pointer; transition: color var(--t-fast), background var(--t-fast); outline: none; white-space: nowrap; } -.home-tab:hover:not(:disabled) { color: var(--ink-soft); background: rgba(255, 255, 255, 0.04); } -.home-tab.active { color: var(--accent-on); background: var(--accent); } -.home-tab:disabled { opacity: 0.5; cursor: not-allowed; } + +.home-tab:hover:not(:disabled) { + color: var(--ink-soft); + background: rgba(255, 255, 255, 0.04); +} + +.home-tab.active { + color: var(--accent-on); + background: var(--accent); +} + +.home-tab:disabled { + opacity: 0.5; + cursor: not-allowed; +} .home-search { flex: 1; position: relative; max-width: 340px; } + .home-search-icon { - position: absolute; left: 11px; top: 50%; transform: translateY(-50%); - color: var(--ink-muted); pointer-events: none; + position: absolute; + left: 11px; + top: 50%; + transform: translateY(-50%); + color: var(--ink-muted); + pointer-events: none; } + .home-search-input { width: 100%; padding: 7px 12px 7px 34px; @@ -1094,16 +1488,27 @@ body.controller-mode { border: 1px solid var(--panel-border); background: var(--card); color: var(--ink); - font-size: 0.82rem; font-family: inherit; + font-size: 0.82rem; + font-family: inherit; outline: none; transition: border-color var(--t-fast), box-shadow var(--t-fast); } -.home-search-input::placeholder { color: var(--ink-muted); } -.home-search-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-surface); } + +.home-search-input::placeholder { + color: var(--ink-muted); +} + +.home-search-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-surface); +} .home-count { - font-size: 0.78rem; color: var(--ink-muted); font-weight: 500; - white-space: nowrap; margin-left: auto; + font-size: 0.78rem; + color: var(--ink-muted); + font-weight: 500; + white-space: nowrap; + margin-left: auto; } /* Grid area */ @@ -1117,15 +1522,37 @@ body.controller-mode { /* Empty / Loading states */ .home-empty-state { - display: flex; flex-direction: column; - align-items: center; justify-content: center; - height: 100%; min-height: 260px; - gap: 12px; color: var(--ink-soft); text-align: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 260px; + gap: 12px; + color: var(--ink-soft); + text-align: center; +} + +.home-empty-state h3 { + font-size: 1.05rem; + color: var(--ink); + margin: 0; +} + +.home-empty-state p { + font-size: 0.82rem; + margin: 0; +} + +.home-empty-icon { + color: var(--panel-border-solid); + opacity: 0.5; +} + +.home-spinner { + animation: spin 1s linear infinite; + color: var(--accent); } -.home-empty-state h3 { font-size: 1.05rem; color: var(--ink); margin: 0; } -.home-empty-state p { font-size: 0.82rem; margin: 0; } -.home-empty-icon { color: var(--panel-border-solid); opacity: 0.5; } -.home-spinner { animation: spin 1s linear infinite; color: var(--accent); } /* ====================================================== @@ -1138,11 +1565,35 @@ body.controller-mode { padding-bottom: 16px; } -@media (min-width: 1600px) { .game-grid { grid-template-columns: repeat(6, 1fr); } } -@media (min-width: 1280px) and (max-width: 1599px) { .game-grid { grid-template-columns: repeat(5, 1fr); } } -@media (min-width: 1024px) and (max-width: 1279px) { .game-grid { grid-template-columns: repeat(4, 1fr); } } -@media (min-width: 768px) and (max-width: 1023px) { .game-grid { grid-template-columns: repeat(3, 1fr); } } -@media (max-width: 767px) { .game-grid { grid-template-columns: repeat(2, 1fr); } } +@media (min-width: 1600px) { + .game-grid { + grid-template-columns: repeat(6, 1fr); + } +} + +@media (min-width: 1280px) and (max-width: 1599px) { + .game-grid { + grid-template-columns: repeat(5, 1fr); + } +} + +@media (min-width: 1024px) and (max-width: 1279px) { + .game-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +@media (min-width: 768px) and (max-width: 1023px) { + .game-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 767px) { + .game-grid { + grid-template-columns: repeat(2, 1fr); + } +} /* ====================================================== @@ -1150,11 +1601,13 @@ body.controller-mode { ====================================================== */ .game-card { position: relative; - display: flex; flex-direction: column; + display: flex; + flex-direction: column; background: var(--card); border: 1px solid var(--panel-border); border-radius: var(--r-md); - overflow: hidden; cursor: pointer; + overflow: hidden; + cursor: pointer; transition: transform var(--t-normal), border-color var(--t-normal); contain: layout style; transform: translateZ(0); @@ -1194,58 +1647,91 @@ body.controller-mode { background: var(--bg-c); backface-visibility: hidden; } + .game-card-image { - width: calc(100% + 2px); height: calc(100% + 2px); + width: calc(100% + 2px); + height: calc(100% + 2px); margin: -1px; object-fit: cover; transition: transform var(--t-normal); } -.game-card:hover .game-card-image { transform: scale(1.03); } + +.game-card:hover .game-card-image { + transform: scale(1.03); +} .game-card-image-placeholder { - width: 100%; height: 100%; - display: flex; align-items: center; justify-content: center; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; background: linear-gradient(135deg, var(--bg-c), var(--panel)); color: var(--ink-muted); } /* Play overlay */ .game-card-overlay { - position: absolute; inset: 0; - display: flex; align-items: center; justify-content: center; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; opacity: 0; transition: opacity var(--t-normal); } -.game-card:hover .game-card-overlay { opacity: 1; } + +.game-card:hover .game-card-overlay { + opacity: 1; +} .game-card-gradient { - position: absolute; inset: 0; + position: absolute; + inset: 0; background: linear-gradient(to bottom, transparent 20%, rgba(10, 10, 12, 0.8) 100%); } + .game-card-play-button { - position: relative; z-index: 1; - width: 44px; height: 44px; + position: relative; + z-index: 1; + width: 44px; + height: 44px; border-radius: 50%; - background: var(--accent); border: none; - display: flex; align-items: center; justify-content: center; + background: var(--accent); + border: none; + display: flex; + align-items: center; + justify-content: center; cursor: pointer; transition: transform var(--t-fast), background var(--t-fast); box-shadow: 0 4px 16px var(--accent-glow); color: var(--accent-on); } -.game-card-play-button:hover { transform: scale(1.1); background: var(--accent-press); } + +.game-card-play-button:hover { + transform: scale(1.1); + background: var(--accent-press); +} /* Card info (bottom section) */ .game-card-info { padding: 10px 12px; - display: flex; flex-direction: column; gap: 6px; + display: flex; + flex-direction: column; + gap: 6px; } + .game-card-title { margin: 0; - font-size: 0.8rem; font-weight: 600; color: var(--ink); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-size: 0.8rem; + font-weight: 600; + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; line-height: 1.3; } + .game-card-platform { margin: 0; font-size: 0.72rem; @@ -1263,9 +1749,13 @@ body.controller-mode { gap: 3px; flex-wrap: wrap; } + .game-card-store-chip { - display: flex; align-items: center; justify-content: center; - width: 22px; height: 22px; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; background: var(--chip); border: none; padding: 0; @@ -1273,51 +1763,89 @@ body.controller-mode { color: var(--ink-muted); transition: color var(--t-fast), background var(--t-fast); } -button.game-card-store-chip { cursor: pointer; } + +button.game-card-store-chip { + cursor: pointer; +} + .game-card-store-chip:hover { color: var(--ink-soft); background: var(--panel-border-solid); } + .game-card-store-chip.active { color: var(--accent-on); background: var(--accent); } + button.game-card-store-chip.active:hover { background: var(--accent-press); } + .game-card-store-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } -.store-svg { display: block; flex-shrink: 0; } + +.store-svg { + display: block; + flex-shrink: 0; +} /* ====================================================== LIBRARY PAGE ====================================================== */ .library-page { - display: flex; flex-direction: column; - height: 100%; max-width: 1600px; - margin: 0 auto; gap: 16px; overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + max-width: 1600px; + margin: 0 auto; + gap: 16px; + overflow: hidden; } .library-toolbar { - display: flex; align-items: center; gap: 12px; + display: flex; + align-items: center; + gap: 12px; flex-shrink: 0; } + .library-title { - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; +} + +.library-title-icon { + color: var(--accent); + flex-shrink: 0; +} + +.library-title h1 { + font-size: 1.1rem; + font-weight: 700; + margin: 0; + white-space: nowrap; } -.library-title-icon { color: var(--accent); flex-shrink: 0; } -.library-title h1 { font-size: 1.1rem; font-weight: 700; margin: 0; white-space: nowrap; } .library-search { - flex: 1; position: relative; max-width: 340px; + flex: 1; + position: relative; + max-width: 340px; } + .library-search-icon { - position: absolute; left: 11px; top: 50%; transform: translateY(-50%); - color: var(--ink-muted); pointer-events: none; + position: absolute; + left: 11px; + top: 50%; + transform: translateY(-50%); + color: var(--ink-muted); + pointer-events: none; } + .library-search-input { width: 100%; padding: 7px 12px 7px 34px; @@ -1325,42 +1853,95 @@ button.game-card-store-chip.active:hover { border: 1px solid var(--panel-border); background: var(--card); color: var(--ink); - font-size: 0.82rem; font-family: inherit; outline: none; + font-size: 0.82rem; + font-family: inherit; + outline: none; transition: border-color var(--t-fast), box-shadow var(--t-fast); } -.library-search-input::placeholder { color: var(--ink-muted); } -.library-search-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-surface); } -.library-count { - font-size: 0.78rem; color: var(--ink-muted); font-weight: 500; - white-space: nowrap; margin-left: auto; +.library-search-input::placeholder { + color: var(--ink-muted); } -.library-grid-area { - flex: 1; overflow-y: auto; min-height: 0; padding-right: 2px; - will-change: scroll-position; +.library-search-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-surface); } -.library-game-wrapper { - display: flex; flex-direction: column; gap: 4px; -} -.library-last-played { - display: flex; align-items: center; gap: 5px; - padding: 0 4px; - font-size: 0.7rem; color: var(--ink-muted); +.library-count { + font-size: 0.78rem; + color: var(--ink-muted); + font-weight: 500; + white-space: nowrap; + margin-left: auto; +} + +.library-grid-area { + flex: 1; + overflow-y: auto; + min-height: 0; + padding-right: 2px; + will-change: scroll-position; +} + +.library-game-wrapper { + display: flex; + flex-direction: column; + gap: 4px; +} + +.library-last-played { + display: flex; + align-items: center; + gap: 5px; + padding: 0 4px; + font-size: 0.7rem; + color: var(--ink-muted); } .library-empty-state { - display: flex; flex-direction: column; - align-items: center; justify-content: center; - height: 100%; min-height: 260px; - gap: 12px; color: var(--ink-soft); text-align: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 260px; + gap: 12px; + color: var(--ink-soft); + text-align: center; +} + +.library-empty-state h3 { + font-size: 1.05rem; + color: var(--ink); + margin: 0; +} + +.library-empty-state p { + font-size: 0.82rem; + margin: 0; + max-width: 340px; +} + +.library-empty-icon { + color: var(--panel-border-solid); + opacity: 0.5; } -.library-empty-state h3 { font-size: 1.05rem; color: var(--ink); margin: 0; } -.library-empty-state p { font-size: 0.82rem; margin: 0; max-width: 340px; } -.library-empty-icon { color: var(--panel-border-solid); opacity: 0.5; } -.library-spinner { animation: spin 1s linear infinite; color: var(--accent); } +.library-spinner { + animation: spin 1s linear infinite; + color: var(--accent); +} + +.controller-selected-overlay { + position: absolute; + inset: auto 12px 12px 12px; + height: 96px; + border-radius: 10px; + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.45) 100%); + pointer-events: none; + mix-blend-mode: multiply; +} /* ====================================================== SETTINGS PAGE @@ -1368,31 +1949,56 @@ button.game-card-store-chip.active:hover { .settings-page { max-width: 760px; margin: 0 auto; - display: flex; flex-direction: column; gap: 18px; + display: flex; + flex-direction: column; + gap: 18px; } .settings-header { - display: flex; align-items: center; gap: 12px; + display: flex; + align-items: center; + gap: 12px; color: var(--ink); } -.settings-header svg { width: 24px; height: 24px; } -.settings-header h1 { font-size: 1.35rem; font-weight: 700; margin: 0; flex: 1; } + +.settings-header svg { + width: 24px; + height: 24px; +} + +.settings-header h1 { + font-size: 1.35rem; + font-weight: 700; + margin: 0; + flex: 1; +} .settings-saved { - display: flex; align-items: center; gap: 5px; + display: flex; + align-items: center; + gap: 5px; padding: 6px 14px; background: var(--accent-surface); border-radius: var(--r-full); - font-size: 0.84rem; font-weight: 600; + font-size: 0.84rem; + font-weight: 600; color: var(--accent); - opacity: 0; transform: translateY(-3px); - transition: opacity var(--t-normal), transform var(--t-normal); pointer-events: none; + opacity: 0; + transform: translateY(-3px); + transition: opacity var(--t-normal), transform var(--t-normal); + pointer-events: none; +} + +.settings-saved.visible { + opacity: 1; + transform: translateY(0); } -.settings-saved.visible { opacity: 1; transform: translateY(0); } /* Sections */ .settings-sections { - display: flex; flex-direction: column; gap: 14px; + display: flex; + flex-direction: column; + gap: 14px; } .settings-section { @@ -1402,38 +2008,65 @@ button.game-card-store-chip.active:hover { padding: 18px 20px; transition: border-color var(--t-normal); } -.settings-section:hover { border-color: rgba(255, 255, 255, 0.08); } + +.settings-section:hover { + border-color: rgba(255, 255, 255, 0.08); +} .settings-section-header { - display: flex; align-items: center; gap: 10px; - margin-bottom: 16px; padding-bottom: 12px; + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + padding-bottom: 12px; border-bottom: 1px solid var(--panel-border); color: var(--accent); } -.settings-section-header svg { width: 20px; height: 20px; } + +.settings-section-header svg { + width: 20px; + height: 20px; +} + .settings-section-header h2 { - font-size: 1rem; font-weight: 600; color: var(--ink); margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--ink); + margin: 0; } /* Rows */ .settings-rows { - display: flex; flex-direction: column; gap: 14px; + display: flex; + flex-direction: column; + gap: 14px; } + .settings-row { - display: flex; align-items: center; justify-content: space-between; gap: 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; } + .settings-row--column { - flex-direction: column; align-items: stretch; + flex-direction: column; + align-items: stretch; } + .settings-row-top { - display: flex; align-items: center; justify-content: space-between; + display: flex; + align-items: center; + justify-content: space-between; margin-bottom: 8px; } + .settings-shortcut-actions { display: flex; align-items: center; gap: 8px; } + .settings-shortcut-reset-btn { padding: 5px 10px; border: 1px solid var(--panel-border-solid); @@ -1446,20 +2079,26 @@ button.game-card-store-chip.active:hover { cursor: pointer; transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast); } + .settings-shortcut-reset-btn:hover:not(:disabled) { color: var(--accent); border-color: rgba(88, 217, 138, 0.35); background: var(--accent-surface); } + .settings-shortcut-reset-btn:disabled { opacity: 0.5; cursor: default; } .settings-label { - font-size: 0.92rem; color: var(--ink-soft); font-weight: 500; - flex-shrink: 0; cursor: default; + font-size: 0.92rem; + color: var(--ink-soft); + font-weight: 500; + flex-shrink: 0; + cursor: default; } + .settings-hint { display: block; margin-top: 2px; @@ -1475,19 +2114,38 @@ button.game-card-store-chip.active:hover { border: 1px solid var(--panel-border-solid); border-radius: 6px; color: var(--ink); - font-size: 0.82rem; font-family: inherit; outline: none; + font-size: 0.82rem; + font-family: inherit; + outline: none; min-width: 120px; transition: border-color var(--t-fast), box-shadow var(--t-fast); } -.settings-text-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-surface); } -.settings-text-input.error { border-color: var(--error); box-shadow: 0 0 0 2px var(--error-bg); } -.settings-text-input--narrow { min-width: 70px; max-width: 90px; } + +.settings-text-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-surface); +} + +.settings-text-input.error { + border-color: var(--error); + box-shadow: 0 0 0 2px var(--error-bg); +} + +.settings-text-input--narrow { + min-width: 70px; + max-width: 90px; +} .settings-input-group { - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; } + .settings-input-hint { - font-size: 0.82rem; color: var(--error); font-weight: 500; + font-size: 0.82rem; + color: var(--error); + font-weight: 500; } .settings-subtle-hint { @@ -1495,34 +2153,41 @@ button.game-card-store-chip.active:hover { color: var(--ink-muted); font-weight: 500; } + .settings-shortcut-hint { font-size: 0.72rem; color: var(--ink-muted); } + .settings-shortcut-grid { display: grid; gap: 8px; } + .settings-shortcut-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; } + .settings-shortcut-label { font-size: 0.77rem; color: var(--ink-soft); } + .settings-shortcut-input { min-width: 185px; text-align: right; } + .settings-shortcut-input--static { background: var(--bg-e); color: var(--ink); border-color: var(--panel-border); cursor: default; } + .settings-shortcut-input--static:focus { border-color: var(--panel-border); box-shadow: none; @@ -1530,49 +2195,87 @@ button.game-card-store-chip.active:hover { /* Chip row (presets) */ .settings-chip-row { - display: flex; flex-wrap: wrap; gap: 4px; + display: flex; + flex-wrap: wrap; + gap: 4px; } + .settings-chip { - display: flex; align-items: center; gap: 4px; + display: flex; + align-items: center; + gap: 4px; padding: 7px 12px; border-radius: 6px; border: 1px solid var(--panel-border); background: var(--chip); color: var(--ink-muted); - font-size: 0.84rem; font-weight: 600; + font-size: 0.84rem; + font-weight: 600; font-family: inherit; cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); outline: none; white-space: nowrap; } -.settings-chip:hover { background: var(--panel-border-solid); color: var(--ink-soft); border-color: var(--panel-border-solid); } + +.settings-chip:hover { + background: var(--panel-border-solid); + color: var(--ink-soft); + border-color: var(--panel-border-solid); +} + .settings-chip.active { - background: var(--accent-surface-strong); color: var(--accent); + background: var(--accent-surface-strong); + color: var(--accent); border-color: rgba(88, 217, 138, 0.25); } /* Tier indicator on preset chips */ .settings-chip-tier { - font-size: 0.62rem; font-weight: 800; - padding: 2px 4px; border-radius: 3px; - line-height: 1; text-transform: uppercase; + font-size: 0.62rem; + font-weight: 800; + padding: 2px 4px; + border-radius: 3px; + line-height: 1; + text-transform: uppercase; letter-spacing: 0.04em; } -.settings-chip-tier.free { color: var(--ink-muted); background: rgba(255, 255, 255, 0.05); } -.settings-chip-tier.priority { color: #cdaf95; background: rgba(205, 175, 149, 0.1); } -.settings-chip-tier.ultimate { color: #ffd700; background: rgba(255, 215, 0, 0.1); } -.settings-chip.active .settings-chip-tier { opacity: 0.8; } + +.settings-chip-tier.free { + color: var(--ink-muted); + background: rgba(255, 255, 255, 0.05); +} + +.settings-chip-tier.priority { + color: #cdaf95; + background: rgba(205, 175, 149, 0.1); +} + +.settings-chip-tier.ultimate { + color: #ffd700; + background: rgba(255, 215, 0, 0.1); +} + +.settings-chip.active .settings-chip-tier { + opacity: 0.8; +} /* Preset groups (aspect ratio grouped resolutions) */ .settings-preset-groups { - display: flex; flex-direction: column; gap: 10px; + display: flex; + flex-direction: column; + gap: 10px; } + .settings-preset-group { - display: flex; flex-direction: column; gap: 5px; + display: flex; + flex-direction: column; + gap: 5px; } + .settings-preset-group-label { - font-size: 0.74rem; font-weight: 700; + font-size: 0.74rem; + font-weight: 700; color: var(--ink-muted); text-transform: uppercase; letter-spacing: 0.06em; @@ -1592,58 +2295,89 @@ button.game-card-store-chip.active:hover { padding: 4px 12px; background: var(--chip); border-radius: 5px; - font-size: 0.86rem; font-weight: 700; + font-size: 0.86rem; + font-weight: 700; color: var(--accent); white-space: nowrap; } /* Slider */ .settings-slider { - width: 100%; height: 6px; + width: 100%; + height: 6px; background: var(--chip); border-radius: 2px; outline: none; -webkit-appearance: none; + appearance: none; cursor: pointer; } + .settings-slider::-webkit-slider-thumb { -webkit-appearance: none; - width: 16px; height: 16px; + appearance: none; + width: 16px; + height: 16px; background: var(--accent); border-radius: 50%; cursor: pointer; transition: transform var(--t-fast); } -.settings-slider::-webkit-slider-thumb:hover { transform: scale(1.2); } + +.settings-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); +} /* Toggle */ .settings-toggle { position: relative; display: inline-block; - width: 44px; height: 24px; + width: 44px; + height: 24px; cursor: pointer; } -.settings-toggle input { opacity: 0; width: 0; height: 0; } + +.settings-toggle input { + opacity: 0; + width: 0; + height: 0; +} + .settings-toggle-track { - position: absolute; inset: 0; + position: absolute; + inset: 0; background: var(--chip); border-radius: 10px; transition: background var(--t-normal); } + .settings-toggle-track::before { - content: ""; position: absolute; - top: 3px; left: 3px; - width: 18px; height: 18px; - background: white; border-radius: 50%; + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + background: white; + border-radius: 50%; transition: transform var(--t-normal); } -.settings-toggle input:checked + .settings-toggle-track { background: var(--accent); } -.settings-toggle input:checked + .settings-toggle-track::before { transform: translateX(20px); } + +.settings-toggle input:checked+.settings-toggle-track { + background: var(--accent); +} + +.settings-toggle input:checked+.settings-toggle-track::before { + transform: translateX(20px); +} /* Placeholder */ .settings-placeholder { - padding: 14px; text-align: center; - font-size: 0.92rem; color: var(--ink-muted); font-style: italic; + padding: 14px; + text-align: center; + font-size: 0.92rem; + color: var(--ink-muted); + font-style: italic; } /* Custom settings dropdown */ @@ -1651,44 +2385,65 @@ button.game-card-store-chip.active:hover { position: relative; width: 100%; } + .settings-dropdown-selected { width: 100%; - display: flex; align-items: center; justify-content: space-between; + display: flex; + align-items: center; + justify-content: space-between; gap: 8px; padding: 11px 14px; background: var(--bg-a); border: 1px solid var(--panel-border-solid); border-radius: var(--r-sm); - color: var(--ink); font-size: 0.92rem; font-weight: 500; + color: var(--ink); + font-size: 0.92rem; + font-weight: 500; font-family: inherit; cursor: pointer; transition: border-color var(--t-fast), box-shadow var(--t-fast), opacity var(--t-fast); outline: none; } -.settings-dropdown-selected:hover { border-color: #444; } -.settings-dropdown-selected.open { border-color: var(--accent); border-radius: var(--r-sm) var(--r-sm) 0 0; } + +.settings-dropdown-selected:hover { + border-color: #444; +} + +.settings-dropdown-selected.open { + border-color: var(--accent); + border-radius: var(--r-sm) var(--r-sm) 0 0; +} + .settings-dropdown-selected:disabled { opacity: 0.55; cursor: not-allowed; } + .settings-dropdown-selected-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .settings-dropdown-chevron { color: var(--ink-muted); transition: transform var(--t-fast), color var(--t-fast); flex-shrink: 0; } + .settings-dropdown-chevron.flipped { transform: rotate(180deg); color: var(--accent); } + .settings-dropdown-menu { - position: absolute; top: 100%; left: 0; right: 0; + position: absolute; + top: 100%; + left: 0; + right: 0; background: var(--bg-a); - border: 1px solid var(--accent); border-top: none; + border: 1px solid var(--accent); + border-top: none; border-radius: 0 0 var(--r-sm) var(--r-sm); z-index: 22; box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5); @@ -1696,57 +2451,104 @@ button.game-card-store-chip.active:hover { overflow-y: auto; animation: fade-in-down 120ms var(--ease); } + .settings-dropdown-menu--tall { max-height: 260px; } + .settings-dropdown-item { width: 100%; - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; padding: 10px 14px; - background: transparent; border: none; - color: var(--ink); font-size: 0.9rem; font-weight: 500; + background: transparent; + border: none; + color: var(--ink); + font-size: 0.9rem; + font-weight: 500; font-family: inherit; - cursor: pointer; text-align: left; + cursor: pointer; + text-align: left; transition: background var(--t-fast), color var(--t-fast); } -.settings-dropdown-item:hover { background: var(--accent-surface); } -.settings-dropdown-item.active { background: var(--accent-surface-strong); color: var(--accent); } -.settings-dropdown-item span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.settings-dropdown-check { color: var(--accent); flex-shrink: 0; } -.settings-mic-device-wrap { - display: flex; - flex-direction: column; - gap: 6px; - width: 100%; +.settings-dropdown-item:hover { + background: var(--accent-surface); } -/* Region selector */ -.region-selector { - position: relative; +.settings-dropdown-item.active { + background: var(--accent-surface-strong); + color: var(--accent); } -.region-selected { - width: 100%; - display: flex; align-items: center; justify-content: space-between; + +.settings-dropdown-item span { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-dropdown-check { + color: var(--accent); + flex-shrink: 0; +} + +.settings-mic-device-wrap { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +} + +/* Region selector */ +.region-selector { + position: relative; +} + +.region-selected { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; padding: 11px 14px; background: var(--bg-a); border: 1px solid var(--panel-border-solid); border-radius: var(--r-sm); - color: var(--ink); font-size: 0.92rem; font-weight: 500; + color: var(--ink); + font-size: 0.92rem; + font-weight: 500; font-family: inherit; cursor: pointer; transition: border-color var(--t-fast), box-shadow var(--t-fast); outline: none; } -.region-selected:hover { border-color: #444; } -.region-selected.open { border-color: var(--accent); border-radius: var(--r-sm) var(--r-sm) 0 0; } -.region-selected-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.region-selected:hover { + border-color: #444; +} + +.region-selected.open { + border-color: var(--accent); + border-radius: var(--r-sm) var(--r-sm) 0 0; +} + +.region-selected-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .region-chevron { color: var(--ink-muted); transition: transform var(--t-fast); flex-shrink: 0; } -.region-chevron.flipped { transform: rotate(180deg); color: var(--accent); } + +.region-chevron.flipped { + transform: rotate(180deg); + color: var(--accent); +} .region-selected-ping { font-size: 0.8rem; @@ -1810,63 +2612,112 @@ button.game-card-store-chip.active:hover { } .region-dropdown { - position: absolute; top: 100%; left: 0; right: 0; + position: absolute; + top: 100%; + left: 0; + right: 0; background: var(--bg-a); - border: 1px solid var(--accent); border-top: none; + border: 1px solid var(--accent); + border-top: none; border-radius: 0 0 var(--r-sm) var(--r-sm); z-index: 20; box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5); animation: fade-in-down 120ms var(--ease); } + .region-dropdown-search { - display: flex; align-items: center; gap: 6px; + display: flex; + align-items: center; + gap: 6px; padding: 10px 12px; border-bottom: 1px solid var(--panel-border); position: relative; } + .region-dropdown-search-icon { - color: var(--ink-muted); flex-shrink: 0; + color: var(--ink-muted); + flex-shrink: 0; } + .region-dropdown-search-input { flex: 1; padding: 5px 0; - background: transparent; border: none; - color: var(--ink); font-size: 0.9rem; font-family: inherit; + background: transparent; + border: none; + color: var(--ink); + font-size: 0.9rem; + font-family: inherit; outline: none; } -.region-dropdown-search-input::placeholder { color: var(--ink-muted); } + +.region-dropdown-search-input::placeholder { + color: var(--ink-muted); +} + .region-dropdown-clear { - display: flex; align-items: center; justify-content: center; - width: 20px; height: 20px; - border-radius: 4px; border: none; background: var(--chip); - color: var(--ink-muted); cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + border: none; + background: var(--chip); + color: var(--ink-muted); + cursor: pointer; transition: color var(--t-fast), background var(--t-fast); } -.region-dropdown-clear:hover { background: var(--panel-border-solid); color: var(--ink); } + +.region-dropdown-clear:hover { + background: var(--panel-border-solid); + color: var(--ink); +} .region-dropdown-list { - max-height: 200px; overflow-y: auto; + max-height: 200px; + overflow-y: auto; } + .region-dropdown-item { width: 100%; - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; padding: 10px 14px; - background: transparent; border: none; - color: var(--ink); font-size: 0.9rem; font-weight: 500; + background: transparent; + border: none; + color: var(--ink); + font-size: 0.9rem; + font-weight: 500; font-family: inherit; - cursor: pointer; text-align: left; + cursor: pointer; + text-align: left; transition: background var(--t-fast); } -.region-dropdown-item:hover { background: var(--accent-surface); } -.region-dropdown-item.active { background: var(--accent-surface-strong); color: var(--accent); } -.region-dropdown-item .region-name-with-badge { + +.region-dropdown-item:hover { + background: var(--accent-surface); +} + +.region-dropdown-item.active { + background: var(--accent-surface-strong); + color: var(--accent); +} + +.region-dropdown-item .region-name-with-badge { flex: 1; } -.region-check { color: var(--accent); flex-shrink: 0; } + +.region-check { + color: var(--accent); + flex-shrink: 0; +} .region-dropdown-empty { - padding: 14px; text-align: center; - font-size: 0.9rem; color: var(--ink-muted); + padding: 14px; + text-align: center; + font-size: 0.9rem; + color: var(--ink-muted); } /* Region ping test styles */ @@ -1914,11 +2765,6 @@ button.game-card-store-chip.active:hover { animation: spin 1s linear infinite; } -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - .region-ping { display: flex; align-items: center; @@ -2019,61 +2865,122 @@ button.game-card-store-chip.active:hover { /* Footer */ .settings-footer { - display: flex; justify-content: flex-end; + display: flex; + justify-content: flex-end; } + .settings-save-btn { - display: flex; align-items: center; gap: 7px; + display: flex; + align-items: center; + gap: 7px; padding: 11px 20px; background: linear-gradient(135deg, var(--accent), var(--accent-press)); - border: none; border-radius: var(--r-sm); - color: var(--accent-on); font-size: 0.92rem; font-weight: 700; + border: none; + border-radius: var(--r-sm); + color: var(--accent-on); + font-size: 0.92rem; + font-weight: 700; font-family: inherit; cursor: pointer; transition: transform var(--t-normal), box-shadow var(--t-normal); box-shadow: 0 4px 16px var(--accent-glow); } -.settings-save-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 20px var(--accent-glow); } -.settings-save-btn:active { transform: translateY(0); } + +.settings-save-btn:hover { + transform: translateY(-1px); + box-shadow: 0 6px 20px var(--accent-glow); +} + +.settings-save-btn:active { + transform: translateY(0); +} /* Export Logs Button */ .settings-export-logs-btn { - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; padding: 10px 18px; background: var(--bg-c); border: 1px solid var(--panel-border-solid); border-radius: var(--r-sm); color: var(--ink-soft); - font-size: 0.9rem; font-weight: 600; + font-size: 0.9rem; + font-weight: 600; font-family: inherit; cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast), transform var(--t-fast); white-space: nowrap; } + .settings-export-logs-btn:hover { color: var(--ink); background: var(--card-hover); - border-color: rgba(255,255,255,0.12); + border-color: rgba(255, 255, 255, 0.12); + transform: translateY(-1px); +} + +.settings-export-logs-btn:active { + transform: translateY(0); +} + +.settings-delete-cache-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + background: var(--bg-c); + border: 1px solid rgba(248, 113, 113, 0.3); + border-radius: var(--r-sm); + color: rgba(248, 113, 113, 0.8); + font-size: 0.9rem; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast), transform var(--t-fast); + white-space: nowrap; +} + +.settings-delete-cache-btn:hover { + color: #f87171; + background: rgba(248, 113, 113, 0.08); + border-color: rgba(248, 113, 113, 0.5); transform: translateY(-1px); } -.settings-export-logs-btn:active { transform: translateY(0); } + +.settings-delete-cache-btn:active { + transform: translateY(0); +} /* Codec Diagnostics */ .codec-test-btn { - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; padding: 9px 18px; background: var(--bg-c); border: 1px solid var(--panel-border-solid); border-radius: 6px; color: var(--ink-soft); - font-size: 0.88rem; font-weight: 600; + font-size: 0.88rem; + font-weight: 600; font-family: inherit; cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); white-space: nowrap; flex-shrink: 0; } -.codec-test-btn:hover { background: var(--panel-border-solid); color: var(--ink); border-color: var(--accent); } -.codec-test-btn:disabled { opacity: 0.6; cursor: not-allowed; } + +.codec-test-btn:hover { + background: var(--panel-border-solid); + color: var(--ink); + border-color: var(--accent); +} + +.codec-test-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} .codec-test-description { font-size: 0.9rem; @@ -2113,46 +3020,68 @@ button.game-card-store-chip.active:hover { border: 1px solid var(--panel-border); border-radius: 10px; padding: 16px 18px; - display: flex; flex-direction: column; gap: 12px; + display: flex; + flex-direction: column; + gap: 12px; transition: border-color var(--t-fast); } -.codec-result-card:hover { border-color: rgba(255, 255, 255, 0.1); } + +.codec-result-card:hover { + border-color: rgba(255, 255, 255, 0.1); +} .codec-result-header { - display: flex; align-items: center; justify-content: space-between; gap: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } .codec-result-name { - font-size: 1.1rem; font-weight: 700; color: var(--ink); + font-size: 1.1rem; + font-weight: 700; + color: var(--ink); letter-spacing: 0.02em; } .codec-result-badge { - font-size: 0.72rem; font-weight: 700; - padding: 3px 8px; border-radius: 5px; - text-transform: uppercase; letter-spacing: 0.04em; + font-size: 0.72rem; + font-weight: 700; + padding: 3px 8px; + border-radius: 5px; + text-transform: uppercase; + letter-spacing: 0.04em; white-space: nowrap; } + .codec-result-badge.supported { - color: var(--success); background: rgba(74, 222, 128, 0.1); + color: var(--success); + background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.2); } + .codec-result-badge.unsupported { - color: var(--ink-muted); background: rgba(255, 255, 255, 0.04); + color: var(--ink-muted); + background: rgba(255, 255, 255, 0.04); border: 1px solid var(--panel-border); } .codec-result-rows { - display: flex; flex-direction: column; gap: 8px; + display: flex; + flex-direction: column; + gap: 8px; } .codec-result-row { - display: flex; align-items: center; gap: 10px; + display: flex; + align-items: center; + gap: 10px; font-size: 0.88rem; } .codec-result-direction { - color: var(--ink-muted); font-weight: 600; + color: var(--ink-muted); + font-weight: 600; min-width: 54px; text-transform: uppercase; font-size: 0.78rem; @@ -2160,44 +3089,61 @@ button.game-card-store-chip.active:hover { } .codec-result-status { - font-weight: 700; font-size: 0.82rem; - padding: 2px 8px; border-radius: 5px; - min-width: 38px; text-align: center; + font-weight: 700; + font-size: 0.82rem; + padding: 2px 8px; + border-radius: 5px; + min-width: 38px; + text-align: center; } + .codec-result-status.hw { - color: var(--accent); background: var(--accent-surface); + color: var(--accent); + background: var(--accent-surface); } + .codec-result-status.sw { - color: var(--warning); background: rgba(251, 191, 36, 0.1); + color: var(--warning); + background: rgba(251, 191, 36, 0.1); } + .codec-result-status.none { - color: var(--error); background: var(--error-bg); + color: var(--error); + background: var(--error-bg); } .codec-result-via { - color: var(--ink-muted); font-size: 0.84rem; + color: var(--ink-muted); + font-size: 0.84rem; flex: 1; } .codec-result-profiles { border-top: 1px solid var(--panel-border); padding-top: 10px; - display: flex; flex-direction: column; gap: 6px; + display: flex; + flex-direction: column; + gap: 6px; } .codec-result-profiles-label { - font-size: 0.72rem; font-weight: 700; - color: var(--ink-muted); text-transform: uppercase; + font-size: 0.72rem; + font-weight: 700; + color: var(--ink-muted); + text-transform: uppercase; letter-spacing: 0.05em; } .codec-result-profiles-list { - display: flex; flex-wrap: wrap; gap: 5px; + display: flex; + flex-wrap: wrap; + gap: 5px; } .codec-result-profile { font-size: 0.74rem; - padding: 2px 7px; border-radius: 4px; + padding: 2px 7px; + border-radius: 4px; background: rgba(255, 255, 255, 0.04); color: var(--ink-muted); font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; @@ -2209,46 +3155,121 @@ button.game-card-store-chip.active:hover { STREAM VIEW (.sv-) ====================================================== */ .sv { - position: fixed; inset: 0; - background: #000; z-index: 1000; + position: fixed; + inset: 0; + background: #000; + z-index: 1000; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; + transition: opacity 300ms ease; +} + +.sv--switching { + opacity: 0; + pointer-events: none; } + .sv-video { - width: 100%; height: 100%; - object-fit: contain; display: block; - position: relative; z-index: 1; + width: 100%; + height: 100%; + object-fit: contain; + display: block; + position: relative; + z-index: 1; outline: none; } + .sv-video:focus, .sv-video:focus-visible { outline: none; box-shadow: none; } + .sv-empty { - position: absolute; inset: 0; z-index: 0; - display: flex; align-items: center; justify-content: center; + position: absolute; + inset: 0; + z-index: 0; + display: flex; + align-items: center; + justify-content: center; pointer-events: none; } + .sv-empty-grad { - position: absolute; inset: 0; + position: absolute; + inset: 0; background: linear-gradient(135deg, #0a0a0c 0%, #19191e 50%, #0a0a0c 100%); } /* Connecting overlay (inside StreamView) */ -.sv-connect { - position: absolute; inset: 0; - display: flex; align-items: center; justify-content: center; z-index: 10; + +.switching-overlay { + position: fixed; + inset: 0; + z-index: 2100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,1); + color: #fff; + pointer-events: auto; + transition: opacity 200ms ease; } -.sv-connect-inner { - display: flex; flex-direction: column; align-items: center; gap: 14px; - animation: fade-in 300ms var(--ease); + +.switching-card { + text-align: center; + max-width: 680px; + padding: 28px 36px; + border-radius: 10px; } -.sv-connect-spin { color: var(--accent); animation: spin 1s linear infinite; } -.sv-connect-title { font-size: 1.05rem; font-weight: 600; color: var(--ink); margin: 0; } -.sv-connect-platform { - display: inline-flex; + +.switching-spinner { + margin: 0 auto 12px auto; + color: var(--ink-muted, rgba(255,255,255,0.8)); +} + +.switching-title { + font-size: 18px; + font-weight: 600; + margin: 6px 0 4px 0; +} + +.switching-sub { + margin: 0; + color: rgba(255,255,255,0.72); +} +.sv-connect { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.sv-connect-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + animation: fade-in 300ms var(--ease); +} + +.sv-connect-spin { + color: var(--accent); + animation: spin 1s linear infinite; +} + +.sv-connect-title { + font-size: 1.05rem; + font-weight: 600; + color: var(--ink); + margin: 0; +} + +.sv-connect-platform { + display: inline-flex; align-items: center; gap: 7px; padding: 5px 10px; @@ -2260,11 +3281,13 @@ button.game-card-store-chip.active:hover { font-size: 0.76rem; font-weight: 600; } -.sv-connect-platform > span:last-child { + +.sv-connect-platform>span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .sv-connect-platform-icon { display: inline-flex; align-items: center; @@ -2273,7 +3296,12 @@ button.game-card-store-chip.active:hover { height: 16px; color: var(--accent); } -.sv-connect-sub { font-size: 0.82rem; color: var(--ink-soft); margin: 0; } + +.sv-connect-sub { + font-size: 0.82rem; + color: var(--ink-soft); + margin: 0; +} .sv-session-clock { position: fixed; @@ -2298,10 +3326,12 @@ button.game-card-store-chip.active:hover { transition: opacity 420ms var(--ease), transform 420ms var(--ease); pointer-events: none; } + .sv-session-clock.is-visible { opacity: 1; transform: translate(-50%, 0); } + .sv-session-clock svg { color: var(--accent); } @@ -2327,44 +3357,72 @@ button.game-card-store-chip.active:hover { animation: fade-in 140ms var(--ease); backdrop-filter: blur(5px); } + .sv-time-warning--warn { border-color: color-mix(in srgb, var(--warning) 55%, var(--panel-border)); color: #f8e5b0; } + .sv-time-warning--warn svg { color: var(--warning); } + .sv-time-warning--critical { border-color: color-mix(in srgb, var(--error) 65%, var(--panel-border)); color: #ffd0d0; } + .sv-time-warning--critical svg { color: var(--error); } /* Stats HUD (StreamView inline stats) */ .sv-stats { - position: fixed; top: 14px; right: 14px; z-index: 1001; - display: flex; flex-direction: column; gap: 5px; + position: fixed; + top: 14px; + right: 14px; + z-index: 1001; + display: flex; + flex-direction: column; + gap: 5px; padding: 8px 10px; background: rgba(10, 10, 12, 0.9); - border: 1px solid var(--panel-border); border-radius: var(--r-md); - font-size: 0.7rem; min-width: 240px; max-width: 320px; font-variant-numeric: tabular-nums; + border: 1px solid var(--panel-border); + border-radius: var(--r-md); + font-size: 0.7rem; + min-width: 240px; + max-width: 320px; + font-variant-numeric: tabular-nums; backdrop-filter: blur(4px); } + .sv-stats-head { - display: flex; align-items: center; justify-content: space-between; gap: 8px; - font-weight: 700; color: var(--ink); font-size: 0.75rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-weight: 700; + color: var(--ink); + font-size: 0.75rem; line-height: 1.1; } + .sv-stats-primary { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.sv-stats-wait { color: var(--ink-muted); font-style: italic; font-weight: 500; } + +.sv-stats-wait { + color: var(--ink-muted); + font-style: italic; + font-weight: 500; +} + .sv-stats-live { - display: inline-flex; align-items: center; justify-content: center; + display: inline-flex; + align-items: center; + justify-content: center; min-width: 40px; padding: 1px 7px; border-radius: 999px; @@ -2374,39 +3432,60 @@ button.game-card-store-chip.active:hover { color: var(--ink-soft); background: rgba(255, 255, 255, 0.03); } + .sv-stats-live.is-live { color: var(--success); border-color: color-mix(in srgb, var(--success) 45%, var(--panel-border)); background: color-mix(in srgb, var(--success) 12%, transparent); } + .sv-stats-live.is-pending { color: var(--warning); border-color: color-mix(in srgb, var(--warning) 40%, var(--panel-border)); background: color-mix(in srgb, var(--warning) 10%, transparent); } + .sv-stats-sub { - display: flex; align-items: center; justify-content: space-between; gap: 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; padding-top: 1px; } + .sv-stats-sub-left { - display: inline-flex; align-items: center; gap: 6px; + display: inline-flex; + align-items: center; + gap: 6px; color: var(--ink-soft); } + .sv-stats-sub-right { color: var(--ink); font-weight: 600; } + .sv-stats-hdr { - padding: 1px 5px; background: var(--accent-surface); - border-radius: 3px; font-size: 0.6rem; font-weight: 700; - color: var(--accent); text-transform: uppercase; + padding: 1px 5px; + background: var(--accent-surface); + border-radius: 3px; + font-size: 0.6rem; + font-weight: 700; + color: var(--accent); + text-transform: uppercase; } + .sv-stats-metrics { - display: flex; align-items: center; gap: 4px; + display: flex; + align-items: center; + gap: 4px; flex-wrap: wrap; } + .sv-stats-chip { - display: inline-flex; align-items: center; gap: 5px; + display: inline-flex; + align-items: center; + gap: 5px; padding: 2px 5px; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--panel-border); @@ -2414,10 +3493,12 @@ button.game-card-store-chip.active:hover { font-size: 0.64rem; color: var(--ink-muted); } + .sv-stats-chip-val { font-weight: 700; color: var(--ink-soft); } + .sv-stats-foot { font-size: 0.64rem; color: var(--ink-muted); @@ -2428,113 +3509,177 @@ button.game-card-store-chip.active:hover { /* Controller indicator */ .sv-ctrl { - position: fixed; top: 14px; left: 14px; - display: flex; align-items: center; gap: 6px; + position: fixed; + top: 14px; + left: 14px; + display: flex; + align-items: center; + gap: 6px; padding: 7px 11px; background: rgba(10, 10, 12, 0.92); - border: 1px solid var(--panel-border); border-radius: var(--r-md); - z-index: 1001; color: var(--accent); + border: 1px solid var(--panel-border); + border-radius: var(--r-md); + z-index: 1001; + color: var(--accent); +} + +.sv-ctrl-n { + font-size: 0.75rem; + font-weight: 700; + color: var(--ink); } -.sv-ctrl-n { font-size: 0.75rem; font-weight: 700; color: var(--ink); } .sv-afk { - position: fixed; top: 14px; left: 14px; - display: inline-flex; align-items: center; gap: 7px; + position: fixed; + top: 14px; + left: 14px; + display: inline-flex; + align-items: center; + gap: 7px; padding: 7px 11px; background: rgba(10, 10, 12, 0.92); - border: 1px solid var(--panel-border); border-radius: var(--r-md); - z-index: 1001; color: var(--success); + border: 1px solid var(--panel-border); + border-radius: var(--r-md); + z-index: 1001; + color: var(--success); } + .sv-afk--stacked { top: 56px; } + .sv-afk-dot { - width: 8px; height: 8px; border-radius: 999px; + width: 8px; + height: 8px; + border-radius: 999px; background: var(--success); box-shadow: 0 0 8px rgba(88, 217, 138, 0.8); } + .sv-afk-label { - font-size: 0.72rem; font-weight: 700; color: var(--ink); + font-size: 0.72rem; + font-weight: 700; + color: var(--ink); letter-spacing: 0.03em; } .sv-rec { - position: fixed; left: 14px; - display: inline-flex; align-items: center; gap: 7px; + position: fixed; + left: 14px; + display: inline-flex; + align-items: center; + gap: 7px; padding: 7px 11px; background: rgba(10, 10, 12, 0.92); - border: 1px solid var(--panel-border); border-radius: var(--r-md); + border: 1px solid var(--panel-border); + border-radius: var(--r-md); z-index: 1001; } + .sv-rec-dot { - width: 8px; height: 8px; border-radius: 999px; + width: 8px; + height: 8px; + border-radius: 999px; background: var(--error); animation: rec-pulse 1.2s ease-in-out infinite; } + .sv-rec-label { - font-size: 0.72rem; font-weight: 700; color: var(--error); + font-size: 0.72rem; + font-weight: 700; + color: var(--error); letter-spacing: 0.06em; } + @keyframes rec-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.25; } + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.25; + } } .sv-mic { - position: fixed; top: 14px; left: 14px; - display: inline-flex; align-items: center; justify-content: center; - width: 36px; height: 36px; + position: fixed; + top: 14px; + left: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; padding: 0; background: rgba(10, 10, 12, 0.92); - border: 1px solid var(--panel-border); border-radius: var(--r-md); - z-index: 1001; color: var(--error); - cursor: pointer; transition: all 0.15s ease; + border: 1px solid var(--panel-border); + border-radius: var(--r-md); + z-index: 1001; + color: var(--error); + cursor: pointer; + transition: all 0.15s ease; } + .sv-mic:hover { background: rgba(20, 20, 24, 0.95); border-color: rgba(255, 255, 255, 0.12); } + .sv-mic:active { transform: scale(0.96); } + .sv-mic svg { color: var(--error); transition: color 0.15s ease; } + /* When mic is enabled (unmuted), show accent color */ .sv-mic[data-enabled="true"] svg { color: var(--accent); } + .sv-mic[data-enabled="true"] { border-color: rgba(88, 217, 138, 0.3); box-shadow: 0 0 12px rgba(88, 217, 138, 0.15); } + /* Position below controller indicator */ .sv-mic--stacked { top: 56px; } + /* Position below anti-AFK when both are present */ -.sv-ctrl + .sv-afk + .sv-mic, -.sv-afk + .sv-mic:not(.sv-mic--stacked) { +.sv-ctrl+.sv-afk+.sv-mic, +.sv-afk+.sv-mic:not(.sv-mic--stacked) { top: 98px; } -.sv-ctrl + .sv-mic:not(.sv-mic--stacked) { + +.sv-ctrl+.sv-mic:not(.sv-mic--stacked) { top: 56px; } -.sv-ctrl + .sv-afk--stacked + .sv-mic { + +.sv-ctrl+.sv-afk--stacked+.sv-mic { top: 98px; } .sv-esc-hold-backdrop { - position: fixed; inset: 0; + position: fixed; + inset: 0; z-index: 1200; pointer-events: none; background: rgba(8, 9, 10, 0.24); backdrop-filter: blur(7px) saturate(110%); animation: fade-in 140ms var(--ease); } + .sv-esc-hold { - position: fixed; top: 20%; left: 50%; transform: translateX(-50%); + position: fixed; + top: 20%; + left: 50%; + transform: translateX(-50%); z-index: 1201; width: min(520px, calc(100vw - 40px)); padding: 16px 18px; @@ -2544,17 +3689,25 @@ button.game-card-store-chip.active:hover { box-shadow: 0 18px 60px rgba(0, 0, 0, 0.45); animation: fade-in 180ms var(--ease), float-a 8s ease-in-out infinite; } + .sv-esc-hold-title { font-size: 1.02rem; font-weight: 800; letter-spacing: 0.01em; color: var(--ink); } + .sv-esc-hold-head { - display: flex; align-items: center; justify-content: space-between; gap: 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; margin-top: 6px; - font-size: 0.82rem; color: var(--ink-soft); font-weight: 700; + font-size: 0.82rem; + color: var(--ink-soft); + font-weight: 700; } + .sv-esc-hold-track { margin-top: 10px; height: 10px; @@ -2563,6 +3716,7 @@ button.game-card-store-chip.active:hover { background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.08); } + .sv-esc-hold-fill { display: block; width: 100%; @@ -2582,6 +3736,7 @@ button.game-card-store-chip.active:hover { justify-content: center; padding: 24px; } + .sv-exit-backdrop { position: absolute; inset: 0; @@ -2593,6 +3748,7 @@ button.game-card-store-chip.active:hover { backdrop-filter: blur(14px) saturate(130%); cursor: pointer; } + .sv-exit-card { position: relative; z-index: 1; @@ -2607,6 +3763,7 @@ button.game-card-store-chip.active:hover { padding: 20px 20px 16px; animation: fade-in 170ms var(--ease); } + .sv-exit-kicker { display: inline-flex; align-items: center; @@ -2617,6 +3774,7 @@ button.game-card-store-chip.active:hover { text-transform: uppercase; color: var(--accent); } + .sv-exit-title { margin: 6px 0 0; font-size: 1.34rem; @@ -2624,25 +3782,30 @@ button.game-card-store-chip.active:hover { color: var(--ink); letter-spacing: 0.01em; } + .sv-exit-text { margin: 11px 0 0; font-size: 0.96rem; color: var(--ink-soft); line-height: 1.45; } + .sv-exit-text strong { color: var(--ink); } + .sv-exit-subtext { margin: 6px 0 0; font-size: 0.82rem; color: var(--ink-muted); } + .sv-exit-actions { margin-top: 15px; display: flex; gap: 10px; } + .sv-exit-btn { flex: 1; border-radius: 10px; @@ -2657,30 +3820,37 @@ button.game-card-store-chip.active:hover { cursor: pointer; transition: transform var(--t-fast), border-color var(--t-fast), background var(--t-fast), color var(--t-fast); } + .sv-exit-btn:hover { transform: translateY(-1px); } + .sv-exit-btn:active { transform: translateY(0); } + .sv-exit-btn-cancel:hover { border-color: var(--panel-border-solid); background: rgba(255, 255, 255, 0.07); } + .sv-exit-btn-confirm { background: linear-gradient(140deg, rgba(239, 68, 68, 0.26), rgba(239, 68, 68, 0.38)); border-color: rgba(239, 68, 68, 0.5); color: #ffeaea; } + .sv-exit-btn-confirm:hover { border-color: rgba(239, 68, 68, 0.7); background: linear-gradient(140deg, rgba(239, 68, 68, 0.35), rgba(239, 68, 68, 0.52)); } + .sv-exit-hint { margin-top: 12px; font-size: 0.73rem; color: var(--ink-muted); } + .sv-exit-hint kbd { display: inline-block; margin: 0 2px; @@ -2694,64 +3864,121 @@ button.game-card-store-chip.active:hover { /* Fullscreen button */ .sv-fs { - position: fixed; bottom: 18px; right: 18px; z-index: 1001; - width: 38px; height: 38px; + position: fixed; + bottom: 18px; + right: 18px; + z-index: 1001; + width: 38px; + height: 38px; border-radius: var(--r-sm); border: 1px solid var(--panel-border); background: rgba(10, 10, 12, 0.9); - color: var(--ink-muted); cursor: pointer; - display: flex; align-items: center; justify-content: center; - transition: opacity var(--t-fast), background var(--t-fast), border-color var(--t-fast), color var(--t-fast); opacity: 0.5; + color: var(--ink-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity var(--t-fast), background var(--t-fast), border-color var(--t-fast), color var(--t-fast); + opacity: 0.5; +} + +.sv-fs:hover { + opacity: 1; + background: rgba(10, 10, 12, 0.95); + border-color: var(--accent); + color: var(--accent); } -.sv-fs:hover { opacity: 1; background: rgba(10, 10, 12, 0.95); border-color: var(--accent); color: var(--accent); } /* End session button */ .sv-end { - position: fixed; bottom: 18px; right: 64px; z-index: 1001; - width: 38px; height: 38px; + position: fixed; + bottom: 18px; + right: 64px; + z-index: 1001; + width: 38px; + height: 38px; border-radius: var(--r-sm); border: 1px solid var(--panel-border); background: rgba(10, 10, 12, 0.9); - color: var(--ink-muted); cursor: pointer; - display: flex; align-items: center; justify-content: center; - transition: opacity var(--t-fast), background var(--t-fast), border-color var(--t-fast), color var(--t-fast); opacity: 0.5; + color: var(--ink-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity var(--t-fast), background var(--t-fast), border-color var(--t-fast), color var(--t-fast); + opacity: 0.5; +} + +.sv-end:hover { + opacity: 1; + background: rgba(180, 30, 30, 0.9); + border-color: var(--error); + color: #fff; } -.sv-end:hover { opacity: 1; background: rgba(180, 30, 30, 0.9); border-color: var(--error); color: #fff; } /* Keyboard hints */ .sv-hints { - position: fixed; bottom: 18px; left: 18px; z-index: 1001; - display: flex; flex-direction: column; gap: 4px; + position: fixed; + bottom: 18px; + left: 18px; + z-index: 1001; + display: flex; + flex-direction: column; + gap: 4px; padding: 8px 11px; background: rgba(10, 10, 12, 0.9); - border: 1px solid var(--panel-border); border-radius: var(--r-md); - animation: fade-in 300ms var(--ease); opacity: 0.65; -} -.sv-hint { display: flex; align-items: center; gap: 6px; font-size: 0.7rem; color: var(--ink-muted); } -.sv-hint kbd { - padding: 2px 5px; background: var(--chip); - border: 1px solid var(--panel-border-solid); border-radius: 3px; - font-size: 0.65rem; font-family: inherit; color: var(--ink-soft); + border: 1px solid var(--panel-border); + border-radius: var(--r-md); + animation: fade-in 300ms var(--ease); + opacity: 0.65; } -/* Game title toast */ -.sv-title-bar { - position: fixed; bottom: 68px; left: 50%; transform: translateX(-50%); z-index: 1001; - display: inline-flex; +.sv-hint { + display: flex; align-items: center; - gap: 10px; - max-width: min(92vw, 720px); - padding: 7px 14px; - background: rgba(10, 10, 12, 0.9); - border: 1px solid var(--panel-border); border-radius: var(--r-sm); - font-size: 0.88rem; font-weight: 600; color: var(--ink); - white-space: nowrap; animation: fade-in 400ms var(--ease); + gap: 6px; + font-size: 0.7rem; + color: var(--ink-muted); +} + +.sv-hint kbd { + padding: 2px 5px; + background: var(--chip); + border: 1px solid var(--panel-border-solid); + border-radius: 3px; + font-size: 0.65rem; + font-family: inherit; + color: var(--ink-soft); +} + +/* Game title toast */ +.sv-title-bar { + position: fixed; + bottom: 68px; + left: 50%; + transform: translateX(-50%); + z-index: 1001; + display: inline-flex; + align-items: center; + gap: 10px; + max-width: min(92vw, 720px); + padding: 7px 14px; + background: rgba(10, 10, 12, 0.9); + border: 1px solid var(--panel-border); + border-radius: var(--r-sm); + font-size: 0.88rem; + font-weight: 600; + color: var(--ink); + white-space: nowrap; + animation: fade-in 400ms var(--ease); } + .sv-title-game { color: var(--ink); overflow: hidden; text-overflow: ellipsis; } + .sv-title-platform { display: inline-flex; align-items: center; @@ -2761,11 +3988,13 @@ button.game-card-store-chip.active:hover { font-size: 0.76rem; font-weight: 600; } -.sv-title-platform > span:last-child { + +.sv-title-platform>span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .sv-title-platform-icon { display: inline-flex; align-items: center; @@ -2781,36 +4010,44 @@ button.game-card-store-chip.active:hover { padding: 6px 10px; font-size: 0.7rem; } + .sv-time-warning { top: 46px; max-width: calc(100vw - 24px); padding: 7px 10px; font-size: 0.68rem; } + .sv-title-bar { max-width: calc(100vw - 24px); gap: 8px; padding: 6px 10px; } + .sv-title-game { max-width: 52vw; font-size: 0.8rem; } + .sv-title-platform { font-size: 0.7rem; } + .sv-exit { align-items: flex-end; padding: 14px; } + .sv-exit-card { width: 100%; border-radius: 14px; padding: 16px 16px 14px; } + .sv-exit-title { font-size: 1.12rem; } + .sv-exit-actions { flex-direction: column; } @@ -2821,60 +4058,117 @@ button.game-card-store-chip.active:hover { STREAM LOADING (.sload-) ====================================================== */ .sload { - position: fixed; inset: 0; z-index: 1000; - display: flex; align-items: center; justify-content: center; padding: 24px; + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; } + .sload-backdrop { - position: absolute; inset: 0; + position: absolute; + inset: 0; background: linear-gradient(135deg, rgba(10, 10, 12, 0.98), rgba(19, 19, 22, 0.99), rgba(10, 10, 12, 0.98)); } + .sload-glow { - position: absolute; top: 40%; left: 50%; transform: translate(-50%, -50%); - width: 400px; height: 400px; + position: absolute; + top: 40%; + left: 50%; + transform: translate(-50%, -50%); + width: 400px; + height: 400px; background: radial-gradient(circle, rgba(88, 217, 138, 0.06) 0%, transparent 70%); pointer-events: none; animation: float-a 16s ease-in-out infinite; } + .sload.sload--error .sload-glow { background: radial-gradient(circle, rgba(248, 113, 113, 0.14) 0%, transparent 72%); } + .sload-content { - position: relative; z-index: 1; - display: flex; flex-direction: column; align-items: center; gap: 32px; - max-width: 440px; width: 100%; + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 32px; + max-width: 440px; + width: 100%; animation: fade-in 350ms var(--ease); } /* Game info */ -.sload-game { display: flex; align-items: center; gap: 16px; text-align: left; } +.sload-game { + display: flex; + align-items: center; + gap: 16px; + text-align: left; +} + .sload-cover { - width: 68px; height: 68px; border-radius: var(--r-md); - overflow: hidden; flex-shrink: 0; + width: 68px; + height: 68px; + border-radius: var(--r-md); + overflow: hidden; + flex-shrink: 0; box-shadow: var(--shadow-md); position: relative; } -.sload-cover-img { width: 100%; height: 100%; object-fit: cover; display: block; } + +.sload-cover-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + .sload-cover-empty { - width: 100%; height: 100%; - display: flex; align-items: center; justify-content: center; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; background: linear-gradient(135deg, var(--bg-c), var(--panel)); color: var(--ink-soft); } + .sload-cover-shine { - position: absolute; inset: 0; + position: absolute; + inset: 0; background: linear-gradient(135deg, transparent 40%, rgba(255, 255, 255, 0.06) 50%, transparent 60%); pointer-events: none; } -.sload-game-meta { display: flex; flex-direction: column; gap: 3px; } +.sload-game-meta { + display: flex; + flex-direction: column; + gap: 3px; +} + .sload-label { - font-size: 0.7rem; font-weight: 600; - text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent); } + .sload-title { - font-size: 1.3rem; font-weight: 700; color: var(--ink); line-height: 1.25; margin: 0; - max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 1.3rem; + font-weight: 700; + color: var(--ink); + line-height: 1.25; + margin: 0; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } + .sload-platform { display: inline-flex; align-items: center; @@ -2884,11 +4178,13 @@ button.game-card-store-chip.active:hover { font-size: 0.74rem; font-weight: 600; } -.sload-platform > span:last-child { + +.sload-platform>span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .sload-platform-icon { display: inline-flex; align-items: center; @@ -2899,29 +4195,53 @@ button.game-card-store-chip.active:hover { } /* Progress steps */ -.sload-steps { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; } +.sload-steps { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; +} + .sload-step { - display: flex; flex-direction: column; align-items: center; gap: 7px; - flex: 1; position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; + flex: 1; + position: relative; } + .sload-step-dot { - width: 38px; height: 38px; border-radius: 50%; - display: flex; align-items: center; justify-content: center; + width: 38px; + height: 38px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; background: var(--bg-c); border: 2px solid var(--panel-border-solid); color: var(--ink-muted); transition: background var(--t-slow), border-color var(--t-slow), color var(--t-slow), transform var(--t-slow); } + .sload-step.active .sload-step-dot { background: linear-gradient(135deg, var(--accent), var(--accent-press)); - border-color: var(--accent); color: var(--accent-on); + border-color: var(--accent); + color: var(--accent-on); animation: pulse-glow 2s ease-in-out infinite; } + .sload-step.completed .sload-step-dot { background: rgba(88, 217, 138, 0.1); - border-color: var(--accent); color: var(--accent); + border-color: var(--accent); + color: var(--accent); +} + +.sload-step.pending .sload-step-dot { + opacity: 0.35; } -.sload-step.pending .sload-step-dot { opacity: 0.35; } + .sload-step.failed .sload-step-dot { background: linear-gradient(135deg, rgba(248, 113, 113, 0.28), rgba(239, 68, 68, 0.32)); border-color: var(--error); @@ -2930,43 +4250,122 @@ button.game-card-store-chip.active:hover { } .sload-step-name { - font-size: 0.7rem; font-weight: 600; - text-transform: uppercase; letter-spacing: 0.05em; - color: var(--ink-muted); transition: color var(--t-slow); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-muted); + transition: color var(--t-slow); +} + +.sload-step.active .sload-step-name { + color: var(--ink); +} + +.sload-step.completed .sload-step-name { + color: var(--accent); +} + +.sload-step.failed .sload-step-name { + color: var(--error); } -.sload-step.active .sload-step-name { color: var(--ink); } -.sload-step.completed .sload-step-name { color: var(--accent); } -.sload-step.failed .sload-step-name { color: var(--error); } .sload-step-line { - position: absolute; top: 19px; - left: calc(50% + 23px); width: calc(100% - 38px); - height: 2px; background: var(--panel-border-solid); overflow: hidden; + position: absolute; + top: 19px; + left: calc(50% + 23px); + width: calc(100% - 38px); + height: 2px; + background: var(--panel-border-solid); + overflow: hidden; } + .sload-step-line-fill { - height: 100%; width: 0%; + height: 100%; + width: 0%; background: linear-gradient(90deg, var(--accent), var(--accent-press)); transition: width 500ms var(--ease); } + .sload-step-line.failed .sload-step-line-fill { width: 100%; background: linear-gradient(90deg, #f87171, #ef4444); } -.sload-step.completed .sload-step-line-fill { width: 100%; } -.sload-step.active .sload-step-line-fill { width: 50%; } + +.sload-step.completed .sload-step-line-fill { + width: 100%; +} + +.sload-step.active .sload-step-line-fill { + width: 50%; +} /* Status */ -.sload-status { display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; } -.sload-spin { color: var(--accent); animation: spin 1s linear infinite; } -.sload-error-icon { color: var(--error); } -.sload-status-text { display: flex; flex-direction: column; gap: 5px; } -.sload-message { margin: 0; font-size: 0.95rem; font-weight: 500; color: var(--ink); } -.sload-queue { margin: 0; font-size: 0.82rem; color: var(--ink-soft); } -.sload-queue-num { color: var(--accent); font-weight: 700; font-size: 1rem; } -.sload-wait { color: var(--ink-muted); } -.sload-status--error .sload-message { color: #ffdada; } -.sload-error-title { margin: 0; font-size: 0.9rem; font-weight: 700; color: #ffd5d5; } -.sload-error-desc { margin: 0; font-size: 0.8rem; color: #f8b4b4; max-width: 360px; line-height: 1.4; } +.sload-status { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + text-align: center; +} + +.sload-spin { + color: var(--accent); + animation: spin 1s linear infinite; +} + +.sload-error-icon { + color: var(--error); +} + +.sload-status-text { + display: flex; + flex-direction: column; + gap: 5px; +} + +.sload-message { + margin: 0; + font-size: 0.95rem; + font-weight: 500; + color: var(--ink); +} + +.sload-queue { + margin: 0; + font-size: 0.82rem; + color: var(--ink-soft); +} + +.sload-queue-num { + color: var(--accent); + font-weight: 700; + font-size: 1rem; +} + +.sload-wait { + color: var(--ink-muted); +} + +.sload-status--error .sload-message { + color: #ffdada; +} + +.sload-error-title { + margin: 0; + font-size: 0.9rem; + font-weight: 700; + color: #ffd5d5; +} + +.sload-error-desc { + margin: 0; + font-size: 0.8rem; + color: #f8b4b4; + max-width: 360px; + line-height: 1.4; +} + .sload-error-code { margin: 0; font-size: 0.72rem; @@ -2974,6 +4373,7 @@ button.game-card-store-chip.active:hover { font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; letter-spacing: 0.01em; } + .sload.sload--error .sload-queue, .sload.sload--error .sload-queue-num, .sload.sload--error .sload-wait { @@ -2982,60 +4382,124 @@ button.game-card-store-chip.active:hover { /* Cancel */ .sload-cancel { - display: flex; align-items: center; gap: 6px; - padding: 8px 16px; border-radius: var(--r-sm); + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--r-sm); border: 1px solid var(--panel-border-solid); background: var(--bg-c); - color: var(--ink-muted); font-size: 0.8rem; font-weight: 600; + color: var(--ink-muted); + font-size: 0.8rem; + font-weight: 600; font-family: inherit; - cursor: pointer; transition: transform var(--t-normal), background var(--t-normal), border-color var(--t-normal), color var(--t-normal); + cursor: pointer; + transition: transform var(--t-normal), background var(--t-normal), border-color var(--t-normal), color var(--t-normal); } + .sload-cancel:hover { background: var(--card-hover); - border-color: var(--ink-muted); color: var(--ink); + border-color: var(--ink-muted); + color: var(--ink); transform: translateY(-1px); } -.sload-cancel:active { transform: translateY(0); } + +.sload-cancel:active { + transform: translateY(0); +} /* ====================================================== STATS OVERLAY COMPONENT (.sovl-) ====================================================== */ .sovl { - position: fixed; top: 14px; right: 14px; z-index: 1001; + position: fixed; + top: 14px; + right: 14px; + z-index: 1001; } + .sovl-body { - display: flex; flex-wrap: wrap; gap: 5px; + display: flex; + flex-wrap: wrap; + gap: 5px; padding: 8px; background: rgba(10, 10, 12, 0.94); - border: 1px solid var(--panel-border); border-radius: var(--r-md); + border: 1px solid var(--panel-border); + border-radius: var(--r-md); max-width: 260px; } + .sovl-pill { - display: flex; align-items: center; gap: 4px; - padding: 4px 7px; background: var(--card); - border-radius: 6px; font-size: 0.7rem; color: var(--ink-soft); + display: flex; + align-items: center; + gap: 4px; + padding: 4px 7px; + background: var(--card); + border-radius: 6px; + font-size: 0.7rem; + color: var(--ink-soft); } -.sovl-icon { width: 13px; height: 13px; opacity: 0.65; flex-shrink: 0; } -.sovl-icon--ok { color: var(--accent); opacity: 1; } -.sovl-val { font-weight: 600; color: var(--ink); } -.sovl-badge { - padding: 1px 5px; background: var(--accent-surface); - border-radius: 3px; font-size: 0.62rem; font-weight: 700; color: var(--accent); + +.sovl-icon { + width: 13px; + height: 13px; + opacity: 0.65; + flex-shrink: 0; } -.sovl-badge--hdr { background: rgba(251, 191, 36, 0.1); color: var(--warning); } -.sovl-connecting { padding: 6px 10px; font-size: 0.75rem; color: var(--ink-muted); } -.sovl-pill--warn { background: rgba(251, 191, 36, 0.08); color: var(--warning); } -.sovl-pill--warn .sovl-val { color: var(--warning); } -.sovl-region { - width: 100%; margin-top: 3px; padding-top: 5px; - border-top: 1px solid var(--panel-border); - font-size: 0.65rem; color: var(--ink-muted); text-align: center; + +.sovl-icon--ok { + color: var(--accent); + opacity: 1; } -/* Sidebar overlay for settings */ -.sidebar { - position: fixed; +.sovl-val { + font-weight: 600; + color: var(--ink); +} + +.sovl-badge { + padding: 1px 5px; + background: var(--accent-surface); + border-radius: 3px; + font-size: 0.62rem; + font-weight: 700; + color: var(--accent); +} + +.sovl-badge--hdr { + background: rgba(251, 191, 36, 0.1); + color: var(--warning); +} + +.sovl-connecting { + padding: 6px 10px; + font-size: 0.75rem; + color: var(--ink-muted); +} + +.sovl-pill--warn { + background: rgba(251, 191, 36, 0.08); + color: var(--warning); +} + +.sovl-pill--warn .sovl-val { + color: var(--warning); +} + +.sovl-region { + width: 100%; + margin-top: 3px; + padding-top: 5px; + border-top: 1px solid var(--panel-border); + font-size: 0.65rem; + color: var(--ink-muted); + text-align: center; +} + +/* Sidebar overlay for settings */ +.sidebar { + position: fixed; top: 14px; left: 14px; z-index: 1002; @@ -3047,18 +4511,21 @@ button.game-card-store-chip.active:hover { box-shadow: 0 24px 48px rgba(0, 0, 0, 0.55); backdrop-filter: blur(8px); } + .sidebar-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } + .sidebar-header h3 { margin: 0; font-size: 1rem; font-weight: 600; color: var(--ink); } + .sidebar-close { background: transparent; border: none; @@ -3070,24 +4537,29 @@ button.game-card-store-chip.active:hover { border-radius: 6px; transition: background var(--t-normal), color var(--t-normal); } + .sidebar-close:hover { background: var(--card-hover); color: var(--ink); } + .sidebar-body { display: flex; flex-direction: column; gap: 12px; } -.sidebar-body > * { + +.sidebar-body>* { min-width: 0; } + .sidebar-stat-line { display: flex; align-items: center; justify-content: space-between; gap: 8px; } + .sidebar-stat-label { font-size: 0.82rem; font-weight: 600; @@ -3095,11 +4567,13 @@ button.game-card-store-chip.active:hover { text-transform: uppercase; letter-spacing: 0.06em; } + .sidebar-tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } + .sidebar-tab { border-radius: 8px; border: 1px solid var(--panel-border); @@ -3111,25 +4585,30 @@ button.game-card-store-chip.active:hover { cursor: pointer; transition: border-color var(--t-fast), background var(--t-fast), color var(--t-fast); } + .sidebar-tab:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--panel-border)); color: var(--ink); } + .sidebar-tab--active { border-color: var(--accent); color: var(--accent); background: var(--accent-surface); } + .sidebar-separator { height: 1px; background: var(--panel-border); width: 100%; } + .sidebar-section { display: flex; flex-direction: column; gap: 10px; } + .sidebar-section-header { display: flex; flex-direction: column; @@ -3139,43 +4618,52 @@ button.game-card-store-chip.active:hover { letter-spacing: 0.08em; color: var(--ink-muted); } + .sidebar-section-sub { font-size: 0.74rem; letter-spacing: normal; overflow-wrap: anywhere; } + .sidebar-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } + .sidebar-row--column { flex-direction: column; align-items: stretch; } + .sidebar-row--aligned { justify-content: space-between; } + .sidebar-row-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; } + .sidebar-label { font-size: 0.86rem; font-weight: 600; color: var(--ink-muted); } + .sidebar-hint { font-size: 0.72rem; color: var(--ink-muted); overflow-wrap: anywhere; } + .sidebar-hint--error { color: #fda4af; } + .sidebar-hint--codec { font-family: monospace; font-size: 0.67rem; @@ -3183,10 +4671,12 @@ button.game-card-store-chip.active:hover { word-break: break-all; display: block; } + .sidebar-shortcut-input { width: 100%; min-width: 0; } + .sidebar-button { border-radius: 4px; border: 1px solid var(--panel-border); @@ -3198,23 +4688,28 @@ button.game-card-store-chip.active:hover { cursor: pointer; transition: transform var(--t-normal), background var(--t-normal); } + .sidebar-button:hover { transform: translateY(-1px); background: var(--card-hover); } + .sidebar-button:active { transform: translateY(0); } + .sidebar-screenshot-button { display: inline-flex; align-items: center; gap: 6px; } + .sidebar-chip-row { display: flex; flex-wrap: wrap; gap: 8px; } + .sidebar-chip { border-radius: 999px; border: 1px solid var(--panel-border); @@ -3226,21 +4721,25 @@ button.game-card-store-chip.active:hover { cursor: pointer; transition: transform var(--t-normal), background var(--t-normal), border-color var(--t-normal), color var(--t-normal); } + .sidebar-chip:hover { transform: translateY(-1px); background: var(--card-hover); } + .sidebar-chip--active { border-color: var(--accent); background: rgba(119, 187, 255, 0.12); color: var(--accent); } + .sidebar-gallery-row { display: grid; grid-template-columns: 24px minmax(0, 1fr) 24px; gap: 8px; align-items: center; } + .sidebar-gallery-arrow { width: 24px; height: 24px; @@ -3254,11 +4753,13 @@ button.game-card-store-chip.active:hover { cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); } + .sidebar-gallery-arrow:hover { border-color: var(--accent); color: var(--accent); background: var(--card-hover); } + .sidebar-gallery-strip { display: flex; align-items: center; @@ -3267,6 +4768,7 @@ button.game-card-store-chip.active:hover { padding: 2px 2px 6px; scroll-behavior: smooth; } + .sidebar-gallery-item { width: 88px; height: 50px; @@ -3279,20 +4781,26 @@ button.game-card-store-chip.active:hover { cursor: pointer; transition: transform var(--t-fast), border-color var(--t-fast); } + .sidebar-gallery-item:hover { transform: translateY(-1px); border-color: var(--accent); } + .sidebar-gallery-item img { display: block; width: 100%; height: 100%; object-fit: cover; } + .sidebar-gallery-item-delete { flex: 0 0 auto; - display: flex; align-items: center; justify-content: center; - width: 24px; height: 24px; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; border-radius: 4px; margin-left: auto; background: transparent; @@ -3301,11 +4809,13 @@ button.game-card-store-chip.active:hover { cursor: pointer; transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast); } + .sidebar-gallery-item-delete:hover { color: var(--error); border-color: rgba(255, 80, 80, 0.35); background: rgba(255, 80, 80, 0.08); } + .sidebar-rec-strip { display: flex; align-items: flex-start; @@ -3314,6 +4824,7 @@ button.game-card-store-chip.active:hover { padding: 2px 2px 6px; scroll-behavior: smooth; } + .sidebar-rec-card { flex: 0 0 auto; width: 110px; @@ -3325,9 +4836,11 @@ button.game-card-store-chip.active:hover { overflow: hidden; transition: border-color var(--t-fast); } + .sidebar-rec-card:hover { - border-color: rgba(255,255,255,0.12); + border-color: rgba(255, 255, 255, 0.12); } + .sidebar-rec-card-thumb { /* keep the slot a fixed size so the carousel doesn’t jump around; the thumbnail itself will preserve its own proportions below */ @@ -3341,6 +4854,7 @@ button.game-card-store-chip.active:hover { object-fit: contain; background: #08090b; } + .sidebar-rec-card-thumb--placeholder { width: 110px; height: 62px; @@ -3349,6 +4863,7 @@ button.game-card-store-chip.active:hover { justify-content: center; color: var(--ink-dim); } + .sidebar-rec-card-meta { padding: 5px 6px 3px; display: flex; @@ -3356,6 +4871,7 @@ button.game-card-store-chip.active:hover { gap: 2px; flex: 1 1 auto; } + .sidebar-rec-card-title { font-size: 0.72rem; font-weight: 600; @@ -3364,6 +4880,7 @@ button.game-card-store-chip.active:hover { overflow: hidden; text-overflow: ellipsis; } + .sidebar-rec-card-detail { font-size: 0.65rem; color: var(--ink-dim); @@ -3371,12 +4888,14 @@ button.game-card-store-chip.active:hover { overflow: hidden; text-overflow: ellipsis; } + .sidebar-rec-card-actions { display: flex; align-items: center; gap: 4px; padding: 0 6px 5px; } + .sidebar-rec-card-action { flex: 1 1 0; height: 22px; @@ -3390,19 +4909,23 @@ button.game-card-store-chip.active:hover { cursor: pointer; transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast); } + .sidebar-rec-card-action:hover { color: var(--ink); - border-color: rgba(255,255,255,0.15); - background: rgba(255,255,255,0.06); + border-color: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.06); } + .sidebar-rec-card-action--danger:hover { color: var(--error); border-color: rgba(255, 80, 80, 0.35); background: rgba(255, 80, 80, 0.08); } + .sv-sidebar { width: min(360px, 92vw); } + .sv-sidebar-backdrop { position: fixed; inset: 0; @@ -3410,6 +4933,7 @@ button.game-card-store-chip.active:hover { z-index: 1000; cursor: pointer; } + .mic-meter-canvas { display: block; width: 100%; @@ -3426,6 +4950,7 @@ button.game-card-store-chip.active:hover { justify-content: center; padding: 18px; } + .sv-shot-modal-backdrop { position: absolute; inset: 0; @@ -3433,6 +4958,7 @@ button.game-card-store-chip.active:hover { background: rgba(5, 6, 8, 0.72); backdrop-filter: blur(8px); } + .sv-shot-modal-card { position: relative; z-index: 1; @@ -3447,12 +4973,14 @@ button.game-card-store-chip.active:hover { flex-direction: column; gap: 10px; } + .sv-shot-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } + .sv-shot-modal-head h4 { margin: 0; font-size: 0.86rem; @@ -3460,6 +4988,7 @@ button.game-card-store-chip.active:hover { letter-spacing: 0.06em; color: var(--ink-soft); } + .sv-shot-modal-close { width: 26px; height: 26px; @@ -3472,10 +5001,12 @@ button.game-card-store-chip.active:hover { justify-content: center; cursor: pointer; } + .sv-shot-modal-close:hover { color: var(--ink); border-color: var(--panel-border-solid); } + .sv-shot-modal-image { width: 100%; max-height: calc(100vh - 180px); @@ -3484,11 +5015,13 @@ button.game-card-store-chip.active:hover { border: 1px solid var(--panel-border); background: #050607; } + .sv-shot-modal-actions { display: flex; justify-content: flex-end; gap: 8px; } + .sv-shot-modal-btn { border: 1px solid var(--panel-border); background: var(--card); @@ -3502,9 +5035,11 @@ button.game-card-store-chip.active:hover { gap: 6px; cursor: pointer; } + .sv-shot-modal-btn:hover { border-color: var(--accent); } + .sv-shot-modal-btn--danger:hover { border-color: rgba(248, 113, 113, 0.55); color: #ffd4d4; @@ -3515,17 +5050,864 @@ button.game-card-store-chip.active:hover { REDUCED MOTION ====================================================== */ @media (prefers-reduced-motion: reduce) { - *, *::before, *::after { + + *, + *::before, + *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } - /* Keep loading indicator visibly active even with reduced motion. */ .sload-spin { animation-name: spin !important; animation-duration: 1s !important; - animation-timing-function: linear !important; animation-iteration-count: infinite !important; } } + +.xmb-wrapper { + position: fixed; + inset: 0; + background: #000; + color: #fff; + font-family: inherit; + overflow: hidden; + z-index: 2000; + display: flex; + flex-direction: column; +} + +.xmb-bg-layer { + position: absolute; + inset: 0; + z-index: -1; + background: + radial-gradient(118% 72% at 50% -10%, rgba(188, 255, 223, 0.22) 0%, rgba(124, 241, 177, 0.08) 28%, rgba(7, 17, 30, 0) 56%), + linear-gradient(180deg, #081711 0%, #06110d 32%, #030906 68%, #010302 100%); +} + + +.xmb-bg-gradient { + position: absolute; + inset: -16%; + background: + linear-gradient(118deg, transparent 21%, rgba(215, 255, 232, 0.08) 28%, rgba(215, 255, 232, 0.22) 31%, rgba(112, 231, 167, 0.18) 34%, rgba(215, 255, 232, 0.06) 38%, transparent 44%), + linear-gradient(128deg, transparent 30%, rgba(162, 255, 206, 0.06) 37%, rgba(242, 255, 248, 0.2) 40%, rgba(112, 231, 167, 0.16) 44%, rgba(242, 255, 248, 0.05) 48%, transparent 54%), + radial-gradient(circle at 52% 48%, rgba(199, 255, 225, 0.06), transparent 34%); + background-size: 140% 140%, 155% 155%, 100% 100%; + background-repeat: no-repeat; + mix-blend-mode: screen; + opacity: 0.88; + transform-origin: center center; + /* default: animations disabled; enabled via xmb-animate wrapper */ + animation: none; +} + +.xmb-bg-gradient::before, +.xmb-bg-gradient::after { + content: ""; + position: absolute; + inset: -12%; + background-repeat: no-repeat; + pointer-events: none; +} + +.xmb-bg-gradient::before { + background: + radial-gradient(76% 12% at 30% 54%, rgba(241, 255, 248, 0.34) 0%, rgba(241, 255, 248, 0.2) 18%, rgba(241, 255, 248, 0) 58%), + radial-gradient(84% 14% at 67% 60%, rgba(133, 247, 187, 0.22) 0%, rgba(133, 247, 187, 0.12) 16%, rgba(133, 247, 187, 0) 60%); + mix-blend-mode: screen; + opacity: 0.82; + transform: rotate(-8deg) scale(1.06); + animation: none; +} + +.xmb-bg-gradient::after { + background: + radial-gradient(78% 12% at 58% 40%, rgba(169, 255, 212, 0.2) 0%, rgba(169, 255, 212, 0.11) 18%, rgba(169, 255, 212, 0) 58%), + radial-gradient(70% 10% at 41% 70%, rgba(255, 255, 255, 0.16) 0%, rgba(255, 255, 255, 0.08) 16%, rgba(255, 255, 255, 0) 52%); + opacity: 0.52; + transform: rotate(6deg) scale(1.12); + animation: none; +} + +/* Enable ribbon animations when wrapper has xmb-animate */ +.xmb-wrapper.xmb-animate .xmb-bg-gradient { + animation: xmb-ribbon-pan 30s linear infinite; +} +.xmb-wrapper.xmb-animate .xmb-bg-gradient::before { + animation: xmb-ribbon-float 26s ease-in-out infinite; +} +.xmb-wrapper.xmb-animate .xmb-bg-gradient::after { + animation: xmb-ribbon-swell 22s ease-in-out infinite; +} + +.xmb-bg-overlay { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(2, 6, 4, 0.42) 0%, rgba(2, 6, 4, 0.08) 22%, rgba(2, 6, 4, 0.05) 74%, rgba(0, 0, 0, 0.46) 100%), + linear-gradient(90deg, rgba(0, 0, 0, 0.12) 0%, rgba(0, 0, 0, 0) 24%, rgba(0, 0, 0, 0) 76%, rgba(0, 0, 0, 0.16) 100%); +} + +.xmb-categories-container { + position: absolute; + top: 25%; + left: 50%; + height: 100px; + width: max-content; + display: flex; + align-items: center; + padding-left: 0; + transition: transform 600ms cubic-bezier(0.22, 1, 0.36, 1); + pointer-events: none; +} + +.xmb-category-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 120px; + height: 120px; + margin-right: 40px; + opacity: 0.42; + transform: scale(0.65); + transition: all 500ms cubic-bezier(0.22, 1, 0.36, 1); + color: rgba(242, 255, 247, 0.9); + flex-shrink: 0; +} + +.xmb-category-item.active { + opacity: 1; + transform: scale(1.2); + color: #ffffff; +} + +.xmb-category-item.active .xmb-category-icon { + filter: drop-shadow(0 0 15px rgba(255, 255, 255, 0.5)); +} + + +.xmb-category-label { + font-size: 0.85rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.2em; + white-space: nowrap; + opacity: 1; + transform: translateY(0); + transition: all 500ms cubic-bezier(0.22, 1, 0.36, 1); + text-shadow: 0 3px 10px rgba(0, 0, 0, 0.36); +} + + + +.xmb-items-container { + position: absolute; + top: 40%; + left: 50%; + display: fixed; + flex-direction: column; + transition: transform 600ms cubic-bezier(0.22, 1, 0.36, 1); + z-index: 5; +} + +.xmb-game-item { + position: relative; + display: flex; + align-items: center; + width: 540px; + padding: 16px 28px; + margin-bottom: 25px; + border-radius: 24px; + opacity: 0.34; + /* smaller by default so inactive rows are significantly reduced in size; active rows will scale up separately */ + transform: translateX(0) scale(0.75); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(233, 255, 242, 0.08); + backdrop-filter: blur(18px); + transition: all 450ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.xmb-game-item.active { + opacity: 1; + /* counter the default scale so the active row appears larger than its neighbours */ + transform: translateX(50px) scale(1.12); + background: linear-gradient(90deg, rgba(103, 236, 159, 0.34), rgba(245, 255, 249, 0.12)); + border-color: rgba(231, 255, 240, 0.36); + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.52), 0 0 28px rgba(116, 255, 177, 0.22); + z-index: 10; +} + +.xmb-game-poster-container { + width: 56px; + height: 80px; + border-radius: 8px; + overflow: hidden; + margin-right: 24px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.5); + flex-shrink: 0; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.xmb-game-favorite-icon { + position: absolute; + top: 8px; + right: 8px; + width: 20px; + height: 20px; + color: #ffd700; + opacity: 0.9; + z-index: 20; +} + +.xmb-game-poster { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 400ms ease; +} + +.xmb-game-item.active .xmb-game-poster { + transform: scale(1.05); +} + +.xmb-game-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.xmb-game-title { + font-size: 1.2rem; + font-weight: 800; + color: rgba(255, 255, 255, 0.96); + margin-bottom: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.01em; +} + +.xmb-game-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 7px; + margin-top: 4px; +} + +.xmb-game-meta--expanded { + margin-top: 6px; +} + +.xmb-game-meta-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + background: rgba(255, 255, 255, 0.09); + border: 1px solid rgba(255, 255, 255, 0.14); + color: rgba(246, 255, 250, 0.7); + white-space: nowrap; + transition: color 300ms ease, background 300ms ease; +} + +.xmb-game-item.active .xmb-game-meta-chip { + background: rgba(243, 255, 247, 0.14); + border-color: rgba(243, 255, 247, 0.24); + color: rgba(255, 255, 255, 0.96); +} + +.xmb-game-meta-chip--store { + background: rgba(255, 255, 255, 0.07); + border-color: rgba(255, 255, 255, 0.16); +} + +.xmb-game-meta-chip--playtime { + background: rgba(110, 255, 179, 0.09); + border-color: rgba(110, 255, 179, 0.24); +} +.xmb-game-item.active .xmb-game-meta-chip--playtime { + color: #b9ffd0; + border-color: rgba(110, 255, 179, 0.42); +} + +.xmb-game-meta-chip--last-played { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); +} +.xmb-game-item.active .xmb-game-meta-chip--last-played { + color: #ffffff; + border-color: rgba(255, 255, 255, 0.3); +} + +.xmb-game-meta-chip--sessions { + background: rgba(88, 217, 138, 0.08); + border-color: rgba(88, 217, 138, 0.2); +} +.xmb-game-item.active .xmb-game-meta-chip--sessions { + color: #6ee7b7; + border-color: rgba(88, 217, 138, 0.4); +} + +.xmb-game-meta-chip--genre { + background: rgba(168, 85, 247, 0.08); + border-color: rgba(168, 85, 247, 0.2); +} +.xmb-game-item.active .xmb-game-meta-chip--genre { + color: #c4b5fd; + border-color: rgba(168, 85, 247, 0.4); +} + +.xmb-game-meta-chip--tier { + background: rgba(251, 191, 36, 0.1); + border-color: rgba(251, 191, 36, 0.25); +} +.xmb-game-item.active .xmb-game-meta-chip--tier { + color: #fcd34d; + border-color: rgba(251, 191, 36, 0.5); +} + +.xmb-meta-icon { + flex-shrink: 0; + opacity: 0.75; +} + + +.xmb-detail-layer { + position: absolute; + bottom: 200px; + right: 5%; + width: 35%; + opacity: 0; + transform: translateX(40px); + transition: all 600ms cubic-bezier(0.22, 1, 0.36, 1); + pointer-events: none; + z-index: 100; + display: flex; + flex-direction: column; + align-items: flex-end; + text-align: right; +} + +.xmb-detail-layer.visible { + opacity: 1; + transform: translateX(0); +} + + + + +.xmb-footer { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 84px; + display: flex; + align-items: center; + justify-content: center; + gap: 60px; + background: linear-gradient(transparent, rgba(3, 8, 5, 0.34) 48%, rgba(0, 0, 0, 0.22) 100%); + backdrop-filter: blur(6px); + z-index: 2100; + border-top: 1px solid rgba(221, 255, 233, 0.05); +} + +.xmb-btn-hint { + display: flex; + align-items: center; + gap: 14px; + font-size: 0.75rem; + font-weight: 800; + color: rgba(245, 255, 249, 0.7); + text-transform: uppercase; + letter-spacing: 0.15em; + transition: all 300ms ease; +} + +.xmb-btn-hint:hover { + color: #fff; + transform: translateY(-2px); +} + +.xmb-btn-icon { + filter: drop-shadow(0 0 8px rgba(203, 255, 221, 0.2)); + transition: transform 300ms cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.xmb-btn-hint:hover .xmb-btn-icon { + transform: scale(1.3); +} + +.xmb-top-right { + position: absolute; + top: 40px; + right: 60px; + display: flex; + align-items: center; + gap: 25px; + z-index: 2050; +} + +.xmb-top-left { + position: absolute; + top: 40px; + left: 60px; + display: flex; + align-items: center; + gap: 12px; + z-index: 2050; +} + +.xmb-logo img { + height: 100px; + width: auto; + display: block; +} + +.xmb-clock { + font-size: 1.4rem; + font-weight: 300; + color: #fff; + letter-spacing: 0.05em; +} + +.xmb-clock-wrap { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.xmb-remaining-playtime { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.72); +} + +.xmb-user-badge { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 16px; + background: rgba(255, 255, 255, 0.1); + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.xmb-user-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--accent); + object-fit: cover; + display: block; +} + +.xmb-user-avatar[src] { + background: none; +} +.xmb-user-name { + font-size: 0.9rem; + font-weight: 700; + color: #fff; +} + +/* ---------- XMB Settings Subcategories ---------- */ +/* Subcategories are now hierarchical (A/Cross to enter, LEFT to exit) */ +/* Network, Audio, and System appear as selectable items in the root settings list */ + +/* Highlight subcategory items in root with a visual indicator */ +.xmb-game-item[data-subcategory-id="network"], +.xmb-game-item[data-subcategory-id="audio"], +.xmb-game-item[data-subcategory-id="system"], +.xmb-game-item[data-subcategory-id="videos"], +.xmb-game-item[data-subcategory-id="screenshots"] { + position: relative; +} + +.xmb-game-item[data-subcategory-id="network"]::after, +.xmb-game-item[data-subcategory-id="audio"]::after, +.xmb-game-item[data-subcategory-id="system"]::after, +.xmb-game-item[data-subcategory-id="videos"]::after, +.xmb-game-item[data-subcategory-id="screenshots"]::after { + content: "▶"; + position: absolute; + right: 24px; + font-size: 0.7rem; + color: rgba(88, 217, 138, 0.6); + opacity: 0; + transition: all 300ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.xmb-game-item[data-subcategory-id="network"].active::after, +.xmb-game-item[data-subcategory-id="audio"].active::after, +.xmb-game-item[data-subcategory-id="system"].active::after, +.xmb-game-item[data-subcategory-id="videos"].active::after, +.xmb-game-item[data-subcategory-id="screenshots"].active::after { + opacity: 1; + color: var(--accent); +} + +/* ---------- Controller Stream Loading (PS3-Style) ---------- */ +@keyframes csl-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes csl-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes csl-content-slide-up { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes csl-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes csl-pulse { + 0%, 100% { + opacity: 0.6; + } + 50% { + opacity: 1; + } +} + +.controller-stream-loading { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +.csl-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: transparent; + animation: csl-fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +/* Ensure XMB ribbon background sits behind the loading UI */ +.controller-stream-loading .xmb-wrapper { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; +} +.controller-stream-loading .xmb-bg-layer { + z-index: 0; +} +.controller-stream-loading .csl-backdrop { + z-index: 1; +} + +.csl-content-wrapper { + position: relative; + z-index: 2; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 60px; + animation: csl-fade-in 0.8s cubic-bezier(0.22, 1, 0.36, 1) 0.2s both; +} + +.csl-content { + display: grid; + grid-template-columns: 500px 1fr; + gap: 80px; + max-width: 1400px; + width: 100%; + height: auto; + align-items: center; +} + +/* Poster Section (Left) */ +.csl-poster-section { + display: flex; + align-items: center; + justify-content: center; + perspective: 1200px; +} + +.csl-poster { + width: 100%; + aspect-ratio: 9 / 16; + object-fit: cover; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7), 0 0 60px rgba(88, 217, 138, 0.15); + animation: csl-content-slide-up 0.8s cubic-bezier(0.22, 1, 0.36, 1) 0.4s both; + border: 1px solid rgba(88, 217, 138, 0.2); +} + +.csl-poster-placeholder { + width: 100%; + aspect-ratio: 9 / 16; + background: linear-gradient(135deg, rgba(88, 217, 138, 0.1) 0%, rgba(88, 217, 138, 0.05) 100%); + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + color: rgba(88, 217, 138, 0.3); + border: 2px solid rgba(88, 217, 138, 0.1); +} + +/* Info Section (Right) */ +.csl-info-section { + display: flex; + flex-direction: column; + gap: 32px; + color: var(--ink); + animation: csl-content-slide-up 0.8s cubic-bezier(0.22, 1, 0.36, 1) 0.3s both; +} + +.csl-title-container { + display: flex; + flex-direction: column; + gap: 4px; +} + +.csl-title { + font-size: 3.5rem; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.1; + margin: 0; + color: #fff; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); +} + +.csl-description-container { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 120px; + overflow: hidden; +} + +.csl-description { + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: var(--ink-soft); + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.csl-playtime-container { + display: flex; + gap: 12px; + align-items: baseline; + font-size: 1rem; +} + +.csl-playtime-label { + color: var(--ink-soft); + font-weight: 500; +} + +.csl-playtime-value { + color: var(--accent); + font-weight: 700; + font-size: 1.2rem; +} + +/* Status Section */ +.csl-status-container { + display: flex; + flex-direction: column; + gap: 24px; + margin-top: 16px; + padding-top: 24px; + border-top: 1px solid rgba(88, 217, 138, 0.1); +} + +.csl-status-message { + font-size: 1.25rem; + font-weight: 600; + color: var(--accent); + animation: csl-pulse 2s ease-in-out infinite; +} + +/* Progress Indicator */ +.csl-progress-indicator { + display: grid; + grid-template-columns: auto 1fr auto 1fr auto; + gap: 12px; + align-items: center; + width: 100%; +} + +.csl-progress-step { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + transition: all 300ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.csl-progress-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: rgba(88, 217, 138, 0.2); + border: 2px solid rgba(88, 217, 138, 0.3); + transition: all 300ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.csl-progress-step.active .csl-progress-dot { + background: var(--accent); + border-color: var(--accent); + box-shadow: 0 0 12px rgba(88, 217, 138, 0.5); +} + +.csl-progress-step.completed .csl-progress-dot { + background: var(--accent); + border-color: var(--accent); +} + +.csl-progress-step.inactive .csl-progress-dot { + background: rgba(88, 217, 138, 0.1); + border-color: rgba(88, 217, 138, 0.15); +} + +.csl-progress-label { + font-size: 0.8rem; + font-weight: 600; + color: var(--ink-soft); + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + transition: all 300ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.csl-progress-step.active .csl-progress-label { + color: var(--accent); +} + +.csl-progress-step.completed .csl-progress-label { + color: var(--accent); + opacity: 0.7; +} + +.csl-progress-step.inactive .csl-progress-label { + color: var(--ink-muted); +} + +.csl-progress-connector { + height: 2px; + background: linear-gradient(90deg, rgba(88, 217, 138, 0.3) 0%, rgba(88, 217, 138, 0.2) 100%); + transition: all 300ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.csl-progress-connector.inactive { + background: rgba(88, 217, 138, 0.1); +} + +/* Spinner */ +.csl-spinner-container { + display: flex; + align-items: center; + justify-content: center; + height: 60px; +} + +.csl-spinner { + animation: csl-spin 2s linear infinite; + color: var(--accent); + filter: drop-shadow(0 0 8px rgba(88, 217, 138, 0.4)); +} + +/* Responsive Design */ +@media (max-width: 1200px) { + .csl-content { + grid-template-columns: 1fr; + gap: 48px; + } + + .csl-poster-section { + max-width: 350px; + margin: 0 auto; + } + + .csl-poster { + max-width: 100%; + } + + .csl-title { + font-size: 2.5rem; + } +} + +@media (max-width: 768px) { + .csl-content-wrapper { + padding: 40px 24px; + } + + .csl-content { + gap: 32px; + } + + .csl-info-section { + gap: 24px; + } + + .csl-title { + font-size: 2rem; + } + + .csl-description { + font-size: 0.95rem; + } + + .csl-status-message { + font-size: 1.1rem; + } +} \ No newline at end of file diff --git a/opennow-stable/src/renderer/src/utils/usePlaytime.ts b/opennow-stable/src/renderer/src/utils/usePlaytime.ts new file mode 100644 index 0000000..26e9a66 --- /dev/null +++ b/opennow-stable/src/renderer/src/utils/usePlaytime.ts @@ -0,0 +1,115 @@ +import { useCallback, useRef, useState } from "react"; + +const STORAGE_KEY = "opennow:playtime"; + +export interface PlaytimeRecord { + totalSeconds: number; + lastPlayedAt: string | null; + sessionCount: number; +} + +export type PlaytimeStore = Record; + +function loadStore(): PlaytimeStore { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") return parsed as PlaytimeStore; + } + } catch { + } + return {}; +} + +function saveStore(store: PlaytimeStore): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + } catch { + } +} + +function emptyRecord(): PlaytimeRecord { + return { totalSeconds: 0, lastPlayedAt: null, sessionCount: 0 }; +} + +export function formatPlaytime(totalSeconds: number): string { + if (totalSeconds < 60) { + return totalSeconds <= 0 ? "Never played" : "< 1 min"; + } + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + if (h === 0) return `${m} m`; + if (m === 0) return `${h} h`; + return `${h} h ${m} m`; +} + +export function formatLastPlayed(isoString: string | null): string { + if (!isoString) return "Never"; + const then = new Date(isoString); + const now = new Date(); + + const thenDay = new Date(then.getFullYear(), then.getMonth(), then.getDate()).getTime(); + const todayDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + + const diffDays = Math.round((todayDay - thenDay) / 86_400_000); + + if (diffDays === 0) return "Today"; + if (diffDays === 1) return "Yesterday"; + if (diffDays < 7) return `${diffDays} days ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)} wk ago`; + if (diffDays < 365) return `${Math.floor(diffDays / 30)} mo ago`; + return `${Math.floor(diffDays / 365)} yr ago`; +} + +export interface UsePlaytimeReturn { + playtime: PlaytimeStore; + startSession: (gameId: string) => void; + endSession: (gameId: string) => void; +} + +export function usePlaytime(): UsePlaytimeReturn { + const [playtime, setPlaytime] = useState(loadStore); + const sessionStartRef = useRef>({}); + + const startSession = useCallback((gameId: string): void => { + sessionStartRef.current[gameId] = Date.now(); + setPlaytime((prev) => { + const existing = prev[gameId] ?? emptyRecord(); + const next: PlaytimeStore = { + ...prev, + [gameId]: { + ...existing, + lastPlayedAt: new Date().toISOString(), + sessionCount: existing.sessionCount + 1, + }, + }; + saveStore(next); + return next; + }); + }, []); + + const endSession = useCallback((gameId: string): void => { + const startMs = sessionStartRef.current[gameId]; + if (startMs == null) return; + delete sessionStartRef.current[gameId]; + + const elapsedSeconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000)); + if (elapsedSeconds === 0) return; + + setPlaytime((prev) => { + const existing = prev[gameId] ?? emptyRecord(); + const next: PlaytimeStore = { + ...prev, + [gameId]: { + ...existing, + totalSeconds: existing.totalSeconds + elapsedSeconds, + }, + }; + saveStore(next); + return next; + }); + }, []); + + return { playtime, startSession, endSession }; +} diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 4641afb..9186014 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -33,9 +33,11 @@ export function colorQualityIs10Bit(cq: ColorQuality): boolean { } export type MicrophoneMode = "disabled" | "push-to-talk" | "voice-activity"; +export type AspectRatio = "16:9" | "16:10" | "21:9" | "32:9"; export interface Settings { resolution: string; + aspectRatio: AspectRatio; fps: number; maxBitrateMbps: number; codec: VideoCodec; @@ -54,6 +56,14 @@ export interface Settings { microphoneMode: MicrophoneMode; microphoneDeviceId: string; hideStreamButtons: boolean; + controllerMode: boolean; + controllerUiSounds: boolean; + autoLoadControllerLibrary: boolean; + /** When true, controller-mode overlays will show animated background orbs */ + controllerBackgroundAnimations: boolean; + /** When true, the app will automatically enter fullscreen when controller mode triggers it */ + autoFullScreen: boolean; + favoriteGameIds: string[]; sessionClockShowEveryMinutes: number; sessionClockShowDurationSeconds: number; windowWidth: number; @@ -199,7 +209,11 @@ export interface GameInfo { launchAppId?: string; title: string; description?: string; + longDescription?: string; + featureLabels?: string[]; + genres?: string[]; imageUrl?: string; + screenshotUrl?: string; playType?: string; membershipTierLabel?: string; selectedVariantIndex: number; @@ -353,6 +367,7 @@ export interface OpenNowApi { onSignalingEvent(listener: (event: MainToRendererSignalingEvent) => void): () => void; /** Listen for F11 fullscreen toggle from main process */ onToggleFullscreen(listener: () => void): () => void; + setFullscreen(v: boolean): Promise; toggleFullscreen(): Promise; togglePointerLock(): Promise; getSettings(): Promise; @@ -398,6 +413,17 @@ export interface OpenNowApi { /** Reveal a saved recording in the system file manager */ showRecordingInFolder(id: string): Promise; + + /** List screenshot and recording media, optionally filtered by game title */ + listMediaByGame(input?: { gameTitle?: string }): Promise; + + /** Resolve a thumbnail data URL for a media file path */ + getMediaThumbnail(input: { filePath: string }): Promise; + + /** Reveal a media file path in the system file manager */ + showMediaInFolder(input: { filePath: string }): Promise; + + deleteCache(): Promise; } export interface ScreenshotSaveRequest { @@ -465,3 +491,20 @@ export interface RecordingAbortRequest { export interface RecordingDeleteRequest { id: string; } + +export interface MediaListingEntry { + id: string; + fileName: string; + filePath: string; + createdAtMs: number; + sizeBytes: number; + gameTitle?: string; + durationMs?: number; + thumbnailDataUrl?: string; + dataUrl?: string; +} + +export interface MediaListingResult { + screenshots: MediaListingEntry[]; + videos: MediaListingEntry[]; +} diff --git a/opennow-stable/src/shared/ipc.ts b/opennow-stable/src/shared/ipc.ts index 1ea4431..b83535e 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -23,6 +23,7 @@ export const IPC_CHANNELS = { REQUEST_KEYFRAME: "gfn:request-keyframe", SIGNALING_EVENT: "gfn:signaling-event", TOGGLE_FULLSCREEN: "window:toggle-fullscreen", + SET_FULLSCREEN: "window:set-fullscreen", TOGGLE_POINTER_LOCK: "window:toggle-pointer-lock", SETTINGS_GET: "settings:get", SETTINGS_SET: "settings:set", @@ -40,6 +41,13 @@ export const IPC_CHANNELS = { RECORDING_LIST: "recording:list", RECORDING_DELETE: "recording:delete", RECORDING_SHOW_IN_FOLDER: "recording:showInFolder", + CACHE_REFRESH_MANUAL: "cache:refresh-manual", + CACHE_STATUS_UPDATE: "cache:status-update", + CACHE_DELETE_ALL: "cache:delete-all", + // Media browsing + MEDIA_LIST_BY_GAME: "media:list-by-game", + MEDIA_THUMBNAIL: "media:thumbnail", + MEDIA_SHOW_IN_FOLDER: "media:show-in-folder", } as const; export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS];