(core cannot resolve the virtual URL). add_filter( 'rest_pre_dispatch', array( $this, 'oembed_response' ), 10, 3 ); add_action( 'streamsera_station_head', array( $this, 'oembed_discovery' ) ); } /** * Whether the pages are on. * * @return bool */ public static function enabled() { return (bool) streamsera_get_setting( 'station_pages_enabled', false ); } /** * Base slug. * * @return string */ public static function slug() { $slug = sanitize_title( (string) streamsera_get_setting( 'station_pages_slug', 'stations' ) ); return $slug ? $slug : 'stations'; } /** * Enabled sections. * * @return string[] */ public static function sections() { $all = array_keys( self::available_sections() ); $saved = streamsera_get_setting( 'station_pages_sections', null ); if ( ! is_array( $saved ) ) { return $all; } // Saved order wins (the Settings page lets people drag sections around). return array_values( array_intersect( array_map( 'sanitize_key', $saved ), $all ) ); } /** * Sections a station page can show (key → label), in page order. * * @return array */ public static function available_sections() { /** * Filter the station page sections. Add-ons add their own keys and render them on the * `streamsera_station_page_section` action. * * @param array $sections Key → label, in page order. */ return (array) apply_filters( 'streamsera_station_page_sections', array( 'about' => __( 'About & details', 'streamsera' ), 'share' => __( 'Share buttons', 'streamsera' ), ) ); } /** * Player used on station pages. * * @return int */ public static function player_id() { $id = (int) streamsera_get_setting( 'station_pages_player', 0 ); return $id ? $id : Listen_Page::player_id(); } /** * Archive URL. * * @return string */ public static function archive_url() { if ( get_option( 'permalink_structure' ) ) { return home_url( user_trailingslashit( self::slug() ) ); } return add_query_arg( self::QUERY_ARCHIVE, '1', home_url( '/' ) ); } /** * URL of one station's page. * * @param int $station_id Station. * @return string */ public static function url( $station_id ) { $post = get_post( $station_id ); if ( ! $post || Station::POST_TYPE !== $post->post_type ) { return ''; } $slug = $post->post_name ? $post->post_name : (string) $post->ID; if ( get_option( 'permalink_structure' ) ) { return home_url( user_trailingslashit( self::slug() . '/' . $slug ) ); } return add_query_arg( self::QUERY_SINGLE, $slug, home_url( '/' ) ); } /** * Rewrite rules (only while enabled, so a real page at /stations/ keeps working otherwise). */ public function rewrite() { $base = preg_quote( self::slug(), '/' ); if ( ! self::enabled() ) { $rules = get_option( 'rewrite_rules' ); if ( is_array( $rules ) && isset( $rules[ '^' . $base . '/?$' ] ) ) { add_action( 'wp_loaded', 'flush_rewrite_rules' ); } return; } add_rewrite_rule( '^' . $base . '/?$', 'index.php?' . self::QUERY_ARCHIVE . '=1', 'top' ); add_rewrite_rule( '^' . $base . '/([^/]+)/?$', 'index.php?' . self::QUERY_SINGLE . '=$matches[1]', 'top' ); } /** * Query vars. * * @param array $vars Vars. * @return array */ public function query_vars( $vars ) { $vars[] = self::QUERY_ARCHIVE; $vars[] = self::QUERY_SINGLE; return $vars; } /** * Persist the keys; flush rewrites when the slug or the switch changes. * * @param array $next Settings about to be saved. * @param array $input Raw input. * @return array */ public function save_settings( $next, $input ) { $current = (array) get_option( 'streamsera_settings', array() ); if ( isset( $input['station_pages_enabled'] ) && is_scalar( $input['station_pages_enabled'] ) ) { $next['station_pages_enabled'] = rest_sanitize_boolean( $input['station_pages_enabled'] ); } if ( isset( $input['station_pages_slug'] ) && is_scalar( $input['station_pages_slug'] ) ) { $slug = sanitize_title( (string) $input['station_pages_slug'] ); $slug = $slug ? $slug : 'stations'; $previous = sanitize_title( (string) ( $current['station_pages_slug'] ?? 'stations' ) ); $previous = $previous ? $previous : 'stations'; $conflict = $slug !== $previous ? streamsera_slug_conflict( $slug, 'station_pages_slug' ) : ''; if ( $conflict ) { // A rewrite on this slug would hijack existing content: keep the old value and say why. streamsera_add_settings_error( 'station_pages_slug', sprintf( /* translators: 1: slug, 2: what already uses it */ __( '"/%1$s/" is already used by %2$s. The station pages slug was not changed.', 'streamsera' ), $slug, $conflict ) ); $slug = $previous; } $next['station_pages_slug'] = $slug; } if ( isset( $input['station_pages_player'] ) && is_scalar( $input['station_pages_player'] ) ) { $next['station_pages_player'] = absint( $input['station_pages_player'] ); } if ( isset( $input['station_pages_sections'] ) && is_array( $input['station_pages_sections'] ) ) { $next['station_pages_sections'] = array_values( array_unique( array_intersect( array_map( 'sanitize_key', $input['station_pages_sections'] ), array_keys( self::available_sections() ) ) ) ); } streamsera_guard_page_enable( $next, $current, 'station_pages_enabled', 'station_pages_slug', 'stations', /* translators: 1: slug, 2: what already uses it */ __( '"/%1$s/" is already used by %2$s, so the station pages were not turned on. Change the station pages slug first.', 'streamsera' ) ); if ( ( $current['station_pages_slug'] ?? 'stations' ) !== ( $next['station_pages_slug'] ?? 'stations' ) || (bool) ( $next['station_pages_enabled'] ?? false ) !== (bool) ( $current['station_pages_enabled'] ?? false ) ) { update_option( 'streamsera_flush_rewrite', 1, false ); } return $next; } /** * Expose URLs to the admin app. * * @param array $data Admin data. * @return array */ public function admin_data( $data ) { $data['stationPagesUrl'] = self::archive_url(); $data['stationPagesEnabled'] = self::enabled(); // Editor preview: replace 0 with the station id. $data['stationPreviewUrl'] = add_query_arg( self::QUERY_PREVIEW, '0', home_url( '/' ) ); $data['stationPageSections'] = array(); foreach ( self::available_sections() as $key => $label ) { $data['stationPageSections'][] = array( (string) $key, (string) $label ); } return $data; } /** * Add the page URL to the public station payload (players link the station name to it). * * @param array $public Public station. * @param int $id Station id. * @return array */ public function public_station( $public, $id ) { if ( self::enabled() ) { $public['url'] = self::url( (int) $id ); } return $public; } /** * Advertise the archive. */ public function head_link() { if ( self::enabled() ) { echo '' . "\n"; } } /** * Published stations as public arrays (+ page url). * * @return array[] */ public static function all_stations() { $ids = get_posts( array( 'post_type' => Station::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => -1, // The directory lists every published station. 'orderby' => 'title', 'order' => 'ASC', 'fields' => 'ids', ) ); $out = array(); foreach ( $ids as $id ) { $s = Station::to_public( (int) $id ); if ( $s ) { $s['url'] = self::url( (int) $id ); $out[] = $s; } } return $out; } /** * Serve the archive or a single page. */ public function render() { $slug = get_query_var( self::QUERY_SINGLE ); $slug = is_string( $slug ) ? sanitize_title( $slug ) : ''; $archive = (bool) get_query_var( self::QUERY_ARCHIVE ); // Editor preview (station editor → "Preview page"): works while station pages are off, never indexed. $preview_id = isset( $_GET[ self::QUERY_PREVIEW ] ) ? absint( wp_unslash( $_GET[ self::QUERY_PREVIEW ] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, capability-checked. $preview = $preview_id && current_user_can( 'edit_post', $preview_id ); if ( ! $slug && ! $archive && ! $preview ) { return; } if ( ! $preview && ! self::enabled() ) { return; // Theme 404. } if ( $preview ) { $archive = false; nocache_headers(); header( 'X-Robots-Tag: noindex, nofollow' ); add_filter( 'wp_robots', 'wp_robots_no_robots' ); } // template_redirect runs before wp_enqueue_scripts: register exactly the handles these pages // print — the player bundle (core + add-ons). Assets::register_standalone(); $theme = (string) streamsera_get_setting( 'station_pages_theme', '' ); $base = array( 'site' => get_bloginfo( 'name' ), 'back' => home_url( '/' ), 'archiveUrl' => self::archive_url(), 'theme' => in_array( $theme, array( 'light', 'dark' ), true ) ? $theme : 'dark', 'accent' => '', ); if ( $archive ) { $vars = $base + array( 'title' => __( 'Stations', 'streamsera' ), 'stations' => self::all_stations(), 'url' => self::archive_url(), ); $vars = apply_filters( 'streamsera_stations_vars', $vars ); $this->serve( 'stations', $vars ); } $post = $preview ? get_post( $preview_id ) : get_page_by_path( $slug, OBJECT, Station::POST_TYPE ); if ( ! $post && ! $preview && ctype_digit( $slug ) ) { $post = get_post( (int) $slug ); } if ( ! $post || Station::POST_TYPE !== $post->post_type || 'publish' !== $post->post_status ) { $this->not_found(); return; } $station = Station::to_public( (int) $post->ID ); if ( ! $station ) { $this->not_found(); return; } $player_id = self::player_id(); $markup = $player_id ? Assets::render_player( $player_id, array( 'layout' => 'hero', 'station' => (int) $post->ID, ), array( 'listen' => true ) ) : ''; $config = $player_id ? Player::get_config( $player_id ) : array(); $url = self::url( (int) $post->ID ); $vars = $base + array( 'player_id' => $player_id, 'config' => $config, 'station' => $station, 'description' => apply_filters( 'the_content', $post->post_content ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core filter. 'markup' => $markup, 'sections' => self::sections(), 'url' => $url, 'title' => $station['name'], 'others' => array_values( array_filter( self::all_stations(), static function ( $s ) use ( $post ) { return (int) $s['id'] !== (int) $post->ID; } ) ), 'share' => array( 'x' => 'https://twitter.com/intent/tweet?' . http_build_query( array( 'text' => sprintf( /* translators: %s station */ __( 'Listening to %s', 'streamsera' ), $station['name'] ), 'url' => $url, ) ), 'facebook' => 'https://www.facebook.com/sharer/sharer.php?u=' . rawurlencode( $url ), 'whatsapp' => 'https://wa.me/?text=' . rawurlencode( $station['name'] . ' ' . $url ), ), 'schema' => self::schema( $station, $url ), ); /** * Filter the variables handed to the station page template. * * @param array $vars Vars. */ $vars = apply_filters( 'streamsera_station_vars', $vars ); $this->serve( 'station', $vars ); } /** * Turn the request into a real 404 (the rewrite matched, but no station answers to that slug). * * Without this WordPress runs its default query and serves the blog index with HTTP 200. */ private function not_found() { global $wp_query; if ( $wp_query instanceof \WP_Query ) { $wp_query->set_404(); } status_header( 404 ); nocache_headers(); } /** * Resolve a station page URL (pretty or ?streamsera_station=) to a published station id. * * @param string $url URL. * @return int 0 when the URL is not a station page. */ public static function station_id_from_url( $url ) { if ( ! self::enabled() ) { return 0; } $url = (string) $url; $parts = wp_parse_url( $url ); if ( ! $parts || empty( $parts['host'] ) ) { return 0; } $home = wp_parse_url( home_url( '/' ) ); if ( strtolower( preg_replace( '/^www\./', '', $parts['host'] ) ) !== strtolower( preg_replace( '/^www\./', '', (string) $home['host'] ) ) ) { return 0; } $slug = ''; if ( ! empty( $parts['query'] ) ) { parse_str( $parts['query'], $query ); if ( ! empty( $query[ self::QUERY_SINGLE ] ) && is_string( $query[ self::QUERY_SINGLE ] ) ) { $slug = $query[ self::QUERY_SINGLE ]; } } if ( ! $slug ) { $path = isset( $parts['path'] ) ? $parts['path'] : '/'; $root = isset( $home['path'] ) ? rtrim( $home['path'], '/' ) : ''; if ( $root && 0 === strpos( $path, $root ) ) { $path = substr( $path, strlen( $root ) ); } if ( ! preg_match( '#^/' . preg_quote( self::slug(), '#' ) . '/([^/]+)/?$#', $path, $m ) ) { return 0; } $slug = rawurldecode( $m[1] ); } $post = get_page_by_path( sanitize_title( $slug ), OBJECT, Station::POST_TYPE ); if ( ! $post && ctype_digit( $slug ) ) { $post = get_post( (int) $slug ); } if ( ! $post || Station::POST_TYPE !== $post->post_type || 'publish' !== $post->post_status ) { return 0; } return (int) $post->ID; } /** * oEmbed payload for a station page (type "rich": the page itself in an iframe). * * @param int $station_id Station. * @param int $maxwidth Requested width. * @return array|null */ public static function oembed_data( $station_id, $maxwidth = 600 ) { $station = Station::to_public( (int) $station_id ); if ( ! $station ) { return null; } $url = self::url( (int) $station_id ); $maxwidth = max( 200, min( 1200, (int) $maxwidth ) ); $height = 600; $data = array( 'version' => '1.0', 'provider_name' => get_bloginfo( 'name' ), 'provider_url' => get_home_url(), 'author_name' => get_bloginfo( 'name' ), 'author_url' => get_home_url(), 'title' => $station['name'], 'type' => 'rich', 'width' => $maxwidth, 'height' => $height, 'html' => sprintf( '', esc_url( $url ), $maxwidth, $height, esc_attr( sprintf( /* translators: %s station name */ __( '%s — listen live', 'streamsera' ), $station['name'] ) ) ), ); if ( ! empty( $station['logo'] ) ) { $data['thumbnail_url'] = $station['logo']; $data['thumbnail_width'] = 512; $data['thumbnail_height'] = 512; } /** * Filter the oEmbed response for a station page. * * @param array $data oEmbed data. * @param int $station_id Station. */ return apply_filters( 'streamsera_station_oembed', $data, (int) $station_id ); } /** * Answer the core oEmbed endpoint for station page URLs. * * Core maps the URL with url_to_postid(), which cannot see our rewrite, and refuses non-public * post types, so the request 404s. We intercept before dispatch; core's XML formatter still * runs on the returned response (format=xml works). * * @param mixed $result Pre-dispatch result. * @param \WP_REST_Server $server Server. * @param \WP_REST_Request $request Request. * @return mixed */ public function oembed_response( $result, $server, $request ) { unset( $server ); if ( null !== $result || '/oembed/1.0/embed' !== $request->get_route() ) { return $result; } $url = $request->get_param( 'url' ); if ( ! is_string( $url ) || '' === $url ) { return $result; } $station_id = self::station_id_from_url( $url ); if ( ! $station_id ) { return $result; } $data = self::oembed_data( $station_id, (int) $request->get_param( 'maxwidth' ) ?: 600 ); if ( ! $data ) { return $result; } return new \WP_REST_Response( $data ); } /** * oEmbed discovery links in the
of a station page. * * @param array $streamsera Template vars. */ public function oembed_discovery( $streamsera ) { if ( empty( $streamsera['url'] ) || empty( $streamsera['station'] ) ) { return; } $endpoint = rest_url( 'oembed/1.0/embed' ); echo '' . "\n"; echo '' . "\n"; } /** * Schema.org RadioStation for a station page. * * @param array $station Public station. * @param string $url Page URL. * @return array */ public static function schema( $station, $url ) { $same_as = array_values( array_filter( array_merge( array( $station['website'] ?? '' ), array_values( (array) ( $station['social'] ?? array() ) ) ) ) ); $schema = array( '@context' => 'https://schema.org', '@type' => 'RadioStation', 'name' => $station['name'], 'url' => $url, 'description' => wp_strip_all_tags( (string) ( $station['description'] ?? '' ) ), ); if ( ! empty( $station['logo'] ) ) { $schema['logo'] = $station['logo']; $schema['image'] = $station['logo']; } if ( $same_as ) { $schema['sameAs'] = $same_as; } if ( ! empty( $station['streams'][0]['url'] ) ) { $schema['potentialAction'] = array( '@type' => 'ListenAction', 'target' => $url, ); } return $schema; } /** * Include a template and exit. * * @param string $name Template base name. * @param array $vars Vars. */ private function serve( $name, $vars ) { $file = locate_template( array( 'streamsera/' . $name . '.php' ) ); if ( ! $file ) { $file = STREAMSERA_PATH . 'templates/' . $name . '.php'; } /** * Filter a station page template path. * * @param string $file Template. * @param string $name 'station' | 'stations'. */ $file = streamsera_safe_template( apply_filters( 'streamsera_station_template', $file, $name ), STREAMSERA_PATH . 'templates/' . $name . '.php' ); nocache_headers(); status_header( 200 ); header( 'Content-Type: text/html; charset=utf-8' ); $streamsera = $vars; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable include $file; exit; } }