velora version 1.0.0

This commit is contained in:
diyaa 2026-03-23 16:55:03 +01:00
commit b7c0d49d63
21 changed files with 4008 additions and 0 deletions

37
.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
# Logs
*.log
*.tmp
*.swp
*.swo
# Cache
.cache/
tmp/
# Node (لو تستخدم build tools)
node_modules/
dist/
build/
# Env
.env
.env.local
# Composer (إذا لاحقًا أضفت PHP deps)
vendor/
# WordPress specific
*.zip
*.tar.gz
# Mac
.AppleDouble
.LSOverride

33
IMPLEMENTATION-CHANGES.md Normal file
View File

@ -0,0 +1,33 @@
# Velora Player: Fixes Applied
## What I changed
1. Updated block asset references in `blocks/block.json`
- Replaced manual registered handles with file-based metadata references:
- `file:../assets/js/block-editor.js`
- `file:../assets/js/player.js`
- `file:../assets/css/player.css`
- `file:../assets/css/editor.css`
2. Switched block registration to metadata-based loading
- Updated `includes/class-plugin.php` to call `register_block_type_from_metadata()`.
- Removed the old manual asset registration method so block loading no longer depends on custom handle registration order.
3. Added WordPress asset metadata files
- Added `assets/js/block-editor.asset.php` so WordPress knows the Gutenberg editor script dependencies.
- Added `assets/js/player.asset.php` for the frontend player script metadata.
4. Added explicit `modern-dark` theme class
- Added `.map-theme-modern-dark` and `.map-theme-modern-dark::before` rules in `assets/css/player.css`.
- The theme is no longer only implied by the base `.map-player` styles.
5. Ensured shortcode/dynamic rendering still loads frontend assets
- Added block asset enqueue logic in `includes/class-renderer.php`.
- When the player is rendered on the frontend, it now pulls the registered style/script handles from the registered block type and enqueues them.
## Result
- Gutenberg block loading is now metadata-driven and more reliable.
- The block no longer relies on manual script/style handle registration order.
- The `modern-dark` theme now has a real theme class.
- Shortcode rendering still gets the same frontend assets as the block.

362
README.md Normal file
View File

@ -0,0 +1,362 @@
# Velora Player
إضافة ووردبريس توفّر مشغّل صوت حديث مع دعم:
- بلوك
- Gutenberg
- shortcode
- ثيمات جاهزة
- لوحة إعدادات
- تتبّع بسيط لعدد مرات التشغيل
## نظرة سريعة
هذه الإضافة تبني مشغّل صوت مخصص يمكن استخدامه داخل محرر ووردبريس أو عبر
`shortcode`
في الصفحات والمقالات.
المشغّل يدعم صورة غلاف، عنوان للمسار الصوتي، شريط تقدّم، زر تشغيل وإيقاف، وثيمات عرض مختلفة.
كما تحتوي الإضافة على نظام تحليلات بسيط يحسب عدد مرات التشغيل لكل ملف صوتي.
## الميزات الرئيسية
- بلوك مخصص داخل محرر ووردبريس باسم
`Velora Player`
- دعم العرض الديناميكي من السيرفر
- دعم الإدراج باستخدام
`[audio_player]`
- ثلاث ثيمات جاهزة:
`modern-dark`
،
`glassmorphism`
،
`podcast-style`
- إعدادات عامة للتحكم في الألوان، الحواف، والثيم الافتراضي
- صفحة تحليلات داخل لوحة التحكم
- واجهة
`REST API`
لتسجيل التشغيلات
## كيف تعمل الإضافة
### 1. ملف التشغيل الرئيسي
الملف:
`modern-audio-player.php`
وظيفته:
- تعريف معلومات الإضافة
- تعريف الثوابت الأساسية
- تحميل الكلاسات
- تنفيذ
`activation hook`
لإنشاء الإعدادات الافتراضية وجدول التحليلات
- تشغيل الكلاس الرئيسي
### 2. الكلاس الرئيسي
الملف:
`includes/class-plugin.php`
وظيفته:
- تهيئة الخدمات الأساسية:
- الإعدادات
- لوحة التحكم
- الـ shortcode
- REST API
- تحميل الترجمة
- تسجيل البلوك من خلال
`block.json`
- استخدام
`render_callback`
لعرض المشغّل من السيرفر
### 3. الإعدادات
الملف:
`includes/class-settings.php`
يحتوي على:
- القيم الافتراضية للإضافة
- قراءة الإعدادات من قاعدة البيانات
- تنظيف القيم قبل الحفظ
الإعدادات المتوفرة:
- الثيم الافتراضي
- الثيمات المفعّلة
- لون التمييز
- لون الخلفية
- لون النص
- درجة استدارة الحواف
- إظهار صورة الغلاف أو إخفاؤها
### 4. لوحة التحكم
الملف:
`admin/class-admin.php`
يوفّر صفحتين داخل لوحة تحكم ووردبريس:
- صفحة إعدادات
- صفحة تحليلات
ومن خلال صفحة الإعدادات يمكن التحكم في المظهر العام لكل المشغلات، بينما تعرض صفحة التحليلات عدد المسارات المسجّلة وإجمالي التشغيلات وأشهر الملفات الصوتية.
### 5. العرض
الملف:
`includes/class-renderer.php`
هذا الملف هو المسؤول عن بناء
HTML
الخاص بالمشغّل.
أهم ما يفعله:
- يجهّز الخصائص القادمة من البلوك أو الـ shortcode
- يطبّق الثيم الافتراضي إذا لزم الأمر
- يبني متغيرات
CSS
اعتمادًا على إعدادات الإضافة
- يضيف بيانات التحليلات داخل
`data-* attributes`
- يعرض نسخة معاينة داخل المحرر إذا لم يتم اختيار ملف صوتي بعد
- يحمّل ملفات الواجهة الأمامية عند العرض الديناميكي أو عند استخدام الـ shortcode
### 6. الـ shortcode
الملف:
`includes/class-shortcode.php`
يسجّل
`[audio_player]`
الخصائص المدعومة:
- `src`
- `title`
- `image`
- `theme`
مثال:
```text
[audio_player src="https://example.com/audio.mp3" title="Episode 1" image="https://example.com/cover.jpg" theme="glassmorphism"]
```
إذا لم يتم تمرير
`theme`
فسيتم استخدام الثيم الافتراضي من إعدادات الإضافة.
### 7. التحليلات
الملف:
`includes/class-analytics.php`
وظيفته:
- إنشاء جدول خاص في قاعدة البيانات عند تفعيل الإضافة
- إنشاء
`hash`
ثابت لكل رابط صوتي
- تسجيل كل تشغيل في قاعدة البيانات
- إرجاع ملخص عام
- إرجاع أكثر الملفات تشغيلًا
اسم الجدول يكون بصيغة:
```text
{wp_prefix}map_analytics
```
كل سجل يحتوي على معلومات مثل:
- رابط الملف الصوتي
- عنوانه
- عدد مرات التشغيل
- آخر وقت تشغيل
### 8. واجهة REST API
الملف:
`includes/class-rest-api.php`
تسجّل المسار التالي:
```text
/wp-json/map/v1/track-play
```
الطلب من نوع:
`POST`
ويستقبل:
- `src`
- `title`
- `hash`
- `nonce`
قبل تسجيل التشغيل يتم التحقق من:
- وجود القيم المطلوبة
- صحة
`hash`
- صحة
`nonce`
### 9. جافاسكربت الواجهة
الملف:
`assets/js/player.js`
هذا الملف يدير سلوك المشغّل على الواجهة، مثل:
- تشغيل وإيقاف الصوت
- تحديث الوقت الحالي والمدة
- تحديث شريط التقدم
- الانتقال داخل الملف الصوتي
- إرسال أول تشغيل فقط إلى
`REST API`
لكل نسخة من المشغّل داخل الصفحة
### 10. بلوك المحرر
الملف:
`assets/js/block-editor.js`
هذا الملف يسجل البلوك داخل المحرر ويضيف:
- اختيار ملف صوتي من المكتبة
- إدخال عنوان المسار
- اختيار صورة غلاف
- تفعيل أو تعطيل الثيم العام
- اختيار ثيم مخصص
- معاينة مباشرة باستخدام
`ServerSideRender`
### 11. التنسيق
الملف:
`assets/css/player.css`
يحتوي على تنسيقات المشغّل، والثيمات المختلفة، والاستجابة للشاشات الصغيرة.
## طريقة الاستخدام
### استخدام البلوك
من داخل محرر ووردبريس:
1. أضف بلوك
`Velora Player`
2. اختر الملف الصوتي
3. أضف العنوان وصورة الغلاف
4. حدّد هل تريد استخدام الثيم العام أو ثيم مخصص
### استخدام shortcode
مثال بسيط:
```text
[audio_player src="https://example.com/audio.mp3" title="My Track"]
```
مثال كامل:
```text
[audio_player src="https://example.com/audio.mp3" title="Podcast Episode" image="https://example.com/image.jpg" theme="podcast-style"]
```
## الثيمات المتوفرة
### modern-dark
ثيم داكن عالي التباين مناسب للتصميمات الحديثة.
### glassmorphism
ثيم شفاف بتأثير زجاجي وخلفية ضبابية.
### podcast-style
ثيم أقرب لعرض حلقات البودكاست والمحتوى التحريري.
## تدفق العمل داخل الإضافة
بشكل مبسط، التسلسل كالتالي:
1. يتم تحميل الإضافة من الملف الرئيسي.
2. يتم تسجيل البلوك والـ shortcode وصفحات الإدارة.
3. عند إدراج المشغّل، يتم تمرير الخصائص إلى
`Renderer`
4. يتم إنشاء
HTML
وبيانات التتبع.
5. في الواجهة، يتولى
`player.js`
تشغيل الصوت وتحديث الواجهة.
6. عند أول تشغيل، يتم إرسال طلب إلى
`REST API`
7. تقوم طبقة التحليلات بتحديث قاعدة البيانات.
## بنية الملفات
```text
modern-audio-player/
├── modern-audio-player.php
├── uninstall.php
├── README.md
├── admin/
│ └── class-admin.php
├── includes/
│ ├── class-plugin.php
├── class-settings.php
│ ├── class-renderer.php
│ ├── class-shortcode.php
│ ├── class-rest-api.php
│ └── class-analytics.php
├── blocks/
│ └── block.json
└── assets/
├── css/
│ ├── player.css
│ └── editor.css
└── js/
├── player.js
├── block-editor.js
├── player.asset.php
└── block-editor.asset.php
```
## ملاحظات مهمة
- الإضافة تعتمد على العرض الديناميكي، لذلك دالة
`save`
داخل البلوك ترجع
`null`
- ملفات الواجهة يتم تحميلها عبر
`block.json`
وميتاداتا الأصول
- عند استخدام الـ shortcode يتم تحميل ملفات الواجهة أيضًا من خلال
`Renderer::enqueue_block_assets()`
## إزالة الإضافة
يوجد ملف:
`uninstall.php`
ويمكن استخدامه لاحقًا لتنظيف بيانات الإضافة عند الحذف النهائي إذا أردت توسيع سلوك الإزالة.
## ملخص
هذه الإضافة مناسبة عندما تحتاج إلى:
- مشغّل صوت مخصص بدل المشغّل الافتراضي
- دعم للمحرر الحديث
- تخصيص مظهر عام على مستوى الموقع
- تتبّع بسيط لمرات التشغيل بدون نظام تحليلات معقّد

488
admin/class-admin.php Normal file
View File

