spot: refactor popup code, split audio logic from ui code

This commit is contained in:
nick-delirium 2025-05-07 16:24:29 +02:00
parent a13f427816
commit a009ff928c
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
8 changed files with 428 additions and 300 deletions

View file

@ -1,165 +1,49 @@
import orLogo from "~/assets/orSpot.svg"; import { createEffect, onMount } from "solid-js";
import micOff from "~/assets/mic-off-red.svg";
import micOn from "~/assets/mic-on-dark.svg";
import Login from "~/entrypoints/popup/Login"; import Login from "~/entrypoints/popup/Login";
import Settings from "~/entrypoints/popup/Settings"; import Settings from "~/entrypoints/popup/Settings";
import { createSignal, createEffect, onMount } from "solid-js"; import Header from "./components/Header";
import Dropdown from "~/entrypoints/popup/Dropdown"; import RecordingControls from "./components/RecordingControls";
import Button from "~/entrypoints/popup/Button"; import AudioPicker from "./components/AudioPicker";
import { import { useAppState } from "./hooks/useAppState";
ChevronSvg, import { useAudioDevices } from "./hooks/useAudioDevices";
RecordDesktopSvg, import { AppState } from "./types";
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 (
<div class={"flex items-center gap-1"}>
<div
class="flex items-center gap-1 cursor-pointer hover:opacity-50"
onClick={orSite}
>
<img src={orLogo} class="w-5" alt={"OpenReplay Spot"} />
<div class={"text-neutral-600"}>
<span class={"text-lg font-semibold text-black"}>OpenReplay Spot</span>
</div>
</div>
<div class={"ml-auto flex items-center gap-2"}>
<div class="text-sm tooltip tooltip-bottom" data-tip="My Spots">
<div onClick={openHomePage}>
<div class={"cursor-pointer p-2 hover:bg-indigo-50 rounded-full"}>
<HomePageSvg />
</div>
</div>
</div>
<div
class="text-sm tooltip tooltip-bottom"
data-tip="Get help on Slack"
>
<a
href={
"https://join.slack.com/t/openreplay/shared_invite/zt-2brqlwcis-k7OtqHkW53EAoTRqPjCmyg"
}
target={"_blank"}
>
<div class={"cursor-pointer p-2 hover:bg-indigo-50 rounded-full"}>
<SlackSvg />
</div>
</a>
</div>
<div
class="text-sm tooltip tooltip-bottom"
data-tip="Settings"
onClick={openSettings}
>
<div class={"cursor-pointer p-2 hover:bg-indigo-50 rounded-full"}>
<SettingsSvg />
</div>
</div>
</div>
</div>
);
}
const STATE = {
empty: "empty",
login: "login",
ready: "ready",
starting: "starting",
recording: "recording",
};
function App() { function App() {
const [state, setState] = createSignal(STATE.empty); const {
const [isSettingsOpen, setIsSettingsOpen] = createSignal(false); state,
const [mic, setMic] = createSignal(false); isSettingsOpen,
const [selectedAudioDevice, setSelectedAudioDevice] = createSignal(""); startRecording,
const [hasPermissions, setHasPermissions] = createSignal(false); stopRecording,
openSettings,
closeSettings,
} = useAppState();
const {
audioDevices,
selectedAudioDevice,
mic,
hasPermissions,
isChecking,
checkAudioDevices,
handleMicToggle,
selectAudioDevice,
} = useAudioDevices();
// Listen for mic status updates from background
onMount(() => { onMount(() => {
browser.runtime.onMessage.addListener((message) => { 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") { if (message.type === "popup:mic-status") {
setMic(message.status); setMic(message.status);
} }
}); });
void browser.runtime.sendMessage({ type: "popup:check-status" });
}); });
const startRecording = async (reqTab: "tab" | "desktop") => { const handleStartRecording = (area: "tab" | "desktop") => {
setState(STATE.starting); startRecording(area, mic(), selectedAudioDevice(), hasPermissions());
await browser.runtime.sendMessage({
type: "popup:start",
area: reqTab,
mic: mic(),
audioId: selectedAudioDevice(),
permissions: hasPermissions(),
});
window.close();
}; };
const stopRecording = () => { const handleStopRecording = () => {
void browser.runtime.sendMessage({ stopRecording(mic(), selectedAudioDevice());
type: "popup:stop",
mic: mic(),
audioId: selectedAudioDevice(),
});
};
const toggleMic = async () => {
setMic(!mic());
};
const openSettings = () => {
setIsSettingsOpen(true);
};
const closeSettings = () => {
setIsSettingsOpen(false);
}; };
return ( return (
@ -167,58 +51,30 @@ function App() {
{isSettingsOpen() ? ( {isSettingsOpen() ? (
<Settings goBack={closeSettings} /> <Settings goBack={closeSettings} />
) : ( ) : (
<div class={"flex flex-col gap-4 p-5"}> <div class="flex flex-col gap-4 p-5">
<Header openSettings={openSettings} /> <Header openSettings={openSettings} />
{state() === STATE.login ? ( {state() === AppState.LOGIN ? (
<Login /> <Login />
) : ( ) : (
<> <>
{state() === STATE.recording ? ( <RecordingControls
<Button state={state()}
name={"End Recording"} startRecording={handleStartRecording}
onClick={() => stopRecording()} stopRecording={handleStopRecording}
/> />
) : null}
{state() === STATE.starting ? (
<div
class={
"flex flex-row items-center gap-2 w-full justify-center"
}
>
<div class="py-4">Your recording is starting</div>
</div>
) : null}
{state() === STATE.ready ? (
<>
<div class="flex flex-row items-center gap-2 w-full justify-center">
<button
class="btn bg-indigo-100 text-base hover:bg-primary hover:text-white w-6/12"
name="Record Tab"
onClick={() => startRecording("tab")}
>
<RecordTabSvg />
Record Tab
</button>
<button {state() === AppState.READY && (
class="btn bg-teal-50 text-base hover:bg-primary hover:text-white" <AudioPicker
name={"Record Desktop"} mic={mic}
onClick={() => startRecording("desktop")} audioDevices={audioDevices}
> selectedAudioDevice={selectedAudioDevice}
<RecordDesktopSvg /> isChecking={isChecking}
Record Desktop onMicToggle={handleMicToggle}
</button> onCheckAudio={checkAudioDevices}
</div> onSelectDevice={selectAudioDevice}
<AudioPicker />
mic={mic} )}
toggleMic={toggleMic}
selectedAudioDevice={selectedAudioDevice}
setSelectedAudioDevice={setSelectedAudioDevice}
setHasPermissions={setHasPermissions}
/>
</>
) : null}
</> </>
)} )}
</div> </div>
@ -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 (
<div class={"inline-flex items-center gap-1 text-xs"}>
<div
class={
"p-1 cursor-pointer btn btn-xs bg-white hover:bg-indigo-50 pointer-events-auto tooltip tooltip-right text-sm font-normal"
}
data-tip={props.mic() ? "Switch Off Mic" : "Switch On Mic"}
onClick={onMicToggle}
>
<img
src={props.mic() ? micOn : micOff}
alt={props.mic() ? "microphone on" : "microphone off"}
width={16}
height={16}
/>
</div>
<div
class={
"flex items-center gap-1 btn btn-xs btn-ghost hover:bg-neutral/20 rounded-lg pointer-events-auto"
}
onClick={checkAudio}
>
{audioDevices().length === 0 ? (
<div class="max-w-64 block leading-tight cursor-pointer whitespace-nowrap overflow-hidden font-normal">
{checkedAudioDevices() === 1
? "Loading audio devices"
: "Grant microphone access"}
</div>
) : (
<Dropdown
options={audioDevices()}
selected={props.selectedAudioDevice()}
onChange={onSelect}
/>
)}
<ChevronSvg />
</div>
</div>
);
}
export default App; export default App;

View file

@ -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<AudioPickerProps> = (props) => {
return (
<div class="inline-flex items-center gap-1 text-xs">
<div
class="p-1 cursor-pointer btn btn-xs bg-white hover:bg-indigo-50 pointer-events-auto tooltip tooltip-right text-sm font-normal"
data-tip={props.mic() ? "Switch Off Mic" : "Switch On Mic"}
onClick={props.onMicToggle}
>
<img
src={props.mic() ? micOn : micOff}
alt={props.mic() ? "microphone on" : "microphone off"}
width={16}
height={16}
/>
</div>
<div
class="flex items-center gap-1 btn btn-xs btn-ghost hover:bg-neutral/20 rounded-lg pointer-events-auto"
onClick={props.onCheckAudio}
>
{props.audioDevices().length === 0 ? (
<div class="max-w-64 block leading-tight cursor-pointer whitespace-nowrap overflow-hidden font-normal">
{props.isChecking()
? "Loading audio devices"
: "Grant microphone access"}
</div>
) : (
<Dropdown
options={props.audioDevices()}
selected={props.selectedAudioDevice()}
onChange={props.onSelectDevice}
/>
)}
<ChevronSvg />
</div>
</div>
);
};
export default AudioPicker;

View file

@ -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<HeaderProps> = (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 (
<div class="flex items-center gap-1">
<div
class="flex items-center gap-1 cursor-pointer hover:opacity-50"
onClick={openOrSite}
>
<img src={orLogo} class="w-5" alt="OpenReplay Spot" />
<div class="text-neutral-600">
<span class="text-lg font-semibold text-black">OpenReplay Spot</span>
</div>
</div>
<div class="ml-auto flex items-center gap-2">
<div class="text-sm tooltip tooltip-bottom" data-tip="My Spots">
<div onClick={openHomePage}>
<div class="cursor-pointer p-2 hover:bg-indigo-50 rounded-full">
<HomePageSvg />
</div>
</div>
</div>
<div
class="text-sm tooltip tooltip-bottom"
data-tip="Get help on Slack"
>
<a
href="https://join.slack.com/t/openreplay/shared_invite/zt-2brqlwcis-k7OtqHkW53EAoTRqPjCmyg"
target="_blank"
rel="noopener noreferrer"
>
<div class="cursor-pointer p-2 hover:bg-indigo-50 rounded-full">
<SlackSvg />
</div>
</a>
</div>
<div
class="text-sm tooltip tooltip-bottom"
data-tip="Settings"
onClick={props.openSettings}
>
<div class="cursor-pointer p-2 hover:bg-indigo-50 rounded-full">
<SettingsSvg />
</div>
</div>
</div>
</div>
);
};
export default Header;

View file

@ -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<RecordingControlsProps> = (props) => {
return (
<>
{props.state === AppState.RECORDING && (
<Button name="End Recording" onClick={props.stopRecording} />
)}
{props.state === AppState.STARTING && (
<div class="flex flex-row items-center gap-2 w-full justify-center">
<div class="py-4">Your recording is starting</div>
</div>
)}
{props.state === AppState.READY && (
<div class="flex flex-row items-center gap-2 w-full justify-center">
<button
class="btn bg-indigo-100 text-base hover:bg-primary hover:text-white w-6/12"
onClick={() => props.startRecording("tab")}
>
<RecordTabSvg />
Record Tab
</button>
<button
class="btn bg-teal-50 text-base hover:bg-primary hover:text-white"
onClick={() => props.startRecording("desktop")}
>
<RecordDesktopSvg />
Record Desktop
</button>
</div>
)}
</>
);
};
export default RecordingControls;

View file

@ -0,0 +1,63 @@
import { createSignal, onMount } from "solid-js";
import { AppState, RecordingArea } from "../types";
export function useAppState() {
const [state, setState] = createSignal<AppState>(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,
};
}

View file

@ -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<AudioDevice[]>([]);
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<AudioDevice[]> => {
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,
};
}

View file

@ -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";

View file

@ -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: [],
};
}
}