velora version 1.0.0
This commit is contained in:
commit
b7c0d49d63
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal 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
33
IMPLEMENTATION-CHANGES.md
Normal 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
362
README.md
Normal 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
488
admin/class-admin.php
Normal 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
|
||||
}
|
||||
}
|
||||
361
assets/css/admin-settings.css
Normal file
361
assets/css/admin-settings.css
Normal 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
8
assets/css/editor.css
Normal 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
445
assets/css/player.css
Normal 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
170
assets/js/admin-settings.js
Normal 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 ) );
|
||||
18
assets/js/block-editor.asset.php
Normal file
18
assets/js/block-editor.asset.php
Normal 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
221
assets/js/block-editor.js
Normal 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 );
|
||||
11
assets/js/player.asset.php
Normal file
11
assets/js/player.asset.php
Normal 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
382
assets/js/player.js
Normal 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
46
blocks/block.json
Normal 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"
|
||||
}
|
||||
174
includes/class-analytics.php
Normal file
174
includes/class-analytics.php
Normal 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
142
includes/class-plugin.php
Normal 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
348
includes/class-renderer.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
98
includes/class-rest-api.php
Normal file
98
includes/class-rest-api.php
Normal 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
516
includes/class-settings.php
Normal 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;
|
||||
}
|
||||
}
|
||||
98
includes/class-shortcode.php
Normal file
98
includes/class-shortcode.php
Normal 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
40
modern-audio-player.php
Normal 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
10
uninstall.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* Uninstall handler.
|
||||
*
|
||||
* @package ModernAudioPlayer
|
||||
*/
|
||||
|
||||
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
|
||||
|
||||
delete_option( 'map_settings' );
|
||||
Loading…
Reference in New Issue
Block a user