@ -0,0 +1,488 @@
<?php
/**
* Admin settings and analytics UI.
*
* @package ModernAudioPlayer
*/
namespace ModernAudioPlayer;
defined( 'ABSPATH' ) || exit;
class Admin {
/**
* Register admin hooks.
*
* @return void
*/
public function register() {
add_action( 'admin_menu', array( $this, 'register_menus' ) );
add_action( 'admin_init', array( $this, 'register_settings' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
add_action( 'admin_post_map_reset_design_settings', array( $this, 'handle_reset_design_settings' ) );
}
/**
* Register admin menu pages.
*
* @return void
*/
public function register_menus() {
add_menu_page(
__( 'Velora Player', 'modern-audio-player' ),
__( 'Velora Player', 'modern-audio-player' ),
'manage_options',
'map-settings',
array( $this, 'render_settings_page' ),
'dashicons-format-audio',
56
);
add_submenu_page(
'map-settings',
__( 'Design Settings', 'modern-audio-player' ),
__( 'Design Settings', 'modern-audio-player' ),
'manage_options',
'map-settings',
array( $this, 'render_settings_page' )
);
add_submenu_page(
'map-settings',
__( 'Analytics', 'modern-audio-player' ),
__( 'Analytics', 'modern-audio-player' ),
'manage_options',
'map-analytics',
array( $this, 'render_analytics_page' )
);
}
/**
* Register settings.
*
* @return void
*/
public function register_settings() {
register_setting(
'map_settings_group',
MAP_OPTION_KEY,
array(
'type' => 'array',
'sanitize_callback' => array( '\ModernAudioPlayer\Settings', 'sanitize' ),
'default' => Settings::defaults(),
)
);
}
/**
* Enqueue admin design assets.
*
* @param string $hook_suffix Current screen hook.
* @return void
*/
public function enqueue_assets( $hook_suffix ) {
if ( 'toplevel_page_map-settings' !== $hook_suffix ) {
return;
}
wp_enqueue_style( 'wp-color-picker' );
wp_enqueue_style(
'map-admin-settings',
MAP_PLUGIN_URL . 'assets/css/admin-settings.css',
array( 'wp-color-picker' ),
MAP_VERSION
);
wp_enqueue_style(
'map-player-preview',
MAP_PLUGIN_URL . 'assets/css/player.css',
array(),
MAP_VERSION
);
wp_enqueue_script( 'wp-color-picker' );
wp_enqueue_script(
'map-admin-settings',
MAP_PLUGIN_URL . 'assets/js/admin-settings.js',
array( 'jquery', 'wp-color-picker' ),
MAP_VERSION,
true
);
$settings = Settings::get();
$resolved_design = Settings::get_resolved_design_settings( $settings['default_theme'], $settings );
$presets = Settings::get_design_presets();
$preset_tokens = array();
foreach ( $presets as $slug => $preset ) {
$preset_tokens[ $slug ] = $preset['tokens'];
}
wp_add_inline_script(
'map-admin-settings',
'window.mapAdminSettings = ' . wp_json_encode(
array(
'optionKey' => MAP_OPTION_KEY,
'presets' => $preset_tokens,
'resolvedDesign' => $resolved_design,
'i18n' => array(
'rangeValueLabel' => __( '%1$s %2$s', 'modern-audio-player' ),
'resetConfirm' => __( 'Reset all design settings back to the default preset values?', 'modern-audio-player' ),
),
)
) . ';',
'before'
);
}
/**
* Handle reset action.
*
* @return void
*/
public function handle_reset_design_settings() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to perform this action.', 'modern-audio-player' ) );
}
check_admin_referer( 'map_reset_design_settings' );
update_option( MAP_OPTION_KEY, Settings::defaults() );
$redirect = add_query_arg(
array(
'page' => 'map-settings',
'map-reset' => 1,
),
admin_url( 'admin.php' )
);
wp_safe_redirect( $redirect );
exit;
}
/**
* Render settings page.
*
* @return void
*/
public function render_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$settings = Settings::get();
$schema = Settings::get_design_schema();
$resolved_design = Settings::get_resolved_design_settings( $settings['default_theme'], $settings );
$preview_style = Settings::build_design_css_variables( $resolved_design );
$presets = Settings::get_design_presets();
$preview_preset = isset( $presets[ $settings['default_theme'] ] ) ? $presets[ $settings['default_theme'] ]['label'] : $presets[ Settings::get_default_preset_slug() ]['label'];
?>
<div class="wrap map-admin-page">
<h1><?php esc_html_e( 'Velora Player Design System', 'modern-audio-player' ); ?></h1>
<p class="map-admin-page__intro">
<?php esc_html_e( 'Manage the global player design tokens used by both the block renderer and shortcode output.', 'modern-audio-player' ); ?>
</p>
<?php if ( isset( $_GET['settings-updated'] ) ) : ?>
<div class="notice notice-success is-dismissible"><p><?php esc_html_e( 'Design settings saved.', 'modern-audio-player' ); ?></p></div>
<?php endif; ?>
<?php if ( isset( $_GET['map-reset'] ) ) : ?>
<div class="notice notice-success is-dismissible"><p><?php esc_html_e( 'Design settings reset to the plugin defaults.', 'modern-audio-player' ); ?></p></div>
<?php endif; ?>
<div class="map-admin-layout">
<form class="map-design-form" action="options.php" method="post">
<?php settings_fields( 'map_settings_group' ); ?>
<div class="map-settings-shell">
<div class="map-settings-shell__header">
<div class="map-settings-shell__meta">
<span class="map-settings-shell__eyebrow"><?php esc_html_e( 'Global Design Controls', 'modern-audio-player' ); ?></span>
<h2><?php esc_html_e( 'Design Backend', 'modern-audio-player' ); ?></h2>
<p><?php esc_html_e( 'Switch between sections, edit tokens quickly, and keep the preview visible while you work.', 'modern-audio-player' ); ?></p>
</div>
<div class="map-settings-actions map-settings-actions--header">
<?php submit_button( __( 'Save Design Settings', 'modern-audio-player' ), 'primary', 'submit', false ); ?>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=map_reset_design_settings' ), 'map_reset_design_settings' ) ); ?>" class="button button-secondary map-reset-button">
<?php esc_html_e( 'Reset to Defaults', 'modern-audio-player' ); ?>
</a>
</div>
</div>
<h2 class="nav-tab-wrapper wp-clearfix">
<a href="#map-section-presets" class="nav-tab nav-tab-active" data-map-tab="presets"><?php esc_html_e( 'Presets', 'modern-audio-player' ); ?></a>
<a href="#map-section-colors" class="nav-tab" data-map-tab="colors"><?php esc_html_e( 'Colors', 'modern-audio-player' ); ?></a>
<a href="#map-section-text" class="nav-tab" data-map-tab="text"><?php esc_html_e( 'Text', 'modern-audio-player' ); ?></a>
<a href="#map-section-layout" class="nav-tab" data-map-tab="layout"><?php esc_html_e( 'Layout', 'modern-audio-player' ); ?></a>
<a href="#map-section-controls" class="nav-tab" data-map-tab="controls"><?php esc_html_e( 'Controls', 'modern-audio-player' ); ?></a>
</h2>
<div class="map-settings-panels">
<?php foreach ( $schema as $section_key => $section ) : ?>
<section id="map-section-<?php echo esc_attr( $section_key ); ?>" class="map-settings-card map-settings-panel<?php echo 'presets' === $section_key ? ' is-active' : ''; ?>" data-map-panel="<?php echo esc_attr( $section_key ); ?>">
<div class="map-settings-card__header">
<h2><?php echo esc_html( $section['title'] ); ?></h2>
<p><?php echo esc_html( $section['description'] ); ?></p>
</div>
<div class="map-field-grid">
<?php foreach ( $section['fields'] as $field_key => $field ) : ?>
<div class="map-field-card map-field-card--<?php echo esc_attr( $field['type'] ); ?>">
<div class="map-field-card__top">
<label class="map-field-card__label" for="<?php echo esc_attr( $field_key ); ?>"><?php echo esc_html( $field['label'] ); ?></label>
<?php $this->render_field( $field_key, $field, $settings, $resolved_design ); ?>
</div>
<?php if ( ! empty( $field['description'] ) ) : ?>
<p class="description"><?php echo esc_html( $field['description'] ); ?></p>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endforeach; ?>
</div>
<div class="map-settings-actions map-settings-actions--footer">
<?php submit_button( __( 'Save Design Settings', 'modern-audio-player' ), 'primary', 'submit', false ); ?>
</div>
</div>
</form>
<aside class="map-preview-panel">
<div class="map-settings-card map-settings-card--sticky">
<div class="map-settings-card__header">
<h2><?php esc_html_e( 'Live Preview', 'modern-audio-player' ); ?></h2>
<p><?php esc_html_e( 'This static preview reflects the current saved values and updates as you edit the form.', 'modern-audio-player' ); ?></p>
</div>
<div class="map-admin-preview-shell">
<div class="map-player map-theme-<?php echo esc_attr( $settings['default_theme'] ); ?><?php echo empty( $resolved_design['player_show_cover_image'] ) ? ' map-player--no-cover' : ''; ?>" data-map-admin-preview data-map-has-cover="<?php echo empty( $resolved_design['player_show_cover_image'] ) ? 'false' : 'true'; ?>" style="<?php echo esc_attr( $preview_style ); ?>">
<div class="map-player__cover-wrap"<?php echo empty( $resolved_design['player_show_cover_image'] ) ? ' style="display:none;"' : ''; ?>>
<div class="map-admin-preview-cover"></div>
</div>
<div class="map-player__content">
<div class="map-player__header">
<div class="map-player__eyebrow" data-map-preview-theme<?php echo empty( $resolved_design['player_show_theme_label'] ) ? ' style="display:none;"' : ''; ?>><?php echo esc_html( $preview_preset ); ?></div>
<div class="map-player__title"><?php esc_html_e( 'Velora Weekly Mix', 'modern-audio-player' ); ?></div>
</div>
<div class="map-player__controls">
<div class="map-player__actions">
<button type="button" class="map-player__toggle" 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" disabled>
<span class="map-player__icon map-player__icon--previous" aria-hidden="true"></span>
</button>
<button type="button" class="map-player__icon-button">
<span class="map-player__icon map-player__icon--next" aria-hidden="true"></span>
</button>
<button type="button" class="map-player__icon-button">
<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" min="0" max="100" value="38" step="0.1" />
<div class="map-player__time">
<span><?php esc_html_e( '1:12', 'modern-audio-player' ); ?></span>
<span><?php esc_html_e( '3:08', 'modern-audio-player' ); ?></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</aside>
</div>
</div>
<?php
}
/**
* Render a field based on schema metadata.
*
* @param string $field_key Field key.
* @param array<string, mixed> $field Field definition.
* @param array<string, mixed> $settings Saved settings.
* @param array<string, mixed> $resolved_design Resolved design values.
* @return void
*/
private function render_field( $field_key, $field, $settings, $resolved_design ) {
$option_name = MAP_OPTION_KEY . '[' . $field_key . ']';
$value = isset( $settings[ $field_key ] ) && '' !== $settings[ $field_key ] ? $settings[ $field_key ] : $resolved_design[ $field_key ];
$override = isset( $settings[ $field_key ] ) && '' !== $settings[ $field_key ];
switch ( $field['type'] ) {
case 'color':
?>
<input
type="text"
id="<?php echo esc_attr( $field_key ); ?>"
class="map-color-field"
name="<?php echo esc_attr( $option_name ); ?>"
value="<?php echo esc_attr( (string) $value ); ?>"
data-map-field="<?php echo esc_attr( $field_key ); ?>"
data-default-color="<?php echo esc_attr( (string) $resolved_design[ $field_key ] ); ?>"
/>
<?php
break;
case 'range':
?>
<div class="map-range-control">
<input
type="range"
id="<?php echo esc_attr( $field_key ); ?>"
name="<?php echo esc_attr( $option_name ); ?>"
value="<?php echo esc_attr( (string) $value ); ?>"
min="<?php echo esc_attr( (string) $field['min'] ); ?>"
max="<?php echo esc_attr( (string) $field['max'] ); ?>"
step="<?php echo esc_attr( (string) $field['step'] ); ?>"
data-map-field="<?php echo esc_attr( $field_key ); ?>"
data-unit="<?php echo esc_attr( (string) $field['unit'] ); ?>"
/>
<span class="map-range-control__value" data-map-range-value="<?php echo esc_attr( $field_key ); ?>">
<?php echo esc_html( (string) $value . (string) $field['unit'] ); ?>
</span>
</div>
<?php
break;
case 'checkbox':
?>
<label class="map-switch" for="<?php echo esc_attr( $field_key ); ?>">
<input
type="checkbox"
id="<?php echo esc_attr( $field_key ); ?>"
name="<?php echo esc_attr( $option_name ); ?>"
value="1"
data-map-field="<?php echo esc_attr( $field_key ); ?>"
<?php checked( ! empty( $value ) ); ?>
/>
<span class="map-switch__track" aria-hidden="true"></span>
<span class="map-switch__label"><?php esc_html_e( 'Enabled', 'modern-audio-player' ); ?></span>
</label>
<?php
break;
case 'checkbox_group':
?>
<fieldset class="map-checkbox-group">
<?php foreach ( $field['options'] as $option_value => $option_label ) : ?>
<label>
<input
type="checkbox"
name="<?php echo esc_attr( $option_name ); ?>[]"
value="<?php echo esc_attr( $option_value ); ?>"
<?php checked( in_array( $option_value, (array) $settings[ $field_key ], true ) ); ?>
/>
<span><?php echo esc_html( $option_label ); ?></span>
</label>
<?php endforeach; ?>
</fieldset>
<?php
break;
case 'select':
?>
<select
id="<?php echo esc_attr( $field_key ); ?>"
name="<?php echo esc_attr( $option_name ); ?>"
class="map-select-control"
data-map-preset-select="true"
>
<?php foreach ( $field['options'] as $option_value => $option_label ) : ?>
<option value="<?php echo esc_attr( $option_value ); ?>" <?php selected( $settings[ $field_key ], $option_value ); ?>>
<?php echo esc_html( $option_label ); ?>
</option>
<?php endforeach; ?>
</select>
<p class="description"><?php esc_html_e( 'Selecting a preset updates the visible field values immediately. Saved customizations remain layered over the chosen preset.', 'modern-audio-player' ); ?></p>
<?php
break;
}
if ( 0 === strpos( $field_key, 'player_' ) ) {
?>
<p class="map-field-state">
<?php
echo $override
? esc_html__( 'Custom override saved for this token.', 'modern-audio-player' )
: esc_html__( 'Currently inherited from the selected preset.', 'modern-audio-player' );
?>
</p>
<?php
}
}
/**
* Render analytics page.
*
* @return void
*/
public function render_analytics_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$summary = Analytics::get_summary();
$tracks = Analytics::get_top_tracks( 50 );
?>
<div class="wrap">
<h1><?php esc_html_e( 'Velora Player Analytics', 'modern-audio-player' ); ?></h1>
<div style="display:flex;gap:16px;flex-wrap:wrap;margin:20px 0;">
<div class="postbox" style="padding:16px;min-width:180px;">
<strong><?php esc_html_e( 'Tracked Sources', 'modern-audio-player' ); ?></strong>
<div style="font-size:24px;"><?php echo esc_html( number_format_i18n( $summary['track_count'] ) ); ?></div>
</div>
<div class="postbox" style="padding:16px;min-width:180px;">
<strong><?php esc_html_e( 'Total Plays', 'modern-audio-player' ); ?></strong>
<div style="font-size:24px;"><?php echo esc_html( number_format_i18n( $summary['total_plays'] ) ); ?></div>
</div>
<div class="postbox" style="padding:16px;min-width:220px;">
<strong><?php esc_html_e( 'Last Played', 'modern-audio-player' ); ?></strong>
<div style="font-size:16px;">
<?php echo $summary['last_played'] ? esc_html( get_date_from_gmt( $summary['last_played'], get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ) : esc_html__( 'No data yet', 'modern-audio-player' ); ?>
</div>
</div>
</div>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e( 'Track', 'modern-audio-player' ); ?></th>
<th><?php esc_html_e( 'Audio Source', 'modern-audio-player' ); ?></th>
<th><?php esc_html_e( 'Plays', 'modern-audio-player' ); ?></th>
<th><?php esc_html_e( 'Last Played', 'modern-audio-player' ); ?></th>
</tr>
</thead>
<tbody>
<?php if ( empty( $tracks ) ) : ?>
<tr>
<td colspan="4"><?php esc_html_e( 'No analytics recorded yet.', 'modern-audio-player' ); ?></td>
</tr>
<?php else : ?>
<?php foreach ( $tracks as $track ) : ?>
<tr>
<td><?php echo esc_html( $track->track_title ? $track->track_title : __( 'Untitled Track', 'modern-audio-player' ) ); ?></td>
<td>
<a href="<?php echo esc_url( $track->audio_src ); ?>" target="_blank" rel="noopener noreferrer">
<?php echo esc_html( $track->audio_src ); ?>
</a>
</td>
<td><?php echo esc_html( number_format_i18n( (int) $track->play_count ) ); ?></td>
<td><?php echo $track->last_played ? esc_html( get_date_from_gmt( $track->last_played, get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ) : esc_html__( 'Never', 'modern-audio-player' ); ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php
}
}

