mirror of
https://code.castopod.org/adaures/castopod
synced 2025-04-19 13:01:19 +00:00
fix(audio-clipper): add mouse position offset when stretching clip to prevent content from jumping
update Forms.Section component to adapt to full width
This commit is contained in:
parent
6809789206
commit
602654b99b
@ -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 . '" >' .
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
70
app/Resources/js/modules/VideoClipBuilder.ts
Normal file
70
app/Resources/js/modules/VideoClipBuilder.ts
Normal file
@ -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;
|
@ -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>
|
||||
|
@ -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>`;
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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)) {
|
||||
|
@ -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)',
|
||||
],
|
||||
];
|
||||
|
@ -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)',
|
||||
],
|
||||
];
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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() ?>
|
||||
|
||||
|
||||
|
@ -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() ?>
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
],
|
||||
|
@ -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() ?>
|
||||
|
29
themes/cp_admin/episode/video_clips_requirements.php
Normal file
29
themes/cp_admin/episode/video_clips_requirements.php
Normal file
@ -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() ?>
|
@ -40,6 +40,7 @@
|
||||
],
|
||||
],
|
||||
$blockedActors,
|
||||
'mt-8'
|
||||
) ?>
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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') ?>"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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') ?>"
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user