diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index 28d4eae0..10b9866f 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -1,41 +1,34 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, m } from "motion/react"; import type { JSX } from "react"; -import { Maximize, Minimize, LogOut, Clock3, AlertTriangle, Mic, Camera, ChevronLeft, ChevronRight, Save, Trash2, X, Circle, Square, Video, FolderOpen, Gamepad2, Gauge, Images, Keyboard, MousePointer2, SlidersHorizontal } from "lucide-react"; -import SideBar from "./SideBar"; +import { Maximize, Minimize, LogOut, AlertTriangle } from "lucide-react"; import { SessionStartedSplash } from "./SessionStartedSplash"; import { StreamStatsHud } from "./StreamStatsHud"; import type { StreamDiagnosticsStore } from "../utils/streamDiagnosticsStore"; import { useStreamDiagnosticsSelector } from "../utils/streamDiagnosticsStore"; import { getStoreDisplayName, getStoreIconComponent } from "./GameCard"; -import { RemainingPlaytimeIndicator, SessionElapsedIndicator } from "./ElapsedSessionIndicators"; -import type { MicrophoneMode, ScreenshotEntry, RecordingEntry, SubscriptionInfo, VideoShaderSettings } from "@shared/gfn"; -import { DEFAULT_VIDEO_SHADER_SETTINGS } from "@shared/gfn"; +import { SessionElapsedIndicator } from "./ElapsedSessionIndicators"; +import type { MicrophoneMode, SubscriptionInfo, VideoShaderSettings } from "@shared/gfn"; import { VideoShaderPipeline } from "../platforms/gfn/videoShaderPipeline"; -import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut, shortcutFromKeyboardEvent } from "../shortcuts"; -import { addStreamShortcutActionListener } from "../streamShortcutActions"; -import { useMicMeter } from "../hooks/useMicMeter"; -import { formatElapsed } from "../utils/timeFormat"; -import { useTranslation } from "../i18n"; -import { controllerButton, readControllerGamepadButtons } from "../utils/controllerGamepad"; -import { formatFileSize, formatSessionTimeRemaining, formatWarningSeconds } from "./stream/streamFormatters"; +import { formatShortcutForDisplay } from "../shortcuts"; +import { useScreenshotGallery } from "../hooks/useScreenshotGallery"; +import { useStreamMenuNavigation } from "../hooks/useStreamMenuNavigation"; +import { useStreamRecorder } from "../hooks/useStreamRecorder"; +import { formatSessionTimeRemaining, formatWarningSeconds } from "./stream/streamFormatters"; import { AntiAfkIndicator, MicrophoneIndicator, RecordingIndicator } from "./stream/StreamIndicators"; import { StreamTitleBar } from "./stream/StreamTitleBar"; import { hasVisibleStreamVideo, - SidebarMicMutedBadge, StreamEmptyState, StreamWaitingForVideo, VideoFocusOnReady, } from "./stream/StreamEmptyStates"; +import { StreamQuickMenu } from "./stream/quick-menu/StreamQuickMenu"; import { MotionSpinner } from "./MotionSpinner"; const ANTI_AFK_TOGGLE_ACK_MS = 5000; -const CONTROLLER_MENU_REPEAT_MS = 180; const CONTROLLER_SIDEBAR_SHORTCUT_DISPLAY = "View + Menu"; -const STREAM_MENU_TABS = ["session", "controls", "media", "shortcuts"] as const; -type StreamMenuTab = (typeof STREAM_MENU_TABS)[number]; interface StreamViewProps { videoRef: React.Ref; @@ -163,38 +156,18 @@ export function StreamView({ videoShader, onVideoShaderChange, }: StreamViewProps): JSX.Element { - const { t } = useTranslation(); const [showHints, setShowHints] = useState(true); const [showSessionClock, setShowSessionClock] = useState(false); const [antiAfkToggleAck, setAntiAfkToggleAck] = useState<"on" | "off" | null>(null); - const [showSideBar, setShowSideBar] = useState(false); const [isPointerLocked, setIsPointerLocked] = useState(false); const [pointerLockHintVisible, setPointerLockHintVisible] = useState(false); const pointerLockHintTimerRef = useRef(null); - 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("session"); - const sidebarRef = useRef(null); - const sidebarGamepadFrameRef = useRef(null); - const sidebarGamepadPreviousButtonsRef = useRef(0); - const sidebarGamepadLastMoveAtRef = useRef(0); - const exitPromptGamepadFrameRef = useRef(null); - const exitPromptGamepadPreviousButtonsRef = useRef(0); - const suppressVideoFocusOnSidebarCloseRef = useRef(false); - const screenshotApiAvailable = - typeof window.openNow?.saveScreenshot === "function" && - typeof window.openNow?.listScreenshots === "function" && - typeof window.openNow?.deleteScreenshot === "function" && - typeof window.openNow?.saveScreenshotAs === "function"; const nativeRendererActive = useStreamDiagnosticsSelector( diagnosticsStore, (stats) => stats.nativeRendererActive, ); const localVideoRef = useRef(null); + const localAudioRef = useRef(null); const shaderPipelineRef = useRef(null); const streamHasVideo = useStreamDiagnosticsSelector( diagnosticsStore, @@ -256,37 +229,6 @@ export function StreamView({ setSessionReadySplashVisible(false); }, []); - // 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"; - - 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]); @@ -388,514 +330,48 @@ export function StreamView({ 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 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.toggleAntiAfk, - shortcuts.toggleMicrophone, - shortcuts.recording, - ...(isMacClient ? ["Meta+G"] : ["Ctrl+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.toggleAntiAfk, 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.toggleAntiAfk, - shortcuts.toggleMicrophone, - shortcuts.screenshot, - ...(isMacClient ? ["Meta+G"] : ["Ctrl+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.toggleAntiAfk, shortcuts.toggleMicrophone, shortcuts.togglePointerLock, shortcuts.toggleStats]); - - const SIDEBAR_TOGGLE_RAW = isMacClient ? "Meta+G" : "Ctrl+G"; - const sidebarToggleShortcutDisplay = formatShortcutForDisplay(SIDEBAR_TOGGLE_RAW, isMacClient); - - const applyScreenshotShortcutFromCapture = useCallback( - (canonical: string) => { - const error = getScreenshotShortcutError(canonical); - if (error) { - setScreenshotShortcutError(error); - return; - } - const normalized = normalizeShortcut(canonical.trim()); - if (!normalized.valid) { - setScreenshotShortcutError("Invalid shortcut format."); - return; - } - setScreenshotShortcutError(null); - setScreenshotShortcutInput(normalized.canonical); - if (normalized.canonical !== shortcuts.screenshot) { - onScreenshotShortcutChange(normalized.canonical); - } - }, - [getScreenshotShortcutError, onScreenshotShortcutChange, shortcuts.screenshot], - ); - - const applyRecordingShortcutFromCapture = useCallback( - (canonical: string) => { - const error = getRecordingShortcutError(canonical); - if (error) { - setRecordingShortcutError(error); - return; - } - const normalized = normalizeShortcut(canonical.trim()); - if (!normalized.valid) { - setRecordingShortcutError("Invalid shortcut format."); - return; - } - setRecordingShortcutError(null); - setRecordingShortcutInput(normalized.canonical); - if (normalized.canonical !== shortcuts.recording) { - onRecordingShortcutChange(normalized.canonical); - } - }, - [getRecordingShortcutError, onRecordingShortcutChange, shortcuts.recording], - ); - - const handleStreamScreenshotShortcutKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === "Escape") { - e.preventDefault(); - e.currentTarget.blur(); - return; - } - if (e.key === "Enter" || e.key === "Tab") { - return; - } - const captured = shortcutFromKeyboardEvent(e.nativeEvent); - if (!captured) { - return; - } - e.preventDefault(); - e.stopPropagation(); - applyScreenshotShortcutFromCapture(captured); - }; - - const handleStreamRecordingShortcutKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === "Escape") { - e.preventDefault(); - e.currentTarget.blur(); - return; - } - if (e.key === "Enter" || e.key === "Tab") { - return; - } - const captured = shortcutFromKeyboardEvent(e.nativeEvent); - if (!captured) { - return; - } - e.preventDefault(); - e.stopPropagation(); - applyRecordingShortcutFromCapture(captured); - }; - - const handleStreamScreenshotShortcutPaste = (e: React.ClipboardEvent): void => { - const text = e.clipboardData.getData("text/plain").trim(); - if (!text) { - return; - } - e.preventDefault(); - applyScreenshotShortcutFromCapture(text); - }; - - const handleStreamRecordingShortcutPaste = (e: React.ClipboardEvent): void => { - const text = e.clipboardData.getData("text/plain").trim(); - if (!text) { - return; - } - e.preventDefault(); - applyRecordingShortcutFromCapture(text); - }; - - 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"); + const sidebarToggleRaw = isMacClient ? "Meta+G" : "Ctrl+G"; + const sidebarToggleShortcutDisplay = formatShortcutForDisplay(sidebarToggleRaw, isMacClient); + + const screenshotGallery = useScreenshotGallery({ + videoRef: localVideoRef, + gameTitle, + }); + const streamRecorder = useStreamRecorder({ + videoRef: localVideoRef, + audioRef: localAudioRef, + gameTitle, + micTrack: micTrack ?? null, + recordingBitrateMbps, + }); + const releasePointerLockForMenu = useCallback(() => { + if (document.pointerLockElement) { + if (onReleasePointerLock) { + onReleasePointerLock(); + } else { + document.exitPointerLock(); } - - 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