View File

@ -0,0 +1,361 @@
.map-admin-page {
max-width: 1480px;
}
.map-admin-page__intro {
max-width: 820px;
margin: 0 0 18px;
color: #5f6c7b;
}
.map-admin-layout {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.85fr);
gap: 20px;
align-items: start;
}
.map-design-form,
.map-preview-panel {
min-width: 0;
}
.map-settings-shell {
display: grid;
gap: 12px;
padding: 14px;
background: linear-gradient(180deg, #ffffff, #f8fafc);
border: 1px solid #d7dde5;
border-radius: 18px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.05);
}
.map-settings-shell__header {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-start;
}
.map-settings-shell__meta h2 {
margin: 2px 0 4px;
font-size: 24px;
line-height: 1.15;
}
.map-settings-shell__meta p {
margin: 0;
max-width: 720px;
color: #5f6c7b;
}
.map-settings-shell__eyebrow {
display: inline-flex;
padding: 5px 10px;
border-radius: 999px;
background: #eef4ff;
color: #2754c5;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.map-settings-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.map-settings-actions--header {
justify-content: flex-end;
}
.map-settings-actions--footer {
padding-top: 4px;
}
.map-design-form .nav-tab-wrapper {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0;
padding: 0;
border-bottom: 0 !important;
}
.map-design-form .nav-tab {
margin: 0;
padding: 10px 14px;
border: 1px solid #d7dde5;
border-radius: 12px;
background: #f6f8fb;
color: #334155;
}
.map-design-form .nav-tab:hover {
background: #edf2f7;
color: #0f172a;
}
.map-design-form .nav-tab.nav-tab-active {
border-color: #c9d9ff;
background: #eaf1ff;
color: #1d4ed8;
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.12);
}
.map-settings-panels {
position: relative;
max-height: calc(100vh - 265px);
padding-right: 4px;
overflow-y: auto;
scroll-behavior: smooth;
scrollbar-width: thin;
scrollbar-color: #c3cfdf transparent;
}
.map-settings-panels::-webkit-scrollbar {
width: 8px;
}
.map-settings-panels::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #c3cfdf;
}
.map-settings-panels::-webkit-scrollbar-track {
background: transparent;
}
.map-settings-card {
padding: 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.map-settings-panel {
display: none;
}
.map-settings-panel.is-active {
display: block;
}
.map-settings-card__header {
margin-bottom: 12px;
}
.map-settings-card__header h2 {
margin: 0 0 2px;
font-size: 22px;
}
.map-settings-card__header p {
margin: 0;
color: #64748b;
}
.map-field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.map-field-card {
display: grid;
gap: 8px;
padding: 12px 14px;
border: 1px solid #e7edf4;
border-radius: 14px;
background: #fbfdff;
}
.map-field-card--checkbox,
.map-field-card--select,
.map-field-card--checkbox_group {
grid-column: 1 / -1;
}
.map-field-card__top {
display: grid;
gap: 8px;
}
.map-field-card__label {
display: block;
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
.map-field-card .description {
margin: 0;
color: #64748b;
line-height: 1.45;
}
.map-select-control {
min-width: 220px;
max-width: 340px;
}
.map-color-field {
max-width: 140px;
}
.map-checkbox-group {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.map-checkbox-group label {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border: 1px solid #d9e3ee;
border-radius: 12px;
background: #fff;
}
.map-range-control {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
max-width: 100%;
}
.map-range-control__value {
min-width: 56px;
padding: 6px 10px;
border-radius: 999px;
background: #eef4ff;
color: #1e40af;
font-weight: 700;
text-align: center;
}
.map-switch {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
cursor: pointer;
}
.map-switch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.map-switch__track {
position: relative;
width: 48px;
height: 28px;
border-radius: 999px;
background: #dbe4ee;
transition: background-color 0.2s ease;
}
.map-switch__track::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.18);
transition: transform 0.2s ease;
}
.map-switch input:checked + .map-switch__track {
background: #2563eb;
}
.map-switch input:checked + .map-switch__track::after {
transform: translateX(20px);
}
.map-switch__label {
font-weight: 600;
color: #334155;
}
.map-field-state {
margin: 0;
font-size: 12px;
color: #7b8794;
}
.map-preview-panel {
position: relative;
}
.map-settings-card--sticky {
position: sticky;
top: 32px;
}
.map-admin-preview-shell {
padding: 16px;
border-radius: 16px;
background:
radial-gradient(circle at top left, rgba(59, 130, 246, 0.16), transparent 34%),
linear-gradient(180deg, #f8fafc, #eef2ff);
}
.map-settings-actions--footer {
padding-top: 0;
}
.map-settings-actions--footer .button-primary {
margin-top: 2px;
}
.map-admin-preview-shell .map-player {
margin: 0;
}
.map-admin-preview-cover {
width: 100%;
height: 100%;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.22), transparent),
linear-gradient(145deg, #fb7185, #0ea5e9);
}
@media (max-width: 1200px) {
.map-admin-layout {
grid-template-columns: 1fr;
}
.map-settings-panels {
max-height: none;
overflow: visible;
padding-right: 0;
}
.map-settings-card--sticky {
position: static;
}
}
@media (max-width: 820px) {
.map-settings-shell__header {
flex-direction: column;
}
.map-field-grid,
.map-checkbox-group {
grid-template-columns: 1fr;
}
}

8
assets/css/editor.css Normal file
View File

@ -0,0 +1,8 @@
.map-editor-preview .map-player {
margin-top: 0;
margin-bottom: 0;
}
.map-editor-preview .map-player__audio {
pointer-events: none;
}

445
assets/css/player.css Normal file
View File

@ -0,0 +1,445 @@
.map-player {
--velora-bg: #0f172a;
--velora-surface: #111827;
--velora-text: #f9fafb;
--velora-muted: #94a3b8;
--velora-accent: #7dd3fc;
--velora-button-bg: #7dd3fc;
--velora-button-text: #0f172a;
--velora-progress-bg: #334155;
--velora-progress-fill: #7dd3fc;
--velora-border: #1f2937;
--velora-shadow: #0f172a;
--velora-radius: 18px;
--velora-padding: 16px;
--velora-font-size: 16px;
--map-progress-percent: 0%;
position: relative;
display: grid;
grid-template-columns: minmax(96px, 144px) 1fr;
gap: calc(var(--velora-padding) * 0.75);
align-items: center;
padding: var(--velora-padding);
margin: 1.5rem 0;
border: 1px solid var(--velora-border);
border-radius: var(--velora-radius);
color: var(--velora-text);
font-size: var(--velora-font-size);
background:
linear-gradient(140deg, color-mix(in srgb, var(--velora-bg) 92%, white 8%), var(--velora-surface)),
var(--velora-bg);
box-shadow:
0 24px 48px color-mix(in srgb, var(--velora-shadow) 28%, transparent),
inset 0 1px 0 color-mix(in srgb, var(--velora-border) 35%, white 65%);
overflow: hidden;
}
.map-player::before {
content: "";
position: absolute;
inset: auto auto -35% -10%;
width: 240px;
height: 240px;
background: radial-gradient(circle, color-mix(in srgb, var(--velora-accent) 28%, transparent), transparent 70%);
pointer-events: none;
}
.map-player__cover-wrap {
position: relative;
aspect-ratio: 1 / 1;
border: 1px solid color-mix(in srgb, var(--velora-border) 85%, white 15%);
border-radius: calc(var(--velora-radius) - 4px);
overflow: hidden;
background: linear-gradient(145deg, var(--velora-surface), color-mix(in srgb, var(--velora-bg) 70%, black 30%));
}
.map-player__cover {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.map-player__content {
position: relative;
z-index: 1;
display: grid;
gap: 1rem;
min-width: 0;
}
.map-player__header {
display: grid;
gap: 0.35rem;
}
.map-player__eyebrow {
font-size: 0.72em;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--velora-accent);
}
.map-player__title {
font-size: 1.15em;
font-weight: 700;
line-height: 1.3;
}
.map-player__editor-note {
margin: 0;
font-size: 0.88em;
color: var(--velora-muted);
}
.map-player__controls {
display: grid;
grid-template-columns: minmax(220px, auto) minmax(0, 1fr);
gap: 1rem;
align-items: center;
min-width: 0;
}
.map-player__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.map-player__toggle {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.55rem;
padding: 0.85rem 1rem;
border: 1px solid color-mix(in srgb, var(--velora-button-bg) 82%, black 18%);
border-radius: 999px;
background: var(--velora-button-bg);
color: var(--velora-button-text);
font-weight: 700;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 12px 24px color-mix(in srgb, var(--velora-shadow) 24%, transparent);
}
.map-player__toggle:hover,
.map-player__toggle:focus-visible {
transform: translateY(-1px);
box-shadow: 0 16px 28px color-mix(in srgb, var(--velora-shadow) 34%, transparent);
}
.map-player__toggle-icon {
display: inline-block;
width: 0;
height: 0;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 12px solid var(--velora-button-text);
}
.map-player.is-playing .map-player__toggle-icon {
width: 12px;
height: 14px;
border: 0;
border-left: 4px solid var(--velora-button-text);
border-right: 4px solid var(--velora-button-text);
}
.map-player__transport {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.map-player__icon-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: 1px solid color-mix(in srgb, var(--velora-border) 72%, white 28%);
border-radius: 999px;
background: color-mix(in srgb, var(--velora-surface) 88%, white 12%);
color: var(--velora-text);
cursor: pointer;
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 8px 18px color-mix(in srgb, var(--velora-shadow) 16%, transparent);
}
.map-player__icon-button:hover,
.map-player__icon-button:focus-visible {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--velora-accent) 68%, white 32%);
background: color-mix(in srgb, var(--velora-surface) 72%, var(--velora-accent) 28%);
box-shadow: 0 12px 20px color-mix(in srgb, var(--velora-shadow) 20%, transparent);
}
.map-player__icon-button[disabled] {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.map-player__icon-button.is-active {
border-color: color-mix(in srgb, var(--velora-accent) 76%, white 24%);
background: color-mix(in srgb, var(--velora-accent) 20%, var(--velora-surface) 80%);
color: var(--velora-accent);
}
.map-player__icon {
position: relative;
display: inline-block;
width: 16px;
height: 16px;
}
.map-player__icon--previous::before,
.map-player__icon--next::before {
content: "";
position: absolute;
top: 2px;
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
}
.map-player__icon--previous::after,
.map-player__icon--next::after {
content: "";
position: absolute;
top: 2px;
width: 2px;
height: 12px;
background: currentColor;
border-radius: 999px;
}
.map-player__icon--previous::before {
left: 2px;
border-right: 8px solid currentColor;
}
.map-player__icon--previous::after {
left: 0;
}
.map-player__icon--next::before {
right: 2px;
border-left: 8px solid currentColor;
}
.map-player__icon--next::after {
right: 0;
}
.map-player__icon--repeat::before,
.map-player__icon--repeat::after {
content: "";
position: absolute;
border-radius: 999px;
}
.map-player__icon--repeat::before {
top: 3px;
left: 1px;
width: 10px;
height: 10px;
border-top: 2px solid currentColor;
border-left: 2px solid currentColor;
transform: rotate(-35deg);
}
.map-player__icon--repeat::after {
right: 1px;
bottom: 3px;
width: 10px;
height: 10px;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(-35deg);
}
.map-player__timeline {
display: grid;
gap: 0.5rem;
min-width: 0;
}
.map-player__progress {
width: 100%;
height: 6px;
margin: 0;
appearance: none;
border-radius: 999px;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--velora-border) 52%, transparent);
background: linear-gradient(
90deg,
var(--velora-progress-fill) 0%,
var(--velora-progress-fill) var(--map-progress-percent),
var(--velora-progress-bg) var(--map-progress-percent),
var(--velora-progress-bg) 100%
);
outline: none;
}
.map-player__progress::-webkit-slider-runnable-track {
height: 6px;
background: transparent;
border-radius: 999px;
}
.map-player__progress::-moz-range-track {
height: 6px;
background: transparent;
border-radius: 999px;
}
.map-player__progress::-webkit-slider-thumb {
appearance: none;
width: 18px;
height: 18px;
margin-top: -5px;
border: 2px solid var(--velora-progress-fill);
border-radius: 50%;
background: var(--velora-surface);
box-shadow: 0 4px 12px color-mix(in srgb, var(--velora-shadow) 18%, transparent);
}
.map-player__progress::-moz-range-thumb {
width: 18px;
height: 18px;
border: 2px solid var(--velora-progress-fill);
border-radius: 50%;
background: var(--velora-surface);
box-shadow: 0 4px 12px color-mix(in srgb, var(--velora-shadow) 18%, transparent);
}
.map-player__time {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.88em;
color: var(--velora-muted);
}
.map-player--no-cover {
grid-template-columns: 1fr;
}
.map-player--no-cover .map-player__content {
gap: 0.85rem;
}
.map-player--no-cover .map-player__controls {
grid-template-columns: minmax(220px, auto) minmax(0, 1fr);
align-items: center;
column-gap: clamp(0.9rem, 2vw, 1.25rem);
}
.map-player--no-cover .map-player__timeline {
width: 100%;
max-width: none;
}
.map-player--no-cover .map-player__progress,
.map-player--no-cover .map-player__time {
width: 100%;
}
.map-player--placeholder {
grid-template-columns: 1fr;
background: linear-gradient(145deg, var(--velora-surface), var(--velora-bg));
}
.map-theme-glassmorphism {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.map-theme-podcast-style {
grid-template-columns: minmax(120px, 160px) 1fr;
}
.map-theme-podcast-style .map-player__header {
padding-bottom: 0.5rem;
border-bottom: 1px solid color-mix(in srgb, var(--velora-border) 75%, white 25%);
}
@media (max-width: 920px) {
.map-player {
grid-template-columns: minmax(92px, 132px) 1fr;
}
.map-theme-podcast-style {
grid-template-columns: minmax(92px, 132px) 1fr;
}
.map-player__content {
gap: 0.85rem;
}
.map-player__controls,
.map-player--no-cover .map-player__controls {
grid-template-columns: 1fr;
gap: 0.85rem;
align-items: stretch;
}
.map-player__actions {
justify-content: space-between;
width: 100%;
}
.map-player__transport {
justify-content: flex-start;
}
.map-player__timeline,
.map-player--no-cover .map-player__timeline {
width: 100%;
max-width: none;
}
}
@media (max-width: 640px) {
.map-player,
.map-theme-podcast-style {
grid-template-columns: 1fr;
}
.map-player__cover-wrap {
max-width: 180px;
}
.map-player__controls {
grid-template-columns: 1fr;
}
.map-player__toggle {
justify-self: start;
}
.map-player__actions {
gap: 0.65rem;
}
.map-player__transport {
gap: 0.45rem;
}
.map-player--no-cover .map-player__controls {
grid-template-columns: 1fr;
align-items: stretch;
}
.map-player--no-cover .map-player__timeline {
width: 100%;
}
}

170
assets/js/admin-settings.js Normal file
View File

@ -0,0 +1,170 @@
( function( $, window ) {
'use strict';
var config = window.mapAdminSettings || {};
var optionKey = config.optionKey || 'map_settings';
var presetTokens = config.presets || {};
function getFieldSelector( key ) {
return '[name="' + optionKey + '[' + key + ']"]';
}
function getFieldValue( key ) {
var $field = $( getFieldSelector( key ) );
if ( ! $field.length ) {
return null;
}
if ( 'checkbox' === $field.attr( 'type' ) ) {
return $field.is( ':checked' ) ? 1 : 0;
}
return $field.val();
}
function setFieldValue( key, value ) {
var $field = $( getFieldSelector( key ) );
if ( ! $field.length ) {
return;
}
if ( 'checkbox' === $field.attr( 'type' ) ) {
$field.prop( 'checked', !! Number( value ) ).trigger( 'change' );
return;
}
if ( $field.hasClass( 'map-color-field' ) ) {
$field.wpColorPicker( 'color', value );
return;
}
$field.val( value ).trigger( 'input' ).trigger( 'change' );
}
function updateRangeLabels() {
$( '[data-map-range-value]' ).each( function() {
var key = $( this ).data( 'mapRangeValue' );
var $input = $( getFieldSelector( key ) );
var unit = $input.data( 'unit' ) || '';
$( this ).text( $input.val() + unit );
} );
}
function buildPreviewStyles() {
return {
'--velora-bg': getFieldValue( 'player_background_color' ),
'--velora-surface': getFieldValue( 'player_surface_color' ),
'--velora-text': getFieldValue( 'player_text_color' ),
'--velora-muted': getFieldValue( 'player_muted_text_color' ),
'--velora-accent': getFieldValue( 'player_accent_color' ),
'--velora-button-bg': getFieldValue( 'player_button_background_color' ),
'--velora-button-text': getFieldValue( 'player_button_text_color' ),
'--velora-progress-bg': getFieldValue( 'player_progress_background_color' ),
'--velora-progress-fill': getFieldValue( 'player_progress_fill_color' ),
'--velora-border': getFieldValue( 'player_border_color' ),
'--velora-shadow': getFieldValue( 'player_shadow_color' ),
'--velora-radius': getFieldValue( 'player_border_radius' ) + 'px',
'--velora-padding': getFieldValue( 'player_padding' ) + 'px',
'--velora-font-size': getFieldValue( 'player_font_size' ) + 'px',
'--map-progress-percent': '38%'
};
}
function updatePreview() {
var preset = $( '[data-map-preset-select]' ).val();
var $preview = $( '[data-map-admin-preview]' );
var styles = buildPreviewStyles();
var hasCover = Number( getFieldValue( 'player_show_cover_image' ) );
$preview.css( styles );
$preview
.removeClass( 'map-theme-modern-dark map-theme-glassmorphism map-theme-podcast-style map-player--no-cover' )
.addClass( 'map-theme-' + preset );
$( '[data-map-preview-theme]' ).text(
$( '[data-map-preset-select] option:selected' ).text()
);
if ( hasCover ) {
$preview.find( '.map-player__cover-wrap' ).show();
$preview.attr( 'data-map-has-cover', 'true' );
} else {
$preview.find( '.map-player__cover-wrap' ).hide();
$preview
.addClass( 'map-player--no-cover' )
.attr( 'data-map-has-cover', 'false' );
}
if ( Number( getFieldValue( 'player_show_theme_label' ) ) ) {
$preview.find( '.map-player__eyebrow' ).show();
} else {
$preview.find( '.map-player__eyebrow' ).hide();
}
updateRangeLabels();
}
function applyPreset( preset ) {
if ( ! presetTokens[ preset ] ) {
return;
}
Object.keys( presetTokens[ preset ] ).forEach( function( key ) {
setFieldValue( key, presetTokens[ preset ][ key ] );
} );
updatePreview();
}
function initColorPickers() {
$( '.map-color-field' ).wpColorPicker( {
change: function() {
window.setTimeout( updatePreview, 0 );
},
clear: function() {
window.setTimeout( updatePreview, 0 );
}
} );
}
function setActivePanel( panelKey ) {
$( '.map-design-form .nav-tab' ).removeClass( 'nav-tab-active' );
$( '.map-design-form .nav-tab[data-map-tab="' + panelKey + '"]' ).addClass( 'nav-tab-active' );
$( '.map-settings-panel' ).removeClass( 'is-active' );
$( '.map-settings-panel[data-map-panel="' + panelKey + '"]' ).addClass( 'is-active' );
$( '.map-settings-panels' ).scrollTop( 0 );
}
function initTabs() {
$( '.map-design-form .nav-tab' ).on( 'click', function( event ) {
event.preventDefault();
setActivePanel( $( this ).data( 'mapTab' ) );
} );
}
function initResetConfirmation() {
$( '.map-reset-button' ).on( 'click', function( event ) {
if ( ! window.confirm( config.i18n.resetConfirm ) ) {
event.preventDefault();
}
} );
}
$( function() {
initColorPickers();
initTabs();
initResetConfirmation();
$( document ).on( 'input change', '[data-map-field], [data-map-preset-select]', updatePreview );
$( '[data-map-preset-select]' ).on( 'change', function() {
applyPreset( $( this ).val() );
} );
setActivePanel( 'presets' );
updatePreview();
} );
}( jQuery, window ) );

View File

@ -0,0 +1,18 @@
<?php
/**
* Asset metadata for block editor script.
*
* @package ModernAudioPlayer
*/
return array(
'dependencies' => array(
'wp-blocks',
'wp-block-editor',
'wp-components',
'wp-element',
'wp-i18n',
'wp-server-side-render',
),
'version' => '1.0.0',
);

221
assets/js/block-editor.js Normal file
View File

@ -0,0 +1,221 @@
( function( wp ) {
const { __ } = wp.i18n;
const { getBlockType, registerBlockType } = wp.blocks;
const { createElement: el, Fragment, useState } = wp.element;
const { InspectorControls, MediaUpload, MediaUploadCheck } = wp.blockEditor;
const { PanelBody, TextControl, TextareaControl, Button, ToggleControl, SelectControl, BaseControl, Notice } = wp.components;
const ServerSideRender = wp.serverSideRender;
const themeOptions = [
{ label: __( 'Modern Dark', 'modern-audio-player' ), value: 'modern-dark' },
{ label: __( 'Glassmorphism', 'modern-audio-player' ), value: 'glassmorphism' },
{ label: __( 'Podcast Style', 'modern-audio-player' ), value: 'podcast-style' }
];
function getAudioUrlWarning( value ) {
if ( ! value ) {
return '';
}
try {
const url = new window.URL( value, window.location.origin );
const hostname = url.hostname.toLowerCase();
const pathname = url.pathname.toLowerCase();
if ( [ 'youtube.com', 'www.youtube.com', 'youtu.be', 'soundcloud.com', 'open.spotify.com', 'music.apple.com' ].includes( hostname ) ) {
return __( 'This URL looks like a page, not a direct audio file. Please use a direct MP3 or other audio file URL.', 'modern-audio-player' );
}
if ( /\.(html?|php)$/i.test( pathname ) ) {
return __( 'This URL does not look like a direct audio file. Please confirm it points directly to the audio asset.', 'modern-audio-player' );
}
} catch ( error ) {
return __( 'Please enter a valid audio URL.', 'modern-audio-player' );
}
return '';
}
function formatPlaylistText( playlist ) {
if ( ! Array.isArray( playlist ) || ! playlist.length ) {
return '';
}
return playlist.map( function( track ) {
return [
track.title || '',
track.src || '',
track.image || ''
].join( ' | ' );
} ).join( '\n' );
}
function parsePlaylistText( value ) {
return String( value || '' )
.split( /\r\n|\r|\n/ )
.map( function( line ) {
return line.trim();
} )
.filter( function( line ) {
return line.length > 0;
} )
.map( function( line ) {
const parts = line.split( '|' ).map( function( part ) {
return part.trim();
} );
return {
title: parts[ 0 ] || '',
src: parts[ 1 ] || '',
image: parts[ 2 ] || ''
};
} )
.filter( function( track ) {
return !! track.src;
} );
}
const blockSettings = {
edit: function( props ) {
const attributes = props.attributes;
const setAttributes = props.setAttributes;
const initialPlaylistText = formatPlaylistText( attributes.playlist || [] );
const playlistState = useState( initialPlaylistText );
const playlistText = playlistState[ 0 ];
const setPlaylistText = playlistState[ 1 ];
const firstPlaylistTrack = Array.isArray( attributes.playlist ) && attributes.playlist.length ? attributes.playlist[ 0 ] : null;
const audioUrlWarning = getAudioUrlWarning( attributes.src || ( firstPlaylistTrack && firstPlaylistTrack.src ) || '' );
function onSelectAudio( media ) {
setAttributes( {
src: media && media.url ? media.url : '',
title: attributes.title || ( media && media.title ? media.title : '' )
} );
}
function onSelectImage( media ) {
setAttributes( {
image: media && media.url ? media.url : ''
} );
}
return el(
Fragment,
{},
el(
InspectorControls,
{},
el(
PanelBody,
{ title: __( 'Player Content', 'modern-audio-player' ), initialOpen: true },
el( TextControl, {
label: __( 'Track Title', 'modern-audio-player' ),
value: attributes.title || '',
onChange: function( value ) {
setAttributes( { title: value } );
}
} ),
el( TextControl, {
label: __( 'Audio Source URL', 'modern-audio-player' ),
value: attributes.src || '',
help: __( 'Paste a direct audio URL or choose one from the media library below. This remains the fallback single-track source.', 'modern-audio-player' ),
onChange: function( value ) {
setAttributes( { src: value } );
}
} ),
audioUrlWarning ? el( Notice, {
status: 'warning',
isDismissible: false
}, audioUrlWarning ) : null,
el(
BaseControl,
{ label: __( 'Audio File', 'modern-audio-player' ) },
el(
MediaUploadCheck,
{},
el( MediaUpload, {
onSelect: onSelectAudio,
allowedTypes: [ 'audio' ],
render: function( renderProps ) {
return el(
Button,
{ variant: 'secondary', onClick: renderProps.open },
attributes.src ? __( 'Replace Audio File', 'modern-audio-player' ) : __( 'Choose Audio File', 'modern-audio-player' )
);
}
} )
)
),
el(
BaseControl,
{ label: __( 'Cover Image', 'modern-audio-player' ) },
el(
MediaUploadCheck,
{},
el( MediaUpload, {
onSelect: onSelectImage,
allowedTypes: [ 'image' ],
render: function( renderProps ) {
return el(
Button,
{ variant: 'secondary', onClick: renderProps.open },
attributes.image ? __( 'Replace Cover Image', 'modern-audio-player' ) : __( 'Choose Cover Image', 'modern-audio-player' )
);
}
} )
)
),
el( TextareaControl, {
label: __( 'Playlist Tracks', 'modern-audio-player' ),
help: __( 'One track per line in this format: Title | Audio URL | Image URL. If empty, the single-track fields above are used.', 'modern-audio-player' ),
value: playlistText,
onChange: function( value ) {
setPlaylistText( value );
setAttributes( { playlist: parsePlaylistText( value ) } );
}
} )
),
el(
PanelBody,
{ title: __( 'Theme', 'modern-audio-player' ), initialOpen: false },
el( ToggleControl, {
label: __( 'Inherit Global Theme', 'modern-audio-player' ),
checked: !! attributes.useGlobalTheme,
onChange: function( value ) {
setAttributes( { useGlobalTheme: value } );
}
} ),
el( SelectControl, {
label: __( 'Theme Override', 'modern-audio-player' ),
value: attributes.theme || 'modern-dark',
options: themeOptions,
disabled: !! attributes.useGlobalTheme,
onChange: function( value ) {
setAttributes( { theme: value } );
}
} )
)
),
el(
'div',
{ className: 'map-editor-preview' },
el( ServerSideRender, {
block: props.name,
attributes: attributes
} )
)
);
},
save: function() {
return null;
}
};
if ( ! getBlockType( 'velora/audio-player' ) ) {
registerBlockType( 'velora/audio-player', blockSettings );
}
if ( ! getBlockType( 'map/audio-player' ) ) {
registerBlockType( 'map/audio-player', blockSettings );
}
} )( window.wp );

View File

@ -0,0 +1,11 @@
<?php
/**
* Asset metadata for frontend player script.
*
* @package ModernAudioPlayer
*/
return array(
'dependencies' => array(),
'version' => '1.0.0',
);

382
assets/js/player.js Normal file
View File

@ -0,0 +1,382 @@
( function() {
function formatTime( totalSeconds ) {
if ( ! Number.isFinite( totalSeconds ) || totalSeconds < 0 ) {
return '0:00';
}
const minutes = Math.floor( totalSeconds / 60 );
const seconds = Math.floor( totalSeconds % 60 );
return minutes + ':' + String( seconds ).padStart( 2, '0' );
}
function initPlayers( root ) {
root.querySelectorAll( '[data-map-player]' ).forEach( initPlayer );
}
function initPlayer( root ) {
if ( root.dataset.mapInitialized === 'true' ) {
return;
}
const audio = root.querySelector( '[data-map-audio]' );
const toggle = root.querySelector( '[data-map-toggle]' );
const previousButton = root.querySelector( '[data-map-previous]' );
const nextButton = root.querySelector( '[data-map-next]' );
const repeatButton = root.querySelector( '[data-map-repeat]' );
const progress = root.querySelector( '[data-map-progress]' );
const currentTime = root.querySelector( '[data-map-current]' );
const duration = root.querySelector( '[data-map-duration]' );
const title = root.querySelector( '[data-map-title]' );
const coverWrap = root.querySelector( '.map-player__cover-wrap' );
const cover = root.querySelector( '[data-map-cover]' );
const toggleText = root.querySelector( '.map-player__toggle-text' );
const editorNotice = root.querySelector( '[data-map-editor-notice]' );
const isEditorPreview = root.dataset.mapEditorPreview === 'true';
const showCoverSetting = root.dataset.mapShowCoverSetting === 'true';
const trackedHashes = new Set();
let isRepeatEnabled = root.dataset.mapRepeatEnabled === 'true';
let currentIndex = Number( root.dataset.mapCurrentIndex || 0 );
let playlist = [];
if ( ! audio || ! toggle || ! progress || ! currentTime || ! duration ) {
return;
}
try {
const parsedPlaylist = JSON.parse( root.dataset.mapPlaylist || '[]' );
if ( Array.isArray( parsedPlaylist ) ) {
playlist = parsedPlaylist.filter( function( track ) {
return track && track.src;
} );
}
} catch ( error ) {
playlist = [];
}
if ( ! playlist.length && root.dataset.trackSrc ) {
playlist = [
{
src: root.dataset.trackSrc,
title: root.dataset.trackTitle || 'Untitled Track',
image: cover && cover.getAttribute( 'src' ) ? cover.getAttribute( 'src' ) : '',
hash: root.dataset.trackHash || '',
nonce: root.dataset.trackNonce || ''
}
];
}
if ( ! playlist.length ) {
return;
}
currentIndex = Math.max( 0, Math.min( currentIndex, playlist.length - 1 ) );
root.dataset.mapInitialized = 'true';
function getCurrentTrack() {
return playlist[ currentIndex ] || playlist[ 0 ];
}
function setEditorNotice( message ) {
if ( ! editorNotice ) {
return;
}
editorNotice.textContent = message;
}
function syncTrackDataset( track ) {
root.dataset.mapCurrentIndex = String( currentIndex );
root.dataset.trackSrc = track.src || '';
root.dataset.trackTitle = track.title || '';
root.dataset.trackHash = track.hash || '';
root.dataset.trackNonce = track.nonce || '';
}
function updateCoverUI( track ) {
const shouldShowCover = showCoverSetting && !! track.image;
root.classList.toggle( 'map-player--no-cover', ! shouldShowCover );
root.dataset.mapHasCover = shouldShowCover ? 'true' : 'false';
if ( ! coverWrap || ! cover ) {
return;
}
coverWrap.style.display = shouldShowCover ? '' : 'none';
if ( shouldShowCover ) {
cover.src = track.image;
cover.alt = track.title || 'Audio cover';
}
}
function updateTransportState() {
const atFirstTrack = currentIndex === 0;
const atLastTrack = currentIndex === playlist.length - 1;
if ( previousButton ) {
previousButton.disabled = atFirstTrack;
}
if ( nextButton ) {
nextButton.disabled = atLastTrack;
}
if ( repeatButton ) {
repeatButton.classList.toggle( 'is-active', isRepeatEnabled );
repeatButton.setAttribute( 'aria-pressed', isRepeatEnabled ? 'true' : 'false' );
}
}
function updateProgressUI() {
if ( ! Number.isFinite( audio.duration ) || audio.duration <= 0 ) {
progress.value = 0;
duration.textContent = '0:00';
currentTime.textContent = '0:00';
progress.style.setProperty( '--map-progress-percent', '0%' );
return;
}
const percent = ( audio.currentTime / audio.duration ) * 100;
progress.value = percent;
progress.style.setProperty( '--map-progress-percent', percent + '%' );
currentTime.textContent = formatTime( audio.currentTime );
duration.textContent = formatTime( audio.duration );
}
function setPlayingState( isPlaying ) {
root.classList.toggle( 'is-playing', isPlaying );
toggle.setAttribute( 'aria-pressed', isPlaying ? 'true' : 'false' );
toggle.setAttribute( 'aria-label', isPlaying ? 'Pause audio' : 'Play audio' );
toggleText.textContent = isPlaying ? 'Pause' : 'Play';
}
function applyTrack( options ) {
const track = getCurrentTrack();
const autoplay = !! ( options && options.autoplay );
if ( title ) {
title.textContent = track.title || 'Untitled Track';
}
updateCoverUI( track );
updateTransportState();
syncTrackDataset( track );
if ( audio.getAttribute( 'src' ) !== track.src ) {
audio.setAttribute( 'src', track.src );
}
if ( audio.src !== track.src ) {
audio.src = track.src;
}
audio.currentTime = 0;
audio.load();
updateProgressUI();
if ( autoplay ) {
audio.play().catch( function() {
if ( isEditorPreview ) {
setEditorNotice( 'Playback preview is limited in the editor. Test full playback on the frontend.' );
}
} );
}
}
function ensureAudioLoaded() {
const track = getCurrentTrack();
if ( ! track || ! track.src ) {
return;
}
if ( audio.getAttribute( 'src' ) !== track.src ) {
applyTrack( { autoplay: false } );
return;
}
if ( audio.networkState === HTMLMediaElement.NETWORK_EMPTY || ! Number.isFinite( audio.duration ) || audio.duration <= 0 ) {
try {
audio.load();
} catch ( error ) {
return;
}
}
}
function trackPlay() {
const track = getCurrentTrack();
if ( ! track || ! track.src || ! track.hash || ! track.nonce || ! root.dataset.restEndpoint || trackedHashes.has( track.hash ) ) {
return;
}
trackedHashes.add( track.hash );
window.fetch( root.dataset.restEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify( {
src: track.src,
title: track.title || '',
hash: track.hash,
nonce: track.nonce
} ),
credentials: 'same-origin'
} ).catch( function() {
return null;
} );
}
function switchTrack( nextIndex, options ) {
if ( nextIndex < 0 || nextIndex >= playlist.length || nextIndex === currentIndex ) {
return;
}
currentIndex = nextIndex;
applyTrack( options );
}
toggle.addEventListener( 'click', function() {
ensureAudioLoaded();
if ( audio.paused ) {
audio.play().catch( function() {
if ( isEditorPreview ) {
setEditorNotice( 'Playback preview is limited in the editor. Test full playback on the frontend.' );
}
} );
return;
}
audio.pause();
} );
if ( previousButton ) {
previousButton.addEventListener( 'click', function() {
if ( previousButton.disabled ) {
return;
}
if ( audio.currentTime > 5 ) {
audio.currentTime = 0;
updateProgressUI();
return;
}
switchTrack( currentIndex - 1, { autoplay: ! audio.paused } );
} );
}
if ( nextButton ) {
nextButton.addEventListener( 'click', function() {
if ( nextButton.disabled ) {
return;
}
switchTrack( currentIndex + 1, { autoplay: ! audio.paused } );
} );
}
if ( repeatButton ) {
repeatButton.addEventListener( 'click', function() {
isRepeatEnabled = ! isRepeatEnabled;
root.dataset.mapRepeatEnabled = isRepeatEnabled ? 'true' : 'false';
updateTransportState();
} );
}
audio.addEventListener( 'play', function() {
setPlayingState( true );
trackPlay();
} );
audio.addEventListener( 'pause', function() {
setPlayingState( false );
} );
audio.addEventListener( 'loadedmetadata', updateProgressUI );
audio.addEventListener( 'durationchange', updateProgressUI );
audio.addEventListener( 'canplay', updateProgressUI );
audio.addEventListener( 'timeupdate', updateProgressUI );
audio.addEventListener( 'error', function() {
updateProgressUI();
setEditorNotice( 'Unable to load audio preview. Confirm the URL points directly to an audio file.' );
} );
audio.addEventListener( 'ended', function() {
if ( isRepeatEnabled ) {
audio.currentTime = 0;
audio.play().catch( function() {
return null;
} );
return;
}
if ( currentIndex < playlist.length - 1 ) {
switchTrack( currentIndex + 1, { autoplay: true } );
return;
}
setPlayingState( false );
audio.currentTime = 0;
updateProgressUI();
} );
progress.addEventListener( 'input', function() {
if ( ! Number.isFinite( audio.duration ) || audio.duration <= 0 ) {
return;
}
const targetTime = ( Number( progress.value ) / 100 ) * audio.duration;
audio.currentTime = targetTime;
updateProgressUI();
} );
applyTrack( { autoplay: false } );
}
function observePlayerMounts() {
if ( ! document.body || typeof MutationObserver === 'undefined' ) {
return;
}
const observer = new MutationObserver( function( mutations ) {
mutations.forEach( function( mutation ) {
mutation.addedNodes.forEach( function( node ) {
if ( ! node || node.nodeType !== 1 ) {
return;
}
if ( node.matches && node.matches( '[data-map-player]' ) ) {
initPlayer( node );
return;
}
if ( node.querySelectorAll ) {
initPlayers( node );
}
} );
} );
} );
observer.observe( document.body, {
childList: true,
subtree: true
} );
}
function boot() {
initPlayers( document );
observePlayerMounts();
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', boot );
} else {
boot();
}
} )();

