Allowing settings to be changed in URL hash (#45)

Co-authored-by: mattherzog <Herzog.Matt@gmail.com>
Co-authored-by: Neil Dwyer <neildwyer1991@gmail.com>
This commit is contained in:
David Zhao
2024-05-06 10:07:37 -07:00
committed by GitHub
parent 0d82ab4b26
commit 5b99dae15f
14 changed files with 1353 additions and 292 deletions

View File

@@ -1,54 +0,0 @@
import jsYaml from "js-yaml";
const APP_CONFIG = process.env.NEXT_PUBLIC_APP_CONFIG;
export type AppConfig = {
title: string;
description: string;
github_link?: string;
theme_color?: string;
video_fit?: "cover" | "contain";
outputs: {
audio: boolean;
video: boolean;
chat: boolean;
};
inputs: {
mic: boolean;
camera: boolean;
};
show_qr?: boolean;
};
// Fallback if NEXT_PUBLIC_APP_CONFIG is not set
const defaultConfig: AppConfig = {
title: "Agents Playground",
description: "A playground for testing LiveKit Agents",
theme_color: "cyan",
video_fit: "cover",
outputs: {
audio: true,
video: true,
chat: true,
},
inputs: {
mic: true,
camera: true,
},
show_qr: false,
};
export const useAppConfig = (): AppConfig => {
if (APP_CONFIG) {
try {
const parsedConfig = jsYaml.load(APP_CONFIG);
console.log("parsedConfig:", parsedConfig);
return parsedConfig as AppConfig;
} catch (e) {
console.error("Error parsing app config:", e);
return defaultConfig;
}
} else {
return defaultConfig;
}
};

198
src/hooks/useConfig.tsx Normal file
View File

@@ -0,0 +1,198 @@
"use client"
import { getCookie, setCookie } from "cookies-next";
import jsYaml from "js-yaml";
import { useRouter } from "next/navigation";
import React, { createContext, useCallback, useMemo, useState } from "react";
import { useEffect } from "react";
export type AppConfig = {
title: string;
description: string;
github_link?: string;
video_fit?: "cover" | "contain";
settings: UserSettings;
show_qr?: boolean;
};
export type UserSettings = {
theme_color: string;
chat: boolean;
inputs: {
camera: boolean;
mic: boolean;
};
outputs: {
audio: boolean;
video: boolean;
};
ws_url: string;
token: string;
};
// Fallback if NEXT_PUBLIC_APP_CONFIG is not set
const defaultConfig: AppConfig = {
title: "LiveKit Agents Playground",
description: "A playground for testing LiveKit Agents",
video_fit: "cover",
settings: {
theme_color: "cyan",
chat: true,
inputs: {
camera: true,
mic: true,
},
outputs: {
audio: true,
video: true,
},
ws_url: "",
token: ""
},
show_qr: false,
};
const useAppConfig = (): AppConfig => {
return useMemo(() => {
if (process.env.NEXT_PUBLIC_APP_CONFIG) {
try {
const parsedConfig = jsYaml.load(
process.env.NEXT_PUBLIC_APP_CONFIG
) as AppConfig;
return parsedConfig;
} catch (e) {
console.error("Error parsing app config:", e);
}
}
return defaultConfig;
}, []);
};
type ConfigData = {
config: AppConfig;
setUserSettings: (settings: UserSettings) => void;
};
const ConfigContext = createContext<ConfigData | undefined>(undefined);
export const ConfigProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const appConfig = useAppConfig();
const router = useRouter();
const getSettingsFromUrl = useCallback(() => {
if(typeof window === 'undefined') {
return null;
}
if (!window.location.hash) {
return null;
}
const params = new URLSearchParams(window.location.hash.replace("#", ""));
return {
chat: params.get("chat") === "1",
theme_color: params.get("theme_color"),
inputs: {
camera: params.get("cam") === "1",
mic: params.get("mic") === "1",
},
outputs: {
audio: params.get("audio") === "1",
video: params.get("video") === "1",
chat: params.get("chat") === "1",
},
ws_url: "",
token: ""
} as UserSettings;
}, [])
const getSettingsFromCookies = useCallback(() => {
const jsonSettings = getCookie("lk_settings");
if (!jsonSettings) {
return null;
}
return JSON.parse(jsonSettings) as UserSettings;
}, [])
const setUrlSettings = useCallback((us: UserSettings) => {
const obj = new URLSearchParams({
cam: boolToString(us.inputs.camera),
mic: boolToString(us.inputs.mic),
video: boolToString(us.outputs.video),
audio: boolToString(us.outputs.audio),
chat: boolToString(us.chat),
theme_color: us.theme_color || "cyan",
});
// Note: We don't set ws_url and token to the URL on purpose
router.replace("/#" + obj.toString());
}, [router])
const setCookieSettings = useCallback((us: UserSettings) => {
const json = JSON.stringify(us);
setCookie("lk_settings", json);
}, [])
const getConfig = useCallback(() => {
const appConfigFromSettings = appConfig;
const cookieSettigs = getSettingsFromCookies();
const urlSettings = getSettingsFromUrl();
if(!cookieSettigs) {
if(urlSettings) {
setCookieSettings(urlSettings);
}
}
if(!urlSettings) {
if(cookieSettigs) {
setUrlSettings(cookieSettigs);
}
}
const newCookieSettings = getSettingsFromCookies();
if(!newCookieSettings) {
return appConfigFromSettings;
}
appConfigFromSettings.settings = newCookieSettings;
return {...appConfigFromSettings};
}, [
appConfig,
getSettingsFromCookies,
getSettingsFromUrl,
setCookieSettings,
setUrlSettings,
]);
const setUserSettings = useCallback((settings: UserSettings) => {
setUrlSettings(settings);
setCookieSettings(settings);
_setConfig((prev) => {
return {
...prev,
settings: settings,
};
})
}, [setCookieSettings, setUrlSettings]);
const [config, _setConfig] = useState<AppConfig>(getConfig());
// Run things client side because we use cookies
useEffect(() => {
_setConfig(getConfig());
}, [getConfig]);
return (
<ConfigContext.Provider value={{ config, setUserSettings }}>
{children}
</ConfigContext.Provider>
);
};
export const useConfig = () => {
const context = React.useContext(ConfigContext);
if (context === undefined) {
throw new Error("useConfig must be used within a ConfigProvider");
}
return context;
}
const boolToString = (b: boolean) => (b ? "1" : "0");

