1, // Player config schema version. 'station' => 0, 'stations' => array(), // Extra stations for the switcher. 'layout' => 'card', // card | hero | compact | button (+ add-on layouts). 'skin' => 'modern', 'theme' => 'auto', // light | dark | auto. 'tokens' => array( 'primary' => '#6d5dfc', 'accent' => '#22d3ee', 'background' => '', 'text' => '', 'radius' => '16', 'fontFamily' => '', ), // per-device component visibility, e.g. { mobile: { social: false } }. 'visibility' => array(), 'components' => array_map( static function ( $c ) { return ! empty( $c['default'] ); }, \StreamSera\Registry\Components::all() ), 'behavior' => array( 'autoplay' => false, 'autoplayMuted' => true, // Start muted when the browser blocks autoplay with sound. 'switcherStyle' => 'dropdown', // dropdown | tabs | list. 'metadataInterval' => 15, 'liveText' => 'LIVE', 'volume' => -1, // Starting volume 0–100; -1 uses the site default. ), // Off-air fallback: loop a standby file while the live stream is down, then switch back. 'fallback' => array( 'enabled' => false, 'url' => '', 'id' => 0, 'message' => '', 'retryAfter' => 30, ), ); /** * Filter the default player config. * * @param array $config Default config. */ return apply_filters( 'streamsera_default_player_config', $config ); } /** * Registered player layouts. Add-ons extend this via filter. * * @return array */ function streamsera_layouts() { return \StreamSera\Registry\Layouts::all(); } /** * Core layout definitions (seed for the registry). * * @return array */ function streamsera_core_layouts() { $layouts = array( 'card' => array( 'label' => __( 'Card', 'streamsera' ), 'description' => __( 'Neat card with artwork beside the station and song, and controls below.', 'streamsera' ), ), 'hero' => array( 'label' => __( 'Hero', 'streamsera' ), 'description' => __( 'Large full-width player with blurred artwork background.', 'streamsera' ), ), // two more free layouts. 'compact' => array( 'label' => __( 'Compact', 'streamsera' ), 'description' => __( 'Inline pill with play, title and volume. Fits sidebars and menus.', 'streamsera' ), ), 'button' => array( 'label' => __( 'Listen live button', 'streamsera' ), 'description' => __( 'A single call-to-action button that plays the stream in place.', 'streamsera' ), ), 'shelf' => array( 'label' => __( 'Station shelf', 'streamsera' ), 'description' => __( 'A row of big station cards you scroll sideways, with the live song on the playing one.', 'streamsera' ), ), ); return $layouts; } /** * Registered skins (token presets). * * @return array */ function streamsera_skins() { return \StreamSera\Registry\Skins::all(); } /** * Core skin definitions (seed for the registry). * * @return array */ function streamsera_core_skins() { // skins carry tokens plus an optional `vars` map of extra CSS custom properties // (emitted as --ss- on the player root) and a short description for the builder. $skins = array( 'modern' => array( 'label' => __( 'Modern', 'streamsera' ), 'description' => __( 'Soft gradients and rounded corners.', 'streamsera' ), 'tokens' => array( 'primary' => '#6d5dfc', 'accent' => '#22d3ee', 'radius' => '16', ), 'vars' => array( 'shadow' => '0 10px 30px rgba(17, 24, 39, 0.12)', ), ), 'minimal' => array( 'label' => __( 'Minimal', 'streamsera' ), 'description' => __( 'Monochrome, no shadows, tight spacing.', 'streamsera' ), // No primary/accent: the stylesheet picks ink-on-paper per theme (dark text in light, // light text in dark) so the monochrome look survives dark mode. 'tokens' => array( 'primary' => '', 'accent' => '', 'radius' => '8', ), 'vars' => array( 'shadow' => 'none', 'border' => 'rgba(127, 127, 127, 0.3)', 'surface' => 'transparent', ), ), 'neon' => array( 'label' => __( 'Neon', 'streamsera' ), 'description' => __( 'Glowing controls on a dark surface.', 'streamsera' ), 'tokens' => array( 'primary' => '#f0abfc', 'accent' => '#4ade80', 'radius' => '24', 'background' => '#0b0b14', 'text' => '#f5f3ff', ), 'vars' => array( 'shadow' => '0 0 28px rgba(240, 171, 252, 0.45)', 'surface' => 'rgba(255,255,255,0.06)', ), ), 'glass' => array( 'label' => __( 'Glass', 'streamsera' ), 'description' => __( 'Frosted translucent panel.', 'streamsera' ), 'tokens' => array( 'primary' => '#ffffff', 'accent' => '#38bdf8', 'radius' => '20', ), 'vars' => array( 'surface' => 'rgba(255,255,255,0.18)', 'backdrop' => 'blur(18px) saturate(1.4)', 'border' => 'rgba(255,255,255,0.35)', 'on-primary' => '#111827', // White primary: icons on it must be dark. ), ), 'studio' => array( 'label' => __( 'Dark Studio', 'streamsera' ), 'description' => __( 'Warm amber on charcoal.', 'streamsera' ), 'tokens' => array( 'primary' => '#f59e0b', 'accent' => '#fbbf24', 'radius' => '12', 'background' => '#18181b', 'text' => '#fafaf9', ), 'vars' => array( 'surface' => 'rgba(255,255,255,0.05)', 'shadow' => '0 12px 40px rgba(0,0,0,0.5)', ), ), ); return $skins; } /** * Recursively sanitize a player config array against the defaults. * * @param array $config Raw config. * @return array */ function streamsera_sanitize_player_config( $config ) { $defaults = streamsera_default_player_config(); $config = is_array( $config ) ? $config : array(); $out = array( 'version' => 1 ); $out['station'] = isset( $config['station'] ) ? absint( $config['station'] ) : 0; // Extra stations the listener can switch to (no limit). $stations = isset( $config['stations'] ) && is_array( $config['stations'] ) ? array_values( array_unique( array_filter( array_map( 'absint', $config['stations'] ) ) ) ) : array(); $out['stations'] = array_values( array_diff( $stations, array( $out['station'] ) ) ); // A layout that isn't registered (e.g. one an add-on provided, now inactive) falls back to the default. $layouts = streamsera_layouts(); $out['layout'] = isset( $config['layout'] ) && is_string( $config['layout'] ) && array_key_exists( $config['layout'], $layouts ) ? $config['layout'] : $defaults['layout']; $out['skin'] = isset( $config['skin'] ) ? sanitize_key( $config['skin'] ) : $defaults['skin']; $out['theme'] = isset( $config['theme'] ) && in_array( $config['theme'], array( 'light', 'dark', 'auto' ), true ) ? $config['theme'] : 'auto'; $out['tokens'] = array(); foreach ( $defaults['tokens'] as $key => $default ) { $value = isset( $config['tokens'][ $key ] ) ? $config['tokens'][ $key ] : $default; $out['tokens'][ $key ] = is_string( $value ) ? sanitize_text_field( $value ) : $default; } // skin `vars` (extra CSS custom properties) travel with the tokens. if ( isset( $config['tokens']['vars'] ) && is_array( $config['tokens']['vars'] ) ) { $out['tokens']['vars'] = array(); foreach ( array_slice( $config['tokens']['vars'], 0, 40, true ) as $vk => $vv ) { $vk = sanitize_key( $vk ); if ( $vk && is_scalar( $vv ) ) { $out['tokens']['vars'][ $vk ] = streamsera_sanitize_css_value( (string) $vv ); } } } // Style tab: selected custom skin (preset id), the "Customize colors" switch and the // remembered color overrides (kept while the switch is off so turning it on restores them). $out['customSkin'] = isset( $config['customSkin'] ) && is_scalar( $config['customSkin'] ) ? preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $config['customSkin'] ) : ''; $out['customColors'] = isset( $config['customColors'] ) ? (bool) $config['customColors'] : null; $out['colorOverrides'] = array(); if ( isset( $config['colorOverrides'] ) && is_array( $config['colorOverrides'] ) ) { foreach ( array( 'primary', 'accent', 'background', 'text' ) as $ck ) { if ( isset( $config['colorOverrides'][ $ck ] ) && is_string( $config['colorOverrides'][ $ck ] ) ) { $out['colorOverrides'][ $ck ] = sanitize_text_field( $config['colorOverrides'][ $ck ] ); } } } // per-device component visibility overrides. $out['visibility'] = array(); foreach ( array( 'desktop', 'tablet', 'mobile' ) as $device ) { if ( empty( $config['visibility'][ $device ] ) || ! is_array( $config['visibility'][ $device ] ) ) { continue; } $out['visibility'][ $device ] = array(); foreach ( $config['visibility'][ $device ] as $ck => $cv ) { if ( array_key_exists( $ck, $defaults['components'] ) ) { $out['visibility'][ $device ][ $ck ] = (bool) $cv; } } } $out['components'] = array(); foreach ( $defaults['components'] as $key => $default ) { $out['components'][ $key ] = isset( $config['components'][ $key ] ) ? (bool) $config['components'][ $key ] : $default; } $out['behavior'] = array( 'autoplay' => ! empty( $config['behavior']['autoplay'] ), 'autoplayMuted' => isset( $config['behavior']['autoplayMuted'] ) ? (bool) $config['behavior']['autoplayMuted'] : true, 'metadataInterval' => isset( $config['behavior']['metadataInterval'] ) ? max( 5, absint( $config['behavior']['metadataInterval'] ) ) : 15, 'liveText' => isset( $config['behavior']['liveText'] ) ? sanitize_text_field( $config['behavior']['liveText'] ) : 'LIVE', 'buttonText' => isset( $config['behavior']['buttonText'] ) ? sanitize_text_field( $config['behavior']['buttonText'] ) : '', // "Listen live" layout label. 'volume' => isset( $config['behavior']['volume'] ) && is_numeric( $config['behavior']['volume'] ) ? max( -1, min( 100, (int) $config['behavior']['volume'] ) ) : -1, 'switcherStyle' => isset( $config['behavior']['switcherStyle'] ) && in_array( $config['behavior']['switcherStyle'], array( 'dropdown', 'tabs', 'list' ), true ) ? $config['behavior']['switcherStyle'] : 'dropdown', ); $fallback = isset( $config['fallback'] ) && is_array( $config['fallback'] ) ? $config['fallback'] : array(); $out['fallback'] = array( 'enabled' => ! empty( $fallback['enabled'] ), 'url' => isset( $fallback['url'] ) && is_string( $fallback['url'] ) ? esc_url_raw( $fallback['url'] ) : '', 'id' => isset( $fallback['id'] ) ? absint( $fallback['id'] ) : 0, 'message' => isset( $fallback['message'] ) && is_scalar( $fallback['message'] ) ? sanitize_text_field( (string) $fallback['message'] ) : '', 'retryAfter' => isset( $fallback['retryAfter'] ) ? max( 10, min( 600, absint( $fallback['retryAfter'] ) ) ) : 30, ); /** * Filter the sanitized player config (lets add-ons add keys). * * @param array $out Sanitized config. * @param array $config Raw config. */ return apply_filters( 'streamsera_sanitize_player_config', $out, $config ); } /** * Sanitize a single CSS value (no braces, no url(javascript:), no expression()). * * @since 0.1.0 * @param string $value Raw value. * @return string */ function streamsera_sanitize_css_value( $value ) { $value = wp_strip_all_tags( (string) $value ); $value = preg_replace( '/[{};]/', '', $value ); $value = preg_replace( '/expression\s*\(/i', '', $value ); $value = preg_replace( '/url\s*\(\s*[\'"]?\s*(javascript|data):/i', 'url(', $value ); return trim( substr( $value, 0, 300 ) ); } /** * Refuse to switch on a virtual page (listen page, station pages) whose address is already used. * * The slug is checked when it changes; this also checks it when the feature is turned on with an * unchanged (default) slug, e.g. a site that already has a published "Listen" page at /listen/. * Only with pretty permalinks: plain permalinks use a query string and can't collide. * * @since 0.1.0 * * @param array $next Settings about to be saved (changed in place). * @param array $current Saved settings. * @param string $enabled_key Setting that turns the page on. * @param string $slug_key Setting with its slug. * @param string $default Default slug. * @param string $message Error shown when it can't be turned on (%1$s slug, %2$s what uses it). */ function streamsera_guard_page_enable( array &$next, array $current, $enabled_key, $slug_key, $default, $message ) { if ( empty( $next[ $enabled_key ] ) || ! empty( $current[ $enabled_key ] ) || ! get_option( 'permalink_structure' ) ) { return; } $slug = sanitize_title( (string) ( $next[ $slug_key ] ?? $default ) ); $slug = $slug ? $slug : $default; $conflict = streamsera_slug_conflict( $slug, $slug_key ); if ( $conflict ) { $next[ $enabled_key ] = false; streamsera_add_settings_error( $enabled_key, sprintf( $message, $slug, $conflict ) ); } } /** * A parameter from the REST route path (e.g. the `id` in `/presets/(?P…)`). * * `$request['id']` prefers a value from the JSON body or query string over the one in the URL, so a * body containing `id` could point the request at a different item than its URL. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @param string $key Path parameter. * @return string Empty string when missing. */ function streamsera_url_param( $request, $key = 'id' ) { $params = $request->get_url_params(); return isset( $params[ $key ] ) && is_scalar( $params[ $key ] ) ? (string) $params[ $key ] : ''; } /** * Plain-text title of a post for JSON payloads, feeds and other non-HTML contexts. * * `get_the_title()` returns entity-encoded, texturized HTML (`&`, `“`), which the React * player and plain-text feeds would show literally. This strips tags and decodes entities once; * callers escape again at output. * * @param int|\WP_Post $post Post or ID. * @return string */ function streamsera_plain_title( $post ) { $post = get_post( $post ); if ( ! $post ) { return ''; } $title = wp_strip_all_tags( (string) $post->post_title ); $title = html_entity_decode( $title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); return trim( preg_replace( '/\s+/u', ' ', $title ) ); } /** * Decode an already entity-encoded plain string (titles stored by WordPress hold `&`). * * @param string $text Text. * @return string */ function streamsera_plain_text( $text ) { return html_entity_decode( wp_strip_all_tags( (string) $text ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } /** * Whether the site runs white-label: the admin hides the credit-link option. Off unless an add-on * (or site code) turns it on. * * @return bool */ function streamsera_white_label() { /** * Filter whether StreamSera runs white-label (no credit-link option). * * @param bool $white_label Default false. */ return (bool) apply_filters( 'streamsera_white_label', false ); } /** * Tags and attributes StreamSera front-end markup may contain: the post allow-list plus inline SVG icons * and the form controls used by players and add-on widgets. `data-*`, `aria-*` and `style` (with CSS * custom properties) are allowed globally by core. * * @return array */ function streamsera_allowed_html() { static $allowed = null; if ( null !== $allowed ) { return $allowed; } $allowed = wp_kses_allowed_html( 'post' ); $svg_attr = array( 'class' => true, 'xmlns' => true, 'viewbox' => true, 'width' => true, 'height' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'fill-rule' => true, 'clip-rule' => true, 'opacity' => true, 'transform' => true, 'aria-hidden' => true, 'focusable' => true, 'role' => true, 'd' => true, 'cx' => true, 'cy' => true, 'r' => true, 'rx' => true, 'ry' => true, 'x' => true, 'y' => true, 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'points' => true, 'preserveaspectratio' => true, ); foreach ( array( 'svg', 'g', 'path', 'circle', 'ellipse', 'rect', 'line', 'polyline', 'polygon', 'title', 'defs', 'lineargradient', 'stop' ) as $tag ) { $allowed[ $tag ] = array_merge( isset( $allowed[ $tag ] ) ? $allowed[ $tag ] : array(), $svg_attr, array( 'offset' => true, 'stop-color' => true, 'id' => true ) ); } $control = array( 'class' => true, 'id' => true, 'name' => true, 'type' => true, 'value' => true, 'placeholder' => true, 'checked' => true, 'selected' => true, 'disabled' => true, 'required' => true, 'readonly' => true, 'min' => true, 'max' => true, 'step' => true, 'maxlength' => true, 'autocomplete' => true, 'for' => true, 'label' => true, 'multiple' => true, 'aria-label' => true, 'title' => true, ); // Only the controls the plugin's own markup uses (search box, buttons). Add-ons that render other // form controls add them through the `streamsera_allowed_html` filter. foreach ( array( 'input', 'label', 'button' ) as $tag ) { $allowed[ $tag ] = array_merge( isset( $allowed[ $tag ] ) ? $allowed[ $tag ] : array(), $control ); } unset( $allowed['form'], $allowed['select'], $allowed['option'], $allowed['optgroup'], $allowed['textarea'] ); $allowed['time'] = array( 'class' => true, 'datetime' => true ); $allowed['source'] = array( 'src' => true, 'type' => true ); // Accessibility / state attributes core does not allow globally. $global = array( 'aria-busy' => true, 'aria-pressed' => true, 'aria-selected' => true, 'aria-atomic' => true, 'aria-modal' => true, 'aria-haspopup' => true, 'aria-valuenow' => true, 'aria-valuemin' => true, 'aria-valuemax' => true, 'aria-valuetext' => true, 'aria-orientation' => true, 'tabindex' => true, 'hidden' => true, 'data-*' => true, 'class' => true, 'id' => true, 'style' => true, 'title' => true, 'role' => true, 'aria-hidden' => true, 'aria-label' => true, 'aria-live' => true, 'aria-expanded' => true, 'aria-controls' => true, 'aria-current' => true, 'aria-describedby' => true, 'aria-labelledby' => true, ); foreach ( $allowed as $tag => $attrs ) { if ( is_array( $attrs ) ) { $allowed[ $tag ] = array_merge( $attrs, $global ); } } /** * Filter the tags and attributes allowed in StreamSera front-end markup. * * @param array $allowed wp_kses() allow-list. */ $allowed = (array) apply_filters( 'streamsera_allowed_html', $allowed ); return $allowed; } /** * Render a player anywhere in PHP (theme templates, page builders). * * @param int $player_id Player ID. * @param array $overrides Optional { layout, skin, theme, station }. * @return string Markup ('' when the player cannot render). */ function streamsera_render_player( $player_id, $overrides = array() ) { return \StreamSera\Frontend\Assets::render_player( absint( $player_id ), (array) $overrides ); } /** * Echo a player. * * @param int $player_id Player ID. * @param array $overrides Optional overrides. */ function streamsera_player( $player_id, $overrides = array() ) { echo wp_kses( streamsera_render_player( $player_id, $overrides ), streamsera_allowed_html() ); } /* ------------------------------------------------------------------ settings validation */ /** * Record a settings validation error for the current request. * * The settings REST endpoint returns the stored settings; `StreamSera\Modules\Settings_Guard` appends * the errors recorded here under `_errors` ({ key: message }) so the admin app can show them. * * @since 0.1.0 * @param string $key Setting key. * @param string $message Human-readable message. */ function streamsera_add_settings_error( $key, $message ) { if ( ! isset( $GLOBALS['streamsera_settings_errors'] ) || ! is_array( $GLOBALS['streamsera_settings_errors'] ) ) { $GLOBALS['streamsera_settings_errors'] = array(); } $GLOBALS['streamsera_settings_errors'][ (string) $key ] = (string) $message; } /** * Settings validation errors recorded during this request. * * @since 0.1.0 * @return array key => message */ function streamsera_get_settings_errors() { return isset( $GLOBALS['streamsera_settings_errors'] ) && is_array( $GLOBALS['streamsera_settings_errors'] ) ? $GLOBALS['streamsera_settings_errors'] : array(); } /** * Whether a root-level slug (/{slug}/) is already answered by WordPress content, so a plugin * rewrite on it would hijack that page. * * Checks published posts/pages (and public post types that live at the root), the rewrite bases of * public post types and taxonomies, and the plugin's own virtual pages. * * @since 0.1.0 * @param string $slug Slug to test. * @param string $ignore Setting key whose own slug should not count as a conflict * ('listen_slug' | 'station_pages_slug'). * @return string '' when free, otherwise a short description of what owns the slug. */ function streamsera_slug_conflict( $slug, $ignore = '' ) { $slug = sanitize_title( (string) $slug ); if ( '' === $slug ) { return ''; } // Content living at the site root: pages, posts and public types without a rewrite base. $types = array( 'page', 'post' ); foreach ( get_post_types( array( 'public' => true ), 'objects' ) as $type ) { if ( in_array( $type->name, $types, true ) || 'attachment' === $type->name ) { continue; } $base = is_array( $type->rewrite ) && ! empty( $type->rewrite['slug'] ) ? trim( (string) $type->rewrite['slug'], '/' ) : ''; if ( ! $base ) { $types[] = $type->name; } elseif ( $base === $slug ) { return $type->labels->name; } if ( ! empty( $type->has_archive ) && is_string( $type->has_archive ) && trim( $type->has_archive, '/' ) === $slug ) { return $type->labels->name; } } $found = get_page_by_path( $slug, OBJECT, $types ); if ( $found && 'publish' === $found->post_status ) { return get_post_type_object( $found->post_type )->labels->singular_name . ': ' . streamsera_plain_title( $found ); } foreach ( get_taxonomies( array( 'public' => true ), 'objects' ) as $tax ) { $base = is_array( $tax->rewrite ) && ! empty( $tax->rewrite['slug'] ) ? trim( (string) $tax->rewrite['slug'], '/' ) : $tax->name; if ( $base === $slug ) { return $tax->labels->name; } } // The plugin's other virtual page. $own = array( 'listen_slug' => __( 'the listen page', 'streamsera' ), 'station_pages_slug' => __( 'the station pages', 'streamsera' ), ); foreach ( $own as $key => $label ) { if ( $key !== $ignore && sanitize_title( (string) streamsera_get_setting( $key, 'listen_slug' === $key ? 'listen' : 'stations' ) ) === $slug ) { return $label; } } /** * Filter the conflict found for a plugin page slug ('' = none). * * @param string $conflict Description of the owner, or ''. * @param string $slug Slug. * @param string $ignore Setting key being saved. */ return (string) apply_filters( 'streamsera_slug_conflict', '', $slug, $ignore ); } /** * Find a post of a type by its title, comparing plain (entity-decoded, tag-free) text on both sides. * * Titles stored by WordPress hold `&` / `&`, exports hold the decoded form; a byte * comparison would miss the match and importing would duplicate the post. * * @since 0.1.0 * @param string $post_type Post type. * @param string $title Title (encoded or decoded). * @param array $statuses Statuses to consider. * @return \WP_Post|null */ function streamsera_find_post_by_title( $post_type, $title, $statuses = array( 'publish', 'draft', 'pending', 'private', 'future' ) ) { $plain = trim( preg_replace( '/\s+/u', ' ', streamsera_plain_text( $title ) ) ); if ( '' === $plain ) { return null; } // A LIKE on the stored title cannot see through `&` vs `&`: compare decoded titles in PHP. $candidates = get_posts( array( 'post_type' => $post_type, 'post_status' => $statuses, 'posts_per_page' => 1000, 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); foreach ( $candidates as $candidate ) { if ( strcasecmp( streamsera_plain_title( $candidate ), $plain ) === 0 ) { return $candidate; } } return null; } /** * Print a JSON-LD block through the core script-tag API. * * @since 0.1.0 * @param array $data Structured data. */ function streamsera_print_json_ld( array $data ) { wp_print_inline_script_tag( (string) wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ), array( 'type' => 'application/ld+json' ) ); } /* ------------------------------------------------------------------ token hardening */ /** * Run the free-text player tokens through the CSS value sanitizer: `fontFamily` (and the other * tokens) end up as CSS custom properties, so braces, `expression()` and `url(javascript:)` must * never survive. Runs first so add-on filters see clean values. * * @since 0.1.0 * @param array $out Sanitized config. * @return array */ function streamsera_sanitize_config_css_tokens( $out ) { if ( ! is_array( $out ) || empty( $out['tokens'] ) || ! is_array( $out['tokens'] ) ) { return $out; } foreach ( $out['tokens'] as $key => $value ) { if ( 'vars' !== $key && is_string( $value ) ) { $out['tokens'][ $key ] = streamsera_sanitize_css_value( $value ); } } if ( isset( $out['colorOverrides'] ) && is_array( $out['colorOverrides'] ) ) { foreach ( $out['colorOverrides'] as $key => $value ) { if ( is_string( $value ) ) { $out['colorOverrides'][ $key ] = streamsera_sanitize_css_value( $value ); } } } return $out; } add_filter( 'streamsera_sanitize_player_config', 'streamsera_sanitize_config_css_tokens', 1 ); /** * Validate a template path returned by a template filter before it is included: an existing, * readable .php file inside the active theme, the parent theme or this plugin. Anything else * falls back to the bundled template. * * @param mixed $file Filtered path. * @param string $fallback Bundled template. * @return string */ function streamsera_safe_template( $file, $fallback ) { if ( ! is_string( $file ) || '' === $file || '.php' !== strtolower( substr( $file, -4 ) ) ) { return $fallback; } $real = realpath( $file ); if ( ! $real || ! is_file( $real ) || ! is_readable( $real ) ) { return $fallback; } $real = wp_normalize_path( $real ); $roots = array_filter( array_map( 'realpath', array( get_stylesheet_directory(), get_template_directory(), STREAMSERA_PATH ) ) ); foreach ( $roots as $root ) { if ( 0 === strpos( $real, trailingslashit( wp_normalize_path( $root ) ) ) ) { return $real; } } return $fallback; } /** * Lower-case a string (multibyte-aware when the mbstring extension is available; WordPress only * polyfills mb_substr() and mb_strlen()). * * @param string $text Text. * @return string */ function streamsera_strtolower( $text ) { return function_exists( 'mb_strtolower' ) ? mb_strtolower( (string) $text, 'UTF-8' ) : strtolower( (string) $text ); } /** * Upper-case a string (multibyte-aware when mbstring is available). * * @param string $text Text. * @return string */ function streamsera_strtoupper( $text ) { return function_exists( 'mb_strtoupper' ) ? mb_strtoupper( (string) $text, 'UTF-8' ) : strtoupper( (string) $text ); }