Newer
Older
import { css, html, LitElement, TemplateResult } from "lit";
import {
customElement,
property,
query,

Yassine Doghri
committed
queryAll,
queryAssignedNodes,
state,
} from "lit/decorators.js";
import WaveSurfer from "wavesurfer.js";

Yassine Doghri
committed
enum ActionType {
StretchLeft,
StretchRight,
Seek,
}

Yassine Doghri
committed
interface Action {
type: ActionType;
payload?: any;
}

Yassine Doghri
committed
interface EventElement {
events: string[];
onEvent: EventListener;
}
@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;

Yassine Doghri
committed
@query(".slider__seeking-placeholder")
_seekingNode!: HTMLDivElement;
@query("#waveform")
_waveformNode!: HTMLDivElement;
@query(".buffering-bar")
_bufferingBarNode!: HTMLCanvasElement;

Yassine Doghri
committed
@queryAll(".slider__segment-handle")
_segmentHandleNodes!: NodeListOf<HTMLButtonElement>;
@property({ type: Number, attribute: "start-time" })

Yassine Doghri
committed
initStartTime = 0;

Yassine Doghri
committed
@property({ type: Number, attribute: "duration" })
initDuration = 10;
@property({ type: Number, attribute: "min-duration" })
minDuration = 5;
@property({ type: Number, attribute: "volume" })
initVolume = 0.5;

Yassine Doghri
committed
@property({ type: Number, attribute: "height" })
height = 100;
@property({ attribute: "trim-start-label" })
trimStartLabel = "Trim start";
@property({ attribute: "trim-end-label" })
trimEndLabel = "Trim end";
@state()
_isPlaying = false;
@state()
_clip = {
startTime: 0,
endTime: 0,
};
@state()

Yassine Doghri
committed
_action: Action | null = null;
@state()
_audioDuration = 0;
@state()
_sliderWidth = 0;
@state()
_currentTime = 0;
@state()
_volume = 0.5;

Yassine Doghri
committed
@state()
_seekingTime: number | null = null;
@state()
_wavesurfer!: WaveSurfer;
@state()
_isBuffering = false;

Yassine Doghri
committed
_windowEvents: EventElement[] = [
{
events: ["load", "resize"],
onEvent: () => {
this._sliderWidth = this._sliderNode.clientWidth;
this.setSegmentPosition();
},
},
];
_documentEvents: EventElement[] = [
{
events: ["mouseup"],
onEvent: () => {
if (this._action !== null) {
document.body.style.cursor = "";

Yassine Doghri
committed
if (this._action.type === ActionType.Seek && this._seekingTime) {

Yassine Doghri
committed
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
this._audio[0].currentTime = this._seekingTime;
this._seekingTime = 0;
}
this._action = null;
}
},
},
{
events: ["mousemove"],
onEvent: (event: Event) => {
if (this._action !== null) {
this.updatePosition(event as MouseEvent);
}
},
},
];
_audioEvents: EventElement[] = [
{
events: ["play"],
onEvent: () => {
this._isPlaying = true;
},
},
{
events: ["pause"],
onEvent: () => {
this._isPlaying = false;
},
},
{
events: ["progress"],

Yassine Doghri
committed
onEvent: () => {
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);
}
}

Yassine Doghri
committed
},
},
{
events: ["timeupdate"],
onEvent: () => {
// TODO: change this?
this._currentTime = parseFloat(this._audio[0].currentTime.toFixed(3));

Yassine Doghri
committed
if (this._currentTime > this._clip.endTime) {
this.pause();
this._audio[0].currentTime = this._clip.endTime;

Yassine Doghri
committed
} else if (this._currentTime < this._clip.startTime) {
this._isBuffering = true;

Yassine Doghri
committed
this._audio[0].currentTime = this._clip.startTime;
} else {
this._isBuffering = false;

Yassine Doghri
committed
this.setCurrentTime(this._currentTime);
}
},
},
];

Yassine Doghri
committed
_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();
this._clip = {

Yassine Doghri
committed
startTime: this.initStartTime,
endTime: this.initStartTime + this.initDuration,
};
this._volume = this.initVolume;
}
protected firstUpdated(): void {
this._audioDuration = this._audio[0].duration;
this._audio[0].volume = this._volume;
this._startTimeInput[0].hidden = true;
this._durationInput[0].hidden = true;
this._wavesurfer = WaveSurfer.create({
container: this._waveformNode,

Yassine Doghri
committed
height: this.height,
barWidth: 2,
// barGap: 4,
waveColor: "hsl(0 5% 85%)",

Yassine Doghri
committed
cursorColor: "transparent",
});
this._wavesurfer.load(this._audio[0].src);

Yassine Doghri
committed
this.addEventListeners();
}

