` path suffix — strip it and the URL 404s, so this * rewrites the suffix rather than removing it. Width alone (no `x`) * keeps the frame's own aspect ratio; the stage crops via CSS. * * @param string $url Thumbnail URL from oEmbed. * @param int $width Desired width in pixels. */ private static function vimeo_thumb_width( string $url, int $width ): string { if ( ! preg_match( '#^https://[\w.-]*vimeocdn\.com/#', $url ) ) { return $url; } $parts = explode( '?', $url, 2 ); $path = preg_replace( '/-d_\d+(x\d+)?$/', '', $parts[0] ) . '-d_' . $width; return isset( $parts[1] ) ? $path . '?' . $parts[1] : $path; } /** * Vimeo's own thumbnail for a video, at the largest size oEmbed offers. * * Unlike YouTube, Vimeo publishes no guessable thumbnail URL, so this costs * a remote call — and that call sits on a page render, so it is fenced: the * result is cached for a day, the miss is claimed before the request goes * out (a slow Vimeo delays one render, not every concurrent one), and a * failure is remembered briefly so a broken video does not re-fetch on * every view. Saving a video warms this ahead of the first render. */ public static function vimeo_poster( string $vid ): string { if ( '' === $vid ) { return ''; } $key = 'trueplayer_vimeo_poster_' . $vid; $cached = get_transient( $key ); if ( is_string( $cached ) ) { return $cached; } // Claim the miss first — concurrent renders skip the call and paint // posterless instead of queueing behind the same request. set_transient( $key, '', 15 * MINUTE_IN_SECONDS ); $poster = ''; $res = wp_safe_remote_get( 'https://vimeo.com/api/oembed.json?url=' . rawurlencode( 'https://vimeo.com/' . $vid ), [ 'timeout' => 3 ] ); if ( ! is_wp_error( $res ) && 200 === wp_remote_retrieve_response_code( $res ) ) { $body = json_decode( wp_remote_retrieve_body( $res ), true ); if ( is_array( $body ) && ! empty( $body['thumbnail_url'] ) ) { $poster = self::vimeo_thumb_width( (string) $body['thumbnail_url'], 1280 ); } } if ( '' !== $poster ) { set_transient( $key, $poster, DAY_IN_SECONDS ); } return $poster; } /** * The poster to paint for a source when the author set none: the provider's * own thumbnail, at the best resolution it publishes. * * Returns [ url, fallbacks ] — `fallbacks` is the rest of the candidate * chain for providers (YouTube) that 404 their HD sizes on some videos. */ public static function derive_poster( array $source ): array { $type = (string) ( $source['type'] ?? '' ); $src = (string) ( $source['src'] ?? '' ); if ( 'youtube' === $type ) { $candidates = self::youtube_poster_candidates( self::youtube_id( $src ) ); if ( $candidates ) { return [ array_shift( $candidates ), $candidates ]; } } elseif ( 'vimeo' === $type ) { $poster = self::vimeo_poster( self::vimeo_id( $src ) ); if ( '' !== $poster ) { return [ $poster, [] ]; } } return [ '', [] ]; } /** * Fill `source.poster` from the provider when the author left it empty, so * every frontend consumer (facade, player, playlist, SEO schema, ambient * skin) paints the same image. Render-time only — the admin editor reads * raw meta, so a derived poster is never written back as an author choice. */ public static function with_derived_poster( array $config ): array { $source = is_array( $config['source'] ?? null ) ? $config['source'] : []; if ( ! empty( $source['poster'] ) && is_string( $source['poster'] ) ) { return $config; } list( $poster, $fallbacks ) = self::derive_poster( $source ); if ( '' === $poster ) { return $config; } $config['source']['poster'] = $poster; $config['source']['posterFallbacks'] = $fallbacks; $config['source']['posterDerived'] = true; return $config; } /** * The product name shown in the admin and any attribution. Filterable via * `trueplayer/brand_name`. */ public static function brand_name(): string { return (string) apply_filters( 'trueplayer/brand_name', 'TruePlayer' ); } /** * The site owner's own mark, via `trueplayer/brand_logo`. Empty by default — * the admin then falls back to TruePlayer's built-in play glyph. */ public static function brand_logo(): string { return (string) apply_filters( 'trueplayer/brand_logo', '' ); } /** * The site-wide watch-verification policy, with every key present. * * One source of truth for three consumers that used to keep their own copy: * the settings screen, a video's gating config, and the per-video editor. * The editor's copy was the damaging one — it wrote its hardcoded `false` * for `requireLogin` into every video the moment anyone opened the Questions * & gating tab, so the site-wide toggle silently stopped applying. */ /** * What happens once a viewer has used every quiz attempt. * * lock_retry_after_rewatch — lock the video and wipe the watch coverage, so * re-reaching the completion threshold earns a * fresh set of attempts. The original, and the * default, so no existing site changes. * never_lock — attempts keep being counted and reported, but * running out never locks anything and coverage * is left alone, so the video still resumes. */ const ON_FAIL_POLICIES = [ 'lock_retry_after_rewatch', 'never_lock' ]; public static function enforcement_defaults(): array { $saved = self::get_settings_section( 'enforcement' ); $on_fail = isset( $saved['onFail'] ) ? (string) $saved['onFail'] : ''; return [ 'completionThreshold' => isset( $saved['completionThreshold'] ) ? (int) $saved['completionThreshold'] : 90, 'antiSkip' => array_key_exists( 'antiSkip', $saved ) ? (bool) $saved['antiSkip'] : true, 'maxAttempts' => isset( $saved['maxAttempts'] ) ? (int) $saved['maxAttempts'] : 3, // Settings are stored verbatim, so an unknown value here would flow // straight into the grading branch — validated back to the default. 'onFail' => in_array( $on_fail, self::ON_FAIL_POLICIES, true ) ? $on_fail : 'lock_retry_after_rewatch', 'requireLogin' => array_key_exists( 'requireLogin', $saved ) ? (bool) $saved['requireLogin'] : false, 'strict' => array_key_exists( 'strict', $saved ) ? (bool) $saved['strict'] : false, 'trackGuests' => array_key_exists( 'trackGuests', $saved ) ? (bool) $saved['trackGuests'] : true, ]; } /** Recursive array merge where $over wins; list (numeric) arrays are replaced. */ private static function deep_merge( array $base, array $over ): array { foreach ( $over as $k => $v ) { if ( is_array( $v ) && isset( $base[ $k ] ) && is_array( $base[ $k ] ) && ! self::is_list( $v ) ) { $base[ $k ] = self::deep_merge( $base[ $k ], $v ); } else { $base[ $k ] = $v; } } return $base; } private static function is_list( array $a ): bool { return array_keys( $a ) === range( 0, count( $a ) - 1 ); } /** * Strict validation for every style-bearing value in a stored config, * preset or settings blob. * * Any key naming a colour (`accent`, `color`, `hoverColor`, `borderColor`, * `backgroundColor`, `captionColor`, …) ends up in a `style` attribute or a * CSS custom property, so it must be a colour and nothing else — a value * like `red;background:url(…)` would otherwise inject arbitrary CSS. Invalid * values are dropped and the player falls back to its default. * * @param mixed $data Decoded JSON data. * @return mixed */ public static function sanitize_style_values( $data ) { if ( ! is_array( $data ) ) { return $data; } foreach ( $data as $key => $value ) { if ( is_array( $value ) ) { $data[ $key ] = self::sanitize_style_values( $value ); } elseif ( is_string( $key ) && preg_match( '/(color|accent)$/i', $key ) ) { $data[ $key ] = self::sanitize_css_color( $value ); } } return $data; } /** * A CSS colour, or '' when the value is anything else. * * Accepts #rgb/#rgba/#rrggbb/#rrggbbaa, rgb()/rgba()/hsl()/hsla() with * numeric arguments only, and `transparent`. * * @param mixed $value Raw value. * @return string */ public static function sanitize_css_color( $value ): string { if ( ! is_string( $value ) ) { return ''; } $value = strtolower( trim( $value ) ); if ( preg_match( '/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/', $value ) ) { return $value; } if ( preg_match( '/^(?:rgba?|hsla?)\(\s*[0-9.]+(?:deg|%)?(?:\s*[,\s\/]\s*[0-9.]+%?){2,3}\s*\)$/', $value ) ) { return $value; } return 'transparent' === $value ? $value : ''; } }