diff --git a/app/Helpers/components_helper.php b/app/Helpers/components_helper.php
index 89c5a121..2d12bc3d 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 '
' .
diff --git a/app/Libraries/MediaClipper/Config/MediaClipper.php b/app/Libraries/MediaClipper/Config/MediaClipper.php
index e300f24d..f4274d6d 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 a626874e..310cc336 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 00000000..11c1a637
--- /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
= form.querySelectorAll(
+ 'input[name="theme"]'
+ ) as NodeListOf;
+ const formatOptions: NodeListOf = form.querySelectorAll(
+ 'input[name="format"]'
+ ) as NodeListOf;
+
+ 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 97549f7e..e77b4d2e 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;
+
@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 {
this.setAction(ACTIONS.Seek)}"
+ @mousedown="${(event: MouseEvent) =>
+ this.setAction(event, { type: ActionType.Seek })}"
>
+ @mousedown="${(event: MouseEvent) =>
+ this.setAction(event, {
+ type: ActionType.StretchLeft,
+ })}"
+ >
+
${this.secondsToHHMMSS(this._clip.startTime)}
+
this.setAction(ACTIONS.Seek)}"
+ @mousedown="${(event: MouseEvent) =>
+ this.setAction(event, { type: ActionType.Seek })}"
@click="${(event: MouseEvent) => this.goTo(event)}"
>
+ @mousedown="${(event: MouseEvent) =>
+ this.setAction(event, { type: ActionType.StretchRight })}"
+ >
+
${this.secondsToHHMMSS(this._clip.endTime)}
+
@@ -727,6 +850,7 @@ export class AudioClipper extends LitElement {
@change="${this.setVolume}"
/>
+