diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index 15553ee5a..204af8f32 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -1,6 +1,6 @@ -import { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, session } from "electron"; +import { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, session, protocol } from "electron"; import { fileURLToPath } from "node:url"; -import { dirname, join, resolve, relative } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { existsSync, readFileSync, createWriteStream } from "node:fs"; import { copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile, realpath } from "node:fs/promises"; import * as net from "node:net"; @@ -14,6 +14,11 @@ import { spawn } from "node:child_process"; // F8 - Toggle mouse/pointer lock (handled in main process via IPC) import { IPC_CHANNELS } from "@shared/ipc"; +import { + getTrustedVideoPlaybackFileUrl, + registerOpenNowMediaProtocol, + resolveTrustedOpenNowMediaPath, +} from "./mediaPaths"; import { initLogCapture, exportLogs } from "@shared/logger"; import { cacheManager } from "./services/cacheManager"; import { refreshScheduler } from "./services/refreshScheduler"; @@ -223,6 +228,20 @@ app.commandLine.appendSwitch("disable-backgrounding-occluded-windows"); // Remove getUserMedia FPS cap (not strictly needed for receive-only but avoids potential limits) app.commandLine.appendSwitch("max-gum-fps", "999"); +// file:// in <video> is blocked by Chromium for renderer pages; use a privileged custom scheme. +protocol.registerSchemesAsPrivileged([ + { + scheme: "opennow-media", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true, + corsEnabled: true, + }, + }, +]); + let mainWindow: BrowserWindow | null = null; let rendererControlledFullscreen = false; let signalingClient: GfnSignalingClient | null = null; @@ -567,10 +586,49 @@ function md5(input: string): string { return createHash("md5").update(input).digest("hex"); } -async function generateVideoThumbnail(sourcePath: string, outPath: string): Promise { +/** Seconds; null if ffprobe missing or unreadable. */ +async function probeVideoDurationSeconds(sourcePath: string): Promise { + return new Promise((resolve) => { + const args = [ + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + sourcePath, + ]; + const child = spawn("ffprobe", args, { stdio: ["ignore", "pipe", "ignore"] }); + let out = ""; + child.stdout?.on("data", (chunk: Buffer) => { + out += chunk.toString(); + }); + child.on("error", () => resolve(null)); + child.on("close", (code) => { + if (code !== 0) { + resolve(null); + return; + } + const n = Number.parseFloat(out.trim()); + resolve(Number.isFinite(n) && n > 0 ? n : null); + }); + }); +} + +function randomThumbnailSeekSeconds(durationSec: number | null): number { + if (durationSec !== null && durationSec > 0.2) { + const margin = Math.min(0.35, durationSec * 0.08); + const hi = Math.max(durationSec - margin, margin + 0.05); + const lo = Math.min(margin, hi * 0.5); + return lo + Math.random() * (hi - lo); + } + return 0.2 + Math.random() * 4.8; +} + +function ffmpegExtractOneFrame(sourcePath: string, outPath: string, seekSec: number): Promise { + const ss = seekSec.toFixed(3); 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 args = ["-y", "-ss", ss, "-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) => { @@ -579,6 +637,14 @@ async function generateVideoThumbnail(sourcePath: string, outPath: string): Prom }); } +async function generateVideoThumbnail(sourcePath: string, outPath: string): Promise { + const durationSec = await probeVideoDurationSeconds(sourcePath); + const seekSec = randomThumbnailSeekSeconds(durationSec); + if (await ffmpegExtractOneFrame(sourcePath, outPath, seekSec)) return true; + if (seekSec > 0.02) return ffmpegExtractOneFrame(sourcePath, outPath, 0); + return false; +} + async function ensureThumbnailForMedia(filePath: string): Promise { try { const stats = await stat(filePath); @@ -648,7 +714,7 @@ async function listRecordings(): Promise { const filePath = join(dir, fileName); try { const fileStats = await stat(filePath); - const stem = fileName.replace(/\.webm$/i, ""); + const stem = fileName.replace(/\.(mp4|webm)$/i, ""); const thumbName = `${stem}-thumb.jpg`; const thumbPath = join(dir, thumbName); @@ -1803,14 +1869,9 @@ function registerIpcHandlers(): void { 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 fpReal = await resolveTrustedOpenNowMediaPath(rawFp); + if (!fpReal) return null; const lower = fpReal.toLowerCase(); if (lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".webp")) { @@ -1855,18 +1916,89 @@ function registerIpcHandlers(): void { 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; + const fpReal = await resolveTrustedOpenNowMediaPath(rawFp); + if (!fpReal) return; shell.showItemInFolder(fpReal); } catch { return; } }); + ipcMain.handle(IPC_CHANNELS.MEDIA_PLAYBACK_URL, async (_event, payload: { filePath: string }): Promise => { + const rawFp = payload?.filePath; + if (typeof rawFp !== "string") return null; + try { + return await getTrustedVideoPlaybackFileUrl(rawFp); + } catch (err) { + console.warn("MEDIA_PLAYBACK_URL error:", err); + return null; + } + }); + + ipcMain.handle(IPC_CHANNELS.MEDIA_DELETE_FILE, async (_event, payload: { filePath: string }): Promise<{ ok: boolean }> => { + const rawFp = payload?.filePath; + if (typeof rawFp !== "string") return { ok: false }; + try { + const fpReal = await resolveTrustedOpenNowMediaPath(rawFp); + if (!fpReal) return { ok: false }; + let st: Awaited>; + try { + st = await stat(fpReal); + } catch { + return { ok: false }; + } + const key = md5(`${fpReal}|${st.mtimeMs}`); + const cacheDir = await ensureThumbnailCacheDirectory(); + await unlink(join(cacheDir, `${key}.jpg`)).catch(() => undefined); + const stem = fpReal.replace(/\.(mp4|webm|mkv|mov|png|jpg|jpeg|webp)$/i, ""); + await unlink(`${stem}-thumb.jpg`).catch(() => undefined); + await unlink(fpReal); + return { ok: true }; + } catch (err) { + console.warn("MEDIA_DELETE_FILE error:", err); + return { ok: false }; + } + }); + + ipcMain.handle( + IPC_CHANNELS.MEDIA_REGEN_THUMBNAIL, + async (_event, payload: { filePath: string }): Promise<{ ok: boolean; thumbnailDataUrl: string | null }> => { + const rawFp = payload?.filePath; + if (typeof rawFp !== "string") return { ok: false, thumbnailDataUrl: null }; + try { + const fpReal = await resolveTrustedOpenNowMediaPath(rawFp); + if (!fpReal) return { ok: false, thumbnailDataUrl: null }; + const st = await stat(fpReal); + const key = md5(`${fpReal}|${st.mtimeMs}`); + const cacheDir = await ensureThumbnailCacheDirectory(); + await unlink(join(cacheDir, `${key}.jpg`)).catch(() => undefined); + if (/\.(mp4|webm|mkv|mov)$/i.test(fpReal)) { + const videoStem = fpReal.replace(/\.(mp4|webm|mkv|mov)$/i, ""); + await unlink(`${videoStem}-thumb.jpg`).catch(() => undefined); + } + const genPath = await ensureThumbnailForMedia(fpReal); + if (!genPath) return { ok: false, thumbnailDataUrl: null }; + + if (/\.(mp4|webm|mkv|mov)$/i.test(fpReal)) { + const videoStem = fpReal.replace(/\.(mp4|webm|mkv|mov)$/i, ""); + const sidecar = `${videoStem}-thumb.jpg`; + await copyFile(genPath, sidecar).catch((err) => { + console.warn("MEDIA_REGEN_THUMBNAIL sidecar copy:", err); + }); + } + + const b = await readFile(genPath); + return { + ok: true, + thumbnailDataUrl: `data:image/jpeg;base64,${b.toString("base64")}`, + }; + } catch (err) { + console.warn("MEDIA_REGEN_THUMBNAIL error:", err); + return { ok: false, thumbnailDataUrl: null }; + } + }, + ); + ipcMain.handle(IPC_CHANNELS.CACHE_REFRESH_MANUAL, async (): Promise => { await refreshScheduler.manualRefresh(); }); @@ -2173,6 +2305,7 @@ app.whenReady().then(async () => { return allowedPermissions.has(permission); }); + registerOpenNowMediaProtocol(); registerIpcHandlers(); refreshScheduler.initialize( diff --git a/opennow-stable/src/main/mediaPaths.ts b/opennow-stable/src/main/mediaPaths.ts new file mode 100644 index 000000000..52ea7164a --- /dev/null +++ b/opennow-stable/src/main/mediaPaths.ts @@ -0,0 +1,151 @@ +import { app, protocol } from "electron"; +import { createReadStream } from "node:fs"; +import { join, resolve, relative } from "node:path"; +import { Readable } from "node:stream"; +import { realpath, stat } from "node:fs/promises"; +import { isPlayableVideoFilePath } from "@shared/mediaPlayback"; + +const MAX_MEDIA_PATH_LENGTH = 4096; + +const OPENNOW_MEDIA_HOST = "opennow"; + +let openNowMediaProtocolHandleInstalled = false; + +function videoMimeTypeForPath(filePath: string): string { + const lower = filePath.toLowerCase(); + if (lower.endsWith(".webm")) return "video/webm"; + if (lower.endsWith(".mp4")) return "video/mp4"; + if (lower.endsWith(".mov")) return "video/quicktime"; + if (lower.endsWith(".mkv")) return "video/x-matroska"; + return "application/octet-stream"; +} + +/** + * Parse a single Range: bytes=… header. Returns inclusive start/end, or null if unsatisfiable. + */ +function parseByteRangeHeader(rangeHeader: string, fileSize: number): { start: number; end: number } | null { + const m = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader.trim()); + if (!m) return null; + const g1 = m[1]; + const g2 = m[2]; + if (g1 !== "" && g2 !== "") { + const start = Number(g1); + const end = Number(g2); + if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= fileSize) return null; + return { start, end: Math.min(end, fileSize - 1) }; + } + if (g1 !== "" && g2 === "") { + const start = Number(g1); + if (!Number.isFinite(start) || start >= fileSize) return null; + return { start, end: fileSize - 1 }; + } + if (g1 === "" && g2 !== "") { + const len = Number(g2); + if (!Number.isFinite(len) || len <= 0) return null; + if (len >= fileSize) return { start: 0, end: fileSize - 1 }; + return { start: fileSize - len, end: fileSize - 1 }; + } + return { start: 0, end: fileSize - 1 }; +} + +/** + * Resolve a user-supplied path to a real path under Pictures/OpenNOW, or null if unsafe / missing. + */ +export async function resolveTrustedOpenNowMediaPath(rawFp: string): Promise { + if (typeof rawFp !== "string" || rawFp.length > MAX_MEDIA_PATH_LENGTH) 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; + return fpReal; + } catch { + return null; + } +} + +/** + * URL for in-renderer <video src>. Uses custom scheme (registered in main) because file:// is often blocked. + */ +export async function getTrustedVideoPlaybackFileUrl(rawFp: string): Promise { + const fpReal = await resolveTrustedOpenNowMediaPath(rawFp); + if (!fpReal || !isPlayableVideoFilePath(fpReal)) return null; + return `opennow-media://${OPENNOW_MEDIA_HOST}/playback?p=${encodeURIComponent(fpReal)}`; +} + +/** + * Must run during app startup (after ready), before windows load media URLs. + */ +export function registerOpenNowMediaProtocol(): void { + if (openNowMediaProtocolHandleInstalled) return; + openNowMediaProtocolHandleInstalled = true; + + protocol.handle("opennow-media", async (request) => { + try { + const url = new URL(request.url); + if (url.hostname.toLowerCase() !== OPENNOW_MEDIA_HOST) { + return new Response(null, { status: 404 }); + } + const pathNorm = url.pathname.replace(/\/$/, "") || "/"; + if (!pathNorm.endsWith("/playback")) { + return new Response(null, { status: 404 }); + } + const p = url.searchParams.get("p"); + if (!p) return new Response(null, { status: 400 }); + const fpReal = await resolveTrustedOpenNowMediaPath(p); + if (!fpReal || !isPlayableVideoFilePath(fpReal)) return new Response(null, { status: 404 }); + + const mime = videoMimeTypeForPath(fpReal); + const { size } = await stat(fpReal); + const baseHeaders: Record = { + "Content-Type": mime, + "Accept-Ranges": "bytes", + }; + + if (request.method === "HEAD") { + return new Response(null, { + status: 200, + headers: { + ...baseHeaders, + "Content-Length": String(size), + }, + }); + } + + const rangeRaw = request.headers.get("range"); + if (rangeRaw) { + const firstRange = rangeRaw.split(",")[0]?.trim() ?? ""; + const parsed = firstRange ? parseByteRangeHeader(firstRange, size) : null; + if (parsed) { + const { start, end } = parsed; + const chunkLength = end - start + 1; + const nodeStream = createReadStream(fpReal, { start, end }); + const body = Readable.toWeb(nodeStream) as ReadableStream; + return new Response(body, { + status: 206, + headers: { + ...baseHeaders, + "Content-Length": String(chunkLength), + "Content-Range": `bytes ${start}-${end}/${size}`, + }, + }); + } + } + + const nodeStream = createReadStream(fpReal); + const body = Readable.toWeb(nodeStream) as ReadableStream; + return new Response(body, { + status: 200, + headers: { + ...baseHeaders, + "Content-Length": String(size), + }, + }); + } catch (err) { + console.warn("[opennow-media] protocol handler:", err); + return new Response(null, { status: 500 }); + } + }); +} diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index f3a0cab17..20c17ddc5 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -83,6 +83,8 @@ export interface Settings { controllerThemeStyle: ControllerThemeStyle; /** Controller-mode library background tint */ controllerThemeColor: ControllerThemeRgb; + /** When true, library/hub/loading may show game- or shelf-driven backdrop art */ + controllerLibraryGameBackdrop: boolean; /** Auto-load controller library at startup when controller mode is enabled */ autoLoadControllerLibrary: boolean; /** Automatically enter fullscreen when controller-mode triggers it */ @@ -172,6 +174,7 @@ const DEFAULT_SETTINGS: Settings = { controllerBackgroundAnimations: false, controllerThemeStyle: "aurora", controllerThemeColor: { r: 124, g: 241, b: 177 }, + controllerLibraryGameBackdrop: true, autoLoadControllerLibrary: false, autoFullScreen: false, favoriteGameIds: [], diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index d3a18d19f..357a6b7dc 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -173,6 +173,12 @@ const api: OpenNowApi = { 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), + getMediaPlaybackUrl: (input: { filePath: string }): Promise => + ipcRenderer.invoke(IPC_CHANNELS.MEDIA_PLAYBACK_URL, input), + deleteMediaFile: (input: { filePath: string }): Promise<{ ok: boolean }> => + ipcRenderer.invoke(IPC_CHANNELS.MEDIA_DELETE_FILE, input), + regenMediaThumbnail: (input: { filePath: string }): Promise<{ ok: boolean; thumbnailDataUrl: string | null }> => + ipcRenderer.invoke(IPC_CHANNELS.MEDIA_REGEN_THUMBNAIL, input), deleteCache: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CACHE_DELETE_ALL), fetchPrintedWasteQueue: (): Promise => diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 07d62f537..a05fe3fd0 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -922,6 +922,7 @@ export function App(): JSX.Element { controllerBackgroundAnimations: false, controllerThemeStyle: "aurora", controllerThemeColor: { r: 124, g: 241, b: 177 }, + controllerLibraryGameBackdrop: true, autoLoadControllerLibrary: false, autoFullScreen: false, favoriteGameIds: [], @@ -1213,6 +1214,12 @@ export function App(): JSX.Element { setStreamVolume(audioRef.current.volume); } }, [streamStatus]); + useEffect(() => { + if (audioRef.current) { + audioRef.current.volume = streamVolume; + } + clientRef.current?.setOutputVolume(streamVolume); + }, [streamVolume]); const sessionRef = useRef(null); const hasInitializedRef = useRef(false); const regionsRequestRef = useRef(0); @@ -2474,7 +2481,6 @@ export function App(): JSX.Element { const handleStreamVolumeChange = useCallback((v: number) => { const n = Math.max(0, Math.min(1, v)); setStreamVolume(n); - if (audioRef.current) audioRef.current.volume = n; }, []); const handleToggleStreamMicrophone = useCallback(() => { @@ -3348,6 +3354,7 @@ export function App(): JSX.Element { }, }); clientRef.current.inputPaused = controllerOverlayOpenRef.current; + clientRef.current.setOutputVolume(streamVolume); clientRef.current.setMicrophoneLevel(streamMicLevel); if (settings.microphoneMode !== "disabled") { void clientRef.current.startMicrophone(); @@ -4642,6 +4649,7 @@ export function App(): JSX.Element { onStreamMenuVolumeChange={handleStreamVolumeChange} streamMenuMicLevel={streamMicLevel} onStreamMenuMicLevelChange={handleStreamMicLevelChange} + streamMicTrack={clientRef.current?.getMicTrack() ?? null} onStreamMenuToggleMicrophone={handleToggleStreamMicrophone} onStreamMenuToggleFullscreen={() => { void toggleSessionFullscreen(); @@ -4661,14 +4669,12 @@ export function App(): JSX.Element { await releasePointerLockIfNeeded(); await handleStopStream(); }} - pendingSwitchGameCover={pendingSwitchGameCover} userName={authSession?.user.displayName} userAvatarUrl={authSession?.user.avatarUrl} subscriptionInfo={subscriptionInfo} playtimeData={playtime} sessionStartedAtMs={sessionStartedAtMs} isStreaming={isStreaming} - sessionCounterEnabled={settings.sessionCounterEnabled} settings={{ resolution: settings.resolution, fps: settings.fps, @@ -4680,6 +4686,7 @@ export function App(): JSX.Element { controllerBackgroundAnimations: settings.controllerBackgroundAnimations, controllerThemeStyle: settings.controllerThemeStyle, controllerThemeColor: settings.controllerThemeColor, + controllerLibraryGameBackdrop: settings.controllerLibraryGameBackdrop, autoLoadControllerLibrary: settings.autoLoadControllerLibrary, autoFullScreen: settings.autoFullScreen, aspectRatio: settings.aspectRatio, @@ -4839,14 +4846,12 @@ export function App(): JSX.Element { }} cloudResumeBusy={isResumingNavbarSession} onCloseGame={handlePromptedStopStream} - pendingSwitchGameCover={pendingSwitchGameCover} userName={authSession?.user.displayName} userAvatarUrl={authSession?.user.avatarUrl} subscriptionInfo={subscriptionInfo} playtimeData={playtime} sessionStartedAtMs={sessionStartedAtMs} isStreaming={isStreaming} - sessionCounterEnabled={settings.sessionCounterEnabled} settings={{ resolution: settings.resolution, fps: settings.fps, @@ -4858,6 +4863,7 @@ export function App(): JSX.Element { controllerBackgroundAnimations: settings.controllerBackgroundAnimations, controllerThemeStyle: settings.controllerThemeStyle, controllerThemeColor: settings.controllerThemeColor, + controllerLibraryGameBackdrop: settings.controllerLibraryGameBackdrop, autoLoadControllerLibrary: settings.autoLoadControllerLibrary, autoFullScreen: settings.autoFullScreen, aspectRatio: settings.aspectRatio, diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index a28ab4960..f9672cf59 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -11,6 +11,7 @@ import { getStoreDisplayName, getStoreIconComponent } from "./GameCard"; import { RemainingPlaytimeIndicator, SessionElapsedIndicator } from "./ElapsedSessionIndicators"; import type { MicrophoneMode, ScreenshotEntry, RecordingEntry, SubscriptionInfo } from "@shared/gfn"; import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut, shortcutFromKeyboardEvent } from "../shortcuts"; +import { useMicMeter } from "../hooks/useMicMeter"; const ANTI_AFK_TOGGLE_ACK_MS = 5000; @@ -296,13 +297,41 @@ function ControllerIndicator({ diagnosticsStore, (stats) => stats.connectedGamepads, ); + const [badgeVisible, setBadgeVisible] = useState(true); + const hideTimerRef = useRef(null); + + useEffect(() => { + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + if (connectedGamepads > 0) { + setBadgeVisible(true); + hideTimerRef.current = window.setTimeout(() => { + setBadgeVisible(false); + hideTimerRef.current = null; + }, 5000); + } else { + setBadgeVisible(true); + } + return () => { + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }; + }, [connectedGamepads]); if (isConnecting || connectedGamepads <= 0) { return null; } return ( -
+
{connectedGamepads > 1 && {connectedGamepads}}
@@ -532,121 +561,6 @@ function VideoFocusOnReady({ return null; } -function useMicMeter( - canvasRef: React.RefObject, - track: MediaStreamTrack | null, - active: boolean, -): void { - const pendingCloseRef = useRef | null>(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!active || !track || !canvas) return; - - const ctx2d = canvas.getContext("2d"); - if (!ctx2d) return; - - const dpr = window.devicePixelRatio || 1; - canvas.width = Math.round(canvas.clientWidth * dpr); - canvas.height = Math.round(canvas.clientHeight * dpr); - const W = canvas.width; - const H = canvas.height; - if (W <= 0 || H <= 0) { - return; - } - - let audioCtx: AudioContext | null = null; - let source: MediaStreamAudioSourceNode | null = null; - let analyser: AnalyserNode | null = null; - let tickTimer: number | null = null; - let dead = false; - - const start = async () => { - if (pendingCloseRef.current) { - try { - await pendingCloseRef.current; - } catch { - // Ignore close errors from previous contexts. - } - } - if (dead) { - return; - } - - try { - audioCtx = new AudioContext(); - await audioCtx.resume().catch(() => undefined); - if (dead) { - return; - } - - analyser = audioCtx.createAnalyser(); - analyser.fftSize = 256; - analyser.smoothingTimeConstant = 0.65; - source = audioCtx.createMediaStreamSource(new MediaStream([track])); - source.connect(analyser); - - const buf = new Uint8Array(analyser.frequencyBinCount); - const SEG = 20; - const GAP = Math.round(2 * dpr); - const bw = (W - GAP * (SEG - 1)) / SEG; - const radius = Math.min(3 * dpr, bw / 2); - const frameIntervalMs = 33; - - const frame = () => { - if (dead || !analyser) return; - tickTimer = window.setTimeout(frame, frameIntervalMs); - analyser.getByteTimeDomainData(buf); - - let sum = 0; - for (let i = 0; i < buf.length; i++) { - const v = ((buf[i] ?? 128) - 128) / 128; - sum += v * v; - } - const rms = Math.sqrt(sum / buf.length); - const level = Math.min(1, rms * 5.5); - const filled = Math.round(level * SEG); - - ctx2d.clearRect(0, 0, W, H); - for (let i = 0; i < SEG; i++) { - const x = i * (bw + GAP); - if (i < filled) { - ctx2d.fillStyle = - i < SEG * 0.7 ? "#58d98a" : i < SEG * 0.9 ? "#fbbf24" : "#f87171"; - } else { - ctx2d.fillStyle = "rgba(255,255,255,0.07)"; - } - ctx2d.beginPath(); - ctx2d.roundRect(x, 0, Math.max(1, bw), H, radius); - ctx2d.fill(); - } - }; - - frame(); - } catch (e) { - console.warn("[MicMeter]", e); - } - }; - - void start(); - - return () => { - dead = true; - if (tickTimer !== null) { - window.clearTimeout(tickTimer); - } - source?.disconnect(); - analyser?.disconnect(); - if (audioCtx && audioCtx.state !== "closed") { - pendingCloseRef.current = audioCtx - .close() - .catch(() => undefined) - .then(() => undefined); - } - }; - }, [track, active, canvasRef]); -} - export function StreamView({ videoRef, audioRef, @@ -1641,13 +1555,13 @@ export function StreamView({ {microphoneMode !== "disabled" && (
- Input Level + Send level
{!micTrack && Mic not active — check mode and permissions.}
diff --git a/opennow-stable/src/renderer/src/components/controllerMode/ControllerGameHub.tsx b/opennow-stable/src/renderer/src/components/controllerMode/ControllerGameHub.tsx index fc68e7f86..1c40decb8 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/ControllerGameHub.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/ControllerGameHub.tsx @@ -29,6 +29,8 @@ export interface ControllerGameHubProps { focusIndex: number; /** In-stream overlay: copy emphasizes switching away from the active title */ inStreamMenu?: boolean; + /** When false, omit the full-bleed blurred hero; poster row and copy remain */ + showHeroBackdrop?: boolean; } export function ControllerGameHub({ @@ -41,6 +43,7 @@ export function ControllerGameHub({ tiles, focusIndex, inStreamMenu = false, + showHeroBackdrop = true, }: ControllerGameHubProps): JSX.Element { const record = playtimeData[game.id]; const totalSecs = record?.totalSeconds ?? 0; @@ -65,7 +68,7 @@ export function ControllerGameHub({ return (
- {heroBackdropUrl ? ( + {showHeroBackdrop && heroBackdropUrl ? (
) : null}
diff --git a/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx b/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx index 2d2976c99..fc6069b3a 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/ControllerLibraryPage.tsx @@ -29,11 +29,14 @@ import { spotlightEntryHasGame, } from "./controllerLibrary/helpers"; import { ControllerLibraryLayout } from "./controllerLibrary/ControllerLibraryLayout"; +import { loadScreenshotUrlsForGameTitle } from "./controllerLibrary/loadGameScreenshotUrls"; import { TopLevelMenuTrack } from "./controllerLibrary/TopLevelMenuTrack"; import { useControllerLibraryGameDerivations } from "./controllerLibrary/useControllerLibraryGameDerivations"; import { useControllerLibraryEvents } from "./controllerLibrary/useControllerLibraryEvents"; import { useControllerLibraryLayoutMotion } from "./controllerLibrary/useControllerLibraryLayoutMotion"; +import { LocalVideoPlayerOverlay } from "./controllerLibrary/LocalVideoPlayerOverlay"; import { useControllerLibraryMedia } from "./controllerLibrary/useControllerLibraryMedia"; +import { useLocalVideoPlayback } from "./controllerLibrary/useLocalVideoPlayback"; import { useControllerWindowBindings } from "./controllerLibrary/useControllerWindowBindings"; import { routeCancel, @@ -47,11 +50,11 @@ import type { Direction, GameSubcategory, GamesHubReturnSnapshot, + HomeRootPlane, LibrarySortId, MediaSubcategory, SettingsSubcategory, SoundKind, - SpotlightEntry, TopCategory, } from "./controllerLibrary/types"; @@ -72,7 +75,6 @@ export function ControllerLibraryPage({ onResumeGame, onCloseGame, onExitApp, - pendingSwitchGameCover, userName = "Player One", userAvatarUrl, subscriptionInfo, @@ -86,12 +88,12 @@ export function ControllerLibraryPage({ onExitControllerMode, sessionStartedAtMs = null, isStreaming = false, - sessionCounterEnabled = false, inStreamMenu = false, streamMenuVolume = 1, onStreamMenuVolumeChange, streamMenuMicLevel = 1, onStreamMenuMicLevelChange, + streamMicTrack = null, onStreamMenuToggleMicrophone, onStreamMenuToggleFullscreen, streamMenuMicOn = false, @@ -102,14 +104,8 @@ export function ControllerLibraryPage({ onResumeCloudSession, cloudResumeBusy = false, }: ControllerLibraryPageProps): JSX.Element { - const initialCategoryIndex = (() => { - if (currentStreamingGame) { - // TOP_CATEGORIES: current (game title), settings, all, media - return 0; - } - // TOP_CATEGORIES without `current`: settings, all, media - return 1; - })(); + /** Top strip: Home (current / last played) is always the default landing tab. */ + const initialCategoryIndex = 0; const [categoryIndex, setCategoryIndex] = useState(initialCategoryIndex); const [endSessionConfirm, setEndSessionConfirm] = useState(false); const [editingStreamVolume, setEditingStreamVolume] = useState(false); @@ -135,15 +131,19 @@ export function ControllerLibraryPage({ const [ps5Row, setPs5Row] = useState<"top" | "main" | "detail">("main"); const [detailRailIndex, setDetailRailIndex] = useState(0); const [librarySortId, setLibrarySortId] = useState(() => readLibrarySortId()); - const [gamesRootPlane, setGamesRootPlane] = useState<"spotlight" | "categories">("spotlight"); + const [gamesRootPlane, setGamesRootPlane] = useState<"spotlight" | "categories">("categories"); + const [homeRootPlane, setHomeRootPlane] = useState("spotlight"); const [spotlightIndex, setSpotlightIndex] = useState(0); const [optionsOpen, setOptionsOpen] = useState(false); const [optionsEntries, setOptionsEntries] = useState>([]); const [optionsFocusIndex, setOptionsFocusIndex] = useState(0); const [gamesHubOpen, setGamesHubOpen] = useState(false); const [gamesHubFocusIndex, setGamesHubFocusIndex] = useState(0); + const [mediaListRefreshNonce, setMediaListRefreshNonce] = useState(0); /** Local captures for the focused game; loaded when hub opens so Media tab need not be visited first */ const [gameHubScreenshotUrls, setGameHubScreenshotUrls] = useState([]); + /** Random capture for Home last-played resume tile; falls back to poster in TopLevelMenuTrack */ + const [homeResumeSnapshotUrl, setHomeResumeSnapshotUrl] = useState(null); const gamesHubReturnSnapshotRef = useRef(null); const spotlightTrackRef = useRef(null); @@ -154,8 +154,6 @@ export function ControllerLibraryPage({ } }, [librarySortId]); - // poster measurement handled by `attachPosterRef` callback ref - useEffect(() => { const detectTypeFromGamepad = (g: Gamepad | null): "ps" | "xbox" | "nintendo" | "generic" => { if (!g || !g.id) return "generic"; @@ -207,16 +205,44 @@ export function ControllerLibraryPage({ playControllerUiSound(kind, uiSoundsEnabled); }, [uiSoundsEnabled]); - 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: "Games" }); - categories.push({ id: "media", label: "Media" }); - return categories; - }, [currentStreamingGame]); + const { + playback: localVideoPlayback, + localVideoPlayerOpen, + openFromEntry: openLocalVideoPlayer, + close: closeLocalVideoPlayer, + } = useLocalVideoPlayback(playUiSound); + + const bumpMediaListRefresh = useCallback((): void => { + setMediaListRefreshNonce((n) => n + 1); + }, []); + + const lastPlayedGame = useMemo((): GameInfo | null => { + const lastPlayedMs = (gameId: string) => { + const raw = playtimeData[gameId]?.lastPlayedAt; + if (!raw) return 0; + const ms = Date.parse(raw); + return Number.isFinite(ms) ? ms : 0; + }; + const played = games.filter((g) => lastPlayedMs(g.id) > 0); + if (played.length === 0) return null; + played.sort((a, b) => { + const d = lastPlayedMs(b.id) - lastPlayedMs(a.id); + if (d !== 0) return d; + return a.title.localeCompare(b.title); + }); + return played[0] ?? null; + }, [games, playtimeData]); + + const currentTabGame = currentStreamingGame ?? lastPlayedGame; + + const TOP_CATEGORIES = useMemo((): Array<{ id: TopCategory; label: string }> => { + return [ + { id: "current", label: "Home" }, + { id: "all", label: "Games" }, + { id: "media", label: "Media" }, + { id: "settings", label: "Settings" }, + ]; + }, []); const topCategory = (TOP_CATEGORIES[categoryIndex]?.id ?? "all") as TopCategory; const { @@ -231,7 +257,19 @@ export function ControllerLibraryPage({ topCategory, mediaSubcategory, selectedMediaIndex, + mediaListRefreshNonce, }); + + useEffect(() => { + if (topCategory !== "media" || mediaSubcategory === "root") return; + const len = mediaAssetItems.length; + if (len === 0) { + if (selectedMediaIndex !== 0) setSelectedMediaIndex(0); + return; + } + if (selectedMediaIndex >= len) setSelectedMediaIndex(len - 1); + }, [topCategory, mediaSubcategory, mediaAssetItems.length, selectedMediaIndex]); + const { favoriteGameIdSet, allGenres, @@ -245,12 +283,15 @@ export function ControllerLibraryPage({ selectedIndex, selectedGame, selectedVariantId, + featuredHomeGame, } = useControllerLibraryGameDerivations({ games, favoriteGameIds, playtimeData, topCategory, currentStreamingGame, + homeShelfGameTitle: currentTabGame?.title ?? null, + resumeContextGameId: currentTabGame?.id ?? null, gameSubcategory, selectedGameId, selectedVariantByGameId, @@ -396,6 +437,11 @@ export function ControllerLibraryPage({ Theme: [ { id: "themeColor", label: "Color", value: `RGB ${themeRgb.r}, ${themeRgb.g}, ${themeRgb.b}` }, { id: "themeStyle", label: "Style", value: CONTROLLER_THEME_STYLE_LABEL[themeStyleResolved] }, + { + id: "libraryGameBackdrop", + label: "Match background to game", + value: (settings.controllerLibraryGameBackdrop !== false) ? "On" : "Off", + }, ], ThemeColor: [ { id: "themeR", label: "Red", value: `${themeRgb.r}` }, @@ -446,80 +492,81 @@ export function ControllerLibraryPage({ setSpotlightIndex((i) => Math.min(i, spotlightEntries.length - 1)); }, [spotlightEntries.length]); - const hadCloudResumeSpotlightRef = useRef(false); - useEffect(() => { - const hasResume = spotlightEntries.some((e) => e.kind === "cloudResume"); - if (hasResume && !hadCloudResumeSpotlightRef.current && topCategory === "all" && gameSubcategory === "root") { - setGamesRootPlane("spotlight"); - setSpotlightIndex(0); + const gamesHubDisplayGame = useMemo((): GameInfo | null => { + if (!gamesHubOpen) return null; + if (topCategory === "current") { + return games.find((g) => g.id === selectedGameId) ?? null; } - hadCloudResumeSpotlightRef.current = hasResume; - }, [spotlightEntries, topCategory, gameSubcategory]); - + if (topCategory === "all" && gameSubcategory !== "root") { + return selectedGame; + } + return null; + }, [gamesHubOpen, topCategory, gameSubcategory, games, selectedGameId, selectedGame]); useEffect(() => { - if (!gamesHubOpen || !selectedGame?.title?.trim()) { - setGameHubScreenshotUrls([]); - return; - } - if (typeof window.openNow?.listMediaByGame !== "function") { + if (!gamesHubOpen || !gamesHubDisplayGame?.title?.trim()) { setGameHubScreenshotUrls([]); return; } let cancelled = false; - const titleArg = selectedGame.title.trim(); + void loadScreenshotUrlsForGameTitle(gamesHubDisplayGame.title).then((urls) => { + if (!cancelled) setGameHubScreenshotUrls(urls); + }); - void (async () => { - try { - const listing = await window.openNow.listMediaByGame({ gameTitle: titleArg }); - if (cancelled) return; - - const rows = [...(listing.screenshots ?? [])].sort((a, b) => b.createdAtMs - a.createdAtMs); - const urls: string[] = []; - - for (const s of rows) { - let u = s.thumbnailDataUrl || s.dataUrl; - if (!u && typeof window.openNow?.getMediaThumbnail === "function") { - try { - u = (await window.openNow.getMediaThumbnail({ filePath: s.filePath })) ?? undefined; - } catch { - u = undefined; - } - } - if (u) urls.push(u); - } + return () => { + cancelled = true; + }; + }, [gamesHubOpen, gamesHubDisplayGame?.id, gamesHubDisplayGame?.title]); - if (!cancelled) setGameHubScreenshotUrls(urls); - } catch { - if (!cancelled) setGameHubScreenshotUrls([]); + useEffect(() => { + const game = currentTabGame; + if (!game?.title?.trim()) { + setHomeResumeSnapshotUrl(null); + return; + } + + let cancelled = false; + void loadScreenshotUrlsForGameTitle(game.title).then((urls) => { + if (cancelled) return; + if (urls.length === 0) { + setHomeResumeSnapshotUrl(null); + return; } - })(); + const pick = urls[Math.floor(Math.random() * urls.length)] ?? null; + setHomeResumeSnapshotUrl(pick); + }); return () => { cancelled = true; }; - }, [gamesHubOpen, selectedGame?.id, selectedGame?.title]); - + }, [currentTabGame?.id, currentTabGame?.title]); - const showCurrentDetail = topCategory === "current" && Boolean(currentStreamingGame); - const detailVisible = showCurrentDetail; const gamesShelfBrowseActive = topCategory === "all" && gameSubcategory !== "root"; const mediaShelfBrowseActive = topCategory === "media" && mediaSubcategory !== "root"; const topLevelShelfActive = !gamesShelfBrowseActive && !mediaShelfBrowseActive && + !(topCategory === "current" && gamesHubOpen) && (topCategory === "settings" || topCategory === "current" || (topCategory === "media" && mediaSubcategory === "root") || (topCategory === "all" && gameSubcategory === "root")); - const gamesDualShelf = - topCategory === "all" && - gameSubcategory === "root" && - (games.length > 0 || Boolean(cloudSessionResumable && onResumeCloudSession)); + /** Games root: category row only (no Recently played / spotlight strip; that lives on Home). */ + const gamesDualShelf = false; + const homeDualShelf = + topCategory === "current" && !inStreamMenu && spotlightEntries.length > 0; + + const featuredIsFavorite = Boolean(featuredHomeGame && favoriteGameIdSet.has(featuredHomeGame.id)); + + useEffect(() => { + if (topCategory !== "current") return; + if (!inStreamMenu) setHomeRootPlane("spotlight"); + }, [topCategory, inStreamMenu]); const topLevelRowBehaviorActive = topLevelShelfActive && !(topCategory === "settings" && settingsSubcategory !== "root"); - const canEnterDetailRow = mediaShelfBrowseActive; + /** Media browse: no secondary “detail” row (down used to open Open folder / Media hub cards). */ + const canEnterDetailRow = false; const canEnterTopRow = topLevelRowBehaviorActive || gamesShelfBrowseActive || mediaShelfBrowseActive; const topLevelShelfIndex = topCategory === "media" @@ -538,10 +585,16 @@ export function ControllerLibraryPage({ if (item?.id !== "closeGame") setEndSessionConfirm(false); }, [inStreamMenu, endSessionConfirm, topCategory, displayItems, topLevelShelfIndex]); - const selectedCategoryLabel = useMemo(() => getCategoryLabel(topCategory, currentStreamingGame?.title).label, [topCategory, currentStreamingGame?.title]); + const selectedCategoryLabel = useMemo(() => getCategoryLabel(topCategory).label, [topCategory]); const selectedTopLevelItemLabel = useMemo(() => { if (!topLevelShelfActive) return selectedCategoryLabel; - if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight") { + if (topCategory === "current" && homeDualShelf && homeRootPlane === "spotlight") { + const entry = spotlightEntries[spotlightIndex]; + if (entry?.kind === "cloudResume") return entry.title; + if (spotlightEntryHasGame(entry)) return entry.game.title; + return "Recently played"; + } + if (topCategory === "all" && gameSubcategory === "root" && gamesDualShelf && gamesRootPlane === "spotlight") { const entry = spotlightEntries[spotlightIndex]; if (entry?.kind === "cloudResume") return entry.title; if (spotlightEntryHasGame(entry)) return entry.game.title; @@ -551,8 +604,23 @@ export function ControllerLibraryPage({ if (topCategory === "all" && gameSubcategory === "root" && active?.label) return active.label; if (topCategory === "media" && mediaSubcategory === "root" && active?.label) return active.label; if (topCategory === "settings" && active?.label) return active.label; + if (topCategory === "current" && active?.label) return active.label; return selectedCategoryLabel; - }, [topLevelShelfActive, selectedCategoryLabel, displayItems, topLevelShelfIndex, topCategory, gameSubcategory, mediaSubcategory, gamesRootPlane, spotlightEntries, spotlightIndex]); + }, [ + topLevelShelfActive, + selectedCategoryLabel, + displayItems, + topLevelShelfIndex, + topCategory, + gameSubcategory, + mediaSubcategory, + gamesRootPlane, + gamesDualShelf, + homeDualShelf, + homeRootPlane, + spotlightEntries, + spotlightIndex, + ]); const detailRailItems = useMemo>(() => { if (topCategory === "media" && mediaSubcategory !== "root") { const current = mediaAssetItems[selectedMediaIndex]; @@ -566,16 +634,16 @@ export function ControllerLibraryPage({ }, [topCategory, mediaSubcategory, mediaAssetItems, selectedMediaIndex, mediaThumbById]); const gamesHubTiles = useMemo(() => { - if (!selectedGame || topCategory !== "all" || gameSubcategory === "root") return []; - const fav = favoriteGameIdSet.has(selectedGame.id); + if (!gamesHubDisplayGame) return []; + const fav = favoriteGameIdSet.has(gamesHubDisplayGame.id); const tiles: Array<{ id: string; title: string; subtitle: string; disabled?: boolean }> = [ { id: "play", - title: currentStreamingGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play", + title: currentStreamingGame && currentStreamingGame.id !== gamesHubDisplayGame.id ? "Switch" : "Play", subtitle: - inStreamMenu && currentStreamingGame && currentStreamingGame.id !== selectedGame.id + inStreamMenu && currentStreamingGame && currentStreamingGame.id !== gamesHubDisplayGame.id ? `Switch from ${currentStreamingGame.title}` - : currentStreamingGame && currentStreamingGame.id !== selectedGame.id + : currentStreamingGame && currentStreamingGame.id !== gamesHubDisplayGame.id ? "Switch to this title" : "Launch now", }, @@ -585,21 +653,30 @@ export function ControllerLibraryPage({ subtitle: "Library", }, ]; - if (selectedGame.variants.length > 1) { + if (gamesHubDisplayGame.variants.length > 1) { tiles.push({ id: "version", title: "Version", subtitle: "Cycle stream variant" }); } tiles.push({ id: "activities", title: "Activities", subtitle: "Coming soon", disabled: true }); tiles.push({ id: "progress", title: "Progress", subtitle: "Coming soon", disabled: true }); return tiles; - }, [topCategory, gameSubcategory, selectedGame, favoriteGameIdSet, currentStreamingGame, inStreamMenu]); + }, [gamesHubDisplayGame, favoriteGameIdSet, currentStreamingGame, inStreamMenu]); useEffect(() => { const n = gamesHubTiles.length; if (n === 0) return; setGamesHubFocusIndex((i) => Math.max(0, Math.min(n - 1, i))); - }, [gamesHubTiles.length, selectedGame?.id]); + }, [gamesHubTiles.length, gamesHubDisplayGame?.id]); const focusMotionKey = useMemo(() => { - if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight") { + if (topCategory === "current" && gamesHubOpen) { + return `game-${gamesHubDisplayGame?.id ?? "none"}`; + } + if (topCategory === "current" && homeDualShelf && homeRootPlane === "spotlight") { + const entry = spotlightEntries[spotlightIndex]; + if (entry?.kind === "cloudResume") return `home-spotlight-resume-${entry.busy ? "busy" : "idle"}`; + if (spotlightEntryHasGame(entry)) return `home-spotlight-${entry.game.id}`; + return `home-spotlight-empty-${spotlightIndex}`; + } + if (topCategory === "all" && gameSubcategory === "root" && gamesDualShelf && gamesRootPlane === "spotlight") { const entry = spotlightEntries[spotlightIndex]; if (entry?.kind === "cloudResume") return `spotlight-resume-${entry.busy ? "busy" : "idle"}`; if (spotlightEntryHasGame(entry)) return `spotlight-${entry.game.id}`; @@ -608,14 +685,28 @@ export function ControllerLibraryPage({ if (topCategory === "all" && gameSubcategory !== "root") return `game-${selectedGame?.id ?? "none"}`; if (topCategory === "media" && mediaSubcategory !== "root") return `media-${selectedMediaIndex}-${mediaAssetItems[selectedMediaIndex]?.id ?? "none"}`; return `menu-${topCategory}-${topLevelShelfIndex}`; - }, [topCategory, gameSubcategory, gamesRootPlane, spotlightEntries, spotlightIndex, selectedGame?.id, topLevelShelfIndex, mediaSubcategory, selectedMediaIndex, mediaAssetItems]); + }, [ + topCategory, + gameSubcategory, + gamesRootPlane, + homeDualShelf, + homeRootPlane, + gamesHubOpen, + gamesHubDisplayGame?.id, + spotlightEntries, + spotlightIndex, + gamesDualShelf, + selectedGame?.id, + topLevelShelfIndex, + mediaSubcategory, + selectedMediaIndex, + mediaAssetItems, + ]); const { listTranslateX, spotlightShelfTranslateX, gamesRootMenuTranslateX, heroTransitionMs, - metaMaxWidth, - attachPosterRef, wrapperThemeVars, wrapperClassNameWithRow, menuShelfTranslateX, @@ -631,6 +722,7 @@ export function ControllerLibraryPage({ selectedIndex, selectedMediaIndex, gamesDualShelf, + homeDualShelf, spotlightIndex, spotlightEntriesLength: spotlightEntries.length, itemsContainerRef, @@ -641,7 +733,7 @@ export function ControllerLibraryPage({ }); useEffect(() => { if (topCategory !== "all") { - setGamesRootPlane("spotlight"); + setGamesRootPlane("categories"); setSpotlightIndex(0); } }, [topCategory]); @@ -745,6 +837,7 @@ export function ControllerLibraryPage({ codecOptions, aspectRatioOptions, currentStreamingGame, + currentTabGame, onResumeGame, onResumeCloudSession, onCloseGame, @@ -765,12 +858,17 @@ export function ControllerLibraryPage({ optionsFocusIndex, optionsEntries, gamesRootPlane, + homeRootPlane, spotlightIndex, spotlightEntries, gamesDualShelf, + homeDualShelf, + categoryIndex, + featuredHomeGame, favoriteGameIdSet, microphoneDevices, gamesHubOpen, + gamesHubDisplayGame, gamesHubFocusIndex, gamesHubTiles, inStreamMenu, @@ -794,11 +892,14 @@ export function ControllerLibraryPage({ setEditingThemeChannel, setEditingStreamVolume, setEditingStreamMicLevel, + setOptionsEntries, + setOptionsOpen, setOptionsFocusIndex, setGamesHubFocusIndex, setPs5Row, setDetailRailIndex, setGamesRootPlane, + setHomeRootPlane, setSpotlightIndex, gamesHubReturnSnapshotRef, setGamesHubOpen, @@ -809,6 +910,11 @@ export function ControllerLibraryPage({ setLastThemeRootIndex, setLastRootMediaIndex, setLibrarySortId, + localVideoPlayerOpen, + closeLocalVideoPlayer, + openLocalVideoPlayer, + localVideoFilePathForOptions: localVideoPlayback?.filePath ?? null, + bumpMediaListRefresh, }); @@ -837,8 +943,29 @@ export function ControllerLibraryPage({ : ; }; - const heroBackdropUrl = useMemo(() => { - if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight" && spotlightEntries.length > 0) { + const libraryGameBackdropOn = settings.controllerLibraryGameBackdrop !== false; + + const heroBackdropUrlRaw = useMemo(() => { + if (topCategory === "current" && gamesHubOpen && gamesHubDisplayGame?.imageUrl) { + return gamesHubDisplayGame.imageUrl; + } + if ( + topCategory === "all" && + gameSubcategory === "root" && + gamesDualShelf && + gamesRootPlane === "spotlight" && + spotlightEntries.length > 0 + ) { + const cur = spotlightEntries[spotlightIndex]; + if (cur?.kind === "cloudResume" && cur.coverUrl) return cur.coverUrl; + if (spotlightEntryHasGame(cur) && cur.game.imageUrl) return cur.game.imageUrl; + for (const e of spotlightEntries) { + if (e.kind === "cloudResume" && e.coverUrl) return e.coverUrl; + if (e.kind === "recent" && e.game?.imageUrl) return e.game.imageUrl; + } + return null; + } + if (topCategory === "current" && homeDualShelf && homeRootPlane === "spotlight" && spotlightEntries.length > 0) { const cur = spotlightEntries[spotlightIndex]; if (cur?.kind === "cloudResume" && cur.coverUrl) return cur.coverUrl; if (spotlightEntryHasGame(cur) && cur.game.imageUrl) return cur.game.imageUrl; @@ -849,15 +976,36 @@ export function ControllerLibraryPage({ return null; } if (topCategory === "all") return selectedGame?.imageUrl ?? null; - if (topCategory === "current") return currentStreamingGame?.imageUrl ?? null; + if (topCategory === "current") return currentTabGame?.imageUrl ?? null; if (topCategory === "media") { if (selectedMediaItem?.thumbnailDataUrl) return selectedMediaItem.thumbnailDataUrl; if (selectedMediaItem?.dataUrl) return selectedMediaItem.dataUrl; return selectedMediaItem ? mediaThumbById[selectedMediaItem.id] ?? null : null; } - if (currentStreamingGame?.imageUrl) return currentStreamingGame.imageUrl; + if (currentTabGame?.imageUrl) return currentTabGame.imageUrl; return selectedGame?.imageUrl ?? null; - }, [topCategory, gameSubcategory, gamesRootPlane, spotlightEntries, spotlightIndex, selectedGame, currentStreamingGame, selectedMediaItem, mediaThumbById]); + }, [ + topCategory, + gameSubcategory, + gamesRootPlane, + homeDualShelf, + homeRootPlane, + gamesHubOpen, + gamesHubDisplayGame, + gamesDualShelf, + spotlightEntries, + spotlightIndex, + selectedGame, + currentTabGame, + selectedMediaItem, + mediaThumbById, + ]); + + const heroBackdropUrl = useMemo(() => { + if (libraryGameBackdropOn) return heroBackdropUrlRaw; + if (topCategory === "media") return heroBackdropUrlRaw; + return null; + }, [libraryGameBackdropOn, topCategory, heroBackdropUrlRaw]); const themeRgbForTrack = settings.controllerThemeColor ?? { r: 124, g: 241, b: 177 }; const maxBitrateMbpsForTrack = settings.maxBitrateMbps ?? 75; @@ -871,7 +1019,8 @@ export function ControllerLibraryPage({ displayItems={displayItems} topLevelShelfIndex={topLevelShelfIndex} gameCategoryPreviewById={gameCategoryPreviewById} - currentStreamingImageUrl={currentStreamingGame?.imageUrl} + currentStreamingImageUrl={homeResumeSnapshotUrl ?? currentTabGame?.imageUrl} + featuredPreviewImageUrl={featuredHomeGame?.imageUrl ?? null} settingsSubcategory={settingsSubcategory} editingBandwidth={editingBandwidth} maxBitrateMbpsForTrack={maxBitrateMbpsForTrack} @@ -886,6 +1035,7 @@ export function ControllerLibraryPage({ onStreamMenuVolumeChange={onStreamMenuVolumeChange} editingStreamVolume={editingStreamVolume} controllerType={controllerType} + streamMicTrack={streamMicTrack} /> ), [ topCategory, @@ -894,7 +1044,9 @@ export function ControllerLibraryPage({ displayItems, topLevelShelfIndex, gameCategoryPreviewById, - currentStreamingGame?.imageUrl, + homeResumeSnapshotUrl, + currentTabGame?.imageUrl, + featuredHomeGame?.imageUrl, editingBandwidth, editingThemeChannel, settingsSubcategory, @@ -911,19 +1063,25 @@ export function ControllerLibraryPage({ editingStreamVolume, editingStreamMicLevel, controllerType, + streamMicTrack, ]); return ( + <> + {localVideoPlayback ? ( + + ) : null} + ); } diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx index 5c53d65de..0b482da0d 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/ControllerLibraryLayout.tsx @@ -6,7 +6,6 @@ import { LIBRARY_SORT_LABEL } from "./constants"; import { AllGamesBrowseSection } from "./AllGamesBrowseSection"; import { TopLevelShelfSection } from "./TopLevelShelfSection"; import { MediaHubSection } from "./MediaHubSection"; -import { CurrentDetailPanel } from "./CurrentDetailPanel"; import { DetailRail } from "./DetailRail"; import { OptionsSheet } from "./OptionsSheet"; import { FooterHints } from "./FooterHints"; @@ -19,10 +18,13 @@ export function ControllerLibraryLayout(props: Record): JSX.Element wrapperClassNameWithRow, wrapperThemeVars, currentStreamingGame, + currentTabGame, inStreamMenu, endSessionConfirm, parallaxBackdropTiles, heroBackdropUrl, + loadingBackdropImageUrl, + gameHubShowHeroBackdrop = true, settings, subscriptionInfo, sessionStartedAtMs, @@ -34,6 +36,7 @@ export function ControllerLibraryLayout(props: Record): JSX.Element getCategoryIcon, gameSubcategory, gamesHubOpen, + gamesHubDisplayGame, selectedGame, gameHubScreenshotUrls, playtimeData, @@ -51,11 +54,15 @@ export function ControllerLibraryLayout(props: Record): JSX.Element topLevelShelfActive, selectedTopLevelItemLabel, gamesRootPlane, + homeRootPlane, spotlightEntries, spotlightIndex, displayItems, topLevelShelfIndex, gamesDualShelf, + homeDualShelf, + featuredHomeGame, + featuredIsFavorite, cloudSessionResumable, onResumeCloudSession, spotlightTrackRef, @@ -70,11 +77,6 @@ export function ControllerLibraryLayout(props: Record): JSX.Element mediaHubSlots, selectedMediaIndex, mediaThumbById, - detailVisible, - pendingSwitchGameCover, - attachPosterRef, - metaMaxWidth, - sessionCounterEnabled, ps5Row, canEnterDetailRow, detailRailItems, @@ -98,7 +100,7 @@ export function ControllerLibraryLayout(props: Record): JSX.Element
); @@ -167,17 +169,18 @@ export function ControllerLibraryLayout(props: Record): JSX.Element getCategoryIcon={getCategoryIcon} /> - {topCategory === "all" && gameSubcategory !== "root" && gamesHubOpen && selectedGame ? ( + {gamesHubOpen && gamesHubDisplayGame && ((topCategory === "all" && gameSubcategory !== "root") || topCategory === "current") ? ( ) : null} @@ -205,13 +208,19 @@ export function ControllerLibraryLayout(props: Record): JSX.Element topCategory={topCategory} gameSubcategory={gameSubcategory} gamesRootPlane={gamesRootPlane} + homeRootPlane={homeRootPlane ?? "spotlight"} spotlightEntries={spotlightEntries} spotlightIndex={spotlightIndex} displayItems={displayItems} topLevelShelfIndex={topLevelShelfIndex} - currentStreamingGame={currentStreamingGame} + currentTabGame={currentTabGame} + featuredHomeGame={featuredHomeGame ?? null} + featuredIsFavorite={Boolean(featuredIsFavorite)} playtimeData={playtimeData} gamesDualShelf={gamesDualShelf} + homeDualShelf={Boolean(homeDualShelf)} + inStreamMenu={inStreamMenu} + subscriptionInfo={subscriptionInfo} cloudSessionResumable={cloudSessionResumable} onResumeCloudSession={onResumeCloudSession} spotlightTrackRef={spotlightTrackRef} @@ -236,21 +245,6 @@ export function ControllerLibraryLayout(props: Record): JSX.Element /> )} -
- -
- ): JSX.Element /> ): JSX.Element gameSubcategory={gameSubcategory} gamesHubOpen={gamesHubOpen} gamesRootPlane={gamesRootPlane} + gamesDualShelf={Boolean(gamesDualShelf)} + homeRootPlane={homeRootPlane ?? "spotlight"} + homeDualShelf={Boolean(homeDualShelf)} spotlightEntries={spotlightEntries} spotlightIndex={spotlightIndex} currentStreamingGame={currentStreamingGame} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/CurrentDetailPanel.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/CurrentDetailPanel.tsx deleted file mode 100644 index c2becdbed..000000000 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/CurrentDetailPanel.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import type { JSX } from "react"; -import type { GameInfo } from "@shared/gfn"; -import { Clock, Calendar, Repeat2 } from "lucide-react"; -import { getStoreDisplayName } from "../../GameCard"; -import { SessionElapsedIndicator } from "../../ElapsedSessionIndicators"; -import { formatPlaytime, formatLastPlayed, type PlaytimeStore } from "../../../utils/usePlaytime"; -import { sanitizeGenreName } from "./helpers"; - -interface CurrentDetailPanelProps { - topCategory: string; - pendingSwitchGameCover?: string | null; - currentStreamingGame?: GameInfo | null; - attachPosterRef: (el: HTMLImageElement | null) => void; - metaMaxWidth: number | null; - selectedVariantByGameId: Record; - playtimeData: PlaytimeStore; - sessionCounterEnabled: boolean; - sessionStartedAtMs: number | null; - isStreaming: boolean; -} - -export function CurrentDetailPanel({ - topCategory, - pendingSwitchGameCover, - currentStreamingGame, - attachPosterRef, - metaMaxWidth, - selectedVariantByGameId, - playtimeData, - sessionCounterEnabled, - sessionStartedAtMs, - isStreaming, -}: CurrentDetailPanelProps): JSX.Element | null { - if (topCategory !== "current") return null; - - return ( -
-
- {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}} - {sessionCounterEnabled && ( - - - - )} - - - {playtimeLabel} - - - - {lastPlayedLabel} - - {sessionCount > 0 && ( - - - {sessionCount === 1 ? "1 session" : `${sessionCount} sessions`} - - )} - {genres.map((g) => ( - {sanitizeGenreName(g)} - ))} - {tier && {tier}} - - ); - })()} -
-
-
- ); -} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/FooterHints.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/FooterHints.tsx index 468e7182d..0389efe20 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/FooterHints.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/FooterHints.tsx @@ -2,9 +2,10 @@ import type { JSX } from "react"; import { ButtonA, ButtonB, ButtonPSCircle, ButtonPSCross } from "../ControllerButtons"; import { spotlightEntryHasGame } from "./helpers"; import type { GameInfo } from "@shared/gfn"; -import type { GameSubcategory, SettingsSubcategory, SpotlightEntry } from "./types"; +import type { GameSubcategory, HomeRootPlane, SettingsSubcategory, SpotlightEntry } from "./types"; interface FooterHintsProps { + localVideoPlayerOpen?: boolean; topLevelRowBehaviorActive: boolean; topCategory: string; settingsSubcategory: SettingsSubcategory; @@ -13,6 +14,10 @@ interface FooterHintsProps { gameSubcategory: GameSubcategory; gamesHubOpen: boolean; gamesRootPlane: "spotlight" | "categories"; + /** Games tab spotlight row; when false, root Games uses categories row only. */ + gamesDualShelf?: boolean; + homeRootPlane?: HomeRootPlane; + homeDualShelf?: boolean; spotlightEntries: SpotlightEntry[]; spotlightIndex: number; currentStreamingGame: GameInfo | null | undefined; @@ -22,6 +27,7 @@ interface FooterHintsProps { } export function FooterHints({ + localVideoPlayerOpen = false, topLevelRowBehaviorActive, topCategory, settingsSubcategory, @@ -30,6 +36,9 @@ export function FooterHints({ gameSubcategory, gamesHubOpen, gamesRootPlane, + gamesDualShelf = true, + homeRootPlane = "spotlight", + homeDualShelf = false, spotlightEntries, spotlightIndex, currentStreamingGame, @@ -37,22 +46,63 @@ export function FooterHints({ controllerType, renderFaceButton, }: FooterHintsProps): JSX.Element { + if (localVideoPlayerOpen) { + return ( +
+
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Close +
+
+ ); + } + return (
{topLevelRowBehaviorActive ? ( - <> -
- {controllerType === "ps" ? ( - - ) : ( - - )} - Select -
-
L1 Prev Section
-
R1 Next Section
- - ) : topCategory === "current" ? ( + topCategory === "current" && homeDualShelf ? ( + <> +
Rows · ↑ / ↓
+
+ {controllerType === "ps" ? ( + + ) : ( + + )} + + {homeRootPlane === "spotlight" && spotlightEntries[spotlightIndex]?.kind === "cloudResume" + ? spotlightEntries[spotlightIndex].busy + ? "Please wait" + : "Resume session" + : homeRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex]) + ? "Game hub" + : homeRootPlane === "spotlight" + ? "Enter" + : "Select"} + +
+
L1 Prev Section
+
R1 Next Section
+ + ) : ( + <> +
+ {controllerType === "ps" ? ( + + ) : ( + + )} + Select +
+
L1 Prev Section
+
R1 Next Section
+ + ) + ) : topCategory === "current" && !gamesHubOpen ? (
{controllerType === "ps" ? ( @@ -151,7 +201,7 @@ export function FooterHints({ ) : ( )} - Open Folder + {mediaSubcategory === "Videos" ? "Play" : "Open Folder"}
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
@@ -165,7 +215,8 @@ export function FooterHints({ )} - ) : topCategory === "all" && gameSubcategory !== "root" ? ( + ) : (topCategory === "all" && gameSubcategory !== "root") || + (topCategory === "current" && gamesHubOpen) ? ( gamesHubOpen ? ( <>
Actions · Left / Right
@@ -183,7 +234,7 @@ export function FooterHints({ ) : ( )} - Back + {topCategory === "current" ? "Back to Home" : "Back"}
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
@@ -208,16 +259,16 @@ export function FooterHints({ )} - {gamesRootPlane === "spotlight" && spotlightEntries[spotlightIndex]?.kind === "cloudResume" + {gamesDualShelf && gamesRootPlane === "spotlight" && spotlightEntries[spotlightIndex]?.kind === "cloudResume" ? spotlightEntries[spotlightIndex].busy ? "Please wait" : "Resume session" - : gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex]) + : gamesDualShelf && gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex]) ? "View in library" : "Enter"}
- {gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex]) ? ( + {gamesDualShelf && gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex]) ? (
{renderFaceButton("tertiary", "xmb-btn-icon", 24)} Options
) : null}
L1 Prev Section
diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/HomeSubscriptionMeta.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/HomeSubscriptionMeta.tsx new file mode 100644 index 000000000..d9d26e0c4 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/HomeSubscriptionMeta.tsx @@ -0,0 +1,58 @@ +import type { JSX } from "react"; +import { Clock, Shield } from "lucide-react"; +import type { SubscriptionInfo } from "@shared/gfn"; +import { formatRemainingPlaytimeFromSubscription } from "../../../utils/usePlaytime"; + +function formatRenewalRelative(isoEnd: string | undefined): string | null { + if (!isoEnd?.trim()) return null; + const end = Date.parse(isoEnd); + if (!Number.isFinite(end)) return null; + const now = Date.now(); + const diffMs = end - now; + const dayMs = 86_400_000; + const days = Math.round(diffMs / dayMs); + if (days < 0) return "Period ended"; + if (days === 0) return "Renews today"; + if (days === 1) return "Renews tomorrow"; + return `Renews in ${days} days`; +} + +interface HomeSubscriptionMetaProps { + subscriptionInfo: SubscriptionInfo | null; +} + +export function HomeSubscriptionMeta({ subscriptionInfo }: HomeSubscriptionMetaProps): JSX.Element | null { + if (!subscriptionInfo) return null; + + const tier = subscriptionInfo.membershipTier?.trim() || "Membership"; + const timeText = subscriptionInfo.isUnlimited + ? "Unlimited" + : `${formatRemainingPlaytimeFromSubscription(subscriptionInfo, 0)} left`; + const renewal = formatRenewalRelative(subscriptionInfo.currentSpanEndDateTime); + const state = subscriptionInfo.state?.trim(); + const warnState = state && state.toUpperCase() !== "ACTIVE"; + const blocked = subscriptionInfo.isGamePlayAllowed === false; + + return ( +
+ + + {tier} + + + + {timeText} + + {renewal ? ( + + {renewal} + + ) : null} + {warnState || blocked ? ( + + {blocked ? "Play may be restricted" : `Status: ${state}`} + + ) : null} +
+ ); +} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/LocalVideoPlayerOverlay.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/LocalVideoPlayerOverlay.tsx new file mode 100644 index 000000000..1ed8d25b6 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/LocalVideoPlayerOverlay.tsx @@ -0,0 +1,19 @@ +import type { JSX } from "react"; + +export interface LocalVideoPlayerOverlayProps { + src: string; + onClose: () => void; +} + +export function LocalVideoPlayerOverlay({ src, onClose }: LocalVideoPlayerOverlayProps): JSX.Element { + return ( +
+
+ ); +} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/MediaHubSection.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/MediaHubSection.tsx index f6329cd03..179c66370 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/MediaHubSection.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/MediaHubSection.tsx @@ -47,9 +47,6 @@ export function MediaHubSection({ Options
-
- Captures -
; + spotlightShelfTranslateX: number; + spotlightEntries: SpotlightEntry[]; + spotlightIndex: number; + /** When true, spotlight row receives focus styling and tile `active` state. */ + spotlightPlaneActive: boolean; + shelfLabel: string; + ariaLabel?: string; +} + +export function SpotlightShelfBand({ + spotlightTrackRef, + spotlightShelfTranslateX, + spotlightEntries, + spotlightIndex, + spotlightPlaneActive, + shelfLabel, + ariaLabel = "Recently played games", +}: SpotlightShelfBandProps): JSX.Element { + return ( +
+
+ {shelfLabel} +
+
+
+ {spotlightEntries.map((entry, idx) => { + const isActive = spotlightPlaneActive && idx === spotlightIndex; + if (entry.kind === "cloudResume") { + return ( +
+
+ {entry.coverUrl ? ( + + ) : ( +
+ )} +
+ {entry.busy ? "Connecting…" : "Resume"} +
+
+
+ ); + } + const game = entry.game; + const key = game ? game.id : `recent-empty-${idx}`; + return ( +
+
+ {game?.imageUrl ? :
} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/StreamMenuMicLevelField.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/StreamMenuMicLevelField.tsx new file mode 100644 index 000000000..70778a31c --- /dev/null +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/StreamMenuMicLevelField.tsx @@ -0,0 +1,69 @@ +import type { JSX } from "react"; +import { useRef } from "react"; +import { useMicMeter } from "../../../hooks/useMicMeter"; + +export function StreamMenuMicLevelField({ + streamMenuMicLevel, + onStreamMenuMicLevelChange, + editingStreamMicLevel, + isRowSelected, + micTrack, + controllerType, +}: { + streamMenuMicLevel?: number; + onStreamMenuMicLevelChange?: (value: number) => void; + editingStreamMicLevel: boolean; + isRowSelected: boolean; + micTrack?: MediaStreamTrack | null; + controllerType: "ps" | "xbox" | "nintendo" | "generic"; +}): JSX.Element { + const canvasRef = useRef(null); + const trackLive = Boolean(micTrack && micTrack.readyState === "live"); + const meterActive = trackLive && (editingStreamMicLevel || isRowSelected); + useMicMeter(canvasRef, micTrack ?? null, meterActive); + + return ( +
+
+ onStreamMenuMicLevelChange?.(Math.max(0, Math.min(1, Number(e.target.value) / 100)))} + aria-label="Microphone level" + style={editingStreamMicLevel ? { outline: "2px solid rgba(255,255,255,0.2)" } : undefined} + /> + + {`${Math.round((streamMenuMicLevel ?? 1) * 100)}%`} + {editingStreamMicLevel ? " • Editing ←/→" : controllerType === "ps" ? " • □ to adjust" : " • X to adjust"} + +
+
+ + Send level (what others hear) + + + {!trackLive ? ( + + No send audio — unmute mic or check permissions to test + + ) : null} +
+
+ ); +} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx index 00fb06412..626d9a575 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/TopLevelMenuTrack.tsx @@ -1,6 +1,7 @@ import type { JSX, RefObject } from "react"; import { PREVIEW_TILE_COUNT, SHELF_IMAGE_PROPS } from "./constants"; import { clampRgbByte } from "./helpers"; +import { StreamMenuMicLevelField } from "./StreamMenuMicLevelField"; interface TopLevelMenuTrackProps { itemsContainerRef: RefObject; @@ -11,6 +12,8 @@ interface TopLevelMenuTrackProps { topLevelShelfIndex: number; gameCategoryPreviewById: Record; currentStreamingImageUrl?: string; + /** Home “Featured” tile poster (separate from resume snapshot). */ + featuredPreviewImageUrl?: string | null; settingsSubcategory: string; editingBandwidth: boolean; maxBitrateMbpsForTrack: number; @@ -25,6 +28,8 @@ interface TopLevelMenuTrackProps { onStreamMenuVolumeChange?: ((value: number) => void) | undefined; editingStreamVolume: boolean; controllerType: "ps" | "xbox" | "nintendo" | "generic"; + /** Live capture track while streaming; drives the mic test meter on the Mic level row. */ + streamMicTrack?: MediaStreamTrack | null; } export function TopLevelMenuTrack({ @@ -36,6 +41,7 @@ export function TopLevelMenuTrack({ topLevelShelfIndex, gameCategoryPreviewById, currentStreamingImageUrl, + featuredPreviewImageUrl = null, settingsSubcategory, editingBandwidth, maxBitrateMbpsForTrack, @@ -50,6 +56,7 @@ export function TopLevelMenuTrack({ onStreamMenuVolumeChange, editingStreamVolume, controllerType, + streamMicTrack = null, }: TopLevelMenuTrackProps): JSX.Element { return (
@@ -66,14 +73,16 @@ export function TopLevelMenuTrack({ const themeChannelForRow = item.id === "themeR" ? "r" : item.id === "themeG" ? "g" : item.id === "themeB" ? "b" : null; const isGameRootTile = topCategory === "all" && gameSubcategory === "root"; const isCurrentResumeTile = topCategory === "current" && item.id === "resume"; + const isCurrentFeaturedTile = topCategory === "current" && item.id === "featured"; const isSettingsTile = topCategory === "settings"; const previewThumbs = isGameRootTile ? (gameCategoryPreviewById[item.id] ?? []) : []; return (
{isCurrentResumeTile ? (
@@ -83,7 +92,19 @@ export function TopLevelMenuTrack({
)}
- Live Snapshot + Last played +
+
+ ) : null} + {isCurrentFeaturedTile ? ( +
+ {featuredPreviewImageUrl ? ( + + ) : ( +
+ )} +
+ Featured
) : null} @@ -99,7 +120,7 @@ export function TopLevelMenuTrack({ ))}
) : null} -
{item.label}
+ {isCurrentResumeTile || isCurrentFeaturedTile ? null :
{item.label}
} {item.value ? (
{item.id === "bandwidth" && settingsSubcategory !== "root" ? ( @@ -134,22 +155,14 @@ export function TopLevelMenuTrack({
) : item.id === "streamMicLevel" && inStreamMenu ? ( -
- onStreamMenuMicLevelChange?.(Math.max(0, Math.min(1, Number(e.target.value) / 100)))} - aria-label="Microphone level" - style={editingStreamMicLevel ? { outline: "2px solid rgba(255,255,255,0.2)" } : undefined} - /> - - {`${Math.round((streamMenuMicLevel ?? 1) * 100)}%`} - {editingStreamMicLevel ? " • Editing ←/→" : controllerType === "ps" ? " • □ to adjust" : " • X to adjust"} - -
+ ) : item.id === "streamVolume" && inStreamMenu ? (
; topLevelShelfIndex: number; - currentStreamingGame?: GameInfo | null; + currentTabGame?: GameInfo | null; + featuredHomeGame?: GameInfo | null; + /** When focused tile is featured, true if that game is in favorites. */ + featuredIsFavorite?: boolean; playtimeData: PlaytimeStore; gamesDualShelf: boolean; + homeDualShelf?: boolean; + inStreamMenu?: boolean; + subscriptionInfo?: SubscriptionInfo | null; cloudSessionResumable?: boolean; onResumeCloudSession?: () => void; spotlightTrackRef: RefObject; @@ -34,13 +42,19 @@ export function TopLevelShelfSection({ topCategory, gameSubcategory, gamesRootPlane, + homeRootPlane = "spotlight", spotlightEntries, spotlightIndex, displayItems, topLevelShelfIndex, - currentStreamingGame, + currentTabGame, + featuredHomeGame = null, + featuredIsFavorite = false, playtimeData, gamesDualShelf, + homeDualShelf = false, + inStreamMenu = false, + subscriptionInfo = null, cloudSessionResumable, onResumeCloudSession, spotlightTrackRef, @@ -49,11 +63,35 @@ export function TopLevelShelfSection({ }: TopLevelShelfSectionProps): JSX.Element | null { if (!topLevelShelfActive) return null; + const showDualShelf = gamesDualShelf || homeDualShelf; + const gamesSpotlightPlane = gamesRootPlane === "spotlight"; + const homeSpotlightPlane = homeRootPlane === "spotlight"; + const shelfLabel = + cloudSessionResumable && onResumeCloudSession ? "Resume & recently played" : "Recently played"; + + const focusedId = displayItems[topLevelShelfIndex]?.id; + return (

{selectedTopLevelItemLabel}

- {topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight" ? ( + {topCategory === "all" && gameSubcategory === "root" && gamesDualShelf && gamesSpotlightPlane ? ( +

+ {(() => { + const se = spotlightEntries[spotlightIndex]; + if (se?.kind === "cloudResume") { + return se.busy + ? "Resuming your cloud session…" + : "Active cloud session · Enter continues from where you left off"; + } + if (spotlightEntryHasGame(se)) { + return "Recently played"; + } + return "Recently played · Empty slot — play games to fill your shelf"; + })()} +

+ ) : null} + {topCategory === "current" && homeSpotlightPlane && homeDualShelf ? (

{(() => { const se = spotlightEntries[spotlightIndex]; @@ -63,16 +101,17 @@ export function TopLevelShelfSection({ : "Active cloud session · Enter continues from where you left off"; } if (spotlightEntryHasGame(se)) { - return "Recently played · Enter opens this title in your library"; + return "Recently played"; } return "Recently played · Empty slot — play games to fill your shelf"; })()}

) : null} - {topCategory === "current" && displayItems[topLevelShelfIndex]?.id === "resume" && currentStreamingGame ? ( + {topCategory === "current" && !inStreamMenu ? : null} + {topCategory === "current" && focusedId === "resume" && currentTabGame ? (
{(() => { - const record = playtimeData[currentStreamingGame.id]; + const record = playtimeData[currentTabGame.id]; const totalSecs = record?.totalSeconds ?? 0; const lastPlayedAt = record?.lastPlayedAt ?? null; const sessionCount = record?.sessionCount ?? 0; @@ -97,79 +136,54 @@ export function TopLevelShelfSection({ })()}
) : null} + {topCategory === "current" && focusedId === "featured" && featuredHomeGame ? ( +
+ {(() => { + const record = playtimeData[featuredHomeGame.id]; + const totalSecs = record?.totalSeconds ?? 0; + const genres = featuredHomeGame.genres?.filter(Boolean).slice(0, 2).join(" · "); + return ( + <> + {totalSecs === 0 ? ( + Never played + ) : ( + + + {formatPlaytime(totalSecs)} + + )} + + + {featuredIsFavorite ? "Favorite · featured pick" : "Featured pick"} + + {genres ? ( + {genres} + ) : null} + + ); + })()} +
+ ) : null}
- {gamesDualShelf ? ( + {showDualShelf ? (
-
-
- - {cloudSessionResumable && onResumeCloudSession ? "Resume & recently played" : "Recently played"} - -
-
-
- {spotlightEntries.map((entry, idx) => { - const isActive = gamesRootPlane === "spotlight" && idx === spotlightIndex; - if (entry.kind === "cloudResume") { - return ( -
-
- {entry.coverUrl ? ( - - ) : ( -
- )} -
- {entry.busy ? "Connecting…" : "Resume"} -
-
-
- ); - } - const game = entry.game; - const key = game ? game.id : `recent-empty-${idx}`; - return ( -
-
- {game?.imageUrl ? :
} -
-
- ); - })} -
-
-
+
{topLevelMenuTrack}
) : ( - <> - {topCategory === "current" ? ( -
- Current -
- ) : null} -
{topLevelMenuTrack}
- +
{topLevelMenuTrack}
)}
); diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/actionRouter.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/actionRouter.ts index 8c56e944d..a3f0f6a7b 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/actionRouter.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/actionRouter.ts @@ -69,7 +69,11 @@ export function routeCancel(context: { if (context.topCategory === "media" && context.media) { return handleMediaCancelAction(context.media); } - if (context.topCategory === "all" && context.all) { + if ( + context.all && + (context.topCategory === "all" || + (context.topCategory === "current" && context.all.gamesHubOpen)) + ) { return handleAllCancelAction(context.all); } return false; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/allCategoryActions.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/allCategoryActions.ts index 3e5431d70..eba1ad4c7 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/allCategoryActions.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/allCategoryActions.ts @@ -107,6 +107,8 @@ export function handleAllCancelAction(context: AllCancelContext): boolean { throttledOnSelectGame, lastRootGameIndex, playUiSound, + setCategoryIndex, + setHomeRootPlane, } = context; if (gamesHubOpen) { @@ -123,6 +125,12 @@ export function handleAllCancelAction(context: AllCancelContext): boolean { if (snap.restoreSelectedGameId) { throttledOnSelectGame(snap.restoreSelectedGameId); } + if (snap.restoreCategoryIndex != null && setCategoryIndex) { + setCategoryIndex(snap.restoreCategoryIndex); + } + if (snap.restoreHomeRootPlane != null && setHomeRootPlane) { + setHomeRootPlane(snap.restoreHomeRootPlane); + } } return true; } diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/contracts.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/contracts.ts index 78fee38b1..178ca359e 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/contracts.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/contracts.ts @@ -2,6 +2,7 @@ import type { ControllerThemeStyle, GameInfo, MediaListingEntry, Settings } from import type { GameSubcategory, GamesHubReturnSnapshot, + HomeRootPlane, LibrarySortId, MediaSubcategory, SettingsSubcategory, @@ -19,11 +20,16 @@ export interface OptionsActionContext { topCategory: TopCategory; gameSubcategory: GameSubcategory; gamesRootPlane: "spotlight" | "categories"; + /** When false, Games root has no spotlight row. */ + gamesDualShelf?: boolean; spotlightEntries: SpotlightEntry[]; spotlightIndex: number; selectedMediaIndex: number; mediaAssetItems: MediaListingEntry[]; selectedGame: GameInfo | null; + /** When Game Hub is open from Home, the focused title (Games browse uses `selectedGame`). */ + gamesHubDisplayGame?: GameInfo | null; + gamesHubOpen?: boolean; currentStreamingGame?: GameInfo | null; favoriteGameIdSet: Set; setOptionsEntries: (entries: OptionEntry[]) => void; @@ -31,6 +37,11 @@ export interface OptionsActionContext { setOptionsOpen: (open: boolean) => void; playUiSound: (kind: SoundKind) => void; spotlightEntryHasGame: (entry: SpotlightEntry | undefined) => entry is { kind: "recent"; game: GameInfo }; + /** When the in-app video player is open, options apply to this file path. */ + localVideoFilePathForOptions: string | null; + bumpMediaListRefresh: () => void; + closeLocalVideoPlayer: () => void; + setSelectedMediaIndex: (updater: (prev: number) => number) => void; } export interface SettingsActivateContext { @@ -93,6 +104,8 @@ export interface MediaActivateContext { setSelectedMediaIndex: (index: number) => void; mediaAssetItems: MediaListingEntry[]; playUiSound: (kind: SoundKind) => void; + /** In-app playback for Media > Videos (orchestrated outside this module). */ + openLocalVideoPlayer: (entry: MediaListingEntry) => void; } export interface MediaCancelContext { @@ -144,6 +157,8 @@ export interface AllCancelContext { setSpotlightIndex: (index: number) => void; throttledOnSelectGame: (id: string) => void; playUiSound: (kind: SoundKind) => void; + setCategoryIndex?: (index: number) => void; + setHomeRootPlane?: (plane: HomeRootPlane) => void; } export type ControllerLibrarySettings = { @@ -161,4 +176,5 @@ export type ControllerLibrarySettings = { maxBitrateMbps?: number; controllerThemeStyle?: ControllerThemeStyle; controllerThemeColor?: { r: number; g: number; b: number }; + controllerLibraryGameBackdrop?: boolean; }; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/mediaActions.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/mediaActions.ts index 270a06973..9acc210fd 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/mediaActions.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/mediaActions.ts @@ -1,3 +1,4 @@ +import { isPlayableVideoFilePath } from "@shared/mediaPlayback"; import type { MediaActivateContext, MediaCancelContext } from "./contracts"; export function handleMediaActivateAction(context: MediaActivateContext): boolean { @@ -10,6 +11,7 @@ export function handleMediaActivateAction(context: MediaActivateContext): boolea setSelectedMediaIndex, mediaAssetItems, playUiSound, + openLocalVideoPlayer, } = context; const item = displayItems[selectedMediaIndex]; @@ -23,6 +25,10 @@ export function handleMediaActivateAction(context: MediaActivateContext): boolea if (mediaSubcategory !== "root") { const selectedMedia = mediaAssetItems[selectedMediaIndex]; + if (mediaSubcategory === "Videos" && selectedMedia && isPlayableVideoFilePath(selectedMedia.filePath)) { + openLocalVideoPlayer(selectedMedia); + return true; + } if (selectedMedia && typeof window.openNow?.showMediaInFolder === "function") { void window.openNow.showMediaInFolder({ filePath: selectedMedia.filePath }); playUiSound("confirm"); diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts index 67a2e1fff..de3b2fa19 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/optionsActions.ts @@ -5,6 +5,8 @@ export function openOptionsMenuAction(context: OptionsActionContext): boolean { const { gamesShelfBrowseActive, selectedGame, + gamesHubDisplayGame = null, + gamesHubOpen = false, currentStreamingGame, favoriteGameIdSet, mediaShelfBrowseActive, @@ -13,6 +15,7 @@ export function openOptionsMenuAction(context: OptionsActionContext): boolean { topCategory, gameSubcategory, gamesRootPlane, + gamesDualShelf = true, spotlightEntries, spotlightIndex, spotlightEntryHasGame, @@ -20,24 +23,47 @@ export function openOptionsMenuAction(context: OptionsActionContext): boolean { setOptionsFocusIndex, setOptionsOpen, playUiSound, + localVideoFilePathForOptions, } = context; const entries: Array<{ id: string; label: string }> = []; - if (gamesShelfBrowseActive && selectedGame) { + const gameForHubOptions = + gamesShelfBrowseActive && selectedGame + ? selectedGame + : topCategory === "current" && gamesHubOpen && gamesHubDisplayGame + ? gamesHubDisplayGame + : null; + if (gameForHubOptions) { entries.push({ id: "play", - label: currentStreamingGame && currentStreamingGame.id !== selectedGame.id ? "Switch" : "Play", + label: currentStreamingGame && currentStreamingGame.id !== gameForHubOptions.id ? "Switch" : "Play", }); entries.push({ id: "favorite", - label: favoriteGameIdSet.has(selectedGame.id) ? "Remove favorite" : "Add favorite", + label: favoriteGameIdSet.has(gameForHubOptions.id) ? "Remove favorite" : "Add favorite", }); - if (selectedGame.variants.length > 1) { + if (gameForHubOptions.variants.length > 1) { entries.push({ id: "variant", label: "Change version" }); } - } else if (mediaShelfBrowseActive && mediaAssetItems[selectedMediaIndex]) { - entries.push({ id: "openFolder", label: "Open folder" }); - } else if (topCategory === "all" && gameSubcategory === "root" && gamesRootPlane === "spotlight" && spotlightEntryHasGame(spotlightEntries[spotlightIndex])) { + } else if ( + (mediaShelfBrowseActive && mediaAssetItems[selectedMediaIndex]) || + (typeof localVideoFilePathForOptions === "string" && localVideoFilePathForOptions.length > 0) + ) { + const mediaPath = localVideoFilePathForOptions ?? mediaAssetItems[selectedMediaIndex]?.filePath; + if (!mediaPath) { + /* noop */ + } else { + entries.push({ id: "openFolder", label: "Open folder" }); + entries.push({ id: "mediaDelete", label: "Delete File" }); + entries.push({ id: "mediaRegenThumb", label: "Regen Thumbnail" }); + } + } else if ( + topCategory === "all" && + gameSubcategory === "root" && + gamesDualShelf && + gamesRootPlane === "spotlight" && + spotlightEntryHasGame(spotlightEntries[spotlightIndex]) + ) { entries.push({ id: "openLibrary", label: "View in library" }); } if (entries.length === 0) return false; @@ -77,6 +103,9 @@ export function handleOptionsActivateAction(context: OptionsActivateContext): bo optionsEntries, optionsFocusIndex, selectedGame, + gamesHubDisplayGame = null, + gamesHubOpen = false, + topCategory, onPlayGame, gamesHubReturnSnapshotRef, setGamesHubOpen, @@ -97,8 +126,15 @@ export function handleOptionsActivateAction(context: OptionsActivateContext): bo setGamesHubFocusIndex, setPs5Row, playUiSound, + localVideoFilePathForOptions, + bumpMediaListRefresh, + closeLocalVideoPlayer, + setSelectedMediaIndex, } = context; + const mediaOptionsFilePath = (): string | null => + localVideoFilePathForOptions ?? mediaAssetItems[selectedMediaIndex]?.filePath ?? null; + if (optionsEntries.length === 0) return false; const opt = optionsEntries[optionsFocusIndex]; if (!opt) return true; @@ -107,37 +143,69 @@ export function handleOptionsActivateAction(context: OptionsActivateContext): bo playUiSound("move"); return true; } - if (opt.id === "play" && selectedGame) { - onPlayGame(selectedGame); + const gameForOption = + topCategory === "current" && gamesHubOpen && gamesHubDisplayGame ? gamesHubDisplayGame : selectedGame; + if (opt.id === "play" && gameForOption) { + onPlayGame(gameForOption); gamesHubReturnSnapshotRef.current = null; setGamesHubOpen(false); setOptionsOpen(false); playUiSound("confirm"); return true; } - if (opt.id === "favorite" && selectedGame) { - onToggleFavoriteGame(selectedGame.id); + if (opt.id === "favorite" && gameForOption) { + onToggleFavoriteGame(gameForOption.id); setOptionsOpen(false); playUiSound("confirm"); return true; } - if (opt.id === "variant" && 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); + if (opt.id === "variant" && gameForOption && gameForOption.variants.length > 1) { + const idx = gameForOption.variants.findIndex((v) => v.id === selectedVariantId); + const next = gameForOption.variants[(idx + 1) % gameForOption.variants.length]; + onSelectGameVariant(gameForOption.id, next.id); setOptionsOpen(false); playUiSound("confirm"); return true; } if (opt.id === "openFolder") { - const cur = mediaAssetItems[selectedMediaIndex]; - if (cur && typeof window.openNow?.showMediaInFolder === "function") { - void window.openNow.showMediaInFolder({ filePath: cur.filePath }); + const fp = mediaOptionsFilePath(); + if (fp && typeof window.openNow?.showMediaInFolder === "function") { + void window.openNow.showMediaInFolder({ filePath: fp }); } setOptionsOpen(false); playUiSound("confirm"); return true; } + if (opt.id === "mediaDelete") { + const fp = mediaOptionsFilePath(); + if (!fp || typeof window.openNow?.deleteMediaFile !== "function") return true; + void window.openNow.deleteMediaFile({ filePath: fp }).then((r) => { + if (r.ok) { + setOptionsOpen(false); + closeLocalVideoPlayer(); + bumpMediaListRefresh(); + setSelectedMediaIndex((i) => Math.max(0, i - 1)); + playUiSound("confirm"); + } else { + playUiSound("move"); + } + }); + return true; + } + if (opt.id === "mediaRegenThumb") { + const fp = mediaOptionsFilePath(); + if (!fp || typeof window.openNow?.regenMediaThumbnail !== "function") return true; + void window.openNow.regenMediaThumbnail({ filePath: fp }).then((r) => { + if (r.ok) { + setOptionsOpen(false); + bumpMediaListRefresh(); + playUiSound("confirm"); + } else { + playUiSound("move"); + } + }); + return true; + } if (opt.id === "openLibrary") { const entry = spotlightEntries[spotlightIndex]; const game = spotlightEntryHasGame(entry) ? entry.game : null; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/settingsActions.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/settingsActions.ts index fccd0bd23..de650cc01 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/settingsActions.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/actions/settingsActions.ts @@ -57,6 +57,14 @@ export function handleSettingsActivateAction(context: SettingsActivateContext): } if (settingsSubcategory === "Theme") { const item = displayItems[selectedSettingIndex]; + if (item?.id === "libraryGameBackdrop" && onSettingChange) { + onSettingChange( + "controllerLibraryGameBackdrop" as never, + !(settings.controllerLibraryGameBackdrop !== false) as never, + ); + playUiSound("confirm"); + return true; + } if (item?.id === "themeColor") { setLastThemeRootIndex(selectedSettingIndex); setSettingsSubcategory("ThemeColor"); diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts index e18f76425..bde30fd6d 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/actionHandlers.ts @@ -60,6 +60,7 @@ export function createActionHandlers( codecOptions, aspectRatioOptions, currentStreamingGame, + currentTabGame, onResumeGame, onResumeCloudSession, onCloseGame, @@ -83,6 +84,7 @@ export function createActionHandlers( favoriteGameIdSet, microphoneDevices, gamesHubOpen, + gamesHubDisplayGame, gamesHubFocusIndex, gamesHubTiles, inStreamMenu, @@ -119,7 +121,17 @@ export function createActionHandlers( setLibrarySortId, setCategoryIndex, setGamesRootPlane, + setHomeRootPlane, setSpotlightIndex, + featuredHomeGame, + homeDualShelf, + gamesDualShelf, + homeRootPlane, + localVideoPlayerOpen, + closeLocalVideoPlayer, + openLocalVideoPlayer, + localVideoFilePathForOptions, + bumpMediaListRefresh, } = ctx; const openOptionsMenu = (): void => { @@ -129,11 +141,14 @@ export function createActionHandlers( topCategory, gameSubcategory, gamesRootPlane, + gamesDualShelf, spotlightEntries, spotlightIndex, selectedMediaIndex, mediaAssetItems, selectedGame, + gamesHubDisplayGame, + gamesHubOpen, currentStreamingGame, favoriteGameIdSet, setOptionsEntries, @@ -141,10 +156,15 @@ export function createActionHandlers( setOptionsOpen, playUiSound, spotlightEntryHasGame, + localVideoFilePathForOptions, + bumpMediaListRefresh, + closeLocalVideoPlayer, + setSelectedMediaIndex, }); }; const onSecondaryActivate = (): void => { + if (localVideoPlayerOpen) return; if (optionsOpen) return; if (gamesHubOpen) return; if (routeSecondaryActivate({ @@ -198,6 +218,8 @@ export function createActionHandlers( selectedMediaIndex, mediaAssetItems, selectedGame, + gamesHubDisplayGame, + gamesHubOpen, currentStreamingGame, favoriteGameIdSet, setOptionsEntries, @@ -217,16 +239,26 @@ export function createActionHandlers( setGamesHubFocusIndex, setPs5Row, gamesHubReturnSnapshotRef, + localVideoFilePathForOptions, + bumpMediaListRefresh, + closeLocalVideoPlayer, + setSelectedMediaIndex, })) return; + if (localVideoPlayerOpen) return; - if (gamesHubOpen && topCategory === "all" && gameSubcategory !== "root" && selectedGame) { + if ( + gamesHubOpen && + gamesHubDisplayGame && + ((topCategory === "all" && gameSubcategory !== "root") || topCategory === "current") + ) { const tile = gamesHubTiles[gamesHubFocusIndex]; + const hubGame = gamesHubDisplayGame; if (!tile || tile.disabled) { playUiSound("move"); return; } if (tile.id === "play") { - onPlayGame(selectedGame); + onPlayGame(hubGame); gamesHubReturnSnapshotRef.current = null; setGamesHubOpen(false); setGamesHubFocusIndex(0); @@ -234,14 +266,14 @@ export function createActionHandlers( return; } if (tile.id === "favorite") { - onToggleFavoriteGame(selectedGame.id); + onToggleFavoriteGame(hubGame.id); playUiSound("confirm"); return; } - if (tile.id === "version" && selectedGame.variants.length > 1) { - const idx = selectedGame.variants.findIndex((v: { id: string }) => v.id === selectedVariantId); - const next = selectedGame.variants[(idx + 1) % selectedGame.variants.length]; - onSelectGameVariant(selectedGame.id, next.id); + if (tile.id === "version" && hubGame.variants.length > 1) { + const idx = hubGame.variants.findIndex((v: { id: string }) => v.id === selectedVariantId); + const next = hubGame.variants[(idx + 1) % hubGame.variants.length]; + onSelectGameVariant(hubGame.id, next.id); playUiSound("confirm"); return; } @@ -307,10 +339,51 @@ export function createActionHandlers( return; } + if (topCategory === "current" && homeDualShelf && homeRootPlane === "spotlight") { + const entry = spotlightEntries[spotlightIndex]; + if (entry?.kind === "cloudResume") { + if (!entry.busy && onResumeCloudSession) { + onResumeCloudSession(); + playUiSound("confirm"); + } else { + playUiSound("move"); + } + return; + } + if (spotlightEntryHasGame(entry)) { + const game = entry.game; + if (game) { + gamesHubReturnSnapshotRef.current = { + gameSubcategory, + selectedGameSubcategoryIndex, + gamesRootPlane, + spotlightIndex, + restoreSelectedGameId: game.id, + restoreHomeRootPlane: homeRootPlane, + }; + throttledOnSelectGame(game.id); + setGamesHubOpen(true); + setGamesHubFocusIndex(0); + setPs5Row("main"); + playUiSound("confirm"); + } else { + playUiSound("move"); + } + return; + } + playUiSound("move"); + return; + } + if (topCategory === "current") { const item = displayItems[selectedSettingIndex]; - if (item?.id === "resume" && currentStreamingGame && onResumeGame) { - onResumeGame(currentStreamingGame); + if (item?.id === "featured" && featuredHomeGame && onPlayGame) { + onPlayGame(featuredHomeGame); + playUiSound("confirm"); + return; + } + if (item?.id === "resume" && currentTabGame && onResumeGame) { + onResumeGame(currentTabGame); playUiSound("confirm"); return; } @@ -379,6 +452,7 @@ export function createActionHandlers( setSelectedMediaIndex, mediaAssetItems, playUiSound, + openLocalVideoPlayer, } : undefined, all: topCategory === "all" ? { gameSubcategory, @@ -427,6 +501,12 @@ export function createActionHandlers( e.preventDefault(); return; } + if (localVideoPlayerOpen) { + closeLocalVideoPlayer(); + playUiSound("move"); + e.preventDefault(); + return; + } if (inStreamMenu && endSessionConfirm) { setEndSessionConfirm(false); playUiSound("move"); @@ -478,7 +558,10 @@ export function createActionHandlers( e.preventDefault(); return; } - if (topCategory === "all" && gameSubcategory !== "root") { + if ( + (topCategory === "all" && gameSubcategory !== "root") || + (topCategory === "current" && gamesHubOpen) + ) { routeCancel({ topCategory, all: { @@ -494,6 +577,10 @@ export function createActionHandlers( setSpotlightIndex, throttledOnSelectGame, playUiSound, + setCategoryIndex: (idx: number) => { + setCategoryIndex(idx); + }, + setHomeRootPlane, }, }); e.preventDefault(); @@ -549,12 +636,12 @@ export function createActionHandlers( onTertiaryActivate(); return; } - if (e.key.toLowerCase() === "q" && topLevelRowBehaviorActive && !gamesHubOpen) { + if (e.key.toLowerCase() === "q" && topLevelRowBehaviorActive && !gamesHubOpen && !localVideoPlayerOpen) { e.preventDefault(); cycleTopCategory(-1); return; } - if (e.key.toLowerCase() === "e" && topLevelRowBehaviorActive && !gamesHubOpen) { + if (e.key.toLowerCase() === "e" && topLevelRowBehaviorActive && !gamesHubOpen && !localVideoPlayerOpen) { e.preventDefault(); cycleTopCategory(1); return; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/navigationHandlers.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/navigationHandlers.ts index e87e563d9..a3e74786a 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/navigationHandlers.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/events/navigationHandlers.ts @@ -57,10 +57,13 @@ export function createNavigationHandlers( optionsFocusIndex, optionsEntries, gamesRootPlane, + homeRootPlane, spotlightIndex, spotlightEntries, gamesDualShelf, + homeDualShelf, gamesHubOpen, + gamesHubDisplayGame, gamesHubFocusIndex, gamesHubTiles, onSettingChange, @@ -80,9 +83,11 @@ export function createNavigationHandlers( setPs5Row, setDetailRailIndex, setGamesRootPlane, + setHomeRootPlane, setSpotlightIndex, gamesHubReturnSnapshotRef, setGamesHubOpen, + localVideoPlayerOpen, } = ctx; const cycleTopCategory = (delta: number): void => { @@ -93,6 +98,7 @@ export function createNavigationHandlers( setMediaSubcategory("root"); setSelectedGameSubcategoryIndex(0); setGameSubcategory("root"); + setHomeRootPlane("spotlight"); setEditingBandwidth(false); setEditingThemeChannel(null); setEditingStreamVolume(false); @@ -191,7 +197,15 @@ export function createNavigationHandlers( return; } - if (gamesHubOpen && topCategory === "all" && gameSubcategory !== "root") { + if (localVideoPlayerOpen) { + return; + } + + if ( + gamesHubOpen && + gamesHubDisplayGame && + ((topCategory === "all" && gameSubcategory !== "root") || topCategory === "current") + ) { const n = gamesHubTiles.length; if (n === 0) return; if (direction === "left") { @@ -222,6 +236,9 @@ export function createNavigationHandlers( if (topCategory === "all" && gameSubcategory === "root" && gamesDualShelf) { setGamesRootPlane("spotlight"); } + if (topCategory === "current" && homeDualShelf) { + setHomeRootPlane("spotlight"); + } return; } return; @@ -343,7 +360,17 @@ export function createNavigationHandlers( if (topLevelRowBehaviorActive) { const isGamesRoot = topCategory === "all" && gameSubcategory === "root"; + const isHomeDual = topCategory === "current" && homeDualShelf; const itemCount = displayItems.length; + if (isHomeDual && homeRootPlane === "spotlight" && (direction === "left" || direction === "right")) { + const delta = direction === "left" ? -1 : 1; + const next = Math.max(0, Math.min(spotlightEntries.length - 1, spotlightIndex + delta)); + if (next !== spotlightIndex) { + playUiSound("move"); + setSpotlightIndex(next); + } + return; + } if (isGamesRoot && gamesDualShelf && gamesRootPlane === "spotlight" && (direction === "left" || direction === "right")) { const delta = direction === "left" ? -1 : 1; const next = Math.max(0, Math.min(spotlightEntries.length - 1, spotlightIndex + delta)); @@ -367,6 +394,25 @@ export function createNavigationHandlers( } if (direction === "up" || direction === "down") { + if (isHomeDual) { + if (direction === "up") { + if (homeRootPlane === "actions") { + playUiSound("move"); + setHomeRootPlane("spotlight"); + return; + } + if (homeRootPlane === "spotlight" && canEnterTopRow) { + playUiSound("move"); + setPs5Row("top"); + return; + } + } + if (direction === "down" && homeRootPlane === "spotlight") { + playUiSound("move"); + setHomeRootPlane("actions"); + return; + } + } if (isGamesRoot && gamesDualShelf) { if (direction === "up") { if (gamesRootPlane === "categories") { @@ -421,6 +467,7 @@ export function createNavigationHandlers( setMediaSubcategory("root"); setSelectedGameSubcategoryIndex(0); setGameSubcategory("root"); + setHomeRootPlane("spotlight"); setEditingBandwidth(false); setEditingThemeChannel(null); return; @@ -434,6 +481,7 @@ export function createNavigationHandlers( setMediaSubcategory("root"); setSelectedGameSubcategoryIndex(0); setGameSubcategory("root"); + setHomeRootPlane("spotlight"); setEditingBandwidth(false); setEditingThemeChannel(null); return; @@ -511,6 +559,8 @@ export function createNavigationHandlers( const onShoulder = (e: any): void => { const direction = e?.detail?.direction as "prev" | "next" | undefined; if (!direction) return; + if (optionsOpen) return; + if (localVideoPlayerOpen) return; if (gamesHubOpen) return; if (topCategory === "settings" && settingsSubcategory !== "root") return; if (editingBandwidth || editingThemeChannel || editingStreamVolume || editingStreamMicLevel) return; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/helpers.tsx b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/helpers.tsx index 96438eb71..f801eb904 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/helpers.tsx +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/helpers.tsx @@ -73,8 +73,8 @@ export function isEditableTarget(target: EventTarget | null): boolean { return target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT"; } -export function getCategoryLabel(categoryId: string, currentGameTitle?: string): { label: string } { - if (categoryId === "current") return { label: currentGameTitle || "Current" }; +export function getCategoryLabel(categoryId: string): { label: string } { + if (categoryId === "current") return { label: "Home" }; if (categoryId === "all") return { label: "Games" }; if (categoryId === "settings") return { label: "Settings" }; if (categoryId === "media") return { label: "Media" }; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/loadGameScreenshotUrls.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/loadGameScreenshotUrls.ts new file mode 100644 index 000000000..770f6be8e --- /dev/null +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/loadGameScreenshotUrls.ts @@ -0,0 +1,31 @@ +/** + * Loads screenshot image URLs for a library game title (newest first). + * Uses embedded data URLs when present, otherwise resolves via getMediaThumbnail when available. + */ +export async function loadScreenshotUrlsForGameTitle(gameTitle: string): Promise { + const trimmed = gameTitle.trim(); + if (!trimmed) return []; + if (typeof window.openNow?.listMediaByGame !== "function") return []; + + try { + const listing = await window.openNow.listMediaByGame({ gameTitle: trimmed }); + const rows = [...(listing.screenshots ?? [])].sort((a, b) => b.createdAtMs - a.createdAtMs); + const urls: string[] = []; + + for (const s of rows) { + let u = s.thumbnailDataUrl || s.dataUrl; + if (!u && typeof window.openNow?.getMediaThumbnail === "function") { + try { + u = (await window.openNow.getMediaThumbnail({ filePath: s.filePath })) ?? undefined; + } catch { + u = undefined; + } + } + if (u) urls.push(u); + } + + return urls; + } catch { + return []; + } +} diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts index 8049ffea4..9cefb47ce 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/types.ts @@ -21,7 +21,6 @@ export interface ControllerLibraryPageProps { onResumeGame?: (game: GameInfo) => void; onCloseGame?: () => void; onExitApp?: () => void; - pendingSwitchGameCover?: string | null; settings?: { resolution?: string; fps?: number; @@ -38,6 +37,7 @@ export interface ControllerLibraryPageProps { maxBitrateMbps?: number; controllerThemeStyle?: ControllerThemeStyle; controllerThemeColor?: { r: number; g: number; b: number }; + controllerLibraryGameBackdrop?: boolean; }; resolutionOptions?: string[]; fpsOptions?: number[]; @@ -47,12 +47,13 @@ export interface ControllerLibraryPageProps { onExitControllerMode?: () => void; sessionStartedAtMs?: number | null; isStreaming?: boolean; - sessionCounterEnabled?: boolean; inStreamMenu?: boolean; streamMenuVolume?: number; onStreamMenuVolumeChange?: (volume01: number) => void; streamMenuMicLevel?: number; onStreamMenuMicLevelChange?: (level01: number) => void; + /** Live mic track from the streaming client; used for the in-stream mic level test meter. */ + streamMicTrack?: MediaStreamTrack | null; onStreamMenuToggleMicrophone?: () => void; onStreamMenuToggleFullscreen?: () => void; streamMenuMicOn?: boolean; @@ -72,12 +73,17 @@ export type MediaSubcategory = "root" | "Videos" | "Screenshots"; export type GameSubcategory = "root" | "all" | "favorites" | `genre:${string}`; export type LibrarySortId = "recent" | "az" | "za" | "favoritesFirst"; +export type HomeRootPlane = "spotlight" | "actions"; + export type GamesHubReturnSnapshot = { gameSubcategory: GameSubcategory; selectedGameSubcategoryIndex: number; gamesRootPlane: "spotlight" | "categories"; spotlightIndex: number; restoreSelectedGameId?: string; + /** When hub was opened from Home spotlight, restore this top tab on back. */ + restoreCategoryIndex?: number; + restoreHomeRootPlane?: HomeRootPlane; }; export type SpotlightEntry = diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts index b324849a6..82ca981fe 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryEvents.ts @@ -44,6 +44,7 @@ export function useControllerLibraryEvents( codecOptions, aspectRatioOptions, currentStreamingGame, + currentTabGame, onResumeGame, onResumeCloudSession, onCloseGame, @@ -64,12 +65,17 @@ export function useControllerLibraryEvents( optionsFocusIndex, optionsEntries, gamesRootPlane, + homeRootPlane, spotlightIndex, spotlightEntries, gamesDualShelf, + homeDualShelf, + categoryIndex, + featuredHomeGame, favoriteGameIdSet, microphoneDevices, gamesHubOpen, + gamesHubDisplayGame, gamesHubFocusIndex, gamesHubTiles, inStreamMenu, @@ -100,6 +106,7 @@ export function useControllerLibraryEvents( setPs5Row, setDetailRailIndex, setGamesRootPlane, + setHomeRootPlane, setSpotlightIndex, gamesHubReturnSnapshotRef, setGamesHubOpen, @@ -110,6 +117,11 @@ export function useControllerLibraryEvents( setLastThemeRootIndex, setLastRootMediaIndex, setLibrarySortId, + localVideoPlayerOpen, + closeLocalVideoPlayer, + openLocalVideoPlayer, + localVideoFilePathForOptions, + bumpMediaListRefresh, } = ctx; return useMemo(() => { @@ -147,6 +159,7 @@ export function useControllerLibraryEvents( codecOptions, aspectRatioOptions, currentStreamingGame, + currentTabGame, onResumeGame, onResumeCloudSession, onCloseGame, @@ -167,12 +180,17 @@ export function useControllerLibraryEvents( optionsFocusIndex, optionsEntries, gamesRootPlane, + homeRootPlane, spotlightIndex, spotlightEntries, gamesDualShelf, + homeDualShelf, + categoryIndex, + featuredHomeGame, favoriteGameIdSet, microphoneDevices, gamesHubOpen, + gamesHubDisplayGame, gamesHubFocusIndex, gamesHubTiles, inStreamMenu, @@ -203,6 +221,7 @@ export function useControllerLibraryEvents( setPs5Row, setDetailRailIndex, setGamesRootPlane, + setHomeRootPlane, setSpotlightIndex, gamesHubReturnSnapshotRef, setGamesHubOpen, @@ -213,6 +232,11 @@ export function useControllerLibraryEvents( setLastThemeRootIndex, setLastRootMediaIndex, setLibrarySortId, + localVideoPlayerOpen, + closeLocalVideoPlayer, + openLocalVideoPlayer, + localVideoFilePathForOptions, + bumpMediaListRefresh, }; const { applyDirection, cycleTopCategory, onDirection, onShoulder } = createNavigationHandlers(eventContext); @@ -266,6 +290,7 @@ export function useControllerLibraryEvents( codecOptions, aspectRatioOptions, currentStreamingGame, + currentTabGame, onResumeGame, onResumeCloudSession, onCloseGame, @@ -286,12 +311,17 @@ export function useControllerLibraryEvents( optionsFocusIndex, optionsEntries.length, gamesRootPlane, + homeRootPlane, spotlightIndex, spotlightEntries, gamesDualShelf, + homeDualShelf, + categoryIndex, + featuredHomeGame?.id, favoriteGameIdSet, microphoneDevices, gamesHubOpen, + gamesHubDisplayGame?.id, gamesHubFocusIndex, gamesHubTiles, inStreamMenu, @@ -304,6 +334,11 @@ export function useControllerLibraryEvents( onStreamMenuVolumeChange, onStreamMenuToggleMicrophone, onStreamMenuToggleFullscreen, + localVideoPlayerOpen, + closeLocalVideoPlayer, + openLocalVideoPlayer, + localVideoFilePathForOptions, + bumpMediaListRefresh, ]); } diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryGameDerivations.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryGameDerivations.ts index 807cd55b1..08246e062 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryGameDerivations.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryGameDerivations.ts @@ -16,6 +16,10 @@ type UseControllerLibraryGameDerivationsArgs = { playtimeData: PlaytimeStore; topCategory: TopCategory; currentStreamingGame?: GameInfo | null; + /** Shown as the first row label on the Home shelf (stream or last-played title). */ + homeShelfGameTitle?: string | null; + /** Resume / Home context game id — excluded from Featured pick. */ + resumeContextGameId?: string | null; gameSubcategory: GameSubcategory; selectedGameId: string; selectedVariantByGameId: Record; @@ -60,16 +64,29 @@ type UseControllerLibraryGameDerivationsResult = { selectedVariantId: string; selectedGameDescription: string; selectedGameSessionState: string | null; + featuredHomeGame: GameInfo | null; }; const isNonEmptyString = (value: string | undefined): value is string => typeof value === "string" && value.length > 0; +function pickFeaturedGameDeterministic(pool: GameInfo[]): GameInfo { + const sorted = [...pool].sort((a, b) => a.id.localeCompare(b.id)); + const seed = sorted.map((g) => g.id).join("|"); + let h = 0; + for (let i = 0; i < seed.length; i++) { + h = (h * 31 + seed.charCodeAt(i)) | 0; + } + return sorted[Math.abs(h) % sorted.length]!; +} + export function useControllerLibraryGameDerivations({ games, favoriteGameIds, playtimeData, topCategory, currentStreamingGame, + homeShelfGameTitle, + resumeContextGameId = null, gameSubcategory, selectedGameId, selectedVariantByGameId, @@ -89,6 +106,20 @@ export function useControllerLibraryGameDerivations({ const favoriteGameIdSet = useMemo(() => new Set(favoriteGameIds), [favoriteGameIds]); const favoriteGames = useMemo(() => games.filter((game) => favoriteGameIdSet.has(game.id)), [games, favoriteGameIdSet]); + const featuredHomeGame = useMemo((): GameInfo | null => { + const excludeId = resumeContextGameId ?? undefined; + const candidates = excludeId ? games.filter((g) => g.id !== excludeId) : [...games]; + if (candidates.length === 0) return null; + const playSecs = (id: string) => playtimeData[id]?.totalSeconds ?? 0; + const favUnplayed = candidates.filter((g) => favoriteGameIdSet.has(g.id) && playSecs(g.id) === 0); + if (favUnplayed.length > 0) return pickFeaturedGameDeterministic(favUnplayed); + const anyUnplayed = candidates.filter((g) => playSecs(g.id) === 0); + if (anyUnplayed.length > 0) return pickFeaturedGameDeterministic(anyUnplayed); + const favs = candidates.filter((g) => favoriteGameIdSet.has(g.id)); + if (favs.length > 0) return pickFeaturedGameDeterministic(favs); + return pickFeaturedGameDeterministic(candidates); + }, [games, favoriteGameIdSet, playtimeData, resumeContextGameId]); + const allGenres = useMemo(() => { const genreSet = new Set(); for (const game of games) { @@ -109,12 +140,28 @@ export function useControllerLibraryGameDerivations({ { id: "toggleFullscreen", label: "Fullscreen", value: streamMenuIsFullscreen ? "On" : "Off" }, ] : []; + const resumeLabel = homeShelfGameTitle?.trim() || "Last played"; + const homeHead: Array<{ id: string; label: string; value: string }> = [{ id: "resume", label: resumeLabel, value: "" }]; + if (!inStreamMenu && featuredHomeGame) { + homeHead.push({ id: "featured", label: featuredHomeGame.title, value: "" }); + } return [ - { id: "resume", label: "Resume Game", value: "" }, + ...homeHead, ...streamExtras, - { id: "closeGame", label: inStreamMenu && endSessionConfirm ? "End session (confirm)" : "Close Game", value: "" }, + ...(inStreamMenu + ? [{ id: "closeGame", label: endSessionConfirm ? "End session (confirm)" : "Close Game", value: "" }] + : []), ]; - }, [inStreamMenu, endSessionConfirm, streamMenuMicOn, streamMenuMicLevel, streamMenuVolume, streamMenuIsFullscreen]); + }, [ + inStreamMenu, + endSessionConfirm, + streamMenuMicOn, + streamMenuMicLevel, + streamMenuVolume, + streamMenuIsFullscreen, + homeShelfGameTitle, + featuredHomeGame, + ]); const mediaRootItems = useMemo( () => [ @@ -318,5 +365,6 @@ export function useControllerLibraryGameDerivations({ selectedVariantId, selectedGameDescription, selectedGameSessionState, + featuredHomeGame, }; } diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryLayoutMotion.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryLayoutMotion.ts index cfc944868..cf5350b8d 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryLayoutMotion.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryLayoutMotion.ts @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useState } from "react"; import type { CSSProperties, RefObject } from "react"; import { computeShelfTranslateXClamped, sanitizeControllerThemeStyle } from "./helpers"; import type { GameSubcategory, MediaSubcategory, SettingsSubcategory, TopCategory } from "./types"; @@ -15,6 +15,7 @@ type UseControllerLibraryLayoutMotionArgs = { selectedIndex: number; selectedMediaIndex: number; gamesDualShelf: boolean; + homeDualShelf: boolean; spotlightIndex: number; spotlightEntriesLength: number; itemsContainerRef: RefObject; @@ -35,8 +36,6 @@ type UseControllerLibraryLayoutMotionResult = { spotlightShelfTranslateX: number; gamesRootMenuTranslateX: number; heroTransitionMs: number; - metaMaxWidth: number | null; - attachPosterRef: (el: HTMLImageElement | null) => void; wrapperThemeVars: CSSProperties; wrapperClassNameWithRow: string; menuShelfTranslateX: number; @@ -54,6 +53,7 @@ export function useControllerLibraryLayoutMotion({ selectedIndex, selectedMediaIndex, gamesDualShelf, + homeDualShelf, spotlightIndex, spotlightEntriesLength, itemsContainerRef, @@ -69,34 +69,6 @@ export function useControllerLibraryLayoutMotion({ const [gamesRootMenuTranslateX, setGamesRootMenuTranslateX] = useState(0); const [viewportWidth, setViewportWidth] = useState(() => (typeof window === "undefined" ? 1200 : window.innerWidth)); const [heroTransitionMs, setHeroTransitionMs] = useState(420); - const [metaMaxWidth, setMetaMaxWidth] = useState(null); - const currentPosterImgRef = useRef(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); - } - }; useEffect(() => { if (typeof window === "undefined") { @@ -135,11 +107,13 @@ export function useControllerLibraryLayoutMotion({ useLayoutEffect(() => { const gamesRoot = topCategory === "all" && gameSubcategory === "root"; - if (!gamesRoot || !gamesDualShelf) { + const homeDualRoot = topCategory === "current" && homeDualShelf; + const dualShelfActive = (gamesRoot && gamesDualShelf) || homeDualRoot; + if (!dualShelfActive) { setSpotlightShelfTranslateX(0); setGamesRootMenuTranslateX(0); } - if (gamesRoot && gamesDualShelf) { + if (dualShelfActive) { setSpotlightShelfTranslateX(computeShelfTranslateXClamped(spotlightTrackRef.current, spotlightIndex)); setGamesRootMenuTranslateX(computeShelfTranslateXClamped(itemsContainerRef.current, topLevelShelfIndex)); setListTranslateY(0); @@ -174,6 +148,7 @@ export function useControllerLibraryLayoutMotion({ topCategory, gameSubcategory, gamesDualShelf, + homeDualShelf, spotlightIndex, spotlightEntriesLength, itemsContainerRef, @@ -196,7 +171,7 @@ export function useControllerLibraryLayoutMotion({ } as CSSProperties; const wrapperClassName = `xmb-wrapper xmb-theme-${themeStyleSafe} ${settings.controllerBackgroundAnimations ? "xmb-animate" : "xmb-static"} ${isEntering ? "xmb-entering" : "xmb-ready"} xmb-layout--ps5-home`; const wrapperClassNameWithRow = `${wrapperClassName} xmb-row-${ps5Row} ${topCategory === "settings" ? "xmb-ps5-section-settings" : ""} ${topCategory === "settings" && settingsSubcategory === "root" ? "xmb-ps5-settings-root" : ""} ${topCategory === "settings" && settingsSubcategory !== "root" ? "xmb-ps5-settings-sub" : ""}`; - const menuShelfTranslateX = gamesDualShelf ? gamesRootMenuTranslateX : listTranslateX; + const menuShelfTranslateX = gamesDualShelf || homeDualShelf ? gamesRootMenuTranslateX : listTranslateX; return { isEntering, @@ -205,8 +180,6 @@ export function useControllerLibraryLayoutMotion({ spotlightShelfTranslateX, gamesRootMenuTranslateX, heroTransitionMs, - metaMaxWidth, - attachPosterRef, wrapperThemeVars, wrapperClassNameWithRow, menuShelfTranslateX, diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryMedia.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryMedia.ts index 3abec020a..4d66e86c6 100644 --- a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryMedia.ts +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useControllerLibraryMedia.ts @@ -11,6 +11,8 @@ type UseControllerLibraryMediaArgs = { topCategory: TopCategory; mediaSubcategory: MediaSubcategory; selectedMediaIndex: number; + /** Increment to refetch listing and thumbnails after delete/regen. */ + mediaListRefreshNonce: number; }; type UseControllerLibraryMediaResult = { @@ -29,6 +31,7 @@ export function useControllerLibraryMedia({ topCategory, mediaSubcategory, selectedMediaIndex, + mediaListRefreshNonce, }: UseControllerLibraryMediaArgs): UseControllerLibraryMediaResult { const [mediaLoading, setMediaLoading] = useState(false); const [mediaError, setMediaError] = useState(null); @@ -92,7 +95,7 @@ export function useControllerLibraryMedia({ return () => { cancelled = true; }; - }, [topCategory, mediaSubcategory]); + }, [topCategory, mediaSubcategory, mediaListRefreshNonce]); const mediaAssetItems = useMemo(() => { if (mediaSubcategory === "Videos") return mediaVideos; diff --git a/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useLocalVideoPlayback.ts b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useLocalVideoPlayback.ts new file mode 100644 index 000000000..9867e4156 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/controllerMode/controllerLibrary/useLocalVideoPlayback.ts @@ -0,0 +1,48 @@ +import { useCallback, useState } from "react"; +import type { MediaListingEntry } from "@shared/gfn"; +import type { SoundKind } from "./types"; + +export type LocalVideoPlaybackState = { src: string; filePath: string } | null; + +export function useLocalVideoPlayback(playUiSound: (kind: SoundKind) => void): { + playback: LocalVideoPlaybackState; + localVideoPlayerOpen: boolean; + openFromEntry: (entry: MediaListingEntry) => Promise; + close: () => void; +} { + const [playback, setPlayback] = useState(null); + + const close = useCallback(() => { + setPlayback(null); + }, []); + + const openFromEntry = useCallback( + async (entry: MediaListingEntry) => { + if (typeof window.openNow?.getMediaPlaybackUrl !== "function") { + if (typeof window.openNow?.showMediaInFolder === "function") { + void window.openNow.showMediaInFolder({ filePath: entry.filePath }); + } + playUiSound("confirm"); + return; + } + const url = await window.openNow.getMediaPlaybackUrl({ filePath: entry.filePath }); + if (!url) { + if (typeof window.openNow?.showMediaInFolder === "function") { + void window.openNow.showMediaInFolder({ filePath: entry.filePath }); + } + playUiSound("confirm"); + return; + } + setPlayback({ src: url, filePath: entry.filePath }); + playUiSound("confirm"); + }, + [playUiSound], + ); + + return { + playback, + localVideoPlayerOpen: playback !== null, + openFromEntry, + close, + }; +} diff --git a/opennow-stable/src/renderer/src/gfn/microphoneManager.ts b/opennow-stable/src/renderer/src/gfn/microphoneManager.ts index 7131beb38..451399f03 100644 --- a/opennow-stable/src/renderer/src/gfn/microphoneManager.ts +++ b/opennow-stable/src/renderer/src/gfn/microphoneManager.ts @@ -29,10 +29,17 @@ export class MicrophoneManager { private onStateChangeCallback: ((state: MicStateChange) => void) | null = null; private sampleRate: number = 48000; // Official client uses 48kHz + /** Web Audio graph: raw getUserMedia → GainNode → track sent to WebRTC (`volume` constraints are rarely supported for input). */ + private micProcessCtx: AudioContext | null = null; + private micMediaSource: MediaStreamAudioSourceNode | null = null; + private micGainNode: GainNode | null = null; + private outboundMicTrack: MediaStreamTrack | null = null; + // Track if we should auto-retry with different devices on failure private attemptedDevices: Set = new Set(); private readonly handleMicStreamInactive = (): void => { console.log("[Microphone] Stream inactive"); + this.tearDownMicProcessing(); this.detachMicStreamListeners(this.micStream); this.attemptedDevices.clear(); this.micStream = null; @@ -94,12 +101,24 @@ export class MicrophoneManager { if (!this.pc) { return; } - const track = this.micStream?.getAudioTracks()[0]; - if (!track) { - await this.ensurePlaceholderSender(); + if (this.outboundMicTrack && this.outboundMicTrack.readyState === "live") { + await this.addTrackToPeerConnection(this.outboundMicTrack); return; } - await this.addTrackToPeerConnection(track); + if (this.micStream) { + const out = this.buildMicProcessingPipeline(this.micStream); + if (out) { + await this.addTrackToPeerConnection(out); + return; + } + const raw = this.micStream.getAudioTracks()[0]; + if (raw) { + await this.addTrackToPeerConnection(raw); + void this.applyTrackMicLevel(raw); + return; + } + } + await this.ensurePlaceholderSender(); } /** @@ -199,11 +218,15 @@ export class MicrophoneManager { console.log("[Microphone] Track ended"); this.stop(); }; - await this.applyTrackMicLevel(track); - // Add track to peer connection if available + const outbound = this.buildMicProcessingPipeline(stream); + const sendTrack = outbound ?? track; + if (!outbound) { + void this.applyTrackMicLevel(track); + } + if (this.pc) { - await this.addTrackToPeerConnection(track); + await this.addTrackToPeerConnection(sendTrack); } this.setState("started", track.label); @@ -269,9 +292,13 @@ export class MicrophoneManager { console.log("[Microphone] Track ended"); this.stop(); }; - await this.applyTrackMicLevel(track); - if (this.pc && track) { - await this.addTrackToPeerConnection(track); + const outbound = this.buildMicProcessingPipeline(stream); + const sendTrack = outbound ?? track; + if (!outbound) { + void this.applyTrackMicLevel(track); + } + if (this.pc && sendTrack) { + await this.addTrackToPeerConnection(sendTrack); } this.setState("started", track?.label); return; @@ -404,7 +431,73 @@ export class MicrophoneManager { } } + private tearDownMicProcessing(): void { + try { + this.micMediaSource?.disconnect(); + this.micMediaSource = null; + this.micGainNode?.disconnect(); + this.micGainNode = null; + this.outboundMicTrack = null; + const ctx = this.micProcessCtx; + this.micProcessCtx = null; + if (ctx && ctx.state !== "closed") { + void ctx.close(); + } + } catch { + // ignore + } + } + + /** + * Build mic → GainNode → MediaStreamDestination; {@link outboundMicTrack} is what we attach to WebRTC. + * Raw {@link micStream} remains for mute state and {@link getTrack} (UI meter). + */ + private buildMicProcessingPipeline(rawStream: MediaStream): MediaStreamTrack | null { + this.tearDownMicProcessing(); + if (!rawStream.getAudioTracks()[0]) { + return null; + } + + const AudioCtx = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!AudioCtx) { + return null; + } + + try { + let ctx: AudioContext; + try { + ctx = new AudioCtx({ sampleRate: this.sampleRate }); + } catch { + ctx = new AudioCtx(); + } + void ctx.resume().catch(() => undefined); + + const src = ctx.createMediaStreamSource(rawStream); + const gain = ctx.createGain(); + gain.gain.value = this.micLevel; + const dest = ctx.createMediaStreamDestination(); + src.connect(gain).connect(dest); + + const out = dest.stream.getAudioTracks()[0] ?? null; + if (!out) { + void ctx.close(); + return null; + } + + this.micProcessCtx = ctx; + this.micMediaSource = src; + this.micGainNode = gain; + this.outboundMicTrack = out; + return out; + } catch (e) { + console.warn("[Microphone] Web Audio gain pipeline failed:", e); + this.tearDownMicProcessing(); + return null; + } + } + private clearMicStream(): void { + this.tearDownMicProcessing(); if (!this.micStream) { return; } @@ -440,9 +533,14 @@ export class MicrophoneManager { setMicLevel(level01: number): void { this.micLevel = Math.max(0, Math.min(1, Number.isFinite(level01) ? level01 : 1)); + if (this.micGainNode) { + this.micGainNode.gain.value = this.micLevel; + return; + } const track = this.micStream?.getAudioTracks()[0] ?? null; - if (!track) return; - void this.applyTrackMicLevel(track); + if (track) { + void this.applyTrackMicLevel(track); + } } getMicLevel(): number { @@ -499,9 +597,14 @@ export class MicrophoneManager { } /** - * Get active microphone track if available + * MediaStreamTrack suitable for metering and local monitoring: post-gain audio that matches + * what is attached to the WebRTC sender when the processing pipeline is active; otherwise + * the raw capture track (fallback when Web Audio routing failed). */ getTrack(): MediaStreamTrack | null { + if (this.outboundMicTrack && this.outboundMicTrack.readyState === "live") { + return this.outboundMicTrack; + } return this.micStream?.getAudioTracks()[0] ?? null; } diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index 076dba36e..34fb1caff 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -542,6 +542,8 @@ export class GfnWebRtcClient { private controlChannel: RTCDataChannel | null = null; private audioContext: AudioContext | null = null; private audioSourceNode: MediaStreamAudioSourceNode | null = null; + private audioGainNode: GainNode | null = null; + private outputVolume = 1; private inputReady = false; /** When true, the host (e.g. in-stream controller menu) blocks forwarding; not cleared by focus/visibility. */ @@ -730,6 +732,7 @@ export class GfnWebRtcClient { options.videoElement.srcObject = this.videoStream; options.audioElement.srcObject = this.audioStream; options.audioElement.muted = true; + options.audioElement.volume = this.outputVolume; this.mouseSensitivity = options.mouseSensitivity ?? 1; this.mouseAccelerationPercent = Math.max(1, Math.min(150, Math.round(options.mouseAcceleration ?? 1))); this.autoFullScreenEnabled = options.autoFullScreen !== false; @@ -1663,6 +1666,15 @@ export class GfnWebRtcClient { this.audioSourceNode = null; } + if (this.audioGainNode) { + try { + this.audioGainNode.disconnect(); + } catch { + // Ignore cleanup errors from an already-disconnected node. + } + this.audioGainNode = null; + } + if (this.audioContext) { void this.audioContext.close().catch(() => {}); this.audioContext = null; @@ -1675,6 +1687,7 @@ export class GfnWebRtcClient { private startDirectAudioPlayback(reason: string): void { this.log(reason); this.options.audioElement.muted = false; + this.options.audioElement.volume = this.outputVolume; this.options.audioElement .play() .then(() => { @@ -1792,6 +1805,7 @@ export class GfnWebRtcClient { // matching what the official GFN browser client does for low-latency playback. let audioContext: AudioContext | null = null; let audioSourceNode: MediaStreamAudioSourceNode | null = null; + let audioGainNode: GainNode | null = null; try { audioContext = new AudioContext({ @@ -1799,7 +1813,10 @@ export class GfnWebRtcClient { sampleRate: 48000, }); audioSourceNode = audioContext.createMediaStreamSource(this.audioStream); - audioSourceNode.connect(audioContext.destination); + audioGainNode = audioContext.createGain(); + audioGainNode.gain.value = this.outputVolume; + audioSourceNode.connect(audioGainNode); + audioGainNode.connect(audioContext.destination); // Resume the context (browsers require user gesture, but Electron is more lenient) if (audioContext.state === "suspended") { @@ -1808,6 +1825,7 @@ export class GfnWebRtcClient { this.audioContext = audioContext; this.audioSourceNode = audioSourceNode; + this.audioGainNode = audioGainNode; this.log( `Audio routed through AudioContext (latency: ${(audioContext.baseLatency * 1000).toFixed(1)}ms, sampleRate: ${audioContext.sampleRate}Hz)`, ); @@ -1819,6 +1837,13 @@ export class GfnWebRtcClient { // Ignore cleanup errors from a partially-created node. } } + if (audioGainNode) { + try { + audioGainNode.disconnect(); + } catch { + // Ignore cleanup errors from a partially-created node. + } + } if (audioContext) { void audioContext.close().catch(() => {}); } @@ -4295,6 +4320,15 @@ export class GfnWebRtcClient { this.micManager.setMicLevel(level01); } + setOutputVolume(volume: number): void { + const next = Math.max(0, Math.min(1, Number.isFinite(volume) ? volume : 1)); + this.outputVolume = next; + this.options.audioElement.volume = next; + if (this.audioGainNode) { + this.audioGainNode.gain.value = next; + } + } + getMicrophoneLevel(): number { return this.micManager?.getMicLevel() ?? 1; } @@ -4314,8 +4348,8 @@ export class GfnWebRtcClient { } /** - * Return the live audio track from the microphone stream, or null if - * the mic has not been started or has been stopped. + * Live audio track for UI metering / local recording mix: post-gain send path when available + * (same levels the remote session hears), else raw capture. */ getMicTrack(): MediaStreamTrack | null { return this.micManager?.getTrack() ?? null; diff --git a/opennow-stable/src/renderer/src/hooks/useMicMeter.ts b/opennow-stable/src/renderer/src/hooks/useMicMeter.ts new file mode 100644 index 000000000..eafd5e531 --- /dev/null +++ b/opennow-stable/src/renderer/src/hooks/useMicMeter.ts @@ -0,0 +1,121 @@ +import type { RefObject } from "react"; +import { useEffect, useRef } from "react"; + +/** + * Draws a segmented RMS level meter for a live microphone track (time-domain). + * Shared by stream sidebar and controller in-stream mic level row. + */ +export function useMicMeter( + canvasRef: RefObject, + track: MediaStreamTrack | null, + active: boolean, +): void { + const pendingCloseRef = useRef | null>(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!active || !track || !canvas) return; + + const ctx2d = canvas.getContext("2d"); + if (!ctx2d) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(canvas.clientWidth * dpr); + canvas.height = Math.round(canvas.clientHeight * dpr); + const W = canvas.width; + const H = canvas.height; + if (W <= 0 || H <= 0) { + return; + } + + let audioCtx: AudioContext | null = null; + let source: MediaStreamAudioSourceNode | null = null; + let analyser: AnalyserNode | null = null; + let tickTimer: number | null = null; + let dead = false; + + const start = async () => { + if (pendingCloseRef.current) { + try { + await pendingCloseRef.current; + } catch { + // Ignore close errors from previous contexts. + } + } + if (dead) { + return; + } + + try { + audioCtx = new AudioContext(); + await audioCtx.resume().catch(() => undefined); + if (dead) { + return; + } + + analyser = audioCtx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.65; + source = audioCtx.createMediaStreamSource(new MediaStream([track])); + source.connect(analyser); + + const buf = new Uint8Array(analyser.frequencyBinCount); + const SEG = 20; + const GAP = Math.round(2 * dpr); + const bw = (W - GAP * (SEG - 1)) / SEG; + const radius = Math.min(3 * dpr, bw / 2); + const frameIntervalMs = 33; + + const frame = () => { + if (dead || !analyser) return; + tickTimer = window.setTimeout(frame, frameIntervalMs); + analyser.getByteTimeDomainData(buf); + + let sum = 0; + for (let i = 0; i < buf.length; i++) { + const v = ((buf[i] ?? 128) - 128) / 128; + sum += v * v; + } + const rms = Math.sqrt(sum / buf.length); + const level = Math.min(1, rms * 5.5); + const filled = Math.round(level * SEG); + + ctx2d.clearRect(0, 0, W, H); + for (let i = 0; i < SEG; i++) { + const x = i * (bw + GAP); + if (i < filled) { + ctx2d.fillStyle = + i < SEG * 0.7 ? "#58d98a" : i < SEG * 0.9 ? "#fbbf24" : "#f87171"; + } else { + ctx2d.fillStyle = "rgba(255,255,255,0.07)"; + } + ctx2d.beginPath(); + ctx2d.roundRect(x, 0, Math.max(1, bw), H, radius); + ctx2d.fill(); + } + }; + + frame(); + } catch (e) { + console.warn("[MicMeter]", e); + } + }; + + void start(); + + return () => { + dead = true; + if (tickTimer !== null) { + window.clearTimeout(tickTimer); + } + source?.disconnect(); + analyser?.disconnect(); + if (audioCtx && audioCtx.state !== "closed") { + pendingCloseRef.current = audioCtx + .close() + .catch(() => undefined) + .then(() => undefined); + } + }; + }, [track, active, canvasRef]); +} diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 6427ccc0c..17e609bd9 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -5028,6 +5028,12 @@ button.game-card-store-chip.owned.active:hover { border-radius: var(--r-md); z-index: 1001; color: var(--accent); + transition: opacity 0.4s var(--ease); +} + +.sv-ctrl--hidden { + opacity: 0; + pointer-events: none; } .sv-ctrl-n { @@ -7482,6 +7488,19 @@ button.game-card-store-chip.owned.active:hover { height: clamp(340px, 44vh, 520px); } +/* + * Home / Settings / Media root: menu strip is not games-root, so it used the default shelf + * viewport (hard max-height ~330px + overflow:hidden). That clipped the Live Snapshot tile + * below the preview image — not an object-fit issue. Shrink-wrap height so the full card fits. + */ +.xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport:not(.xmb-ps5-shelf-viewport--games-root) { + height: auto; + min-height: clamp(240px, 30vh, 330px); + max-height: min(520px, calc(100dvh - 160px)); + overflow-x: hidden; + overflow-y: auto; +} + /* Non–dual-shelf games root: never exceed viewport; scroll if the window is very short */ .xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport--games-root { height: min(clamp(300px, 40vh, 520px), calc(100dvh - 200px)); @@ -7677,6 +7696,14 @@ button.game-card-store-chip.owned.active:hover { height: clamp(196px, 25.5vh, 300px); } + .xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport:not(.xmb-ps5-shelf-viewport--games-root) { + height: auto; + min-height: clamp(196px, 25.5vh, 300px); + max-height: min(480px, calc(100dvh - 140px)); + overflow-x: hidden; + overflow-y: auto; + } + .xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport--games-root { bottom: 114px; height: clamp(268px, 36vh, 420px); @@ -7867,6 +7894,14 @@ button.game-card-store-chip.owned.active:hover { height: clamp(172px, 22vh, 260px); } + .xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport:not(.xmb-ps5-shelf-viewport--games-root) { + height: auto; + min-height: clamp(172px, 22vh, 260px); + max-height: min(420px, calc(100dvh - 110px)); + overflow-x: hidden; + overflow-y: auto; + } + .xmb-layout--ps5-home .xmb-ps5-stack > .xmb-ps5-shelf-viewport--games-root { bottom: 100px; height: clamp(220px, 30vh, 340px); @@ -8359,18 +8394,28 @@ button.game-card-store-chip.owned.active:hover { .xmb-ps5-menu-resume-preview { position: relative; width: 100%; - aspect-ratio: 16 / 9; + display: flex; + align-items: center; + justify-content: center; + /* Taller than 16:9 so “Live Snapshot” reads more like a hero still */ + aspect-ratio: 16 / 10; + min-height: clamp(150px, 20vh, 240px); border-radius: 12px; overflow: hidden; + background: rgba(6, 10, 14, 0.92); border: 1px solid rgba(255, 255, 255, 0.24); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.44); - margin-bottom: 10px; + margin-bottom: 20px; } .xmb-ps5-menu-resume-image { - width: 100%; - height: 100%; - object-fit: cover; + flex: 0 1 auto; + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + object-fit: contain; + object-position: center center; display: block; } @@ -8919,6 +8964,8 @@ button.game-card-store-chip.owned.active:hover { padding-left: 0; transition: transform 600ms cubic-bezier(0.22, 1, 0.36, 1); pointer-events: none; + /* Above .xmb-ps5-stack (5) / focus meta (6) so Home · Settings · Games · Media stay visible */ + z-index: 20; } .xmb-category-item { @@ -9959,3 +10006,55 @@ button.game-card-store-chip.owned.active:hover { font-size: 0.88rem; } } + +/* Local video playback (Media hub) */ +.xmb-local-video-overlay { + position: fixed; + inset: 0; + z-index: 20000; + display: flex; + align-items: center; + justify-content: center; + pointer-events: auto; +} + +.xmb-local-video-backdrop { + position: absolute; + inset: 0; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + background: rgba(2, 6, 10, 0.92); +} + +.xmb-local-video-panel { + position: relative; + z-index: 1; + width: min(96vw, 1200px); + max-height: min(92vh, 900px); + display: flex; + flex-direction: column; + padding: 10px; + border-radius: 16px; + background: rgba(12, 18, 24, 0.96); + border: 1px solid rgba(255, 255, 255, 0.14); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.65); +} + +.xmb-local-video-frame { + flex: 1; + min-height: 200px; + display: flex; + align-items: center; + justify-content: center; + background: #000; + border-radius: 12px; + overflow: hidden; +} + +.xmb-local-video-element { + width: 100%; + max-height: min(72vh, 760px); + vertical-align: middle; +} diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 737b26845..7f3fda76d 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -173,6 +173,11 @@ export interface Settings { controllerThemeStyle: ControllerThemeStyle; /** Controller-mode library background tint (applied per style preset) */ controllerThemeColor: ControllerThemeRgb; + /** + * When true, controller library/hub/loading layers may show art from the focused game or shelf. + * Theme color/style presets still apply when false. + */ + controllerLibraryGameBackdrop: boolean; /** When true, the app will automatically enter fullscreen when controller mode triggers it */ autoFullScreen: boolean; favoriteGameIds: string[]; @@ -853,6 +858,15 @@ export interface OpenNowApi { /** Reveal a media file path in the system file manager */ showMediaInFolder(input: { filePath: string }): Promise; + /** Trusted file:// URL for in-app playback of a video under OpenNOW media root, or null */ + getMediaPlaybackUrl(input: { filePath: string }): Promise; + + /** Delete a media file under the OpenNOW pictures root (recordings, screenshots, etc.) */ + deleteMediaFile(input: { filePath: string }): Promise<{ ok: boolean }>; + + /** Invalidate cached / sidecar thumbnails and regenerate (returns data URL when possible) */ + regenMediaThumbnail(input: { filePath: string }): Promise<{ ok: boolean; thumbnailDataUrl: string | null }>; + deleteCache(): Promise; /** Fetch current GFN queue wait times from the PrintedWaste API */ diff --git a/opennow-stable/src/shared/ipc.ts b/opennow-stable/src/shared/ipc.ts index 6c49a4449..8ee3f6cff 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -64,6 +64,9 @@ export const IPC_CHANNELS = { MEDIA_LIST_BY_GAME: "media:list-by-game", MEDIA_THUMBNAIL: "media:thumbnail", MEDIA_SHOW_IN_FOLDER: "media:show-in-folder", + MEDIA_PLAYBACK_URL: "media:playback-url", + MEDIA_DELETE_FILE: "media:delete-file", + MEDIA_REGEN_THUMBNAIL: "media:regen-thumbnail", // PrintedWaste queue integration PRINTEDWASTE_QUEUE_FETCH: "printedwaste:queue-fetch", PRINTEDWASTE_SERVER_MAPPING_FETCH: "printedwaste:server-mapping-fetch", diff --git a/opennow-stable/src/shared/mediaPlayback.ts b/opennow-stable/src/shared/mediaPlayback.ts new file mode 100644 index 000000000..ebacc8c52 --- /dev/null +++ b/opennow-stable/src/shared/mediaPlayback.ts @@ -0,0 +1,7 @@ +/** Extensions the app treats as local video files for in-app playback and thumbnails. Keep in sync with main media handling. */ +export const PLAYABLE_VIDEO_EXTENSIONS = [".mp4", ".webm", ".mkv", ".mov"] as const; + +export function isPlayableVideoFilePath(filePath: string): boolean { + const lower = filePath.toLowerCase(); + return PLAYABLE_VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext)); +}