From b7c0d49d6386b5d778ed35e6b392e376b5fade74 Mon Sep 17 00:00:00 2001 From: diyaa Date: Mon, 23 Mar 2026 16:55:03 +0100 Subject: [PATCH] velora version 1.0.0 --- .gitignore | 37 +++ IMPLEMENTATION-CHANGES.md | 33 ++ README.md | 362 ++++++++++++++++++++++ admin/class-admin.php | 488 +++++++++++++++++++++++++++++ assets/css/admin-settings.css | 361 +++++++++++++++++++++ assets/css/editor.css | 8 + assets/css/player.css | 445 ++++++++++++++++++++++++++ assets/js/admin-settings.js | 170 ++++++++++ assets/js/block-editor.asset.php | 18 ++ assets/js/block-editor.js | 221 +++++++++++++ assets/js/player.asset.php | 11 + assets/js/player.js | 382 +++++++++++++++++++++++ blocks/block.json | 46 +++ includes/class-analytics.php | 174 +++++++++++ includes/class-plugin.php | 142 +++++++++ includes/class-renderer.php | 348 +++++++++++++++++++++ includes/class-rest-api.php | 98 ++++++ includes/class-settings.php | 516 +++++++++++++++++++++++++++++++ includes/class-shortcode.php | 98 ++++++ modern-audio-player.php | 40 +++ uninstall.php | 10 + 21 files changed, 4008 insertions(+) create mode 100644 .gitignore create mode 100644 IMPLEMENTATION-CHANGES.md create mode 100644 README.md create mode 100644 admin/class-admin.php create mode 100644 assets/css/admin-settings.css create mode 100644 assets/css/editor.css create mode 100644 assets/css/player.css create mode 100644 assets/js/admin-settings.js create mode 100644 assets/js/block-editor.asset.php create mode 100644 assets/js/block-editor.js create mode 100644 assets/js/player.asset.php create mode 100644 assets/js/player.js create mode 100644 blocks/block.json create mode 100644 includes/class-analytics.php create mode 100644 includes/class-plugin.php create mode 100644 includes/class-renderer.php create mode 100644 includes/class-rest-api.php create mode 100644 includes/class-settings.php create mode 100644 includes/class-shortcode.php create mode 100644 modern-audio-player.php create mode 100644 uninstall.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..055e459 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/IMPLEMENTATION-CHANGES.md b/IMPLEMENTATION-CHANGES.md new file mode 100644 index 0000000..a45d019 --- /dev/null +++ b/IMPLEMENTATION-CHANGES.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..140c591 --- /dev/null +++ b/README.md @@ -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` + +ويمكن استخدامه لاحقًا لتنظيف بيانات الإضافة عند الحذف النهائي إذا أردت توسيع سلوك الإزالة. + +## ملخص + +هذه الإضافة مناسبة عندما تحتاج إلى: + +- مشغّل صوت مخصص بدل المشغّل الافتراضي +- دعم للمحرر الحديث +- تخصيص مظهر عام على مستوى الموقع +- تتبّع بسيط لمرات التشغيل بدون نظام تحليلات معقّد diff --git a/admin/class-admin.php b/admin/class-admin.php new file mode 100644 index 0000000..0365c03 --- /dev/null +++ b/admin/class-admin.php @@ -0,0 +1,488 @@ + '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']; + ?> +
+

+

+ +

+ + +

+ + + +

+ + +
+
+ + +
+
+
+ +

+

+
+ +
+ + + + +
+
+ + + +
+ $section ) : ?> +
+
+

+

+
+ +
+ $field ) : ?> +
+
+ + render_field( $field_key, $field, $settings, $resolved_design ); ?> +
+ +

+ +
+ +
+
+ +
+ + +
+
+ + +
+
+ $field Field definition. + * @param array $settings Saved settings. + * @param array $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': + ?> + + +
+ + + + +
+ + + +
+ $option_label ) : ?> + + +
+ + +

+ +

+ +

+ +
+

+
+
+ +
+
+
+ +
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
track_title ? $track->track_title : __( 'Untitled Track', 'modern-audio-player' ) ); ?> + + audio_src ); ?> + + play_count ) ); ?>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' ); ?>
+
+ array( + 'wp-blocks', + 'wp-block-editor', + 'wp-components', + 'wp-element', + 'wp-i18n', + 'wp-server-side-render', + ), + 'version' => '1.0.0', +); diff --git a/assets/js/block-editor.js b/assets/js/block-editor.js new file mode 100644 index 0000000..a8b9975 --- /dev/null +++ b/assets/js/block-editor.js @@ -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 ); diff --git a/assets/js/player.asset.php b/assets/js/player.asset.php new file mode 100644 index 0000000..fb87548 --- /dev/null +++ b/assets/js/player.asset.php @@ -0,0 +1,11 @@ + array(), + 'version' => '1.0.0', +); diff --git a/assets/js/player.js b/assets/js/player.js new file mode 100644 index 0000000..44317cb --- /dev/null +++ b/assets/js/player.js @@ -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(); + } +} )(); diff --git a/blocks/block.json b/blocks/block.json new file mode 100644 index 0000000..8056214 --- /dev/null +++ b/blocks/block.json @@ -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" +} diff --git a/includes/class-analytics.php b/includes/class-analytics.php new file mode 100644 index 0000000..a9fecbd --- /dev/null +++ b/includes/class-analytics.php @@ -0,0 +1,174 @@ +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 + */ + 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 + */ + 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, + ); + } +} diff --git a/includes/class-plugin.php b/includes/class-plugin.php new file mode 100644 index 0000000..782498b --- /dev/null +++ b/includes/class-plugin.php @@ -0,0 +1,142 @@ +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 $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' ), + ) + ); + } + +} diff --git a/includes/class-renderer.php b/includes/class-renderer.php new file mode 100644 index 0000000..aafd19b --- /dev/null +++ b/includes/class-renderer.php @@ -0,0 +1,348 @@ + $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(); + ?> +
+ + +
+
+ +
+ +
+ +

+ +
+ +
+
+ + +
+ + + +
+
+ +
+ +
+ + +
+
+
+ + +
+
+ > + */ + public static function get_theme_presets() { + return Settings::get_design_presets(); + } + + /** + * Normalize and sanitize player attributes. + * + * @param array $attributes Raw attributes. + * @param array $settings Global settings. + * @return array + */ + 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 $design_settings Resolved design settings. + * @return string + */ + private static function render_placeholder( $theme_slug, $theme_name, $style_attr, $design_settings ) { + ob_start(); + ?> +
+
+
+ +
+ +
+

+
+
+
+ $args Normalized player arguments. + * @return array> + */ + 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 $track Track data. + * @return array + */ + 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> $playlist Playlist items. + * @return array> + */ + 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 ); + } + } +} diff --git a/includes/class-rest-api.php b/includes/class-rest-api.php new file mode 100644 index 0000000..cd338fc --- /dev/null +++ b/includes/class-rest-api.php @@ -0,0 +1,98 @@ + '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 + ); + } +} diff --git a/includes/class-settings.php b/includes/class-settings.php new file mode 100644 index 0000000..556b4f9 --- /dev/null +++ b/includes/class-settings.php @@ -0,0 +1,516 @@ + + */ + 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 + */ + 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> + */ + 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> + */ + 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 $settings Optional settings array. + * @return array + */ + 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 $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 $input Raw input. + * @return array + */ + 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> + */ + 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 + */ + public static function get_design_field_keys() { + return array_keys( self::get_design_field_definitions() ); + } + + /** + * Get select-ready preset options. + * + * @return array + */ + 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 $field Field config. + * @param array $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 $settings Stored settings. + * @return array + */ + 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; + } +} diff --git a/includes/class-shortcode.php b/includes/class-shortcode.php new file mode 100644 index 0000000..5237dc7 --- /dev/null +++ b/includes/class-shortcode.php @@ -0,0 +1,98 @@ + $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> + */ + 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; + } +} diff --git a/modern-audio-player.php b/modern-audio-player.php new file mode 100644 index 0000000..64300de --- /dev/null +++ b/modern-audio-player.php @@ -0,0 +1,40 @@ +init(); +} + +map_boot_plugin(); diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..d7406c8 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,10 @@ +