| 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 |
|