Commit 21d4251b authored by Yassine Doghri's avatar Yassine Doghri
Browse files

feat: add audio-clipper webcomponent (wip)

parent 7609bb60
Pipeline #1179 passed with stages
in 7 minutes and 41 seconds
import "@github/markdown-toolbar-element";
import "@github/time-elements";
import "./modules/audio-clipper";
import ClientTimezone from "./modules/ClientTimezone";
import Clipboard from "./modules/Clipboard";
import DateTimePicker from "./modules/DateTimePicker";
......
import { css, html, LitElement, TemplateResult } from "lit";
import {
customElement,
property,
query,
queryAssignedNodes,
state,
} from "lit/decorators.js";
import WaveSurfer from "wavesurfer.js";
enum ACTIONS {
StretchLeft,
StretchRight,
Seek,
}
@customElement("audio-clipper")
export class AudioClipper extends LitElement {
@queryAssignedNodes("audio", true)
_audio!: NodeListOf<HTMLAudioElement>;
@queryAssignedNodes("start_time", true)
_startTimeInput!: NodeListOf<HTMLInputElement>;
@queryAssignedNodes("duration", true)
_durationInput!: NodeListOf<HTMLInputElement>;
@query(".slider")
_sliderNode!: HTMLDivElement;
@query(".slider__segment--wrapper")
_segmentNode!: HTMLDivElement;
@query(".slider__segment-content")
_segmentContentNode!: HTMLDivElement;
@query(".slider__segment-progress-handle")
_progressNode!: HTMLDivElement;
@query("#waveform")
_waveformNode!: HTMLDivElement;
@property({ type: Number, attribute: "start-time" })
startTime = 0;
@property({ type: Number })
duration = 10;
@property({ type: Number, attribute: "min-duration" })
minDuration = 5;
@property({ type: Number, attribute: "volume" })
initVolume = 0.5;
@state()
_isPlaying = false;
@state()
_clip = {
startTime: 0,
endTime: 0,
};
@state()
_action: ACTIONS | null = null;
@state()
_audioDuration = 0;
@state()
_sliderWidth = 0;
@state()
_currentTime = 0;
@state()
_volume = 0.5;
@state()
_wavesurfer!: WaveSurfer;
connectedCallback(): void {
super.connectedCallback();
console.log("connectedCallback_before");
this._clip = {
startTime: this.startTime,
endTime: this.startTime + this.duration,
};
this._volume = this.initVolume;
console.log("connectedCallback_after");
}
protected firstUpdated(): void {
console.log("firstUpdate");
this._audioDuration = this._audio[0].duration;
this._audio[0].volume = this._volume;
this._wavesurfer = WaveSurfer.create({
container: this._waveformNode,
interact: false,
barWidth: 2,
barHeight: 1,
responsive: true,
});
this._wavesurfer.load(this._audio[0].src);
window.addEventListener("load", () => {
this._sliderWidth = this._sliderNode.clientWidth;
this.setSegmentPosition();
});
window.addEventListener("resize", () => {
this._sliderWidth = this._sliderNode.clientWidth;
this.setSegmentPosition();
});
document.addEventListener("mouseup", () => {
if (this._action !== null) {
this._action = null;
}
});
document.addEventListener("mousemove", (event: MouseEvent) => {
if (this._action !== null) {
this.updatePosition(event);
}
});
this._audio[0].addEventListener("play", () => {
this._isPlaying = true;
});
this._audio[0].addEventListener("pause", () => {
this._isPlaying = false;
});
// this._audio[0].addEventListener("timeupdate", () => {
// this._currentTime = this._audio[0].currentTime;
// });
}
disconnectedCallback(): void {
console.log("disconnectedCallback");
window.removeEventListener("load", () => {
this._sliderWidth = this._sliderNode.clientWidth;
this.setSegmentPosition();
});
window.removeEventListener("resize", () => {
this._sliderWidth = this._sliderNode.clientWidth;
this.setSegmentPosition();
});
document.removeEventListener("mouseup", () => {
if (this._action !== null) {
this._action = null;
}
});
document.removeEventListener("mousemove", (event: MouseEvent) => {
if (this._action !== null) {
this.updatePosition(event);
}
});
this._audio[0].removeEventListener("play", () => {
this._isPlaying = true;
});
this._audio[0].removeEventListener("pause", () => {
this._isPlaying = false;
});
// this._audio[0].removeEventListener("timeupdate", () => {
// this._currentTime = this._audio[0].currentTime;
// });
}
setSegmentPosition(): void {
const startTimePosition = this.getPositionFromSeconds(this._clip.startTime);
const endTimePosition = this.getPositionFromSeconds(this._clip.endTime);
this._segmentNode.style.transform = `translateX(${startTimePosition}px)`;
this._segmentContentNode.style.width = `${
endTimePosition - startTimePosition
}px`;
}
getPositionFromSeconds(seconds: number) {
return (seconds * this._sliderWidth) / this._audioDuration;
}
getSecondsFromPosition(position: number) {
return (this._audioDuration * position) / this._sliderWidth;
}
protected updated(
_changedProperties: Map<string | number | symbol, unknown>
): void {
// console.log("updated", _changedProperties);
if (_changedProperties.has("_clip")) {
// console.log("CLIP", _changedProperties.get("_clip"));
this.pause();
this.setSegmentPosition();
console.log(this._clip.startTime);
this._audio[0].currentTime = 58;
console.log(this._audio[0].currentTime);
}
}
play(): void {
this._audio[0].play();
// setTimeout(() => {
// this.pause();
// this._audio[0].currentTime = this._clip.startTime;
// }, (this._clip.endTime - this._clip.startTime) * 1000);
}
pause(): void {
this._audio[0].pause();
}
updatePosition(event: MouseEvent): void {
const cursorPosition =
event.clientX -
(this._sliderNode.getBoundingClientRect().left +
document.documentElement.scrollLeft);
const seconds = this.getSecondsFromPosition(cursorPosition);
switch (this._action) {
case ACTIONS.StretchLeft: {
let startTime;
if (seconds > 0) {
if (seconds > this._clip.endTime - this.minDuration) {
startTime = this._clip.endTime - this.minDuration;
} else {
startTime = seconds;
}
} else {
startTime = 0;
}
this._clip = {
startTime,
endTime: this._clip.endTime,
};
break;
}
case ACTIONS.StretchRight: {
let endTime;
if (seconds < this._audioDuration) {
if (seconds < this._clip.startTime + this.minDuration) {
endTime = this._clip.startTime + this.minDuration;
} else {
endTime = seconds;
}
} else {
endTime = this._audioDuration;
}
this._clip = {
startTime: this._clip.startTime,
endTime,
};
break;
}
case ACTIONS.Seek: {
console.log("seeking");
break;
}
default:
break;
}
}
setVolume(event: InputEvent): void {
this._volume = parseFloat((event.target as HTMLInputElement).value);
this._audio[0].volume = this._volume;
}
setCurrentTime(event: MouseEvent): void {
const cursorPosition =
event.clientX -
(this._sliderNode.getBoundingClientRect().left +
document.documentElement.scrollLeft);
const seconds = this.getSecondsFromPosition(cursorPosition);
this._audio[0].currentTime = seconds;
}
setAction(action: ACTIONS): void {
this._action = action;
}
secondsToHHMMSS(seconds: number): string {
return new Date(seconds * 1000).toISOString().substr(11, 8);
}
static styles = css`
.slider {
position: relative;
height: 6rem;
display: flex;
align-items: center;
width: 100%;
background-color: #0f172a;
}
.slider__track-placeholder {
width: 100%;
height: 8px;
background-color: #64748b;
}
.slider__segment--wrapper {
position: absolute;
}
.slider__segment {
position: relative;
display: flex;
}
.slider__segment-content {
background-color: rgba(255, 255, 255, 0.9);
height: 4rem;
width: 1px;
border: none;
}
.slider__segment-progress-handle {
position: absolute;
width: 9px;
height: 9px;
margin-top: -9px;
margin-left: -4px;
background-color: #3b82f6;
cursor: pointer;
}
.slider__segment .slider__segment-handle {
position: absolute;
cursor: pointer;
width: 1rem;
height: 100%;
background-color: #b91c1c;
border: none;
}
.slider__segment .slider__segment-handle::before {
content: "";
position: absolute;
height: 3rem;
width: 2px;
background-color: #ffffff;
margin: auto;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.slider__segment .clipper__handle-left {
left: -1rem;
border-radius: 0.2rem 0 0 0.2rem;
}
.slider__segment .clipper__handle-right {
right: -1rem;
border-radius: 0 0.2rem 0.2rem 0;
}
`;
render(): TemplateResult<1> {
return html`
<slot name="audio"></slot>
<slot name="start_time"></slot>
<slot name="duration"></slot>
<div>${this.secondsToHHMMSS(this._clip.startTime)}</div>
<div>${this.secondsToHHMMSS(this._currentTime)}</div>
<div>${this.secondsToHHMMSS(this._clip.endTime)}</div>
<input
type="range"
id="volume"
min="0"
max="1"
step="0.1"
value="${this._volume}"
@change="${this.setVolume}"
/>
<div id="waveform"></div>
<div class="slider" role="slider">
<div class="slider__track-placeholder"></div>
<div class="slider__segment--wrapper">
<div
class="slider__segment-progress-handle"
@mousedown="${() => this.setAction(ACTIONS.Seek)}"
></div>
<!-- <div class="slider__segment-progress-handle-bar"></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>
<div
class="slider__segment-content"
@click="${this.setCurrentTime}"
></div>
<button
class="slider__segment-handle clipper__handle-right"
title="${this.secondsToHHMMSS(this._clip.endTime)}"
@mousedown="${() => this.setAction(ACTIONS.StretchRight)}"
></button>
</div>
</div>
</div>
<button @click="${this._isPlaying ? this.pause : this.play}">
${this._isPlaying
? html`<svg
viewBox="0 0 24 24"
fill="currentColor"
width="1em"
height="1em"
>
<g>
<path fill="none" d="M0 0h24v24H0z" />
<path d="M6 5h2v14H6V5zm10 0h2v14h-2V5z" />
</g>
</svg>`
: html` <svg
viewBox="0 0 24 24"
fill="currentColor"
width="1em"
height="1em"
>
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M7.752 5.439l10.508 6.13a.5.5 0 0 1 0 .863l-10.508 6.13A.5.5 0 0 1 7 18.128V5.871a.5.5 0 0 1 .752-.432z"
/>
</svg>`}
</button>
`;
}
}
......@@ -29,6 +29,7 @@
"leaflet.markercluster": "^1.5.3",
"lit": "^2.0.2",
"marked": "^4.0.7",
"wavesurfer.js": "^5.2.0",
"xml-formatter": "^2.5.1"
},
"devDependencies": {
......@@ -43,6 +44,7 @@
"@tailwindcss/typography": "^0.5.0-alpha.3",
"@types/leaflet": "^1.7.6",
"@types/marked": "^4.0.1",
"@types/wavesurfer.js": "^5.2.2",
"@typescript-eslint/eslint-plugin": "^5.7.0",
"@typescript-eslint/parser": "^5.7.0",
"cross-env": "^7.0.3",
......@@ -3248,6 +3250,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.1.tgz",
"integrity": "sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==",
"dev": true
},
"node_modules/@types/estree": {
"version": "0.0.39",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
......@@ -3342,6 +3350,15 @@
"version": "2.0.2",
"license": "MIT"
},
"node_modules/@types/wavesurfer.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@types/wavesurfer.js/-/wavesurfer.js-5.2.2.tgz",
"integrity": "sha512-/vjpf81co0SK3z4F5V79fZrFPQ8pw9/fEpgkzcgNVkBa9sY0gAaYzKuaQyCX/yjVf6kc73uPtWABQuVgvpguDQ==",
"dev": true,
"dependencies": {
"@types/debounce": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.8.1.tgz",
......@@ -15895,6 +15912,11 @@
"node": ">=10"
}
},
"node_modules/wavesurfer.js": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-5.2.0.tgz",
"integrity": "sha512-SkPlTXfvKy+ZnEA7f7g7jn6iQg5/8mAvWpVV5vRbIS/FF9TB2ak9J7VayQfzfshOLW/CqccTiN6DDR/fZA902g=="
},
"node_modules/webidl-conversions": {
"version": "6.1.0",
"license": "BSD-2-Clause",
......@@ -18897,6 +18919,12 @@
"version": "1.1.1",
"dev": true
},
"@types/debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.1.tgz",
"integrity": "sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==",
"dev": true
},
"@types/estree": {
"version": "0.0.39",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
......@@ -18979,6 +19007,15 @@
"@types/trusted-types": {
"version": "2.0.2"
},
"@types/wavesurfer.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@types/wavesurfer.js/-/wavesurfer.js-5.2.2.tgz",
"integrity": "sha512-/vjpf81co0SK3z4F5V79fZrFPQ8pw9/fEpgkzcgNVkBa9sY0gAaYzKuaQyCX/yjVf6kc73uPtWABQuVgvpguDQ==",
"dev": true,
"requires": {
"@types/debounce": "*"
}
},
"@typescript-eslint/eslint-plugin": {
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.8.1.tgz",
......@@ -27308,6 +27345,11 @@
"xml-name-validator": "^3.0.0"
}
},
"wavesurfer.js": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-5.2.0.tgz",
"integrity": "sha512-SkPlTXfvKy+ZnEA7f7g7jn6iQg5/8mAvWpVV5vRbIS/FF9TB2ak9J7VayQfzfshOLW/CqccTiN6DDR/fZA902g=="
},
"webidl-conversions": {
"version": "6.1.0"
},
......
......@@ -10,9 +10,20 @@
<?= $this->section('content') ?>
<form action="<?= route_to('video-clips-create', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col gap-y-4">
<form action="<?= route_to('video-clips-create', $podcast->id, $episode->id) ?>" method="POST" class="flex gap-4">
<Forms.Section title="<?= lang('VideoClip.form.params_section_title') ?>" >
<div class="flex-1 w-full">
<!-- <div class="h-full bg-black"></div> -->
<audio-clipper start-time="1000" duration="140" min-duration="10" volume=".25">
<audio slot="audio" src="<?= $episode->audio->file_url ?>" class="w-full">
Your browser does not support the <code>audio</code> element.
</audio>
<input slot="start_time" type="number" name="start_time" placeholder="<?= lang('VideoClip.form.start_time') ?>" step="0.001" />
<input slot="duration" type="number" name="duration" placeholder="<?= lang('VideoClip.form.duration') ?>" step="0.001" />
</audio-clipper>
</div>
<!-- <Forms.Section title="<?= lang('VideoClip.form.params_section_title') ?>" >
<Forms.Field
name="label"
......@@ -49,26 +60,9 @@
</div>
</fieldset>
<div class="flex flex-col gap-x-2 gap-y-4 md:flex-row">
<Forms.Field
type="number"
name="start_time"
label="<?= lang('VideoClip.form.start_time') ?>"
required="true"
step="0.001"
/>
<Forms.Field
type="number"
name="duration"
label="<?= lang('VideoClip.form.duration') ?>"
required="true"
step="0.001"
/>
</div>
<Button variant="primary" type="submit" iconRight="arrow-right" class="self-end"><?= lang('VideoClip.form.submit') ?></Button>
</Forms.Section>
</Forms.Section> -->
</form>
......