/* global HTMLScriptElement */
/**
* WordPress dependencies
*/
import apiFetch from '@wordpress/api-fetch';
import { useSelect } from '@wordpress/data';
import { useEffect, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
pollContentStatus,
PROCESSED_STATUS,
} from '../../lib/poll-content-status';
const PLAYER_SCRIPT_SRC =
'https://proxy.beyondwords.io/npm/@beyondwords/player@latest/dist/umd.js';
export function useBeyondWordsNamespace() {
const [ value, setValue ] = useState( () => {
return window?.BeyondWords ?? null;
} );
useEffect( () => {
// The script can finish loading between the useState initializer and this
// effect; re-read now so we don't listen on an already-loaded script.
if ( window?.BeyondWords ) {
setValue( window.BeyondWords );
return;
}
const onLoad = () => {
setValue( window?.BeyondWords ?? null );
};
const existingScript = document.head.querySelector(
`script[src="${ PLAYER_SCRIPT_SRC }"]`
);
if ( existingScript instanceof HTMLScriptElement ) {
existingScript.addEventListener( 'load', onLoad );
return () => {
existingScript.removeEventListener( 'load', onLoad );
};
}
const newScript = document.createElement( 'script' );
newScript.src = PLAYER_SCRIPT_SRC;
newScript.async = true;
newScript.defer = true;
newScript.addEventListener( 'load', onLoad );
document.head.appendChild( newScript );
return () => {
newScript.removeEventListener( 'load', onLoad );
};
}, [] );
return value;
}
/**
* Create a (preview) BeyondWords player once its content is ready.
*
* Always confirms `processed` before embedding a contentId, whether it just
* appeared this session or was already on the post when the editor opened —
* a just-published, still-processing post (e.g. a slower voice/model
* override) is otherwise indistinguishable from a long-finished one. Mirrors
* the classic editor metabox, which polls unconditionally for the same
* reason. See lib/poll-content-status.js.
*
* @param {Object} options Options.
* @param {HTMLElement} options.target Player mount node.
* @param {number} options.projectId BeyondWords project ID.
* @param {number} options.sourceId Post ID (client-side integration).
* @param {string} options.contentId BeyondWords content ID, if any.
* @param {string} options.previewToken Preview token, if any.
*
* @return {{player: (Object|null), status: (string|undefined), isPolling: boolean, timedOut: boolean}}
* The player instance and the current polling state.
*/
export function useBeyondWordsPlayer( {
target,
projectId,
sourceId,
contentId,
previewToken,
} ) {
const BeyondWords = useBeyondWordsNamespace();
const [ player, setPlayer ] = useState( null );
const [ pollState, setPollState ] = useState( {
status: undefined,
isPolling: false,
timedOut: false,
} );
useEffect( () => {
if ( ! BeyondWords?.Player || ! target ) {
setPlayer( null );
setPollState( {
status: undefined,
isPolling: false,
timedOut: false,
} );
return;
}
let newPlayer;
let cancelled = false;
const controller = new AbortController();
const initPlayer = () => {
try {
const params = {
target,
projectId,
sourceId,
contentId,
loadContentAs: [ 'article' ],
previewToken: previewToken || '',
analyticsConsent: 'none',
playerStyle: 'small',
widgetStyle: 'none',
introsOutros: [],
adverts: [],
};
if ( contentId ) {
delete params.sourceId;
}
newPlayer = new BeyondWords.Player( params );
} catch {
setPlayer( null );
// @todo display error notice in Wordpress admin.
return;
}
setPlayer( newPlayer );
};
if ( contentId ) {
// Poll until processed, then embed, so a 404 is never CDN-cached
// for content that's still processing.
setPollState( {
status: undefined,
isPolling: true,
timedOut: false,
} );
pollContentStatus( {
fetchStatus: async () => {
const data = await apiFetch( {
path: `/beyondwords/v1/projects/${ projectId }/content/${ contentId }`,
signal: controller.signal,
} );
return { status: data?.status };
},
onTick: ( status ) => {
if ( ! cancelled ) {
setPollState( {
status,
isPolling: true,
timedOut: false,
} );
}
},
isHidden: () => document.hidden,
signal: controller.signal,
} )
.then( ( { status, timedOut } ) => {
if ( cancelled ) {
return;
}
setPollState( { status, isPolling: false, timedOut } );
if ( ! timedOut && status === PROCESSED_STATUS ) {
initPlayer();
}
} )
.catch( () => {
// Aborted (unmount or dependency change) — nothing to do.
} );
} else {
// Client-side integration, keyed on sourceId: nothing to poll,
// embed now.
setPollState( {
status: undefined,
isPolling: false,
timedOut: false,
} );
initPlayer();
}
return () => {
cancelled = true;
controller.abort();
setPlayer( null );
if ( newPlayer ) {
newPlayer.destroy();
}
};
}, [
BeyondWords?.Player,
target,
projectId,
sourceId,
contentId,
previewToken,
] );
return {
player,
status: pollState.status,
isPolling: pollState.isPolling,
timedOut: pollState.timedOut,
};
}
/**
* Whether the post has everything the BeyondWords player needs to load a preview.
*
* Pure selector (usable in `useSelect`/`withSelect`, unit-testable). Legacy
* `podcast_id` keys are recognised so upgraded posts still preview.
*
* @param {Function} select Redux-style select() from `@wordpress/data`.
*
* @return {boolean} True when the player can load.
*/
export function selectHasPlayAudioAction( select ) {
const { getEditedPostAttribute } = select( 'core/editor' );
const status = getEditedPostAttribute( 'status' );
const projectId = getEditedPostAttribute( 'meta' ).beyondwords_project_id;
const integrationMethod =
getEditedPostAttribute( 'meta' ).beyondwords_integration_method;
const beyondwordsContentId =
getEditedPostAttribute( 'meta' ).beyondwords_content_id;
const beyondwordsPodcastId =
getEditedPostAttribute( 'meta' ).beyondwords_podcast_id;
const speechkitPodcastId =
getEditedPostAttribute( 'meta' ).speechkit_podcast_id;
const contentId =
beyondwordsContentId || beyondwordsPodcastId || speechkitPodcastId;
const isClientSide = integrationMethod === 'client-side';
const hasClientSideContent = isClientSide && projectId;
const hasRestApiContent = ! isClientSide && projectId && contentId;
return Boolean(
status !== 'pending' && ( hasClientSideContent || hasRestApiContent )
);
}
/**
* Hook wrapper around {@link selectHasPlayAudioAction} for function components.
*
* @return {boolean} True when the player can load.
*/
export function useHasPlayAudioAction() {
return useSelect( selectHasPlayAudioAction, [] );
}