From de19317138a2106deb825c1eed7dda036ed7dac3 Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Mon, 3 Jan 2022 13:52:07 +0000
Subject: [PATCH] feat(soundbites): add soundbite list and creation forms with
 audio-clipper component

---
 .../2021-12-09-130000_add_clips.php           |   3 +-
 app/Entities/Clip/BaseClip.php                |   4 +-
 app/Helpers/rss_helper.php                    |   4 +-
 app/Models/ClipModel.php                      |  76 ++++---
 app/Resources/js/admin.ts                     |   3 +-
 app/Resources/js/modules/Soundbites.ts        |  87 --------
 app/Resources/js/modules/play-soundbite.ts    | 198 ++++++++++++++++++
 modules/Admin/Config/Routes.php               |  21 +-
 .../Admin/Controllers/EpisodeController.php   |  78 -------
 .../Admin/Controllers/SoundbiteController.php | 163 ++++++++++++++
 .../Controllers/VideoClipsController.php      |   8 +-
 modules/Admin/Language/en/Episode.php         |  19 --
 .../Admin/Language/en/EpisodeNavigation.php   |   3 +-
 modules/Admin/Language/en/Soundbite.php       |  27 +++
 modules/Admin/Language/fr/Episode.php         |  20 --
 .../Admin/Language/fr/EpisodeNavigation.php   |   3 +-
 modules/Admin/Language/fr/Soundbite.php       |  27 +++
 themes/cp_admin/episode/_sidebar.php          |   2 +-
 themes/cp_admin/episode/soundbites.php        |  72 -------
 themes/cp_admin/episode/soundbites_list.php   |  48 +++++
 themes/cp_admin/episode/soundbites_new.php    |  35 ++++
 themes/cp_admin/episode/video_clip.php        |   4 +-
 themes/cp_admin/episode/video_clips_list.php  |   4 +-
 themes/cp_admin/episode/video_clips_new.php   |   4 +-
 24 files changed, 582 insertions(+), 331 deletions(-)
 delete mode 100644 app/Resources/js/modules/Soundbites.ts
 create mode 100644 app/Resources/js/modules/play-soundbite.ts
 create mode 100644 modules/Admin/Controllers/SoundbiteController.php
 create mode 100644 modules/Admin/Language/en/Soundbite.php
 create mode 100644 modules/Admin/Language/fr/Soundbite.php
 delete mode 100644 themes/cp_admin/episode/soundbites.php
 create mode 100644 themes/cp_admin/episode/soundbites_list.php
 create mode 100644 themes/cp_admin/episode/soundbites_new.php

diff --git a/app/Database/Migrations/2021-12-09-130000_add_clips.php b/app/Database/Migrations/2021-12-09-130000_add_clips.php
index 0c42a92299..cf5a24fd54 100644
--- a/app/Database/Migrations/2021-12-09-130000_add_clips.php
+++ b/app/Database/Migrations/2021-12-09-130000_add_clips.php
@@ -39,10 +39,9 @@ class AddClips extends Migration
                 'type' => 'DECIMAL(7,3)',
                 'unsigned' => true,
             ],
