PluginProbe
BeyondWords – AI audio for publishers / 7.0.0
BeyondWords – AI audio for publishers v7.0.0
7.1.0 trunk 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.1.0 4.1.1 4.1.2 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.3.0 4.4.0 4.5.0 4.5.1 4.6.0 4.6.1 4.6.2 4.7.0 All 43 releases
speechkit / src / editor / components / settings-panel / voice-section.js

voice-section.js in BeyondWords – AI audio for publishers 7.0.0, at src/editor/components/settings-panel/voice-section.js

376 lines 10.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * WordPress dependencies
3 */
4 import { __ } from '@wordpress/i18n';
5 import { PanelBody, SelectControl, Spinner } from '@wordpress/components';
6 import { useEntityProp } from '@wordpress/core-data';
7 import { select, useSelect } from '@wordpress/data';
8 import { useEffect, useRef, useState } from '@wordpress/element';
9 import { decodeEntities } from '@wordpress/html-entities';
10
11 /**
12 * Internal dependencies
13 */
14 import {
15 NATIVE_ALL,
16 NATIVE_ONLY,
17 filterVoicesByNative,
18 findLanguageByCode,
19 getAccentsForName,
20 getLanguageModels,
21 getLanguageNames,
22 voiceIsNative,
23 voiceModelKey,
24 } from './helpers';
25 import Stack from '../stack';
26 import Toggle from '../toggle';
27
28 export function VoiceSection( { withPanel = true } ) {
29 const postType = useSelect(
30 ( s ) => s( 'core/editor' ).getCurrentPostType(),
31 []
32 );
33
34 // `useEntityProp` yields undefined meta until the post entity record is hydrated.
35 const [ rawMeta, setMeta ] = useEntityProp( 'postType', postType, 'meta' );
36 const meta = rawMeta ?? {};
37
38 const languageCode = meta.beyondwords_language_code || '';
39 const voiceId = meta.beyondwords_body_voice_id || '';
40
41 // "Customize" is opt-in: a post counts as customised once it carries an explicit
42 // language or voice. Local state so toggling on reveals the pickers pre-choice.
43 const [ customize, setCustomize ] = useState(
44 () => !! ( languageCode || voiceId )
45 );
46
47 const projectId = useSelect(
48 ( s ) =>
49 s( 'core/editor' ).getEditedPostAttribute( 'meta' )
50 ?.beyondwords_project_id ||
51 s( 'beyondwords/settings' ).getSettings()?.projectId,
52 []
53 );
54
55 // Customize-on with no choice yet: fetch the project default language and seed
56 // only the Language (a project default voice can belong to another language).
57 const needsDefault = customize && ! languageCode && ! voiceId;
58
59 const project = useSelect(
60 ( s ) =>
61 needsDefault
62 ? s( 'beyondwords/settings' ).getProject( projectId )
63 : null,
64 [ needsDefault, projectId ]
65 );
66
67 // True once the project fetch has settled; keeps the spinner up until then to
68 // avoid a one-frame "empty dropdown then spinner" flicker.
69 const projectResolved = useSelect(
70 ( s ) =>
71 ! needsDefault ||
72 s( 'beyondwords/settings' ).hasFinishedResolution( 'getProject', [
73 projectId,
74 ] ),
75 [ needsDefault, projectId ]
76 );
77
78 // Apply the resolved default language. Reads the freshest meta — not the
79 // closure's — so a concurrent edit during the async fetch isn't clobbered.
80 useEffect( () => {
81 if ( needsDefault && project?.language ) {
82 const current =
83 select( 'core/editor' ).getEditedPostAttribute( 'meta' );
84 setMeta( {
85 ...current,
86 beyondwords_language_code: project.language,
87 } );
88 }
89 // eslint-disable-next-line react-hooks/exhaustive-deps
90 }, [ needsDefault, project ] );
91
92 const loadingProject = customize && needsDefault && ! projectResolved;
93
94 // Fetched lazily behind the toggle — a default post makes no language/voice API calls.
95 const languages = useSelect(
96 ( s ) =>
97 customize ? s( 'beyondwords/settings' ).getLanguages() : [],
98 [ customize ]
99 );
100
101 const voices = useSelect(
102 ( s ) =>
103 customize && languageCode
104 ? s( 'beyondwords/settings' ).getVoices( languageCode )
105 : [],
106 [ customize, languageCode ]
107 );
108
109 // `hasFinishedResolution` is monotonic; `isResolving` flip-flops and leaves a
110 // one-frame gap where stale voices show.
111 const languagesResolving = useSelect(
112 ( s ) =>
113 customize &&
114 ! s( 'beyondwords/settings' ).hasFinishedResolution(
115 'getLanguages',
116 []
117 ),
118 [ customize ]
119 );
120
121 const voicesResolving = useSelect(
122 ( s ) =>
123 customize &&
124 !! languageCode &&
125 ! s( 'beyondwords/settings' ).hasFinishedResolution( 'getVoices', [
126 languageCode,
127 ] ),
128 [ customize, languageCode ]
129 );
130
131 const [ nativeFilter, setNativeFilter ] = useState( NATIVE_ONLY );
132
133 // Open on "All" when the saved voice is not native to the language, so that
134 // voice stays visible in the list.
135 const nativeSeeded = useRef( false );
136 useEffect( () => {
137 if ( nativeSeeded.current || ! customize || voicesResolving ) {
138 return;
139 }
140 const saved = ( voices ?? [] ).find(
141 ( voice ) => String( voice.id ) === String( voiceId )
142 );
143 if ( voiceId && ! saved ) {
144 return;
145 }
146 nativeSeeded.current = true;
147 if ( saved && languageCode && ! voiceIsNative( saved, languageCode ) ) {
148 setNativeFilter( NATIVE_ALL );
149 }
150 }, [ customize, voicesResolving, voices, voiceId, languageCode ] );
151
152 const setVoiceId = ( value ) => {
153 setMeta( { ...meta, beyondwords_body_voice_id: value } );
154 };
155
156 // Seeds the default body voice too: we never send the language itself, so a
157 // concrete voice must always be stored.
158 const setLanguageCode = ( value ) => {
159 const language = ( languages ?? [] ).find(
160 ( item ) => decodeEntities( item.code ) === value
161 );
162 const defaultVoiceId = language?.default_voices?.body?.id;
163
164 setMeta( {
165 ...meta,
166 beyondwords_language_code: value,
167 beyondwords_body_voice_id: defaultVoiceId
168 ? String( defaultVoiceId )
169 : '',
170 } );
171 };
172
173 const setLanguageName = ( name ) => {
174 const first = getAccentsForName( languages, name )[ 0 ];
175 setLanguageCode( first ? first.value : '' );
176 };
177
178 // Customize off reverts to the project defaults by clearing both choices.
179 const toggleCustomize = () => {
180 const next = ! customize;
181 setCustomize( next );
182
183 if ( ! next ) {
184 setMeta( {
185 ...meta,
186 beyondwords_language_code: '',
187 beyondwords_body_voice_id: '',
188 } );
189 }
190 };
191
192 // The Accent select carries the language CODE — it is the stored value, and
193 // a (name, accent) pair maps to exactly one code.
194 const selectedLanguage = findLanguageByCode( languages, languageCode );
195 const languageName = selectedLanguage
196 ? decodeEntities( selectedLanguage.name )
197 : '';
198
199 const languageNameOptions = [
200 { label: __( 'Select a language…', 'speechkit' ), value: '' },
201 ...getLanguageNames( languages ).map( ( name ) => ( {
202 label: name,
203 value: name,
204 } ) ),
205 ];
206
207 const accentOptions = getAccentsForName( languages, languageName );
208
209 const showAccent = accentOptions.length > 1;
210
211 const filteredVoices = filterVoicesByNative(
212 voices,
213 languageCode,
214 nativeFilter,
215 voiceId
216 );
217
218 // "Model" is a language-level filter over the voices; with a single bucket
219 // there is no Model dropdown and every voice is listed.
220 const models = getLanguageModels( filteredVoices );
221 const showModel = models.length > 1;
222
223 const selectedVoice = filteredVoices.find(
224 ( voice ) => String( voice.id ) === String( voiceId )
225 );
226 // Derived from the selected voice — we persist only the voice id.
227 const selectedModelKey = selectedVoice
228 ? voiceModelKey( selectedVoice )
229 : '';
230
231 const bucketVoices = showModel
232 ? filteredVoices.filter(
233 ( voice ) => voiceModelKey( voice ) === selectedModelKey
234 )
235 : filteredVoices;
236
237 const hasVoices = filteredVoices.length > 0;
238
239 // Model gates the Voice list: hide Voice until a model is chosen.
240 const showVoice = hasVoices && ( ! showModel || '' !== selectedModelKey );
241
242 const modelOptions = [
243 { label: __( 'Select a model', 'speechkit' ), value: '' },
244 ...models.map( ( model ) => ( {
245 label: decodeEntities( model.label ),
246 value: model.key,
247 } ) ),
248 ];
249
250 const voiceOptions = [
251 { label: __( 'Select a voice', 'speechkit' ), value: '' },
252 ...bucketVoices.map( ( voice ) => ( {
253 label: decodeEntities( voice.name ),
254 value: String( voice.id ),
255 } ) ),
256 ];
257
258 // Picking a Model selects that bucket's first voice, so a concrete voice is
259 // always stored (the voice carries the model).
260 const setModel = ( key ) => {
261 const first = filteredVoices.find(
262 ( voice ) => voiceModelKey( voice ) === key
263 );
264 setVoiceId( first ? String( first.id ) : '' );
265 };
266
267 // The Model + Voice group is hidden with an inline style rather than
268 // unmounted, so the <select> can't detach mid-interaction or lose to
269 // component CSS specificity.
270 const fieldsReady = customize && ! loadingProject && ! languagesResolving;
271
272 const fields = (
273 <Stack>
274 <Toggle
275 className="beyondwords--customize"
276 label={ __( 'Customize', 'speechkit' ) }
277 checked={ customize }
278 onChange={ toggleCustomize }
279 />
280 { customize && ( loadingProject || languagesResolving ) && (
281 <div className="beyondwords--languages-spinner">
282 <Spinner />
283 </div>
284 ) }
285 { fieldsReady && (
286 <SelectControl
287 className="beyondwords--language"
288 label={ __( 'Language', 'speechkit' ) }
289 options={ languageNameOptions }
290 value={ languageName }
291 onChange={ setLanguageName }
292 __nextHasNoMarginBottom
293 __next40pxDefaultSize
294 />
295 ) }
296 { fieldsReady && showAccent && (
297 <SelectControl
298 className="beyondwords--accent"
299 label={ __( 'Accent', 'speechkit' ) }
300 options={ accentOptions }
301 value={ languageCode }
302 onChange={ setLanguageCode }
303 __nextHasNoMarginBottom
304 __next40pxDefaultSize
305 />
306 ) }
307 { fieldsReady && languageCode && (
308 <SelectControl
309 className="beyondwords--native"
310 label={ __( 'Native', 'speechkit' ) }
311 options={ [
312 {
313 label: __( 'Native', 'speechkit' ),
314 value: NATIVE_ONLY,
315 },
316 { label: __( 'All', 'speechkit' ), value: NATIVE_ALL },
317 ] }
318 value={ nativeFilter }
319 onChange={ setNativeFilter }
320 __nextHasNoMarginBottom
321 __next40pxDefaultSize
322 />
323 ) }
324 { fieldsReady && languageCode && ( showModel || showVoice ) && (
325 <div
326 className="beyondwords--voice-fields"
327 style={ voicesResolving ? { display: 'none' } : undefined }
328 >
329 <Stack>
330 { showModel && (
331 <SelectControl
332 className="beyondwords--model"
333 label={ __( 'Model', 'speechkit' ) }
334 options={ modelOptions }
335 value={ selectedModelKey }
336 onChange={ setModel }
337 __nextHasNoMarginBottom
338 __next40pxDefaultSize
339 />
340 ) }
341 { showVoice && (
342 <SelectControl
343 className="beyondwords--voice"
344 label={ __( 'Voice', 'speechkit' ) }
345 options={ voiceOptions }
346 value={ String( voiceId ) }
347 onChange={ setVoiceId }
348 __nextHasNoMarginBottom
349 __next40pxDefaultSize
350 />
351 ) }
352 </Stack>
353 </div>
354 ) }
355 { fieldsReady && languageCode && voicesResolving && (
356 <div className="beyondwords--voice-spinner">
357 <Spinner />
358 </div>
359 ) }
360 </Stack>
361 );
362
363 // Document/pre-publish panels render the fields without nesting another panel.
364 if ( ! withPanel ) {
365 return fields;
366 }
367
368 return (
369 <PanelBody title={ __( 'Voice', 'speechkit' ) } initialOpen={ true }>
370 { fields }
371 </PanelBody>
372 );
373 }
374
375 export default VoiceSection;
376