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

feat(soundbites): add soundbite list and creation forms with audio-clipper component

parent 602654b9
Pipeline #1184 passed with stages
in 7 minutes and 46 seconds
......@@ -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',
......
......@@ -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',
......
......@@ -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);
}
......
......@@ -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()
......
......@@ -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();
......
/**
* 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;
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>`;
}
}
......@@ -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',
],
);
......
......@@ -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']);
......
<?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;