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 +346 -42 2.4.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,15 @@
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),
75 + /* translators: %1$s is the maximum file size value, %2$s is the size unit (KB, MB, etc.) */
69 76 'file.max' => sprintf(__('The file size must be less than %1$s%2$s.', 'fluent-community'), $maxFileSize, $maxFileUnit)
70 77 ]);
71 78 // File size check
72 79 if ($error = Helper::checkUploadSizeError()) {
@@ -84,10 +91,11 @@
84 91 'driver' => 'local',
85 92 'media_path' => $file['path'],
86 93 'media_url' => $file['url'],
87 94 'settings' => [
88 - 'src' => $file['url'],
89 - 'title' => $file['original_name']
95 + 'src' => $file['url'],
96 + 'title' => $file['original_name'],
97 + 'viewType' => $this->resolveViewType($file, $mediaKind)
90 98 ]
91 99 ];
92 100
93 101 $mediaData = apply_filters('fluent_community/media_upload_data', $mediaData, $file);
@@ -124,8 +132,102 @@
124 132 ]
125 133 ];
126 134 }
127 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 +
128 230 public function getFluentPlayerContent(Request $request)
129 231 {
130 232 if (!defined('FLUENT_PLAYER_VERSION')) {
131 233 return [
@@ -133,14 +235,34 @@
133 235 ];
134 236 }
135 237 $mediaId = intval($request->get('media_id'));
136 238 $instanceKey = sanitize_key($request->get('player_instance_key'));
137 - $shareUrl = esc_url_raw($request->get('share_url'));
239 + $shareUrl = esc_url_raw((string) $request->get('share_url', ''));
138 240 $media = Media::find($mediaId);
139 241 if (!$media) {
140 - $media = (object) $request->all();
141 - } else if ($shareUrl) {
142 - $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 + }
143 265 }
144 266 return $this->generateFluentPlayerHtml($mediaId, $media, $instanceKey);
145 267 }
146 268
@@ -179,8 +301,23 @@
179 301 $mediaSettings['is_short'] = true;
180 302 }
181 303
182 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 +
183 320 $formattedMedia['settings'] = $mediaSettings;
184 321 $formattedMedia['deep_link_ref'] = $playerKey;
185 322
186 323 // Generate the HTML using fluent-player's view system
@@ -206,40 +343,42 @@
206 343 'media' => $formattedMedia
207 344 ];
208 345 }
209 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 +
210 356 private function generateFluentPlayerCustomStyle($instanceId, $settings)
211 357 {
212 358 $customCss = '';
213 - $playerWidth = Arr::get($settings, 'playerWidth');
214 - if ($playerWidth) {
359 + $isAudio = $this->isAudioMedia($settings);
360 + $playerWidth = intval(Arr::get($settings, 'playerWidth'));
361 + if ($playerWidth > 0) {
215 362 $customCss .= "
216 363 #fluent_player_" . esc_attr($instanceId) . " .fluent-player-container {
217 - width: " . esc_attr($playerWidth) . 'px' . ";
364 + width: " . $playerWidth . 'px' . ";
218 365 }
219 366 ";
220 367 }
221 - $brandingColor = Arr::get($settings, 'brandColor', '');
222 - 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) {
223 375 $customCss .= "
224 - #fluent_player_" . esc_attr($instanceId) . " {
225 - --media-brand: " . esc_attr($brandingColor) . ";
226 - }
227 - ";
228 - }
229 - $brandingColor = Arr::get($settings, 'brandColor', '');
230 - $controlBarColor = Arr::get($settings, 'controlBarColor', '');
231 - if ($brandingColor || $controlBarColor) {
232 - $customCss .= "
233 376 #fluent_player_" . esc_attr($instanceId) . " {";
234 - if ($brandingColor) {
377 + foreach ($colorVars as $varName => $varValue) {
235 378 $customCss .= "
236 - --media-brand: " . esc_attr($brandingColor) . ";";
379 + " . $varName . ": " . esc_attr($varValue) . ";";
237 380 }
238 - if ($controlBarColor) {
239 - $customCss .= "
240 - --fp-control-bar-bg: " . esc_attr($controlBarColor) . ";";
241 - }
242 381 $customCss .= "
243 382 }
244 383 ";
245 384 }
@@ -248,14 +387,179 @@
248 387 #fluent_player_" . esc_attr($instanceId) . " .fluent-player-container {
249 388 background-image: url('" . esc_url(Arr::get($settings, 'posterSrc', '')) . "');
250 389 background-size: cover;
251 390 background-position: center;
391 + min-height: 100%;
252 392 }
253 393 ";
254 394 }
255 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 +
256 560 $aspectRatio = Arr::get($settings, 'aspectRatio');
257 - if ($aspectRatio && $aspectRatio != 'original') {
561 + if ($aspectRatio && $aspectRatio != 'original' && preg_match('/^\d+:\d+$/', $aspectRatio)) {
258 562 $cssAspectRatio = preg_replace('/^(\d+):(\d+)$/', '$1/$2', $aspectRatio);
259 563 $customCss .= "
260 564 #fluent_player_" . esc_attr($instanceId) . " {
261 565 aspect-ratio: " . esc_attr($cssAspectRatio) . ";
@@ -263,11 +567,12 @@
263 567 #fluent_player_" . esc_attr($instanceId) . " media-player[data-view-type='video'] {
264 568 aspect-ratio: " . esc_attr($cssAspectRatio) . ";
265 569 }
266 570 ";
267 - } else {
571 + } else if (!$isAudio) {
268 572 $customCss .= "
269 573 #fluent_player_" . esc_attr($instanceId) . " {
574 + max-width: 100%;
270 575 min-height: 300px;
271 576 }
272 577
273 578 @media (max-width: 768px) {
@@ -292,8 +597,9 @@
292 597 'viewType' => 'video',
293 598 'brandColor' => '#4a90e2',
294 599 'aspectRatio' => 'original',
295 600 'playerWidth' => '',
601 + 'playsInline' => true
296 602 ];
297 603 $mediaSettings = Bootstrap::getSettings();
298 604 if (Arr::isTrue($mediaSettings, 'behaviors.muted_autoplay')) {
299 605 $mediaSettings['autoplay'] = true;
@@ -301,13 +607,11 @@
301 607 }
302 608 $settings['loadStrategy'] = 'idle'; // for SPA context this needs to be set to 'idle'
303 609 $settings = array_merge($settings, $mediaSettings);
304 610
305 - // Apply iOS Safari compatibility settings (playsinline, preload)
306 - // The standalone player gets these via MediaService::getIOSSafariSettings(),
307 - // but the community API path bypasses that — apply them here too.
611 + // Apply iOS Safari compatibility (playsinline, preload; muted only when autoplay is on)
308 612 if (class_exists('\FluentPlayer\App\Services\MediaService')) {
309 - [$isIosSafari, $iosSafariSettings] = \FluentPlayer\App\Services\MediaService::getIOSSafariSettings();
613 + [$isIosSafari, $iosSafariSettings] = \FluentPlayer\App\Services\MediaService::getIOSSafariSettings($settings);
310 614 if ($isIosSafari) {
311 615 $settings = array_merge($settings, $iosSafariSettings);
312 616 }
313 617 }