mirror of
https://code.castopod.org/adaures/castopod
synced 2025-06-06 18:31:05 +00:00
feat(soundbites): add soundbite list and creation forms with audio-clipper component
This commit is contained in:
parent
602654b99b
commit
de19317138
@ -39,10 +39,9 @@ class AddClips extends Migration
|
|||||||
'type' => 'DECIMAL(7,3)',
|
'type' => 'DECIMAL(7,3)',
|
||||||
'unsigned' => true,
|
'unsigned' => true,
|
||||||
],
|
],
|
||||||
'label' => [
|
'title' => [
|
||||||
'type' => 'VARCHAR',
|
'type' => 'VARCHAR',
|
||||||
'constraint' => 128,
|
'constraint' => 128,
|
||||||
'null' => true,
|
|
||||||
],
|
],
|
||||||
'type' => [
|
'type' => [
|
||||||
'type' => 'ENUM',
|
'type' => 'ENUM',
|
||||||
|
@ -29,7 +29,7 @@ use Modules\Auth\Entities\User;
|
|||||||
* @property Podcast $podcast
|
* @property Podcast $podcast
|
||||||
* @property int $episode_id
|
* @property int $episode_id
|
||||||
* @property Episode $episode
|
* @property Episode $episode
|
||||||
* @property string $label
|
* @property string $title
|
||||||
* @property double $start_time
|
* @property double $start_time
|
||||||
* @property double $end_time
|
* @property double $end_time
|
||||||
* @property double $duration
|
* @property double $duration
|
||||||
@ -68,7 +68,7 @@ class BaseClip extends Entity
|
|||||||
'id' => 'integer',
|
'id' => 'integer',
|
||||||
'podcast_id' => 'integer',
|
'podcast_id' => 'integer',
|
||||||
'episode_id' => 'integer',
|
'episode_id' => 'integer',
|
||||||
'label' => 'string',
|
'title' => 'string',
|
||||||
'start_time' => 'double',
|
'start_time' => 'double',
|
||||||
'duration' => 'double',
|
'duration' => 'double',
|
||||||
'type' => 'string',
|
'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('uri', url_to('episode-comments', $podcast->handle, $episode->slug));
|
||||||
$comments->addAttribute('contentType', 'application/podcast-activity+json');
|
$comments->addAttribute('contentType', 'application/podcast-activity+json');
|
||||||
|
|
||||||
if ($episode->transcript->file_url !== '') {
|
if ($episode->transcript !== null) {
|
||||||
$transcriptElement = $item->addChild('transcript', null, $podcastNamespace);
|
$transcriptElement = $item->addChild('transcript', null, $podcastNamespace);
|
||||||
$transcriptElement->addAttribute('url', $episode->transcript->file_url);
|
$transcriptElement->addAttribute('url', $episode->transcript->file_url);
|
||||||
$transcriptElement->addAttribute(
|
$transcriptElement->addAttribute(
|
||||||
@ -275,7 +275,7 @@ if (! function_exists('get_rss_feed')) {
|
|||||||
|
|
||||||
foreach ($episode->soundbites as $soundbite) {
|
foreach ($episode->soundbites as $soundbite) {
|
||||||
// TODO: differentiate video from soundbites?
|
// 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('start_time', (string) $soundbite->start_time);
|
||||||
$soundbiteElement->addAttribute('duration', (string) $soundbite->duration);
|
$soundbiteElement->addAttribute('duration', (string) $soundbite->duration);
|
||||||
}
|
}
|
||||||
|
@ -39,7 +39,7 @@ class ClipModel extends Model
|
|||||||
'id',
|
'id',
|
||||||
'podcast_id',
|
'podcast_id',
|
||||||
'episode_id',
|
'episode_id',
|
||||||
'label',
|
'title',
|
||||||
'start_time',
|
'start_time',
|
||||||
'duration',
|
'duration',
|
||||||
'type',
|
'type',
|
||||||
@ -89,33 +89,6 @@ class ClipModel extends Model
|
|||||||
parent::__construct($db, $validation);
|
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
|
public function getVideoClipById(int $videoClipId): ?VideoClip
|
||||||
{
|
{
|
||||||
$cacheName = "video-clip#{$videoClipId}";
|
$cacheName = "video-clip#{$videoClipId}";
|
||||||
@ -184,6 +157,53 @@ class ClipModel extends Model
|
|||||||
return $found;
|
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
|
public function deleteSoundbite(int $podcastId, int $episodeId, int $clipId): BaseResult | bool
|
||||||
{
|
{
|
||||||
cache()
|
cache()
|
||||||
|
@ -10,11 +10,11 @@ import "./modules/markdown-preview";
|
|||||||
import "./modules/markdown-write-preview";
|
import "./modules/markdown-write-preview";
|
||||||
import MultiSelect from "./modules/MultiSelect";
|
import MultiSelect from "./modules/MultiSelect";
|
||||||
import "./modules/permalink-edit";
|
import "./modules/permalink-edit";
|
||||||
|
import "./modules/play-soundbite";
|
||||||
import PublishMessageWarning from "./modules/PublishMessageWarning";
|
import PublishMessageWarning from "./modules/PublishMessageWarning";
|
||||||
import Select from "./modules/Select";
|
import Select from "./modules/Select";
|
||||||
import SidebarToggler from "./modules/SidebarToggler";
|
import SidebarToggler from "./modules/SidebarToggler";
|
||||||
import Slugify from "./modules/Slugify";
|
import Slugify from "./modules/Slugify";
|
||||||
import Soundbites from "./modules/Soundbites";
|
|
||||||
import ThemePicker from "./modules/ThemePicker";
|
import ThemePicker from "./modules/ThemePicker";
|
||||||
import Time from "./modules/Time";
|
import Time from "./modules/Time";
|
||||||
import Tooltip from "./modules/Tooltip";
|
import Tooltip from "./modules/Tooltip";
|
||||||
@ -31,7 +31,6 @@ SidebarToggler();
|
|||||||
ClientTimezone();
|
ClientTimezone();
|
||||||
DateTimePicker();
|
DateTimePicker();
|
||||||
Time();
|
Time();
|
||||||
Soundbites();
|
|
||||||
Clipboard();
|
Clipboard();
|
||||||
ThemePicker();
|
ThemePicker();
|
||||||
PublishMessageWarning();
|
PublishMessageWarning();
|
||||||
|
@ -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;
|
|
198
app/Resources/js/modules/play-soundbite.ts
Normal file
198
app/Resources/js/modules/play-soundbite.ts
Normal file
@ -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>`;
|
||||||
|
}
|
||||||
|
}
|
@ -334,24 +334,33 @@ $routes->group(
|
|||||||
);
|
);
|
||||||
$routes->get(
|
$routes->get(
|
||||||
'soundbites',
|
'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',
|
'filter' => 'permission:podcast_episodes-edit',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
$routes->post(
|
$routes->post(
|
||||||
'soundbites',
|
'soundbites/new',
|
||||||
'EpisodeController::soundbitesAttemptEdit/$1/$2',
|
'SoundbiteController::attemptCreate/$1/$2',
|
||||||
[
|
[
|
||||||
|
'as' => 'soundbites-create',
|
||||||
'filter' => 'permission:podcast_episodes-edit',
|
'filter' => 'permission:podcast_episodes-edit',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
$routes->get(
|
$routes->get(
|
||||||
'soundbites/(:num)/delete',
|
'soundbites/(:num)/delete',
|
||||||
'EpisodeController::soundbiteDelete/$1/$2/$3',
|
'SoundbiteController::delete/$1/$2/$3',
|
||||||
[
|
[
|
||||||
'as' => 'soundbite-delete',
|
'as' => 'soundbites-delete',
|
||||||
'filter' => 'permission:podcast_episodes-edit',
|
'filter' => 'permission:podcast_episodes-edit',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
@ -15,7 +15,6 @@ use App\Entities\EpisodeComment;
|
|||||||
use App\Entities\Location;
|
use App\Entities\Location;
|
||||||
use App\Entities\Podcast;
|
use App\Entities\Podcast;
|
||||||
use App\Entities\Post;
|
use App\Entities\Post;
|
||||||
use App\Models\ClipModel;
|
|
||||||
use App\Models\EpisodeCommentModel;
|
use App\Models\EpisodeCommentModel;
|
||||||
use App\Models\EpisodeModel;
|
use App\Models\EpisodeModel;
|
||||||
use App\Models\MediaModel;
|
use App\Models\MediaModel;
|
||||||
@ -719,83 +718,6 @@ class EpisodeController extends BaseController
|
|||||||
return redirect()->route('episode-list', [$this->podcast->id]);
|
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
|
public function embed(): string
|
||||||
{
|
{
|
||||||
helper(['form']);
|
helper(['form']);
|
||||||
|
163
modules/Admin/Controllers/SoundbiteController.php
Normal file
163
modules/Admin/Controllers/SoundbiteController.php
Normal file
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
@ -101,7 +101,7 @@ class VideoClipsController extends BaseController
|
|||||||
replace_breadcrumb_params([
|
replace_breadcrumb_params([
|
||||||
0 => $this->podcast->title,
|
0 => $this->podcast->title,
|
||||||
1 => $this->episode->title,
|
1 => $this->episode->title,
|
||||||
2 => $videoClip->label,
|
2 => $videoClip->title,
|
||||||
]);
|
]);
|
||||||
return view('episode/video_clip', $data);
|
return view('episode/video_clip', $data);
|
||||||
}
|
}
|
||||||
@ -140,8 +140,8 @@ class VideoClipsController extends BaseController
|
|||||||
public function attemptCreate(): RedirectResponse
|
public function attemptCreate(): RedirectResponse
|
||||||
{
|
{
|
||||||
$rules = [
|
$rules = [
|
||||||
'label' => 'required',
|
'title' => 'required',
|
||||||
'start_time' => 'required|numeric',
|
'start_time' => 'required|greater_than_equal_to[0]',
|
||||||
'duration' => 'required|greater_than[0]',
|
'duration' => 'required|greater_than[0]',
|
||||||
'format' => 'required|in_list[' . implode(',', array_keys(config('MediaClipper')->formats)) . ']',
|
'format' => 'required|in_list[' . implode(',', array_keys(config('MediaClipper')->formats)) . ']',
|
||||||
'theme' => 'required|in_list[' . implode(',', array_keys(config('Colors')->themes)) . ']',
|
'theme' => 'required|in_list[' . implode(',', array_keys(config('Colors')->themes)) . ']',
|
||||||
@ -163,7 +163,7 @@ class VideoClipsController extends BaseController
|
|||||||
];
|
];
|
||||||
|
|
||||||
$videoClip = new VideoClip([
|
$videoClip = new VideoClip([
|
||||||
'label' => $this->request->getPost('label'),
|
'title' => $this->request->getPost('title'),
|
||||||
'start_time' => (float) $this->request->getPost('start_time'),
|
'start_time' => (float) $this->request->getPost('start_time'),
|
||||||
'duration' => (float) $this->request->getPost('duration',),
|
'duration' => (float) $this->request->getPost('duration',),
|
||||||
'theme' => $theme,
|
'theme' => $theme,
|
||||||
|
@ -144,25 +144,6 @@ return [
|
|||||||
'understand' => 'I understand, I want to delete the episode',
|
'understand' => 'I understand, I want to delete the episode',
|
||||||
'submit' => 'Delete',
|
'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' => [
|
'embed' => [
|
||||||
'title' => 'Embeddable player',
|
'title' => 'Embeddable player',
|
||||||
'label' =>
|
'label' =>
|
||||||
|
@ -16,7 +16,8 @@ return [
|
|||||||
'episode-persons-manage' => 'Manage persons',
|
'episode-persons-manage' => 'Manage persons',
|
||||||
'embed-add' => 'Embeddable player',
|
'embed-add' => 'Embeddable player',
|
||||||
'clips' => 'Clips',
|
'clips' => 'Clips',
|
||||||
'soundbites-edit' => 'Soundbites',
|
|
||||||
'video-clips-list' => 'Video clips',
|
'video-clips-list' => 'Video clips',
|
||||||
'video-clips-create' => 'New video clip',
|
'video-clips-create' => 'New video clip',
|
||||||
|
'soundbites-list' => 'Soundbites',
|
||||||
|
'soundbites-create' => 'New soundbite',
|
||||||
];
|
];
|
||||||
|
27
modules/Admin/Language/en/Soundbite.php
Normal file
27
modules/Admin/Language/en/Soundbite.php
Normal file
@ -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',
|
||||||
|
];
|
@ -151,26 +151,6 @@ return [
|
|||||||
'understand' => 'Je comprends, Je veux supprimer l’épisode',
|
'understand' => 'Je comprends, Je veux supprimer l’épisode',
|
||||||
'submit' => 'Supprimer',
|
'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' => [
|
'embed' => [
|
||||||
'add' => 'Ajouter un lecteur intégré',
|
'add' => 'Ajouter un lecteur intégré',
|
||||||
'title' => 'Lecteur intégré',
|
'title' => 'Lecteur intégré',
|
||||||
|
@ -16,7 +16,8 @@ return [
|
|||||||
'episode-persons-manage' => 'Gestion des intervenants',
|
'episode-persons-manage' => 'Gestion des intervenants',
|
||||||
'embed' => 'Lecteur intégré',
|
'embed' => 'Lecteur intégré',
|
||||||
'clips' => 'Extraits',
|
'clips' => 'Extraits',
|
||||||
'soundbites-edit' => 'Extraits sonores',
|
|
||||||
'video-clips-list' => 'Extraits video',
|
'video-clips-list' => 'Extraits video',
|
||||||
'video-clips-create' => 'Nouvel extrait video',
|
'video-clips-create' => 'Nouvel extrait video',
|
||||||
|
'soundbites-list' => 'Extraits sonores',
|
||||||
|
'soundbites-create' => 'Nouvel extrait sonore',
|
||||||
];
|
];
|
||||||
|
27
modules/Admin/Language/fr/Soundbite.php
Normal file
27
modules/Admin/Language/fr/Soundbite.php
Normal file
@ -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',
|
||||||
|
];
|
@ -7,7 +7,7 @@ $podcastNavigation = [
|
|||||||
],
|
],
|
||||||
'clips' => [
|
'clips' => [
|
||||||
'icon' => 'clapperboard',
|
'icon' => 'clapperboard',
|
||||||
'items' => ['video-clips-list', 'video-clips-create', 'soundbites-edit'],
|
'items' => ['video-clips-list', 'video-clips-create', 'soundbites-list', 'soundbites-create'],
|
||||||
],
|
],
|
||||||
]; ?>
|
]; ?>
|
||||||
|
|
||||||
|
@ -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() ?>
|
|
48
themes/cp_admin/episode/soundbites_list.php
Normal file
48
themes/cp_admin/episode/soundbites_list.php
Normal file
@ -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() ?>
|
35
themes/cp_admin/episode/soundbites_new.php
Normal file
35
themes/cp_admin/episode/soundbites_new.php
Normal file
@ -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() ?>
|
@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
<?= $this->section('title') ?>
|
<?= $this->section('title') ?>
|
||||||
<?= lang('VideoClip.title', [
|
<?= lang('VideoClip.title', [
|
||||||
'videoClipLabel' => $videoClip->label,
|
'videoClipLabel' => $videoClip->title,
|
||||||
]) ?>
|
]) ?>
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
<?= $this->section('pageTitle') ?>
|
<?= $this->section('pageTitle') ?>
|
||||||
<?= lang('VideoClip.title', [
|
<?= lang('VideoClip.title', [
|
||||||
'videoClipLabel' => $videoClip->label,
|
'videoClipLabel' => $videoClip->title,
|
||||||
]) ?>
|
]) ?>
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
@ -62,7 +62,7 @@ use CodeIgniter\I18n\Time;
|
|||||||
'portrait' => 'aspect-[9/16]',
|
'portrait' => 'aspect-[9/16]',
|
||||||
'squared' => 'aspect-square',
|
'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 = '';
|
$downloadButton = '';
|
||||||
if ($videoClip->media) {
|
if ($videoClip->media) {
|
||||||
helper('misc');
|
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>';
|
$downloadButton = '<IconButton glyph="download" uri="' . $videoClip->media->file_url . '" download="' . $filename . '">' . lang('VideoClip.download_clip') . '</IconButton>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
<img slot="preview_image" src="<?= $episode->cover->thumbnail_url ?>" alt="<?= $episode->cover->description ?>" />
|
<img slot="preview_image" src="<?= $episode->cover->thumbnail_url ?>" alt="<?= $episode->cover->description ?>" />
|
||||||
</video-clip-previewer>
|
</video-clip-previewer>
|
||||||
<audio-clipper start-time="<?= old('start_time', 0) ?>" duration="<?= old('duration', 30) ?>" min-duration="10" volume=".5" height="50">
|
<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.
|
Your browser does not support the <code>audio</code> element.
|
||||||
</audio>
|
</audio>
|
||||||
<input slot="start_time" type="number" name="start_time" placeholder="<?= lang('VideoClip.form.start_time') ?>" step="0.001" />
|
<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">
|
<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.Section title="<?= lang('VideoClip.form.params_section_title') ?>" >
|
||||||
<Forms.Field
|
<Forms.Field
|
||||||
name="label"
|
name="title"
|
||||||
label="<?= lang('VideoClip.form.clip_title') ?>"
|
label="<?= lang('VideoClip.form.clip_title') ?>"
|
||||||
required="true"
|
required="true"
|
||||||
/>
|
/>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user