velora-player/includes/class-renderer.php
2026-03-23 16:55:03 +01:00

349 lines
12 KiB
PHP

<?php
/**
* Frontend renderer.
*
* @package ModernAudioPlayer
*/
namespace ModernAudioPlayer;
defined( 'ABSPATH' ) || exit;
class Renderer {
/**
* Render a player instance.
*
* @param array<string, mixed> $attributes Player attributes.
* @param string $context Rendering context.
* @return string
*/
public static function render_player( $attributes, $context = 'frontend' ) {
self::enqueue_block_assets();
$settings = Settings::get();
$args = self::normalize_attributes( $attributes, $settings );
$theme_data = self::get_theme_presets();
$theme_name = isset( $theme_data[ $args['theme'] ] ) ? $theme_data[ $args['theme'] ]['label'] : $theme_data[ Settings::get_default_preset_slug() ]['label'];
$design_settings = Settings::get_resolved_design_settings( $args['theme'], $settings );
$style_attr = Settings::build_design_css_variables( $design_settings );
$is_editor_context = self::is_editor_context( $context );
$playlist_payload = self::prepare_playlist_payload( $args['playlist'] );
$current_track = $playlist_payload[ $args['currentIndex'] ];
$show_cover_image = ! empty( $current_track['image'] ) && ! empty( $design_settings['player_show_cover_image'] );
$instance_id = 'map-player-' . wp_unique_id();
if ( empty( $current_track['src'] ) ) {
if ( $is_editor_context ) {
return self::render_placeholder( $args['theme'], $theme_name, $style_attr, $design_settings );
}
return '';
}
ob_start();
?>
<div
id="<?php echo esc_attr( $instance_id ); ?>"
class="map-player map-theme-<?php echo esc_attr( $args['theme'] ); ?><?php echo $show_cover_image ? '' : ' map-player--no-cover'; ?>"
data-map-player
data-map-editor-preview="<?php echo $is_editor_context ? 'true' : 'false'; ?>"
data-map-show-cover-setting="<?php echo ! empty( $design_settings['player_show_cover_image'] ) ? 'true' : 'false'; ?>"
data-map-playlist="<?php echo esc_attr( wp_json_encode( $playlist_payload ) ); ?>"
data-map-current-index="<?php echo esc_attr( (string) $args['currentIndex'] ); ?>"
data-map-repeat-enabled="false"
data-map-has-cover="<?php echo $show_cover_image ? 'true' : 'false'; ?>"
data-track-src="<?php echo esc_url( $current_track['src'] ); ?>"
data-track-title="<?php echo esc_attr( $current_track['title'] ); ?>"
data-track-hash="<?php echo esc_attr( $current_track['hash'] ); ?>"
data-track-nonce="<?php echo esc_attr( $current_track['nonce'] ); ?>"
data-rest-endpoint="<?php echo esc_url( rest_url( 'map/v1/track-play' ) ); ?>"
style="<?php echo esc_attr( $style_attr ); ?>"
>
<div class="map-player__cover-wrap"<?php echo $show_cover_image ? '' : ' style="display:none;"'; ?>>
<img class="map-player__cover" src="<?php echo esc_url( $current_track['image'] ); ?>" alt="<?php echo esc_attr( $current_track['title'] ); ?>" loading="lazy" data-map-cover />
</div>
<div class="map-player__content">
<div class="map-player__header">
<?php if ( ! empty( $design_settings['player_show_theme_label'] ) ) : ?>
<div class="map-player__eyebrow"><?php echo esc_html( $theme_name ); ?></div>
<?php endif; ?>
<div class="map-player__title" data-map-title><?php echo esc_html( $current_track['title'] ); ?></div>
<?php if ( $is_editor_context ) : ?>
<p class="map-player__editor-note" data-map-editor-notice><?php esc_html_e( 'Playback preview is limited in the editor. Test full playback on the frontend.', 'modern-audio-player' ); ?></p>
<?php endif; ?>
</div>
<div class="map-player__controls">
<div class="map-player__actions">
<button type="button" class="map-player__toggle" data-map-toggle aria-label="<?php esc_attr_e( 'Play audio', 'modern-audio-player' ); ?>" aria-pressed="false">
<span class="map-player__toggle-icon map-player__toggle-icon--play" aria-hidden="true"></span>
<span class="map-player__toggle-text"><?php esc_html_e( 'Play', 'modern-audio-player' ); ?></span>
</button>
<div class="map-player__transport">
<button type="button" class="map-player__icon-button" data-map-previous aria-label="<?php esc_attr_e( 'Previous track', 'modern-audio-player' ); ?>" disabled>
<span class="map-player__icon map-player__icon--previous" aria-hidden="true"></span>
</button>
<button type="button" class="map-player__icon-button" data-map-next aria-label="<?php esc_attr_e( 'Next track', 'modern-audio-player' ); ?>">
<span class="map-player__icon map-player__icon--next" aria-hidden="true"></span>
</button>
<button type="button" class="map-player__icon-button" data-map-repeat aria-label="<?php esc_attr_e( 'Repeat track', 'modern-audio-player' ); ?>" aria-pressed="false">
<span class="map-player__icon map-player__icon--repeat" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="map-player__timeline">
<input
type="range"
class="map-player__progress"
data-map-progress
min="0"
max="100"
value="0"
step="0.1"
aria-label="<?php esc_attr_e( 'Track progress', 'modern-audio-player' ); ?>"
/>
<div class="map-player__time">
<span data-map-current><?php esc_html_e( '0:00', 'modern-audio-player' ); ?></span>
<span data-map-duration><?php esc_html_e( '0:00', 'modern-audio-player' ); ?></span>
</div>
</div>
</div>
<audio class="map-player__audio" preload="metadata" src="<?php echo esc_url( $current_track['src'] ); ?>" data-map-audio></audio>
</div>
</div>
<?php
return (string) ob_get_clean();
}
/**
* Get available theme presets.
*
* @return array<string, array<string, mixed>>
*/
public static function get_theme_presets() {
return Settings::get_design_presets();
}
/**
* Normalize and sanitize player attributes.
*
* @param array<string, mixed> $attributes Raw attributes.
* @param array<string, mixed> $settings Global settings.
* @return array<string, mixed>
*/
public static function normalize_attributes( $attributes, $settings = array() ) {
if ( empty( $settings ) ) {
$settings = Settings::get();
}
$defaults = array(
'src' => '',
'title' => __( 'Untitled Track', 'modern-audio-player' ),
'image' => '',
'playlist' => array(),
'theme' => $settings['default_theme'],
'useGlobalTheme' => true,
);
$args = wp_parse_args( is_array( $attributes ) ? $attributes : array(), $defaults );
$args['src'] = esc_url_raw( $args['src'] );
$args['title'] = sanitize_text_field( $args['title'] );
$args['image'] = esc_url_raw( $args['image'] );
$use_global_theme = rest_sanitize_boolean( $args['useGlobalTheme'] );
$enabled_themes = isset( $settings['enabled_themes'] ) && is_array( $settings['enabled_themes'] ) ? $settings['enabled_themes'] : array_keys( self::get_theme_presets() );
$requested_theme = sanitize_key( $args['theme'] );
if ( $use_global_theme || ! in_array( $requested_theme, $enabled_themes, true ) ) {
$args['theme'] = $settings['default_theme'];
} else {
$args['theme'] = $requested_theme;
}
$args['playlist'] = self::normalize_playlist_items( $args['playlist'], $args );
if ( empty( $args['playlist'] ) ) {
$args['playlist'] = array(
self::prepare_track(
array(
'src' => $args['src'],
'title' => $args['title'],
'image' => $args['image'],
)
),
);
}
$args['currentIndex'] = 0;
$args['src'] = $args['playlist'][0]['src'];
$args['title'] = $args['playlist'][0]['title'];
$args['image'] = $args['playlist'][0]['image'];
$args['useGlobalTheme'] = $use_global_theme;
return $args;
}
/**
* Detect whether the current render is happening inside the block editor.
*
* @param string $context Rendering context.
* @return bool
*/
private static function is_editor_context( $context ) {
if ( 'editor' === $context || is_admin() ) {
return true;
}
if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) {
return false;
}
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
$rest_context = isset( $_REQUEST['context'] ) ? sanitize_key( wp_unslash( $_REQUEST['context'] ) ) : '';
$referer = wp_get_referer();
if ( 'edit' === $rest_context || false !== strpos( $request_uri, '/wp/v2/block-renderer/' ) ) {
return true;
}
return is_string( $referer ) && false !== strpos( $referer, 'post.php' );
}
/**
* Render a visible placeholder for editor previews without an audio source.
*
* @param string $theme_slug Theme class suffix.
* @param string $theme_name Human-readable theme name.
* @param string $style_attr Inline CSS variable string.
* @param array<string, mixed> $design_settings Resolved design settings.
* @return string
*/
private static function render_placeholder( $theme_slug, $theme_name, $style_attr, $design_settings ) {
ob_start();
?>
<div
class="map-player map-player--placeholder map-theme-<?php echo esc_attr( $theme_slug ); ?>"
data-map-player
data-map-editor-preview="true"
style="<?php echo esc_attr( $style_attr ); ?>"
>
<div class="map-player__content">
<div class="map-player__header">
<?php if ( ! empty( $design_settings['player_show_theme_label'] ) ) : ?>
<div class="map-player__eyebrow"><?php echo esc_html( $theme_name ); ?></div>
<?php endif; ?>
<div class="map-player__title"><?php esc_html_e( 'Select an audio file to preview', 'modern-audio-player' ); ?></div>
<p class="map-player__editor-note" data-map-editor-notice><?php esc_html_e( 'Playback preview is limited in the editor. Test full playback on the frontend.', 'modern-audio-player' ); ?></p>
</div>
</div>
</div>
<?php
return (string) ob_get_clean();
}
/**
* Normalize playlist items and preserve backward compatibility.
*
* @param mixed $playlist Raw playlist attribute.
* @param array<string, mixed> $args Normalized player arguments.
* @return array<int, array<string, string>>
*/
private static function normalize_playlist_items( $playlist, $args ) {
$normalized = array();
if ( is_array( $playlist ) ) {
foreach ( $playlist as $track ) {
$prepared = self::prepare_track( is_array( $track ) ? $track : array() );
if ( '' !== $prepared['src'] ) {
$normalized[] = $prepared;
}
}
}
if ( empty( $normalized ) && ! empty( $args['src'] ) ) {
$normalized[] = self::prepare_track(
array(
'src' => $args['src'],
'title' => $args['title'],
'image' => $args['image'],
)
);
}
return $normalized;
}
/**
* Sanitize a track item.
*
* @param array<string, mixed> $track Track data.
* @return array<string, string>
*/
private static function prepare_track( $track ) {
return array(
'src' => esc_url_raw( $track['src'] ?? '' ),
'title' => sanitize_text_field( $track['title'] ?? __( 'Untitled Track', 'modern-audio-player' ) ),
'image' => esc_url_raw( $track['image'] ?? '' ),
);
}
/**
* Prepare playlist payload for the frontend runtime.
*
* @param array<int, array<string, string>> $playlist Playlist items.
* @return array<int, array<string, string>>
*/
private static function prepare_playlist_payload( $playlist ) {
$payload = array();
foreach ( $playlist as $track ) {
$hash = Analytics::build_source_hash( $track['src'] );
$payload[] = array(
'src' => $track['src'],
'title' => $track['title'],
'image' => $track['image'],
'hash' => $hash,
'nonce' => wp_create_nonce( 'map_track_play_' . $hash ),
);
}
return $payload;
}
/**
* Enqueue frontend block assets for shortcode and dynamic rendering.
*
* @return void
*/
private static function enqueue_block_assets() {
if ( is_admin() ) {
return;
}
$registry = \WP_Block_Type_Registry::get_instance();
$block = $registry->get_registered( 'velora/audio-player' );
if ( ! $block ) {
$block = $registry->get_registered( 'map/audio-player' );
}
if ( ! $block ) {
return;
}
foreach ( $block->style_handles as $handle ) {
wp_enqueue_style( $handle );
}
foreach ( $block->script_handles as $handle ) {
wp_enqueue_script( $handle );
}
}
}