46
blocks/block.json Normal file
View File

@ -0,0 +1,46 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "velora/audio-player",
"version": "1.2.0",
"title": "Velora Player",
"category": "media",
"icon": "format-audio",
"description": "Insert a modern themed audio player with analytics tracking.",
"textdomain": "modern-audio-player",
"keywords": [ "audio", "player", "podcast" ],
"supports": {
"html": false,
"align": [ "wide", "full" ]
},
"attributes": {
"title": {
"type": "string",
"default": ""
},
"src": {
"type": "string",
"default": ""
},
"image": {
"type": "string",
"default": ""
},
"playlist": {
"type": "array",
"default": []
},
"theme": {
"type": "string",
"default": "modern-dark"
},
"useGlobalTheme": {
"type": "boolean",
"default": true
}
},
"editorScript": "file:../assets/js/block-editor.js",
"style": "file:../assets/css/player.css",
"editorStyle": "file:../assets/css/editor.css",
"script": "file:../assets/js/player.js"
}

View File

@ -0,0 +1,174 @@
<?php
/**
* Analytics service.
*
* @package ModernAudioPlayer
*/
namespace ModernAudioPlayer;
defined( 'ABSPATH' ) || exit;
class Analytics {
/**
* Create database table on activation.
*
* @return void
*/
public static function create_table() {
global $wpdb;
$table_name = self::table_name();
$charset_collate = $wpdb->get_charset_collate();
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$sql = "CREATE TABLE {$table_name} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
source_hash varchar(64) NOT NULL,
audio_src text NOT NULL,
track_title varchar(255) NOT NULL DEFAULT '',
play_count bigint(20) unsigned NOT NULL DEFAULT 0,
last_played datetime NULL DEFAULT NULL,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY source_hash (source_hash),
KEY play_count (play_count),
KEY last_played (last_played)
) {$charset_collate};";
dbDelta( $sql );
}
/**
* Get the analytics table name.
*
* @return string
*/
public static function table_name() {
global $wpdb;
return $wpdb->prefix . 'map_analytics';
}
/**
* Build a stable source hash.
*
* @param string $audio_src Audio URL.
* @return string
*/
public static function build_source_hash( $audio_src ) {
return hash( 'sha256', esc_url_raw( trim( (string) $audio_src ) ) );
}
/**
* Record a play.
*
* Count rule:
* One request counts once for the rendered player instance session. The frontend
* only sends this call on the first meaningful play event per player rendered on a page.
*
* @param string $audio_src Audio source URL.
* @param string $track_title Track title.
* @return bool
*/
public static function record_play( $audio_src, $track_title = '' ) {
global $wpdb;
$audio_src = esc_url_raw( $audio_src );
$track_title = sanitize_text_field( $track_title );
$source_hash = self::build_source_hash( $audio_src );
$table_name = self::table_name();
$current_time = current_time( 'mysql', true );
if ( empty( $audio_src ) ) {
return false;
}
$existing_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$table_name} WHERE source_hash = %s LIMIT 1",
$source_hash
)
);
if ( $existing_id ) {
$result = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table_name}
SET play_count = play_count + 1,
track_title = %s,
last_played = %s,
updated_at = %s
WHERE source_hash = %s",
$track_title,
$current_time,
$current_time,
$source_hash
)
);
return false !== $result;
}
$result = $wpdb->insert(
$table_name,
array(
'source_hash' => $source_hash,
'audio_src' => $audio_src,
'track_title' => $track_title,
'play_count' => 1,
'last_played' => $current_time,
'created_at' => $current_time,
'updated_at' => $current_time,
),
array( '%s', '%s', '%s', '%d', '%s', '%s', '%s' )
);
return false !== $result;
}
/**
* Fetch top tracks.
*
* @param int $limit Row limit.
* @return array<int, object>
*/
public static function get_top_tracks( $limit = 20 ) {
global $wpdb;
$table_name = self::table_name();
$limit = max( 1, absint( $limit ) );
return $wpdb->get_results(
$wpdb->prepare(
"SELECT source_hash, audio_src, track_title, play_count, last_played
FROM {$table_name}
ORDER BY play_count DESC, last_played DESC
LIMIT %d",
$limit
)
);
}
/**
* Get aggregate stats.
*
* @return array<string, int|string|null>
*/
public static function get_summary() {
global $wpdb;
$table_name = self::table_name();
$row = $wpdb->get_row(
"SELECT COUNT(*) AS track_count, COALESCE(SUM(play_count), 0) AS total_plays, MAX(last_played) AS last_played FROM {$table_name}",
ARRAY_A
);
return array(
'track_count' => isset( $row['track_count'] ) ? (int) $row['track_count'] : 0,
'total_plays' => isset( $row['total_plays'] ) ? (int) $row['total_plays'] : 0,
'last_played' => $row['last_played'] ?? null,
);
}
}

