0, 'label' => '' ], $atts, 'trueplayer_popup' ); $video_id = (int) $atts['id']; if ( ! $video_id || get_post_type( $video_id ) !== TRUEPLAYER_VIDEO_POST_TYPE ) { return ''; } wp_enqueue_style( Assets::FRONTEND_STYLE_HANDLE ); wp_enqueue_script( Assets::FRONTEND_SCRIPT_HANDLE ); $config = self::resolved_config( $video_id ); self::maybe_enqueue_quizpress( $config ); $json = wp_json_encode( [ 'videoId' => $video_id, 'title' => get_the_title( $video_id ), 'config' => $config ] ); $label = '' !== trim( (string) $content ) ? do_shortcode( $content ) : ( $atts['label'] ?: __( 'Watch video', 'trueplayer' ) ); $accent = Helper::sanitize_css_color( $config['customize']['appearance']['accent'] ?? '' ); $style = $accent ? sprintf( ' style="--tp-accent:%s"', esc_attr( $accent ) ) : ''; return sprintf( '', $video_id, esc_attr( $json ), wp_kses_post( $label ), $style ); } public function render( $atts ) { $atts = shortcode_atts( [ 'id' => 0 ], $atts, self::TAG ); $video_id = (int) $atts['id']; if ( ! $video_id || get_post_type( $video_id ) !== TRUEPLAYER_VIDEO_POST_TYPE ) { return ''; } // Engine router. Core renders the native player; another engine can claim // an item by returning markup from this filter. Nothing registered means nothing // changes — core carries no knowledge of the other engines. $engine = apply_filters( 'trueplayer/render_engine', null, $video_id ); if ( null !== $engine ) { return (string) $engine; } // Ensure the runtime is on the page. wp_enqueue_style( Assets::FRONTEND_STYLE_HANDLE ); wp_enqueue_script( Assets::FRONTEND_SCRIPT_HANDLE ); $config = self::resolved_config( $video_id ); self::maybe_enqueue_quizpress( $config ); $json = wp_json_encode( [ 'videoId' => $video_id, 'title' => get_the_title( $video_id ), 'config' => $config, ] ); $behavior = is_array( $config['customize']['behavior'] ?? null ) ? $config['customize']['behavior'] : []; $ap_mode = $behavior['autoplayMode'] ?? ''; $autoplay = $ap_mode ? 'off' !== $ap_mode : ! empty( $behavior['autoplay'] ); // Load strategy — when the real player boots: // facade (default): a lightweight poster; nothing loads until click. // eager : boot the player on page load (no facade, no auto-play). // onvisible : boot when scrolled into view (facade until then). // Autoplay forces eager (there is nothing to wait for). $strategy = in_array( $behavior['loadStrategy'] ?? '', [ 'facade', 'eager', 'onvisible' ], true ) ? $behavior['loadStrategy'] : 'facade'; if ( $autoplay ) { $strategy = 'eager'; } $use_facade = 'eager' !== $strategy; // Dynamic description rendered under the player. $description = ''; if ( ! empty( $config['description'] ) && is_string( $config['description'] ) ) { $description = sprintf( '
%s
', wp_kses_post( wpautop( $config['description'] ) ) ); } $attrs = sprintf( ' data-tp-load="%s"', esc_attr( $strategy ) ); if ( $autoplay ) { $attrs .= ' data-tp-autoplay="1"'; } self::video_schema( $video_id, $config ); return sprintf( '
%3$s
%5$s', $video_id, esc_attr( $json ), $use_facade ? self::render_facade( $config ) : '', $attrs, $description ); } /** * The full frontend config pipeline, shared by the inline and popup embeds: * raw meta → preset merge → `trueplayer/player/config` → answer-key strip. */ public static function resolved_config( int $video_id ): array { // Resolve the media type FIRST. Everything downstream branches on it — // the preset merge picks a video or an audio default, the facade picks // its shape — and an item that was never explicitly marked (an import, a // pasted URL, anything predating first-class audio) has no answer until // this runs. Read-time only; nothing is written back. $config = Media::normalize_config( Helper::get_video_config( $video_id ) ); $config = Helper::apply_preset( $config ); $config = Helper::with_derived_poster( $config ); $config = Helper::apply_global_branding( $config ); /** * The resolved config, before answer keys are stripped. Add-ons that * prepare their own config keys for the browser hook in here. * * @param array $config The resolved config. * @param int $video_id The video being rendered. */ $config = (array) apply_filters( 'trueplayer/player/config', $config, $video_id ); // Re-validate at render too, so values stored before validation existed // (or written by another path) can never reach a style attribute. $config = Helper::sanitize_style_values( $config ); return self::strip_answer_keys( $config ); } /** * VideoObject JSON-LD for SEO (Google video rich results). Requires a * thumbnail — provider posters are derived upstream (Helper::with_derived_poster) * when none is set. Filterable/disable-able via `trueplayer/seo_schema`. */ private static function video_schema( int $video_id, array $config ): string { $source = is_array( $config['source'] ?? null ) ? $config['source'] : []; $poster = is_string( $source['poster'] ?? null ) ? $source['poster'] : ''; // Google requires a thumbnail — skip schema rather than emit an invalid one. if ( '' === $poster ) { return ''; } $post = get_post( $video_id ); $schema = [ '@context' => 'https://schema.org', // An audio item described as a VideoObject is simply wrong data for // search engines, and the thumbnail requirement below reads as cover // art rather than a video still. '@type' => Media::is_audio( $source ) ? 'AudioObject' : 'VideoObject', 'name' => get_the_title( $video_id ), 'description' => wp_strip_all_tags( $post && $post->post_excerpt ? $post->post_excerpt : get_the_title( $video_id ) ), 'thumbnailUrl' => $poster, 'uploadDate' => $post ? get_post_time( 'c', true, $post ) : '', ]; if ( ! empty( $source['src'] ) && in_array( $source['type'] ?? '', [ 'url', 'hls' ], true ) ) { $schema['contentUrl'] = $source['src']; } $schema = apply_filters( 'trueplayer/seo_schema', $schema, $video_id, $config ); if ( empty( $schema ) || ! is_array( $schema ) ) { return ''; } // Printed through core's inline-script helper in the footer. JSON_HEX_TAG // keeps any `` inside a string from closing the block early. $json = wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG ); add_action( 'wp_footer', static function () use ( $json ) { wp_print_inline_script_tag( $json, [ 'type' => 'application/ld+json' ] ); } ); return ''; } /** * Static poster + play button rendered up front so a page with videos stays * fast: nothing heavy (video element, YouTube/Vimeo iframe, hls.js, gate * request) loads until the visitor clicks this. Pure HTML — no JS needed to * paint it. Skipped when autoplay is on. */ private static function render_facade( array $config ): string { $source = is_array( $config['source'] ?? null ) ? $config['source'] : []; // Detected, not merely declared. The facade is painted before any script // runs, so if it disagrees with what the player will build the visitor // sees a 16:9 black box collapse into an audio bar on boot. Callers pass // a normalized config already; this re-resolves cheaply so a future one // that doesn't can't reintroduce the flash. $is_audio = Media::is_audio( $source ); $poster = is_string( $source['poster'] ?? null ) ? $source['poster'] : ''; $accent = Helper::sanitize_css_color( $config['customize']['appearance']['accent'] ?? ( $config['branding']['accent'] ?? '' ) ); $ratio = $config['customize']['appearance']['aspectRatio'] ?? ''; $rules = []; if ( $accent ) { $rules[] = '--tp-accent:' . $accent; } // Match the player's aspect ratio so the boot swap causes no layout shift. if ( $ratio && '16:9' !== $ratio && ! $is_audio && preg_match( '/^\d+:\d+$/', $ratio ) ) { $rules[] = 'aspect-ratio:' . str_replace( ':', ' / ', $ratio ); } $style = $rules ? sprintf( ' style="%s"', esc_attr( implode( ';', $rules ) ) ) : ''; // A derived poster asks for the widest size first, which YouTube 404s // on non-HD uploads — the rest of the chain rides on the tag for the // runtime to step through (mount.js: attachPosterFallback). $fallbacks = array_values( array_filter( (array) ( $source['posterFallbacks'] ?? [] ), 'is_string' ) ); $chain = $fallbacks ? sprintf( ' data-tp-poster-fallback="%s"', esc_attr( wp_json_encode( $fallbacks ) ) ) : ''; $img = $poster ? sprintf( '', esc_url( $poster ), $chain ) : ''; return sprintf( '', $is_audio ? ' is-audio' : '', $style, $is_audio ? esc_attr__( 'Play audio', 'trueplayer' ) : esc_attr__( 'Play video', 'trueplayer' ), $img ); } /** * Remove `correct`/`answer` keys from any question set before the config * reaches the browser. The gate/grade endpoints hold the source of truth. */ public static function strip_answer_keys( array $config ): array { if ( empty( $config['gating'] ) || ! is_array( $config['gating'] ) ) { return $config; } $gating = $config['gating']; $scrub = static function ( $questions ) { if ( ! is_array( $questions ) ) { return $questions; } foreach ( $questions as &$q ) { unset( $q['correct'], $q['answer'], $q['correctIndex'], $q['correctIndexes'] ); if ( isset( $q['options'] ) && is_array( $q['options'] ) ) { foreach ( $q['options'] as &$opt ) { if ( is_array( $opt ) ) { unset( $opt['correct'], $opt['isCorrect'] ); } } unset( $opt ); } } unset( $q ); return $questions; }; if ( ! empty( $gating['checkpoints'] ) && is_array( $gating['checkpoints'] ) ) { foreach ( $gating['checkpoints'] as &$cp ) { if ( isset( $cp['questions'] ) ) { $cp['questions'] = $scrub( $cp['questions'] ); } } unset( $cp ); } if ( ! empty( $gating['finalQuiz']['questions'] ) ) { $gating['finalQuiz']['questions'] = $scrub( $gating['finalQuiz']['questions'] ); } $config['gating'] = $gating; return $config; } }