` block (Modules\Blocks). * - Page-builder add-ons: one widget / module per element, grouped under "StreamSera". * * Each element: { * label, description, keywords[], icon { dashicon, eicon }, block (block name), * fields: key → { type, label, default, section (content|display), options, min, max, help, empty }, * render: callable( array $args ): string * } * Field types: player | station | show | host (post pickers), select, toggle, number, text. * Field keys are the renderer's argument names, so builders hand values straight to `render`. * * Add-ons extend the list with the `streamsera_elements` filter. * * @package StreamSera */ namespace StreamSera\Frontend; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Elements. */ class Elements { /** * Cache. * * @var array|null */ private static $all = null; /** * Post-picker choices cache. * * @var array */ private static $choices = array(); /** * Every element, in display order. * * @return array id → definition. */ public static function all() { if ( null !== self::$all ) { return self::$all; } /** * Filter the page elements StreamSera exposes to the block editor and page builders. * * @param array $elements id → definition (see the class docblock). */ $elements = (array) apply_filters( 'streamsera_elements', self::core() ); foreach ( $elements as $id => $el ) { if ( empty( $el['render'] ) || ! is_callable( $el['render'] ) ) { unset( $elements[ $id ] ); continue; } $elements[ $id ] = wp_parse_args( $el, array( 'label' => $id, 'description' => '', 'keywords' => array(), 'icon' => array(), 'block' => 'streamsera/' . $id, 'fields' => array(), ) ); $elements[ $id ]['icon'] = wp_parse_args( (array) $elements[ $id ]['icon'], array( 'dashicon' => 'format-audio', 'eicon' => 'eicon-play', ) ); foreach ( $elements[ $id ]['fields'] as $key => $field ) { $elements[ $id ]['fields'][ $key ] = wp_parse_args( $field, array( 'type' => 'text', 'label' => $key, 'default' => '', 'section' => 'content', 'options' => array(), 'help' => '', ) ); } } self::$all = $elements; return self::$all; } /** * One element. * * @param string $id Element id. * @return array|null */ public static function get( $id ) { $all = self::all(); return isset( $all[ $id ] ) ? $all[ $id ] : null; } /** * Render an element from builder values (missing keys take the field defaults; values are coerced to field types). * * @param string $id Element id. * @param array $args Field values. * @return string */ public static function render( $id, $args = array() ) { $el = self::get( $id ); if ( ! $el ) { return ''; } return (string) call_user_func( $el['render'], self::normalize( $el, (array) $args ) ); } /** * Coerce raw values (strings from shortcodes / builders, 'yes'/'on' switches) to field types. * * @param array $el Element. * @param array $args Raw values. * @return array */ public static function normalize( $el, $args ) { $out = array(); foreach ( $el['fields'] as $key => $field ) { $value = array_key_exists( $key, $args ) && null !== $args[ $key ] && '' !== $args[ $key ] ? $args[ $key ] : $field['default']; switch ( self::is_numeric_type( $field['type'] ) ? 'number' : $field['type'] ) { case 'toggle': $out[ $key ] = is_bool( $value ) ? $value : in_array( strtolower( (string) $value ), array( '1', 'true', 'yes', 'on' ), true ); break; case 'number': $n = (int) $value; if ( isset( $field['min'] ) ) { $n = max( (int) $field['min'], $n ); } if ( isset( $field['max'] ) ) { $n = min( (int) $field['max'], $n ); } $out[ $key ] = $n; break; case 'select': $value = (string) $value; $out[ $key ] = array_key_exists( $value, (array) $field['options'] ) ? $value : (string) $field['default']; break; default: $out[ $key ] = sanitize_text_field( (string) $value ); } } // Pass wrapper hints through untouched. foreach ( array( 'className', 'class', 'align' ) as $key ) { if ( ! empty( $args[ $key ] ) ) { $out[ $key ] = sanitize_text_field( (string) $args[ $key ] ); } } return $out; } /** * Post picker field types (field type → post type). Add-ons register their own. * * @return array */ public static function picker_types() { /** * Post picker field types for elements. * * @param array $types field type → post type. */ return (array) apply_filters( 'streamsera_element_picker_types', array( 'player' => \StreamSera\PostTypes\Player::POST_TYPE, 'station' => \StreamSera\PostTypes\Station::POST_TYPE, ) ); } /** * Whether an element brings its own design system, so page builders skip StreamSera's card colour * controls for it: the player (skins) and add-on elements that set `own_styles`. * * @param string $id Element id. * @return bool */ public static function has_own_styles( $id ) { $el = self::get( $id ); return 'player' === $id || ( is_array( $el ) && ! empty( $el['own_styles'] ) ); } /** * Whether a field type stores an integer (number or a post picker). * * @param string $type Field type. * @return bool */ public static function is_numeric_type( $type ) { return 'number' === $type || isset( self::picker_types()[ $type ] ); } /** * Options for a field: select options, or the published posts for a picker (first entry = the "empty" choice). * * @param array $field Field. * @return array value → label (string keys). */ public static function options( $field ) { $types = self::picker_types(); if ( isset( $types[ $field['type'] ] ) ) { $out = array( '0' => ! empty( $field['empty'] ) ? $field['empty'] : __( '— Default —', 'streamsera' ) ); foreach ( self::choices( $types[ $field['type'] ] ) as $id => $title ) { $out[ (string) $id ] = $title; } return $out; } $out = array(); foreach ( (array) $field['options'] as $value => $label ) { $out[ (string) $value ] = (string) $label; } return $out; } /** * Published posts of a type, id → title. * * @param string $type Post type. * @return array */ private static function choices( $type ) { if ( ! isset( self::$choices[ $type ] ) ) { self::$choices[ $type ] = array(); foreach ( get_posts( array( 'post_type' => $type, 'post_status' => 'publish', 'posts_per_page' => 300, 'orderby' => 'title', 'order' => 'ASC', ) ) as $p ) { self::$choices[ $type ][ $p->ID ] = get_the_title( $p ) ? get_the_title( $p ) : '#' . $p->ID; } } return self::$choices[ $type ]; } /** * Block attribute name for a field key (snake_case → camelCase; the player keeps its legacy `playerId`). * * @param string $key Field key. * @return string */ public static function attr( $key ) { return lcfirst( str_replace( ' ', '', ucwords( str_replace( '_', ' ', $key ) ) ) ); } /* --------------------------------------------------------------- core list */ /** * Built-in elements. * * @return array */ private static function core() { $yes = static function ( $label, $default = true, $section = 'display' ) { return array( 'type' => 'toggle', 'label' => $label, 'default' => $default, 'section' => $section, ); }; $layouts = array( '' => __( 'Player default', 'streamsera' ) ); foreach ( streamsera_layouts() as $id => $layout ) { $layouts[ $id ] = is_array( $layout ) ? ( $layout['label'] ?? $id ) : (string) $layout; } $skins = array( '' => __( 'Player default', 'streamsera' ) ); foreach ( streamsera_skins() as $id => $skin ) { $skins[ $id ] = is_array( $skin ) ? ( $skin['label'] ?? $id ) : (string) $skin; } $station_style = array( 'card' => __( 'Card', 'streamsera' ), 'horizontal' => __( 'Horizontal', 'streamsera' ), ); $columns = array( 'type' => 'number', 'label' => __( 'Columns', 'streamsera' ), 'default' => 3, 'min' => 1, 'max' => 4, 'section' => 'display', ); $count = array( 'type' => 'number', 'label' => __( 'Limit (0 = all)', 'streamsera' ), 'default' => 0, 'min' => 0, 'max' => 48, ); $station_first = array( 'type' => 'station', 'label' => __( 'Station', 'streamsera' ), 'empty' => __( 'First station', 'streamsera' ), ); return array( 'player' => array( 'label' => __( 'Player', 'streamsera' ), 'description' => __( 'A player you built, in any layout and skin.', 'streamsera' ), 'keywords' => array( 'radio', 'audio', 'stream', 'player' ), 'icon' => array( 'dashicon' => 'controls-play', 'eicon' => 'eicon-play' ), 'fields' => array( 'player' => array( 'type' => 'player', 'label' => __( 'Player', 'streamsera' ), 'empty' => __( '— Choose a player —', 'streamsera' ) ), 'layout' => array( 'type' => 'select', 'label' => __( 'Layout', 'streamsera' ), 'options' => $layouts ), 'skin' => array( 'type' => 'select', 'label' => __( 'Skin', 'streamsera' ), 'options' => $skins, 'section' => 'display' ), 'theme' => array( 'type' => 'select', 'label' => __( 'Theme', 'streamsera' ), 'section' => 'display', 'options' => array( '' => __( 'Player default', 'streamsera' ), 'light' => __( 'Light', 'streamsera' ), 'dark' => __( 'Dark', 'streamsera' ), 'auto' => __( 'Follow visitor', 'streamsera' ) ) ), 'station' => array( 'type' => 'station', 'label' => __( 'Station', 'streamsera' ), 'empty' => __( 'Player’s own station', 'streamsera' ), 'help' => __( 'Play a different station with the same design.', 'streamsera' ) ), ), 'render' => static function ( $a ) { if ( ! $a['player'] ) { return current_user_can( 'edit_posts' ) ? '
' . esc_html__( 'StreamSera: choose a player.', 'streamsera' ) . '
' : ''; } return Assets::render_player( $a['player'], array_filter( array( 'layout' => $a['layout'], 'skin' => $a['skin'], 'theme' => $a['theme'], 'station' => $a['station'] ) ) ); }, ), 'listen-button' => array( 'label' => __( 'Listen Button', 'streamsera' ), 'description' => __( 'A “Listen live” button that opens the listen page or plays the player on the page.', 'streamsera' ), 'keywords' => array( 'listen', 'button', 'live', 'popout' ), 'icon' => array( 'dashicon' => 'megaphone', 'eicon' => 'eicon-button' ), 'fields' => array( 'label' => array( 'type' => 'text', 'label' => __( 'Label', 'streamsera' ), 'default' => __( 'Listen live', 'streamsera' ) ), 'player' => array( 'type' => 'player', 'label' => __( 'Player', 'streamsera' ), 'empty' => __( 'Listen page player', 'streamsera' ) ), 'target' => array( 'type' => 'select', 'label' => __( 'Opens', 'streamsera' ), 'default' => 'listen', 'options' => Widgets::listen_targets(), 'help' => __( 'Without a listen page the button plays the player on the same page.', 'streamsera' ) ), 'style' => array( 'type' => 'select', 'label' => __( 'Style', 'streamsera' ), 'default' => 'fill', 'section' => 'display', 'options' => array( 'fill' => __( 'Filled', 'streamsera' ), 'outline' => __( 'Outline', 'streamsera' ), 'pill' => __( 'Pill', 'streamsera' ) ) ), 'size' => array( 'type' => 'select', 'label' => __( 'Size', 'streamsera' ), 'default' => 'md', 'section' => 'display', 'options' => array( 'sm' => __( 'Small', 'streamsera' ), 'md' => __( 'Medium', 'streamsera' ), 'lg' => __( 'Large', 'streamsera' ) ) ), ), 'render' => array( Widgets::class, 'listen_button' ), ), 'now-playing' => array( 'label' => __( 'Now Playing', 'streamsera' ), 'description' => __( 'The current track, updating live.', 'streamsera' ), 'keywords' => array( 'now playing', 'track', 'song', 'live' ), 'icon' => array( 'dashicon' => 'format-audio', 'eicon' => 'eicon-headphones' ), 'fields' => array( 'station' => $station_first, 'show_artwork' => $yes( __( 'Artwork', 'streamsera' ) ), 'show_station' => $yes( __( 'Station name', 'streamsera' ) ), 'show_listeners' => $yes( __( 'Listener count', 'streamsera' ), false ), 'compact' => $yes( __( 'Compact', 'streamsera' ), false ), ), 'render' => array( Widgets::class, 'now_playing' ), ), 'station' => array( 'label' => __( 'Station', 'streamsera' ), 'description' => __( 'A station card: logo, name, details, what’s playing and a link.', 'streamsera' ), 'keywords' => array( 'station', 'channel', 'card' ), 'icon' => array( 'dashicon' => 'admin-site-alt3', 'eicon' => 'eicon-site-identity' ), 'fields' => array( 'station' => $station_first, 'cta' => array( 'type' => 'select', 'label' => __( 'Button', 'streamsera' ), 'default' => 'page', 'options' => array( 'page' => __( 'Visit station page', 'streamsera' ), 'listen' => __( 'Listen live', 'streamsera' ), 'none' => __( 'None', 'streamsera' ) ), 'help' => __( '“Visit station page” needs station pages turned on in Settings.', 'streamsera' ) ), 'style' => array( 'type' => 'select', 'label' => __( 'Style', 'streamsera' ), 'default' => 'card', 'options' => $station_style, 'section' => 'display' ), 'show_logo' => $yes( __( 'Logo', 'streamsera' ) ), 'show_details' => $yes( __( 'Genre & frequency', 'streamsera' ) ), 'show_slogan' => $yes( __( 'Slogan', 'streamsera' ) ), 'show_description' => $yes( __( 'Description', 'streamsera' ), false ), 'show_now_playing' => $yes( __( 'Now playing', 'streamsera' ) ), 'show_social' => $yes( __( 'Social links', 'streamsera' ), false ), ), 'render' => array( Widgets::class, 'station' ), ), 'station-list' => array( 'label' => __( 'Station List', 'streamsera' ), 'description' => __( 'A directory of your stations with search and genre filters.', 'streamsera' ), 'keywords' => array( 'stations', 'directory', 'network', 'grid' ), 'icon' => array( 'dashicon' => 'grid-view', 'eicon' => 'eicon-gallery-grid' ), 'fields' => array( 'count' => $count, 'orderby' => array( 'type' => 'select', 'label' => __( 'Order', 'streamsera' ), 'default' => 'menu_order', 'options' => array( 'menu_order' => __( 'Custom order', 'streamsera' ), 'title' => __( 'Name', 'streamsera' ), 'date' => __( 'Newest first', 'streamsera' ) ) ), 'cta' => array( 'type' => 'select', 'label' => __( 'Button', 'streamsera' ), 'default' => 'page', 'options' => array( 'page' => __( 'Visit station page', 'streamsera' ), 'none' => __( 'None', 'streamsera' ) ) ), 'columns' => $columns, 'show_search' => $yes( __( 'Search & genre filter', 'streamsera' ) ), 'show_logo' => $yes( __( 'Logo', 'streamsera' ) ), 'show_details' => $yes( __( 'Genre & frequency', 'streamsera' ) ), 'show_slogan' => $yes( __( 'Slogan', 'streamsera' ) ), 'show_now_playing' => $yes( __( 'Now playing', 'streamsera' ), false ), ), 'render' => array( Widgets::class, 'stations' ), ), ); } }