diff --git a/opennow-stable/src/main/gfn/signaling.ts b/opennow-stable/src/main/gfn/signaling.ts index c15e2c4..341c794 100644 --- a/opennow-stable/src/main/gfn/signaling.ts +++ b/opennow-stable/src/main/gfn/signaling.ts @@ -4,6 +4,7 @@ import WebSocket from "ws"; import type { IceCandidatePayload, + KeyframeRequest, MainToRendererSignalingEvent, SendAnswerRequest, } from "@shared/gfn"; @@ -271,6 +272,25 @@ export class GfnSignalingClient { }); } + async requestKeyframe(payload: KeyframeRequest): Promise { + this.sendJson({ + peer_msg: { + from: this.peerId, + to: 1, + msg: JSON.stringify({ + type: "request_keyframe", + reason: payload.reason, + backlogFrames: payload.backlogFrames, + attempt: payload.attempt, + }), + }, + ackid: this.nextAckId(), + }); + console.log( + `[Signaling] Sent keyframe request (reason=${payload.reason}, backlog=${payload.backlogFrames}, attempt=${payload.attempt})`, + ); + } + disconnect(): void { this.clearHeartbeat(); if (this.ws) { diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index 8219783..ba65e9e 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -1,11 +1,13 @@ -import { app, BrowserWindow, ipcMain, dialog, systemPreferences, session } from "electron"; +import { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, session } from "electron"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, createWriteStream } from "node:fs"; +import { copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"; import * as net from "node:net"; +import { randomUUID } from "node:crypto"; // Keyboard shortcuts reference (matching Rust implementation): -// F11 - Toggle fullscreen (handled in main process) +// Screenshot keybind - configurable, handled in renderer // F3 - Toggle stats overlay (handled in renderer) // Ctrl+Shift+Q - Stop streaming (handled in renderer) // F8 - Toggle mouse/pointer lock (handled in main process via IPC) @@ -26,11 +28,25 @@ import type { SignalingConnectRequest, SendAnswerRequest, IceCandidatePayload, + KeyframeRequest, Settings, SubscriptionFetchRequest, SessionConflictChoice, PingResult, StreamRegion, + VideoAccelerationPreference, + ScreenshotDeleteRequest, + ScreenshotEntry, + ScreenshotSaveAsRequest, + ScreenshotSaveAsResult, + ScreenshotSaveRequest, + RecordingEntry, + RecordingBeginRequest, + RecordingBeginResult, + RecordingChunkRequest, + RecordingFinishRequest, + RecordingAbortRequest, + RecordingDeleteRequest, } from "@shared/gfn"; import { getSettingsManager, type SettingsManager } from "./settings"; @@ -53,9 +69,12 @@ const __dirname = dirname(__filename); // Configure Chromium video and WebRTC behavior before app.whenReady(). // Video acceleration is always set to "auto" - decoder and encoder preferences removed from settings -const bootstrapVideoPrefs = { - decoderPreference: "auto" as const, - encoderPreference: "auto" as const, +const bootstrapVideoPrefs: { + decoderPreference: VideoAccelerationPreference; + encoderPreference: VideoAccelerationPreference; +} = { + decoderPreference: "auto", + encoderPreference: "auto", }; console.log( `[Main] Video acceleration: decode=${bootstrapVideoPrefs.decoderPreference}, encode=${bootstrapVideoPrefs.encoderPreference}`, @@ -102,6 +121,8 @@ if (process.platform === "win32") { app.commandLine.appendSwitch("enable-features", [ + // --- MP4 recording via MediaRecorder (Chromium 127+) --- + "MediaRecorderEnableMp4Muxer", // --- AV1 support (cross-platform) --- "Dav1dVideoDecoder", // Fast AV1 software fallback via dav1d (if no HW decoder) // --- Additional (cross-platform) --- @@ -157,6 +178,232 @@ let signalingClient: GfnSignalingClient | null = null; let signalingClientKey: string | null = null; let authService: AuthService; let settingsManager: SettingsManager; +const SCREENSHOT_LIMIT = 60; + +function getScreenshotDirectory(): string { + return join(app.getPath("pictures"), "OpenNOW", "Screenshots"); +} + +async function ensureScreenshotDirectory(): Promise { + const dir = getScreenshotDirectory(); + await mkdir(dir, { recursive: true }); + return dir; +} + +function sanitizeTitleForFileName(value: string | undefined): string { + const source = (value ?? "").trim().toLowerCase(); + const compact = source.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + if (!compact) return "stream"; + return compact.slice(0, 48); +} + +function dataUrlToBuffer(dataUrl: string): { ext: "png" | "jpg" | "webp"; buffer: Buffer } { + const match = /^data:image\/(png|jpeg|jpg|webp);base64,([a-z0-9+/=\s]+)$/i.exec(dataUrl); + if (!match || !match[1] || !match[2]) { + throw new Error("Invalid screenshot payload"); + } + + const rawExt = match[1].toLowerCase(); + const ext: "png" | "jpg" | "webp" = rawExt === "jpeg" ? "jpg" : (rawExt as "png" | "jpg" | "webp"); + const buffer = Buffer.from(match[2].replace(/\s+/g, ""), "base64"); + if (!buffer.length) { + throw new Error("Empty screenshot payload"); + } + + return { ext, buffer }; +} + +function buildScreenshotDataUrl(ext: string, buffer: Buffer): string { + const mime = ext === "jpg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png"; + return `data:${mime};base64,${buffer.toString("base64")}`; +} + +function assertSafeScreenshotId(id: string): void { + if (!id || id.includes("/") || id.includes("\\") || id.includes("..")) { + throw new Error("Invalid screenshot id"); + } +} + +async function listScreenshots(): Promise { + const dir = await ensureScreenshotDirectory(); + const entries = await readdir(dir, { withFileTypes: true }); + const screenshotFiles = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter((name) => /\.(png|jpg|jpeg|webp)$/i.test(name)); + + const loaded = await Promise.all( + screenshotFiles.map(async (fileName): Promise => { + const filePath = join(dir, fileName); + try { + const fileStats = await stat(filePath); + const fileBuffer = await readFile(filePath); + const extMatch = /\.([^.]+)$/.exec(fileName); + const ext = (extMatch?.[1] ?? "png").toLowerCase(); + + return { + id: fileName, + fileName, + filePath, + createdAtMs: fileStats.birthtimeMs || fileStats.mtimeMs, + sizeBytes: fileStats.size, + dataUrl: buildScreenshotDataUrl(ext, fileBuffer), + }; + } catch { + return null; + } + }), + ); + + return loaded + .filter((item): item is ScreenshotEntry => item !== null) + .sort((a, b) => b.createdAtMs - a.createdAtMs) + .slice(0, SCREENSHOT_LIMIT); +} + +async function saveScreenshot(input: ScreenshotSaveRequest): Promise { + const { ext, buffer } = dataUrlToBuffer(input.dataUrl); + const dir = await ensureScreenshotDirectory(); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const title = sanitizeTitleForFileName(input.gameTitle); + const fileName = `${stamp}-${title}-${Math.random().toString(16).slice(2, 8)}.${ext}`; + const filePath = join(dir, fileName); + + await writeFile(filePath, buffer); + + return { + id: fileName, + fileName, + filePath, + createdAtMs: Date.now(), + sizeBytes: buffer.byteLength, + dataUrl: buildScreenshotDataUrl(ext, buffer), + }; +} + +async function deleteScreenshot(input: ScreenshotDeleteRequest): Promise { + assertSafeScreenshotId(input.id); + const dir = await ensureScreenshotDirectory(); + const filePath = join(dir, input.id); + await unlink(filePath); +} + +async function saveScreenshotAs(input: ScreenshotSaveAsRequest): Promise { + assertSafeScreenshotId(input.id); + const dir = await ensureScreenshotDirectory(); + const sourcePath = join(dir, input.id); + + const saveDialogOptions = { + title: "Save Screenshot", + defaultPath: join(app.getPath("pictures"), input.id), + filters: [ + { name: "PNG Image", extensions: ["png"] }, + { name: "JPEG Image", extensions: ["jpg", "jpeg"] }, + { name: "WebP Image", extensions: ["webp"] }, + { name: "All Files", extensions: ["*"] }, + ], + }; + const target = + mainWindow && !mainWindow.isDestroyed() + ? await dialog.showSaveDialog(mainWindow, saveDialogOptions) + : await dialog.showSaveDialog(saveDialogOptions); + + if (target.canceled || !target.filePath) { + return { saved: false }; + } + + await copyFile(sourcePath, target.filePath); + return { saved: true, filePath: target.filePath }; +} + +// --------------------------------------------------------------------------- +// Recording helpers +// --------------------------------------------------------------------------- + +const RECORDING_LIMIT = 20; + +interface ActiveRecording { + writeStream: ReturnType; + tempPath: string; + mimeType: string; +} + +const activeRecordings = new Map(); + +function getRecordingsDirectory(): string { + return join(app.getPath("pictures"), "OpenNOW", "Recordings"); +} + +async function ensureRecordingsDirectory(): Promise { + const dir = getRecordingsDirectory(); + await mkdir(dir, { recursive: true }); + return dir; +} + +function assertSafeRecordingId(id: string): void { + if (!id || id.includes("/") || id.includes("\\") || id.includes("..")) { + throw new Error("Invalid recording id"); + } +} + +function extFromMimeType(mimeType: string): ".mp4" | ".webm" { + return mimeType.startsWith("video/mp4") ? ".mp4" : ".webm"; +} + +async function listRecordings(): Promise { + const dir = await ensureRecordingsDirectory(); + const entries = await readdir(dir, { withFileTypes: true }); + const webmFiles = entries + .filter((e) => e.isFile()) + .map((e) => e.name) + .filter((name) => /\.(mp4|webm)$/i.test(name)); + + const loaded = await Promise.all( + webmFiles.map(async (fileName): Promise => { + const filePath = join(dir, fileName); + try { + const fileStats = await stat(filePath); + const stem = fileName.replace(/\.webm$/i, ""); + const thumbName = `${stem}-thumb.jpg`; + const thumbPath = join(dir, thumbName); + + let thumbnailDataUrl: string | undefined; + try { + const thumbBuf = await readFile(thumbPath); + thumbnailDataUrl = `data:image/jpeg;base64,${thumbBuf.toString("base64")}`; + } catch { + // No thumbnail for this recording — that's fine + } + + // Parse durationMs encoded in filename as last numeric segment before extension + const durMatch = /-dur(\d+)\.(mp4|webm)$/i.exec(fileName); + const durationMs = durMatch ? Number(durMatch[1]) : 0; + + // Parse game title from filename: {stamp}-{title}-{rand}[-dur{ms}].{ext} + const titleMatch = /^[^-]+-[^-]+-([^-]+(?:-[^-]+)*?)-[a-f0-9]{6}(?:-dur\d+)?\.(mp4|webm)$/i.exec(fileName); + const gameTitle = titleMatch ? titleMatch[1].replace(/-/g, " ") : undefined; + + return { + id: fileName, + fileName, + filePath, + createdAtMs: fileStats.birthtimeMs || fileStats.mtimeMs, + sizeBytes: fileStats.size, + durationMs, + gameTitle, + thumbnailDataUrl, + }; + } catch { + return null; + } + }), + ); + + return loaded + .filter((item): item is RecordingEntry => item !== null) + .sort((a, b) => b.createdAtMs - a.createdAtMs) + .slice(0, RECORDING_LIMIT); +} function emitToRenderer(event: MainToRendererSignalingEvent): void { if (mainWindow && !mainWindow.isDestroyed()) { @@ -186,18 +433,6 @@ async function createMainWindow(): Promise { }, }); - // Handle F11 fullscreen toggle — send to renderer so it uses W3C Fullscreen API - // (which enables navigator.keyboard.lock for Escape key capture) - mainWindow.webContents.on("before-input-event", (event, input) => { - if (input.key === "F11" && input.type === "keyDown") { - event.preventDefault(); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("app:toggle-fullscreen"); - } - } - - }); - if (process.platform === "win32") { // Keep native window fullscreen in sync with HTML fullscreen so Windows treats // stream playback like a real fullscreen window instead of only DOM fullscreen. @@ -441,6 +676,13 @@ function registerIpcHandlers(): void { return signalingClient.sendIceCandidate(payload); }); + ipcMain.handle(IPC_CHANNELS.REQUEST_KEYFRAME, async (_event, payload: KeyframeRequest) => { + if (!signalingClient) { + throw new Error("Signaling is not connected"); + } + return signalingClient.requestKeyframe(payload); + }); + // Toggle fullscreen via IPC (for completeness) ipcMain.handle(IPC_CHANNELS.TOGGLE_FULLSCREEN, async () => { if (mainWindow && !mainWindow.isDestroyed()) { @@ -474,6 +716,142 @@ function registerIpcHandlers(): void { return exportLogs(format); }); + ipcMain.handle(IPC_CHANNELS.SCREENSHOT_SAVE, async (_event, input: ScreenshotSaveRequest): Promise => { + return saveScreenshot(input); + }); + + ipcMain.handle(IPC_CHANNELS.SCREENSHOT_LIST, async (): Promise => { + return listScreenshots(); + }); + + ipcMain.handle(IPC_CHANNELS.SCREENSHOT_DELETE, async (_event, input: ScreenshotDeleteRequest): Promise => { + return deleteScreenshot(input); + }); + + ipcMain.handle( + IPC_CHANNELS.SCREENSHOT_SAVE_AS, + async (_event, input: ScreenshotSaveAsRequest): Promise => { + return saveScreenshotAs(input); + }, + ); + + ipcMain.handle(IPC_CHANNELS.RECORDING_BEGIN, async (_event, input: RecordingBeginRequest): Promise => { + const dir = await ensureRecordingsDirectory(); + const recordingId = randomUUID(); + const ext = extFromMimeType(input.mimeType); + const tempPath = join(dir, `${recordingId}${ext}.tmp`); + const writeStream = createWriteStream(tempPath); + activeRecordings.set(recordingId, { writeStream, tempPath, mimeType: input.mimeType }); + return { recordingId }; + }); + + ipcMain.handle(IPC_CHANNELS.RECORDING_CHUNK, async (_event, input: RecordingChunkRequest): Promise => { + const rec = activeRecordings.get(input.recordingId); + if (!rec) { + throw new Error("Unknown recording id"); + } + await new Promise((resolve, reject) => { + rec.writeStream.write(Buffer.from(input.chunk), (err) => { + if (err) reject(err); + else resolve(); + }); + }); + }); + + ipcMain.handle(IPC_CHANNELS.RECORDING_FINISH, async (_event, input: RecordingFinishRequest): Promise => { + const rec = activeRecordings.get(input.recordingId); + if (!rec) { + throw new Error("Unknown recording id"); + } + activeRecordings.delete(input.recordingId); + + await new Promise((resolve, reject) => { + rec.writeStream.end((err?: Error | null) => { + if (err) reject(err); + else resolve(); + }); + }); + + const dir = getRecordingsDirectory(); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const title = sanitizeTitleForFileName(input.gameTitle); + const rand = Math.random().toString(16).slice(2, 8); + const durSuffix = input.durationMs > 0 ? `-dur${Math.round(input.durationMs)}` : ""; + const ext = extFromMimeType(rec.mimeType); + const fileName = `${stamp}-${title}-${rand}${durSuffix}${ext}`; + const finalPath = join(dir, fileName); + + await rename(rec.tempPath, finalPath); + + // Save thumbnail if provided + let thumbnailDataUrl: string | undefined; + if (input.thumbnailDataUrl) { + try { + const { buffer } = dataUrlToBuffer(input.thumbnailDataUrl); + const stem = fileName.replace(/\.(mp4|webm)$/i, ""); + const thumbPath = join(dir, `${stem}-thumb.jpg`); + await writeFile(thumbPath, buffer); + thumbnailDataUrl = input.thumbnailDataUrl; + } catch { + // Thumbnail save is best-effort — don't fail the recording + } + } + + // Enforce recording limit: delete oldest entries beyond RECORDING_LIMIT + const all = await listRecordings(); + if (all.length > RECORDING_LIMIT) { + const toDelete = all.slice(RECORDING_LIMIT); + await Promise.all( + toDelete.map(async (entry) => { + await unlink(entry.filePath).catch(() => undefined); + const stem = entry.fileName.replace(/\.(mp4|webm)$/i, ""); + await unlink(join(dir, `${stem}-thumb.jpg`)).catch(() => undefined); + }), + ); + } + + const fileStats = await stat(finalPath); + return { + id: fileName, + fileName, + filePath: finalPath, + createdAtMs: Date.now(), + sizeBytes: fileStats.size, + durationMs: input.durationMs, + gameTitle: input.gameTitle, + thumbnailDataUrl, + }; + }); + + ipcMain.handle(IPC_CHANNELS.RECORDING_ABORT, async (_event, input: RecordingAbortRequest): Promise => { + const rec = activeRecordings.get(input.recordingId); + if (!rec) { + return; + } + activeRecordings.delete(input.recordingId); + rec.writeStream.destroy(); + await unlink(rec.tempPath).catch(() => undefined); + }); + + ipcMain.handle(IPC_CHANNELS.RECORDING_LIST, async (): Promise => { + return listRecordings(); + }); + + ipcMain.handle(IPC_CHANNELS.RECORDING_DELETE, async (_event, input: RecordingDeleteRequest): Promise => { + assertSafeRecordingId(input.id); + const dir = await ensureRecordingsDirectory(); + const filePath = join(dir, input.id); + await unlink(filePath); + const stem = input.id.replace(/\.(mp4|webm)$/i, ""); + await unlink(join(dir, `${stem}-thumb.jpg`)).catch(() => undefined); + }); + + ipcMain.handle(IPC_CHANNELS.RECORDING_SHOW_IN_FOLDER, async (_event, id: string): Promise => { + assertSafeRecordingId(id); + const dir = await ensureRecordingsDirectory(); + shell.showItemInFolder(join(dir, id)); + }); + // TCP-based ping function - more accurate than HTTP as it only measures connection time async function tcpPing(hostname: string, port: number, timeoutMs: number = 3000): Promise { return new Promise((resolve) => { diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index bff9829..14a887e 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -20,6 +20,8 @@ export interface Settings { clipboardPaste: boolean; /** Mouse sensitivity multiplier */ mouseSensitivity: number; + /** Software mouse acceleration strength percentage (1-150) */ + mouseAcceleration: number; /** Toggle stats overlay shortcut */ shortcutToggleStats: string; /** Toggle pointer lock shortcut */ @@ -30,6 +32,10 @@ export interface Settings { shortcutToggleAntiAfk: string; /** Toggle microphone shortcut */ shortcutToggleMicrophone: string; + /** Take screenshot shortcut */ + shortcutScreenshot: string; + /** Toggle stream recording shortcut */ + shortcutToggleRecording: string; /** How often to re-show the session timer while streaming (0 = off) */ sessionClockShowEveryMinutes: number; /** How long the session timer stays visible when it appears */ @@ -63,11 +69,14 @@ const DEFAULT_SETTINGS: Settings = { region: "", clipboardPaste: false, mouseSensitivity: 1, + mouseAcceleration: 1, shortcutToggleStats: "F3", shortcutTogglePointerLock: "F8", shortcutStopStream: defaultStopShortcut, shortcutToggleAntiAfk: defaultAntiAfkShortcut, shortcutToggleMicrophone: defaultMicShortcut, + shortcutScreenshot: "F11", + shortcutToggleRecording: "F12", microphoneMode: "disabled", microphoneDeviceId: "", hideStreamButtons: false, @@ -105,7 +114,15 @@ export class SettingsManager { ...parsed, }; - const migrated = this.migrateLegacyShortcutDefaults(merged); + let migrated = this.migrateLegacyShortcutDefaults(merged); + + // Migrate legacy boolean accelerator setting to percentage slider. + if (typeof (parsed as { mouseAcceleration?: unknown }).mouseAcceleration === "boolean") { + merged.mouseAcceleration = (parsed as { mouseAcceleration?: boolean }).mouseAcceleration ? 100 : 1; + migrated = true; + } + + merged.mouseAcceleration = Math.max(1, Math.min(150, Math.round(merged.mouseAcceleration))); if (migrated) { writeFileSync(this.settingsPath, JSON.stringify(merged, null, 2), "utf-8"); } diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index 638397e..e5decf8 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -16,9 +16,20 @@ import type { SignalingConnectRequest, SendAnswerRequest, IceCandidatePayload, + KeyframeRequest, Settings, SubscriptionFetchRequest, StreamRegion, + ScreenshotSaveRequest, + ScreenshotDeleteRequest, + ScreenshotSaveAsRequest, + RecordingBeginRequest, + RecordingBeginResult, + RecordingChunkRequest, + RecordingFinishRequest, + RecordingAbortRequest, + RecordingEntry, + RecordingDeleteRequest, } from "@shared/gfn"; // Extend the OpenNowApi interface for internal preload use @@ -51,6 +62,8 @@ const api: PreloadApi = { sendAnswer: (input: SendAnswerRequest) => ipcRenderer.invoke(IPC_CHANNELS.SEND_ANSWER, input), sendIceCandidate: (input: IceCandidatePayload) => ipcRenderer.invoke(IPC_CHANNELS.SEND_ICE_CANDIDATE, input), + requestKeyframe: (input: KeyframeRequest) => + ipcRenderer.invoke(IPC_CHANNELS.REQUEST_KEYFRAME, input), onSignalingEvent: (listener: (event: MainToRendererSignalingEvent) => void) => { const wrapped = (_event: Electron.IpcRendererEvent, payload: MainToRendererSignalingEvent) => { listener(payload); @@ -76,6 +89,31 @@ const api: PreloadApi = { resetSettings: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_RESET), exportLogs: (format?: "text" | "json") => ipcRenderer.invoke(IPC_CHANNELS.LOGS_EXPORT, format), pingRegions: (regions: StreamRegion[]) => ipcRenderer.invoke(IPC_CHANNELS.PING_REGIONS, regions), + saveScreenshot: (input: ScreenshotSaveRequest) => ipcRenderer.invoke(IPC_CHANNELS.SCREENSHOT_SAVE, input), + listScreenshots: () => ipcRenderer.invoke(IPC_CHANNELS.SCREENSHOT_LIST), + deleteScreenshot: (input: ScreenshotDeleteRequest) => ipcRenderer.invoke(IPC_CHANNELS.SCREENSHOT_DELETE, input), + saveScreenshotAs: (input: ScreenshotSaveAsRequest) => ipcRenderer.invoke(IPC_CHANNELS.SCREENSHOT_SAVE_AS, input), + onTriggerScreenshot: (listener: () => void) => { + const wrapped = () => listener(); + ipcRenderer.on("app:trigger-screenshot", wrapped); + return () => { + ipcRenderer.off("app:trigger-screenshot", wrapped); + }; + }, + beginRecording: (input: RecordingBeginRequest): Promise => + ipcRenderer.invoke(IPC_CHANNELS.RECORDING_BEGIN, input), + sendRecordingChunk: (input: RecordingChunkRequest): Promise => + ipcRenderer.invoke(IPC_CHANNELS.RECORDING_CHUNK, input), + finishRecording: (input: RecordingFinishRequest): Promise => + ipcRenderer.invoke(IPC_CHANNELS.RECORDING_FINISH, input), + abortRecording: (input: RecordingAbortRequest): Promise => + ipcRenderer.invoke(IPC_CHANNELS.RECORDING_ABORT, input), + listRecordings: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.RECORDING_LIST), + deleteRecording: (input: RecordingDeleteRequest): Promise => + ipcRenderer.invoke(IPC_CHANNELS.RECORDING_DELETE, input), + showRecordingInFolder: (id: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.RECORDING_SHOW_IN_FOLDER, id), }; contextBridge.exposeInMainWorld("openNow", api); diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index c9388f1..12359c5 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -38,6 +38,7 @@ const resolutionOptions = ["1280x720", "1920x1080", "2560x1440", "3840x2160", "2 const fpsOptions = [30, 60, 120, 144, 240]; const SESSION_READY_POLL_INTERVAL_MS = 2000; const SESSION_READY_TIMEOUT_MS = 180000; +const PLAYTIME_RESYNC_INTERVAL_MS = 5 * 60 * 1000; type GameSource = "main" | "library" | "public"; type AppPage = "home" | "library" | "settings"; @@ -67,6 +68,8 @@ const DEFAULT_SHORTCUTS = { shortcutStopStream: "Ctrl+Shift+Q", shortcutToggleAntiAfk: "Ctrl+Shift+K", shortcutToggleMicrophone: "Ctrl+Shift+M", + shortcutScreenshot: "F11", + shortcutToggleRecording: "F12", } as const; function sleep(ms: number): Promise { @@ -152,6 +155,9 @@ function defaultDiagnostics(): StreamDiagnostics { inputQueueMaxSchedulingDelayMs: 0, gpuType: "", serverRegion: "", + decoderPressureActive: false, + decoderRecoveryAttempts: 0, + decoderRecoveryAction: "none", micState: "uninitialized", micEnabled: false, }; @@ -184,6 +190,29 @@ function warningMessage(code: StreamTimeWarning["code"]): string { return "Maximum session time approaching"; } +function formatRemainingPlaytimeFromSubscription( + subscription: SubscriptionInfo | null, + consumedHours = 0, +): string { + if (!subscription) { + return "--"; + } + if (subscription.isUnlimited) { + return "Unlimited"; + } + + const baseHours = Number.isFinite(subscription.remainingHours) ? subscription.remainingHours : 0; + const safeHours = Math.max(0, baseHours - Math.max(0, consumedHours)); + const totalMinutes = Math.round(safeHours * 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + if (hours > 0) { + return `${hours}h ${minutes.toString().padStart(2, "0")}m`; + } + return `${minutes}m`; +} + function toLoadingStatus(status: StreamStatus): StreamLoadingStatus { switch (status) { case "queue": @@ -303,11 +332,14 @@ export function App(): JSX.Element { region: "", clipboardPaste: false, mouseSensitivity: 1, + mouseAcceleration: 1, shortcutToggleStats: DEFAULT_SHORTCUTS.shortcutToggleStats, shortcutTogglePointerLock: DEFAULT_SHORTCUTS.shortcutTogglePointerLock, shortcutStopStream: DEFAULT_SHORTCUTS.shortcutStopStream, shortcutToggleAntiAfk: DEFAULT_SHORTCUTS.shortcutToggleAntiAfk, shortcutToggleMicrophone: DEFAULT_SHORTCUTS.shortcutToggleMicrophone, + shortcutScreenshot: DEFAULT_SHORTCUTS.shortcutScreenshot, + shortcutToggleRecording: DEFAULT_SHORTCUTS.shortcutToggleRecording, microphoneMode: "disabled", microphoneDeviceId: "", hideStreamButtons: false, @@ -315,6 +347,7 @@ export function App(): JSX.Element { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, + gameLanguage: "en_US", }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [regions, setRegions] = useState([]); @@ -590,13 +623,17 @@ export function App(): JSX.Element { const stopStream = parseWithFallback(settings.shortcutStopStream, DEFAULT_SHORTCUTS.shortcutStopStream); const toggleAntiAfk = parseWithFallback(settings.shortcutToggleAntiAfk, DEFAULT_SHORTCUTS.shortcutToggleAntiAfk); const toggleMicrophone = parseWithFallback(settings.shortcutToggleMicrophone, DEFAULT_SHORTCUTS.shortcutToggleMicrophone); - return { toggleStats, togglePointerLock, stopStream, toggleAntiAfk, toggleMicrophone }; + const screenshot = parseWithFallback(settings.shortcutScreenshot, DEFAULT_SHORTCUTS.shortcutScreenshot); + const recording = parseWithFallback(settings.shortcutToggleRecording, DEFAULT_SHORTCUTS.shortcutToggleRecording); + return { toggleStats, togglePointerLock, stopStream, toggleAntiAfk, toggleMicrophone, screenshot, recording }; }, [ settings.shortcutToggleStats, settings.shortcutTogglePointerLock, settings.shortcutStopStream, settings.shortcutToggleAntiAfk, settings.shortcutToggleMicrophone, + settings.shortcutScreenshot, + settings.shortcutToggleRecording, ]); const requestEscLockedPointerCapture = useCallback(async (target: HTMLVideoElement) => { @@ -631,6 +668,12 @@ export function App(): JSX.Element { .catch(() => {}); }, []); + const handleRequestPointerLock = useCallback(() => { + if (videoRef.current) { + void requestEscLockedPointerCapture(videoRef.current); + } + }, [requestEscLockedPointerCapture]); + const resolveExitPrompt = useCallback((confirmed: boolean) => { const resolver = exitPromptResolverRef.current; exitPromptResolverRef.current = null; @@ -693,6 +736,35 @@ export function App(): JSX.Element { return () => clearInterval(interval); }, [antiAfkEnabled, streamStatus]); + // Periodically re-sync subscription playtime from backend while streaming. + useEffect(() => { + if (streamStatus !== "streaming" || !authSession) { + return; + } + + let cancelled = false; + + const syncPlaytime = async (): Promise => { + try { + await loadSubscriptionInfo(authSession); + } catch (error) { + if (!cancelled) { + console.warn("Failed to re-sync subscription playtime:", error); + } + } + }; + + void syncPlaytime(); + const timer = window.setInterval(() => { + void syncPlaytime(); + }, PLAYTIME_RESYNC_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [authSession, loadSubscriptionInfo, streamStatus]); + // Restore focus to video element when navigating away from Settings during streaming useEffect(() => { if (streamStatus === "streaming" && currentPage !== "settings" && videoRef.current) { @@ -777,6 +849,7 @@ export function App(): JSX.Element { microphoneMode: settings.microphoneMode, microphoneDeviceId: settings.microphoneDeviceId || undefined, mouseSensitivity: settings.mouseSensitivity, + mouseAcceleration: settings.mouseAcceleration, onLog: (line: string) => console.log(`[WebRTC] ${line}`), onStats: (stats) => setDiagnostics(stats), onEscHoldProgress: (visible, progress) => { @@ -844,8 +917,37 @@ export function App(): JSX.Element { // ignore } } + if (key === "mouseAcceleration") { + try { + (clientRef.current as any)?.setMouseAccelerationPercent?.(value as number); + } catch { + // ignore + } + } + if (key === "maxBitrateMbps") { + try { + void (clientRef.current as any)?.setMaxBitrateKbps?.((value as number) * 1000); + } catch { + // ignore + } + } }, [settingsLoaded]); + const handleMouseSensitivityChange = useCallback((value: number) => { + void updateSetting("mouseSensitivity", value); + }, [updateSetting]); + + const handleMouseAccelerationChange = useCallback((value: number) => { + void updateSetting("mouseAcceleration", value); + }, [updateSetting]); + + const handleMicrophoneModeChange = useCallback((value: import("@shared/gfn").MicrophoneMode) => { + // Keep UI responsive while still surfacing persistence failures. + void updateSetting("microphoneMode", value).catch((error) => { + console.warn("Failed to persist microphone mode setting:", error); + }); + }, [updateSetting]); + // Login handler const handleLogin = useCallback(async () => { setIsLoggingIn(true); @@ -1274,6 +1376,13 @@ export function App(): JSX.Element { const releasePointerLockIfNeeded = useCallback(async () => { if (document.pointerLockElement) { + // Tell the client to suppress synthetic Escape/reactive re-acquisition + try { + // clientRef is a mutable ref to the GfnWebRtcClient instance; access runtime property + (clientRef.current as any).suppressNextSyntheticEscape = true; + } catch (e) { + // ignore + } document.exitPointerLock(); setEscHoldReleaseIndicator({ visible: false, progress: 0 }); await sleep(75); @@ -1365,8 +1474,14 @@ export function App(): JSX.Element { e.stopPropagation(); e.stopImmediatePropagation(); if (streamStatus === "streaming" && videoRef.current) { - if (document.pointerLockElement) { + if (document.pointerLockElement === videoRef.current) { + try { + (clientRef.current as any).suppressNextSyntheticEscape = true; + } catch { + // best-effort — client may not be initialised + } document.exitPointerLock(); + setEscHoldReleaseIndicator({ visible: false, progress: 0 }); } else { void requestEscLockedPointerCapture(videoRef.current); } @@ -1493,6 +1608,11 @@ export function App(): JSX.Element { } const showLaunchOverlay = streamStatus !== "idle" || launchError !== null; + const consumedHours = + streamStatus === "streaming" + ? Math.floor(sessionElapsedSeconds / 60) / 60 + : 0; + const remainingPlaytimeText = formatRemainingPlaytimeFromSubscription(subscriptionInfo, consumedHours); // Show stream lifecycle (waiting/connecting/streaming/failure) if (showLaunchOverlay) { @@ -1510,6 +1630,8 @@ export function App(): JSX.Element { togglePointerLock: formatShortcutForDisplay(settings.shortcutTogglePointerLock, isMac), stopStream: formatShortcutForDisplay(settings.shortcutStopStream, isMac), toggleMicrophone: formatShortcutForDisplay(settings.shortcutToggleMicrophone, isMac), + screenshot: shortcuts.screenshot.canonical, + recording: shortcuts.recording.canonical, }} hideStreamButtons={settings.hideStreamButtons} serverRegion={session?.serverIp} @@ -1539,6 +1661,24 @@ export function App(): JSX.Element { onToggleMicrophone={() => { clientRef.current?.toggleMicrophone(); }} + mouseSensitivity={settings.mouseSensitivity} + onMouseSensitivityChange={handleMouseSensitivityChange} + mouseAcceleration={settings.mouseAcceleration} + onMouseAccelerationChange={handleMouseAccelerationChange} + microphoneMode={settings.microphoneMode} + onMicrophoneModeChange={handleMicrophoneModeChange} + onScreenshotShortcutChange={(value) => { + void updateSetting("shortcutScreenshot", value); + }} + onRecordingShortcutChange={(value) => { + void updateSetting("shortcutToggleRecording", value); + }} + remainingPlaytimeText={remainingPlaytimeText} + micTrack={clientRef.current?.getMicTrack() ?? null} + onRequestPointerLock={handleRequestPointerLock} + onReleasePointerLock={() => { + void releasePointerLockIfNeeded(); + }} /> )} {streamStatus !== "streaming" && ( diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index 0b9b82b..25696aa 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -70,6 +70,7 @@ const shortcutDefaults = { shortcutStopStream: "Ctrl+Shift+Q", shortcutToggleAntiAfk: "Ctrl+Shift+K", shortcutToggleMicrophone: "Ctrl+Shift+M", + shortcutScreenshot: "F11", } as const; const microphoneModeOptions: Array<{ value: MicrophoneMode; label: string }> = [ @@ -605,11 +606,13 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag const [stopStreamInput, setStopStreamInput] = useState(settings.shortcutStopStream); const [toggleAntiAfkInput, setToggleAntiAfkInput] = useState(settings.shortcutToggleAntiAfk); const [toggleMicrophoneInput, setToggleMicrophoneInput] = useState(settings.shortcutToggleMicrophone); + const [screenshotInput, setScreenshotInput] = useState(settings.shortcutScreenshot); const [toggleStatsError, setToggleStatsError] = useState(false); const [togglePointerLockError, setTogglePointerLockError] = useState(false); const [stopStreamError, setStopStreamError] = useState(false); const [toggleAntiAfkError, setToggleAntiAfkError] = useState(false); const [toggleMicrophoneError, setToggleMicrophoneError] = useState(false); + const [screenshotError, setScreenshotError] = useState(false); // Game language dropdown state const [gameLanguageDropdownOpen, setGameLanguageDropdownOpen] = useState(false); @@ -639,6 +642,10 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag setToggleMicrophoneInput(settings.shortcutToggleMicrophone); }, [settings.shortcutToggleMicrophone]); + useEffect(() => { + setScreenshotInput(settings.shortcutScreenshot); + }, [settings.shortcutScreenshot]); + // Fetch subscription data (cached per account; reload only when account changes) useEffect(() => { let cancelled = false; @@ -859,13 +866,15 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag && settings.shortcutTogglePointerLock === shortcutDefaults.shortcutTogglePointerLock && settings.shortcutStopStream === shortcutDefaults.shortcutStopStream && settings.shortcutToggleAntiAfk === shortcutDefaults.shortcutToggleAntiAfk - && settings.shortcutToggleMicrophone === shortcutDefaults.shortcutToggleMicrophone, + && settings.shortcutToggleMicrophone === shortcutDefaults.shortcutToggleMicrophone + && settings.shortcutScreenshot === shortcutDefaults.shortcutScreenshot, [ settings.shortcutToggleStats, settings.shortcutTogglePointerLock, settings.shortcutStopStream, settings.shortcutToggleAntiAfk, settings.shortcutToggleMicrophone, + settings.shortcutScreenshot, ] ); @@ -875,11 +884,13 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag setStopStreamInput(shortcutDefaults.shortcutStopStream); setToggleAntiAfkInput(shortcutDefaults.shortcutToggleAntiAfk); setToggleMicrophoneInput(shortcutDefaults.shortcutToggleMicrophone); + setScreenshotInput(shortcutDefaults.shortcutScreenshot); setToggleStatsError(false); setTogglePointerLockError(false); setStopStreamError(false); setToggleAntiAfkError(false); setToggleMicrophoneError(false); + setScreenshotError(false); const shortcutKeys = [ "shortcutToggleStats", @@ -887,6 +898,7 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag "shortcutStopStream", "shortcutToggleAntiAfk", "shortcutToggleMicrophone", + "shortcutScreenshot", ] as const; for (const key of shortcutKeys) { @@ -1499,6 +1511,40 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag Multiplier applied to mouse movement (1.00 = default) +
+
+ + {Math.round(settings.mouseAcceleration)}% +
+
+ handleChange("mouseAcceleration", Math.max(1, Math.min(150, Math.round(Number(e.target.value) || 1))))} + /> + { + const v = Number(e.target.value || "1"); + if (Number.isFinite(v)) { + handleChange("mouseAcceleration", Math.max(1, Math.min(150, Math.round(v)))); + } + }} + /> +
+ Dynamic turn boost strength (1% = off-like, 150% = strongest). +
+
@@ -1585,17 +1631,40 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag spellCheck={false} /> + + +
- {(toggleStatsError || togglePointerLockError || stopStreamError || toggleAntiAfkError || toggleMicrophoneError) && ( + {(toggleStatsError || togglePointerLockError || stopStreamError || toggleAntiAfkError || toggleMicrophoneError || screenshotError) && ( Invalid shortcut. Use {shortcutExamples} )} - {!toggleStatsError && !togglePointerLockError && !stopStreamError && !toggleAntiAfkError && !toggleMicrophoneError && ( + {!toggleStatsError && !togglePointerLockError && !stopStreamError && !toggleAntiAfkError && !toggleMicrophoneError && !screenshotError && ( - {shortcutExamples}. Stop: {formatShortcutForDisplay(settings.shortcutStopStream, isMac)}. Mic: {formatShortcutForDisplay(settings.shortcutToggleMicrophone, isMac)}. + {shortcutExamples}. Stop: {formatShortcutForDisplay(settings.shortcutStopStream, isMac)}. Mic: {formatShortcutForDisplay(settings.shortcutToggleMicrophone, isMac)}. ScreensShot: {formatShortcutForDisplay(settings.shortcutScreenshot, isMac)}. )}
diff --git a/opennow-stable/src/renderer/src/components/SideBar.tsx b/opennow-stable/src/renderer/src/components/SideBar.tsx new file mode 100644 index 0000000..f3c06e3 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/SideBar.tsx @@ -0,0 +1,42 @@ +import type { JSX, ReactNode } from "react"; + +interface SideBarProps { + title?: string; + children?: ReactNode; + className?: string; + onClose?: () => void; +} + +export default function SideBar({ + title, + children, + className = "", + onClose, +}: SideBarProps): JSX.Element { + const classNames = ["sidebar", className].filter(Boolean).join(" "); + + return ( + + ); +} \ No newline at end of file diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index 9f2b810..b2cb738 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -1,8 +1,11 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import type { JSX } from "react"; -import { Maximize, Minimize, Gamepad2, Loader2, LogOut, Clock3, AlertTriangle, Mic, MicOff } from "lucide-react"; +import { Maximize, Minimize, Gamepad2, Loader2, LogOut, Clock3, AlertTriangle, Mic, MicOff, Camera, ChevronLeft, ChevronRight, Save, Trash2, X, Circle, Square, Video, FolderOpen } from "lucide-react"; +import SideBar from "./SideBar"; import type { StreamDiagnostics } from "../gfn/webrtcClient"; import { getStoreDisplayName, getStoreIconComponent } from "./GameCard"; +import type { MicrophoneMode, ScreenshotEntry, RecordingEntry } from "@shared/gfn"; +import { isShortcutMatch, normalizeShortcut } from "../shortcuts"; interface StreamViewProps { videoRef: React.Ref; @@ -14,6 +17,8 @@ interface StreamViewProps { togglePointerLock: string; stopStream: string; toggleMicrophone?: string; + screenshot: string; + recording: string; }; hideStreamButtons?: boolean; serverRegion?: string; @@ -44,6 +49,18 @@ interface StreamViewProps { onCancelExit: () => void; onEndSession: () => void; onToggleMicrophone?: () => void; + mouseSensitivity: number; + onMouseSensitivityChange: (value: number) => void; + mouseAcceleration: number; + onMouseAccelerationChange: (value: number) => void; + onRequestPointerLock?: () => void; + onReleasePointerLock?: () => void; + microphoneMode: MicrophoneMode; + onMicrophoneModeChange: (value: MicrophoneMode) => void; + onScreenshotShortcutChange: (value: string) => void; + onRecordingShortcutChange: (value: string) => void; + remainingPlaytimeText: string; + micTrack?: MediaStreamTrack | null; } function getRttColor(rttMs: number): string { @@ -83,6 +100,12 @@ function formatElapsed(totalSeconds: number): string { return `${minutes}:${seconds.toString().padStart(2, "0")}`; } +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + function formatWarningSeconds(value: number | undefined): string | null { if (value === undefined || !Number.isFinite(value) || value < 0) { return null; @@ -96,6 +119,125 @@ function formatWarningSeconds(value: number | undefined): string | null { return `${seconds}s`; } +/** + * Drives a canvas-based segmented level meter from a live MediaStreamTrack. + * Uses the Web Audio API AnalyserNode as a read-only tap — audio is never + * routed to the speaker. Runs a requestAnimationFrame loop while active; + * tears down fully (rAF cancelled, AudioContext closed) on deactivation. + */ +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 raf = 0; + 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 = 512; + analyser.smoothingTimeConstant = 0.65; + source = audioCtx.createMediaStreamSource(new MediaStream([track])); + source.connect(analyser); + // NOT connected to destination — monitoring only, no loopback + + 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 frame = () => { + if (dead || !analyser) return; + raf = requestAnimationFrame(frame); + 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; + cancelAnimationFrame(raf); + 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, @@ -119,22 +261,88 @@ export function StreamView({ onCancelExit, onEndSession, onToggleMicrophone, + mouseSensitivity, + onMouseSensitivityChange, + mouseAcceleration, + onMouseAccelerationChange, + onRequestPointerLock, + onReleasePointerLock, + microphoneMode, + onMicrophoneModeChange, + onScreenshotShortcutChange, + onRecordingShortcutChange, + remainingPlaytimeText, + micTrack, hideStreamButtons = false, }: StreamViewProps): JSX.Element { const [isFullscreen, setIsFullscreen] = useState(false); const [showHints, setShowHints] = useState(true); const [showSessionClock, setShowSessionClock] = useState(false); + const [showSideBar, setShowSideBar] = useState(false); + const [isPointerLocked, setIsPointerLocked] = useState(false); + const [screenshots, setScreenshots] = useState([]); + const [isSavingScreenshot, setIsSavingScreenshot] = useState(false); + const [galleryError, setGalleryError] = useState(null); + const [selectedScreenshotId, setSelectedScreenshotId] = useState(null); + const [screenshotShortcutInput, setScreenshotShortcutInput] = useState(shortcuts.screenshot); + const [screenshotShortcutError, setScreenshotShortcutError] = useState(null); + const [activeSidebarTab, setActiveSidebarTab] = useState<"preferences" | "shortcuts">("preferences"); + const screenshotApiAvailable = + typeof window.openNow?.saveScreenshot === "function" && + typeof window.openNow?.listScreenshots === "function" && + typeof window.openNow?.deleteScreenshot === "function" && + typeof window.openNow?.saveScreenshotAs === "function"; + + // Recording state + const [isRecording, setIsRecording] = useState(false); + const [recordings, setRecordings] = useState([]); + const [recordingDurationMs, setRecordingDurationMs] = useState(0); + const [recordingError, setRecordingError] = useState(null); + const [usedMimeType, setUsedMimeType] = useState(null); + const [recordingShortcutInput, setRecordingShortcutInput] = useState(shortcuts.recording); + const [recordingShortcutError, setRecordingShortcutError] = useState(null); + const mediaRecorderRef = useRef(null); + const recordingIdRef = useRef(null); + const recordingStartTimeRef = useRef(0); + const recordingTimerRef = useRef(undefined); + const thumbnailDataUrlRef = useRef(null); + const recCarouselRef = useRef(null); + const recordingApiAvailable = + typeof window.openNow?.beginRecording === "function" && + typeof window.openNow?.sendRecordingChunk === "function" && + typeof window.openNow?.finishRecording === "function" && + typeof window.openNow?.abortRecording === "function" && + typeof window.openNow?.listRecordings === "function" && + typeof window.openNow?.deleteRecording === "function"; // Microphone state const micState = stats.micState ?? "uninitialized"; const micEnabled = stats.micEnabled ?? false; const hasMicrophone = micState === "started" || micState === "stopped"; const showMicIndicator = hasMicrophone && !isConnecting && !hideStreamButtons; + const microphoneModes = useMemo( + () => [ + { value: "disabled" as MicrophoneMode, label: "Disabled", description: "No microphone input" }, + { value: "push-to-talk" as MicrophoneMode, label: "Push-to-Talk", description: "Hold a key to talk" }, + { value: "voice-activity" as MicrophoneMode, label: "Voice Activity", description: "Always listen" }, + ], + [] + ); const handleFullscreenToggle = useCallback(() => { onToggleFullscreen(); }, [onToggleFullscreen]); + const handlePointerLockToggle = useCallback(() => { + if (isPointerLocked) { + document.exitPointerLock(); + return; + } + if (onRequestPointerLock) { + onRequestPointerLock(); + } + }, [isPointerLocked, onRequestPointerLock]); + useEffect(() => { const timer = setTimeout(() => setShowHints(false), 5000); return () => clearTimeout(timer); @@ -211,14 +419,415 @@ export function StreamView({ const sessionTimeText = formatElapsed(sessionElapsedSeconds); const platformName = platformStore ? getStoreDisplayName(platformStore) : ""; const PlatformIcon = platformStore ? getStoreIconComponent(platformStore) : null; + const isMacClient = navigator.platform?.toLowerCase().includes("mac") || navigator.userAgent.includes("Macintosh"); // Local ref for video element to manage focus const localVideoRef = useRef(null); + // Local ref for audio element (game audio stream) + const localAudioRef = useRef(null); + // AudioContext used during an active recording (torn down on stop/error) + const audioCtxRef = useRef(null); + + // Mic level meter canvas + const micMeterRef = useRef(null); + const galleryStripRef = useRef(null); + useMicMeter(micMeterRef, micTrack ?? null, showSideBar && microphoneMode !== "disabled"); + + const selectedScreenshot = useMemo(() => { + if (!selectedScreenshotId) return null; + return screenshots.find((item) => item.id === selectedScreenshotId) ?? null; + }, [screenshots, selectedScreenshotId]); + + useEffect(() => { + setScreenshotShortcutInput(shortcuts.screenshot); + setScreenshotShortcutError(null); + }, [shortcuts.screenshot]); + + useEffect(() => { + setRecordingShortcutInput(shortcuts.recording); + setRecordingShortcutError(null); + }, [shortcuts.recording]); + + const getScreenshotShortcutError = useCallback((rawValue: string): string | null => { + const trimmed = rawValue.trim(); + if (!trimmed) { + return "Shortcut cannot be empty."; + } + + const normalized = normalizeShortcut(trimmed); + if (!normalized.valid) { + return "Invalid shortcut format."; + } + + const reserved = [ + shortcuts.toggleStats, + shortcuts.togglePointerLock, + shortcuts.stopStream, + shortcuts.toggleMicrophone, + shortcuts.recording, + isMacClient ? "Cmd+G" : "Ctrl+Shift+G", + ] + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map((value) => normalizeShortcut(value)) + .filter((parsed) => parsed.valid) + .map((parsed) => parsed.canonical); + + if (reserved.includes(normalized.canonical)) { + return "Shortcut conflicts with an existing binding."; + } + + return null; + }, [isMacClient, shortcuts.recording, shortcuts.stopStream, shortcuts.toggleMicrophone, shortcuts.togglePointerLock, shortcuts.toggleStats]); + + const getRecordingShortcutError = useCallback((rawValue: string): string | null => { + const trimmed = rawValue.trim(); + if (!trimmed) { + return "Shortcut cannot be empty."; + } + + const normalized = normalizeShortcut(trimmed); + if (!normalized.valid) { + return "Invalid shortcut format."; + } + + const reserved = [ + shortcuts.toggleStats, + shortcuts.togglePointerLock, + shortcuts.stopStream, + shortcuts.toggleMicrophone, + shortcuts.screenshot, + isMacClient ? "Cmd+G" : "Ctrl+Shift+G", + ] + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map((value) => normalizeShortcut(value)) + .filter((parsed) => parsed.valid) + .map((parsed) => parsed.canonical); + + if (reserved.includes(normalized.canonical)) { + return "Shortcut conflicts with an existing binding."; + } + + return null; + }, [isMacClient, shortcuts.screenshot, shortcuts.stopStream, shortcuts.toggleMicrophone, shortcuts.togglePointerLock, shortcuts.toggleStats]); + + const refreshScreenshots = useCallback(async () => { + setGalleryError(null); + if (!screenshotApiAvailable) { + setGalleryError("Screenshot API unavailable. Restart OpenNOW to enable gallery."); + return; + } + try { + const items = await window.openNow.listScreenshots(); + setScreenshots(items); + } catch (error) { + console.error("[StreamView] Failed to load screenshots:", error); + setGalleryError("Unable to load screenshot gallery."); + } + }, [screenshotApiAvailable]); + + const captureScreenshot = useCallback(async () => { + setGalleryError(null); + if (!screenshotApiAvailable) { + setGalleryError("Screenshot API unavailable. Restart OpenNOW to enable capture."); + return; + } + if (isSavingScreenshot) { + return; + } + + const video = localVideoRef.current; + if (!video || video.videoWidth <= 0 || video.videoHeight <= 0) { + setGalleryError("Stream is not ready for screenshots yet."); + return; + } + + setIsSavingScreenshot(true); + try { + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Could not acquire 2D context"); + } + + context.drawImage(video, 0, 0, canvas.width, canvas.height); + const dataUrl = canvas.toDataURL("image/png"); + const saved = await window.openNow.saveScreenshot({ dataUrl, gameTitle }); + setScreenshots((prev) => [saved, ...prev.filter((item) => item.id !== saved.id)].slice(0, 60)); + } catch (error) { + console.error("[StreamView] Failed to capture screenshot:", error); + setGalleryError("Screenshot failed. Try again."); + } finally { + setIsSavingScreenshot(false); + } + }, [gameTitle, isSavingScreenshot, screenshotApiAvailable]); + + const scrollGallery = useCallback((direction: "left" | "right") => { + const strip = galleryStripRef.current; + if (!strip) return; + const delta = Math.max(180, Math.round(strip.clientWidth * 0.7)); + strip.scrollBy({ left: direction === "left" ? -delta : delta, behavior: "smooth" }); + }, []); + + const handleDeleteScreenshot = useCallback(async () => { + setGalleryError(null); + if (!screenshotApiAvailable) { + setGalleryError("Screenshot API unavailable. Restart OpenNOW to enable gallery."); + return; + } + if (!selectedScreenshot) return; + + try { + await window.openNow.deleteScreenshot({ id: selectedScreenshot.id }); + setScreenshots((prev) => prev.filter((item) => item.id !== selectedScreenshot.id)); + setSelectedScreenshotId(null); + } catch (error) { + console.error("[StreamView] Failed to delete screenshot:", error); + setGalleryError("Unable to delete screenshot."); + } + }, [screenshotApiAvailable, selectedScreenshot]); + + const handleSaveScreenshotAs = useCallback(async () => { + setGalleryError(null); + if (!screenshotApiAvailable) { + setGalleryError("Screenshot API unavailable. Restart OpenNOW to enable gallery."); + return; + } + if (!selectedScreenshot) return; + + try { + await window.openNow.saveScreenshotAs({ id: selectedScreenshot.id }); + } catch (error) { + console.error("[StreamView] Failed to save screenshot as:", error); + setGalleryError("Unable to save screenshot."); + } + }, [screenshotApiAvailable, selectedScreenshot]); + + const refreshRecordings = useCallback(async () => { + setRecordingError(null); + if (!recordingApiAvailable) return; + try { + const items = await window.openNow.listRecordings(); + setRecordings(items); + } catch (error) { + console.error("[StreamView] Failed to load recordings:", error); + setRecordingError("Unable to load recordings."); + } + }, [recordingApiAvailable]); + + const handleDeleteRecording = useCallback(async (id: string) => { + setRecordingError(null); + if (!recordingApiAvailable) return; + try { + await window.openNow.deleteRecording({ id }); + setRecordings((prev) => prev.filter((r) => r.id !== id)); + } catch (error) { + console.error("[StreamView] Failed to delete recording:", error); + setRecordingError("Unable to delete recording."); + } + }, [recordingApiAvailable]); + + const scrollRecCarousel = useCallback((direction: "left" | "right") => { + const strip = recCarouselRef.current; + if (!strip) return; + strip.scrollBy({ left: direction === "left" ? -200 : 200, behavior: "smooth" }); + }, []); + + const toggleRecording = useCallback(async () => { + setRecordingError(null); + + if (isRecording) { + mediaRecorderRef.current?.stop(); + return; + } + + if (!recordingApiAvailable) { + setRecordingError("Recording API unavailable. Restart OpenNOW to enable recording."); + return; + } + + const video = localVideoRef.current; + if (!video || !video.srcObject) { + setRecordingError("Stream is not ready for recording yet."); + return; + } + + const stream = video.srcObject as MediaStream; + const mimeTypes = [ + "video/mp4;codecs=avc1.42E01E,mp4a.40.2", + "video/mp4;codecs=avc1", + "video/mp4", + "video/webm;codecs=h264", + "video/webm;codecs=vp8", + "video/webm", + ]; + const mimeType = mimeTypes.find((m) => MediaRecorder.isTypeSupported(m)) ?? "video/webm"; + setUsedMimeType(mimeType); + + // Build a composed MediaStream: video tracks + mixed audio (game + mic) + const audioCtx = new AudioContext(); + audioCtxRef.current = audioCtx; + const audioDest = audioCtx.createMediaStreamDestination(); + + // Wire game audio (from the