Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.90% covered (success)
85.90%
134 / 156
33.33% covered (danger)
33.33%
5 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Player
85.90% covered (success)
85.90%
134 / 156
33.33% covered (danger)
33.33%
5 / 15
63.48
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 registerShortcodes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 playerShortcode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 autoPrependPlayer
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 playerHtml
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
7.54
 hasCustomPlayer
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 jsPlayerHtml
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 ampPlayerHtml
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 isPlayerEnabled
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 enqueueScripts
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 useAmpPlayer
57.14% covered (danger)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 scriptLoaderTag
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 jsPlayerParams
85.00% covered (success)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
7.17
 addPluginSettingsToSdkParams
87.50% covered (success)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
4.03
 usePlayerJsSdk
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
10.27
1<?php
2
3declare(strict_types=1);
4
5namespace Beyondwords\Wordpress\Core\Player;
6
7use Beyondwords\Wordpress\Component\Post\PostMetaUtils;
8use Beyondwords\Wordpress\Component\Settings\Fields\PlayerUI\PlayerUI;
9use Beyondwords\Wordpress\Core\Environment;
10use Beyondwords\Wordpress\Core\CoreUtils;
11use Symfony\Component\DomCrawler\Crawler;
12
13/**
14 * The "Latest" BeyondWords Player.
15 *
16 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
17 **/
18class Player
19{
20    /**
21     * Init.
22     */
23    public function init()
24    {
25        // Actions
26        add_action('init', array($this, 'registerShortcodes'));
27        add_action('wp_enqueue_scripts', array($this, 'enqueueScripts'));
28
29        // Filters
30        add_filter('the_content', array($this, 'autoPrependPlayer'), 1000000);
31        add_filter('newsstand_the_content', array($this, 'autoPrependPlayer'));
32    }
33
34    /**
35     * Register shortcodes.
36     *
37     * @since 4.2.0
38     */
39    public function registerShortcodes()
40    {
41        add_shortcode('beyondwords_player', array($this, 'playerShortcode'));
42    }
43
44    /**
45     * HTML output for the BeyondWords player shortcode.
46     *
47     * @since 4.2.0
48     *
49     * @param array $atts Shortcode attributes.
50     *
51     * @return string
52     */
53    public function playerShortcode()
54    {
55        return $this->playerHtml();
56    }
57
58    /**
59     * Auto-prepends the BeyondWords player to WordPress content.
60     *
61     * @since 3.0.0
62     * @since 4.2.0 Renamed from addPlayerToContent to autoPrependPlayer.
63     * @since 4.2.0 Perform hasCustomPlayer() check here.
64     * @since 4.6.1 Only auto-prepend player for frontend is_singular screens.
65     *
66     * @param string $content WordPress content.
67     *
68     * @return string
69     */
70    public function autoPrependPlayer($content)
71    {
72        if (! is_singular()) {
73            return $content;
74        }
75
76        if ($this->hasCustomPlayer($content)) {
77            return $content;
78        }
79
80        return $this->playerHtml() . $content;
81    }
82
83    /**
84     * Player HTML.
85     *
86     * Displays JS SDK variant of the BeyondWords audio player, for both
87     * AMP and non-AMP content.
88     *
89     * @param WP_Post $post WordPress Post.
90     *
91     * @since 3.0.0
92     * @since 3.1.0 Added _doing_it_wrong deprecation warnings
93     *
94     * @return string
95     */
96    public function playerHtml($post = false)
97    {
98        if (! ($post instanceof \WP_Post)) {
99            $post = get_post($post);
100        }
101
102        if (! $post) {
103            return '';
104        }
105
106        if (! $this->isPlayerEnabled($post)) {
107            return '';
108        }
109
110        $projectId = PostMetaUtils::getProjectId($post->ID);
111
112        if (! $projectId) {
113            return '';
114        }
115
116        $contentId = PostMetaUtils::getContentId($post->ID);
117
118        if (! $contentId) {
119            return '';
120        }
121
122        // AMP or JS Player?
123        if ($this->useAmpPlayer()) {
124            $html = $this->ampPlayerHtml($post->ID, $projectId, $contentId);
125        } else {
126            $html = $this->jsPlayerHtml($post->ID, $projectId, $contentId);
127        }
128
129        /**
130         * Filters the HTML of the BeyondWords Player.
131         *
132         * @since 4.0.0
133         * @since 4.3.0 Applied to both AMP and no-AMP content.
134         *
135         * @param string $html      The HTML for the JS audio player. The audio player JavaScript may
136         *                          fail to locate the target element if you remove or replace the
137         *                          default contents of this parameter.
138         * @param int    $postId    WordPress post ID.
139         * @param int    $projectId BeyondWords project ID.
140         * @param int    $contentId BeyondWords content ID.
141         */
142        $html = apply_filters('beyondwords_player_html', $html, $post->ID, $projectId, $contentId);
143
144        /**
145         * Filters the HTML of the BeyondWords player.
146         *
147         * Scheduled for removal in v5.0
148         *
149         * @deprecated 4.3.0 Replaced with beyondwords_player_html.
150         *
151         * @since 3.3.3
152         * @since 4.3.0 Applied to both AMP and no-AMP content.
153         *
154         * @param string $html      The HTML for the JS audio player. The audio player JavaScript may
155         *                          fail to locate the target element if you remove or replace the
156         *                          default contents of this parameter.
157         * @param int    $postId    WordPress post ID.
158         * @param int    $projectId BeyondWords project ID.
159         * @param int    $contentId BeyondWords content ID.
160         */
161        $html = apply_filters('beyondwords_js_player_html', $html, $post->ID, $projectId, $contentId);
162
163        return $html;
164    }
165
166    /**
167     * Has custom player?
168     *
169     * Checks the post content to see whether a custom player has been added.
170     *
171     * @since 3.2.0
172     * @since 4.2.0 Pass $content as a parameter, check for [beyondwords_player] shortcode
173     * @since 4.2.4 Check $content is a string
174     *
175     * @param string $content WordPress content.
176     *
177     * @return boolean
178     */
179    public function hasCustomPlayer($content)
180    {
181        if (! is_string($content)) {
182            return false;
183        }
184
185        if (strpos($content, '[beyondwords_player]') !== false) {
186            return true;
187        }
188
189        $crawler = new Crawler($content);
190
191        return count($crawler->filterXPath('//div[@data-beyondwords-player="true"]')) > 0;
192    }
193
194    /**
195     * JS Player HTML.
196     *
197     * Displays the HTML required for the JS player.
198     *
199     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
200     *
201     * @param int $postId    WordPress Post ID.
202     * @param int $projectId BeyondWords Project ID.
203     * @param int $contentId BeyondWords Content ID.
204     *
205     * @since 3.0.0
206     * @since 3.1.0 Added speechkit_js_player_html filter
207     * @since 4.2.0 Remove hasCustomPlayer() check from here.
208     *
209     * @return string
210     */
211    public function jsPlayerHtml($postId, $projectId, $contentId)
212    {
213        $html = '<div data-beyondwords-player="true" contenteditable="false"></div>';
214
215        return $html;
216    }
217
218    /**
219     * AMP Player HTML.
220     *
221     * Displays the HTML required for the AMP player.
222     *
223     * @param int $postId    WordPress Post ID.
224     * @param int $projectId BeyondWords Project ID.
225     * @param int $contentId BeyondWords Content ID.
226     *
227     * @since 3.0.0
228     * @since 3.1.0 Added speechkit_amp_player_html filter
229     *
230     * @return string
231     */
232    public function ampPlayerHtml($postId, $projectId, $contentId)
233    {
234        $src = sprintf(Environment::getAmpPlayerUrl(), $projectId, $contentId);
235
236        // Turn on output buffering
237        ob_start();
238
239        ?>
240        <amp-iframe
241            frameborder="0"
242            height="43"
243            layout="responsive"
244            sandbox="allow-scripts allow-same-origin allow-popups"
245            scrolling="no"
246            src="<?php echo esc_url($src); ?>"
247            width="295"
248        >
249            <amp-img
250                height="150"
251                layout="responsive"
252                placeholder
253                src="<?php echo esc_url(Environment::getAmpImgUrl()); ?>"
254                width="643"
255            ></amp-img>
256        </amp-iframe>
257        <?php
258
259        $html = ob_get_clean();
260
261        /**
262         * Filters the HTML of the BeyondWords AMP audio player.
263         *
264         * This filter is scheduled to be removed in v5.0.
265         *
266         * @since 3.3.3
267         *
268         * @deprecated 4.3.0 beyondwords_player_html is now applied to AMP and non-AMP content.
269         * @see Beyondwords\Wordpress\Core\Player\Player::playerHtml()
270         *
271         * @param string $html       The HTML for the AMP audio player.
272         * @param int    $post_id    WordPress Post ID.
273         * @param int    $project_id BeyondWords Project ID.
274         * @param int    $contentId  BeyondWords Content ID.
275         */
276        $html = apply_filters('beyondwords_amp_player_html', $html, $postId, $projectId, $contentId);
277
278        return $html;
279    }
280
281    /**
282     * Should we show the BeyondWords audio player?
283     *
284     * We DO NOT want to show the player if:
285     * 1. BeyondWords has been disabled in our plugin settings.
286     * 2. The current post type has not been selected in our plugin settings.
287     * 3. The current post has specifically been disabled from processing.
288     *
289     * The return value of this can be overriden with the WordPress
290     * "beyondwords_post_player_enabled" filter.
291     *
292     * @param int|WP_Post (Optional) Post ID or WP_Post object. Default is global $post.
293     *
294     * @since 3.0.0
295     * @since 3.3.4 Accept int|WP_Post as method parameter.
296     * @since 4.0.0 Check beyondwords_player_ui custom field.
297     * @since 5.0.0 Remove beyondwords_post_player_enabled filter.
298     *
299     * @return bool
300     **/
301    public function isPlayerEnabled($post = null)
302    {
303        $post = get_post($post);
304
305        if (! ($post instanceof \WP_Post)) {
306            return false;
307        }
308
309        // Assume we can show the player
310        $enabled = true;
311
312        // Has 'Display Player' been unchecked?
313        if (PostMetaUtils::getDisabled($post->ID)) {
314            $enabled = false;
315        }
316
317        // Is the player ui enabled in plugin settings?
318        if ($enabled) {
319            $enabled = get_option('beyondwords_player_ui', PlayerUI::ENABLED) === PlayerUI::ENABLED;
320        }
321
322        return $enabled;
323    }
324
325    /**
326     * Register the JavaScript for the public-facing side of the site.
327     *
328     * @since 3.0.0
329     *
330     * @return void
331     */
332    public function enqueueScripts()
333    {
334        if (! is_singular()) {
335            return;
336        }
337
338        if (get_option('beyondwords_player_ui', PlayerUI::ENABLED) === PlayerUI::DISABLED) {
339            return;
340        }
341
342        // JS SDK Player inline script, filtered by $this->scriptLoaderTag()
343        add_filter('script_loader_tag', array($this, 'scriptLoaderTag'), 10, 3);
344
345        wp_enqueue_script(
346            'beyondwords-sdk',
347            Environment::getJsSdkUrl(),
348            array(),
349            BEYONDWORDS__PLUGIN_VERSION,
350            true
351        );
352    }
353
354    /**
355     * Use the AMP player?
356     *
357     * There are multiple AMP plugins for WordPress, so multiple checks are performed.
358     *
359     * @since 3.0.7
360     *
361     * @return bool
362     */
363    public function useAmpPlayer()
364    {
365        // https://amp-wp.org/reference/function/amp_is_request/
366        if (function_exists('amp_is_request')) {
367            return \amp_is_request();
368        }
369
370        // https://ampforwp.com/tutorials/article/detect-amp-page-function/
371        if (function_exists('ampforwp_is_amp_endpoint')) {
372            return \ampforwp_is_amp_endpoint();
373        }
374
375        // https://amp-wp.org/reference/function/is_amp_endpoint/
376        if (function_exists('is_amp_endpoint')) {
377            return \is_amp_endpoint();
378        }
379
380        return false;
381    }
382
383    /**
384     * Filters the HTML script tag of an enqueued script.
385     *
386     * @param string $tag    The <script> tag for the enqueued script.
387     * @param string $handle The script's registered handle.
388     * @param string $src    The script's source URL.
389     *
390     * @since 3.0.0
391     * @since 4.0.0 Updated Player SDK and added `beyondwords_player_script_onload` filter
392     * @since 5.3.1 Use esc_attr for the onload attribute to support UTF-8 characters.
393     *
394     * @see https://developer.wordpress.org/reference/hooks/script_loader_tag/
395     * @see https://stackoverflow.com/a/59594789
396     *
397     * @return string
398     */
399    public function scriptLoaderTag($tag, $handle, $src)
400    {
401        if ($handle === 'beyondwords-sdk') :
402            if (! $this->usePlayerJsSdk()) {
403                return '';
404            }
405
406            $post     = get_post();
407            $params   = $this->jsPlayerParams($post);
408            $playerUI = get_option('beyondwords_player_ui', PlayerUI::ENABLED);
409
410            $paramsJson = wp_json_encode($params, JSON_UNESCAPED_SLASHES);
411
412            if ($playerUI === PlayerUI::HEADLESS) {
413                // Headless instantiates a player without a target
414                $onload = 'new BeyondWords.Player(' . $paramsJson . ');';
415            } else {
416                // Standard mode instantiates player(s) with every div[data-beyondwords-player] as the target(s)
417                $onload = sprintf(
418                    'document.querySelectorAll("div[data-beyondwords-player]").forEach(function(el) { new BeyondWords.Player({ ...%s, target: el });});', // phpcs:ignore Generic.Files.LineLength.TooLong
419                    $paramsJson
420                );
421            }
422
423            // strip newlines to prevent "invalid character" errors
424            $onload = str_replace(array("\r", "\n"), '', $onload);
425
426            // limit whitespace to 1 space for legibility
427            $onload = preg_replace('/\s+/', ' ', $onload);
428
429            /**
430             * Filters the onload attribute of the BeyondWords Player script.
431             *
432             * Note that to support multiple players on one page, the
433             * default script uses `document.querySelectorAll() to target all
434             * instances of `div[data-beyondwords-player]` in the HTML source.
435             * If this approach is removed then multiple occurrences of the
436             * BeyondWords player in one page may not work as expected.
437             *
438             * @link https://github.com/beyondwords-io/player/blob/main/doc/getting-started.md#how-to-configure-it
439             *
440             * @since 4.0.0
441             *
442             * @param string $script The string value of the onload script.
443             * @param array  $params The SDK params for the current post, including
444             *                       `projectId` and `contentId`.
445             */
446            $onload = apply_filters('beyondwords_player_script_onload', $onload, $params);
447
448            ob_start();
449
450            if ($playerUI === PlayerUI::ENABLED || $playerUI === PlayerUI::HEADLESS) :
451                ?>
452                <script
453                    data-beyondwords-sdk="true"
454                    async
455                    defer
456                    src="<?php echo esc_url($src); ?>"
457                    onload='<?php echo esc_attr($onload); ?>'
458                ></script>
459                <?php
460            endif;
461
462            return ob_get_clean();
463        endif;
464
465        return $tag;
466    }
467
468    /**
469     * JavaScript SDK parameters.
470     *
471     * @since 3.1.0
472     * @since 4.0.0 Use new JS SDK params format.
473     * @since 5.3.0 Prioritise post-specific player settings, falling-back to the
474     *              values of the "Player" tab in the plugin settings.
475     * @since 5.3.0 Support loadContentAs param and return an object.
476     *
477     * @param WP_Post $post WordPress Post.
478     *
479     * @return object
480     */
481    public function jsPlayerParams($post)
482    {
483        if (!($post instanceof \WP_Post)) {
484            return [];
485        }
486
487        $projectId = PostMetaUtils::getProjectId($post->ID);
488        $contentId = PostMetaUtils::getContentId($post->ID);
489
490        $params = [
491            'projectId' => is_numeric($projectId) ? (int)$projectId : $projectId,
492            'contentId' => is_numeric($contentId) ? (int)$contentId : $contentId,
493        ];
494
495        // Set initial SDK params from plugin settings
496        $params = $this->addPluginSettingsToSdkParams($params);
497
498        // Player UI
499        $playerUI = get_option('beyondwords_player_ui', PlayerUI::ENABLED);
500        if ($playerUI === PlayerUI::HEADLESS) {
501            $params['showUserInterface'] = false;
502        }
503
504        // Player Style
505        // @todo overwrite global styles with post settings
506        $playerStyle = PostMetaUtils::getPlayerStyle($post->ID);
507        if (!empty($playerStyle)) {
508            $params['playerStyle'] = $playerStyle;
509        }
510
511        // Player Content
512        $playerContent = get_post_meta($post->ID, 'beyondwords_player_content', true);
513        if (!empty($playerContent)) {
514            $params['loadContentAs'] = [ $playerContent ];
515        }
516
517        /**
518         * Filters the BeyondWords JavaScript SDK parameters.
519         *
520         * @since 4.0.0
521         *
522         * @param array $params The default JS SDK params.
523         * @param int   $postId The Post ID.
524         */
525        $params = apply_filters('beyondwords_player_sdk_params', $params, $post->ID);
526
527        // Cast assoc array to object
528        return (object)$params;
529    }
530
531    /**
532     * Add plugin settings to SDK params.
533     *
534     * @since 5.0.0
535     *
536     * @param array $params BeyondWords Player SDK params.
537     *
538     * @return array Modified SDK params.
539     */
540    public function addPluginSettingsToSdkParams($params)
541    {
542        $mapping = [
543            'beyondwords_player_style'              => 'playerStyle',
544            'beyondwords_player_call_to_action'     => 'callToAction',
545            'beyondwords_player_highlight_sections' => 'highlightSections',
546            'beyondwords_player_widget_style'       => 'widgetStyle',
547            'beyondwords_player_widget_position'    => 'widgetPosition',
548            'beyondwords_player_skip_button_style'  => 'skipButtonStyle',
549        ];
550
551        foreach ($mapping as $wpOption => $sdkParam) {
552            $val = get_option($wpOption);
553            if (!empty($val)) {
554                $params[$sdkParam] = $val;
555            }
556        }
557
558        // Special case for clickableSections
559        $val = get_option('beyondwords_player_clickable_sections');
560        if (!empty($val)) {
561            $params['clickableSections'] = 'body';
562        }
563
564        return $params;
565    }
566
567    /**
568     * Use Player JS SDK?
569     *
570     * @since 3.0.7
571     *
572     * @return string
573     */
574    public function usePlayerJsSdk()
575    {
576        // AMP requests don't use the Player JS SDK
577        if ($this->useAmpPlayer()) {
578            return false;
579        }
580
581        // Both Gutenberg/Classic editors have their own player scripts
582        if (CoreUtils::isGutenbergPage() || CoreUtils::isEditScreen()) {
583            return false;
584        }
585
586        // Disable audio player in Preview, because we have not sent updates to BeyondWords API yet
587        if (function_exists('is_preview') && is_preview()) {
588            return false;
589        }
590
591        $post = get_post();
592
593        if (! $post) {
594            return false;
595        }
596
597        $projectId = PostMetaUtils::getProjectId($post->ID);
598        if (! $projectId) {
599            return false;
600        }
601
602        $contentId = PostMetaUtils::getContentId($post->ID);
603        if (! $contentId) {
604            return false;
605        }
606
607        return true;
608    }
609}