PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.0
2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 1.1.0 All 77 releases
← All changes | Modules/Integrations/FluentPlayer/Http/Controllers/MediaController.php +341 -38 2.6.012.10.0 View file →
@@ -2,8 +2,9 @@
2 2
3 3 namespace FluentCommunity\Modules\Integrations\FluentPlayer\Http\Controllers;
4 4
5 5 use FluentCommunity\App\Http\Controllers\Controller;
6 +use FluentCommunity\App\Models\Feed;
6 7 use FluentCommunity\App\Models\Media;
7 8 use FluentCommunity\App\Services\Helper;
8 9 use FluentCommunity\App\Services\Libs\FileSystem;
9 10 use FluentCommunity\Framework\Http\Request\Request;
@@ -42,16 +43,21 @@
42 43 'message' => __('You do not have permission to upload videos', 'fluent-community')
43 44 ]);
44 45 }
45 46
46 - $allowedVideoTypes = apply_filters('fluent_community/support_video_types', [
47 - 'video/mp4',
48 - 'video/m3u8',
49 - 'video/mpd',
50 - 'video/webm',
51 - 'video/mov',
52 - 'video/quicktime'
53 - ]);
47 + $hasAudioUpload = Arr::get($fluentPlayerSettings, 'enable_audio') === 'yes';
48 + $mediaKind = $request->getSafe('media_kind', 'sanitize_text_field', '');
49 + if (!in_array($mediaKind, ['video', 'audio'], true)) {
50 + $mediaKind = null;
51 + }
52 +
53 + if ($mediaKind === 'audio' && !$hasAudioUpload) {
54 + return $this->sendError([
55 + 'message' => __('Uploading audio files is not allowed', 'fluent-community')
56 + ]);
57 + }
58 +
59 + $allowedMediaTypes = Bootstrap::getAllowedMediaTypes($fluentPlayerSettings, $mediaKind);
54 60 $maxFileUnit = apply_filters('fluent_community/video_upload_max_file_unit', 'MB');
55 61 $maxFileSize = apply_filters('fluent_community/video_upload_max_file_size', 300);
56 62 $allowedFileSize = $maxFileSize;
57 63 if (strtoupper($maxFileUnit) == 'MB') {
@@ -59,14 +65,14 @@
59 65 } else if (strtoupper($maxFileUnit) == 'GB') {
60 66 $allowedFileSize = $maxFileSize * 1024 * 1024;
61 67 }
62 68
63 - $allowedTypesString = implode(',', $allowedVideoTypes);
69 + $allowedTypesString = implode(',', $allowedMediaTypes);
64 70
65 71 $files = $this->validate($request->files(), [
66 72 'file' => 'mimetypes:' . $allowedTypesString . '|max:' . $allowedFileSize,
67 73 ], [
68 - 'file.mimetypes' => __('The file must be a valid video type (MP4, M3U8, MPD, WebM, MOV).', 'fluent-community'),
74 + 'file.mimetypes' => $this->getMimeTypeErrorMessage($mediaKind, $hasAudioUpload),
69 75 /* translators: %1$s is the maximum file size value, %2$s is the size unit (KB, MB, etc.) */
70 76 'file.max' => sprintf(__('The file size must be less than %1$s%2$s.', 'fluent-community'), $maxFileSize, $maxFileUnit)
71 77 ]);
72 78 // File size check
@@ -85,10 +91,11 @@
85 91 'driver' => 'local',
86 92 'media_path' => $file['path'],
87 93 'media_url' => $file['url'],
88 94 'settings' => [
89 - 'src' => $file['url'],
90 - 'title' => $file['original_name']
95 + 'src' => $file['url'],
96 + 'title' => $file['original_name'],
97 + 'viewType' => $this->resolveViewType($file, $mediaKind)
91 98 ]
92 99 ];
93 100
94 101 $mediaData = apply_filters('fluent_community/media_upload_data', $mediaData, $file);
@@ -125,8 +132,102 @@
125 132 ]
126 133 ];
127 134 }
128 135
136 + /**
137 + * Whether the current user may edit an audio media's metadata.
138 + * Owner (upload sets user_id) or a moderator/site admin.
139 + */
140 + public function canEditAudioMedia($media)
141 + {
142 + if (!$media) {
143 + return false;
144 + }
145 + // Read user_id via the model accessor. A (array) cast of a WPFluent model exposes
146 + // the protected $attributes under a mangled key, not a flat user_id.
147 + if ((int) $media->user_id === (int) get_current_user_id()) {
148 + return true;
149 + }
150 + return Helper::isModerator() || Helper::isSiteAdmin();
151 + }
152 +
153 + /**
154 + * Update an audio media's display title and poster thumbnail from the composer.
155 + */
156 + public function updateAudioMeta(Request $request)
157 + {
158 + $media = Media::find(intval($request->get('media_id')));
159 + if (!$media || !$this->canEditAudioMedia($media)) {
160 + return $this->sendError([
161 + 'message' => __('You are not allowed to edit this audio', 'fluent-community'),
162 + ], 403);
163 + }
164 +
165 + // Only fluent-player audio media may be edited through this endpoint.
166 + if ($media->media_type !== 'fluent_player' || !$this->isAudioMedia((array) $media->settings)) {
167 + return $this->sendError([
168 + 'message' => __('This media is not an editable audio', 'fluent-community'),
169 + ], 422);
170 + }
171 +
172 + $settings = (array) $media->settings;
173 + $title = $request->getSafe('title', 'sanitize_text_field', '');
174 + $posterSrc = $request->getSafe('posterSrc', 'sanitize_url', '');
175 +
176 + if ($title !== '') {
177 + $settings['title'] = $title;
178 + }
179 + if ($posterSrc !== '') {
180 + $settings['posterSrc'] = $posterSrc;
181 + // poster_is_custom gates whether the render path shows the custom thumbnail
182 + // (see show_poster in generateFluentPlayerHtml).
183 + $settings['poster_is_custom'] = true;
184 + } else {
185 + $settings['posterSrc'] = '';
186 + $settings['poster_is_custom'] = false;
187 + }
188 +
189 + $media->settings = $settings;
190 + $media->save();
191 +
192 + return [
193 + 'media' => [
194 + 'media_id' => $media->id,
195 + 'settings' => $media->settings,
196 + ],
197 + ];
198 + }
199 +
200 + private function resolveViewType($file, $mediaKind)
201 + {
202 + $audioExtensions = ['mp3', 'wav', 'm4a', 'aac', 'ogg', 'oga', 'flac'];
203 + $name = Arr::get($file, 'original_name', Arr::get($file, 'url', ''));
204 + $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
205 +
206 + if (in_array($extension, $audioExtensions, true)) {
207 + return 'audio';
208 + }
209 +
210 + if ($extension) {
211 + return 'video';
212 + }
213 +
214 + return $mediaKind === 'audio' ? 'audio' : 'video';
215 + }
216 +
217 + private function getMimeTypeErrorMessage($mediaKind, $hasAudioUpload)
218 + {
219 + if ($mediaKind === 'audio') {
220 + return __('The file must be a valid audio (MP3, WAV, M4A, AAC, OGG, FLAC) type.', 'fluent-community');
221 + }
222 +
223 + if ($mediaKind === 'video' || !$hasAudioUpload) {
224 + return __('The file must be a valid video (MP4, WebM, MOV) type.', 'fluent-community');
225 + }
226 +
227 + return __('The file must be a valid video (MP4, WebM, MOV) or audio (MP3, WAV, M4A, AAC, OGG, FLAC) type.', 'fluent-community');
228 + }
229 +
129 230 public function getFluentPlayerContent(Request $request)
130 231 {
131 232 if (!defined('FLUENT_PLAYER_VERSION')) {
132 233 return [
@@ -134,14 +235,34 @@
134 235 ];
135 236 }
136 237 $mediaId = intval($request->get('media_id'));
137 238 $instanceKey = sanitize_key($request->get('player_instance_key'));
138 - $shareUrl = esc_url_raw($request->get('share_url'));
239 + $shareUrl = esc_url_raw((string) $request->get('share_url', ''));
139 240 $media = Media::find($mediaId);
140 241 if (!$media) {
141 - $media = (object) $request->all();
142 - } else if ($shareUrl) {
143 - $media->share_url = $shareUrl;
242 + // Non-DB media (external embed / share URL) from a synthetic hash id: build from
243 + // allowlisted scalars only. Never trust request `settings`/`layers` — accepting
244 + // them lets an anon caller inject shortcode layers into FluentPlayer's renderer.
245 + $media = (object) [
246 + 'url' => esc_url_raw((string) $request->get('url', '')),
247 + 'title' => sanitize_text_field($request->get('title')),
248 + 'image' => esc_url_raw((string) $request->get('image', '')),
249 + 'share_url' => esc_url_raw((string) $request->get('share_url', '')),
250 + 'provider' => sanitize_text_field($request->get('provider')),
251 + 'type' => sanitize_text_field($request->get('type')),
252 + ];
253 + } else {
254 + $currentUserId = get_current_user_id();
255 + $canView = ($currentUserId && (int) $media->user_id === $currentUserId)
256 + || ($media->feed_id && Feed::byUserAccess($currentUserId)
257 + ->byContentModerationAccessStatus($this->getUser())
258 + ->find($media->feed_id));
259 + if (!$canView) {
260 + return ['html' => ''];
261 + }
262 + if ($shareUrl) {
263 + $media->share_url = $shareUrl;
264 + }
144 265 }
145 266 return $this->generateFluentPlayerHtml($mediaId, $media, $instanceKey);
146 267 }
147 268
@@ -180,8 +301,23 @@
180 301 $mediaSettings['is_short'] = true;
181 302 }
182 303
183 304 $mediaSettings = wp_parse_args($mediaSettings, $this->getFluentplayerDefaultsSettings());
305 +
306 + if ($this->isAudioMedia($mediaSettings)) {
307 + // Only show a thumbnail block when the author uploaded one; otherwise render a
308 + // clean player with no poster (per design). poster_is_custom is set by the
309 + // audio edit modal / updateAudioMeta.
310 + $hasCustomPoster = Arr::get($mediaSettings, 'posterSrc', '') !== ''
311 + && filter_var(Arr::get($mediaSettings, 'poster_is_custom'), FILTER_VALIDATE_BOOLEAN);
312 + $mediaSettings['show_poster'] = $hasCustomPoster;
313 + // Show the inline "1x" speed control instead of the settings gear menu.
314 + if (!isset($mediaSettings['controls']) || !is_array($mediaSettings['controls'])) {
315 + $mediaSettings['controls'] = [];
316 + }
317 + $mediaSettings['controls']['settings'] = false;
318 + }
319 +
184 320 $formattedMedia['settings'] = $mediaSettings;
185 321 $formattedMedia['deep_link_ref'] = $playerKey;
186 322
187 323 // Generate the HTML using fluent-player's view system
@@ -207,40 +343,42 @@
207 343 'media' => $formattedMedia
208 344 ];
209 345 }
210 346
347 + private function isAudioMedia($settings)
348 + {
349 + if (class_exists('\FluentPlayer\App\Helpers\Helper')
350 + && method_exists('\FluentPlayer\App\Helpers\Helper', 'isAudioSource')) {
351 + return \FluentPlayer\App\Helpers\Helper::isAudioSource($settings);
352 + }
353 + return Arr::get($settings, 'viewType') === 'audio';
354 + }
355 +
211 356 private function generateFluentPlayerCustomStyle($instanceId, $settings)
212 357 {
213 358 $customCss = '';
214 - $playerWidth = Arr::get($settings, 'playerWidth');
215 - if ($playerWidth) {
359 + $isAudio = $this->isAudioMedia($settings);
360 + $playerWidth = intval(Arr::get($settings, 'playerWidth'));
361 + if ($playerWidth > 0) {
216 362 $customCss .= "
217 363 #fluent_player_" . esc_attr($instanceId) . " .fluent-player-container {
218 - width: " . esc_attr($playerWidth) . 'px' . ";
364 + width: " . $playerWidth . 'px' . ";
219 365 }
220 366 ";
221 367 }
222 - $brandingColor = Arr::get($settings, 'brandColor', '');
223 - if ($brandingColor) {
368 + $colorVars = array_filter([
369 + '--media-brand' => Bootstrap::sanitizeColor(Arr::get($settings, 'brandColor', '')),
370 + '--fp-control-bar-bg' => Bootstrap::sanitizeColor(Arr::get($settings, 'controlBarColor', '')),
371 + '--media-play-color' => Bootstrap::sanitizeColor(Arr::get($settings, 'playButtonColor', '')),
372 + '--media-play-bg' => Bootstrap::sanitizeColor(Arr::get($settings, 'playButtonBgColor', ''))
373 + ]);
374 + if ($colorVars) {
224 375 $customCss .= "
225 - #fluent_player_" . esc_attr($instanceId) . " {
226 - --media-brand: " . esc_attr($brandingColor) . ";
227 - }
228 - ";
229 - }
230 - $brandingColor = Arr::get($settings, 'brandColor', '');
231 - $controlBarColor = Arr::get($settings, 'controlBarColor', '');
232 - if ($brandingColor || $controlBarColor) {
233 - $customCss .= "
234 376 #fluent_player_" . esc_attr($instanceId) . " {";
235 - if ($brandingColor) {
377 + foreach ($colorVars as $varName => $varValue) {
236 378 $customCss .= "
237 - --media-brand: " . esc_attr($brandingColor) . ";";
379 + " . $varName . ": " . esc_attr($varValue) . ";";
238 380 }
239 - if ($controlBarColor) {
240 - $customCss .= "
241 - --fp-control-bar-bg: " . esc_attr($controlBarColor) . ";";
242 - }
243 381 $customCss .= "
244 382 }
245 383 ";
246 384 }
@@ -254,10 +392,174 @@
254 392 }
255 393 ";
256 394 }
257 395
396 + if ($isAudio) {
397 + // Audio follows the FluentCommunity color schema (Utility::getColorSchemas()).
398 + // The player renders inside the portal DOM and inherits the --fcom-* CSS vars that
399 + // generateCss() emits on body (and html.dark body), so theme/skin/dark changes recolor
400 + // audio live with no re-save. The stock "modern" audio skin is built for a dark
401 + // background (white title, translucent-white controls, brand-colored bar); we override
402 + // those to a flat, theme-surface card. Play circle + progress fill use the auto-
403 + // inverting primary_text/primary_bg pair so the same rules match a light card
404 + // (dark on light) and a dark card (light on dark) without per-theme branching.
405 + $playerId = '#fluent_player_' . esc_attr($instanceId);
406 + $customCss .= "
407 + {$playerId} {
408 + --media-brand: var(--fcom-primary-text, #19283a);
409 + --fp-control-bar-bg: transparent;
410 + }
411 + {$playerId} .fp-audio-layout {
412 + background-color: var(--fcom-secondary-content-bg, var(--fcom-active-bg, #f0f2f5));
413 + border-radius: 12px;
414 + }
415 + {$playerId} .fp-audio-main-controls {
416 + background: transparent;
417 + }
418 + {$playerId} .fp-audio-title {
419 + color: var(--fcom-primary-text, #19283a);
420 + font-size: 14px;
421 + font-weight: 600;
422 + }
423 + {$playerId} .fp-audio-subtitle {
424 + color: var(--fcom-primary-text, #19283a);
425 + }
426 + {$playerId} media-play-button {
427 + background: var(--fcom-primary-text, #19283a);
428 + }
429 + {$playerId} media-play-button media-icon {
430 + color: var(--fcom-primary-bg, #ffffff);
431 + }
432 + {$playerId} media-seek-button {
433 + background: transparent;
434 + }
435 + {$playerId} media-time-slider .fp-media-slider-track {
436 + background: var(--fcom-secondary-border, #9CA3AF);
437 + background: color-mix(in srgb, var(--fcom-primary-text, #19283a) 20%, transparent);
438 + }
439 + {$playerId} media-time-slider .fp-media-slider-track-fill {
440 + background: var(--fcom-primary-text, #19283a);
441 + }
442 + {$playerId} .fp-seek-backward-10,
443 + {$playerId} .fp-seek-forward-10,
444 + {$playerId} .fp-media-mute-icon,
445 + {$playerId} .fp-media-volume-low-icon,
446 + {$playerId} .fp-media-volume-high-icon,
447 + {$playerId} media-seek-button media-icon,
448 + {$playerId} media-icon[type=\"odometer\"],
449 + {$playerId} media-icon[type=\"settings\"] {
450 + color: var(--fcom-secondary-text, #525866);
451 + }
452 + {$playerId} .fp-media-time-group,
453 + {$playerId} .fp-media-time-group media-time,
454 + {$playerId} .fp-media-time-divider {
455 + color: var(--fcom-primary-text, #19283a);
456 + }
457 + ";
458 +
459 + // Exact layout: large play circle on the left (spanning title + controls),
460 + // a single control row (seek / seek / volume / speed / time) and a full-width
461 + // progress bar at the bottom. The volume slider is hidden until hover.
462 + $customCss .= "
463 + {$playerId} .fp-audio-layout {
464 + position: relative;
465 + padding: 22px 24px 14px;
466 + }
467 + {$playerId} .fp-audio-layout .fp-audio-content {
468 + padding: 0 0 0 82px;
469 + gap: 6px;
470 + width: 100%;
471 + }
472 + {$playerId} .fp-audio-header {
473 + padding-left: 8px;
474 + }
475 + {$playerId} media-play-button {
476 + position: absolute;
477 + left: 22px;
478 + top: 50%;
479 + transform: translateY(-50%);
480 + width: 64px;
481 + height: 64px;
482 + min-width: 64px;
483 + }
484 + {$playerId} .fp-audio-main-controls {
485 + display: grid;
486 + grid-template-columns: max-content max-content max-content max-content 1fr max-content;
487 + grid-template-areas: \"sb sf vol spd play time\" \"pr pr pr pr pr pr\";
488 + align-items: center;
489 + column-gap: 0;
490 + row-gap: 6px;
491 + padding: 0;
492 + width: 100%;
493 + }
494 + {$playerId} .fp-audio-main-controls media-tooltip:nth-of-type(1) { grid-area: sb; margin: 0; }
495 + {$playerId} .fp-audio-main-controls media-tooltip:nth-of-type(2) { grid-area: play; }
496 + {$playerId} .fp-audio-main-controls media-tooltip:nth-of-type(3) { grid-area: sf; margin: 0; }
497 + {$playerId} media-seek-button,
498 + {$playerId} .fp-audio-volume-group media-mute-button {
499 + width: 32px;
500 + height: 32px;
501 + min-width: 32px;
502 + }
503 + {$playerId} .fp-audio-secondary-controls media-menu-button { min-width: 32px; padding: 0 6px; }
504 + {$playerId} .fp-audio-volume-group { grid-area: vol; margin: 0; overflow: visible; }
505 + {$playerId} .fp-audio-secondary-controls { grid-area: spd; margin: 0; }
506 + {$playerId} .fp-media-time-group { grid-area: time; margin: 0; justify-self: end; }
507 + {$playerId} media-player[data-view-type=\"audio\"] media-time-slider,
508 + {$playerId} media-time-slider { grid-area: pr; margin: 8px; }
509 + {$playerId} media-seek-button:hover,
510 + {$playerId} .fp-audio-volume-group media-mute-button:hover,
511 + {$playerId} .fp-audio-secondary-controls media-menu-button:hover {
512 + background: transparent;
513 + }
514 + {$playerId} .fp-audio-volume-group media-volume-slider {
515 + width: 0;
516 + max-width: 0;
517 + min-width: 0;
518 + opacity: 0;
519 + overflow: hidden;
520 + margin: 0;
521 + transition: width .2s ease, opacity .2s ease, margin .2s ease;
522 + }
523 + {$playerId} .fp-audio-volume-group:hover media-volume-slider,
524 + {$playerId} .fp-audio-volume-group:focus-within media-volume-slider {
525 + width: 80px;
526 + max-width: 80px;
527 + opacity: 1;
528 + margin-left: 6px;
529 + }
530 + {$playerId} .fp-speed-menu .fcom_speed_label {
531 + font-weight: 600;
532 + font-size: 15px;
533 + line-height: 1;
534 + color: var(--fcom-primary-text, #19283a);
535 + }
536 + ";
537 +
538 + // When a custom thumbnail is set, lay the card out as poster -> play -> content
539 + // (matching the reference). The poster sits on the left, the play circle after it.
540 + $customCss .= "
541 + {$playerId} .fp-audio-layout:not(.fp-audio-no-poster) {
542 + gap: 16px;
543 + padding-left: 16px;
544 + }
545 + {$playerId} .fp-audio-layout:not(.fp-audio-no-poster) .fp-audio-poster {
546 + width: 96px;
547 + height: 96px;
548 + min-width: 96px;
549 + border-radius: 12px;
550 + }
551 + {$playerId} .fp-audio-layout:not(.fp-audio-no-poster) media-play-button {
552 + left: 128px;
553 + }
554 + {$playerId} .fp-audio-layout:not(.fp-audio-no-poster) .fp-audio-content {
555 + padding-left: 82px;
556 + }
557 + ";
558 + }
559 +
258 560 $aspectRatio = Arr::get($settings, 'aspectRatio');
259 - if ($aspectRatio && $aspectRatio != 'original') {
561 + if ($aspectRatio && $aspectRatio != 'original' && preg_match('/^\d+:\d+$/', $aspectRatio)) {
260 562 $cssAspectRatio = preg_replace('/^(\d+):(\d+)$/', '$1/$2', $aspectRatio);
261 563 $customCss .= "
262 564 #fluent_player_" . esc_attr($instanceId) . " {
263 565 aspect-ratio: " . esc_attr($cssAspectRatio) . ";
@@ -265,9 +567,9 @@
265 567 #fluent_player_" . esc_attr($instanceId) . " media-player[data-view-type='video'] {
266 568 aspect-ratio: " . esc_attr($cssAspectRatio) . ";
267 569 }
268 570 ";
269 - } else {
571 + } else if (!$isAudio) {
270 572 $customCss .= "
271 573 #fluent_player_" . esc_attr($instanceId) . " {
272 574 max-width: 100%;
273 575 min-height: 300px;
@@ -295,8 +597,9 @@
295 597 'viewType' => 'video',
296 598 'brandColor' => '#4a90e2',
297 599 'aspectRatio' => 'original',
298 600 'playerWidth' => '',
601 + 'playsInline' => true
299 602 ];
300 603 $mediaSettings = Bootstrap::getSettings();
301 604 if (Arr::isTrue($mediaSettings, 'behaviors.muted_autoplay')) {
302 605 $mediaSettings['autoplay'] = true;