feat: add audio-clipper toolbar + add video-clip-previewer

This commit is contained in:
Yassine Doghri 2021-12-30 17:09:24 +00:00
parent 01a09dc447
commit 02557539e6
4 changed files with 293 additions and 68 deletions

View File

@ -18,6 +18,7 @@ import Soundbites from "./modules/Soundbites";
import ThemePicker from "./modules/ThemePicker"; import ThemePicker from "./modules/ThemePicker";
import Time from "./modules/Time"; import Time from "./modules/Time";
import Tooltip from "./modules/Tooltip"; import Tooltip from "./modules/Tooltip";
import "./modules/video-clip-previewer";
import "./modules/xml-editor"; import "./modules/xml-editor";
Dropdown(); Dropdown();

View File

@ -48,6 +48,9 @@ export class AudioClipper extends LitElement {
@query("#waveform") @query("#waveform")
_waveformNode!: HTMLDivElement; _waveformNode!: HTMLDivElement;
@query(".buffering-bar")
_bufferingBarNode!: HTMLCanvasElement;
@property({ type: Number, attribute: "start-time" }) @property({ type: Number, attribute: "start-time" })
initStartTime = 0; initStartTime = 0;
@ -87,15 +90,15 @@ export class AudioClipper extends LitElement {
@state() @state()
_volume = 0.5; _volume = 0.5;
@state()
_isLoading = false;
@state() @state()
_seekingTime: number | null = null; _seekingTime: number | null = null;
@state() @state()
_wavesurfer!: WaveSurfer; _wavesurfer!: WaveSurfer;
@state()
_isBuffering = false;
_windowEvents: EventElement[] = [ _windowEvents: EventElement[] = [
{ {
events: ["load", "resize"], events: ["load", "resize"],
@ -144,21 +147,46 @@ export class AudioClipper extends LitElement {
}, },
}, },
{ {
events: ["complete"], events: ["progress"],
onEvent: () => { onEvent: () => {
this._isLoading = false; const context = this._bufferingBarNode.getContext("2d");
if (context) {
context.fillStyle = "lightgray";
context.fillRect(
0,
0,
this._bufferingBarNode.width,
this._bufferingBarNode.height
);
context.fillStyle = "#04AC64";
const inc = this._bufferingBarNode.width / this._audio[0].duration;
for (let i = 0; i < this._audio[0].buffered.length; i++) {
const startX = this._audio[0].buffered.start(i) * inc;
const endX = this._audio[0].buffered.end(i) * inc;
const width = endX - startX;
context.fillRect(startX, 0, width, this._bufferingBarNode.height);
context.rect(startX, 0, width, this._bufferingBarNode.height);
}
}
}, },
}, },
{ {
events: ["timeupdate"], events: ["timeupdate"],
onEvent: () => { onEvent: () => {
// TODO: change this // TODO: change this?
this._currentTime = this._audio[0].currentTime; this._currentTime = parseFloat(this._audio[0].currentTime.toFixed(3));
if (this._currentTime > this._clip.endTime) { if (this._currentTime > this._clip.endTime) {
this.pause(); this.pause();
this._audio[0].currentTime = this._clip.endTime;
} else if (this._currentTime < this._clip.startTime) { } else if (this._currentTime < this._clip.startTime) {
this._isBuffering = true;
this._audio[0].currentTime = this._clip.startTime; this._audio[0].currentTime = this._clip.startTime;
} else { } else {
this._isBuffering = false;
this.setCurrentTime(this._currentTime); this.setCurrentTime(this._currentTime);
} }
}, },
@ -178,17 +206,18 @@ export class AudioClipper extends LitElement {
protected firstUpdated(): void { protected firstUpdated(): void {
this._audioDuration = this._audio[0].duration; this._audioDuration = this._audio[0].duration;
this._audio[0].volume = this._volume; this._audio[0].volume = this._volume;
this._audio[0].currentTime = this._clip.startTime; this._startTimeInput[0].hidden = true;
this._isLoading = true; this._durationInput[0].hidden = true;
this._wavesurfer = WaveSurfer.create({ this._wavesurfer = WaveSurfer.create({
container: this._waveformNode, container: this._waveformNode,
height: this.height, height: this.height,
interact: false, interact: false,
barWidth: 4, barWidth: 2,
barHeight: 1, barHeight: 1,
barGap: 4, // barGap: 4,
responsive: true, responsive: true,
waveColor: "hsl(0 5% 85%)",
cursorColor: "transparent", cursorColor: "transparent",
}); });
this._wavesurfer.load(this._audio[0].src); this._wavesurfer.load(this._audio[0].src);
@ -266,6 +295,11 @@ export class AudioClipper extends LitElement {
if (_changedProperties.has("_clip")) { if (_changedProperties.has("_clip")) {
this.pause(); this.pause();
this.setSegmentPosition(); this.setSegmentPosition();
this._startTimeInput[0].value = this._clip.startTime.toString();
this._durationInput[0].value = (
this._clip.endTime - this._clip.startTime
).toFixed(3);
this._audio[0].currentTime = this._clip.startTime; this._audio[0].currentTime = this._clip.startTime;
} }
if (_changedProperties.has("_seekingTime")) { if (_changedProperties.has("_seekingTime")) {
@ -293,18 +327,16 @@ export class AudioClipper extends LitElement {
switch (this._action) { switch (this._action) {
case ACTIONS.StretchLeft: { case ACTIONS.StretchLeft: {
let startTime; let startTime = 0;
if (seconds > 0) { if (seconds > 0) {
if (seconds > this._clip.endTime - this.minDuration) { if (seconds > this._clip.endTime - this.minDuration) {
startTime = this._clip.endTime - this.minDuration; startTime = this._clip.endTime - this.minDuration;
} else { } else {
startTime = seconds; startTime = seconds;
} }
} else {
startTime = 0;
} }
this._clip = { this._clip = {
startTime, startTime: parseFloat(startTime.toFixed(3)),
endTime: this._clip.endTime, endTime: this._clip.endTime,
}; };
break; break;
@ -323,7 +355,7 @@ export class AudioClipper extends LitElement {
this._clip = { this._clip = {
startTime: this._clip.startTime, startTime: this._clip.startTime,
endTime, endTime: parseFloat(endTime.toFixed(3)),
}; };
break; break;
} }
@ -333,7 +365,7 @@ export class AudioClipper extends LitElement {
} else if (seconds > this._clip.endTime) { } else if (seconds > this._clip.endTime) {
this._seekingTime = this._clip.endTime; this._seekingTime = this._clip.endTime;
} else { } else {
this._seekingTime = seconds; this._seekingTime = parseFloat(seconds.toFixed(3));
} }
break; break;
} }
@ -386,6 +418,20 @@ export class AudioClipper extends LitElement {
return new Date(seconds * 1000).toISOString().substr(11, 8); return new Date(seconds * 1000).toISOString().substr(11, 8);
} }
trim(side: "start" | "end") {
if (side === "start") {
this._clip = {
startTime: this._audio[0].currentTime,
endTime: this._clip.endTime,
};
} else {
this._clip = {
startTime: this._clip.startTime,
endTime: this._currentTime,
};
}
}
static styles = css` static styles = css`
.slider-wrapper { .slider-wrapper {
position: relative; position: relative;
@ -393,6 +439,15 @@ export class AudioClipper extends LitElement {
background-color: #0f172a; background-color: #0f172a;
} }
.buffering-bar {
position: absolute;
width: 100%;
height: 4px;
background-color: gray;
bottom: -4px;
left: 0;
}
.slider { .slider {
position: absolute; position: absolute;
z-index: 10; z-index: 10;
@ -404,12 +459,6 @@ export class AudioClipper extends LitElement {
width: 100%; width: 100%;
} }
.slider__track-placeholder {
width: 100%;
height: 8px;
background-color: #64748b;
}
.slider__segment--wrapper { .slider__segment--wrapper {
position: absolute; position: absolute;
height: 100%; height: 100%;
@ -418,14 +467,17 @@ export class AudioClipper extends LitElement {
.slider__segment { .slider__segment {
position: relative; position: relative;
display: flex; display: flex;
height: 100%; height: 120%;
top: -10%;
} }
.slider__segment-content { .slider__segment-content {
box-sizing: border-box;
background-color: rgba(255, 255, 255, 0.5); background-color: rgba(255, 255, 255, 0.5);
height: 100%; height: 100%;
width: 1px; width: 1px;
border: none; border-top: 2px dashed #b91c1c;
border-bottom: 2px dashed #b91c1c;
} }
.slider__seeking-placeholder { .slider__seeking-placeholder {
@ -441,8 +493,9 @@ export class AudioClipper extends LitElement {
position: absolute; position: absolute;
width: 20px; width: 20px;
height: 20px; height: 20px;
top: -23px; top: -50%;
left: -10px; left: -10px;
margin-top: -2px;
background-color: #3b82f6; background-color: #3b82f6;
border-radius: 50%; border-radius: 50%;
} }
@ -453,7 +506,7 @@ export class AudioClipper extends LitElement {
width: 0px; width: 0px;
height: 0px; height: 0px;
bottom: -12px; bottom: -12px;
left: 1px; left: 0;
border: 10px solid transparent; border: 10px solid transparent;
border-top-color: transparent; border-top-color: transparent;
border-top-style: solid; border-top-style: solid;
@ -464,7 +517,7 @@ export class AudioClipper extends LitElement {
.slider__segment .slider__segment-handle { .slider__segment .slider__segment-handle {
position: absolute; position: absolute;
width: 1rem; width: 1rem;
height: 120%; height: 100%;
background-color: #b91c1c; background-color: #b91c1c;
border: none; border: none;
margin: auto 0; margin: auto 0;
@ -475,7 +528,7 @@ export class AudioClipper extends LitElement {
.slider__segment .slider__segment-handle::before { .slider__segment .slider__segment-handle::before {
content: ""; content: "";
position: absolute; position: absolute;
height: 3rem; height: 50%;
width: 2px; width: 2px;
background-color: #ffffff; background-color: #ffffff;
margin: auto; margin: auto;
@ -487,12 +540,79 @@ export class AudioClipper extends LitElement {
.slider__segment .clipper__handle-left { .slider__segment .clipper__handle-left {
left: -1rem; left: -1rem;
border-radius: 0.2rem 9999px 9999px 0.2rem; border-radius: 0.2rem 0 0 0.2rem;
} }
.slider__segment .clipper__handle-right { .slider__segment .clipper__handle-right {
right: -1rem; right: -1rem;
border-radius: 9999px 0.2rem 0.2rem 9999px; border-radius: 0 0.2rem 0.2rem 0;
}
.toolbar {
display: flex;
align-items: center;
padding: 0.5rem 0.5rem 0.25rem 0.5rem;
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;
flex-wrap: wrap;
gap: 0.5rem;
}
.toolbar__audio-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.toolbar .toolbar__play-button {
padding: 0.5rem;
height: 32px;
width: 32px;
font-size: 1em;
}
.toolbar__trim-controls {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.toolbar button {
cursor: pointer;
background-color: hsl(var(--color-accent-base));
color: hsl(var(--color-accent-contrast));
border-radius: 9999px;
border: none;
padding: 0.25rem 0.5rem;
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.volume {
display: flex;
font-size: 1.2rem;
color: hsl(var(--color-accent-base));
align-items: center;
gap: 0.25rem;
}
.range-slider {
accent-color: hsl(var(--color-accent-base));
width: 100px;
} }
`; `;
@ -501,23 +621,9 @@ export class AudioClipper extends LitElement {
<slot name="audio"></slot> <slot name="audio"></slot>
<slot name="start_time"></slot> <slot name="start_time"></slot>
<slot name="duration"></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>
<div>${this._isLoading ? "loading..." : "not loading"}</div>
<input
type="range"
id="volume"
min="0"
max="1"
step="0.1"
value="${this._volume}"
@change="${this.setVolume}"
/>
<div class="slider-wrapper" style="height:${this.height}"> <div class="slider-wrapper" style="height:${this.height}">
<div id="waveform"></div> <div id="waveform"></div>
<div class="slider" role="slider"> <div class="slider" role="slider">
<div class="slider__track-placeholder"></div>
<div class="slider__segment--wrapper"> <div class="slider__segment--wrapper">
<div <div
class="slider__segment-progress-handle" class="slider__segment-progress-handle"
@ -543,10 +649,61 @@ export class AudioClipper extends LitElement {
</div> </div>
</div> </div>
</div> </div>
<canvas class="buffering-bar"></canvas>
</div> </div>
<button @click="${this._isPlaying ? this.pause : this.play}"> <div class="toolbar">
${this._isPlaying <div class="toolbar__audio-controls">
? html`<svg <button
class="toolbar__play-button"
@click="${this._isPlaying ? this.pause : this.play}"
>
${this._isBuffering
? html`<svg
class="animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
opacity="0.25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
opacity="0.75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>`
: 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>
<div class="volume">
<svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
width="1em" width="1em"
@ -554,21 +711,28 @@ export class AudioClipper extends LitElement {
> >
<g> <g>
<path fill="none" d="M0 0h24v24H0z" /> <path fill="none" d="M0 0h24v24H0z" />
<path d="M6 5h2v14H6V5zm10 0h2v14h-2V5z" /> <path
d="M8.889 16H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3.889l5.294-4.332a.5.5 0 0 1 .817.387v15.89a.5.5 0 0 1-.817.387L8.89 16zm9.974.591l-1.422-1.422A3.993 3.993 0 0 0 19 12c0-1.43-.75-2.685-1.88-3.392l1.439-1.439A5.991 5.991 0 0 1 21 12c0 1.842-.83 3.49-2.137 4.591z"
/>
</g> </g>
</svg>` </svg>
: html` <svg <input
viewBox="0 0 24 24" class="range-slider"
fill="currentColor" type="range"
width="1em" id="volume"
height="1em" min="0"
> max="1"
<path fill="none" d="M0 0h24v24H0z" /> step="0.1"
<path value="${this._volume}"
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" @change="${this.setVolume}"
/> />
</svg>`} </div>
</button> </div>
<div class="toolbar__trim-controls">
<button @click="${() => this.trim("start")}">Trim start</button>
<button @click="${() => this.trim("end")}">Trim end</button>
</div>
</div>
`; `;
} }
} }

View File

@ -0,0 +1,58 @@
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, queryAssignedNodes } from "lit/decorators.js";
import { styleMap } from "lit/directives/style-map.js";
enum VideoFormats {
Landscape = "landscape",
Portrait = "portrait",
Squared = "squared",
}
const formatMap = {
[VideoFormats.Landscape]: "16/9",
[VideoFormats.Portrait]: "9/16",
[VideoFormats.Squared]: "1/1",
};
@customElement("video-clip-previewer")
export class VideoClipPreviewer extends LitElement {
@queryAssignedNodes("preview_image", true)
_previewImage!: NodeListOf<HTMLImageElement>;
@property()
format: VideoFormats = VideoFormats.Landscape;
@property()
theme = "#009486";
static styles = css`
.video-background {
display: grid;
justify-items: center;
align-items: center;
background-color: black;
width: 100%;
aspect-ratio: 16 / 9;
}
.video-format {
display: grid;
align-items: center;
justify-items: center;
height: 100%;
}
`;
render(): TemplateResult<1> {
const styles = {
aspectRatio: formatMap[this.format],
backgroundColor: this.theme,
};
return html`<div class="video-background">
<div class="video-format" style=${styleMap(styles)}>
<slot name="preview_image"></slot>
</div>
</div>`;
}
}

View File

@ -10,12 +10,14 @@
<?= $this->section('content') ?> <?= $this->section('content') ?>
<form action="<?= route_to('video-clips-create', $podcast->id, $episode->id) ?>" method="POST" class="flex gap-4"> <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"> <div class="flex-1 w-full">
<!-- <div class="h-full bg-black"></div> --> <video-clip-previewer format="portrait">
<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="15" duration="10" min-duration="10" volume=".25" height="50">
<audio slot="audio" src="<?= $episode->audio->file_url ?>" class="w-full"> <audio slot="audio" src="<?= $episode->audio->file_url ?>" class="w-full" preload="auto">
Your browser does not support the <code>audio</code> element. Your browser does not support the <code>audio</code> element.
</audio> </audio>
<input slot="start_time" type="number" name="start_time" placeholder="<?= lang('VideoClip.form.start_time') ?>" step="0.001" /> <input slot="start_time" type="number" name="start_time" placeholder="<?= lang('VideoClip.form.start_time') ?>" step="0.001" />
@ -23,7 +25,7 @@
</audio-clipper> </audio-clipper>
</div> </div>
<!-- <Forms.Section title="<?= lang('VideoClip.form.params_section_title') ?>" > <Forms.Section title="<?= lang('VideoClip.form.params_section_title') ?>" >
<Forms.Field <Forms.Field
name="label" name="label"
@ -62,7 +64,7 @@
<Button variant="primary" type="submit" iconRight="arrow-right" class="self-end"><?= lang('VideoClip.form.submit') ?></Button> <Button variant="primary" type="submit" iconRight="arrow-right" class="self-end"><?= lang('VideoClip.form.submit') ?></Button>
</Forms.Section> --> </Forms.Section>
</form> </form>