From a009ff928ce335aa650eba096776c3175b6afc62 Mon Sep 17 00:00:00 2001 From: nick-delirium Date: Wed, 7 May 2025 16:24:29 +0200 Subject: [PATCH] spot: refactor popup code, split audio logic from ui code --- spot/entrypoints/popup/App.tsx | 349 +++--------------- .../popup/components/AudioPicker.tsx | 57 +++ spot/entrypoints/popup/components/Header.tsx | 73 ++++ .../popup/components/RecordingControls.tsx | 48 +++ spot/entrypoints/popup/hooks/useAppState.ts | 63 ++++ .../popup/hooks/useAudioDevices.ts | 100 +++++ spot/entrypoints/popup/types/index.ts | 14 + spot/entrypoints/popup/utils/audio.ts | 24 ++ 8 files changed, 428 insertions(+), 300 deletions(-) create mode 100644 spot/entrypoints/popup/components/AudioPicker.tsx create mode 100644 spot/entrypoints/popup/components/Header.tsx create mode 100644 spot/entrypoints/popup/components/RecordingControls.tsx create mode 100644 spot/entrypoints/popup/hooks/useAppState.ts create mode 100644 spot/entrypoints/popup/hooks/useAudioDevices.ts create mode 100644 spot/entrypoints/popup/types/index.ts create mode 100644 spot/entrypoints/popup/utils/audio.ts diff --git a/spot/entrypoints/popup/App.tsx b/spot/entrypoints/popup/App.tsx index 3d9314f76..3317418aa 100644 --- a/spot/entrypoints/popup/App.tsx +++ b/spot/entrypoints/popup/App.tsx @@ -1,165 +1,49 @@ -import orLogo from "~/assets/orSpot.svg"; -import micOff from "~/assets/mic-off-red.svg"; -import micOn from "~/assets/mic-on-dark.svg"; +import { createEffect, onMount } from "solid-js"; import Login from "~/entrypoints/popup/Login"; import Settings from "~/entrypoints/popup/Settings"; -import { createSignal, createEffect, onMount } from "solid-js"; -import Dropdown from "~/entrypoints/popup/Dropdown"; -import Button from "~/entrypoints/popup/Button"; -import { - ChevronSvg, - RecordDesktopSvg, - RecordTabSvg, - HomePageSvg, - SlackSvg, - SettingsSvg, -} from "./Icons"; - -async function getAudioDevices() { - try { - await navigator.mediaDevices.getUserMedia({ audio: true }); - const devices = await navigator.mediaDevices.enumerateDevices(); - const audioDevices = devices - .filter((device) => device.kind === "audioinput") - .map((device) => ({ label: device.label, id: device.deviceId })); - - return { granted: true, audioDevices }; - } catch (error) { - console.error("Error accessing audio devices:", error); - const msg = error.message ?? ""; - return { - granted: false, - denied: msg.includes("denied"), - audioDevices: [], - }; - } -} - -const orSite = () => { - window.open("https://openreplay.com", "_blank"); -}; - -function Header({ openSettings }: { openSettings: () => void }) { - const openHomePage = async () => { - const { settings } = await chrome.storage.local.get("settings"); - return window.open(`${settings.ingestPoint}/spots`, "_blank"); - }; - return ( -
-
- {"OpenReplay -
- OpenReplay Spot -
-
- -
-
-
-
- -
-
-
- - - -
-
- -
-
-
-
- ); -} - -const STATE = { - empty: "empty", - login: "login", - ready: "ready", - starting: "starting", - recording: "recording", -}; +import Header from "./components/Header"; +import RecordingControls from "./components/RecordingControls"; +import AudioPicker from "./components/AudioPicker"; +import { useAppState } from "./hooks/useAppState"; +import { useAudioDevices } from "./hooks/useAudioDevices"; +import { AppState } from "./types"; function App() { - const [state, setState] = createSignal(STATE.empty); - const [isSettingsOpen, setIsSettingsOpen] = createSignal(false); - const [mic, setMic] = createSignal(false); - const [selectedAudioDevice, setSelectedAudioDevice] = createSignal(""); - const [hasPermissions, setHasPermissions] = createSignal(false); + const { + state, + isSettingsOpen, + startRecording, + stopRecording, + openSettings, + closeSettings, + } = useAppState(); + const { + audioDevices, + selectedAudioDevice, + mic, + hasPermissions, + isChecking, + checkAudioDevices, + handleMicToggle, + selectAudioDevice, + } = useAudioDevices(); + + // Listen for mic status updates from background onMount(() => { browser.runtime.onMessage.addListener((message) => { - if (message.type === "popup:no-login") { - setState(STATE.login); - } - if (message.type === "popup:login") { - setState(STATE.ready); - } - if (message.type === "popup:stopped") { - setState(STATE.ready); - } - if (message.type === "popup:started") { - setState(STATE.recording); - } if (message.type === "popup:mic-status") { setMic(message.status); } }); - void browser.runtime.sendMessage({ type: "popup:check-status" }); }); - const startRecording = async (reqTab: "tab" | "desktop") => { - setState(STATE.starting); - await browser.runtime.sendMessage({ - type: "popup:start", - area: reqTab, - mic: mic(), - audioId: selectedAudioDevice(), - permissions: hasPermissions(), - }); - window.close(); + const handleStartRecording = (area: "tab" | "desktop") => { + startRecording(area, mic(), selectedAudioDevice(), hasPermissions()); }; - const stopRecording = () => { - void browser.runtime.sendMessage({ - type: "popup:stop", - mic: mic(), - audioId: selectedAudioDevice(), - }); - }; - - const toggleMic = async () => { - setMic(!mic()); - }; - - const openSettings = () => { - setIsSettingsOpen(true); - }; - const closeSettings = () => { - setIsSettingsOpen(false); + const handleStopRecording = () => { + stopRecording(mic(), selectedAudioDevice()); }; return ( @@ -167,58 +51,30 @@ function App() { {isSettingsOpen() ? ( ) : ( -
+
- {state() === STATE.login ? ( + {state() === AppState.LOGIN ? ( ) : ( <> - {state() === STATE.recording ? ( - + - -
- - - ) : null} + {state() === AppState.READY && ( + + )} )}
@@ -227,111 +83,4 @@ function App() { ); } -interface IAudioPicker { - mic: () => boolean; - toggleMic: () => void; - selectedAudioDevice: () => string; - setSelectedAudioDevice: (value: string) => void; - setHasPermissions: (value: boolean) => void; -} -function AudioPicker(props: IAudioPicker) { - const [audioDevices, setAudioDevices] = createSignal( - [] as { label: string; id: string }[], - ); - const [checkedAudioDevices, setCheckedAudioDevices] = createSignal(0); - - createEffect(() => { - chrome.storage.local.get("audioPerm", (data) => { - if (data.audioPerm && audioDevices().length === 0) { - props.setHasPermissions(true); - void checkAudioDevices(); - } - }); - }); - - const checkAudioDevices = async () => { - const { granted, audioDevices, denied } = await getAudioDevices(); - if (!granted && !denied) { - void browser.runtime.sendMessage({ - type: "popup:get-audio-perm", - }); - browser.runtime.onMessage.addListener((message) => { - if (message.type === "popup:audio-perm") { - void checkAudioDevices(); - } - }); - } else if (audioDevices.length > 0) { - chrome.storage.local.set({ audioPerm: granted }); - setAudioDevices(audioDevices); - props.setSelectedAudioDevice(audioDevices[0]?.id || ""); - } - }; - - const checkAudio = async () => { - if (checkedAudioDevices() > 0) { - return; - } - setCheckedAudioDevices(1); - await checkAudioDevices(); - setCheckedAudioDevices(2); - }; - const onSelect = (value) => { - props.setSelectedAudioDevice(value); - if (!props.mic()) { - props.toggleMic(); - } - }; - - const onMicToggle = async () => { - if (!audioDevices().length) { - return await checkAudioDevices(); - } - if (!props.selectedAudioDevice() && audioDevices().length) { - onSelect(audioDevices()[0].id); - } else { - props.toggleMic(); - } - }; - - return ( -
-
- {props.mic() -
-
- {audioDevices().length === 0 ? ( -
- {checkedAudioDevices() === 1 - ? "Loading audio devices" - : "Grant microphone access"} -
- ) : ( - - )} - -
-
- ); -} - export default App; diff --git a/spot/entrypoints/popup/components/AudioPicker.tsx b/spot/entrypoints/popup/components/AudioPicker.tsx new file mode 100644 index 000000000..627b5c840 --- /dev/null +++ b/spot/entrypoints/popup/components/AudioPicker.tsx @@ -0,0 +1,57 @@ +import { Component, For } from "solid-js"; +import micOff from "~/assets/mic-off-red.svg"; +import micOn from "~/assets/mic-on-dark.svg"; +import Dropdown from "~/entrypoints/popup/Dropdown"; +import { ChevronSvg } from "../Icons"; +import { AudioDevice } from "../types"; + +interface AudioPickerProps { + mic: () => boolean; + audioDevices: () => AudioDevice[]; + selectedAudioDevice: () => string; + isChecking: () => boolean; + onMicToggle: () => void; + onCheckAudio: () => void; + onSelectDevice: (deviceId: string) => void; +} + +const AudioPicker: Component = (props) => { + return ( +
+
+ {props.mic() +
+ +
+ {props.audioDevices().length === 0 ? ( +
+ {props.isChecking() + ? "Loading audio devices" + : "Grant microphone access"} +
+ ) : ( + + )} + +
+
+ ); +}; + +export default AudioPicker; diff --git a/spot/entrypoints/popup/components/Header.tsx b/spot/entrypoints/popup/components/Header.tsx new file mode 100644 index 000000000..ec6f9d5b8 --- /dev/null +++ b/spot/entrypoints/popup/components/Header.tsx @@ -0,0 +1,73 @@ +import { Component } from "solid-js"; +import orLogo from "~/assets/orSpot.svg"; +import { + HomePageSvg, + SlackSvg, + SettingsSvg, +} from "../Icons"; + +interface HeaderProps { + openSettings: () => void; +} + +const Header: Component = (props) => { + const openHomePage = async () => { + const { settings } = await chrome.storage.local.get("settings"); + return window.open(`${settings.ingestPoint}/spots`, "_blank"); + }; + + const openOrSite = () => { + window.open("https://openreplay.com", "_blank"); + }; + + return ( +
+
+ OpenReplay Spot +
+ OpenReplay Spot +
+
+ +
+
+
+
+ +
+
+
+ + + +
+
+ +
+
+
+
+ ); +}; + +export default Header; diff --git a/spot/entrypoints/popup/components/RecordingControls.tsx b/spot/entrypoints/popup/components/RecordingControls.tsx new file mode 100644 index 000000000..1fdd8fa61 --- /dev/null +++ b/spot/entrypoints/popup/components/RecordingControls.tsx @@ -0,0 +1,48 @@ +import { Component } from "solid-js"; +import { RecordTabSvg, RecordDesktopSvg } from "../Icons"; +import Button from "~/entrypoints/popup/Button"; +import { AppState, RecordingArea } from "../types"; + +interface RecordingControlsProps { + state: AppState; + startRecording: (area: RecordingArea) => void; + stopRecording: () => void; +} + +const RecordingControls: Component = (props) => { + return ( + <> + {props.state === AppState.RECORDING && ( + + + + + )} + + ); +}; + +export default RecordingControls; diff --git a/spot/entrypoints/popup/hooks/useAppState.ts b/spot/entrypoints/popup/hooks/useAppState.ts new file mode 100644 index 000000000..4c7b6a844 --- /dev/null +++ b/spot/entrypoints/popup/hooks/useAppState.ts @@ -0,0 +1,63 @@ +import { createSignal, onMount } from "solid-js"; +import { AppState, RecordingArea } from "../types"; + +export function useAppState() { + const [state, setState] = createSignal(AppState.EMPTY); + const [isSettingsOpen, setIsSettingsOpen] = createSignal(false); + + onMount(() => { + browser.runtime.onMessage.addListener((message) => { + if (message.type === "popup:no-login") { + setState(AppState.LOGIN); + } + if (message.type === "popup:login") { + setState(AppState.READY); + } + if (message.type === "popup:stopped") { + setState(AppState.READY); + } + if (message.type === "popup:started") { + setState(AppState.RECORDING); + } + }); + + void browser.runtime.sendMessage({ type: "popup:check-status" }); + }); + + const startRecording = async ( + area: RecordingArea, + mic: boolean, + audioId: string, + permissions: boolean + ) => { + setState(AppState.STARTING); + await browser.runtime.sendMessage({ + type: "popup:start", + area, + mic, + audioId, + permissions, + }); + window.close(); + }; + + const stopRecording = (mic: boolean, audioId: string) => { + void browser.runtime.sendMessage({ + type: "popup:stop", + mic, + audioId, + }); + }; + + const openSettings = () => setIsSettingsOpen(true); + const closeSettings = () => setIsSettingsOpen(false); + + return { + state, + isSettingsOpen, + startRecording, + stopRecording, + openSettings, + closeSettings, + }; +} diff --git a/spot/entrypoints/popup/hooks/useAudioDevices.ts b/spot/entrypoints/popup/hooks/useAudioDevices.ts new file mode 100644 index 000000000..25ae807d3 --- /dev/null +++ b/spot/entrypoints/popup/hooks/useAudioDevices.ts @@ -0,0 +1,100 @@ +import { createSignal, createEffect } from "solid-js"; +import { AudioDevice } from "../types"; +import { getAudioDevices } from "../utils/audio"; + +export function useAudioDevices() { + const [audioDevices, setAudioDevices] = createSignal([]); + const [selectedAudioDevice, setSelectedAudioDevice] = createSignal(""); + const [mic, setMic] = createSignal(false); + const [hasPermissions, setHasPermissions] = createSignal(false); + const [isChecking, setIsChecking] = createSignal(false); + + createEffect(() => { + chrome.storage.local.get("audioPerm", (data) => { + if (data.audioPerm && audioDevices().length === 0) { + setHasPermissions(true); + checkAudioDevices().then(async (devices) => { + const { selectedAudioId, micOn } = await chrome.storage.local.get([ + "selectedAudioId", + "micOn", + ]); + + if (selectedAudioId) { + const selectedDevice = devices.find( + (device) => device.id === selectedAudioId + ); + if (selectedDevice) { + setSelectedAudioDevice(selectedDevice.id); + } + } + + if (micOn) { + toggleMic(); + } + }); + } + }); + }); + + const checkAudioDevices = async (): Promise => { + setIsChecking(true); + + const { granted, audioDevices, denied } = await getAudioDevices(); + + if (!granted && !denied) { + void browser.runtime.sendMessage({ + type: "popup:get-audio-perm", + }); + + browser.runtime.onMessage.addListener((message) => { + if (message.type === "popup:audio-perm") { + void checkAudioDevices(); + } + }); + } else if (audioDevices.length > 0) { + chrome.storage.local.set({ audioPerm: granted }); + setAudioDevices(audioDevices); + setSelectedAudioDevice(audioDevices[0]?.id || ""); + } + + setIsChecking(false); + return audioDevices; + }; + + const toggleMic = () => { + setMic(!mic()); + }; + + const selectAudioDevice = (deviceId: string) => { + setSelectedAudioDevice(deviceId); + if (!mic()) { + toggleMic(); + } + chrome.storage.local.set({ selectedAudioId: deviceId, micOn: true }); + }; + + const handleMicToggle = async () => { + if (!audioDevices().length) { + return await checkAudioDevices(); + } + + if (!selectedAudioDevice() && audioDevices().length) { + selectAudioDevice(audioDevices()[0].id); + } else { + chrome.storage.local.set({ micOn: !mic() }); + toggleMic(); + } + }; + + return { + audioDevices, + selectedAudioDevice, + mic, + hasPermissions, + isChecking, + checkAudioDevices, + toggleMic, + selectAudioDevice, + handleMicToggle, + }; +} diff --git a/spot/entrypoints/popup/types/index.ts b/spot/entrypoints/popup/types/index.ts new file mode 100644 index 000000000..71f13490f --- /dev/null +++ b/spot/entrypoints/popup/types/index.ts @@ -0,0 +1,14 @@ +export type AudioDevice = { + label: string; + id: string; +}; + +export enum AppState { + EMPTY = "empty", + LOGIN = "login", + READY = "ready", + STARTING = "starting", + RECORDING = "recording", +} + +export type RecordingArea = "tab" | "desktop"; diff --git a/spot/entrypoints/popup/utils/audio.ts b/spot/entrypoints/popup/utils/audio.ts new file mode 100644 index 000000000..dab6cf210 --- /dev/null +++ b/spot/entrypoints/popup/utils/audio.ts @@ -0,0 +1,24 @@ +import type { AudioDevice } from "../types"; +export async function getAudioDevices(): Promise<{ + granted: boolean; + denied?: boolean; + audioDevices: AudioDevice[]; +}> { + try { + await navigator.mediaDevices.getUserMedia({ audio: true }); + const devices = await navigator.mediaDevices.enumerateDevices(); + const audioDevices = devices + .filter((device) => device.kind === "audioinput") + .map((device) => ({ label: device.label, id: device.deviceId })); + + return { granted: true, audioDevices }; + } catch (error) { + console.error("Error accessing audio devices:", error); + const msg = error.message ?? ""; + return { + granted: false, + denied: msg.includes("denied"), + audioDevices: [], + }; + } +}