Commit 0e14eb4d authored by Yassine Doghri's avatar Yassine Doghri
Browse files

feat: add js audio player on podcast, admin and embeddable player pages + fix admon episodes ux

- use vimejs as audio player
- add global audio player + play episode buttons on public pages
-
refactor admin episodes list from a grid to a data table
- arrange episode cards to be more
readable

closes #131
parent b72e7c86
......@@ -103,7 +103,7 @@ class EpisodeController extends BaseController
public function embeddablePlayer(string $theme = 'light-transparent'): string
{
header('Content-Security-Policy: frame-ancestors https://* http://*');
header('Content-Security-Policy: frame-ancestors http://*:* https://*:*');
// Prevent analytics hit when authenticated
if (! can_user_interact()) {
......@@ -122,12 +122,13 @@ class EpisodeController extends BaseController
$cacheName = "page_podcast#{$this->podcast->id}_episode#{$this->episode->id}_embeddable_player_{$theme}_{$locale}";
if (! ($cachedView = cache($cacheName))) {
$theme = EpisodeModel::$themes[$theme];
$themeData = EpisodeModel::$themes[$theme];
$data = [
'podcast' => $this->podcast,
'episode' => $this->episode,
'theme' => $theme,
'themeData' => $themeData,
];
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
......@@ -159,9 +160,9 @@ class EpisodeController extends BaseController
'html' =>
'<iframe src="' .
$this->episode->embeddable_player_url .
'" width="100%" height="200" frameborder="0" scrolling="no"></iframe>',
'" width="100%" height="144" frameborder="0" scrolling="no"></iframe>',
'width' => 600,
'height' => 200,
'height' => 144,
'thumbnail_url' => $this->episode->image->large_url,
'thumbnail_width' => config('Images')
->largeSize,
......@@ -189,11 +190,11 @@ class EpisodeController extends BaseController
htmlentities(
'<iframe src="' .
$this->episode->embeddable_player_url .
'" width="100%" height="200" frameborder="0" scrolling="no"></iframe>',
'" width="100%" height="144" frameborder="0" scrolling="no"></iframe>',
),
);
$oembed->addChild('width', '600');
$oembed->addChild('height', '200');
$oembed->addChild('height', '144');
return $this->response->setXML((string) $oembed);
}
......
......@@ -185,7 +185,7 @@ if (! function_exists('data_table')) {
* @param mixed[] $data data to loop through and display in rows
* @param mixed ...$rest Any other argument to pass to the `cell` function
*/
function data_table(array $columns, array $data = [], ...$rest): string
function data_table(array $columns, array $data = [], string $class = '', ...$rest): string
{
$table = new Table();
......@@ -199,8 +199,8 @@ if (! function_exists('data_table')) {
'cell_start' => '<td class="px-4 py-2">',
'cell_alt_start' => '<td class="px-4 py-2">',
'row_start' => '<tr class="bg-gray-100 hover:bg-pine-100">',
'row_alt_start' => '<tr class="hover:bg-pine-100">',
'row_start' => '<tr class="bg-gray-50 hover:bg-pine-50">',
'row_alt_start' => '<tr class="hover:bg-pine-50">',
];
$table->setTemplate($template);
......@@ -225,7 +225,7 @@ if (! function_exists('data_table')) {
return lang('Common.no_data');
}
return '<div class="overflow-x-auto bg-white rounded-lg shadow" >' .
return '<div class="overflow-x-auto bg-white rounded-lg shadow ' . $class . '" >' .
$table->generate() .
'</div>';
}
......@@ -241,28 +241,16 @@ if (! function_exists('publication_pill')) {
*/
function publication_pill(?Time $publicationDate, string $publicationStatus, string $customClass = ''): string
{
if ($publicationDate === null) {
return '';
}
$class =
$publicationStatus === 'published'
? 'text-pine-500 border-pine-500'
: 'text-red-600 border-red-600';
$langOptions = [
'<time pubdate datetime="' .
$publicationDate->format(DateTime::ATOM) .
'" title="' .
$publicationDate .
'">' .
lang('Common.mediumDate', [$publicationDate]) .
'</time>',
];
$class = match ($publicationStatus) {
'published' => 'text-pine-600 border-pine-600 bg-pine-50',
'scheduled' => 'text-red-600 border-red-600 bg-red-50',
'not_published' => 'text-gray-600 border-gray-600 bg-gray-50',
default => 'text-gray-600 border-gray-600 bg-gray-50',
};
$label = lang('Episode.publication_status.' . $publicationStatus, $langOptions);
$label = lang('Episode.publication_status.' . $publicationStatus);
return '<span class="px-1 font-semibold border ' .
return '<span ' . ($publicationDate === null ? '' : 'title="' . $publicationDate . '"') . ' class="px-1 font-semibold border rounded ' .
$class .
' ' .
$customClass .
......@@ -354,7 +342,7 @@ if (! function_exists('episode_numbering')) {
}
if ($isAbbr) {
return '<abbr class="' .
return '<abbr class="tracking-wider ' .
$class .
'" title="' .
lang($transKey, $args) .
......@@ -450,4 +438,79 @@ if (! function_exists('person_list')) {
}
}
// ------------------------------------------------------------------------
if (! function_exists('play_episode_button')) {
/**
* Returns play episode button
*/
function play_episode_button(
string $episodeId,
string $episodeThumbnail,
string $episodeTitle,
string $podcastTitle,
string $source,
string $mediaType,
string $class = ''
): string {
$playLabel = lang('Common.play_episode_button.play');
$playingLabel = lang('Common.play_episode_button.playing');
return <<<CODE_SAMPLE
<play-episode-button
class="${class}"
id="${episodeId}"
imageSrc=${episodeThumbnail}
title="${episodeTitle}"
podcast="${podcastTitle}"
src="${source}"
mediaType="${mediaType}"
playLabel="Play"
playingLabel="Playing"
></play-episode-button>
CODE_SAMPLE;
}
}
// ------------------------------------------------------------------------
if (! function_exists('audio_player')) {
/**
* Returns audio player
*/
function audio_player(string $source, string $mediaType, string $class = ''): string
{
$language = service('request')
->getLocale();
return <<<CODE_SAMPLE
<vm-player
id="castopod-vm-player"
theme="light"
language="${language}"
icons="castopod-icons"
class="${class}"
style="--vm-player-box-shadow:0; --vm-player-theme: #009486; --vm-control-spacing: 4px;"
>
<vm-audio preload="none">
<source src="${source}" type="${mediaType}" />
</vm-audio>
<vm-ui>
<vm-icon-library name="castopod-icons"></vm-icon-library>
<vm-controls full-width>
<vm-playback-control></vm-playback-control>
<vm-volume-control></vm-volume-control>
<vm-current-time></vm-current-time>
<vm-scrubber-control></vm-scrubber-control>
<vm-end-time></vm-end-time>
<vm-settings-control></vm-settings-control>
<vm-default-settings></vm-default-settings>
</vm-controls>
</vm-ui>
</vm-player>
CODE_SAMPLE;
}
}
......@@ -136,20 +136,26 @@ if (! function_exists('slugify')) {
if (! function_exists('format_duration')) {
/**
* Formats duration in seconds to an hh:mm:ss string
* Formats duration in seconds to an hh:mm:ss string. Doesn't show leading zeros if any.
*
* ⚠️ This uses php's gmdate function so any duration > 86000 seconds (24 hours) will not be formatted properly.
*
* @param int $seconds seconds to format
*/
function format_duration(int $seconds, string $separator = ':'): string
function format_duration(int $seconds): string
{
return sprintf(
'%02d%s%02d%s%02d',
floor($seconds / 3600),
$separator,
($seconds / 60) % 60,
$separator,
$seconds % 60,
);
if ($seconds < 60) {
return '0:' . $seconds;
}
if ($seconds < 3600) {
// < 1 hour: returns MM:SS
return ltrim(gmdate('i:s', $seconds), '0');
}
if ($seconds < 36000) {
// < 10 hours: returns H:MM:SS
return ltrim(gmdate('h:i:s', $seconds), '0');
}
return gmdate('h:i:s', $seconds);
}
}
......
......@@ -41,4 +41,8 @@ return [
'upload_file' => 'Upload a file',
'remote_url' => 'Remote URL',
],
'play_episode_button' => [
'play' => 'Play',
'playing' => 'Playing',
],
];
......@@ -44,10 +44,16 @@ return [
'go_to_page' => 'Go to page',
'create' => 'Add an episode',
'publication_status' => [
'published' => 'Published on {0}',
'scheduled' => 'Scheduled for {0}',
'published' => 'Published',
'scheduled' => 'Scheduled',
'not_published' => 'Not published',
],
'list' => [
'episode' => 'Episode',
'visibility' => 'Visibility',
'comments' => 'Comments',
'actions' => 'Actions',
],
'form' => [
'warning' =>
'In case of fatal error, try increasing the `memory_limit`, `upload_max_filesize` and `post_max_size` values in your php configuration file then restart your web server.<br />These values must be higher than the audio file you wish to upload.',
......
......@@ -41,4 +41,8 @@ return [
'upload_file' => 'Téléversez un fichier',
'remote_url' => 'URL distante',
],
'play_episode_button' => [
'play' => 'Lire',
'playing' => 'En cours',
],
];
......@@ -44,10 +44,16 @@ return [
'go_to_page' => 'Voir',
'create' => 'Ajouter un épisode',
'publication_status' => [
'published' => 'Publié le {0}',
'scheduled' => 'Planifié pour le {0}',
'published' => 'Publié',
'scheduled' => 'Planifié',
'not_published' => 'Non publié',
],
'list' => [
'episode' => 'Épisode',
'visibility' => 'Visibilité',
'comments' => 'Commentaires',
'actions' => 'Actions',
],
'form' => [
'warning' =>
'En cas d’erreur fatale, essayez d’augmenter les valeurs de `memory_limit`, `upload_max_filesize` et `post_max_size` dans votre fichier de configuration php puis redémarrez votre serveur web.<br />Les valeurs doivent être plus grandes que le fichier audio que vous souhaitez téléverser.',
......
......@@ -43,7 +43,7 @@ class EpisodeModel extends Model
],
'dark' => [
'style' => 'background-color: #001f1a;',
'background' => '#001f1a',
'background' => '#313131',
'text' => '#fff',
'inverted' => '#000',
],
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<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>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M6 5h2v14H6V5zm10 0h2v14h-2V5z"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M5.889 16H2a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3.889l5.294-4.332a.5.5 0 0 1 .817.387v15.89a.5.5 0 0 1-.817.387L5.89 16zm13.517 4.134l-1.416-1.416A8.978 8.978 0 0 0 21 12a8.982 8.982 0 0 0-3.304-6.968l1.42-1.42A10.976 10.976 0 0 1 23 12c0 3.223-1.386 6.122-3.594 8.134zm-3.543-3.543l-1.422-1.422A3.993 3.993 0 0 0 16 12c0-1.43-.75-2.685-1.88-3.392l1.439-1.439A5.991 5.991 0 0 1 18 12c0 1.842-.83 3.49-2.137 4.591z"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M8.889 16H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3.889l5.294-4.332a.5.5 0 0 1 .817.387v15.89a.5.5 0 0 1-.817.387L8.89 16zm9.974.591l-1.422-1.422A3.993 3.993 0 0 0 19 12c0-1.43-.75-2.685-1.88-3.392l1.439-1.439A5.991 5.991 0 0 1 21 12c0 1.842-.83 3.49-2.137 4.591z"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M5.889 16H2a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3.889l5.294-4.332a.5.5 0 0 1 .817.387v15.89a.5.5 0 0 1-.817.387L5.89 16zm14.525-4l3.536 3.536-1.414 1.414L19 13.414l-3.536 3.536-1.414-1.414L17.586 12 14.05 8.464l1.414-1.414L19 10.586l3.536-3.536 1.414 1.414L20.414 12z"/>
</g>
</svg>
import {
VmAudio,
VmCaptions,
VmClickToPlay,
VmControl,
VmControls,
VmCurrentTime,
VmDefaultControls,
VmDefaultSettings,
VmDefaultUi,
VmEndTime,
VmFile,
VmIcon,
VmIconLibrary,
VmLoadingScreen,
VmMenu,
VmMenuItem,
VmMenuRadio,
VmMenuRadioGroup,
VmMuteControl,
VmPlaybackControl,
VmPlayer,
VmScrubberControl,
VmSettings,
VmSettingsControl,
VmSkeleton,
VmSlider,
VmSubmenu,
VmTime,
VmTimeProgress,
VmTooltip,
VmUi,
VmVolumeControl,
} from "@vime/core";
import "@vime/core/themes/default.css";
import "@vime/core/themes/light.css";
import { html, render } from "lit";
import "./modules/play-episode-button";
const player = html`<div
id="castopod-audio-player"
class="fixed bottom-0 left-0 flex flex-col w-full bg-white border-t sm:flex-row"
data-episode="-1"
style="display: none;"
>
<div class="flex items-center">
<img src="" alt="" class="h-[52px] w-[52px]" />
<div class="flex flex-col px-2">
<p class="text-sm w-48 truncate" title="" id="castopod-player-title"></p>
<p
class="text-xs w-48 truncate"
title=""
id="castopod-player-podcast"
></p>
</div>
</div>
<vm-player
id="castopod-vm-player"
theme="light"
language="en"
icons="castopod-icons"
class="flex-1"
style="--vm-player-box-shadow:0; --vm-player-theme: #009486;"
>
<vm-audio preload="none" id="testing-audio">
<source src="" type="" />
</vm-audio>
<vm-ui>
<vm-icon-library name="castopod-icons"></vm-icon-library>
<vm-controls full-width>
<vm-playback-control></vm-playback-control>
<vm-volume-control></vm-volume-control>
<vm-current-time></vm-current-time>
<vm-scrubber-control></vm-scrubber-control>
<vm-end-time></vm-end-time>
<vm-settings-control></vm-settings-control>
<vm-default-settings></vm-default-settings>
</vm-controls>
</vm-ui>
</vm-player>
</div>`;
render(player, document.body);
// Register Castopod's icons library
const library: HTMLVmIconLibraryElement | null = document.querySelector(
'vm-icon-library[name="castopod-icons"]'
);
if (library) {
library.resolver = (iconName) => `/assets/icons/${iconName}.svg`;
}
// Vime elements for audio player
customElements.define("vm-player", VmPlayer);
customElements.define("vm-file", VmFile);
customElements.define("vm-audio", VmAudio);
customElements.define("vm-ui", VmUi);
customElements.define("vm-default-ui", VmDefaultUi);
customElements.define("vm-click-to-play", VmClickToPlay);
customElements.define("vm-captions", VmCaptions);
customElements.define("vm-loading-screen", VmLoadingScreen);
customElements.define("vm-default-controls", VmDefaultControls);
customElements.define("vm-default-settings", VmDefaultSettings);
customElements.define("vm-controls", VmControls);
customElements.define("vm-playback-control", VmPlaybackControl);
customElements.define("vm-volume-control", VmVolumeControl);
customElements.define("vm-scrubber-control", VmScrubberControl);
customElements.define("vm-current-time", VmCurrentTime);
customElements.define("vm-end-time", VmEndTime);
customElements.define("vm-settings-control", VmSettingsControl);
customElements.define("vm-time-progress", VmTimeProgress);
customElements.define("vm-control", VmControl);
customElements.define("vm-icon", VmIcon);
customElements.define("vm-icon-library", VmIconLibrary);
customElements.define("vm-tooltip", VmTooltip);
customElements.define("vm-mute-control", VmMuteControl);
customElements.define("vm-slider", VmSlider);
customElements.define("vm-time", VmTime);
customElements.define("vm-menu", VmMenu);
customElements.define("vm-menu-item", VmMenuItem);
customElements.define("vm-submenu", VmSubmenu);
customElements.define("vm-menu-radio-group", VmMenuRadioGroup);
customElements.define("vm-menu-radio", VmMenuRadio);
customElements.define("vm-settings", VmSettings);
customElements.define("vm-skeleton", VmSkeleton);
import {
VmAudio,
VmCaptions,
VmClickToPlay,
VmControl,
VmControls,
VmCurrentTime,
VmDefaultControls,
VmDefaultSettings,
VmDefaultUi,
VmEndTime,
VmFile,
VmIcon,
VmIconLibrary,
VmLoadingScreen,
VmMenu,
VmMenuItem,
VmMenuRadio,
VmMenuRadioGroup,
VmMuteControl,
VmPlaybackControl,
VmPlayer,
VmScrubberControl,
VmSettings,
VmSettingsControl,
VmSkeleton,
VmSlider,
VmSubmenu,
VmTime,
VmTimeProgress,
VmTooltip,
VmUi,
VmVolumeControl,
} from "@vime/core";
import "@vime/core/themes/default.css";
import "@vime/core/themes/light.css";
// Vime elements for audio player
customElements.define("vm-player", VmPlayer);
customElements.define("vm-file", VmFile);
customElements.define("vm-audio", VmAudio);
customElements.define("vm-ui", VmUi);
customElements.define("vm-default-ui", VmDefaultUi);
customElements.define("vm-click-to-play", VmClickToPlay);
customElements.define("vm-captions", VmCaptions);
customElements.define("vm-loading-screen", VmLoadingScreen);
customElements.define("vm-default-controls", VmDefaultControls);
customElements.define("vm-default-settings", VmDefaultSettings);
customElements.define("vm-controls", VmControls);
customElements.define("vm-playback-control", VmPlaybackControl);
customElements.define("vm-volume-control", VmVolumeControl);
customElements.define("vm-scrubber-control", VmScrubberControl);
customElements.define("vm-current-time", VmCurrentTime);
customElements.define("vm-end-time", VmEndTime);
customElements.define("vm-settings-control", VmSettingsControl);
customElements.define("vm-time-progress", VmTimeProgress);
customElements.define("vm-control", VmControl);
customElements.define("vm-icon", VmIcon);
customElements.define("vm-icon-library", VmIconLibrary);
customElements.define("vm-tooltip", VmTooltip);
customElements.define("vm-mute-control", VmMuteControl);
customElements.define("vm-slider", VmSlider);
customElements.define("vm-time", VmTime);
customElements.define("vm-menu", VmMenu);
customElements.define("vm-menu-item", VmMenuItem);
customElements.define("vm-submenu", VmSubmenu);
customElements.define("vm-menu-radio-group", VmMenuRadioGroup);
customElements.define("vm-menu-radio", VmMenuRadio);
customElements.define("vm-settings", VmSettings);
customElements.define("vm-skeleton", VmSkeleton);
// Register Castopod's icons library
const library: HTMLVmIconLibraryElement | null = document.querySelector(
'vm-icon-library[name="castopod-icons"]'
);
if (library) {
library.resolver = (iconName) => `/assets/icons/${iconName}.svg`;
}
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@customElement("play-episode-button")
export class PlayEpisodeButton extends LitElement {
@property()
id = "0";
@property()
src = "";
@property()
mediaType = "";
@property()
title!: string;
@property()
podcast!: string;
@property()
imageSrc!: string;
@property()
playLabel!: string;
@property()
playingLabel!: string;
@property()
isPlaying!: boolean;
@property()
_castopodAudioPlayer!: HTMLDivElement;
@property()
_audio!: HTMLAudioElement;
@state()
_playbackSpeed = 1;
@state()
_events = [
{
name: "canplay",
onEvent: (event: Event): void => {
(event.target as HTMLAudioElement)?.play();
},
},
{
name: "play",
onEvent: (): void => {
this.isPlaying = true