From 602654b99b33ee8c29da080058a0aaea976cd484 Mon Sep 17 00:00:00 2001 From: Yassine Doghri <yassine@doghri.fr> Date: Sun, 2 Jan 2022 14:11:05 +0000 Subject: [PATCH] fix(audio-clipper): add mouse position offset when stretching clip to prevent content from jumping update Forms.Section component to adapt to full width --- app/Helpers/components_helper.php | 8 +- .../MediaClipper/Config/MediaClipper.php | 2 +- app/Resources/js/admin.ts | 2 + app/Resources/js/modules/VideoClipBuilder.ts | 70 +++++++ app/Resources/js/modules/audio-clipper.ts | 172 +++++++++++++++--- .../js/modules/video-clip-previewer.ts | 90 ++++++++- .../Components/Forms/ColorRadioButton.php | 16 +- app/Views/Components/Forms/RadioButton.php | 18 +- app/Views/Components/Forms/Section.php | 2 +- modules/Admin/Config/Routes.php | 8 + .../Controllers/VideoClipsController.php | 38 +++- modules/Admin/Language/en/VideoClip.php | 9 + modules/Admin/Language/fr/VideoClip.php | 9 + themes/cp_admin/_partials/_nav_header.php | 2 +- themes/cp_admin/episode/create.php | 2 +- themes/cp_admin/episode/edit.php | 2 +- themes/cp_admin/episode/persons.php | 2 +- themes/cp_admin/episode/soundbites.php | 2 +- themes/cp_admin/episode/video_clips_list.php | 5 + themes/cp_admin/episode/video_clips_new.php | 90 ++++----- .../episode/video_clips_requirements.php | 29 +++ themes/cp_admin/fediverse/blocked_actors.php | 1 + themes/cp_admin/podcast/create.php | 2 +- themes/cp_admin/podcast/edit.php | 2 +- themes/cp_admin/podcast/import.php | 2 +- themes/cp_admin/podcast/persons.php | 2 +- themes/cp_admin/settings/general.php | 4 +- themes/cp_admin/settings/theme.php | 2 +- themes/cp_app/_admin_navbar.php | 2 +- 29 files changed, 491 insertions(+), 104 deletions(-) create mode 100644 app/Resources/js/modules/VideoClipBuilder.ts create mode 100644 themes/cp_admin/episode/video_clips_requirements.php diff --git a/app/Helpers/components_helper.php b/app/Helpers/components_helper.php index 89c5a121a7..2d12bc3dee 100644 --- a/app/Helpers/components_helper.php +++ b/app/Helpers/components_helper.php @@ -85,7 +85,13 @@ if (! function_exists('data_table')) { $table->addRow($rowData); } } else { - return lang('Common.no_data'); + $table->addRow([ + [ + 'colspan' => count($tableHeaders), + 'class' => 'px-4 py-2 italic font-semibold text-center', + 'data' => lang('Common.no_data'), + ], + ]); } return '<div class="overflow-x-auto rounded-lg bg-elevated border-3 border-subtle ' . $class . '" >' . diff --git a/app/Libraries/MediaClipper/Config/MediaClipper.php b/app/Libraries/MediaClipper/Config/MediaClipper.php index e300f24db0..f4274d6de2 100644 --- a/app/Libraries/MediaClipper/Config/MediaClipper.php +++ b/app/Libraries/MediaClipper/Config/MediaClipper.php @@ -213,7 +213,7 @@ class MediaClipper extends BaseConfig 'rescaleHeight' => 1200, 'x' => 0, 'y' => 600, - 'mask' => APPPATH . 'Libraries/MediaClipper/soundwaves-mask-square.png', + 'mask' => APPPATH . 'Libraries/MediaClipper/soundwaves-mask-squared.png', ], 'subtitles' => [ 'fontsize' => 20, diff --git a/app/Resources/js/admin.ts b/app/Resources/js/admin.ts index a626874e5c..310cc336b8 100644 --- a/app/Resources/js/admin.ts +++ b/app/Resources/js/admin.ts @@ -19,6 +19,7 @@ import ThemePicker from "./modules/ThemePicker"; import Time from "./modules/Time"; import Tooltip from "./modules/Tooltip"; import "./modules/video-clip-previewer"; +import VideoClipBuilder from "./modules/VideoClipBuilder"; import "./modules/xml-editor"; Dropdown(); @@ -35,3 +36,4 @@ Clipboard(); ThemePicker(); PublishMessageWarning(); HotKeys(); +VideoClipBuilder(); diff --git a/app/Resources/js/modules/VideoClipBuilder.ts b/app/Resources/js/modules/VideoClipBuilder.ts new file mode 100644 index 0000000000..11c1a637fb --- /dev/null +++ b/app/Resources/js/modules/VideoClipBuilder.ts @@ -0,0 +1,70 @@ +const VideoClipBuilder = (): void => { + const form = document.querySelector("form[id=new-video-clip-form]"); + + if (form) { + const videoClipPreviewer = form?.querySelector("video-clip-previewer"); + + if (videoClipPreviewer) { + const themeOptions: NodeListOf<HTMLInputElement> = form.querySelectorAll( + 'input[name="theme"]' + ) as NodeListOf<HTMLInputElement>; + const formatOptions: NodeListOf<HTMLInputElement> = form.querySelectorAll( + 'input[name="format"]' + ) as NodeListOf<HTMLInputElement>; + + const titleInput = form.querySelector( + 'input[name="label"]' + ) as HTMLInputElement; + if (titleInput) { + videoClipPreviewer.setAttribute("title", titleInput.value || ""); + titleInput.addEventListener("input", () => { + videoClipPreviewer.setAttribute("title", titleInput.value || ""); + }); + } + + let format = ( + form.querySelector('input[name="format"]:checked') as HTMLInputElement + )?.value; + videoClipPreviewer.setAttribute("format", format); + const watchFormatChange = (event: Event) => { + format = (event.target as HTMLInputElement).value; + videoClipPreviewer.setAttribute("format", format); + }; + for (let i = 0; i < formatOptions.length; i++) { + formatOptions[i].addEventListener("change", watchFormatChange); + } + + let theme = form + .querySelector('input[name="theme"]:checked') + ?.parentElement?.style.getPropertyValue("--color-accent-base"); + videoClipPreviewer.setAttribute("theme", theme || ""); + + const watchThemeChange = (event: Event) => { + theme = + ( + event.target as HTMLInputElement + ).parentElement?.style.getPropertyValue("--color-accent-base") ?? + theme; + videoClipPreviewer.setAttribute("theme", theme || ""); + }; + for (let i = 0; i < themeOptions.length; i++) { + themeOptions[i].addEventListener("change", watchThemeChange); + } + + const durationInput = form.querySelector( + 'input[name="duration"]' + ) as HTMLInputElement; + if (durationInput) { + videoClipPreviewer.setAttribute("duration", durationInput.value || "0"); + durationInput.addEventListener("change", () => { + videoClipPreviewer.setAttribute( + "duration", + durationInput.value || "0" + ); + }); + } + } + } +}; + +export default VideoClipBuilder; diff --git a/app/Resources/js/modules/audio-clipper.ts b/app/Resources/js/modules/audio-clipper.ts index 97549f7e21..e77b4d2e5c 100644 --- a/app/Resources/js/modules/audio-clipper.ts +++ b/app/Resources/js/modules/audio-clipper.ts @@ -3,17 +3,23 @@ import { customElement, property, query, + queryAll, queryAssignedNodes, state, } from "lit/decorators.js"; import WaveSurfer from "wavesurfer.js"; -enum ACTIONS { +enum ActionType { StretchLeft, StretchRight, Seek, } +interface Action { + type: ActionType; + payload?: any; +} + interface EventElement { events: string[]; onEvent: EventListener; @@ -51,6 +57,9 @@ export class AudioClipper extends LitElement { @query(".buffering-bar") _bufferingBarNode!: HTMLCanvasElement; + @queryAll(".slider__segment-handle") + _segmentHandleNodes!: NodeListOf<HTMLButtonElement>; + @property({ type: Number, attribute: "start-time" }) initStartTime = 0; @@ -76,7 +85,7 @@ export class AudioClipper extends LitElement { }; @state() - _action: ACTIONS | null = null; + _action: Action | null = null; @state() _audioDuration = 0; @@ -115,7 +124,7 @@ export class AudioClipper extends LitElement { onEvent: () => { if (this._action !== null) { document.body.style.cursor = ""; - if (this._action === ACTIONS.Seek && this._seekingTime) { + if (this._action.type === ActionType.Seek && this._seekingTime) { this._audio[0].currentTime = this._seekingTime; this._seekingTime = 0; } @@ -193,6 +202,31 @@ export class AudioClipper extends LitElement { }, ]; + _segmentHandleEvents: EventElement[] = [ + { + events: ["mouseenter", "focus"], + onEvent: (event: Event) => { + const timeInfoElement = ( + event.target as HTMLButtonElement + ).querySelector("span"); + if (timeInfoElement) { + timeInfoElement.style.opacity = "1"; + } + }, + }, + { + events: ["mouseleave", "blur"], + onEvent: (event: Event) => { + const timeInfoElement = ( + event.target as HTMLButtonElement + ).querySelector("span"); + if (timeInfoElement) { + timeInfoElement.style.opacity = "0"; + } + }, + }, + ]; + connectedCallback(): void { super.connectedCallback(); @@ -249,6 +283,14 @@ export class AudioClipper extends LitElement { this._audio[0].addEventListener(name, event.onEvent); }); } + + for (const event of this._segmentHandleEvents) { + event.events.forEach((name) => { + for (let i = 0; i < this._segmentHandleNodes.length; i++) { + this._segmentHandleNodes[i].addEventListener(name, event.onEvent); + } + }); + } } removeEventListeners(): void { @@ -269,6 +311,14 @@ export class AudioClipper extends LitElement { this._audio[0].removeEventListener(name, event.onEvent); }); } + + for (const event of this._segmentHandleEvents) { + event.events.forEach((name) => { + for (let i = 0; i < this._segmentHandleNodes.length; i++) { + this._segmentHandleNodes[i].addEventListener(name, event.onEvent); + } + }); + } } setSegmentPosition(): void { @@ -300,6 +350,7 @@ export class AudioClipper extends LitElement { this._durationInput[0].value = ( this._clip.endTime - this._clip.startTime ).toFixed(3); + this._durationInput[0].dispatchEvent(new Event("change")); this._audio[0].currentTime = this._clip.startTime; } if (_changedProperties.has("_seekingTime")) { @@ -318,15 +369,20 @@ export class AudioClipper extends LitElement { } private updatePosition(event: MouseEvent): void { + if (this._action === null) { + return; + } + const cursorPosition = - event.clientX - + event.clientX + + (this._action.payload?.offset || 0) - (this._sliderNode.getBoundingClientRect().left + document.documentElement.scrollLeft); const seconds = this.getSecondsFromPosition(cursorPosition); - switch (this._action) { - case ACTIONS.StretchLeft: { + switch (this._action.type) { + case ActionType.StretchLeft: { let startTime = 0; if (seconds > 0) { if (seconds > this._clip.endTime - this.minDuration) { @@ -341,7 +397,7 @@ export class AudioClipper extends LitElement { }; break; } - case ACTIONS.StretchRight: { + case ActionType.StretchRight: { let endTime; if (seconds < this._audioDuration) { if (seconds < this._clip.startTime + this.minDuration) { @@ -359,7 +415,7 @@ export class AudioClipper extends LitElement { }; break; } - case ACTIONS.Seek: { + case ActionType.Seek: { if (seconds < this._clip.startTime) { this._seekingTime = this._clip.startTime; } else if (seconds > this._clip.endTime) { @@ -401,14 +457,23 @@ export class AudioClipper extends LitElement { this._seekingNode.style.transform = `scaleX(${seekingTimePercentage})`; } - setAction(action: ACTIONS): void { - switch (action) { - case ACTIONS.StretchLeft: - case ACTIONS.StretchRight: - document.body.style.cursor = "grabbing"; + setAction(event: MouseEvent, action: Action): void { + switch (action.type) { + case ActionType.StretchLeft: + action.payload = { + offset: + this._segmentHandleNodes[0].getBoundingClientRect().right - + event.clientX, + }; + break; + case ActionType.StretchRight: + action.payload = { + offset: + this._segmentHandleNodes[1].getBoundingClientRect().left - + event.clientX, + }; break; default: - document.body.style.cursor = "default"; break; } this._action = action; @@ -421,7 +486,7 @@ export class AudioClipper extends LitElement { trim(side: "start" | "end") { if (side === "start") { this._clip = { - startTime: this._audio[0].currentTime, + startTime: parseFloat(this._audio[0].currentTime.toFixed(3)), endTime: this._clip.endTime, }; } else { @@ -498,6 +563,7 @@ export class AudioClipper extends LitElement { margin-top: -2px; background-color: #3b82f6; border-radius: 50%; + box-shadow: 0 0 0 2px #ffffff; } .slider__segment-progress-handle::after { @@ -543,6 +609,17 @@ export class AudioClipper extends LitElement { border-radius: 0.2rem 0 0 0.2rem; } + .slider__segment .slider__segment-handle span { + opacity: 0; + pointer-events: none; + position: absolute; + left: -100%; + top: -30%; + background-color: #0f172a; + color: #ffffff; + padding: 0 0.25rem; + } + .slider__segment .clipper__handle-right { right: -1rem; border-radius: 0 0.2rem 0.2rem 0; @@ -555,7 +632,7 @@ export class AudioClipper extends LitElement { justify-content: space-between; background-color: hsl(var(--color-background-elevated)); box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); - border-radius: 0 0 0.25rem 0.25rem; + border-radius: 0 0 0.75rem 0.75rem; flex-wrap: wrap; gap: 0.5rem; } @@ -587,6 +664,39 @@ export class AudioClipper extends LitElement { border-radius: 9999px; border: none; padding: 0.25rem 0.5rem; + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + } + + .toolbar button:hover { + background-color: hsl(var(--color-accent-hover)); + } + + .toolbar button:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 + var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 + calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), + 0 0 rgba(0, 0, 0, 0); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), + 0 0 rgba(0, 0, 0, 0); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), + var(--tw-shadow, 0 0 rgba(0, 0, 0, 0)); + --tw-ring-offset-width: 2px; + --tw-ring-opacity: 1; + --tw-ring-color: hsl(var(--color-accent-base) / var(--tw-ring-opacity)); + --tw-ring-offset-color: hsl(var(--color-background-base)); + } + + .toolbar__trim-controls button { + font-weight: 600; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, + Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, BlinkMacSystemFont, + "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; } .animate-spin { @@ -614,6 +724,11 @@ export class AudioClipper extends LitElement { accent-color: hsl(var(--color-accent-base)); width: 100px; } + + time { + font-size: 0.875rem; + font-family: "Mono"; + } `; render(): TemplateResult<1> { @@ -627,25 +742,33 @@ export class AudioClipper extends LitElement { <div class="slider__segment--wrapper"> <div class="slider__segment-progress-handle" - @mousedown="${() => this.setAction(ACTIONS.Seek)}" + @mousedown="${(event: MouseEvent) => + this.setAction(event, { type: ActionType.Seek })}" ></div> <div class="slider__segment"> <button class="slider__segment-handle clipper__handle-left" - title="${this.secondsToHHMMSS(this._clip.startTime)}" - @mousedown="${() => this.setAction(ACTIONS.StretchLeft)}" - ></button> + @mousedown="${(event: MouseEvent) => + this.setAction(event, { + type: ActionType.StretchLeft, + })}" + > + <span>${this.secondsToHHMMSS(this._clip.startTime)}</span> + </button> <div class="slider__seeking-placeholder"></div> <div class="slider__segment-content" - @mousedown="${() => this.setAction(ACTIONS.Seek)}" + @mousedown="${(event: MouseEvent) => + this.setAction(event, { type: ActionType.Seek })}" @click="${(event: MouseEvent) => this.goTo(event)}" ></div> <button class="slider__segment-handle clipper__handle-right" - title="${this.secondsToHHMMSS(this._clip.endTime)}" - @mousedown="${() => this.setAction(ACTIONS.StretchRight)}" - ></button> + @mousedown="${(event: MouseEvent) => + this.setAction(event, { type: ActionType.StretchRight })}" + > + <span>${this.secondsToHHMMSS(this._clip.endTime)}</span> + </button> </div> </div> </div> @@ -727,6 +850,7 @@ export class AudioClipper extends LitElement { @change="${this.setVolume}" /> </div> + <time>${this.secondsToHHMMSS(this._currentTime)}</time> </div> <div class="toolbar__trim-controls"> <button @click="${() => this.trim("start")}">Trim start</button> diff --git a/app/Resources/js/modules/video-clip-previewer.ts b/app/Resources/js/modules/video-clip-previewer.ts index 1b9b3dc805..faa138af87 100644 --- a/app/Resources/js/modules/video-clip-previewer.ts +++ b/app/Resources/js/modules/video-clip-previewer.ts @@ -1,5 +1,10 @@ import { css, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, queryAssignedNodes } from "lit/decorators.js"; +import { + customElement, + property, + queryAssignedNodes, + state, +} from "lit/decorators.js"; import { styleMap } from "lit/directives/style-map.js"; enum VideoFormats { @@ -17,40 +22,115 @@ const formatMap = { @customElement("video-clip-previewer") export class VideoClipPreviewer extends LitElement { @queryAssignedNodes("preview_image", true) - _previewImage!: NodeListOf<HTMLImageElement>; + _image!: NodeListOf<HTMLImageElement>; @property() - format: VideoFormats = VideoFormats.Landscape; + title = ""; @property() - theme = "#009486"; + format: VideoFormats = VideoFormats.Portrait; + + @property() + theme = "173 44% 96%"; + + @property({ type: Number }) + duration!: number; + + @state() + _previewImage!: HTMLImageElement; + + protected firstUpdated(): void { + this._previewImage = this._image[0].cloneNode(true) as HTMLImageElement; + this._previewImage.classList.add("preview-bg"); + } + + private secondsToHHMMSS(seconds: number) { + // Adapted from https://stackoverflow.com/a/34841026 + const h = Math.floor(seconds / 3600); + const min = Math.floor(seconds / 60) % 60; + const s = seconds % 60; + + return [h, min, s] + .map((v) => (v < 10 ? "0" + v : v)) + .filter((v, i) => v !== "00" || i > 0) + .join(":"); + } static styles = css` + .metadata { + position: absolute; + top: 1rem; + left: 1.5rem; + color: #ffffff; + display: flex; + flex-direction: column; + } + + .title { + font-family: "Kumbh Sans"; + font-weight: 900; + font-size: 1.5rem; + text-shadow: 2px 3px 5px rgba(0, 0, 0, 0.5); + } + + .duration { + font-family: "Inter"; + font-weight: 600; + } + + .preview-bg { + position: absolute; + background-color: red; + width: 100%; + object-fit: cover; + filter: blur(30px); + opacity: 0.5; + } + .video-background { + position: relative; display: grid; justify-items: center; align-items: center; background-color: black; width: 100%; aspect-ratio: 16 / 9; + border-radius: 0.75rem 0.75rem 0 0; + overflow: hidden; } .video-format { + z-index: 10; display: grid; align-items: center; justify-items: center; height: 100%; + border: 4px solid hsl(0 0% 100% / 0.5); + transition: 300ms ease-in-out aspect-ratio; + } + + ::slotted(img) { + border-radius: 0.5rem; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), + 0 2px 4px -2px rgb(0 0 0 / 0.1); } `; render(): TemplateResult<1> { const styles = { aspectRatio: formatMap[this.format], - backgroundColor: this.theme, + backgroundColor: `hsl(${this.theme})`, }; return html`<div class="video-background"> + ${this._previewImage} <div class="video-format" style=${styleMap(styles)}> + <div class="metadata"> + <span class="title">${this.title}</span> + <time datetime="PT${this.duration}S" class="duration" + >${this.secondsToHHMMSS(Math.floor(this.duration))}</time + > + </div> <slot name="preview_image"></slot> </div> </div>`; diff --git a/app/Views/Components/Forms/ColorRadioButton.php b/app/Views/Components/Forms/ColorRadioButton.php index a3454f54ba..470200efb9 100644 --- a/app/Views/Components/Forms/ColorRadioButton.php +++ b/app/Views/Components/Forms/ColorRadioButton.php @@ -17,12 +17,18 @@ class ColorRadioButton extends FormComponent public function render(): string { + $data = [ + 'id' => $this->value, + 'name' => $this->name, + 'class' => 'color-radio-btn', + ]; + + if ($this->required) { + $data['required'] = 'required'; + } + $radioInput = form_radio( - [ - 'id' => $this->value, - 'name' => $this->name, - 'class' => 'color-radio-btn', - ], + $data, $this->value, old($this->name) ? old($this->name) === $this->value : $this->isChecked, ); diff --git a/app/Views/Components/Forms/RadioButton.php b/app/Views/Components/Forms/RadioButton.php index f7d7015a11..1772c3d4b0 100644 --- a/app/Views/Components/Forms/RadioButton.php +++ b/app/Views/Components/Forms/RadioButton.php @@ -17,12 +17,18 @@ class RadioButton extends FormComponent public function render(): string { + $data = [ + 'id' => $this->value, + 'name' => $this->name, + 'class' => 'form-radio-btn bg-elevated', + ]; + + if ($this->required) { + $data['required'] = 'required'; + } + $radioInput = form_radio( - [ - 'id' => $this->value, - 'name' => $this->name, - 'class' => 'form-radio-btn bg-elevated', - ], + $data, $this->value, old($this->name) ? old($this->name) === $this->value : $this->isChecked, ); @@ -30,7 +36,7 @@ class RadioButton extends FormComponent $hint = $this->hint ? hint_tooltip($this->hint, 'ml-1 text-base') : ''; return <<<HTML - <div> + <div class="{$this->class}"> {$radioInput} <label for="{$this->value}">{$this->slot}{$hint}</label> </div> diff --git a/app/Views/Components/Forms/Section.php b/app/Views/Components/Forms/Section.php index 60279a257a..cf7367448e 100644 --- a/app/Views/Components/Forms/Section.php +++ b/app/Views/Components/Forms/Section.php @@ -19,7 +19,7 @@ class Section extends Component $subtitle = $this->subtitle === null ? '' : '<p class="text-sm clear-left text-skin-muted ' . $this->subtitleClass . '">' . $this->subtitle . '</p>'; return <<<HTML - <fieldset class="w-full max-w-xl p-8 bg-elevated border-3 border-subtle rounded-xl {$this->class}"> + <fieldset class="w-full p-8 bg-elevated border-3 border-subtle rounded-xl {$this->class}"> <Heading tagName="legend" class="float-left">{$this->title}</Heading> {$subtitle} <div class="flex flex-col gap-4 py-4 clear-left">{$this->slot}</div> diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php index a41bdb5d0a..dcaf460e12 100644 --- a/modules/Admin/Config/Routes.php +++ b/modules/Admin/Config/Routes.php @@ -387,6 +387,14 @@ $routes->group( 'filter' => 'permission:podcast_episodes-edit', ], ); + $routes->get( + 'video-clips/(:num)/retry', + 'VideoClipsController::retry/$1/$2/$3', + [ + 'as' => 'video-clip-retry', + 'filter' => 'permission:podcast_episodes-edit', + ], + ); $routes->get( 'video-clips/(:num)/delete', 'VideoClipsController::delete/$1/$2/$3', diff --git a/modules/Admin/Controllers/VideoClipsController.php b/modules/Admin/Controllers/VideoClipsController.php index d24b1cccb9..712377e8b2 100644 --- a/modules/Admin/Controllers/VideoClipsController.php +++ b/modules/Admin/Controllers/VideoClipsController.php @@ -108,8 +108,6 @@ class VideoClipsController extends BaseController public function create(): string { - helper('form'); - $data = [ 'podcast' => $this->podcast, 'episode' => $this->episode, @@ -120,7 +118,22 @@ class VideoClipsController extends BaseController 1 => $this->episode->title, ]); - $this->response->setHeader('Accept-Ranges', 'bytes'); + // First, check that requirements to create a video clip are met + $ffmpeg = trim(shell_exec('type -P ffmpeg')); + $checks = [ + 'ffmpeg' => ! empty($ffmpeg), + 'gd' => extension_loaded('gd'), + 'freetype' => extension_loaded('gd') && gd_info()['FreeType Support'], + 'transcript' => $this->episode->transcript !== null, + ]; + + if (in_array(false, $checks, true)) { + $data['checks'] = $checks; + + return view('episode/video_clips_requirements', $data); + } + + helper('form'); return view('episode/video_clips_new', $data); } @@ -171,6 +184,23 @@ class VideoClipsController extends BaseController ); } + public function retry(string $videoClipId): RedirectResponse + { + $videoClip = (new ClipModel())->getVideoClipById((int) $videoClipId); + + if ($videoClip === null) { + throw PageNotFoundException::forPageNotFound(); + } + + (new ClipModel())->update($videoClip->id, [ + 'status' => 'queued', + 'job_started_at' => null, + 'job_ended_at' => null, + ]); + + return redirect()->back(); + } + public function delete(string $videoClipId): RedirectResponse { $videoClip = (new ClipModel())->getVideoClipById((int) $videoClipId); @@ -181,7 +211,7 @@ class VideoClipsController extends BaseController if ($videoClip->media === null) { // delete Clip directly - (new ClipModel())->delete($videoClipId); + (new ClipModel())->delete($videoClip->id); } else { $mediaModel = new MediaModel(); if (! $mediaModel->deleteMedia($videoClip->media)) { diff --git a/modules/Admin/Language/en/VideoClip.php b/modules/Admin/Language/en/VideoClip.php index 49689c673e..ef936401a4 100644 --- a/modules/Admin/Language/en/VideoClip.php +++ b/modules/Admin/Language/en/VideoClip.php @@ -31,6 +31,7 @@ return [ 'download_clip' => 'Download clip', 'create' => 'New video clip', 'go_to_page' => 'Go to clip page', + 'retry' => 'Retry clip generation', 'delete' => 'Delete clip', 'logs' => 'Job logs', 'form' => [ @@ -51,4 +52,12 @@ return [ 'duration' => 'Duration', 'submit' => 'Create video clip', ], + 'requirements' => [ + 'title' => 'Missing requirements', + 'missing' => 'You have missing requirements. Make sure to add all the required items to be allowed creating a video for this episode!', + 'ffmpeg' => 'FFmpeg', + 'gd' => 'Graphics Draw (GD)', + 'freetype' => 'Freetype library for GD', + 'transcript' => 'Transcript file (.srt)', + ], ]; diff --git a/modules/Admin/Language/fr/VideoClip.php b/modules/Admin/Language/fr/VideoClip.php index 6f4bc60e9a..c467714f83 100644 --- a/modules/Admin/Language/fr/VideoClip.php +++ b/modules/Admin/Language/fr/VideoClip.php @@ -31,6 +31,7 @@ return [ 'download_clip' => 'Télécharger l’extrait', 'create' => 'Nouvel extrait vidéo', 'go_to_page' => 'Aller à la page de l’extrait', + 'retry' => 'Relancer la génération de l’extrait', 'delete' => 'Supprimer l’extrait', 'logs' => 'Historique d’exécution', 'form' => [ @@ -51,4 +52,12 @@ return [ 'duration' => 'Durée', 'submit' => 'Créer un extrait vidéo', ], + 'requirements' => [ + 'title' => 'Outils manquants', + 'missing' => 'Il vous manque des outils. Assurez vous d’avoir ajouté tous les outils nécessaires pour accéder au fomulaire de génération d’extrait vidéo !', + 'ffmpeg' => 'FFmpeg', + 'gd' => 'Graphics Draw (GD)', + 'freetype' => 'Librairie Freetype pour GD', + 'transcript' => 'Fichier de transcription (.srt)', + ], ]; diff --git a/themes/cp_admin/_partials/_nav_header.php b/themes/cp_admin/_partials/_nav_header.php index 107e87234e..0631b61a38 100644 --- a/themes/cp_admin/_partials/_nav_header.php +++ b/themes/cp_admin/_partials/_nav_header.php @@ -36,7 +36,7 @@ $interactButtons .= <<<CODE_SAMPLE <button class="inline-flex items-center w-full px-4 py-1 hover:bg-highlight" id="interact-as-actor-{$userPodcast->id}" name="actor_id" value="{$userPodcast->actor_id}"> - <span class="inline-flex items-center flex-1 text-sm"><img src="{$userPodcast->cover->tiny_url}" class="w-6 h-6 mr-2 rounded-full" />{$userPodcast->title}{$checkMark}</span> + <div class="inline-flex items-center flex-1 text-sm"><img src="{$userPodcast->cover->tiny_url}" class="w-6 h-6 mr-2 rounded-full" /><span class="truncate">{$userPodcast->title}</span>{$checkMark}</div> </button> CODE_SAMPLE; } diff --git a/themes/cp_admin/episode/create.php b/themes/cp_admin/episode/create.php index 617a2158f0..fdc2032695 100644 --- a/themes/cp_admin/episode/create.php +++ b/themes/cp_admin/episode/create.php @@ -13,7 +13,7 @@ <Alert variant="danger" glyph="alert" class="max-w-xl"><?= lang('Episode.form.warning') ?></Alert> -<form action="<?= route_to('episode-create', $podcast->id) ?>" method="POST" enctype="multipart/form-data" class="flex flex-col mt-6 gap-y-8"> +<form action="<?= route_to('episode-create', $podcast->id) ?>" method="POST" enctype="multipart/form-data" class="flex flex-col max-w-xl mt-6 gap-y-8"> <?= csrf_field() ?> diff --git a/themes/cp_admin/episode/edit.php b/themes/cp_admin/episode/edit.php index 1c0add5d4a..acc4eb4236 100644 --- a/themes/cp_admin/episode/edit.php +++ b/themes/cp_admin/episode/edit.php @@ -17,7 +17,7 @@ <Alert variant="danger" glyph="alert" class="max-w-xl"><?= lang('Episode.form.warning') ?></Alert> -<form id="episode-edit-form" action="<?= route_to('episode-edit', $podcast->id, $episode->id) ?>" method="POST" enctype="multipart/form-data" class="flex flex-col mt-6 gap-y-8"> +<form id="episode-edit-form" action="<?= route_to('episode-edit', $podcast->id, $episode->id) ?>" method="POST" enctype="multipart/form-data" class="flex flex-col max-w-xl mt-6 gap-y-8"> <?= csrf_field() ?> diff --git a/themes/cp_admin/episode/persons.php b/themes/cp_admin/episode/persons.php index 516f7ca0db..534d0af9dc 100644 --- a/themes/cp_admin/episode/persons.php +++ b/themes/cp_admin/episode/persons.php @@ -14,7 +14,7 @@ <?= $this->section('content') ?> -<form action="<?= route_to('episode-persons-manage', $podcast->id, $episode->id) ?>" method="POST"> +<form action="<?= route_to('episode-persons-manage', $podcast->id, $episode->id) ?>" method="POST" class="max-w-xl"> <?= csrf_field() ?> <Forms.Section diff --git a/themes/cp_admin/episode/soundbites.php b/themes/cp_admin/episode/soundbites.php index ec42a99f3c..111483e371 100644 --- a/themes/cp_admin/episode/soundbites.php +++ b/themes/cp_admin/episode/soundbites.php @@ -15,7 +15,7 @@ <?= $this->section('content') ?> -<form id="soundbites-form" action="<?= route_to('episode-soundbites-edit', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col"> +<form id="soundbites-form" action="<?= route_to('episode-soundbites-edit', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col max-w-xl"> <?= csrf_field() ?> <Forms.Section diff --git a/themes/cp_admin/episode/video_clips_list.php b/themes/cp_admin/episode/video_clips_list.php index 3a0b065e63..f79a3b7cf3 100644 --- a/themes/cp_admin/episode/video_clips_list.php +++ b/themes/cp_admin/episode/video_clips_list.php @@ -103,6 +103,11 @@ use CodeIgniter\I18n\Time; 'title' => lang('VideoClip.go_to_page'), 'uri' => route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id), ], + [ + 'type' => 'link', + 'title' => lang('VideoClip.retry'), + 'uri' => route_to('video-clip-retry', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id), + ], [ 'type' => 'separator', ], diff --git a/themes/cp_admin/episode/video_clips_new.php b/themes/cp_admin/episode/video_clips_new.php index 97a5547613..dbfcd9fce2 100644 --- a/themes/cp_admin/episode/video_clips_new.php +++ b/themes/cp_admin/episode/video_clips_new.php @@ -10,13 +10,13 @@ <?= $this->section('content') ?> -<form action="<?= route_to('video-clips-create', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col items-center gap-4 xl:items-start xl:flex-row"> +<form id="new-video-clip-form" action="<?= route_to('video-clips-create', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col items-center gap-4 xl:items-start xl:flex-row"> -<div class="flex-1 w-full"> - <video-clip-previewer format="portrait"> +<div class="flex-1 w-full rounded-xl border-3 border-subtle"> + <video-clip-previewer duration="<?= old('duration', 30) ?>"> <img slot="preview_image" src="<?= $episode->cover->thumbnail_url ?>" alt="<?= $episode->cover->description ?>" /> </video-clip-previewer> - <audio-clipper start-time="15" duration="10" min-duration="10" volume=".25" height="50"> + <audio-clipper start-time="<?= old('start_time', 0) ?>" duration="<?= old('duration', 30) ?>" min-duration="10" volume=".5" height="50"> <audio slot="audio" src="<?= $episode->audio->file_url ?>" class="w-full" preload="auto"> Your browser does not support the <code>audio</code> element. </audio> @@ -25,47 +25,49 @@ </audio-clipper> </div> -<Forms.Section title="<?= lang('VideoClip.form.params_section_title') ?>" > - -<Forms.Field - name="label" - label="<?= lang('VideoClip.form.clip_title') ?>" - required="true" -/> - -<fieldset class="flex gap-1"> -<legend><?= lang('VideoClip.form.format.label') ?></legend> -<Forms.RadioButton - value="landscape" - name="format" - hint="<?= lang('VideoClip.form.format.landscape_hint') ?>"><?= lang('VideoClip.form.format.landscape') ?></Forms.RadioButton> -<Forms.RadioButton - value="portrait" - name="format" - hint="<?= lang('VideoClip.form.format.portrait_hint') ?>"><?= lang('VideoClip.form.format.portrait') ?></Forms.RadioButton> -<Forms.RadioButton - value="squared" - name="format" - hint="<?= lang('VideoClip.form.format.squared_hint') ?>"><?= lang('VideoClip.form.format.squared') ?></Forms.RadioButton> -</fieldset> - -<fieldset> -<legend><?= lang('VideoClip.form.theme') ?></legend> -<div class="grid gap-4 grid-cols-colorButtons"> - <?php foreach (config('MediaClipper')->themes as $themeName => $colors): ?> - <Forms.ColorRadioButton - class="mx-auto" - value="<?= $themeName ?>" - name="theme" - style="--color-accent-base: <?= $colors['preview']?>"><?= lang('Settings.theme.' . $themeName) ?></Forms.ColorRadioButton> - <?php endforeach; ?> +<div class="flex flex-col items-end w-full max-w-xl xl:max-w-sm 2xl:max-w-xl gap-y-4"> + <Forms.Section title="<?= lang('VideoClip.form.params_section_title') ?>" > + <Forms.Field + name="label" + label="<?= lang('VideoClip.form.clip_title') ?>" + required="true" + /> + <fieldset class="flex flex-wrap gap-x-1 gap-y-2"> + <legend><?= lang('VideoClip.form.format.label') ?></legend> + <Forms.RadioButton + value="landscape" + name="format" + isChecked="true" + required="true" + hint="<?= lang('VideoClip.form.format.landscape_hint') ?>"><?= lang('VideoClip.form.format.landscape') ?></Forms.RadioButton> + <Forms.RadioButton + value="portrait" + name="format" + required="true" + hint="<?= lang('VideoClip.form.format.portrait_hint') ?>"><?= lang('VideoClip.form.format.portrait') ?></Forms.RadioButton> + <Forms.RadioButton + value="squared" + name="format" + required="true" + hint="<?= lang('VideoClip.form.format.squared_hint') ?>"><?= lang('VideoClip.form.format.squared') ?></Forms.RadioButton> + </fieldset> + <fieldset> + <legend><?= lang('VideoClip.form.theme') ?></legend> + <div class="grid gap-x-4 gap-y-2 grid-cols-colorButtons"> + <?php foreach (config('MediaClipper')->themes as $themeName => $colors): ?> + <Forms.ColorRadioButton + class="mx-auto" + value="<?= $themeName ?>" + name="theme" + required="true" + isChecked="<?= $themeName === 'pine' ? 'true' : 'false' ?>" + style="--color-accent-base: <?= $colors['preview']?>"><?= lang('Settings.theme.' . $themeName) ?></Forms.ColorRadioButton> + <?php endforeach; ?> + </div> + </fieldset> + </Forms.Section> + <Button variant="primary" type="submit" iconRight="arrow-right" class="self-end"><?= lang('VideoClip.form.submit') ?></Button> </div> -</fieldset> - -<Button variant="primary" type="submit" iconRight="arrow-right" class="self-end"><?= lang('VideoClip.form.submit') ?></Button> - -</Forms.Section> - </form> <?= $this->endSection() ?> diff --git a/themes/cp_admin/episode/video_clips_requirements.php b/themes/cp_admin/episode/video_clips_requirements.php new file mode 100644 index 0000000000..f9c714aaf0 --- /dev/null +++ b/themes/cp_admin/episode/video_clips_requirements.php @@ -0,0 +1,29 @@ +<?= $this->extend('_layout') ?> + +<?= $this->section('title') ?> +<?= lang('VideoClip.form.title') ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('VideoClip.form.title') ?> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> + +<div class="flex flex-col gap-6"> +<div class="flex flex-col items-start"> + <Heading class="flex items-center gap-x-2"><Icon glyph="alert" class="flex-shrink-0 text-xl text-orange-600" /><?= lang('VideoClip.requirements.title') ?></Heading> + <p class="max-w-sm font-semibold text-gray-500"><?= lang('VideoClip.requirements.missing') ?></p> + <div class="flex flex-col mt-4"> + <?php foreach ($checks as $requirement => $value): ?> + <?php if ($value): ?> + <div class="inline-flex items-center"><Icon glyph="check" class="mr-1 text-white rounded-full bg-pine-500"/><?= lang('VideoClip.requirements.' . $requirement) ?></div> + <?php else: ?> + <div class="inline-flex items-center"><Icon glyph="close" class="mr-1 text-white bg-red-500 rounded-full"/><?= lang('VideoClip.requirements.' . $requirement) ?></div> + <?php endif; ?> + <?php endforeach; ?> + </div> + +</div> + +<?= $this->endSection() ?> diff --git a/themes/cp_admin/fediverse/blocked_actors.php b/themes/cp_admin/fediverse/blocked_actors.php index 2d4bf514c6..3446545a90 100644 --- a/themes/cp_admin/fediverse/blocked_actors.php +++ b/themes/cp_admin/fediverse/blocked_actors.php @@ -40,6 +40,7 @@ ], ], $blockedActors, + 'mt-8' ) ?> diff --git a/themes/cp_admin/podcast/create.php b/themes/cp_admin/podcast/create.php index 452ff8d57e..938121c10b 100644 --- a/themes/cp_admin/podcast/create.php +++ b/themes/cp_admin/podcast/create.php @@ -14,7 +14,7 @@ <?= $this->section('content') ?> -<form action="<?= route_to('podcast-create') ?>" method="POST" enctype='multipart/form-data' class="flex flex-col gap-y-6"> +<form action="<?= route_to('podcast-create') ?>" method="POST" enctype='multipart/form-data' class="flex flex-col max-w-xl gap-y-6"> <?= csrf_field() ?> <Forms.Section diff --git a/themes/cp_admin/podcast/edit.php b/themes/cp_admin/podcast/edit.php index a6fcec9f5d..682febfa45 100644 --- a/themes/cp_admin/podcast/edit.php +++ b/themes/cp_admin/podcast/edit.php @@ -36,7 +36,7 @@ </div> </div> -<div class="flex flex-col gap-y-6"> +<div class="flex flex-col max-w-xl gap-y-6"> <Forms.Section title="<?= lang('Podcast.form.identity_section_title') ?>" diff --git a/themes/cp_admin/podcast/import.php b/themes/cp_admin/podcast/import.php index c2f71a18be..e7abcf463b 100644 --- a/themes/cp_admin/podcast/import.php +++ b/themes/cp_admin/podcast/import.php @@ -12,7 +12,7 @@ <Alert glyph="alert" variant="danger" class="max-w-xl"><?= lang('PodcastImport.warning') ?></Alert> -<form action="<?= route_to('podcast-import') ?>" method="POST" enctype='multipart/form-data' class="flex flex-col mt-6 gap-y-8"> +<form action="<?= route_to('podcast-import') ?>" method="POST" enctype='multipart/form-data' class="flex flex-col max-w-xl mt-6 gap-y-8"> <?= csrf_field() ?> <Forms.Section diff --git a/themes/cp_admin/podcast/persons.php b/themes/cp_admin/podcast/persons.php index 77cc2992b7..fda00612eb 100644 --- a/themes/cp_admin/podcast/persons.php +++ b/themes/cp_admin/podcast/persons.php @@ -14,7 +14,7 @@ <?= $this->section('content') ?> -<form action="<?= route_to('podcast-persons-manage', $podcast->id) ?>" method="POST"> +<form action="<?= route_to('podcast-persons-manage', $podcast->id) ?>" method="POST" class="max-w-xl"> <?= csrf_field() ?> <Forms.Section diff --git a/themes/cp_admin/settings/general.php b/themes/cp_admin/settings/general.php index 46787aa74d..26c8e86d9b 100644 --- a/themes/cp_admin/settings/general.php +++ b/themes/cp_admin/settings/general.php @@ -11,7 +11,7 @@ <?= $this->section('content') ?> <div class="flex flex-col gap-y-4"> -<form action="<?= route_to('settings-instance') ?>" method="POST" enctype="multipart/form-data"> +<form action="<?= route_to('settings-instance') ?>" method="POST" enctype="multipart/form-data" class="max-w-xl"> <?= csrf_field() ?> <Forms.Section @@ -57,7 +57,7 @@ </form> -<form action="<?= route_to('settings-images-regenerate') ?>" method="POST" class="flex flex-col gap-y-4"> +<form action="<?= route_to('settings-images-regenerate') ?>" method="POST" class="flex flex-col max-w-xl gap-y-4"> <?= csrf_field() ?> <Forms.Section diff --git a/themes/cp_admin/settings/theme.php b/themes/cp_admin/settings/theme.php index 1bcb240c1b..6c43b91842 100644 --- a/themes/cp_admin/settings/theme.php +++ b/themes/cp_admin/settings/theme.php @@ -10,7 +10,7 @@ <?= $this->section('content') ?> -<form action="<?= route_to('settings-theme') ?>" method="POST" class="flex flex-col gap-y-4" enctype="multipart/form-data"> +<form action="<?= route_to('settings-theme') ?>" method="POST" class="flex flex-col max-w-xl gap-y-4" enctype="multipart/form-data"> <?= csrf_field() ?> <Forms.Section title="<?= lang('Settings.theme.accent_section_title') ?>" diff --git a/themes/cp_app/_admin_navbar.php b/themes/cp_app/_admin_navbar.php index 2984c28fa6..0cea27a6fa 100644 --- a/themes/cp_app/_admin_navbar.php +++ b/themes/cp_app/_admin_navbar.php @@ -31,7 +31,7 @@ $interactButtons .= <<<CODE_SAMPLE <button class="inline-flex items-center w-full px-4 py-1 hover:bg-highlight" id="interact-as-actor-{$userPodcast->id}" name="actor_id" value="{$userPodcast->actor_id}"> - <span class="inline-flex items-center flex-1 text-sm"><img src="{$userPodcast->cover->tiny_url}" class="w-6 h-6 mr-2 rounded-full" />{$userPodcast->title}{$checkMark}</span> + <div class="inline-flex items-center flex-1 text-sm"><img src="{$userPodcast->cover->tiny_url}" class="w-6 h-6 mr-2 rounded-full" /><span class="truncate">{$userPodcast->title}</span>{$checkMark}</div> </button> CODE_SAMPLE; } -- GitLab