142
includes/class-plugin.php Normal file
View File

@ -0,0 +1,142 @@
<?php
/**
* Main plugin bootstrap.
*
* @package ModernAudioPlayer
*/
namespace ModernAudioPlayer;
defined( 'ABSPATH' ) || exit;
class Plugin {
/**
* Settings service.
*
* @var Settings
*/
private $settings;
/**
* Admin service.
*
* @var Admin
*/
private $admin;
/**
* Shortcode service.
*
* @var Shortcode
*/
private $shortcode;
/**
* REST service.
*
* @var Rest_API
*/
private $rest_api;
/**
* Initialize service objects.
*/
public function __construct() {
$this->settings = new Settings();
$this->admin = new Admin();
$this->shortcode = new Shortcode();
$this->rest_api = new Rest_API();
}
/**
* Plugin activation callback.
*
* @return void
*/
public static function activate() {
Settings::maybe_install_defaults();
Analytics::create_table();
}
/**
* Register hooks.
*
* @return void
*/
public function init() {
add_action( 'init', array( $this, 'load_textdomain' ) );
add_action( 'init', array( $this, 'register_block' ) );
$this->shortcode->register();
$this->admin->register();
$this->rest_api->register();
}
/**
* Load translations.
*
* @return void
*/
public function load_textdomain() {
load_plugin_textdomain( 'modern-audio-player', false, dirname( plugin_basename( MAP_PLUGIN_FILE ) ) . '/languages' );
}
/**
* Register the Gutenberg block.
*
* @return void
*/
public function register_block() {
register_block_type_from_metadata(
MAP_PLUGIN_DIR . 'blocks/block.json',
array(
'render_callback' => array( $this, 'render_block' ),
)
);
$this->register_legacy_block_alias();
}
/**
* Server-render block markup.
*
* @param array<string, mixed> $attributes Block attributes.
* @return string
*/
public function render_block( $attributes ) {
return Renderer::render_player( (array) $attributes, is_admin() ? 'editor' : 'frontend' );
}
/**
* Register the legacy block name as an alias for backward compatibility.
*
* @return void
*/
private function register_legacy_block_alias() {
$primary_block = \WP_Block_Type_Registry::get_instance()->get_registered( 'velora/audio-player' );
if ( ! $primary_block ) {
return;
}
register_block_type(
'map/audio-player',
array(
'api_version' => $primary_block->api_version,
'title' => $primary_block->title,
'category' => $primary_block->category,
'icon' => $primary_block->icon,
'description' => $primary_block->description,
'keywords' => $primary_block->keywords,
'attributes' => $primary_block->attributes,
'supports' => $primary_block->supports,
'style_handles' => $primary_block->style_handles,
'editor_style_handles' => $primary_block->editor_style_handles,
'script_handles' => $primary_block->script_handles,
'editor_script_handles'=> $primary_block->editor_script_handles,
'render_callback' => array( $this, 'render_block' ),
)
);
}
}

