PluginProbe
BeyondWords – AI audio for publishers / trunk
BeyondWords – AI audio for publishers vtrunk
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 / player / class-player.php

class-player.php in BeyondWords – AI audio for publishers trunk, at src/player/class-player.php

208 lines 5.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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
10 declare( strict_types = 1 );
11
12 namespace BeyondWords\Player;
13
14 defined( '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 */
21 class 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 }
208