-            'label' => [
+            'title' => [
                 'type' => 'VARCHAR',
                 'constraint' => 128,
-                'null' => true,
             ],
             'type' => [
                 'type' => 'ENUM',
diff --git a/app/Entities/Clip/BaseClip.php b/app/Entities/Clip/BaseClip.php
index 00057c2ed2..e989d86542 100644
--- a/app/Entities/Clip/BaseClip.php
+++ b/app/Entities/Clip/BaseClip.php
@@ -29,7 +29,7 @@ use Modules\Auth\Entities\User;
  * @property Podcast $podcast
  * @property int $episode_id
  * @property Episode $episode
- * @property string $label
+ * @property string $title
  * @property double $start_time
  * @property double $end_time
  * @property double $duration
@@ -68,7 +68,7 @@ class BaseClip extends Entity
         'id' => 'integer',
         'podcast_id' => 'integer',
         'episode_id' => 'integer',
-        'label' => 'string',
+        'title' => 'string',
         'start_time' => 'double',
         'duration' => 'double',
         'type' => 'string',
diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php
index bee957f962..d30520986e 100644
--- a/app/Helpers/rss_helper.php
+++ b/app/Helpers/rss_helper.php
@@ -255,7 +255,7 @@ if (! function_exists('get_rss_feed')) {
             $comments->addAttribute('uri', url_to('episode-comments', $podcast->handle, $episode->slug));
             $comments->addAttribute('contentType', 'application/podcast-activity+json');
 
-            if ($episode->transcript->file_url !== '') {
+            if ($episode->transcript !== null) {
                 $transcriptElement = $item->addChild('transcript', null, $podcastNamespace);
                 $transcriptElement->addAttribute('url', $episode->transcript->file_url);
                 $transcriptElement->addAttribute(
@@ -275,7 +275,7 @@ if (! function_exists('get_rss_feed')) {
 
             foreach ($episode->soundbites as $soundbite) {
                 // TODO: differentiate video from soundbites?
-                $soundbiteElement = $item->addChild('soundbite', $soundbite->label, $podcastNamespace);
+                $soundbiteElement = $item->addChild('soundbite', $soundbite->title, $podcastNamespace);
                 $soundbiteElement->addAttribute('start_time', (string) $soundbite->start_time);
                 $soundbiteElement->addAttribute('duration', (string) $soundbite->duration);
             }
diff --git a/app/Models/ClipModel.php b/app/Models/ClipModel.php
index 94364cd209..fca84b0ce5 100644
--- a/app/Models/ClipModel.php
+++ b/app/Models/ClipModel.php
@@ -39,7 +39,7 @@ class ClipModel extends Model
         'id',
         'podcast_id',
         'episode_id',
-        'label',
+        'title',
         'start_time',
         'duration',
         'type',
@@ -89,33 +89,6 @@ class ClipModel extends Model
         parent::__construct($db, $validation);
     }
 
-    /**
-     * Gets all clips for an episode
-     *
-     * @return Soundbite[]
-     */
-    public function getEpisodeSoundbites(int $podcastId, int $episodeId): array
-    {
-        $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_soundbites";
-        if (! ($found = cache($cacheName))) {
-            $found = $this->where([
-                'episode_id' => $episodeId,
-                'podcast_id' => $podcastId,
-                'type' => 'audio',
-            ])
-                ->orderBy('start_time')
-                ->findAll();
-
-            foreach ($found as $key => $soundbite) {
-                $found[$key] = new Soundbite($soundbite->toArray());
-            }
-
-            cache()
-                ->save($cacheName, $found, DECADE);
-        }
-        return $found;
-    }
-
     public function getVideoClipById(int $videoClipId): ?VideoClip
     {
         $cacheName = "video-clip#{$videoClipId}";
@@ -184,6 +157,53 @@ class ClipModel extends Model
         return $found;
     }
 
+    public function getSoundbiteById(int $soundbiteId): ?Soundbite
+    {
+        $cacheName = "soundbite#{$soundbiteId}";
+        if (! ($found = cache($cacheName))) {
+            $clip = $this->find($soundbiteId);
+
+            if ($clip === null) {
+                return null;
+            }
+
+            // @phpstan-ignore-next-line
+            $found = new Soundbite($clip->toArray());
+
+            cache()
+                ->save($cacheName, $found, DECADE);
+        }
+
+        return $found;
+    }
+
+    /**
+     * Gets all clips for an episode
+     *
+     * @return Soundbite[]
+     */
+    public function getEpisodeSoundbites(int $podcastId, int $episodeId): array
+    {
+        $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_soundbites";
+        if (! ($found = cache($cacheName))) {
+            $found = $this->where([
+                'episode_id' => $episodeId,
+                'podcast_id' => $podcastId,
+                'type' => 'audio',
+            ])
+                ->orderBy('start_time')
+                ->findAll();
+
+            foreach ($found as $key => $soundbite) {
+                $found[$key] = new Soundbite($soundbite->toArray());
+            }
+
+            cache()
+                ->save($cacheName, $found, DECADE);
+        }
+        return $found;
+    }
+
     public function deleteSoundbite(int $podcastId, int $episodeId, int $clipId): BaseResult | bool
     {
         cache()
diff --git a/app/Resources/js/admin.ts b/app/Resources/js/admin.ts
index 310cc336b8..d9ee93cb82 100644
--- a/app/Resources/js/admin.ts
+++ b/app/Resources/js/admin.ts
@@ -10,11 +10,11 @@ import "./modules/markdown-preview";
 import "./modules/markdown-write-preview";
 import MultiSelect from "./modules/MultiSelect";
 import "./modules/permalink-edit";
+import "./modules/play-soundbite";
 import PublishMessageWarning from "./modules/PublishMessageWarning";
 import Select from "./modules/Select";
 import SidebarToggler from "./modules/SidebarToggler";
 import Slugify from "./modules/Slugify";
-import Soundbites from "./modules/Soundbites";
 import ThemePicker from "./modules/ThemePicker";
 import Time from "./modules/Time";
 import Tooltip from "./modules/Tooltip";
@@ -31,7 +31,6 @@ SidebarToggler();
 ClientTimezone();
 DateTimePicker();
 Time();
-Soundbites();
 Clipboard();
 ThemePicker();
 PublishMessageWarning();
diff --git a/app/Resources/js/modules/Soundbites.ts b/app/Resources/js/modules/Soundbites.ts
deleted file mode 100644
index 16fb174b44..0000000000
--- a/app/Resources/js/modules/Soundbites.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-/**
- * TODO: refactor file
- */
-let timeout: number | null = null;
-
-const playSoundbite = (
-  audioPlayer: HTMLAudioElement,
-  startTime: number,
-  duration: number
-): void => {
-  audioPlayer.currentTime = startTime;
-  if (duration > 0) {
-    audioPlayer.play();
-    if (timeout) {
-      clearTimeout(timeout);
-      timeout = null;
-    }
-    timeout = window.setTimeout(() => {
-      audioPlayer.pause();
-      timeout = null;
-    }, duration * 1000);
-  }
-};
-
-const Soundbites = (): void => {
-  const audioPlayer: HTMLAudioElement | null = document.querySelector("audio");
-
-  if (audioPlayer) {
-    const soundbiteButton: HTMLButtonElement | null = document.querySelector(
-      "button[data-type='get-soundbite']"
-    );
-    if (soundbiteButton) {
-      const startTimeField: HTMLInputElement | null = document.querySelector(
-        `input[name="${soundbiteButton.dataset.startTimeFieldName}"]`
-      );
-      const durationField: HTMLInputElement | null = document.querySelector(
-        `input[name="${soundbiteButton.dataset.durationFieldName}"]`
-      );
-
-      if (startTimeField && durationField) {
-        soundbiteButton.addEventListener("click", () => {
-          if (startTimeField.value === "") {
-            startTimeField.value = (
-              Math.round(audioPlayer.currentTime * 100) / 100
-            ).toString();
-          } else {
-            durationField.value = (
-              Math.round(
-                (audioPlayer.currentTime - Number(startTimeField.value)) * 100
-              ) / 100
-            ).toString();
-          }
-        });
-      }
-    }
-
-    const soundbitePlayButtons: NodeListOf<HTMLButtonElement> | null =
-      document.querySelectorAll("button[data-type='play-soundbite']");
-    if (soundbitePlayButtons) {
-      for (let i = 0; i < soundbitePlayButtons.length; i++) {
-        const soundbitePlayButton: HTMLButtonElement = soundbitePlayButtons[i];
-
-        soundbitePlayButton.addEventListener("click", () => {
-          // get values from inputs to play soundbite
-          const startTime: HTMLInputElement | null | undefined =
-            soundbitePlayButton.parentElement?.parentElement?.querySelector(
-              'input[data-field-type="start_time"]'
-            );
-          const duration: HTMLInputElement | null | undefined =
-            soundbitePlayButton.parentElement?.parentElement?.querySelector(
-              'input[data-field-type="duration"]'
-            );
-
-          if (startTime && duration) {
-            playSoundbite(
-              audioPlayer,
-              parseFloat(startTime.value),
-              parseFloat(duration.value)
-            );
-          }
-        });
-      }
-    }
-  }
-};
-
-export default Soundbites;
diff --git a/app/Resources/js/modules/play-soundbite.ts b/app/Resources/js/modules/play-soundbite.ts
new file mode 100644
index 0000000000..26f24086dc
--- /dev/null
+++ b/app/Resources/js/modules/play-soundbite.ts
@@ -0,0 +1,198 @@
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+
+@customElement("play-soundbite")
+export class PlaySoundbite extends LitElement {
+  @property({ attribute: "audio-src" })
+  audioSrc!: string;
+
+  @property({ type: Number, attribute: "start-time" })
+  startTime!: number;
+
+  @property({ type: Number })
+  duration!: number;
+
+  @property({ attribute: "play-label" })
+  playLabel!: string;
+
+  @property({ attribute: "playing-label" })
+  playingLabel!: string;
+
+  @state()
+  _audio: HTMLAudioElement | null = null;
+
+  @state()
+  _isPlaying = false;
+
+  @state()
+  _isLoading = false;
+
+  _audioEvents = [
+    {
+      name: "play",
+      onEvent: () => {
+        this._isPlaying = true;
+      },
+    },
+    {
+      name: "pause",
+      onEvent: () => {
+        this._isPlaying = false;
+      },
+    },
+    {
+      name: "timeupdate",
+      onEvent: () => {
+        if (this._audio) {
+          console.log(
+            this._audio.currentTime,
+            this.startTime,
+            this.startTime + this.duration
+          );
+          if (this._audio.currentTime < this.startTime) {
+            this._isLoading = true;
+            this._audio.currentTime = this.startTime;
+          } else if (this._audio.currentTime > this.startTime + this.duration) {
+            this.stopSoundbite();
+          } else {
+            this._isLoading = false;
+          }
+        }
+      },
+    },
+  ];
+
+  playSoundbite() {
+    if (this._audio === null) {
+      this._audio = new Audio(this.audioSrc);
+      for (const event of this._audioEvents) {
+        this._audio.addEventListener(event.name, event.onEvent);
+      }
+    }
+
+    this._audio.currentTime = this.startTime;
+    this._audio.play();
+  }
+
+  stopSoundbite() {
+    if (this._audio !== null) {
+      this._audio.pause();
+      this._audio.currentTime = this.startTime;
+    }
+  }
+
+  disconnectedCallback(): void {
+    if (this._audio) {
+      for (const event of this._audioEvents) {
+        this._audio.removeEventListener(event.name, event.onEvent);
+      }
+    }
+  }
+
+  static styles = css`
+    button {
+      background-color: hsl(var(--color-accent-base));
+      cursor: pointer;
+      display: inline-flex;
+      align-items: center;
+      padding: 0.5rem;
+      font-size: 0.875rem;
+      border: 2px solid transparent;
+      border-radius: 9999px;
+
+      box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+    }
+
+    button:hover {
+      background-color: hsl(var(--color-accent-hover));
+    }
+
+    button:focus {
+      outline: none;
+      box-shadow: 0 0 0 2px hsl(var(--color-background-base)),
+        0 0 0 4px hsl(var(--color-accent-base));
+    }
+
+    button.playing {
+      background-color: hsl(var(--color-background-base));
+      border: 2px solid hsl(var(--color-accent-base));
+    }
+
+    button.playing:hover {
+      background-color: hsl(var(--color-background-elevated));
+    }
+
+    button.playing svg {
+      color: hsl(var(--color-accent-base));
+    }
+
+    svg {
+      color: hsl(var(--color-accent-contrast));
+    }
+
+    @keyframes spin {
+      to {
+        transform: rotate(360deg);
+      }
+    }
+
+    .animate-spin {
+      animation: spin 3s linear infinite;
+    }
+  `;
+
+  render(): TemplateResult<1> {
+    return html`<button
+      @click="${this._isPlaying ? this.stopSoundbite : this.playSoundbite}"
+      title="${this._isPlaying ? this.playingLabel : this.playLabel}"
+    >
+      ${this._isLoading
+        ? 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
+            class="animate-spin"
+            viewBox="0 0 24 24"
+            fill="currentColor"
+            width="1em"
+            height="1em"
+          >
+            <g>
+              <path fill="none" d="M0 0h24v24H0z" />
+              <path
+                d="M13 9.17A3 3 0 1 0 15 12V2.458c4.057 1.274 7 5.064 7 9.542 0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2c.337 0 .671.017 1 .05v7.12z"
+              />
+            </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/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php
index dcaf460e12..c22fba6998 100644
--- a/modules/Admin/Config/Routes.php
+++ b/modules/Admin/Config/Routes.php
@@ -334,24 +334,33 @@ $routes->group(
                         );
                         $routes->get(
                             'soundbites',
-                            'EpisodeController::soundbitesEdit/$1/$2',
+                            'SoundbiteController::list/$1/$2',
                             [
-                                'as' => 'soundbites-edit',
+                                'as' => 'soundbites-list',
+                                'filter' => 'permission:podcast_episodes-edit',
+                            ],
+                        );
+                        $routes->get(
+                            'soundbites/new',
+                            'SoundbiteController::create/$1/$2',
+                            [
+                                'as' => 'soundbites-create',
                                 'filter' => 'permission:podcast_episodes-edit',
                             ],
                         );
                         $routes->post(
-                            'soundbites',
-                            'EpisodeController::soundbitesAttemptEdit/$1/$2',
+                            'soundbites/new',
+                            'SoundbiteController::attemptCreate/$1/$2',
                             [
+                                'as' => 'soundbites-create',
                                 'filter' => 'permission:podcast_episodes-edit',
                             ],
                         );
                         $routes->get(
                             'soundbites/(:num)/delete',
-                            'EpisodeController::soundbiteDelete/$1/$2/$3',
+                            'SoundbiteController::delete/$1/$2/$3',
                             [
-                                'as' => 'soundbite-delete',
+                                'as' => 'soundbites-delete',
                                 'filter' => 'permission:podcast_episodes-edit',
                             ],
                         );
diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php
index 7cdb144a8f..a2a2c57e2b 100644
--- a/modules/Admin/Controllers/EpisodeController.php
+++ b/modules/Admin/Controllers/EpisodeController.php
@@ -15,7 +15,6 @@ use App\Entities\EpisodeComment;
 use App\Entities\Location;
 use App\Entities\Podcast;
 use App\Entities\Post;
-use App\Models\ClipModel;
 use App\Models\EpisodeCommentModel;
 use App\Models\EpisodeModel;
 use App\Models\MediaModel;
@@ -719,83 +718,6 @@ class EpisodeController extends BaseController
         return redirect()->route('episode-list', [$this->podcast->id]);
     }
 
-    public function soundbitesEdit(): string
-    {
-        helper(['form']);
-
-        $data = [
-            'podcast' => $this->podcast,
-            'episode' => $this->episode,
-        ];
-
-        replace_breadcrumb_params([
-            0 => $this->podcast->title,
-            1 => $this->episode->title,
-        ]);
-        return view('episode/soundbites', $data);
-    }
-
-    public function soundbitesAttemptEdit(): RedirectResponse
-    {
-        $soundbites = $this->request->getPost('soundbites');
-        $rules = [
-            'soundbites.0.start_time' =>
-                'permit_empty|required_with[soundbites.0.duration]|decimal|greater_than_equal_to[0]',
-            'soundbites.0.duration' =>
-                'permit_empty|required_with[soundbites.0.start_time]|decimal|greater_than_equal_to[0]',
-        ];
-        foreach (array_keys($soundbites) as $soundbite_id) {
-            $rules += [
-                "soundbites.{$soundbite_id}.start_time" => 'required|decimal|greater_than_equal_to[0]',
-                "soundbites.{$soundbite_id}.duration" => 'required|decimal|greater_than_equal_to[0]',
-            ];
-        }
-
-        if (! $this->validate($rules)) {
-            return redirect()
-                ->back()
-                ->withInput()
-                ->with('errors', $this->validator->getErrors());
-        }
-
-        foreach ($soundbites as $soundbite_id => $soundbite) {
-            $data = [
-                'podcast_id' => $this->podcast->id,
-                'episode_id' => $this->episode->id,
-                'start_time' => (float) $soundbite['start_time'],
-                'duration' => (float) $soundbite['duration'],
-                'label' => $soundbite['label'],
-                'updated_by' => user_id(),
-            ];
-            if ($soundbite_id === 0) {
-                $data += [
-                    'created_by' => user_id(),
-                ];
-            } else {
-                $data += [
-                    'id' => $soundbite_id,
-                ];
-            }
-
-            $soundbiteModel = new SoundbiteModel();
-            if (! $soundbiteModel->save($data)) {
-                return redirect()
-                    ->back()
-                    ->withInput()
-                    ->with('errors', $soundbiteModel->errors());
-            }
-        }
-
-        return redirect()->route('soundbites-edit', [$this->podcast->id, $this->episode->id]);
-    }
-
-    public function soundbiteDelete(string $clipId): RedirectResponse
-    {
-        (new ClipModel())->deleteClip($this->podcast->id, $this->episode->id, (int) $clipId);
-
-        return redirect()->route('clips-edit', [$this->podcast->id, $this->episode->id]);
-    }
-
     public function embed(): string
     {
         helper(['form']);
diff --git a/modules/Admin/Controllers/SoundbiteController.php b/modules/Admin/Controllers/SoundbiteController.php
new file mode 100644
index 0000000000..c13b2453c6
--- /dev/null
+++ b/modules/Admin/Controllers/SoundbiteController.php
@@ -0,0 +1,163 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\Admin\Controllers;
+
+use App\Entities\Clip\Soundbite;
+use App\Entities\Episode;
+use App\Entities\Podcast;
+use App\Models\ClipModel;
+use App\Models\EpisodeModel;
+use App\Models\MediaModel;
+use App\Models\PodcastModel;
+use CodeIgniter\Exceptions\PageNotFoundException;
+use CodeIgniter\HTTP\RedirectResponse;
+
+class SoundbiteController extends BaseController
+{
+    protected Podcast $podcast;
+
+    protected Episode $episode;
+
+    public function _remap(string $method, string ...$params): mixed
+    {
+        if (
+            ($podcast = (new PodcastModel())->getPodcastById((int) $params[0])) === null
+        ) {
+            throw PageNotFoundException::forPageNotFound();
+        }
+
+        $this->podcast = $podcast;
+
+        if (count($params) > 1) {
+            if (
+                ! ($episode = (new EpisodeModel())
+                    ->where([
+                        'id' => $params[1],
+                        'podcast_id' => $params[0],
+                    ])
+                    ->first())
+            ) {
+                throw PageNotFoundException::forPageNotFound();
+            }
+
+            $this->episode = $episode;
+
+            unset($params[1]);
+            unset($params[0]);
+        }
+
+        return $this->{$method}(...$params);
+    }
+
+    public function list(): string
+    {
+        $soundbitesBuilder = (new ClipModel('audio'))
+            ->where([
+                'podcast_id' => $this->podcast->id,
+                'episode_id' => $this->episode->id,
+                'type' => 'audio',
+            ])
+            ->orderBy('created_at', 'desc');
+
+        $soundbites = $soundbitesBuilder->paginate(10);
+
+        $data = [
+            'podcast' => $this->podcast,
+            'episode' => $this->episode,
+            'soundbites' => $soundbites,
+            'pager' => $soundbitesBuilder->pager,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+            1 => $this->episode->title,
+        ]);
+        return view('episode/soundbites_list', $data);
+    }
+
+    public function create(): string
+    {
+        helper(['form']);
+
+        $data = [
+            'podcast' => $this->podcast,
+            'episode' => $this->episode,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+            1 => $this->episode->title,
+        ]);
+        return view('episode/soundbites_new', $data);
+    }
+
+    public function attemptCreate(): RedirectResponse
+    {
+        $rules = [
+            'title' => 'required',
+            'start_time' => 'required|greater_than_equal_to[0]',
+            'duration' => 'required|greater_than[0]',
+        ];
+
+        if (! $this->validate($rules)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $this->validator->getErrors());
+        }
+
+        $newSoundbite = new Soundbite([
+            'title' => $this->request->getPost('title'),
+            'start_time' => (float) $this->request->getPost('start_time'),
+            'duration' => (float) $this->request->getPost('duration',),
+            'type' => 'audio',
+            'status' => '',
+            'podcast_id' => $this->podcast->id,
+            'episode_id' => $this->episode->id,
+            'created_by' => user_id(),
+            'updated_by' => user_id(),
+        ]);
+
+        $clipModel = new ClipModel('audio');
+        if (! $clipModel->save($newSoundbite)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $clipModel->errors());
+        }
+
+        return redirect()->route('soundbites-list', [$this->podcast->id, $this->episode->id]);
+    }
+
+    public function delete(string $soundbiteId): RedirectResponse
+    {
+        $soundbite = (new ClipModel())->getSoundbiteById((int) $soundbiteId);
+
+        if ($soundbite === null) {
+            throw PageNotFoundException::forPageNotFound();
+        }
+
+        if ($soundbite->media === null) {
+            // delete Clip directly
+            (new ClipModel())->delete($soundbite->id);
+        } else {
+            $mediaModel = new MediaModel();
+            if (! $mediaModel->deleteMedia($soundbite->media)) {
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('errors', $mediaModel->errors());
+            }
+        }
+
+        return redirect()->route('soundbites-list', [$this->podcast->id, $this->episode->id]);
+    }
+}
diff --git a/modules/Admin/Controllers/VideoClipsController.php b/modules/Admin/Controllers/VideoClipsController.php
index 712377e8b2..2552b5eac4 100644
--- a/modules/Admin/Controllers/VideoClipsController.php
+++ b/modules/Admin/Controllers/VideoClipsController.php
@@ -101,7 +101,7 @@ class VideoClipsController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
             1 => $this->episode->title,
-            2 => $videoClip->label,
+            2 => $videoClip->title,
         ]);
         return view('episode/video_clip', $data);
     }
@@ -140,8 +140,8 @@ class VideoClipsController extends BaseController
     public function attemptCreate(): RedirectResponse
     {
         $rules = [
-            'label' => 'required',
-            'start_time' => 'required|numeric',
+            'title' => 'required',
+            'start_time' => 'required|greater_than_equal_to[0]',
             'duration' => 'required|greater_than[0]',
             'format' => 'required|in_list[' . implode(',', array_keys(config('MediaClipper')->formats)) . ']',
             'theme' => 'required|in_list[' . implode(',', array_keys(config('Colors')->themes)) . ']',
@@ -163,7 +163,7 @@ class VideoClipsController extends BaseController
         ];
 
         $videoClip = new VideoClip([
-            'label' => $this->request->getPost('label'),
+            'title' => $this->request->getPost('title'),
             'start_time' => (float) $this->request->getPost('start_time'),
             'duration' => (float) $this->request->getPost('duration',),
             'theme' => $theme,
diff --git a/modules/Admin/Language/en/Episode.php b/modules/Admin/Language/en/Episode.php
index b3b2354684..7b3d32189d 100644
--- a/modules/Admin/Language/en/Episode.php
+++ b/modules/Admin/Language/en/Episode.php
@@ -144,25 +144,6 @@ return [
         'understand' => 'I understand, I want to delete the episode',
         'submit' => 'Delete',
     ],
-    'soundbites' => 'Soundbites',
-    'soundbites_form' => [
-        'title' => 'Edit soundbites',
-        'info_section_title' => 'Episode soundbites',
-        'info_section_subtitle' => 'Add, edit or delete soundbites',
-        'start_time' => 'Start',
-        'start_time_hint' =>
-            'The first second of the soundbite, it can be a decimal number.',
-        'duration' => 'Duration',
-        'duration_hint' =>
-            'The duration of the soundbite (in seconds), it can be a decimal number.',
-        'label' => 'Label',
-        'label_hint' => 'Text that will be displayed.',
-        'play' => 'Play soundbite',
-        'delete' => 'Delete soundbite',
-        'bookmark' =>
-            'Click while playing to get current position, click again to get duration.',
-        'submit' => 'Save soundbites',
-    ],
     'embed' => [
         'title' => 'Embeddable player',
         'label' =>
diff --git a/modules/Admin/Language/en/EpisodeNavigation.php b/modules/Admin/Language/en/EpisodeNavigation.php
index 6511ff5c00..8eb2b1b5f6 100644
--- a/modules/Admin/Language/en/EpisodeNavigation.php
+++ b/modules/Admin/Language/en/EpisodeNavigation.php
@@ -16,7 +16,8 @@ return [
     'episode-persons-manage' => 'Manage persons',
     'embed-add' => 'Embeddable player',
     'clips' => 'Clips',
-    'soundbites-edit' => 'Soundbites',
     'video-clips-list' => 'Video clips',
     'video-clips-create' => 'New video clip',
+    'soundbites-list' => 'Soundbites',
+    'soundbites-create' => 'New soundbite',
 ];
diff --git a/modules/Admin/Language/en/Soundbite.php b/modules/Admin/Language/en/Soundbite.php
new file mode 100644
index 0000000000..8faed932fe
--- /dev/null
+++ b/modules/Admin/Language/en/Soundbite.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2021 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'list' => [
+        'title' => 'Soundbites',
+        'soundbite' => 'Soundbite',
+    ],
+    'form' => [
+        'title' => 'New soundbite',
+        'soundbite_title' => 'Soundbite title',
+        'start_time' => 'Start at',
+        'duration' => 'Duration',
+        'submit' => 'Create soundbite',
+    ],
+    'play' => 'Play soundbite',
+    'stop' => 'Stop soundbite',
+    'create' => 'New soundbite',
+    'delete' => 'Delete soundbite',
+];
diff --git a/modules/Admin/Language/fr/Episode.php b/modules/Admin/Language/fr/Episode.php
index 796a7aa904..38cb216d1b 100644
--- a/modules/Admin/Language/fr/Episode.php
+++ b/modules/Admin/Language/fr/Episode.php
@@ -151,26 +151,6 @@ return [
         'understand' => 'Je comprends, Je veux supprimer l’épisode',
         'submit' => 'Supprimer',
     ],
-    'soundbites' => 'Extraits sonores',
-    'soundbites_form' => [
-        'title' => 'Modifier les extraits sonores',
-        'info_section_title' => 'Extraits sonores de l’épisode',
-        'info_section_subtitle' =>
-            'Ajouter, modifier ou supprimer des extraits sonores',
-        'start_time' => 'Début',
-        'start_time_hint' =>
-            'La première seconde de l’extrait sonore, cela peut être un nombre décimal.',
-        'duration' => 'Durée',
-        'duration_hint' =>
-            'La durée de l’extrait sonore (en secondes), cela peut être un nombre décimal.',
-        'label' => 'Libellé',
-        'label_hint' => 'Texte qui sera affiché.',
-        'play' => 'Écouter l’extrait sonore',
-        'delete' => 'Supprimer l’extrait sonore',
-        'bookmark' =>
-            'Cliquez pour récupérer la position actuelle, cliquez à nouveau pour récupérer la durée.',
-        'submit' => 'Enregistrer les extraits sonnores',
-    ],
     'embed' => [
         'add' => 'Ajouter un lecteur intégré',
         'title' => 'Lecteur intégré',
diff --git a/modules/Admin/Language/fr/EpisodeNavigation.php b/modules/Admin/Language/fr/EpisodeNavigation.php
index b8c82ff7ad..ac8ca8bf14 100644
--- a/modules/Admin/Language/fr/EpisodeNavigation.php
+++ b/modules/Admin/Language/fr/EpisodeNavigation.php
@@ -16,7 +16,8 @@ return [
     'episode-persons-manage' => 'Gestion des intervenants',
     'embed' => 'Lecteur intégré',
     'clips' => 'Extraits',
-    'soundbites-edit' => 'Extraits sonores',
     'video-clips-list' => 'Extraits video',
     'video-clips-create' => 'Nouvel extrait video',
+    'soundbites-list' => 'Extraits sonores',
+    'soundbites-create' => 'Nouvel extrait sonore',
 ];
diff --git a/modules/Admin/Language/fr/Soundbite.php b/modules/Admin/Language/fr/Soundbite.php
new file mode 100644
index 0000000000..0cb3dddb95
--- /dev/null
+++ b/modules/Admin/Language/fr/Soundbite.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2021 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'list' => [
+        'title' => 'Extraits sonores',
+        'soundbite' => 'Extrait sonore',
+    ],
+    'form' => [
+        'title' => 'Nouvel extrait sonore',
+        'soundbite_title' => 'Titre de l’extrait',
+        'start_time' => 'Début à',
+        'duration' => 'Durée',
+        'submit' => 'Créer l’extrait sonore',
+    ],
+    'play' => 'Lancer l’extrait sonore',
+    'stop' => 'Arrêter l’extrait sonore',
+    'create' => 'Nouvel extrait sonore',
+    'delete' => 'Supprimer l’extrait sonore',
+];
diff --git a/themes/cp_admin/episode/_sidebar.php b/themes/cp_admin/episode/_sidebar.php
index 10c0affe9d..50b2d703a7 100644
--- a/themes/cp_admin/episode/_sidebar.php
+++ b/themes/cp_admin/episode/_sidebar.php
@@ -7,7 +7,7 @@ $podcastNavigation = [
     ],
     'clips' => [
         'icon' => 'clapperboard',
-        'items' => ['video-clips-list', 'video-clips-create', 'soundbites-edit'],
+        'items' => ['video-clips-list', 'video-clips-create', 'soundbites-list', 'soundbites-create'],
     ],
 ]; ?>
 
diff --git a/themes/cp_admin/episode/soundbites.php b/themes/cp_admin/episode/soundbites.php
deleted file mode 100644
index 111483e371..0000000000
--- a/themes/cp_admin/episode/soundbites.php
+++ /dev/null
@@ -1,72 +0,0 @@
-<?= $this->extend('_layout') ?>
-
-<?= $this->section('title') ?>
-<?= lang('Episode.soundbites_form.title') ?>
-<?= $this->endSection() ?>
-
-<?= $this->section('pageTitle') ?>
-<?= lang('Episode.soundbites_form.title') ?>
-<?= $this->endSection() ?>
-
-<?= $this->section('headerRight') ?>
-<Button variant="primary" type="submit" form="soundbites-form"><?= lang('Episode.soundbites_form.submit') ?></Button>
-<?= $this->endSection() ?>
-
-
-<?= $this->section('content') ?>
-
-<form id="soundbites-form" action="<?= route_to('episode-soundbites-edit', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col max-w-xl">
-<?= csrf_field() ?>
-
-<Forms.Section
-    title="<?= lang('Episode.soundbites_form.info_section_title') ?>"
-    subtitle="<?= lang('Episode.soundbites_form.info_section_subtitle') ?>" >
-
-    <?php
-    $table = new \CodeIgniter\View\Table();
-
-    $table->setHeading(
-        lang('Episode.soundbites_form.start_time') . hint_tooltip(lang('Episode.soundbites_form.start_time_hint')),
-        lang('Episode.soundbites_form.duration') . hint_tooltip(lang('Episode.soundbites_form.duration_hint')),
-        lang('Episode.soundbites_form.label') . hint_tooltip(lang('Episode.soundbites_form.label_hint')),
-        '',
-        ''
-    );
-
-    foreach ($episode->soundbites as $soundbite) {
-        $table->addRow(
-            "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[{$soundbite->id}][start_time]' value='{$soundbite->start_time}' data-type='soundbite-field' data-field-type='start_time' required='true' />",
-            "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[{$soundbite->id}][duration]' value='{$soundbite->duration}' data-type='soundbite-field' data-field-type='duration' required='true' />",
-            "<Forms.Input class='flex-1' name='soundbites[{$soundbite->id}][label]' value='{$soundbite->label}' />",
-            "<IconButton variant='primary' glyph='play' data-type='play-soundbite' data-soundbite-id='{$soundbite->id}'>" . lang('Episode.soundbites_form.play') . '</IconButton>',
-            '<IconButton uri=' . route_to(
-                'soundbite-delete',
-                $podcast->id,
-                $episode->id,
-                $soundbite->id,
-            ) . " variant='danger' glyph='delete-bin'>" . lang('Episode.soundbites_form.delete') . '</IconButton>'
-        );
-    }
-
-    $table->addRow(
-        "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[0][start_time]' data-type='soundbite-field' data-field-type='start_time' />",
-        "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[0][duration]' data-type='soundbite-field' data-field-type='duration' />",
-        "<Forms.Input class='flex-1' name='soundbites[0][label]' />",
-        "<IconButton variant='primary' glyph='play' data-type='play-soundbite' data-soundbite-id='0'>" . lang('Episode.soundbites_form.play') . '</IconButton>',
-    );
-
-    echo $table->generate();
-
-    ?>
-
-    <div class="flex items-center gap-x-2">
-        <audio controls preload="auto" class="flex-1 w-full">
-            <source src="<?= $episode->audio->file_url ?>" type="<?= $episode->audio->file_mimetype ?>">
-            Your browser does not support the audio tag.
-        </audio>
-        <IconButton glyph="timer" variant="info" data-type="get-soundbite" data-start-time-field-name="soundbites[0][start_time]" data-duration-field-name="soundbites[0][duration]" ><?= lang('Episode.soundbites_form.bookmark') ?></IconButton>
-    </div>
-</Forms.Section>
-</form>
-
-<?= $this->endSection() ?>
diff --git a/themes/cp_admin/episode/soundbites_list.php b/themes/cp_admin/episode/soundbites_list.php
new file mode 100644
index 0000000000..d6ee8af171
--- /dev/null
+++ b/themes/cp_admin/episode/soundbites_list.php
@@ -0,0 +1,48 @@
+<?= $this->extend('_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('Soundbite.list.title') ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('pageTitle') ?>
+<?= lang('Soundbite.list.title') ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('headerRight') ?>
+<Button uri="<?= route_to('soundbites-create', $podcast->id, $episode->id) ?>" variant="primary" iconLeft="add"><?= lang('Soundbite.create') ?></Button>
+<?= $this->endSection() ?>
+
+<?= $this->section('content') ?>
+
+<?= data_table(
+    [
+        [
+            'header' => lang('Soundbite.list.soundbite'),
+            'cell' => function ($soundbite): string {
+                return '<div class="flex gap-x-2"><play-soundbite audio-src="' . $soundbite->episode->audio->file_url . '" start-time="' . $soundbite->start_time . '" duration="' . $soundbite->duration . '" play-label="' . lang('Soundbite.play') . '" playing-label="' . lang('Soundbite.stop') . '"></play-soundbite><div class="flex flex-col"><span class="text-sm font-semibold">' . $soundbite->title . '</span><span class="text-xs">' . format_duration((int) $soundbite->duration) . '</span></div></div>';
+            },
+        ],
+        [
+            'header' => lang('Common.actions'),
+            'cell' => function ($soundbite): string {
+                return '<button id="more-dropdown-' . $soundbite->id . '" type="button" class="inline-flex items-center p-1 rounded-full focus:ring-accent" data-dropdown="button" data-dropdown-target="more-dropdown-' . $soundbite->id . '-menu" aria-haspopup="true" aria-expanded="false">' .
+                icon('more') .
+                '</button>' .
+                '<DropdownMenu id="more-dropdown-' . $soundbite->id . '-menu" labelledby="more-dropdown-' . $soundbite->id . '" offsetY="-24" items="' . esc(json_encode([
+                    [
+                        'type' => 'link',
+                        'title' => lang('Soundbite.delete'),
+                        'uri' => route_to('soundbites-delete', $soundbite->podcast_id, $soundbite->episode_id, $soundbite->id),
+                        'class' => 'font-semibold text-red-600',
+                    ],
+                ])) . '" />';
+            },
+        ],
+    ],
+    $soundbites,
+    'mb-6',
+) ?>
+
+<?= $pager->links() ?>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_admin/episode/soundbites_new.php b/themes/cp_admin/episode/soundbites_new.php
new file mode 100644
index 0000000000..0452d37d98
--- /dev/null
+++ b/themes/cp_admin/episode/soundbites_new.php
@@ -0,0 +1,35 @@
+<?= $this->extend('_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('Soundbite.form.title') ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('pageTitle') ?>
+<?= lang('Soundbite.form.title') ?>
+<?= $this->endSection() ?>
+
+
+<?= $this->section('content') ?>
+
+<form id="soundbites-form" action="<?= route_to('episode-soundbites-edit', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col">
+<?= csrf_field() ?>
+
+    <Forms.Field
+        name="title"
+        label="<?= lang('Soundbite.form.soundbite_title') ?>"
+        required="true"
+        class="max-w-sm"
+    />
+    <audio-clipper start-time="<?= old('start_time', 0) ?>" duration="<?= old('duration', 30) ?>" min-duration="10" volume=".5" height="50" class="mt-8">
+        <audio slot="audio" src="<?= $episode->audio->file_url ?>" 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" />
+        <input slot="duration" type="number" name="duration" placeholder="<?= lang('VideoClip.form.duration') ?>" step="0.001" />
+    </audio-clipper>
+
+    <Button variant="primary" type="submit" class="self-end mt-4" iconRight="arrow-right"><?= lang('Soundbite.form.submit') ?></Button>
+
+</form>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_admin/episode/video_clip.php b/themes/cp_admin/episode/video_clip.php
index 0504104c25..46f51a1501 100644
--- a/themes/cp_admin/episode/video_clip.php
+++ b/themes/cp_admin/episode/video_clip.php
@@ -2,13 +2,13 @@
 
 <?= $this->section('title') ?>
 <?= lang('VideoClip.title', [
-    'videoClipLabel' => $videoClip->label,
+    'videoClipLabel' => $videoClip->title,
 ]) ?>
 <?= $this->endSection() ?>
 
 <?= $this->section('pageTitle') ?>
 <?= lang('VideoClip.title', [
-    'videoClipLabel' => $videoClip->label,
+    'videoClipLabel' => $videoClip->title,
 ]) ?>
 <?= $this->endSection() ?>
 
diff --git a/themes/cp_admin/episode/video_clips_list.php b/themes/cp_admin/episode/video_clips_list.php
index f79a3b7cf3..6da9bc9d9f 100644
--- a/themes/cp_admin/episode/video_clips_list.php
+++ b/themes/cp_admin/episode/video_clips_list.php
@@ -62,7 +62,7 @@ use CodeIgniter\I18n\Time;
                     'portrait' => 'aspect-[9/16]',
                     'squared' => 'aspect-square',
                 ];
-                return '<a href="' . route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id) . '" class="inline-flex items-center w-full group gap-x-2 focus:ring-accent"><div class="relative"><span class="absolute block w-3 h-3 rounded-full ring-2 ring-white -bottom-1 -left-1" data-tooltip="bottom" title="' . lang('Settings.theme.' . $videoClip->theme['name']) . '" style="background-color:hsl(' . $videoClip->theme['preview'] . ')"></span><div class="flex items-center justify-center h-6 overflow-hidden bg-black rounded-sm aspect-video" data-tooltip="bottom" title="' . $videoClip->format . '"><span class="flex items-center justify-center h-full text-white bg-gray-400 ' . $formatClass[$videoClip->format] . '"><Icon glyph="play"/></span></div></div><div class="flex flex-col"><div class="text-sm">#' . $videoClip->id . ' – <span class="font-semibold group-hover:underline">' . $videoClip->label . '</span><span class="ml-1 text-sm">by ' . $videoClip->user->username . '</span></div><span class="text-xs">' . format_duration((int) $videoClip->duration) . '</span></div></a>';
+                return '<a href="' . route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id) . '" class="inline-flex items-center w-full group gap-x-2 focus:ring-accent"><div class="relative"><span class="absolute block w-3 h-3 rounded-full ring-2 ring-white -bottom-1 -left-1" data-tooltip="bottom" title="' . lang('Settings.theme.' . $videoClip->theme['name']) . '" style="background-color:hsl(' . $videoClip->theme['preview'] . ')"></span><div class="flex items-center justify-center h-6 overflow-hidden bg-black rounded-sm aspect-video" data-tooltip="bottom" title="' . $videoClip->format . '"><span class="flex items-center justify-center h-full text-white bg-gray-400 ' . $formatClass[$videoClip->format] . '"><Icon glyph="play"/></span></div></div><div class="flex flex-col"><div class="text-sm">#' . $videoClip->id . ' – <span class="font-semibold group-hover:underline">' . $videoClip->title . '</span><span class="ml-1 text-sm">by ' . $videoClip->user->username . '</span></div><span class="text-xs">' . format_duration((int) $videoClip->duration) . '</span></div></a>';
             },
         ],
         [
@@ -89,7 +89,7 @@ use CodeIgniter\I18n\Time;
                 $downloadButton = '';
                 if ($videoClip->media) {
                     helper('misc');
-                    $filename = 'clip-' . slugify($videoClip->label) . "-{$videoClip->start_time}-{$videoClip->end_time}";
+                    $filename = 'clip-' . slugify($videoClip->title) . "-{$videoClip->start_time}-{$videoClip->end_time}";
                     $downloadButton = '<IconButton glyph="download" uri="' . $videoClip->media->file_url . '" download="' . $filename . '">' . lang('VideoClip.download_clip') . '</IconButton>';
                 }
 
diff --git a/themes/cp_admin/episode/video_clips_new.php b/themes/cp_admin/episode/video_clips_new.php
index dbfcd9fce2..1c9b3796a9 100644
--- a/themes/cp_admin/episode/video_clips_new.php
+++ b/themes/cp_admin/episode/video_clips_new.php
@@ -17,7 +17,7 @@
         <img slot="preview_image" src="<?= $episode->cover->thumbnail_url ?>" alt="<?= $episode->cover->description ?>" />
     </video-clip-previewer>
     <audio-clipper start-time="<?= old('start_time', 0) ?>" duration="<?= old('duration', 30) ?>" min-duration="10" volume=".5" height="50">
-        <audio slot="audio" src="<?= $episode->audio->file_url ?>" class="w-full" preload="auto">
+        <audio slot="audio" src="<?= $episode->audio->file_url ?>" 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" />
@@ -28,7 +28,7 @@
 <div class="flex flex-col items-end w-full max-w-xl xl:max-w-sm 2xl:max-w-xl gap-y-4">
     <Forms.Section title="<?= lang('VideoClip.form.params_section_title') ?>" >
         <Forms.Field
-            name="label"
+            name="title"
             label="<?= lang('VideoClip.form.clip_title') ?>"
             required="true"
         />
-- 
GitLab