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