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:
Yassine Doghri 2022-01-02 14:11:05 +00:00
parent 6809789206
commit 602654b99b
29 changed files with 491 additions and 104 deletions

View File

@ -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 . '" >' .

View File

@ -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,

View File

@ -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();

View 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;

View File

@ -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>

View File

@ -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>`;

View File

@ -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,
);

View File

@ -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>

View File

@ -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>

View File

@ -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',

View File

@ -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)) {

View File

@ -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)',
],
];

View File

@ -31,6 +31,7 @@ return [
'download_clip' => 'Télécharger lextrait',
'create' => 'Nouvel extrait vidéo',
'go_to_page' => 'Aller à la page de lextrait',
'retry' => 'Relancer la génération de lextrait',
'delete' => 'Supprimer lextrait',
'logs' => 'Historique dexé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 davoir ajouté tous les outils nécessaires pour accéder au fomulaire de génération dextrait vidéo!',
'ffmpeg' => 'FFmpeg',
'gd' => 'Graphics Draw (GD)',
'freetype' => 'Librairie Freetype pour GD',
'transcript' => 'Fichier de transcription (.srt)',
],
];

View File

@ -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;
}

View File

@ -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() ?>

View File

@ -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() ?>

View File

@ -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

View File

@ -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

View File

@ -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',
],

View File

@ -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() ?>

View 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() ?>

View File

@ -40,6 +40,7 @@
],
],
$blockedActors,
'mt-8'
) ?>

View File

@ -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

View File

@ -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') ?>"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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') ?>"

View File

@ -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;
}