spot: refactor popup code, split audio logic from ui code
This commit is contained in:
parent
a13f427816
commit
a009ff928c
8 changed files with 428 additions and 300 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
57
spot/entrypoints/popup/components/AudioPicker.tsx
Normal file
57
spot/entrypoints/popup/components/AudioPicker.tsx
Normal 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;
|
||||||
73
spot/entrypoints/popup/components/Header.tsx
Normal file
73
spot/entrypoints/popup/components/Header.tsx
Normal 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;
|
||||||
48
spot/entrypoints/popup/components/RecordingControls.tsx
Normal file
48
spot/entrypoints/popup/components/RecordingControls.tsx
Normal 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;
|
||||||
63
spot/entrypoints/popup/hooks/useAppState.ts
Normal file
63
spot/entrypoints/popup/hooks/useAppState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
100
spot/entrypoints/popup/hooks/useAudioDevices.ts
Normal file
100
spot/entrypoints/popup/hooks/useAudioDevices.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
14
spot/entrypoints/popup/types/index.ts
Normal file
14
spot/entrypoints/popup/types/index.ts
Normal 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";
|
||||||
24
spot/entrypoints/popup/utils/audio.ts
Normal file
24
spot/entrypoints/popup/utils/audio.ts
Normal 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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue