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

feat: add audio-clipper webcomponent (wip)

parent 7609bb60
Loading
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
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";
+440 −0
Original line number Diff line number Diff line
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>
    `;
  }
}
+42 −0
Original line number Diff line number Diff line
@@ -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"
    },
+2 −0
Original line number Diff line number Diff line
@@ -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",
+14 −20
Original line number Diff line number Diff line
@@ -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>