Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
Player
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
7 / 7
20
100.00% covered (success)
100.00%
1 / 1
 init
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 registerShortcodes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 autoPrependPlayer
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 replaceLegacyCustomPlayer
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 renderPlayer
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 isEnabled
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 hasCustomPlayer
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
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 Symfony\Component\DomCrawler\Crawler;
11
12/**
13 * Class Player
14 *
15 * Entry point for registering player-related WordPress hooks.
16 */
17defined('ABSPATH') || exit;
18
19class Player
20{
21    /**
22     * List of renderer class names (must have static check() and render()).
23     *
24     * @var string[]
25     */
26    protected static array $renderers = [
27        Renderer\Amp::class,
28        Renderer\Javascript::class,
29    ];
30
31    /**
32     * Add WordPress hooks.
33     */
34    public static function init(): void
35    {
36        // Actions.
37        add_action('init', [self::class, 'registerShortcodes']);
38
39        // Filters.
40        add_filter('the_content', [self::class, 'replaceLegacyCustomPlayer'], 5);
41        add_filter('the_content', [self::class, 'autoPrependPlayer'], 1000000);
42        add_filter('newsstand_the_content', [self::class, 'autoPrependPlayer']);
43    }
44
45    /**
46     * Register the [beyondwords_player] shortcode.
47     */
48    public static function registerShortcodes(): void
49    {
50        add_shortcode('beyondwords_player', fn() => self::renderPlayer('shortcode'));
51    }
52    /**
53     * Conditionally prepend the player to a string (the post content).
54     *
55     *
56     */
57    public static function autoPrependPlayer($content)
58    {
59        if (! is_singular() || self::hasCustomPlayer($content)) {
60            return $content;
61        }
62
63        return self::renderPlayer('auto') . $content;
64    }
65
66    /**
67     * Replace the legacy custom player div with the shortcode.
68     *
69     * @since 6.0.0
70     * @since 6.0.1 Use regex to match legacy player divs.
71     *
72     * @param string $content The post content.
73     *
74     * @return string The post content.
75     */
76    public static function replaceLegacyCustomPlayer($content)
77    {
78        if (! is_singular()) {
79            return $content;
80        }
81
82        // Use regex to match legacy player divs with any whitespace or attribute ordering.
83        // data-beyondwords-player is a boolean attribute - its presence indicates a player div,
84        // regardless of the value (true, false, or any other value).
85        // This handles variations like:
86        // - <div data-beyondwords-player="true"></div>
87        // - <div data-beyondwords-player="anything"></div>
88        // - <div data-beyondwords-player></div> (boolean attribute)
89        // - <div data-beyondwords-player contenteditable="false"></div>
90        // - <div contenteditable="false" data-beyondwords-player> </div>
91        // - <div data-beyondwords-player />
92        $pattern = '/<div\s+(?=[^>]*data-beyondwords-player[\s>=\/])[^>]*(?:\/>|>\s*<\/div>)/i';
93
94        return preg_replace($pattern, '[beyondwords_player]', $content);
95    }
96
97    /**
98     * Render a player (AMP/JS depending on context).
99     *
100     * @param string $context The context in which the player is being rendered.
101     *                        One of 'auto' or 'shortcode'.
102     */
103    public static function renderPlayer(string $context = 'shortcode'): string
104    {
105        $post = get_post();
106
107        if (! $post instanceof \WP_Post || ! self::isEnabled($post)) {
108            return '';
109        }
110
111        $html = '';
112
113        foreach (self::$renderers as $rendererClass) {
114            if (is_callable([$rendererClass, 'check']) && $rendererClass::check($post)) {
115                if (is_callable([$rendererClass, 'render'])) {
116                    $html = $rendererClass::render($post, $context);
117                    break;
118                }
119            }
120        }
121
122        $projectId = PostMetaUtils::getProjectId($post->ID);
123        $contentId = PostMetaUtils::getContentId($post->ID, true);
124
125        /**
126         * Filters the HTML of the BeyondWords Player.
127         *
128         * @since 4.0.0
129         * @since 4.3.0 Applied to all player renderers (AMP and JavaScript).
130         * @since 6.1.0 Add $context parameter.
131         *
132         * @param string $html      The HTML for the audio player.
133         * @param int    $postId    WordPress post ID.
134         * @param int    $projectId BeyondWords project ID.
135         * @param int    $contentId BeyondWords content ID.
136         * @param string $context   The context: 'auto' or 'shortcode'.
137         */
138        $html = apply_filters('beyondwords_player_html', $html, $post->ID, $projectId, $contentId, $context);
139
140        return $html;
141    }
142
143    /**
144     * Check if the player is enabled for a post. This considers "Headless" mode
145     * as enabled since we still want to output the player script tag for Headless.
146     *
147     * @param \WP_Post $post Post object.
148     *
149     * @return bool True if the player is enabled.
150     */
151    public static function isEnabled(\WP_Post $post): bool
152    {
153        if (PostMetaUtils::getDisabled($post->ID)) {
154            return false;
155        }
156
157        // Default to "Enabled".
158        $playerUI = get_option(PlayerUI::OPTION_NAME, PlayerUI::ENABLED);
159
160        $enabled = [PlayerUI::ENABLED, PlayerUI::HEADLESS];
161
162        return in_array($playerUI, $enabled, true);
163    }
164
165    /**
166     * Detect if a custom player is already in the content.
167     *
168     *
169     */
170    public static function hasCustomPlayer(string $content): bool
171    {
172        // Detect shortcode.
173        if (has_shortcode($content, 'beyondwords_player')) {
174            return true;
175        }
176
177        $crawler = new Crawler($content);
178
179        // Detect player script tag.
180        $scriptXpath = sprintf('//script[@async][@defer][contains(@src, "%s")]', Environment::getJsSdkUrl());
181        if ($crawler->filterXPath($scriptXpath)->count() > 0) {
182            return true;
183        }
184
185        // Detect legacy player div.
186        if ($crawler->filterXPath('//div[@data-beyondwords-player="true"]')->count() > 0) {
187            return true;
188        }
189
190        return false;
191    }
192}