mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-13 21:32:18 +00:00
Merge pull request #213 from storytold/bt-bflat-add-trump-page
Bt bflat add trump page
This commit is contained in:
11 files changed
+1270
No files matched your search
@@ -95,6 +95,9 @@ class ApiConfig {
|
||||
}
|
||||
}
|
||||
|
||||
//v2ApiHost = "api.dev.fakeyou.com:12345";
|
||||
//useSsl = false;
|
||||
|
||||
this.domain = domain;
|
||||
this.useSsl = useSsl;
|
||||
this.v2ApiHost = v2ApiHost;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
@@ -66,6 +66,7 @@ import { GetComputedTtsCategoryAssignmentsSuccessResponse } from "@storyteller/c
|
||||
import { NewsPage } from "./pages/news/NewsPage";
|
||||
import { LandingPage } from "./pages/landing/LandingPage";
|
||||
import { ChannelsPage } from "./pages/channels/Channels";
|
||||
import { TrumpTtsPage } from "./pages/character/trump/TrumpTtsPage";
|
||||
//import { LandingPage } from "./pages/landing/LandingPage";
|
||||
//import { VcModelListPage } from "./pages/vc/vc_model_list/VcModelListPage";
|
||||
|
||||
@@ -479,6 +480,48 @@ class PageContainer extends React.Component<Props, State> {
|
||||
<VocodesPage />
|
||||
</Route>
|
||||
|
||||
<Route path="/character/donald-trump">
|
||||
<TrumpTtsPage
|
||||
sessionWrapper={this.props.sessionWrapper}
|
||||
sessionSubscriptionsWrapper={
|
||||
this.props.sessionSubscriptionsWrapper
|
||||
}
|
||||
enqueueTtsJob={this.props.enqueueTtsJob}
|
||||
ttsInferenceJobs={this.props.ttsInferenceJobs}
|
||||
ttsModelUploadJobs={this.props.ttsModelUploadJobs}
|
||||
w2lInferenceJobs={this.props.w2lInferenceJobs}
|
||||
w2lTemplateUploadJobs={this.props.w2lTemplateUploadJobs}
|
||||
textBuffer={this.props.textBuffer}
|
||||
setTextBuffer={this.props.setTextBuffer}
|
||||
clearTextBuffer={this.props.clearTextBuffer}
|
||||
ttsModels={this.props.ttsModels}
|
||||
setTtsModels={this.props.setTtsModels}
|
||||
allTtsCategories={this.props.allTtsCategories}
|
||||
setAllTtsCategories={this.props.setAllTtsCategories}
|
||||
allTtsCategoriesByTokenMap={
|
||||
this.props.allTtsCategoriesByTokenMap
|
||||
}
|
||||
computedTtsCategoryAssignments={
|
||||
this.props.computedTtsCategoryAssignments
|
||||
}
|
||||
setComputedTtsCategoryAssignments={
|
||||
this.props.setComputedTtsCategoryAssignments
|
||||
}
|
||||
allTtsModelsByTokenMap={this.props.allTtsModelsByTokenMap}
|
||||
ttsModelsByCategoryToken={this.props.ttsModelsByCategoryToken}
|
||||
dropdownCategories={this.props.dropdownCategories}
|
||||
setDropdownCategories={this.props.setDropdownCategories}
|
||||
selectedCategories={this.props.selectedCategories}
|
||||
setSelectedCategories={this.props.setSelectedCategories}
|
||||
maybeSelectedTtsModel={this.props.maybeSelectedTtsModel}
|
||||
setMaybeSelectedTtsModel={this.props.setMaybeSelectedTtsModel}
|
||||
selectedTtsLanguageScope={this.props.selectedTtsLanguageScope}
|
||||
setSelectedTtsLanguageScope={
|
||||
this.props.setSelectedTtsLanguageScope
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="/">
|
||||
<TtsModelListPage
|
||||
sessionWrapper={this.props.sessionWrapper}
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
import React, { useEffect, useCallback, useState } from "react";
|
||||
import { t } from "i18next";
|
||||
import { Trans } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { SessionTtsInferenceResultList } from "../../../_common/SessionTtsInferenceResultsList";
|
||||
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
|
||||
import { TtsInferenceJob } from "@storyteller/components/src/jobs/TtsInferenceJobs";
|
||||
import { TtsModelUploadJob } from "@storyteller/components/src/jobs/TtsModelUploadJobs";
|
||||
import { W2lInferenceJob } from "@storyteller/components/src/jobs/W2lInferenceJobs";
|
||||
import { W2lTemplateUploadJob } from "@storyteller/components/src/jobs/W2lTemplateUploadJobs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
ListTtsModels,
|
||||
TtsModelListItem,
|
||||
} from "@storyteller/components/src/api/tts/ListTtsModels";
|
||||
import {
|
||||
GenerateTtsAudio,
|
||||
GenerateTtsAudioErrorType,
|
||||
GenerateTtsAudioIsError,
|
||||
GenerateTtsAudioIsOk,
|
||||
} from "@storyteller/components/src/api/tts/GenerateTtsAudio";
|
||||
import {
|
||||
ListTtsCategories,
|
||||
ListTtsCategoriesIsError,
|
||||
ListTtsCategoriesIsOk,
|
||||
} from "@storyteller/components/src/api/category/ListTtsCategories";
|
||||
import { TtsCategoryType } from "../../../../../AppWrapper";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faBarsStaggered,
|
||||
faDeleteLeft,
|
||||
faVolumeHigh,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { motion } from "framer-motion";
|
||||
import { container, panel } from "../../../../../data/animation";
|
||||
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
|
||||
import { TtsPageHero } from "./TrumpTtsPageHero";
|
||||
import { Analytics } from "../../../../../common/Analytics";
|
||||
import {
|
||||
GetComputedTtsCategoryAssignments,
|
||||
GetComputedTtsCategoryAssignmentsIsError,
|
||||
GetComputedTtsCategoryAssignmentsIsOk,
|
||||
GetComputedTtsCategoryAssignmentsSuccessResponse,
|
||||
} from "@storyteller/components/src/api/category/GetComputedTtsCategoryAssignments";
|
||||
import { DynamicallyCategorizeModels } from "../../../../../model/categories/SyntheticCategory";
|
||||
|
||||
import { usePrefixedDocumentTitle } from "../../../../../common/UsePrefixedDocumentTitle";
|
||||
import { SearchOmnibar } from "./search/SearchOmnibar";
|
||||
export interface EnqueueJobResponsePayload {
|
||||
success: boolean;
|
||||
inference_job_token?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sessionWrapper: SessionWrapper;
|
||||
sessionSubscriptionsWrapper: SessionSubscriptionsWrapper;
|
||||
|
||||
enqueueTtsJob: (jobToken: string) => void;
|
||||
ttsInferenceJobs: Array<TtsInferenceJob>;
|
||||
ttsModelUploadJobs: Array<TtsModelUploadJob>;
|
||||
w2lInferenceJobs: Array<W2lInferenceJob>;
|
||||
w2lTemplateUploadJobs: Array<W2lTemplateUploadJob>;
|
||||
textBuffer: string;
|
||||
setTextBuffer: (textBuffer: string) => void;
|
||||
clearTextBuffer: () => void;
|
||||
|
||||
ttsModels: Array<TtsModelListItem>;
|
||||
setTtsModels: (ttsVoices: Array<TtsModelListItem>) => void;
|
||||
|
||||
allTtsCategories: TtsCategoryType[];
|
||||
setAllTtsCategories: (allTtsCategories: TtsCategoryType[]) => void;
|
||||
|
||||
computedTtsCategoryAssignments?: GetComputedTtsCategoryAssignmentsSuccessResponse;
|
||||
setComputedTtsCategoryAssignments: (
|
||||
categoryAssignments: GetComputedTtsCategoryAssignmentsSuccessResponse
|
||||
) => void;
|
||||
|
||||
allTtsCategoriesByTokenMap: Map<string, TtsCategoryType>;
|
||||
allTtsModelsByTokenMap: Map<string, TtsModelListItem>;
|
||||
ttsModelsByCategoryToken: Map<string, Set<TtsModelListItem>>;
|
||||
|
||||
dropdownCategories: TtsCategoryType[][];
|
||||
setDropdownCategories: (dropdownCategories: TtsCategoryType[][]) => void;
|
||||
selectedCategories: TtsCategoryType[];
|
||||
setSelectedCategories: (selectedCategories: TtsCategoryType[]) => void;
|
||||
|
||||
maybeSelectedTtsModel?: TtsModelListItem;
|
||||
setMaybeSelectedTtsModel: (maybeSelectedTtsModel: TtsModelListItem) => void;
|
||||
|
||||
selectedTtsLanguageScope: string;
|
||||
setSelectedTtsLanguageScope: (selectedTtsLanguageScope: string) => void;
|
||||
}
|
||||
|
||||
function TrumpTtsPage(props: Props) {
|
||||
//Loading spinning icon
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isAudioLimitAlertVisible, setAudioLimitAlertVisible] = useState(false);
|
||||
|
||||
const handleLoading = () => {
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 2000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
let {
|
||||
setTtsModels,
|
||||
setAllTtsCategories,
|
||||
setComputedTtsCategoryAssignments,
|
||||
ttsModels,
|
||||
allTtsCategories,
|
||||
computedTtsCategoryAssignments,
|
||||
maybeSelectedTtsModel,
|
||||
setMaybeSelectedTtsModel,
|
||||
} = props;
|
||||
|
||||
const [maybeTtsError, setMaybeTtsError] = useState<
|
||||
GenerateTtsAudioErrorType | undefined
|
||||
>(undefined);
|
||||
|
||||
const ttsModelsLoaded = ttsModels.length > 0;
|
||||
const ttsCategoriesLoaded = allTtsCategories.length > 0;
|
||||
const computedTtsCategoryAssignmentsLoaded =
|
||||
computedTtsCategoryAssignments !== undefined &&
|
||||
computedTtsCategoryAssignments.category_token_to_tts_model_tokens.recursive
|
||||
.size > 0;
|
||||
|
||||
const listModels = useCallback(async () => {
|
||||
if (ttsModelsLoaded) {
|
||||
return; // Already queried.
|
||||
}
|
||||
const models = await ListTtsModels();
|
||||
if (models) {
|
||||
DynamicallyCategorizeModels(models);
|
||||
setTtsModels(models);
|
||||
if (!maybeSelectedTtsModel && models.length > 0) {
|
||||
let model = models[0];
|
||||
const featuredModels = models.filter((m) => m.is_front_page_featured);
|
||||
if (featuredModels.length > 0) {
|
||||
// Random featured model
|
||||
model =
|
||||
featuredModels[Math.floor(Math.random() * featuredModels.length)];
|
||||
}
|
||||
setMaybeSelectedTtsModel(model);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
setTtsModels,
|
||||
maybeSelectedTtsModel,
|
||||
setMaybeSelectedTtsModel,
|
||||
ttsModelsLoaded,
|
||||
]);
|
||||
|
||||
const listTtsCategories = useCallback(async () => {
|
||||
if (ttsCategoriesLoaded) {
|
||||
return; // Already queried.
|
||||
}
|
||||
const categoryList = await ListTtsCategories();
|
||||
if (ListTtsCategoriesIsOk(categoryList)) {
|
||||
let categories = categoryList.categories;
|
||||
|
||||
// NB: We'll use the frontend to order the synthetic categories first.
|
||||
|
||||
const LATEST_MODELS_CATEGORY_TOKEN = "SYNTHETIC_CATEGORY:LATEST_MODELS";
|
||||
const TRENDING_MODELS_CATEGORY_TOKEN =
|
||||
"SYNTHETIC_CATEGORY:TRENDING_MODELS";
|
||||
|
||||
let maybeLatestCategory = categories.find(
|
||||
(category) => category.category_token === LATEST_MODELS_CATEGORY_TOKEN
|
||||
);
|
||||
|
||||
let maybeTrendingCategory = categories.find(
|
||||
(category) => category.category_token === TRENDING_MODELS_CATEGORY_TOKEN
|
||||
);
|
||||
|
||||
let otherCategories = categories
|
||||
.filter(
|
||||
(category) => category.category_token !== LATEST_MODELS_CATEGORY_TOKEN
|
||||
)
|
||||
.filter(
|
||||
(category) =>
|
||||
category.category_token !== TRENDING_MODELS_CATEGORY_TOKEN
|
||||
);
|
||||
|
||||
categories = [];
|
||||
|
||||
if (maybeLatestCategory !== undefined) {
|
||||
categories.push(maybeLatestCategory);
|
||||
}
|
||||
|
||||
if (maybeTrendingCategory !== undefined) {
|
||||
categories.push(maybeTrendingCategory);
|
||||
}
|
||||
|
||||
categories.push(...otherCategories);
|
||||
|
||||
setAllTtsCategories(categories);
|
||||
} else if (ListTtsCategoriesIsError(categoryList)) {
|
||||
// TODO: Retry on decay function
|
||||
}
|
||||
}, [setAllTtsCategories, ttsCategoriesLoaded]);
|
||||
|
||||
const getComputedAssignments = useCallback(async () => {
|
||||
if (computedTtsCategoryAssignmentsLoaded) {
|
||||
return; // Already queried.
|
||||
}
|
||||
const computedAssignments = await GetComputedTtsCategoryAssignments();
|
||||
if (GetComputedTtsCategoryAssignmentsIsOk(computedAssignments)) {
|
||||
setComputedTtsCategoryAssignments(computedAssignments);
|
||||
} else if (GetComputedTtsCategoryAssignmentsIsError(computedAssignments)) {
|
||||
// TODO: Retry on decay function
|
||||
}
|
||||
}, [setComputedTtsCategoryAssignments, computedTtsCategoryAssignmentsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
listModels();
|
||||
listTtsCategories();
|
||||
getComputedAssignments();
|
||||
}, [listModels, listTtsCategories, getComputedAssignments]);
|
||||
|
||||
const handleChangeText = (ev: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const textValue = (ev.target as HTMLTextAreaElement).value;
|
||||
props.setTextBuffer(textValue);
|
||||
setAudioLimitAlertVisible(textValue.length > 100);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (ev: React.FormEvent<HTMLFormElement>) => {
|
||||
ev.preventDefault();
|
||||
|
||||
if (!props.maybeSelectedTtsModel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!props.textBuffer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const modelToken = props.maybeSelectedTtsModel!.model_token;
|
||||
|
||||
const request = {
|
||||
uuid_idempotency_token: uuidv4(),
|
||||
tts_model_token: modelToken,
|
||||
inference_text: props.textBuffer,
|
||||
};
|
||||
|
||||
const response = await GenerateTtsAudio(request);
|
||||
|
||||
Analytics.ttsGenerate(modelToken, props.textBuffer.length);
|
||||
|
||||
if (GenerateTtsAudioIsOk(response)) {
|
||||
setMaybeTtsError(undefined);
|
||||
props.enqueueTtsJob(response.inference_job_token);
|
||||
} else if (GenerateTtsAudioIsError(response)) {
|
||||
setMaybeTtsError(response.error);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleClearClick = (ev: React.FormEvent<HTMLButtonElement>) => {
|
||||
ev.preventDefault();
|
||||
props.clearTextBuffer();
|
||||
|
||||
Analytics.ttsClear(props.maybeSelectedTtsModel?.model_token);
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
usePrefixedDocumentTitle(undefined); // NB: Sets to default title
|
||||
|
||||
// Show errors on TTS failure
|
||||
let maybeError = <></>;
|
||||
if (!!maybeTtsError) {
|
||||
let hasMessage = false;
|
||||
let message = <></>;
|
||||
switch (maybeTtsError) {
|
||||
case GenerateTtsAudioErrorType.TooManyRequests:
|
||||
hasMessage = true;
|
||||
message = (
|
||||
<Trans i18nKey="pages.ttsList.errorTooManyRequests">
|
||||
<strong>You're sending too many requests!</strong>
|
||||
Slow down a little. We have to slow things down a little when the
|
||||
server gets busy.
|
||||
</Trans>
|
||||
);
|
||||
break;
|
||||
case GenerateTtsAudioErrorType.ServerError |
|
||||
GenerateTtsAudioErrorType.BadRequest |
|
||||
GenerateTtsAudioErrorType.NotFound:
|
||||
break;
|
||||
}
|
||||
|
||||
if (hasMessage) {
|
||||
maybeError = (
|
||||
<div
|
||||
className="alert alert-primary alert-dismissible fade show m-0"
|
||||
role="alert"
|
||||
>
|
||||
<button
|
||||
className="btn-close"
|
||||
onClick={() => setMaybeTtsError(undefined)}
|
||||
data-bs-dismiss="alert"
|
||||
aria-label="Close"
|
||||
></button>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// NB: If the text is too long, don't allow submission
|
||||
let remainingCharactersButtonDisabled = props.textBuffer.trim().length > 1024;
|
||||
let noTextInputButtonDisabled = props.textBuffer.trim() === "";
|
||||
|
||||
const speakButtonClass = loading
|
||||
? "btn btn-primary w-100 disabled"
|
||||
: "btn btn-primary w-100";
|
||||
|
||||
let audioLimitAlert = <></>;
|
||||
if (
|
||||
isAudioLimitAlertVisible &&
|
||||
!props.sessionSubscriptionsWrapper.hasPaidFeatures()
|
||||
) {
|
||||
audioLimitAlert = (
|
||||
<>
|
||||
<div className="alert alert-warning fs-7 mb-0">
|
||||
<span className="fw-semibold">
|
||||
<u>Note:</u> Non-premium is limited to 12 seconds of audio.{" "}
|
||||
<Link className="fw-semibold" to="/pricing">
|
||||
Upgrade now
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const TOKENS = new Set<string>([
|
||||
"TM:pmd1wm3kf6az", // Development: "Fake Donald Trump #1"
|
||||
"TM:7rrwdhdq8ezq", // Development: "Fake Donald Trump #2"
|
||||
//"TM:aejrk66wq3ss", // Production: "Donald Trump (bibby's model)"
|
||||
"TM:pyzss4phqk6r", // Production: "Donald Trump (Sarcastic)"
|
||||
"TM:4v0ft4j72y2g", // Production: "Donald Trump (Angry)"
|
||||
"TM:03690khwpsbz", // Production: "Donald Trump (Casual Speech)"
|
||||
]);
|
||||
|
||||
let trumpModels = props.ttsModels.filter((model) => {
|
||||
return TOKENS.has(model.model_token);
|
||||
})
|
||||
|
||||
let maybeSelectedModel = props.maybeSelectedTtsModel;
|
||||
|
||||
if (maybeSelectedModel !== undefined && !TOKENS.has(maybeSelectedModel.model_token)) {
|
||||
// If we don't select a trump model by default (which is rare), the list will be empty.
|
||||
// We'll set it here.
|
||||
maybeSelectedModel = trumpModels[0];
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div initial="hidden" animate="visible" variants={container}>
|
||||
<TtsPageHero
|
||||
sessionWrapper={props.sessionWrapper}
|
||||
sessionSubscriptionsWrapper={props.sessionSubscriptionsWrapper}
|
||||
/>
|
||||
|
||||
<motion.div className="container-panel pb-5 mb-4" variants={panel}>
|
||||
<div className="panel p-3 py-4 p-md-4">
|
||||
<i className="fas fa-volume-high"></i>
|
||||
|
||||
<div className="d-flex gap-4">
|
||||
<form
|
||||
className="w-100 d-flex flex-column"
|
||||
onSubmit={handleFormSubmit}
|
||||
>
|
||||
{/* Explore Rollout */}
|
||||
<SearchOmnibar
|
||||
allTtsCategories={props.allTtsCategories}
|
||||
allTtsModels={trumpModels}
|
||||
allTtsCategoriesByTokenMap={props.allTtsCategoriesByTokenMap}
|
||||
allTtsModelsByTokenMap={props.allTtsModelsByTokenMap}
|
||||
ttsModelsByCategoryToken={props.ttsModelsByCategoryToken}
|
||||
dropdownCategories={props.dropdownCategories}
|
||||
setDropdownCategories={props.setDropdownCategories}
|
||||
selectedCategories={props.selectedCategories}
|
||||
setSelectedCategories={props.setSelectedCategories}
|
||||
maybeSelectedTtsModel={maybeSelectedModel}
|
||||
setMaybeSelectedTtsModel={props.setMaybeSelectedTtsModel}
|
||||
selectedTtsLanguageScope={props.selectedTtsLanguageScope}
|
||||
setSelectedTtsLanguageScope={props.setSelectedTtsLanguageScope}
|
||||
/>
|
||||
{/*
|
||||
|
||||
|
||||
EXPLORE OMNIBAR GOES HERE TODO
|
||||
|
||||
*/}
|
||||
|
||||
<div className="row gx-5 gy-5">
|
||||
<div className="col-12 col-lg-6 d-flex flex-column gap-3">
|
||||
<div className="d-flex flex-column gap-3 h-100">
|
||||
<div className="d-flex gap-2">
|
||||
<label className="sub-title pb-0">
|
||||
{t("tts.TtsModelListPage.form.yourTextLabel")}
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
onClick={() => {
|
||||
Analytics.ttsClickTextInputBox();
|
||||
}}
|
||||
onChange={handleChangeText}
|
||||
className="form-control text-message h-100"
|
||||
value={props.textBuffer}
|
||||
placeholder={t(
|
||||
"tts.TtsModelListPage.form.textInputHint",
|
||||
{
|
||||
voice:
|
||||
props.maybeSelectedTtsModel?.title || "the voice",
|
||||
}
|
||||
)}
|
||||
></textarea>
|
||||
{audioLimitAlert}
|
||||
<div className="d-flex gap-3">
|
||||
<button
|
||||
className={speakButtonClass}
|
||||
disabled={
|
||||
remainingCharactersButtonDisabled ||
|
||||
noTextInputButtonDisabled
|
||||
}
|
||||
onClick={handleLoading}
|
||||
type="submit"
|
||||
>
|
||||
<FontAwesomeIcon icon={faVolumeHigh} className="me-2" />
|
||||
{t("tts.TtsModelListPage.form.buttons.speak")}
|
||||
|
||||
{loading && <LoadingIcon />}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-destructive w-100"
|
||||
onClick={handleClearClick}
|
||||
disabled={noTextInputButtonDisabled}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDeleteLeft} className="me-2" />
|
||||
{t("tts.TtsModelListPage.form.buttons.clear")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-lg-6">
|
||||
<div className="d-flex flex-column gap-3">
|
||||
<h4 className="text-center text-lg-start">
|
||||
<FontAwesomeIcon
|
||||
icon={faBarsStaggered}
|
||||
className="me-3"
|
||||
/>
|
||||
{t("tts.TtsModelListPage.sessionTtsResultsLabel")}
|
||||
</h4>
|
||||
<div className="d-flex flex-column gap-3 session-tts-section">
|
||||
<SessionTtsInferenceResultList
|
||||
ttsInferenceJobs={props.ttsInferenceJobs}
|
||||
sessionSubscriptionsWrapper={
|
||||
props.sessionSubscriptionsWrapper
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{maybeError}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const LoadingIcon: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm ms-3"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { TrumpTtsPage };
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
|
||||
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
|
||||
import { Link } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faStar,
|
||||
faArrowRight,
|
||||
faVolume,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Analytics } from "../../../../../common/Analytics";
|
||||
import { WebUrl } from "../../../../../common/WebUrl";
|
||||
import { PageHeaderWithImage } from "../../../_common/PageHeaderWithImage";
|
||||
|
||||
interface Props {
|
||||
sessionWrapper: SessionWrapper;
|
||||
sessionSubscriptionsWrapper: SessionSubscriptionsWrapper;
|
||||
}
|
||||
|
||||
export function TtsPageHero(props: Props) {
|
||||
const randomImage = useMemo(() => {
|
||||
const images = ["/mascot/trump.webp"];
|
||||
|
||||
return images[Math.floor(Math.random() * images.length)];
|
||||
}, []);
|
||||
|
||||
let signUpButton = <></>;
|
||||
let viewPricingButton = <></>;
|
||||
|
||||
signUpButton = (
|
||||
<>
|
||||
<Link to="/">
|
||||
<button type="button" className="btn btn-primary w-100">
|
||||
See 2000+ more voices
|
||||
<FontAwesomeIcon icon={faArrowRight} className="ms-2" />
|
||||
</button>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
viewPricingButton = (
|
||||
<>
|
||||
<Link
|
||||
to={WebUrl.pricingPageWithReferer("tts_hero_new")}
|
||||
onClick={() => {
|
||||
Analytics.ttsClickHeroViewPricing();
|
||||
}}
|
||||
>
|
||||
<button type="button" className="btn btn-secondary w-100">
|
||||
<FontAwesomeIcon icon={faStar} className="me-2" />
|
||||
{t("tts.TtsModelListPage.heroSection.buttons.viewPricing")}
|
||||
</button>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
|
||||
const titleIcon = <FontAwesomeIcon icon={faVolume} className="me-3" />;
|
||||
const title = <>Donald Trump TTS</>;
|
||||
const subText = (
|
||||
<>FakeYou has the very best Donald Trump AI voice on the internet. Use deep fake Donald Trump to say your favorite memes.</>
|
||||
);
|
||||
const actionButtons = (
|
||||
<>
|
||||
{signUpButton}
|
||||
{viewPricingButton}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeaderWithImage
|
||||
headerImage={randomImage}
|
||||
titleIcon={titleIcon}
|
||||
title={title}
|
||||
subText={subText}
|
||||
showButtons={true}
|
||||
actionButtons={actionButtons}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import { TtsCategoryType } from "../../../../../../AppWrapper";
|
||||
import { TtsModelListItem } from "@storyteller/components/src/api/tts/ListTtsModels";
|
||||
import { ScopedVoiceModelOptions } from "./components/ScopedVoiceModelOptions";
|
||||
import { VoiceCountLabel } from "./components/VoiceCountLabel";
|
||||
|
||||
interface Props {
|
||||
allTtsCategories: TtsCategoryType[];
|
||||
allTtsModels: TtsModelListItem[];
|
||||
|
||||
allTtsCategoriesByTokenMap: Map<string, TtsCategoryType>;
|
||||
allTtsModelsByTokenMap: Map<string, TtsModelListItem>;
|
||||
ttsModelsByCategoryToken: Map<string, Set<TtsModelListItem>>;
|
||||
|
||||
dropdownCategories: TtsCategoryType[][];
|
||||
setDropdownCategories: (dropdownCategories: TtsCategoryType[][]) => void;
|
||||
|
||||
selectedCategories: TtsCategoryType[];
|
||||
setSelectedCategories: (selectedCategories: TtsCategoryType[]) => void;
|
||||
|
||||
maybeSelectedTtsModel?: TtsModelListItem;
|
||||
setMaybeSelectedTtsModel: (maybeSelectedTtsModel: TtsModelListItem) => void;
|
||||
|
||||
selectedTtsLanguageScope: string;
|
||||
setSelectedTtsLanguageScope: (selectedTtsLanguageScope: string) => void;
|
||||
}
|
||||
|
||||
export function SearchOmnibar(props: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="pb-4">
|
||||
<VoiceCountLabel
|
||||
allTtsModels={props.allTtsModels}
|
||||
ttsModelsByCategoryToken={props.ttsModelsByCategoryToken}
|
||||
selectedCategories={props.selectedCategories}
|
||||
selectedTtsLanguageScope={props.selectedTtsLanguageScope}
|
||||
setMaybeSelectedTtsModel={props.setMaybeSelectedTtsModel}
|
||||
/>
|
||||
|
||||
<div className="d-flex flex-column flex-lg-row gap-3">
|
||||
<div className="flex-grow-1">
|
||||
<ScopedVoiceModelOptions
|
||||
allTtsCategories={props.allTtsCategories}
|
||||
allTtsModels={props.allTtsModels}
|
||||
allTtsCategoriesByTokenMap={props.allTtsCategoriesByTokenMap}
|
||||
allTtsModelsByTokenMap={props.allTtsModelsByTokenMap}
|
||||
ttsModelsByCategoryToken={props.ttsModelsByCategoryToken}
|
||||
dropdownCategories={props.dropdownCategories}
|
||||
selectedCategories={props.selectedCategories}
|
||||
maybeSelectedTtsModel={props.maybeSelectedTtsModel}
|
||||
setMaybeSelectedTtsModel={props.setMaybeSelectedTtsModel}
|
||||
selectedTtsLanguageScope={props.selectedTtsLanguageScope}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
import React from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { TtsModelListItem } from "@storyteller/components/src/api/tts/ListTtsModels";
|
||||
import { TtsCategoryType } from "../../../../../../../AppWrapper";
|
||||
import Select, { createFilter } from "react-select";
|
||||
import { SearchFieldClass } from "./SearchFieldClass";
|
||||
import { FastReactSelectOption } from "../../../../../_common/react_select/FastReactSelectOption";
|
||||
import { Analytics } from "../../../../../../../common/Analytics";
|
||||
import { FixedSingleValueSelectOption } from "../../../../../_common/react_select/FixedSingleValueSelectOption";
|
||||
import { faMicrophone } from "@fortawesome/pro-duotone-svg-icons";
|
||||
|
||||
interface Props {
|
||||
allTtsCategories: TtsCategoryType[];
|
||||
allTtsModels: TtsModelListItem[];
|
||||
|
||||
allTtsCategoriesByTokenMap: Map<string, TtsCategoryType>;
|
||||
allTtsModelsByTokenMap: Map<string, TtsModelListItem>;
|
||||
ttsModelsByCategoryToken: Map<string, Set<TtsModelListItem>>;
|
||||
|
||||
dropdownCategories: TtsCategoryType[][];
|
||||
selectedCategories: TtsCategoryType[];
|
||||
|
||||
maybeSelectedTtsModel?: TtsModelListItem;
|
||||
setMaybeSelectedTtsModel: (maybeSelectedTtsModel: TtsModelListItem) => void;
|
||||
|
||||
selectedTtsLanguageScope: string;
|
||||
}
|
||||
|
||||
export function ScopedVoiceModelOptions(props: Props) {
|
||||
const {
|
||||
allTtsModels,
|
||||
ttsModelsByCategoryToken,
|
||||
selectedCategories,
|
||||
maybeSelectedTtsModel,
|
||||
} = props;
|
||||
|
||||
//const { t } = useTranslation();
|
||||
|
||||
const handleChange = (option: any, actionMeta: any) => {
|
||||
const ttsModelToken = option?.value;
|
||||
const maybeNewTtsModel = props.allTtsModelsByTokenMap.get(ttsModelToken);
|
||||
if (maybeNewTtsModel !== undefined) {
|
||||
props.setMaybeSelectedTtsModel(maybeNewTtsModel);
|
||||
}
|
||||
};
|
||||
|
||||
const leafiestCategory = selectedCategories[selectedCategories.length - 1];
|
||||
|
||||
let leafiestCategoryModels: Array<TtsModelListItem> = [];
|
||||
|
||||
if (leafiestCategory !== undefined) {
|
||||
leafiestCategoryModels = Array.from(
|
||||
ttsModelsByCategoryToken.get(leafiestCategory.category_token) || new Set()
|
||||
);
|
||||
} else {
|
||||
leafiestCategoryModels = Array.from(new Set(allTtsModels));
|
||||
}
|
||||
|
||||
interface DropdownOption {
|
||||
label: string;
|
||||
value: string;
|
||||
creatorName?: string;
|
||||
}
|
||||
|
||||
let options: DropdownOption[] = leafiestCategoryModels
|
||||
.filter((ttsModel) => {
|
||||
// Scope to currently selected language
|
||||
if (props.selectedTtsLanguageScope === "*") {
|
||||
return true; // NB: Sentinel value of "*" means all languages.
|
||||
}
|
||||
return (
|
||||
ttsModel.ietf_primary_language_subtag === props.selectedTtsLanguageScope
|
||||
);
|
||||
})
|
||||
.map((ttsModel) => {
|
||||
return {
|
||||
label: ttsModel.title,
|
||||
value: ttsModel.model_token,
|
||||
creatorName: ttsModel.creator_display_name,
|
||||
};
|
||||
});
|
||||
|
||||
let selectedOption = options.find(
|
||||
(option) => option.value === maybeSelectedTtsModel?.model_token
|
||||
);
|
||||
|
||||
if (selectedOption === undefined && options.length > 0) {
|
||||
// NB: We shouldn't select the first item in the list since that won't update the currently
|
||||
// selected model. If the user were to close the dialogue, they'd think they had picked a voice,
|
||||
// when in reality no state would have changed. By forcing the user to choose, the user will set
|
||||
// the state appropriately.
|
||||
selectedOption = {
|
||||
label: "Select voice...",
|
||||
value: "*",
|
||||
creatorName: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let isLoading = false;
|
||||
|
||||
if (props.allTtsModels.length === 0) {
|
||||
// NB: react-select will cache values, even across different instances (!!!)
|
||||
// This can cause confusion when initializing a select instance before the data
|
||||
// is loaded, and the select will never update to show the new data.
|
||||
// The proper way to change voices after load from a placeholder "Loading..."
|
||||
// label is to use controlled props / value as is done here:
|
||||
isLoading = true;
|
||||
selectedOption = {
|
||||
label: "Loading...",
|
||||
value: "*",
|
||||
creatorName: undefined,
|
||||
};
|
||||
} else if (options.length === 0) {
|
||||
// NB: Perhaps the user has refined their search to be too narrow (langauge + category)
|
||||
selectedOption = {
|
||||
label: "No results (remove some filters)",
|
||||
value: "*",
|
||||
creatorName: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const numberVoices = options.length;
|
||||
|
||||
const canSearchVoices =
|
||||
numberVoices > 100 || // Always allow search when there are over 100 voices
|
||||
window.innerWidth >= 1000; // Always allow search when on desktop
|
||||
|
||||
let select;
|
||||
|
||||
// Function to build the options themselves, so we can introduce extra elements.
|
||||
const formatOptionLabel = (
|
||||
data: DropdownOption,
|
||||
formatOptionLabelMeta: any
|
||||
) => {
|
||||
let creatorName = <></>;
|
||||
if (data.creatorName !== undefined) {
|
||||
creatorName = <span className="opacity-50"> — {data.creatorName}</span>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{data.label}
|
||||
{creatorName}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: Cleanup
|
||||
|
||||
if (canSearchVoices) {
|
||||
select = (
|
||||
<Select
|
||||
value={selectedOption} // Controlled components use "value" instead of "defaultValue".
|
||||
options={options}
|
||||
classNames={SearchFieldClass}
|
||||
onChange={handleChange}
|
||||
onMenuOpen={() => {
|
||||
Analytics.ttsOpenPrimaryVoiceSelectMenu();
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
isSearchable={true}
|
||||
// NB: The following settings improve upon performance.
|
||||
// See: https://github.com/JedWatson/react-select/issues/3128
|
||||
filterOption={createFilter({ ignoreAccents: false })}
|
||||
components={
|
||||
{
|
||||
SingleValue: FixedSingleValueSelectOption,
|
||||
Option: FastReactSelectOption,
|
||||
} as any
|
||||
}
|
||||
formatOptionLabel={formatOptionLabel}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
select = (
|
||||
<Select
|
||||
value={selectedOption}
|
||||
options={options}
|
||||
classNames={SearchFieldClass}
|
||||
onChange={handleChange}
|
||||
onMenuOpen={() => {
|
||||
Analytics.ttsOpenScopedVoiceSelectMenu();
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
// On mobile, we don't want the onscreen keyboard to take up half the UI.
|
||||
autoFocus={false}
|
||||
isSearchable={false}
|
||||
// NB: The following settings improve upon performance.
|
||||
// See: https://github.com/JedWatson/react-select/issues/3128
|
||||
filterOption={createFilter({ ignoreAccents: false })}
|
||||
components={{ Option: FastReactSelectOption } as any}
|
||||
formatOptionLabel={formatOptionLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col">
|
||||
<div className="input-icon-search">
|
||||
<span className="form-control-feedback">
|
||||
<FontAwesomeIcon icon={faMicrophone} />
|
||||
</span>
|
||||
|
||||
{select}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
export const SearchFieldClass = {
|
||||
control: (state: any) =>
|
||||
state.isFocused ? "select-search focused" : "select-search",
|
||||
option: (state: any) => (state.isFocused ? "select-option" : "select-option"),
|
||||
input: (state: any) => (state.isFocused ? "select-input" : "select-input"),
|
||||
placeholder: (state: any) =>
|
||||
state.isFocused ? "select-placeholder" : "select-placeholder",
|
||||
singleValue: (state: any) =>
|
||||
state.isFocused ? "select-value" : "select-value",
|
||||
menu: (state: any) =>
|
||||
state.isFocused ? "select-container" : "select-container",
|
||||
indicatorSeparator: (state: any) =>
|
||||
state.isFocused ? "select-separator" : "select-separator",
|
||||
};
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import React from "react";
|
||||
import { TtsModelListItem } from "@storyteller/components/src/api/tts/ListTtsModels";
|
||||
import { TtsCategoryType } from "../../../../../../../AppWrapper";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faShuffle } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Analytics } from "../../../../../../../common/Analytics";
|
||||
import { GetRandomArrayValue } from "@storyteller/components/src/utils/GetRandomArrayValue";
|
||||
import Tippy from "@tippyjs/react";
|
||||
import "tippy.js/dist/tippy.css";
|
||||
|
||||
interface Props {
|
||||
allTtsModels: TtsModelListItem[];
|
||||
ttsModelsByCategoryToken: Map<string, Set<TtsModelListItem>>;
|
||||
selectedCategories: TtsCategoryType[];
|
||||
selectedTtsLanguageScope: string;
|
||||
setMaybeSelectedTtsModel: (maybeSelectedTtsModel: TtsModelListItem) => void;
|
||||
}
|
||||
|
||||
// NB/TODO: This duplicates the work of a sister component, but it was the fastest way
|
||||
// to hack this in without passing callbacks around or moving calculation higher up the
|
||||
// component tree.
|
||||
|
||||
export function VoiceCountLabel(props: Props) {
|
||||
const { allTtsModels, ttsModelsByCategoryToken, selectedCategories } = props;
|
||||
|
||||
const leafiestCategory = selectedCategories[selectedCategories.length - 1];
|
||||
|
||||
let leafiestCategoryModels: Array<TtsModelListItem> = [];
|
||||
|
||||
if (leafiestCategory !== undefined) {
|
||||
leafiestCategoryModels = Array.from(
|
||||
ttsModelsByCategoryToken.get(leafiestCategory.category_token) || new Set()
|
||||
);
|
||||
} else {
|
||||
leafiestCategoryModels = Array.from(new Set(allTtsModels));
|
||||
}
|
||||
|
||||
let possibleVoices = leafiestCategoryModels.filter((ttsModel) => {
|
||||
// Scope to currently selected language
|
||||
if (props.selectedTtsLanguageScope === "*") {
|
||||
return true; // NB: Sentinel value of "*" means all languages.
|
||||
}
|
||||
return (
|
||||
ttsModel.ietf_primary_language_subtag === props.selectedTtsLanguageScope
|
||||
);
|
||||
});
|
||||
|
||||
const selectRandomVoice = () => {
|
||||
let randomVoice = undefined;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// We're going to try to find a *good* voice.
|
||||
randomVoice = GetRandomArrayValue(possibleVoices);
|
||||
if (
|
||||
randomVoice.user_ratings.positive_count >
|
||||
randomVoice.user_ratings.negative_count
|
||||
) {
|
||||
break; // First "mostly good" voice is good.
|
||||
}
|
||||
if (i % 3 === 2 && randomVoice.user_ratings.total_count === 0) {
|
||||
break; // Sometimes we'll return a voice that hasn't been rated.
|
||||
}
|
||||
}
|
||||
if (randomVoice !== undefined) {
|
||||
props.setMaybeSelectedTtsModel(randomVoice);
|
||||
}
|
||||
};
|
||||
|
||||
const voiceCount = possibleVoices.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex gap-2">
|
||||
<label className="sub-title">
|
||||
Choose a Donald Trump Voice ({voiceCount} to choose from)
|
||||
<Tippy
|
||||
content="Random Voice"
|
||||
hideOnClick
|
||||
placement="top"
|
||||
theme="fakeyou"
|
||||
arrow={false}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
Analytics.ttsClickRandomVoice();
|
||||
selectRandomVoice();
|
||||
}}
|
||||
className="btn-link pt-0 ps-2 pe-0"
|
||||
type="button"
|
||||
>
|
||||
<FontAwesomeIcon icon={faShuffle} />
|
||||
</button>
|
||||
</Tippy>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import React from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faArrowRightLong, faTags } from "@fortawesome/free-solid-svg-icons";
|
||||
import { TtsModelListItem } from "@storyteller/components/src/api/tts/ListTtsModels";
|
||||
import { TtsCategoryType } from "../../../../../../../AppWrapper";
|
||||
//import { useTranslation } from "react-i18next";
|
||||
import Select from "react-select";
|
||||
import { SearchFieldClass } from "../components/SearchFieldClass";
|
||||
import { Analytics } from "../../../../../../../common/Analytics";
|
||||
import { t } from "i18next";
|
||||
|
||||
interface Props {
|
||||
allTtsCategories: TtsCategoryType[];
|
||||
allTtsModels: TtsModelListItem[];
|
||||
|
||||
allTtsCategoriesByTokenMap: Map<string, TtsCategoryType>;
|
||||
allTtsModelsByTokenMap: Map<string, TtsModelListItem>;
|
||||
ttsModelsByCategoryToken: Map<string, Set<TtsModelListItem>>;
|
||||
|
||||
dropdownCategories: TtsCategoryType[][];
|
||||
setDropdownCategories: (dropdownCategories: TtsCategoryType[][]) => void;
|
||||
|
||||
selectedCategories: TtsCategoryType[];
|
||||
setSelectedCategories: (selectedCategories: TtsCategoryType[]) => void;
|
||||
|
||||
maybeSelectedTtsModel?: TtsModelListItem;
|
||||
setMaybeSelectedTtsModel: (maybeSelectedTtsModel: TtsModelListItem) => void;
|
||||
|
||||
handleChangeCategory: (level: number, maybeToken?: string) => void;
|
||||
|
||||
selectedTtsLanguageScope: string;
|
||||
}
|
||||
|
||||
export function CategoryOptions(props: Props) {
|
||||
const {
|
||||
ttsModelsByCategoryToken,
|
||||
dropdownCategories,
|
||||
selectedCategories,
|
||||
handleChangeCategory,
|
||||
} = props;
|
||||
|
||||
//const { t } = useTranslation();
|
||||
|
||||
let categoryDropdowns = buildDropdowns(
|
||||
dropdownCategories,
|
||||
selectedCategories,
|
||||
ttsModelsByCategoryToken,
|
||||
handleChangeCategory
|
||||
);
|
||||
|
||||
const CATEGORY_SEPARATOR = (
|
||||
<div className="d-none d-md-flex align-items-center">
|
||||
<FontAwesomeIcon icon={faArrowRightLong} className="fs-6 opacity-75" />
|
||||
</div>
|
||||
);
|
||||
|
||||
let categoryDropdownsWithSeparators = [];
|
||||
for (let i = 0; i < categoryDropdowns.length; i++) {
|
||||
categoryDropdownsWithSeparators.push(categoryDropdowns[i]);
|
||||
if (i < categoryDropdowns.length - 1) {
|
||||
categoryDropdownsWithSeparators.push(CATEGORY_SEPARATOR);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex flex-column flex-md-row gap-2">
|
||||
{categoryDropdownsWithSeparators}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ========= Build dropdowns ========
|
||||
|
||||
function buildDropdowns(
|
||||
dropdownCategories: TtsCategoryType[][],
|
||||
selectedCategories: TtsCategoryType[],
|
||||
ttsModelsByCategoryToken: Map<string, Set<TtsModelListItem>>,
|
||||
handleChangeCategory: (i: number, categoryToken?: string) => void
|
||||
) {
|
||||
if (dropdownCategories.length === 0 || ttsModelsByCategoryToken.size === 0) {
|
||||
// While the XHR requests are still completing, we may have nothing to build.
|
||||
// It's easier to return a fully disabled "loading" <Select /> component.
|
||||
return [
|
||||
<div className="w-100">
|
||||
<span className="form-control-feedback">
|
||||
<FontAwesomeIcon icon={faTags} />
|
||||
</span>
|
||||
<Select
|
||||
isLoading={true}
|
||||
options={[]}
|
||||
inputValue={"Loading..."}
|
||||
classNames={SearchFieldClass}
|
||||
className={"w-100"}
|
||||
/>
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
let categoryDropdowns = [];
|
||||
|
||||
for (let i = 0; i < dropdownCategories.length; i++) {
|
||||
const currentDropdownCategories = dropdownCategories[i];
|
||||
const selectedCategory = selectedCategories[i];
|
||||
|
||||
let defaultName = i === 0 ? "All Voices" : "Select...";
|
||||
|
||||
let dropdownOptions = [];
|
||||
dropdownOptions.push(
|
||||
<option key={`option-${i}-*`} value="*">
|
||||
{defaultName}
|
||||
</option>
|
||||
);
|
||||
|
||||
// TODO(bt, 2023-01-18): Clean this up
|
||||
|
||||
// Transform into "react-select" library compatible options
|
||||
const options = currentDropdownCategories
|
||||
.filter((category) => {
|
||||
// If there are no models at the leaves, skip the category
|
||||
const models = ttsModelsByCategoryToken.get(category.category_token);
|
||||
return !(models === undefined || models.size === 0);
|
||||
})
|
||||
.map((category) => {
|
||||
return {
|
||||
value: category.category_token,
|
||||
label: category.name_for_dropdown,
|
||||
};
|
||||
});
|
||||
|
||||
currentDropdownCategories.forEach((category) => {
|
||||
const models = ttsModelsByCategoryToken.get(category.category_token);
|
||||
if (models === undefined || models.size === 0) {
|
||||
return; // If there are no models at the leaves, skip
|
||||
}
|
||||
dropdownOptions.push(
|
||||
<option
|
||||
key={`option-${i}-${category.category_token}`}
|
||||
value={category.category_token}
|
||||
>
|
||||
{category.name_for_dropdown}
|
||||
</option>
|
||||
);
|
||||
});
|
||||
|
||||
let selectedCategoryOption = undefined;
|
||||
if (selectedCategory !== undefined) {
|
||||
selectedCategoryOption = {
|
||||
value: selectedCategory.category_token,
|
||||
label: selectedCategory.name_for_dropdown,
|
||||
};
|
||||
}
|
||||
|
||||
if (dropdownOptions.length <= 1) {
|
||||
// We've run out of subcategories. (1 == "Select...")
|
||||
// No sense trying to build more.
|
||||
break;
|
||||
}
|
||||
|
||||
let selectProps: any = {
|
||||
options: options,
|
||||
classNames: SearchFieldClass,
|
||||
className: "w-100",
|
||||
autoFocus: false, // On mobile, we don't want the onscreen keyboard to take up half the UI.
|
||||
isSearchable: false, // On mobile, we don't want the onscreen keyboard to take up half the UI.
|
||||
onMenuOpen: () => {
|
||||
Analytics.ttsOpenCategorySelectMenu();
|
||||
},
|
||||
onChange: (option: any) => handleChangeCategory(i, option?.value),
|
||||
};
|
||||
|
||||
if (selectedCategoryOption === undefined) {
|
||||
// NB(bt, 2023-01-19): I'm not sure why we're having to do this to clear categories.
|
||||
// If I had more time to spend with this library, I might have a better solution than this hack.
|
||||
selectProps["value"] = {
|
||||
value: "*",
|
||||
label: t("tts.TtsModelListPage.exploreModal.selectCategoryText"),
|
||||
};
|
||||
} else {
|
||||
selectProps["value"] = selectedCategoryOption;
|
||||
}
|
||||
|
||||
categoryDropdowns.push(
|
||||
<React.Fragment key={`categoryDropdown-${i}`}>
|
||||
<div className="w-100">
|
||||
<span className="form-control-feedback">
|
||||
<FontAwesomeIcon icon={faTags} />
|
||||
</span>
|
||||
<Select {...selectProps} />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return categoryDropdowns;
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import { faGlobe } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import Select from "react-select";
|
||||
import { SearchFieldClass } from "../components/SearchFieldClass";
|
||||
import { AVAILABLE_TTS_LANGUAGE_CATEGORY_MAP } from "../../../../../../../_i18n/AvailableLanguageMap";
|
||||
import { Analytics } from "../../../../../../../common/Analytics";
|
||||
import { t } from "i18next";
|
||||
|
||||
interface Props {
|
||||
selectedTtsLanguageScope: string;
|
||||
setSelectedTtsLanguageScope: (selectedTtsLanguageScope: string) => void;
|
||||
}
|
||||
|
||||
export function LanguageOptions(props: Props) {
|
||||
const handleChange = (option: any, actionMeta: any) => {
|
||||
props.setSelectedTtsLanguageScope(option.value);
|
||||
};
|
||||
|
||||
let languageOptions = Object.entries(AVAILABLE_TTS_LANGUAGE_CATEGORY_MAP).map(
|
||||
([languageCode, language]) => {
|
||||
let label = `${language.languageName}`;
|
||||
|
||||
if (language.languageNameLocalized !== undefined) {
|
||||
label = `${language.languageNameLocalized} / ${label}`;
|
||||
}
|
||||
|
||||
if (language.flags.length > 0) {
|
||||
label += ` ${language.flags.join(" ")}`;
|
||||
}
|
||||
|
||||
return {
|
||||
value: languageCode,
|
||||
label: label,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
languageOptions = [
|
||||
{
|
||||
label: `${t("tts.TtsModelListPage.exploreModal.allLanguagesOptionText")}`,
|
||||
value: "*",
|
||||
},
|
||||
...languageOptions,
|
||||
];
|
||||
|
||||
const currentValue =
|
||||
languageOptions.find((option) => {
|
||||
return option.value === props.selectedTtsLanguageScope;
|
||||
}) || languageOptions[0];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="form-control-feedback">
|
||||
<FontAwesomeIcon icon={faGlobe} />
|
||||
</span>
|
||||
<Select
|
||||
value={currentValue}
|
||||
options={languageOptions}
|
||||
classNames={SearchFieldClass}
|
||||
isSearchable={false}
|
||||
onChange={handleChange}
|
||||
onMenuOpen={() => {
|
||||
Analytics.ttsOpenCategorySelectMenu();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user