Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
Player
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
7 / 7
24
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
 register_shortcodes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 auto_prepend_player
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 replace_legacy_custom_player
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 render_player
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 is_enabled
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 has_custom_player
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2/**
3 * BeyondWords player entry point: front-end hooks that decide when a player renders.
4 *
5 * @package BeyondWords\Player
6 * @since   3.0.0
7 * @since   7.0.0 Refactored to BeyondWords namespace with snake_case methods.
8 */
9
10declare( strict_types = 1 );
11
12namespace BeyondWords\Player;
13
14defined( 'ABSPATH' ) || exit;
15
16/**
17 * Front-end player rendering coordinator.
18 *
19 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
20 */
21class Player {
22
23    /**
24     * Renderer classes evaluated in order; the first whose `check()` passes wins.
25     *
26     * AMP stays first so AMP requests get the AMP player.
27     *
28     * @var string[]
29     */
30    protected static array $renderers = [
31        \BeyondWords\Player\Renderer\Amp::class,
32        \BeyondWords\Player\Renderer\Javascript::class,
33    ];
34
35    /**
36     * Register WordPress hooks.
37     */
38    public static function init(): void {
39        add_action( 'init', [ self::class, 'register_shortcodes' ] );
40
41        // Legacy-markup replacement runs early; auto-prepend runs at very late
42        // priority so other `the_content` filters can't strip the player.
43        add_filter( 'the_content', [ self::class, 'replace_legacy_custom_player' ], 5 );
44        add_filter( 'the_content', [ self::class, 'auto_prepend_player' ], 1000000 );
45        add_filter( 'newsstand_the_content', [ self::class, 'auto_prepend_player' ] );
46    }
47
48    /**
49     * Register the `[beyondwords_player]` shortcode.
50     */
51    public static function register_shortcodes(): void {
52        add_shortcode( 'beyondwords_player', static fn() => self::render_player( 'shortcode' ) );
53    }
54
55    /**
56     * Auto-prepend the player to `the_content` unless the post already embeds one.
57     *
58     * @param string $content Post content.
59     *
60     * @return string
61     */
62    public static function auto_prepend_player( $content ) {
63        if ( ! is_singular() ) {
64            return $content;
65        }
66
67        // Mirrors render_player()'s own early return, but bails before the expensive
68        // has_custom_player() DOM scan when no player could render anyway.
69        $post = get_post();
70
71        if ( ! $post instanceof \WP_Post || ! self::is_enabled( $post ) ) {
72            return $content;
73        }
74
75        if ( self::has_custom_player( $content ) ) {
76            return $content;
77        }
78
79        return self::render_player( 'auto' ) . $content;
80    }
81
82    /**
83     * Convert legacy `<div data-beyondwords-player>` placeholders to the shortcode.
84     *
85     * The regex tolerates attribute reordering, valueless (boolean) attributes,
86     * extra attributes, and self-closing tags.
87     *
88     * @param string $content Post content.
89     *
90     * @return string
91     */
92    public static function replace_legacy_custom_player( $content ) {
93        if ( ! is_singular() ) {
94            return $content;
95        }
96
97        $pattern = '/<div\s+(?=[^>]*data-beyondwords-player[\s>=\/])[^>]*(?:\/>|>\s*<\/div>)/i';
98
99        return preg_replace( $pattern, '[beyondwords_player]', $content );
100    }
101
102    /**
103     * Pick a renderer for the current post and return its HTML.
104     *
105     * The `beyondwords_player_html` filter runs even when no renderer matches
106     * — that way third-party code can still inject markup conditionally.
107     *
108     * @param string $context 'auto' (auto-prepend) or 'shortcode'.
109     */
110    public static function render_player( string $context = 'shortcode' ): string {
111        $post = get_post();
112
113        if ( ! $post instanceof \WP_Post || ! self::is_enabled( $post ) ) {
114            return '';
115        }
116
117        $html = '';
118
119        foreach ( self::$renderers as $renderer_class ) {
120            if (
121                is_callable( [ $renderer_class, 'check' ] )
122                && $renderer_class::check( $post )
123                && is_callable( [ $renderer_class, 'render' ] )
124            ) {
125                $html = $renderer_class::render( $post, $context );
126                break;
127            }
128        }
129
130        $project_id = \BeyondWords\Post\Meta::get_project_id( $post->ID );
131        $content_id = \BeyondWords\Post\Meta::get_content_id( $post->ID, true );
132
133        /**
134         * Filters the HTML of the BeyondWords Player.
135         *
136         * @since 4.0.0
137         * @since 4.3.0 Applied to all player renderers (AMP and JavaScript).
138         * @since 6.1.0 Added `$context` parameter.
139         *
140         * @param string $html       Audio player HTML.
141         * @param int    $post_id    WordPress post ID.
142         * @param int    $project_id BeyondWords project ID.
143         * @param int    $content_id BeyondWords content ID.
144         * @param string $context    Rendering context: 'auto' or 'shortcode'.
145         */
146        return apply_filters( 'beyondwords_player_html', $html, $post->ID, $project_id, $content_id, $context );
147    }
148
149    /**
150     * Whether the player should be active for this post.
151     *
152     * Headless mode counts as enabled — we still emit the SDK script so the
153     * publisher's own UI can drive it.
154     *
155     * @since 7.0.0 "Embed: None" hides the player; the legacy disabled flag is still honoured.
156     *
157     * @param \WP_Post $post Post object.
158     */
159    public static function is_enabled( \WP_Post $post ): bool {
160        if ( \BeyondWords\Editor\Components\SettingsFields::is_player_disabled_for_post( $post->ID ) ) {
161            return false;
162        }
163
164        $player_ui = get_option( \BeyondWords\Settings\Fields::OPTION_PLAYER_UI, \BeyondWords\Settings\Fields::PLAYER_UI_ENABLED );
165
166        return in_array(
167            $player_ui,
168            [ \BeyondWords\Settings\Fields::PLAYER_UI_ENABLED, \BeyondWords\Settings\Fields::PLAYER_UI_HEADLESS ],
169            true
170        );
171    }
172
173    /**
174     * Detect whether the post content already contains a BeyondWords player.
175     *
176     * @param string $content Post content.
177     */
178    public static function has_custom_player( string $content ): bool {
179        if ( has_shortcode( $content, 'beyondwords_player' ) ) {
180            return true;
181        }
182
183        $js_sdk_url = \BeyondWords\Core\Urls::get_js_sdk_url();
184
185        // Both XPath queries need one of these literal substrings, so when neither
186        // appears we can skip the expensive per-pageview DOM parse.
187        if (
188            ! str_contains( $content, 'data-beyondwords-player' )
189            && ! str_contains( $content, $js_sdk_url )
190        ) {
191            return false;
192        }
193
194        $crawler = new \Symfony\Component\DomCrawler\Crawler( $content );
195
196        $script_xpath = sprintf( '//script[@async][@defer][contains(@src, "%s")]', $js_sdk_url );
197        if ( $crawler->filterXPath( $script_xpath )->count() > 0 ) {
198            return true;
199        }
200
201        if ( $crawler->filterXPath( '//div[@data-beyondwords-player="true"]' )->count() > 0 ) {
202            return true;
203        }
204
205        return false;
206    }
207}