From be06e1f03ebee51b4f8f405c88c4e39dce21d8c6 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 20:16:21 -0800 Subject: [PATCH] feat: add Picture-in-Picture pop-out to rVoice recorder Adds a "Pop Out" button that opens a compact floating mini-recorder using the Document Picture-in-Picture API. The floating window stays on top of all other apps and includes: record/stop button, timer, live transcript, copy/save/discard actions, and keyboard shortcuts. Co-Authored-By: Claude Opus 4.6 --- src/app/voice/page.tsx | 250 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/src/app/voice/page.tsx b/src/app/voice/page.tsx index bc46740..94fb9d0 100644 --- a/src/app/voice/page.tsx +++ b/src/app/voice/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useRef, useCallback, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import { useRouter } from 'next/navigation'; import { AppSwitcher } from '@/components/AppSwitcher'; import { SpaceSwitcher } from '@/components/SpaceSwitcher'; @@ -113,6 +114,11 @@ export default function VoicePage() { const [modelDownloading, setModelDownloading] = useState(false); const [modelProgress, setModelProgress] = useState(null); + // Picture-in-Picture + const [pipWindow, setPipWindow] = useState(null); + const [pipSupported, setPipSupported] = useState(false); + const pipContainerRef = useRef(null); + useEffect(() => { // Check install state const standalone = window.matchMedia('(display-mode: standalone)').matches @@ -122,6 +128,9 @@ export default function VoicePage() { // Check model cache setModelCached(isModelCached()); + // Check PiP support + setPipSupported('documentPictureInPicture' in window); + // Capture install prompt const handler = (e: Event) => { e.preventDefault(); @@ -205,6 +214,59 @@ export default function VoicePage() { } }, [modelCached, modelDownloading]); + // --- Picture-in-Picture --- + + const openPiP = useCallback(async () => { + if (!('documentPictureInPicture' in window)) return; + + try { + const pip = await (window as any).documentPictureInPicture.requestWindow({ + width: 360, + height: 320, + }); + + // Copy all stylesheets into PiP window + document.querySelectorAll('link[rel="stylesheet"], style').forEach((el) => { + pip.document.head.appendChild(el.cloneNode(true)); + }); + + // Base styles for PiP body + pip.document.body.style.margin = '0'; + pip.document.body.style.backgroundColor = '#0a0a0a'; + pip.document.body.style.overflow = 'hidden'; + pip.document.body.style.fontFamily = 'system-ui, -apple-system, sans-serif'; + + // Create portal container + const container = pip.document.createElement('div'); + pip.document.body.appendChild(container); + pipContainerRef.current = container as unknown as HTMLDivElement; + + setPipWindow(pip); + + pip.addEventListener('pagehide', () => { + setPipWindow(null); + pipContainerRef.current = null; + }); + } catch (err) { + console.warn('PiP failed:', err); + } + }, []); + + const closePiP = useCallback(() => { + if (pipWindow) { + pipWindow.close(); + setPipWindow(null); + pipContainerRef.current = null; + } + }, [pipWindow]); + + // Cleanup PiP on unmount + useEffect(() => { + return () => { + if (pipWindow) pipWindow.close(); + }; + }, [pipWindow]); + // Load notebooks useEffect(() => { authFetch('/api/notebooks') @@ -609,6 +671,29 @@ export default function VoicePage() { return () => window.removeEventListener('keydown', handler); }, [toggleRecording, saveToRNotes, state]); + // Keyboard events inside PiP window + useEffect(() => { + if (!pipWindow) return; + const handler = (e: Event) => { + const ke = e as KeyboardEvent; + const target = ke.target as HTMLElement; + if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT' || target.isContentEditable) return; + + if (ke.code === 'Space') { + ke.preventDefault(); + toggleRecording(); + } + if ((ke.ctrlKey || ke.metaKey) && ke.code === 'Enter' && state === 'done') { + ke.preventDefault(); + saveToRNotes(); + } + }; + pipWindow.document.addEventListener('keydown', handler); + return () => { + try { pipWindow.document.removeEventListener('keydown', handler); } catch {} + }; + }, [pipWindow, toggleRecording, saveToRNotes, state]); + // --- Render --- const hasLiveText = liveText || interimText || segments.length > 0; @@ -646,6 +731,31 @@ export default function VoicePage() { Local )} + {/* Pop out to floating window */} + {pipSupported && !pipWindow && ( + + )} + {pipWindow && ( + + )} {/* Install app button */} {!isInstalled && installPrompt && ( + + + {/* Record button + timer row */} +
+ +
+
+ {formatTime(state === 'done' ? duration : elapsed)} +
+

+ {state === 'idle' && 'Tap or Space'} + {state === 'recording' && 'Recording... tap to stop'} + {state === 'processing' && (offlineProgress?.message || 'Processing...')} + {state === 'done' && 'Recording complete'} +

+
+
+ + {/* Live transcript (while recording) */} + {state === 'recording' && hasLiveText && ( +
+

+ {segments.length > 0 ? segments.map((s) => s.text).join(' ') : liveText} + {interimText && {interimText}} +

+
+ )} + + {/* Done state: transcript + actions */} + {state === 'done' && ( + <> +
+ {finalTranscript ? ( +

{finalTranscript}

+ ) : ( +

No transcript available

+ )} +
+
+ + {hasTranscript && ( + + )} + +
+ + )} + + {/* Status */} + {status && ( +
+ {status.message} +
+ )} + + {/* PiP footer hint */} +
+ Space record + {' '}Ctrl+⏎ save +
+ , + pipContainerRef.current + )} ); }