Yassine Doghri
committed
disconnectedCallback(): void {
super.disconnectedCallback();

Yassine Doghri
committed
this.removeEventListeners();

Yassine Doghri
committed
addEventListeners(): void {
for (const event of this._windowEvents) {
event.events.forEach((name) => {
window.addEventListener(name, event.onEvent);
});
}

Yassine Doghri
committed
for (const event of this._documentEvents) {
event.events.forEach((name) => {
document.addEventListener(name, event.onEvent);
});
}

Yassine Doghri
committed
for (const event of this._audioEvents) {
event.events.forEach((name) => {
this._audio[0].addEventListener(name, event.onEvent);
});
}

Yassine Doghri
committed
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);
}
});
}

Yassine Doghri
committed
}

Yassine Doghri
committed
removeEventListeners(): void {
for (const event of this._windowEvents) {
event.events.forEach((name) => {
window.removeEventListener(name, event.onEvent);
});
}
for (const event of this._documentEvents) {
event.events.forEach((name) => {
document.removeEventListener(name, event.onEvent);
});
}
for (const event of this._audioEvents) {
event.events.forEach((name) => {
this._audio[0].removeEventListener(name, event.onEvent);
});
}

Yassine Doghri
committed
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 {
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`;
}

Yassine Doghri
committed
private getPositionFromSeconds(seconds: number) {
return (seconds * this._sliderWidth) / this._audioDuration;
}

Yassine Doghri
committed
private getSecondsFromPosition(position: number) {
return (this._audioDuration * position) / this._sliderWidth;
}
protected updated(
_changedProperties: Map<string | number | symbol, unknown>
): void {
if (_changedProperties.has("_clip")) {
this.pause();
this.setSegmentPosition();
this._startTimeInput[0].value = this._clip.startTime.toString();
this._durationInput[0].value = (
this._clip.endTime - this._clip.startTime
).toFixed(3);

Yassine Doghri
committed
this._durationInput[0].dispatchEvent(new Event("change"));

Yassine Doghri
committed
this._audio[0].currentTime = this._clip.startTime;
}
if (_changedProperties.has("_seekingTime")) {
if (this._seekingTime) {
this._audio[0].currentTime = this._seekingTime;
}
}
}
play(): void {
this._audio[0].play();
}
pause(): void {
this._audio[0].pause();
}

Yassine Doghri
committed
private updatePosition(event: MouseEvent): void {

Yassine Doghri
committed
if (this._action === null) {
return;
}

Yassine Doghri
committed
event.clientX +
(this._action.payload?.offset || 0) -
(this._sliderNode.getBoundingClientRect().left +
document.documentElement.scrollLeft);
const seconds = this.getSecondsFromPosition(cursorPosition);

Yassine Doghri
committed
switch (this._action.type) {
case ActionType.StretchLeft: {
let startTime = 0;
if (seconds > 0) {
if (seconds > this._clip.endTime - this.minDuration) {
startTime = this._clip.endTime - this.minDuration;
} else {
startTime = seconds;
}
}
this._clip = {
startTime: parseFloat(startTime.toFixed(3)),
endTime: this._clip.endTime,
};
break;
}

Yassine Doghri
committed
case ActionType.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: parseFloat(endTime.toFixed(3)),

Yassine Doghri
committed
case ActionType.Seek: {

Yassine Doghri
committed
if (seconds < this._clip.startTime) {
this._seekingTime = this._clip.startTime;
} else if (seconds > this._clip.endTime) {
this._seekingTime = this._clip.endTime;
} else {
this._seekingTime = parseFloat(seconds.toFixed(3));

Yassine Doghri
committed
}
break;
}
default:
break;
}
}

Yassine Doghri
committed
goTo(event: MouseEvent): void {
const cursorPosition =
event.clientX -
(this._sliderNode.getBoundingClientRect().left +
document.documentElement.scrollLeft);
const seconds = this.getSecondsFromPosition(cursorPosition);

Yassine Doghri
committed
this._audio[0].currentTime = seconds;
}

Yassine Doghri
committed
setVolume(event: InputEvent): void {
this._volume = parseFloat((event.target as HTMLInputElement).value);
this._audio[0].volume = this._volume;
}
setCurrentTime(currentTime: number): void {
const seekingTimePosition = this.getPositionFromSeconds(currentTime);
const startTimePosition = this.getPositionFromSeconds(this._clip.startTime);
const seekingTimeSegmentPosition = seekingTimePosition - startTimePosition;
const seekingTimePercentage =
(seekingTimeSegmentPosition / this._segmentContentNode.clientWidth) *
this._segmentContentNode.clientWidth;
this._progressNode.style.transform = `translateX(${seekingTimeSegmentPosition}px)`;
this._seekingNode.style.transform = `scaleX(${seekingTimePercentage})`;
}

Yassine Doghri
committed
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,
};

Yassine Doghri
committed
break;
default:
break;
}
this._action = action;
}

Yassine Doghri
committed
private secondsToHHMMSS(seconds: number): string {
return new Date(seconds * 1000).toISOString().substr(11, 8);
}
trim(side: "start" | "end") {
if (side === "start") {
this._clip = {

Yassine Doghri
committed
startTime: parseFloat(this._audio[0].currentTime.toFixed(3)),
endTime: this._clip.endTime,
};
} else {
this._clip = {
startTime: this._clip.startTime,
endTime: this._currentTime,
};
}
}

Yassine Doghri
committed
.slider-wrapper {

Yassine Doghri
committed
width: 100%;
background-color: #0f172a;
}
.buffering-bar {
position: absolute;
width: 100%;
height: 4px;
background-color: gray;
bottom: -4px;
left: 0;
}

Yassine Doghri
committed
.slider {
position: absolute;
z-index: 10;
top: 0;
left: 0;
display: flex;
align-items: center;

Yassine Doghri
committed
height: 100%;
width: 100%;
}
.slider__segment--wrapper {
position: absolute;

Yassine Doghri
committed
height: 100%;
}
.slider__segment {
position: relative;
display: flex;
height: 120%;
top: -10%;
}
.slider__segment-content {
box-sizing: border-box;

Yassine Doghri
committed
background-color: rgba(255, 255, 255, 0.5);
height: 100%;
border-top: 2px dashed #b91c1c;
border-bottom: 2px dashed #b91c1c;

Yassine Doghri
committed
.slider__seeking-placeholder {
position: absolute;
pointer-events: none;
background-color: rgba(255, 255, 255, 0.5);
height: 100%;
width: 1px;
transform-origin: left;
}
.slider__segment-progress-handle {
position: absolute;

Yassine Doghri
committed
width: 20px;
height: 20px;

Yassine Doghri
committed
left: -10px;
margin-top: -2px;

Yassine Doghri
committed
border-radius: 50%;

Yassine Doghri
committed
box-shadow: 0 0 0 2px #ffffff;

Yassine Doghri
committed
}
.slider__segment-progress-handle::after {
position: absolute;
content: "";
width: 0px;
height: 0px;
bottom: -12px;

Yassine Doghri
committed
border: 10px solid transparent;
border-top-color: transparent;
border-top-style: solid;
border-top-width: 10px;
border-top: 10px solid #3b82f6;
}
.slider__segment .slider__segment-handle {
position: absolute;
width: 1rem;
height: 100%;
background-color: #b91c1c;
border: none;

Yassine Doghri
committed
margin: auto 0;
top: 0;
bottom: 0;
}
.slider__segment .slider__segment-handle::before {
content: "";
position: absolute;
height: 50%;
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;

Yassine Doghri
committed
.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;
}
.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);

Yassine Doghri
committed
border-radius: 0 0 0.75rem 0.75rem;
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
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;

Yassine Doghri
committed
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
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";
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
}
.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;

Yassine Doghri
committed
time {
font-size: 0.875rem;
font-family: "Mono";
}
`;
render(): TemplateResult<1> {
return html`
<slot name="audio"></slot>
<slot name="start_time"></slot>
<slot name="duration"></slot>

Yassine Doghri
committed
<div class="slider-wrapper" style="height:${this.height}">
<div id="waveform"></div>
<div class="slider" role="slider">
<div class="slider__segment--wrapper">

Yassine Doghri
committed
class="slider__segment-progress-handle"

Yassine Doghri
committed
@mousedown="${(event: MouseEvent) =>
this.setAction(event, { type: ActionType.Seek })}"

Yassine Doghri
committed
<div class="slider__segment">
<button
class="slider__segment-handle clipper__handle-left"

Yassine Doghri
committed
@mousedown="${(event: MouseEvent) =>
this.setAction(event, {
type: ActionType.StretchLeft,
})}"
>
<span>${this.secondsToHHMMSS(this._clip.startTime)}</span>
</button>

Yassine Doghri
committed
<div class="slider__seeking-placeholder"></div>
<div
class="slider__segment-content"

Yassine Doghri
committed
@mousedown="${(event: MouseEvent) =>
this.setAction(event, { type: ActionType.Seek })}"

Yassine Doghri
committed
@click="${(event: MouseEvent) => this.goTo(event)}"
></div>
<button
class="slider__segment-handle clipper__handle-right"

Yassine Doghri
committed
@mousedown="${(event: MouseEvent) =>
this.setAction(event, { type: ActionType.StretchRight })}"
>
<span>${this.secondsToHHMMSS(this._clip.endTime)}</span>
</button>

Yassine Doghri
committed
</div>
<canvas class="buffering-bar"></canvas>
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
<div class="toolbar">
<div class="toolbar__audio-controls">
<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"
fill="currentColor"
width="1em"
height="1em"
>
<g>
<path fill="none" d="M0 0h24v24H0z" />
<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"
/>
</svg>
<input
class="range-slider"
type="range"
id="volume"
min="0"
max="1"
step="0.1"
value="${this._volume}"
@change="${this.setVolume}"
/>
</div>

Yassine Doghri
committed
<time>${this.secondsToHHMMSS(this._currentTime)}</time>
</div>
<div class="toolbar__trim-controls">
<button @click="${() => this.trim("start")}">
${this.trimStartLabel}
</button>
<button @click="${() => this.trim("end")}">
${this.trimEndLabel}
</button>
</div>
</div>