From 21d4251b9bcd5acb0f8a1761bc4edc34a3dbc228 Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Tue, 28 Dec 2021 16:59:19 +0000
Subject: [PATCH] feat: add audio-clipper webcomponent (wip)

---
 app/Resources/js/admin.ts                   |   1 +
 app/Resources/js/modules/audio-clipper.ts   | 440 ++++++++++++++++++++
 package-lock.json                           |  42 ++
 package.json                                |   2 +
 themes/cp_admin/episode/video_clips_new.php |  34 +-
 5 files changed, 499 insertions(+), 20 deletions(-)
 create mode 100644 app/Resources/js/modules/audio-clipper.ts

diff --git a/app/Resources/js/admin.ts b/app/Resources/js/admin.ts
index cfb0ef080b..d4c31d3e2c 100644
--- a/app/Resources/js/admin.ts
+++ b/app/Resources/js/admin.ts
@@ -1,5 +1,6 @@
 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";
diff --git a/app/Resources/js/modules/audio-clipper.ts b/app/Resources/js/modules/audio-clipper.ts
new file mode 100644
index 0000000000..aed446318e
--- /dev/null
+++ b/app/Resources/js/modules/audio-clipper.ts
@@ -0,0 +1,440 @@
+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>
+    `;
+  }
+}
diff --git a/package-lock.json b/package-lock.json
index cc03f411bc..aebd3b9952 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
     },
diff --git a/package.json b/package.json
index 0016ef2185..d619d3f90d 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,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": {
@@ -61,6 +62,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",
diff --git a/themes/cp_admin/episode/video_clips_new.php b/themes/cp_admin/episode/video_clips_new.php
index 442df6f465..6df761c26d 100644
--- a/themes/cp_admin/episode/video_clips_new.php
+++ b/themes/cp_admin/episode/video_clips_new.php
@@ -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>
 
-- 
GitLab