View File

@@ -0,0 +1,89 @@
"use client"
import React, { createContext, useState } from "react";
import { useConfig } from "./useConfig";
import { useCallback } from "react";
// Note: cloud mode is only used in our private, hosted version
export type Mode = "cloud" | "env" | "manual";
type TokenGeneratorData = {
shouldConnect: boolean;
wsUrl: string;
token: string;
disconnect: () => Promise<void>;
connect: (mode: Mode) => Promise<void>;
};
const TokenGeneratorContext = createContext<TokenGeneratorData | undefined>(undefined);
export const TokenGeneratorProvider = ({
children,
generateConnectionDetails,
}: {
children: React.ReactNode;
// generateConnectionDetails is only required in cloud mode
generateConnectionDetails?: () => Promise<{ wsUrl: string; token: string }>;
}) => {
const { config } = useConfig();
const [token, setToken] = useState("");
const [wsUrl, setWsUrl] = useState("");
const [shouldConnect, setShouldConnect] = useState(false);
const connect = useCallback(
async (mode: Mode) => {
if (mode === "cloud") {
if (!generateConnectionDetails) {
throw new Error(
"generateConnectionDetails must be provided in cloud mode"
);
}
const { wsUrl, token } = await generateConnectionDetails();
setWsUrl(wsUrl);
setToken(token);
} else if (mode === "env") {
const url = process.env.NEXT_PUBLIC_LIVEKIT_URL;
if (!url) {
throw new Error("NEXT_PUBLIC_LIVEKIT_URL must be set in env mode");
}
const res = await fetch("/api/token");
const { accessToken } = await res.json();
setWsUrl(url);
setToken(accessToken);
} else if (mode === "manual") {
setWsUrl(config.settings.ws_url);
setToken(config.settings.token);
}
setShouldConnect(true);
},
[
config.settings.token,
config.settings.ws_url,
generateConnectionDetails,
]
);
const disconnect = useCallback(async () => {
setShouldConnect(false);
}, []);
return (
<TokenGeneratorContext.Provider
value={{
wsUrl,
token,
shouldConnect,
connect,
disconnect,
}}
>
{children}
</TokenGeneratorContext.Provider>
);
};
export const useTokenGenerator = () => {
const context = React.useContext(TokenGeneratorContext);
if (context === undefined) {
throw new Error("useTokenGenerator must be used within a TokenGeneratorProvider");
}
return context;
}