348
includes/class-renderer.php Normal file
View File

@ -0,0 +1,348 @@
<?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 );
}
}
}

View File

@ -0,0 +1,98 @@
<?php
/**
* REST API endpoints.
*
* @package ModernAudioPlayer
*/
namespace ModernAudioPlayer;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
defined( 'ABSPATH' ) || exit;
class Rest_API {
/**
* Register REST routes.
*
* @return void
*/
public function register() {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register endpoint definitions.
*
* @return void
*/
public function register_routes() {
register_rest_route(
'map/v1',
'/track-play',
array(
'methods' => 'POST',
'callback' => array( $this, 'track_play' ),
'permission_callback' => '__return_true',
'args' => array(
'src' => array(
'type' => 'string',
'required' => true,
'sanitize_callback' => 'esc_url_raw',
),
'title' => array(
'type' => 'string',
'required' => false,
'sanitize_callback' => 'sanitize_text_field',
),
'hash' => array(
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'nonce' => array(
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
}
/**
* Persist a play event.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error
*/
public function track_play( WP_REST_Request $request ) {
$src = (string) $request->get_param( 'src' );
$title = (string) $request->get_param( 'title' );
$hash = (string) $request->get_param( 'hash' );
$nonce = (string) $request->get_param( 'nonce' );
if ( empty( $src ) || empty( $hash ) || empty( $nonce ) ) {
return new WP_Error( 'map_invalid_request', __( 'Missing analytics parameters.', 'modern-audio-player' ), array( 'status' => 400 ) );
}
if ( Analytics::build_source_hash( $src ) !== $hash ) {
return new WP_Error( 'map_invalid_hash', __( 'Audio source validation failed.', 'modern-audio-player' ), array( 'status' => 400 ) );
}
if ( ! wp_verify_nonce( $nonce, 'map_track_play_' . $hash ) ) {
return new WP_Error( 'map_invalid_nonce', __( 'Analytics nonce validation failed.', 'modern-audio-player' ), array( 'status' => 403 ) );
}
Analytics::record_play( $src, $title );
return new WP_REST_Response(
array(
'success' => true,
),
200
);
}
}

516
includes/class-settings.php Normal file
View File

@ -0,0 +1,516 @@
<?php
/**
* Settings service.
*
* @package ModernAudioPlayer
*/
namespace ModernAudioPlayer;
defined( 'ABSPATH' ) || exit;
class Settings {
/**
* Get default option values.
*
* @return array<string, mixed>
*/
public static function defaults() {
$defaults = array(
'default_theme' => self::get_default_preset_slug(),
'enabled_themes' => array_keys( self::get_design_presets() ),
);
foreach ( self::get_design_field_keys() as $field_key ) {
$defaults[ $field_key ] = '';
}
return $defaults;
}
/**
* Get current settings merged with defaults.
*
* @return array<string, mixed>
*/
public static function get() {
$settings = get_option( MAP_OPTION_KEY, array() );
if ( ! is_array( $settings ) ) {
$settings = array();
}
$settings = self::migrate_legacy_settings( $settings );
return wp_parse_args( $settings, self::defaults() );
}
/**
* Get the centralized design settings schema.
*
* @return array<string, array<string, mixed>>
*/
public static function get_design_schema() {
return array(
'presets' => array(
'title' => __( 'Presets', 'modern-audio-player' ),
'description' => __( 'Choose the default player preset and which presets can be selected in blocks or shortcodes.', 'modern-audio-player' ),
'fields' => array(
'default_theme' => array(
'label' => __( 'Default preset', 'modern-audio-player' ),
'type' => 'select',
'description' => __( 'Used when a block or shortcode inherits the global design preset.', 'modern-audio-player' ),
'options' => self::get_preset_options(),
),
'enabled_themes' => array(
'label' => __( 'Available presets', 'modern-audio-player' ),
'type' => 'checkbox_group',
'description' => __( 'Controls which presets editors can choose per player instance.', 'modern-audio-player' ),
'options' => self::get_preset_options(),
),
),
),
'colors' => array(
'title' => __( 'Colors', 'modern-audio-player' ),
'description' => __( 'Core palette tokens output to CSS variables for every rendered player.', 'modern-audio-player' ),
'fields' => array(
'player_background_color' => array(
'label' => __( 'Player background', 'modern-audio-player' ),
'type' => 'color',
'description' => __( 'Primary background tone behind the player card.', 'modern-audio-player' ),
),
'player_surface_color' => array(
'label' => __( 'Surface color', 'modern-audio-player' ),
'type' => 'color',
'description' => __( 'Secondary surface used for cover wells and layered areas.', 'modern-audio-player' ),
),
'player_text_color' => array(
'label' => __( 'Primary text', 'modern-audio-player' ),
'type' => 'color',
'description' => __( 'Main title and control text color.', 'modern-audio-player' ),
),
'player_muted_text_color' => array(
'label' => __( 'Muted text', 'modern-audio-player' ),
'type' => 'color',
'description' => __( 'Secondary metadata and timer text color.', 'modern-audio-player' ),
),
'player_accent_color' => array(
'label' => __( 'Accent color', 'modern-audio-player' ),
'type' => 'color',
'description' => __( 'Highlights the preset label and interactive emphasis states.', 'modern-audio-player' ),
),
'player_button_background_color' => array(
'label' => __( 'Button background', 'modern-audio-player' ),
'type' => 'color',
'description' => __( 'Default play button fill color.', 'modern-audio-player' ),
),
'player_button_text_color' => array(
'label' => __( 'Button text', 'modern-audio-player' ),
'type' => 'color',
'description' => __( 'Play button text and icon color.', 'modern-audio-player' ),
),
'player_progress_background_color' => array(
'label' => __( 'Progress background', 'modern-audio-player' ),
'type' => 'color',
'description' => __( 'Unfilled progress track color.', 'modern-audio-player' ),
),
'player_progress_fill_color' => array(
'label' => __( 'Progress fill', 'modern-audio-player' ),
'type' => 'color',
'description' => __( 'Filled portion of the progress track.', 'modern-audio-player' ),
),
'player_border_color' => array(
'label' => __( 'Border color', 'modern-audio-player' ),
'type' => 'color',
'description' => __( 'Border used around the player shell and dividers.', 'modern-audio-player' ),
),
'player_shadow_color' => array(
'label' => __( 'Shadow color', 'modern-audio-player' ),
'type' => 'color',
'description' => __( 'Base color used for depth and glow effects.', 'modern-audio-player' ),
),
),
),
'text' => array(
'title' => __( 'Text', 'modern-audio-player' ),
'description' => __( 'Typography defaults for every player instance.', 'modern-audio-player' ),
'fields' => array(
'player_font_size' => array(
'label' => __( 'Base font size', 'modern-audio-player' ),
'type' => 'range',
'description' => __( 'Applied as the root font size inside the player.', 'modern-audio-player' ),
'min' => 12,
'max' => 22,
'step' => 1,
'unit' => 'px',
),
'player_show_theme_label' => array(
'label' => __( 'Show preset label', 'modern-audio-player' ),
'type' => 'checkbox',
'description' => __( 'Displays the active preset label above the track title.', 'modern-audio-player' ),
),
),
),
'layout' => array(
'title' => __( 'Layout', 'modern-audio-player' ),
'description' => __( 'Spacing and shape tokens for the global player layout.', 'modern-audio-player' ),
'fields' => array(
'player_border_radius' => array(
'label' => __( 'Border radius', 'modern-audio-player' ),
'type' => 'range',
'description' => __( 'Rounds the player card and cover image corners.', 'modern-audio-player' ),
'min' => 0,
'max' => 40,
'step' => 1,
'unit' => 'px',
),
'player_padding' => array(
'label' => __( 'Player padding', 'modern-audio-player' ),
'type' => 'range',
'description' => __( 'Controls the internal spacing of the player shell.', 'modern-audio-player' ),
'min' => 12,
'max' => 36,
'step' => 1,
'unit' => 'px',
),
),
),
'controls' => array(
'title' => __( 'Controls', 'modern-audio-player' ),
'description' => __( 'Display options for the shared player controls and media artwork.', 'modern-audio-player' ),
'fields' => array(
'player_show_cover_image' => array(
'label' => __( 'Show cover image', 'modern-audio-player' ),
'type' => 'checkbox',
'description' => __( 'Displays the cover image whenever the player has artwork.', 'modern-audio-player' ),
),
),
),
);
}
/**
* Get preset tokens.
*
* @return array<string, array<string, mixed>>
*/
public static function get_design_presets() {
$presets = array(
'modern-dark' => array(
'label' => __( 'Modern Dark', 'modern-audio-player' ),
'description' => __( 'High-contrast dark layout with bold controls.', 'modern-audio-player' ),
'tokens' => array(
'player_background_color' => '#0f172a',
'player_surface_color' => '#111827',
'player_text_color' => '#f9fafb',
'player_muted_text_color' => '#94a3b8',
'player_accent_color' => '#7dd3fc',
'player_button_background_color' => '#7dd3fc',
'player_button_text_color' => '#0f172a',
'player_progress_background_color' => '#334155',
'player_progress_fill_color' => '#7dd3fc',
'player_border_color' => '#1f2937',
'player_shadow_color' => '#0f172a',
'player_border_radius' => 18,
'player_padding' => 16,
'player_font_size' => 16,
'player_show_cover_image' => 1,
'player_show_theme_label' => 1,
),
),
'glassmorphism' => array(
'label' => __( 'Glassmorphism', 'modern-audio-player' ),
'description' => __( 'Translucent layered card with a soft backdrop effect.', 'modern-audio-player' ),
'tokens' => array(
'player_background_color' => '#1e293b',
'player_surface_color' => '#334155',
'player_text_color' => '#f8fafc',
'player_muted_text_color' => '#cbd5e1',
'player_accent_color' => '#93c5fd',
'player_button_background_color' => '#e2e8f0',
'player_button_text_color' => '#0f172a',
'player_progress_background_color' => '#64748b',
'player_progress_fill_color' => '#93c5fd',
'player_border_color' => '#94a3b8',
'player_shadow_color' => '#0f172a',
'player_border_radius' => 24,
'player_padding' => 18,
'player_font_size' => 16,
'player_show_cover_image' => 1,
'player_show_theme_label' => 1,
),
),
'podcast-style' => array(
'label' => __( 'Podcast Style', 'modern-audio-player' ),
'description' => __( 'Editorial player treatment for spoken-word content.', 'modern-audio-player' ),
'tokens' => array(
'player_background_color' => '#201533',
'player_surface_color' => '#0f172a',
'player_text_color' => '#fef3c7',
'player_muted_text_color' => '#cbd5e1',
'player_accent_color' => '#fdba74',
'player_button_background_color' => '#f97316',
'player_button_text_color' => '#fff7ed',
'player_progress_background_color' => '#334155',
'player_progress_fill_color' => '#f97316',
'player_border_color' => '#1d4ed8',
'player_shadow_color' => '#172554',
'player_border_radius' => 20,
'player_padding' => 20,
'player_font_size' => 17,
'player_show_cover_image' => 1,
'player_show_theme_label' => 1,
),
),
);
return apply_filters( 'map_theme_presets', $presets );
}
/**
* Get the default preset slug.
*
* @return string
*/
public static function get_default_preset_slug() {
return 'modern-dark';
}
/**
* Resolve effective design settings for a preset with saved overrides.
*
* @param string $preset_slug Preset slug.
* @param array<string, mixed> $settings Optional settings array.
* @return array<string, mixed>
*/
public static function get_resolved_design_settings( $preset_slug = '', $settings = array() ) {
if ( empty( $settings ) ) {
$settings = self::get();
}
$presets = self::get_design_presets();
$preset_slug = sanitize_key( $preset_slug ? $preset_slug : $settings['default_theme'] );
if ( ! isset( $presets[ $preset_slug ] ) ) {
$preset_slug = self::get_default_preset_slug();
}
$resolved = $presets[ $preset_slug ]['tokens'];
foreach ( self::get_design_field_keys() as $field_key ) {
if ( '' !== $settings[ $field_key ] && null !== $settings[ $field_key ] ) {
$resolved[ $field_key ] = $settings[ $field_key ];
}
}
$resolved['preset_slug'] = $preset_slug;
return $resolved;
}
/**
* Build CSS variable declarations from resolved design settings.
*
* @param array<string, mixed> $design_settings Resolved settings.
* @return string
*/
public static function build_design_css_variables( $design_settings ) {
$map = array(
'--velora-bg' => sanitize_hex_color( $design_settings['player_background_color'] ),
'--velora-surface' => sanitize_hex_color( $design_settings['player_surface_color'] ),
'--velora-text' => sanitize_hex_color( $design_settings['player_text_color'] ),
'--velora-muted' => sanitize_hex_color( $design_settings['player_muted_text_color'] ),
'--velora-accent' => sanitize_hex_color( $design_settings['player_accent_color'] ),
'--velora-button-bg' => sanitize_hex_color( $design_settings['player_button_background_color'] ),
'--velora-button-text' => sanitize_hex_color( $design_settings['player_button_text_color'] ),
'--velora-progress-bg' => sanitize_hex_color( $design_settings['player_progress_background_color'] ),
'--velora-progress-fill' => sanitize_hex_color( $design_settings['player_progress_fill_color'] ),
'--velora-border' => sanitize_hex_color( $design_settings['player_border_color'] ),
'--velora-shadow' => sanitize_hex_color( $design_settings['player_shadow_color'] ),
'--velora-radius' => absint( $design_settings['player_border_radius'] ) . 'px',
'--velora-padding' => absint( $design_settings['player_padding'] ) . 'px',
'--velora-font-size' => absint( $design_settings['player_font_size'] ) . 'px',
);
$styles = array();
foreach ( $map as $name => $value ) {
$styles[] = $name . ':' . $value;
}
return implode( ';', $styles );
}
/**
* Update settings with sanitization.
*
* @param array<string, mixed> $input Raw input.
* @return array<string, mixed>
*/
public static function sanitize( $input ) {
$defaults = self::defaults();
$presets = self::get_design_presets();
$themes = array_keys( $presets );
$output = $defaults;
$output['default_theme'] = in_array( $input['default_theme'] ?? '', $themes, true ) ? $input['default_theme'] : $defaults['default_theme'];
$output['enabled_themes'] = array_values(
array_intersect(
$themes,
array_map( 'sanitize_key', (array) ( $input['enabled_themes'] ?? $defaults['enabled_themes'] ) )
)
);
if ( empty( $output['enabled_themes'] ) ) {
$output['enabled_themes'] = $defaults['enabled_themes'];
}
if ( ! in_array( $output['default_theme'], $output['enabled_themes'], true ) ) {
$output['default_theme'] = $output['enabled_themes'][0];
}
$preset_tokens = $presets[ $output['default_theme'] ]['tokens'];
foreach ( self::get_design_field_definitions() as $field_key => $field ) {
$baseline = $preset_tokens[ $field_key ];
$sanitized = self::sanitize_field_value( $field_key, $field, $input, $baseline );
$output[ $field_key ] = self::values_match( $sanitized, $baseline, $field['type'] ) ? '' : $sanitized;
}
return $output;
}
/**
* Install default settings if missing.
*
* @return void
*/
public static function maybe_install_defaults() {
if ( false === get_option( MAP_OPTION_KEY, false ) ) {
add_option( MAP_OPTION_KEY, self::defaults() );
}
}
/**
* Get the flat list of design field definitions.
*
* @return array<string, array<string, mixed>>
*/
public static function get_design_field_definitions() {
$fields = array();
foreach ( self::get_design_schema() as $section ) {
foreach ( $section['fields'] as $field_key => $field ) {
if ( 0 === strpos( $field_key, 'player_' ) ) {
$fields[ $field_key ] = $field;
}
}
}
return $fields;
}
/**
* Get the list of design field keys.
*
* @return array<int, string>
*/
public static function get_design_field_keys() {
return array_keys( self::get_design_field_definitions() );
}
/**
* Get select-ready preset options.
*
* @return array<string, string>
*/
private static function get_preset_options() {
$options = array();
foreach ( self::get_design_presets() as $slug => $preset ) {
$options[ $slug ] = $preset['label'];
}
return $options;
}
/**
* Sanitize a field value.
*
* @param string $field_key Field key.
* @param array<string, mixed> $field Field config.
* @param array<string, mixed> $input Raw input.
* @param string|int $baseline Baseline preset value.
* @return string|int
*/
private static function sanitize_field_value( $field_key, $field, $input, $baseline ) {
switch ( $field['type'] ) {
case 'color':
return self::sanitize_color( $input[ $field_key ] ?? '', (string) $baseline );
case 'checkbox':
return empty( $input[ $field_key ] ) ? 0 : 1;
case 'range':
$min = isset( $field['min'] ) ? (int) $field['min'] : 0;
$max = isset( $field['max'] ) ? (int) $field['max'] : 999;
return max( $min, min( $max, absint( $input[ $field_key ] ?? 0 ) ) );
}
return sanitize_text_field( (string) ( $input[ $field_key ] ?? '' ) );
}
/**
* Check whether two values match for override storage.
*
* @param string|int $value Value.
* @param string|int $baseline Baseline value.
* @param string $type Field type.
* @return bool
*/
private static function values_match( $value, $baseline, $type ) {
if ( 'color' === $type ) {
return strtolower( (string) $value ) === strtolower( (string) $baseline );
}
return (string) $value === (string) $baseline;
}
/**
* Sanitize a hex color.
*
* @param string $color Input color.
* @param string $fallback Fallback color.
* @return string
*/
private static function sanitize_color( $color, $fallback ) {
$sanitized = sanitize_hex_color( $color );
return $sanitized ? $sanitized : $fallback;
}
/**
* Migrate legacy visual settings into the new design keys.
*
* @param array<string, mixed> $settings Stored settings.
* @return array<string, mixed>
*/
private static function migrate_legacy_settings( $settings ) {
$legacy_map = array(
'accent_color' => 'player_accent_color',
'surface_color' => 'player_surface_color',
'text_color' => 'player_text_color',
'border_radius' => 'player_border_radius',
'show_cover_image' => 'player_show_cover_image',
);
foreach ( $legacy_map as $legacy_key => $new_key ) {
if ( ! isset( $settings[ $new_key ] ) && isset( $settings[ $legacy_key ] ) ) {
$settings[ $new_key ] = $settings[ $legacy_key ];
}
}
return $settings;
}
}

View File

@ -0,0 +1,98 @@
<?php
/**
* Shortcode registration.
*
* @package ModernAudioPlayer
*/
namespace ModernAudioPlayer;
defined( 'ABSPATH' ) || exit;
class Shortcode {
/**
* Register shortcode hooks.
*
* @return void
*/
public function register() {
add_shortcode( 'audio_player', array( $this, 'render' ) );
}
/**
* Render the shortcode.
*
* @param array<string, mixed> $atts Shortcode attributes.
* @return string
*/
public function render( $atts ) {
$atts = shortcode_atts(
array(
'src' => '',
'title' => '',
'image' => '',
'playlist' => '',
'theme' => '',
),
(array) $atts,
'audio_player'
);
$settings = Settings::get();
$attrs = array(
'src' => $atts['src'],
'title' => $atts['title'],
'image' => $atts['image'],
'playlist' => $this->parse_playlist_attribute( $atts['playlist'] ),
'theme' => $atts['theme'] ? $atts['theme'] : $settings['default_theme'],
'useGlobalTheme' => empty( $atts['theme'] ),
);
return Renderer::render_player( $attrs );
}
/**
* Parse playlist attribute into normalized track items.
*
* Supported formats:
* - JSON array of objects with src/title/image
* - Line-based entries: title|src|image
*
* @param string $playlist_raw Raw playlist value.
* @return array<int, array<string, string>>
*/
private function parse_playlist_attribute( $playlist_raw ) {
$playlist_raw = trim( html_entity_decode( (string) $playlist_raw, ENT_QUOTES, get_bloginfo( 'charset' ) ) );
if ( '' === $playlist_raw ) {
return array();
}
$decoded = json_decode( $playlist_raw, true );
if ( is_array( $decoded ) ) {
return $decoded;
}
$tracks = array();
$lines = preg_split( '/\r\n|\r|\n/', $playlist_raw );
foreach ( (array) $lines as $line ) {
$line = trim( (string) $line );
if ( '' === $line ) {
continue;
}
$parts = array_map( 'trim', explode( '|', $line ) );
$tracks[] = array(
'title' => $parts[0] ?? '',
'src' => $parts[1] ?? '',
'image' => $parts[2] ?? '',
);
}
return $tracks;
}
}

40
modern-audio-player.php Normal file
View File

@ -0,0 +1,40 @@
<?php
/**
* Plugin Name: Velora Player
* Plugin URI: https://example.com/modern-audio-player
* Description: Modern custom audio player with Gutenberg block, shortcode support, themes, and basic analytics.
* Version: 1.2.0
* Author: Codex
* Text Domain: modern-audio-player
* Domain Path: /languages
*
* @package ModernAudioPlayer
*/
defined( 'ABSPATH' ) || exit;
define( 'MAP_VERSION', '1.2.0' );
define( 'MAP_PLUGIN_FILE', __FILE__ );
define( 'MAP_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'MAP_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'MAP_OPTION_KEY', 'map_settings' );
require_once MAP_PLUGIN_DIR . 'includes/class-settings.php';
require_once MAP_PLUGIN_DIR . 'includes/class-analytics.php';
require_once MAP_PLUGIN_DIR . 'includes/class-renderer.php';
require_once MAP_PLUGIN_DIR . 'includes/class-shortcode.php';
require_once MAP_PLUGIN_DIR . 'includes/class-rest-api.php';
require_once MAP_PLUGIN_DIR . 'admin/class-admin.php';
require_once MAP_PLUGIN_DIR . 'includes/class-plugin.php';
register_activation_hook(
__FILE__,
array( '\ModernAudioPlayer\Plugin', 'activate' )
);
function map_boot_plugin() {
$plugin = new \ModernAudioPlayer\Plugin();
$plugin->init();
}
map_boot_plugin();

10
uninstall.php Normal file
View File

@ -0,0 +1,10 @@
<?php
/**
* Uninstall handler.
*
* @package ModernAudioPlayer
*/
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
delete_option( 'map_settings' );