Commit 02557539 authored by Yassine Doghri's avatar Yassine Doghri
Browse files

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

parent 01a09dc4
Loading
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ import Soundbites from "./modules/Soundbites";
import ThemePicker from "./modules/ThemePicker";
import Time from "./modules/Time";
import Tooltip from "./modules/Tooltip";
import "./modules/video-clip-previewer";
import "./modules/xml-editor";

Dropdown();
+226 −62
Original line number Diff line number Diff line
@@ -48,6 +48,9 @@ export class AudioClipper extends LitElement {
  @query("#waveform")
  _waveformNode!: HTMLDivElement;

  @query(".buffering-bar")
  _bufferingBarNode!: HTMLCanvasElement;

  @property({ type: Number, attribute: "start-time" })
  initStartTime = 0;

@@ -87,15 +90,15 @@ export class AudioClipper extends LitElement {
  @state()
  _volume = 0.5;

  @state()
  _isLoading = false;

  @state()
  _seekingTime: number | null = null;

  @state()
  _wavesurfer!: WaveSurfer;

  @state()
  _isBuffering = false;

  _windowEvents: EventElement[] = [
    {
      events: ["load", "resize"],
@@ -144,21 +147,46 @@ export class AudioClipper extends LitElement {
      },
    },
    {
      events: ["complete"],
      events: ["progress"],
      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"],
      onEvent: () => {
        // TODO: change this
        this._currentTime = this._audio[0].currentTime;
        // TODO: change this?
        this._currentTime = parseFloat(this._audio[0].currentTime.toFixed(3));
        if (this._currentTime > this._clip.endTime) {
          this.pause();
          this._audio[0].currentTime = this._clip.endTime;
        } else if (this._currentTime < this._clip.startTime) {
          this._isBuffering = true;
          this._audio[0].currentTime = this._clip.startTime;
        } else {
          this._isBuffering = false;
          this.setCurrentTime(this._currentTime);
        }
      },
@@ -178,17 +206,18 @@ export class AudioClipper extends LitElement {
  protected firstUpdated(): void {
    this._audioDuration = this._audio[0].duration;
    this._audio[0].volume = this._volume;
    this._audio[0].currentTime = this._clip.startTime;
    this._isLoading = true;
    this._startTimeInput[0].hidden = true;
    this._durationInput[0].hidden = true;

    this._wavesurfer = WaveSurfer.create({
      container: this._waveformNode,
      height: this.height,
      interact: false,
      barWidth: 4,
      barWidth: 2,
      barHeight: 1,
      barGap: 4,
      // barGap: 4,
      responsive: true,
      waveColor: "hsl(0 5% 85%)",
      cursorColor: "transparent",
    });
    this._wavesurfer.load(this._audio[0].src);
@@ -266,6 +295,11 @@ export class AudioClipper extends LitElement {
    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);
      this._audio[0].currentTime = this._clip.startTime;
    }
    if (_changedProperties.has("_seekingTime")) {
@@ -293,18 +327,16 @@ export class AudioClipper extends LitElement {

    switch (this._action) {
      case ACTIONS.StretchLeft: {
        let startTime;
        let startTime = 0;
        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,
          startTime: parseFloat(startTime.toFixed(3)),
          endTime: this._clip.endTime,
        };
        break;
@@ -323,7 +355,7 @@ export class AudioClipper extends LitElement {

        this._clip = {
          startTime: this._clip.startTime,
          endTime,
          endTime: parseFloat(endTime.toFixed(3)),
        };
        break;
      }
@@ -333,7 +365,7 @@ export class AudioClipper extends LitElement {
        } else if (seconds > this._clip.endTime) {
          this._seekingTime = this._clip.endTime;
        } else {
          this._seekingTime = seconds;
          this._seekingTime = parseFloat(seconds.toFixed(3));
        }
        break;
      }
@@ -386,6 +418,20 @@ export class AudioClipper extends LitElement {
    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`
    .slider-wrapper {
      position: relative;
@@ -393,6 +439,15 @@ export class AudioClipper extends LitElement {
      background-color: #0f172a;
    }

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

    .slider {
      position: absolute;
      z-index: 10;
@@ -404,12 +459,6 @@ export class AudioClipper extends LitElement {
      width: 100%;
    }

    .slider__track-placeholder {
      width: 100%;
      height: 8px;
      background-color: #64748b;
    }

    .slider__segment--wrapper {
      position: absolute;
      height: 100%;
@@ -418,14 +467,17 @@ export class AudioClipper extends LitElement {
    .slider__segment {
      position: relative;
      display: flex;
      height: 100%;
      height: 120%;
      top: -10%;
    }

    .slider__segment-content {
      box-sizing: border-box;
      background-color: rgba(255, 255, 255, 0.5);
      height: 100%;
      width: 1px;
      border: none;
      border-top: 2px dashed #b91c1c;
      border-bottom: 2px dashed #b91c1c;
    }

    .slider__seeking-placeholder {
@@ -441,8 +493,9 @@ export class AudioClipper extends LitElement {
      position: absolute;
      width: 20px;
      height: 20px;
      top: -23px;
      top: -50%;
      left: -10px;
      margin-top: -2px;
      background-color: #3b82f6;
      border-radius: 50%;
    }
@@ -453,7 +506,7 @@ export class AudioClipper extends LitElement {
      width: 0px;
      height: 0px;
      bottom: -12px;
      left: 1px;
      left: 0;
      border: 10px solid transparent;
      border-top-color: transparent;
      border-top-style: solid;
@@ -464,7 +517,7 @@ export class AudioClipper extends LitElement {
    .slider__segment .slider__segment-handle {
      position: absolute;
      width: 1rem;
      height: 120%;
      height: 100%;
      background-color: #b91c1c;
      border: none;
      margin: auto 0;
@@ -475,7 +528,7 @@ export class AudioClipper extends LitElement {
    .slider__segment .slider__segment-handle::before {
      content: "";
      position: absolute;
      height: 3rem;
      height: 50%;
      width: 2px;
      background-color: #ffffff;
      margin: auto;
@@ -487,12 +540,79 @@ export class AudioClipper extends LitElement {

    .slider__segment .clipper__handle-left {
      left: -1rem;
      border-radius: 0.2rem 9999px 9999px 0.2rem;
      border-radius: 0.2rem 0 0 0.2rem;
    }

    .slider__segment .clipper__handle-right {
      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="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>
      <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 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"
@@ -543,9 +649,36 @@ export class AudioClipper extends LitElement {
            </div>
          </div>
        </div>
        <canvas class="buffering-bar"></canvas>
      </div>
      <button @click="${this._isPlaying ? this.pause : this.play}">
        ${this._isPlaying
      <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"
@@ -569,6 +702,37 @@ export class AudioClipper extends LitElement {
                  />
                </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"
                />
              </g>
            </svg>
            <input
              class="range-slider"
              type="range"
              id="volume"
              min="0"
              max="1"
              step="0.1"
              value="${this._volume}"
              @change="${this.setVolume}"
            />
          </div>
        </div>
        <div class="toolbar__trim-controls">
          <button @click="${() => this.trim("start")}">Trim start</button>
          <button @click="${() => this.trim("end")}">Trim end</button>
        </div>
      </div>
    `;
  }
}
+58 −0
Original line number Diff line number Diff line
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>`;
  }
}
+8 −6
Original line number Diff line number Diff line
@@ -10,12 +10,14 @@

<?= $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="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 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.
        </audio>
        <input slot="start_time" type="number" name="start_time" placeholder="<?= lang('VideoClip.form.start_time') ?>" step="0.001" />
@@ -23,7 +25,7 @@
    </audio-clipper>
</div>

<!-- <Forms.Section title="<?= lang('VideoClip.form.params_section_title') ?>" >
<Forms.Section title="<?= lang('VideoClip.form.params_section_title') ?>" >

<Forms.Field
    name="label"
@@ -62,7 +64,7 @@

<Button variant="primary" type="submit" iconRight="arrow-right" class="self-end"><?= lang('VideoClip.form.submit') ?></Button>

</Forms.Section> -->
</Forms.Section>

</form>