Merge commit '0b54c7e307109903515e64989202381cb5c523db' into stable

This commit is contained in:
Buster Neece 2024-02-20 04:45:09 -06:00
commit a22a6dcb17
No known key found for this signature in database
323 changed files with 55049 additions and 38374 deletions

View File

@ -37,12 +37,12 @@ jobs:
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: '8.2' php-version: '8.3'
extensions: intl, xdebug extensions: intl, xdebug
tools: composer:v2, cs2pr tools: composer:v2, cs2pr
- name: Cache PHP dependencies - name: Cache PHP dependencies
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: vendor path: vendor
key: ${{ runner.OS }}-build-${{ hashFiles('composer.lock') }} key: ${{ runner.OS }}-build-${{ hashFiles('composer.lock') }}
@ -77,12 +77,12 @@ jobs:
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: '8.2' php-version: '8.3'
extensions: intl, xdebug extensions: intl, xdebug
tools: composer:v2, cs2pr tools: composer:v2, cs2pr
- name: Cache PHP dependencies - name: Cache PHP dependencies
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: vendor path: vendor
key: ${{ runner.OS }}-build-${{ hashFiles('composer.lock') }} key: ${{ runner.OS }}-build-${{ hashFiles('composer.lock') }}
@ -135,7 +135,7 @@ jobs:
chmod 777 .gitinfo chmod 777 .gitinfo
- name: Upload built static assets and translations - name: Upload built static assets and translations
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: assets name: assets
if-no-files-found: error if-no-files-found: error
@ -158,7 +158,7 @@ jobs:
- uses: actions/checkout@master - uses: actions/checkout@master
- name: Download built static assets from previous step - name: Download built static assets from previous step
uses: actions/download-artifact@v3 uses: actions/download-artifact@v4
with: with:
name: assets name: assets
@ -166,13 +166,13 @@ jobs:
uses: depot/setup-action@v1 uses: depot/setup-action@v1
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@ -180,7 +180,7 @@ jobs:
- name: Build Docker Metadata - name: Build Docker Metadata
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v5
with: with:
images: | images: |
azuracast/azuracast azuracast/azuracast

View File

@ -11,6 +11,52 @@ release channel, you can take advantage of these new features and fixes.
--- ---
# AzuraCast 0.19.5 (Feb 20, 2024)
## New Features/Changes
- **Redesigned Public Podcast Pages**: We've completely overhauled the public-facing podcasts pages to be a single
cohesive experience similar to our On-Demand streaming and other public pages.
- Podcasts and episodes are searchable, sortable and paginated, and the pages now use our built-in player for
playing back the podcasts themselves, ensuring uniform controls across browsers.
- Because it's built in our new frontend, you can also continue listening to your podcast episode as you navigate
around the podcast pages.
- The entire new podcast component is also now an embeddable widget in external pages.
- **Improved Listeners Report**: You can now search and sort through the listeners report, view several additional
fields supplying more information about your listeners, and filter your results to only show listeners with a certain
total connected time or using a certain kind of device.
- **Custom Bitrates**: When configuring Mount Points, Remote Relays, or broadcast recordings, you can now specify a
custom bitrate in kilobits per second (kbps) if you want to use a bitrate outside the default options.
- You can now submit a manual metadata update directly via the station profile page. This is useful in cases where the
metadata does not update correctly (i.e. from a live DJ).
## Code Quality/Technical Changes
- Our Docker image is now built directly on top of the official PHP image, which is powered by Debian Bookworm instead
of Ubuntu 22.04 (Jammy). For a majority of station operators, this change will not impact your station operations at
all, but if you specify custom packages to be installed on startup, you should make sure those packages exist in the
Debian repository as well.
- You can now specify a path when using the `azuracast:media:reprocess` command to only mark items starting with the
given (file or directory) path for reprocessing.
- The list of stations on the home dashboard is now paginated, searchable and sortable.
- The "Reorder" dialog for sequential playlists has been emphasized better and now has a "move to top" and "move to
bottom" button.
- The Upcoming Song Queue page automatically refreshes periodically.
## Bug Fixes
- We're continuing to work with Liquidsoap to resolve known issues relating to crackles and pops on crossfade
transitions and issues with metadata not updating on shorter tracks.
---
# AzuraCast 0.19.4 (Jan 4, 2024) # AzuraCast 0.19.4 (Jan 4, 2024)
## New Features/Changes ## New Features/Changes

View File

@ -1,7 +1,7 @@
# #
# Golang dependencies build step # Golang dependencies build step
# #
FROM golang:1.21-bullseye AS go-dependencies FROM golang:1.21-bookworm AS go-dependencies
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl git && apt-get install -y --no-install-recommends openssl git
@ -10,7 +10,7 @@ RUN go install github.com/jwilder/dockerize@v0.6.1
RUN go install github.com/aptible/supercronic@v0.2.28 RUN go install github.com/aptible/supercronic@v0.2.28
RUN go install github.com/centrifugal/centrifugo/v5@v5.2.0 RUN go install github.com/centrifugal/centrifugo/v5@v5.2.2
# #
# MariaDB dependencies build step # MariaDB dependencies build step
@ -25,7 +25,7 @@ FROM ghcr.io/azuracast/azuracast.com:builtin AS docs
# #
# Icecast-KH with AzuraCast customizations build step # Icecast-KH with AzuraCast customizations build step
# #
FROM ghcr.io/azuracast/icecast-kh-ac:latest AS icecast FROM ghcr.io/azuracast/icecast-kh-ac:2024-02-13 AS icecast
# #
# Roadrunner build step # Roadrunner build step
@ -35,9 +35,16 @@ FROM ghcr.io/roadrunner-server/roadrunner:2023.3.8 AS roadrunner
# #
# Final build image # Final build image
# #
FROM ubuntu:jammy AS pre-final FROM php:8.3-fpm-bookworm AS pre-final
ENV TZ="UTC" ENV TZ="UTC" \
LANGUAGE="en_US.UTF-8" \
LC_ALL="en_US.UTF-8" \
LANG="en_US.UTF-8" \
LC_TYPE="en_US.UTF-8"
# Add PHP extension installer tool
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
# Add Go dependencies # Add Go dependencies
COPY --from=go-dependencies /go/bin/dockerize /usr/local/bin COPY --from=go-dependencies /go/bin/dockerize /usr/local/bin
@ -47,8 +54,6 @@ COPY --from=go-dependencies /go/bin/centrifugo /usr/local/bin/centrifugo
# Add MariaDB dependencies # Add MariaDB dependencies
COPY --from=mariadb /usr/local/bin/healthcheck.sh /usr/local/bin/db_healthcheck.sh COPY --from=mariadb /usr/local/bin/healthcheck.sh /usr/local/bin/db_healthcheck.sh
COPY --from=mariadb /usr/local/bin/docker-entrypoint.sh /usr/local/bin/db_entrypoint.sh COPY --from=mariadb /usr/local/bin/docker-entrypoint.sh /usr/local/bin/db_entrypoint.sh
COPY --from=mariadb /etc/apt/sources.list.d/mariadb.list /etc/apt/sources.list.d/mariadb.list
COPY --from=mariadb /etc/apt/trusted.gpg.d/mariadb.gpg /etc/apt/trusted.gpg.d/mariadb.gpg
# Add Icecast # Add Icecast
COPY --from=icecast /usr/local/bin/icecast /usr/local/bin/icecast COPY --from=icecast /usr/local/bin/icecast /usr/local/bin/icecast

View File

@ -16,7 +16,7 @@ MYSQL_ROOT_PASSWORD=azur4c457
# Developer options. # Developer options.
# Populate these! # Populate these!
INIT_BASE_URL=http://azuracast.local INIT_BASE_URL=https://azuracast.local
INIT_INSTANCE_NAME="local development" INIT_INSTANCE_NAME="local development"
INIT_DEMO_API_KEY= INIT_DEMO_API_KEY=
INIT_ADMIN_EMAIL= INIT_ADMIN_EMAIL=

View File

@ -12,7 +12,7 @@ $environment = App\AppFactory::buildEnvironment();
$console = new Symfony\Component\Console\Application( $console = new Symfony\Component\Console\Application(
'AzuraCast installer', 'AzuraCast installer',
App\Version::FALLBACK_VERSION App\Version::STABLE_VERSION
); );
$installCommand = new App\Installer\Command\InstallCommand(); $installCommand = new App\Installer\Command\InstallCommand();

View File

@ -10,7 +10,7 @@
} }
], ],
"require": { "require": {
"php": "^8.2", "php": "^8.3",
"ext-PDO": "*", "ext-PDO": "*",
"ext-curl": "*", "ext-curl": "*",
"ext-ffi": "*", "ext-ffi": "*",
@ -28,7 +28,7 @@
"ext-xmlreader": "*", "ext-xmlreader": "*",
"ext-xmlwriter": "*", "ext-xmlwriter": "*",
"azuracast/nowplaying": "dev-main", "azuracast/nowplaying": "dev-main",
"beberlei/doctrineextensions": "^1.2", "beberlei/doctrineextensions": "^1.4",
"br33f/php-ga4-mp": "^0.1.2", "br33f/php-ga4-mp": "^0.1.2",
"brick/math": "^0.11", "brick/math": "^0.11",
"composer/ca-bundle": "^1.2", "composer/ca-bundle": "^1.2",
@ -57,7 +57,7 @@
"mezzio/mezzio-session-cache": "^1.7", "mezzio/mezzio-session-cache": "^1.7",
"monolog/monolog": "^3", "monolog/monolog": "^3",
"myclabs/deep-copy": "^1.10", "myclabs/deep-copy": "^1.10",
"nesbot/carbon": "^2.36", "nesbot/carbon": "^3",
"pagerfanta/doctrine-collections-adapter": "^4", "pagerfanta/doctrine-collections-adapter": "^4",
"pagerfanta/doctrine-orm-adapter": "^4", "pagerfanta/doctrine-orm-adapter": "^4",
"php-di/php-di": "^7.0.1", "php-di/php-di": "^7.0.1",

1116
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Controller\Admin\IndexAction; use App\Controller\AdminAction;
use App\Enums\GlobalPermissions; use App\Enums\GlobalPermissions;
use App\Middleware; use App\Middleware;
use Slim\Routing\RouteCollectorProxy; use Slim\Routing\RouteCollectorProxy;
@ -33,11 +33,11 @@ return static function (RouteCollectorProxy $app) {
]; ];
foreach ($routes as $routeName => $routePath) { foreach ($routes as $routeName => $routePath) {
$group->get($routePath, IndexAction::class) $group->get($routePath, AdminAction::class)
->setName($routeName); ->setName($routeName);
} }
$group->get('/{routes:.+}', IndexAction::class); $group->get('/{routes:.+}', AdminAction::class);
} }
)->add(Middleware\Module\PanelLayout::class) )->add(Middleware\Module\PanelLayout::class)
->add(Middleware\EnableView::class) ->add(Middleware\EnableView::class)

View File

@ -68,31 +68,67 @@ return static function (RouteCollectorProxy $group) {
$group->get( $group->get(
'/art/{media_id:[a-zA-Z0-9\-]+}[-{timestamp}.jpg]', '/art/{media_id:[a-zA-Z0-9\-]+}[-{timestamp}.jpg]',
Controller\Api\Stations\Art\GetArtAction::class Controller\Api\Stations\Art\GetArtAction::class
)->setName('api:stations:media:art'); )->setName('api:stations:media:art')
->add(new Middleware\Cache\SetStaticFileCache());
// Streamer Art // Streamer Art
$group->get( $group->get(
'/streamer/{id}/art[-{timestamp}.jpg]', '/streamer/{id}/art[-{timestamp}.jpg]',
Controller\Api\Stations\Streamers\Art\GetArtAction::class Controller\Api\Stations\Streamers\Art\GetArtAction::class
)->setName('api:stations:streamer:art'); )->setName('api:stations:streamer:art')
->add(new Middleware\Cache\SetStaticFileCache());
// Podcast and Episode Art
$group->group( $group->group(
'/podcast/{podcast_id}', '/public',
function (RouteCollectorProxy $group) { function (RouteCollectorProxy $group) {
$group->get( // Podcast Public Pages
'/art[-{timestamp}.jpg]', $group->get('/podcasts', Controller\Api\Stations\Podcasts\ListPodcastsAction::class)
Controller\Api\Stations\Podcasts\Art\GetArtAction::class ->setName('api:stations:public:podcasts');
)->setName('api:stations:podcast:art');
$group->get( $group->group(
'/episode/{episode_id}/art[-{timestamp}.jpg]', '/podcast/{podcast_id}',
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class function (RouteCollectorProxy $group) {
)->setName('api:stations:podcast:episode:art'); $group->get('', Controller\Api\Stations\Podcasts\GetPodcastAction::class)
->setName('api:stations:public:podcast');
$group->get(
'/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
)->setName('api:stations:public:podcast:art')
->add(new Middleware\Cache\SetStaticFileCache());
$group->get(
'/episodes',
Controller\Api\Stations\Podcasts\Episodes\ListEpisodesAction::class
)->setName('api:stations:public:podcast:episodes');
$group->group(
'/episode/{episode_id}',
function (RouteCollectorProxy $group) {
$group->get(
'',
Controller\Api\Stations\Podcasts\Episodes\GetEpisodeAction::class
)->setName('api:stations:public:podcast:episode')
->add(new Middleware\Cache\SetStaticFileCache());
$group->get(
'/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
)->setName('api:stations:public:podcast:episode:art')
->add(new Middleware\Cache\SetStaticFileCache());
$group->get(
'/download',
Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class
)->setName('api:stations:public:podcast:episode:download');
}
);
}
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class)
->add(Middleware\GetAndRequirePodcast::class);
} }
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class); );
} }
)->add(new Middleware\Cache\SetStaticFileCache()) )->add(Middleware\RequireStation::class)
->add(Middleware\RequireStation::class)
->add(Middleware\GetStation::class); ->add(Middleware\GetStation::class);
}; };

View File

@ -19,7 +19,7 @@ return static function (RouteCollectorProxy $group) {
->setName('api:stations:index') ->setName('api:stations:index')
->add(new Middleware\RateLimit('api', 5, 2)); ->add(new Middleware\RateLimit('api', 5, 2));
$group->get('/nowplaying', Controller\Api\NowPlayingAction::class . ':getAction'); $group->get('/nowplaying', Controller\Api\NowPlayingAction::class);
$group->get('/schedule', Controller\Api\Stations\ScheduleAction::class) $group->get('/schedule', Controller\Api\Stations\ScheduleAction::class)
->setName('api:stations:schedule'); ->setName('api:stations:schedule');
@ -48,38 +48,7 @@ return static function (RouteCollectorProxy $group) {
->add(new Middleware\StationSupportsFeature(StationFeatures::OnDemand)) ->add(new Middleware\StationSupportsFeature(StationFeatures::OnDemand))
->add(new Middleware\RateLimit('ondemand', 1, 2)); ->add(new Middleware\RateLimit('ondemand', 1, 2));
// Podcast Public Pages // NOTE: See ./api_public.php for podcast public pages.
$group->group(
'/podcast/{podcast_id}',
function (RouteCollectorProxy $group) {
$group->get('', Controller\Api\Stations\PodcastsController::class . ':getAction')
->setName('api:stations:podcast');
// See ./api_public for podcast art.
$group->get(
'/episodes',
Controller\Api\Stations\PodcastEpisodesController::class . ':listAction'
)->setName('api:stations:podcast:episodes');
$group->group(
'/episode/{episode_id}',
function (RouteCollectorProxy $group) {
$group->get(
'',
Controller\Api\Stations\PodcastEpisodesController::class . ':getAction'
)->setName('api:stations:podcast:episode');
$group->get(
'/download',
Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class
)->setName('api:stations:podcast:episode:download');
}
);
}
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class);
// NOTE: See ./api_public.php for media art public path.
/* /*
* Authenticated Functions * Authenticated Functions
@ -159,6 +128,9 @@ return static function (RouteCollectorProxy $group) {
$group->group( $group->group(
'/podcast/{podcast_id}', '/podcast/{podcast_id}',
function (RouteCollectorProxy $group) { function (RouteCollectorProxy $group) {
$group->get('', Controller\Api\Stations\PodcastsController::class . ':getAction')
->setName('api:stations:podcast');
$group->put('', Controller\Api\Stations\PodcastsController::class . ':editAction'); $group->put('', Controller\Api\Stations\PodcastsController::class . ':editAction');
$group->delete( $group->delete(
@ -166,16 +138,26 @@ return static function (RouteCollectorProxy $group) {
Controller\Api\Stations\PodcastsController::class . ':deleteAction' Controller\Api\Stations\PodcastsController::class . ':deleteAction'
); );
$group->get(
'/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
)->setName('api:stations:podcast:art');
$group->post( $group->post(
'/art', '/art',
Controller\Api\Stations\Podcasts\Art\PostArtAction::class Controller\Api\Stations\Podcasts\Art\PostArtAction::class
)->setName('api:stations:podcast:art-internal'); );
$group->delete( $group->delete(
'/art', '/art',
Controller\Api\Stations\Podcasts\Art\DeleteArtAction::class Controller\Api\Stations\Podcasts\Art\DeleteArtAction::class
); );
$group->get(
'/episodes',
Controller\Api\Stations\PodcastEpisodesController::class . ':listAction'
)->setName('api:stations:podcast:episodes');
$group->post( $group->post(
'/episodes', '/episodes',
Controller\Api\Stations\PodcastEpisodesController::class . ':createAction' Controller\Api\Stations\PodcastEpisodesController::class . ':createAction'
@ -194,6 +176,11 @@ return static function (RouteCollectorProxy $group) {
$group->group( $group->group(
'/episode/{episode_id}', '/episode/{episode_id}',
function (RouteCollectorProxy $group) { function (RouteCollectorProxy $group) {
$group->get(
'',
Controller\Api\Stations\PodcastEpisodesController::class . ':getAction'
)->setName('api:stations:podcast:episode');
$group->put( $group->put(
'', '',
Controller\Api\Stations\PodcastEpisodesController::class . ':editAction' Controller\Api\Stations\PodcastEpisodesController::class . ':editAction'
@ -205,20 +192,30 @@ return static function (RouteCollectorProxy $group) {
. ':deleteAction' . ':deleteAction'
); );
$group->get(
'/art[-{timestamp}.jpg]',
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
)->setName('api:stations:podcast:episode:art');
$group->post( $group->post(
'/art', '/art',
Controller\Api\Stations\Podcasts\Episodes\Art\PostArtAction::class Controller\Api\Stations\Podcasts\Episodes\Art\PostArtAction::class
)->setName('api:stations:podcast:episode:art-internal'); );
$group->delete( $group->delete(
'/art', '/art',
Controller\Api\Stations\Podcasts\Episodes\Art\DeleteArtAction::class Controller\Api\Stations\Podcasts\Episodes\Art\DeleteArtAction::class
); );
$group->get(
'/media',
Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class
)->setName('api:stations:podcast:episode:media');
$group->post( $group->post(
'/media', '/media',
Controller\Api\Stations\Podcasts\Episodes\Media\PostMediaAction::class Controller\Api\Stations\Podcasts\Episodes\Media\PostMediaAction::class
)->setName('api:stations:podcast:episode:media-internal'); );
$group->delete( $group->delete(
'/media', '/media',
@ -227,7 +224,7 @@ return static function (RouteCollectorProxy $group) {
} }
); );
} }
); )->add(Middleware\GetAndRequirePodcast::class);
} }
)->add(new Middleware\Permissions(StationPermissions::Podcasts, true)); )->add(new Middleware\Permissions(StationPermissions::Podcasts, true));

View File

@ -57,20 +57,16 @@ return static function (RouteCollectorProxy $app) {
$group->get('/schedule[/{embed:embed}]', Controller\Frontend\PublicPages\ScheduleAction::class) $group->get('/schedule[/{embed:embed}]', Controller\Frontend\PublicPages\ScheduleAction::class)
->setName('public:schedule'); ->setName('public:schedule');
$group->get('/podcasts', Controller\Frontend\PublicPages\PodcastsAction::class) $routes = [
->setName('public:podcasts'); 'public:podcasts' => '/podcasts',
'public:podcast' => '/podcast/{podcast_id}',
'public:podcast:episode' => '/podcast/{podcast_id}/episode/{episode_id}',
];
$group->get( foreach ($routes as $routeName => $routePath) {
'/podcast/{podcast_id}/episodes', $group->get($routePath, Controller\Frontend\PublicPages\PodcastsAction::class)
Controller\Frontend\PublicPages\PodcastEpisodesAction::class ->setName($routeName);
) }
->setName('public:podcast:episodes');
$group->get(
'/podcast/{podcast_id}/episode/{episode_id}',
Controller\Frontend\PublicPages\PodcastEpisodeAction::class
)
->setName('public:podcast:episode');
$group->get('/podcast/{podcast_id}/feed', Controller\Frontend\PublicPages\PodcastFeedAction::class) $group->get('/podcast/{podcast_id}/feed', Controller\Frontend\PublicPages\PodcastFeedAction::class)
->setName('public:podcast:feed'); ->setName('public:podcast:feed');

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Controller\Stations\IndexAction; use App\Controller\StationsAction;
use App\Enums\StationPermissions; use App\Enums\StationPermissions;
use App\Middleware; use App\Middleware;
use Slim\Routing\RouteCollectorProxy; use Slim\Routing\RouteCollectorProxy;
@ -23,6 +23,7 @@ return static function (RouteCollectorProxy $app) {
'stations:logs' => '/logs', 'stations:logs' => '/logs',
'stations:playlists:index' => '/playlists', 'stations:playlists:index' => '/playlists',
'stations:podcasts:index' => '/podcasts', 'stations:podcasts:index' => '/podcasts',
'stations:podcast:episodes' => '/podcast/{podcast_id}',
'stations:mounts:index' => '/mounts', 'stations:mounts:index' => '/mounts',
'stations:profile:index' => '/profile', 'stations:profile:index' => '/profile',
'stations:profile:edit' => '/profile/edit', 'stations:profile:edit' => '/profile/edit',
@ -40,11 +41,11 @@ return static function (RouteCollectorProxy $app) {
]; ];
foreach ($routes as $routeName => $routePath) { foreach ($routes as $routeName => $routePath) {
$group->get($routePath, IndexAction::class) $group->get($routePath, StationsAction::class)
->setName($routeName); ->setName($routeName);
} }
$group->get('/{routes:.+}', IndexAction::class); $group->get('/{routes:.+}', StationsAction::class);
} }
)->add(Middleware\Module\PanelLayout::class) )->add(Middleware\Module\PanelLayout::class)
->add(new Middleware\Permissions(StationPermissions::View, true)) ->add(new Middleware\Permissions(StationPermissions::View, true))

View File

@ -155,7 +155,7 @@ return [
$cacheInterface = new Symfony\Component\Cache\Adapter\ArrayAdapter(); $cacheInterface = new Symfony\Component\Cache\Adapter\ArrayAdapter();
} elseif ($redisFactory->isSupported()) { } elseif ($redisFactory->isSupported()) {
$cacheInterface = new Symfony\Component\Cache\Adapter\RedisAdapter( $cacheInterface = new Symfony\Component\Cache\Adapter\RedisAdapter(
$redisFactory->createInstance(), $redisFactory->getInstance(),
marshaller: new Symfony\Component\Cache\Marshaller\DefaultMarshaller( marshaller: new Symfony\Component\Cache\Marshaller\DefaultMarshaller(
$environment->isProduction() ? null : false $environment->isProduction() ? null : false
) )
@ -190,7 +190,7 @@ return [
Environment $environment, Environment $environment,
App\Service\RedisFactory $redisFactory App\Service\RedisFactory $redisFactory
) => ($redisFactory->isSupported()) ) => ($redisFactory->isSupported())
? new Symfony\Component\Lock\Store\RedisStore($redisFactory->createInstance()) ? new Symfony\Component\Lock\Store\RedisStore($redisFactory->getInstance())
: new Symfony\Component\Lock\Store\FlockStore($environment->getTempDirectory()), : new Symfony\Component\Lock\Store\FlockStore($environment->getTempDirectory()),
// Console // Console
@ -330,8 +330,13 @@ return [
// Register plugin-provided message queue receivers // Register plugin-provided message queue receivers
$receivers = $plugins->registerMessageQueueReceivers($receivers); $receivers = $plugins->registerMessageQueueReceivers($receivers);
/**
* @var class-string $messageClass
* @var class-string $handlerClass
*/
foreach ($receivers as $messageClass => $handlerClass) { foreach ($receivers as $messageClass => $handlerClass) {
$handlers[$messageClass][] = static function ($message) use ($handlerClass, $di) { $handlers[$messageClass][] = static function ($message) use ($handlerClass, $di) {
/** @var callable $obj */
$obj = $di->get($handlerClass); $obj = $di->get($handlerClass);
return $obj($message); return $obj($message);
}; };

View File

@ -8,6 +8,9 @@ services:
- "127.0.0.1:3306:3306" # MariaDB - "127.0.0.1:3306:3306" # MariaDB
- "127.0.0.1:6025:6025" # Centrifugo - "127.0.0.1:6025:6025" # Centrifugo
- "127.0.0.1:6379:6379" # Redis - "127.0.0.1:6379:6379" # Redis
environment:
VAR_DUMPER_FORMAT: server
VAR_DUMPER_SERVER: host.docker.internal:9912
volumes: volumes:
- $PWD/util/local_ssl/default.crt:/var/azuracast/storage/acme/ssl.crt:ro - $PWD/util/local_ssl/default.crt:/var/azuracast/storage/acme/ssl.crt:ro
- $PWD/util/local_ssl/default.key:/var/azuracast/storage/acme/ssl.key:ro - $PWD/util/local_ssl/default.key:/var/azuracast/storage/acme/ssl.key:ro

View File

@ -536,16 +536,8 @@ install-dev() {
.env --file .env set AZURACAST_PODMAN_MODE=true .env --file .env set AZURACAST_PODMAN_MODE=true
fi fi
chmod 777 ./frontend/ ./web/ ./vendor/ \
./web/static/ ./web/static/api/ \
./web/static/dist/ ./web/static/img/
dc build dc build
dc run --rm web -- azuracast_install "$@" dc run --rm web -- azuracast_dev_install "$@"
dc -p azuracast_frontend -f docker-compose.frontend.yml build
dc -p azuracast_frontend -f docker-compose.frontend.yml run --rm frontend npm run build
dc up -d dc up -d
exit exit
} }

View File

@ -9,8 +9,8 @@ import * as url from 'url';
import {JSDOM} from "jsdom"; import {JSDOM} from "jsdom";
const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
const outputPath = path.resolve(__dirname, './vue/components/Common/icons.ts'); const outputPath = path.resolve(__dirname, './src/components/Common/icons.ts');
const iconsPath = path.resolve(__dirname, './icons'); const iconsPath = path.resolve(__dirname, './src/icons');
const materialIconsViewBox = '0 -960 960 960'; const materialIconsViewBox = '0 -960 960 960';
const bootstrapIconsViewBox = '0 0 16 16'; const bootstrapIconsViewBox = '0 0 16 16';
@ -64,7 +64,7 @@ function genIconComponents() {
svgViewBox = `'${svgViewBox}'`; svgViewBox = `'${svgViewBox}'`;
} }
const svgContents = svgInner.innerHTML.trim().replace( const svgContents = svgInner.innerHTML.trim().replace("\n", "").replace(
' xmlns="http://www.w3.org/2000/svg"', ' xmlns="http://www.w3.org/2000/svg"',
'' ''
); );

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"serve": "vite", "serve": "vite",
"generate-icons": "node genicons.mjs",
"generate-locales": "vue-gettext-extract", "generate-locales": "vue-gettext-extract",
"generate-api": "swagger-typescript-api --path http://localhost/api/openapi.yml --output ./src/entities --name ApiInterfaces.ts --no-client" "generate-api": "swagger-typescript-api --path http://localhost/api/openapi.yml --output ./src/entities --name ApiInterfaces.ts --no-client"
}, },
@ -42,7 +43,7 @@
"typescript": "^5.3.2", "typescript": "^5.3.2",
"vue": "^3.2", "vue": "^3.2",
"vue-axios": "^3.5", "vue-axios": "^3.5",
"vue-codemirror6": "^1", "vue-codemirror6": "1.2.0",
"vue-easy-lightbox": "^1.16", "vue-easy-lightbox": "^1.16",
"vue-router": "^4.2.4", "vue-router": "^4.2.4",
"vue3-gettext": "3.0.0-beta.4", "vue3-gettext": "3.0.0-beta.4",
@ -61,11 +62,11 @@
"@vitejs/plugin-vue": "^5", "@vitejs/plugin-vue": "^5",
"@vue/eslint-config-typescript": "^12", "@vue/eslint-config-typescript": "^12",
"del": "^7", "del": "^7",
"esbuild": "^0.19.9", "esbuild": "^0.20",
"eslint": "^8.45.0", "eslint": "^8.45.0",
"eslint-plugin-vue": "^9.8.0", "eslint-plugin-vue": "^9.8.0",
"glob": "^10.2.7", "glob": "^10.2.7",
"jsdom": "^23", "jsdom": "^24",
"sass": "^1.39.2", "sass": "^1.39.2",
"svg.js": "^2.7.1", "svg.js": "^2.7.1",
"swagger-typescript-api": "^13.0.3", "swagger-typescript-api": "^13.0.3",

View File

@ -151,7 +151,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Icon from "~/components/Common/Icon.vue"; import Icon from "~/components/Common/Icon.vue";
import DataTable, { DataTableField } from "~/components/Common/DataTable.vue"; import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import AdminBackupsLastOutputModal from "./Backups/LastOutputModal.vue"; import AdminBackupsLastOutputModal from "./Backups/LastOutputModal.vue";
import formatFileSize from "~/functions/formatFileSize"; import formatFileSize from "~/functions/formatFileSize";
import AdminBackupsConfigureModal from "~/components/Admin/Backups/ConfigureModal.vue"; import AdminBackupsConfigureModal from "~/components/Admin/Backups/ConfigureModal.vue";
@ -196,7 +196,7 @@ const settings = ref({...blankSettings});
const {$gettext} = useTranslate(); const {$gettext} = useTranslate();
const {timeConfig} = useAzuraCast(); const {timeConfig} = useAzuraCast();
const {DateTime} = useLuxon(); const {DateTime, timestampToRelative} = useLuxon();
const fields: DataTableField[] = [ const fields: DataTableField[] = [
{ {
@ -250,8 +250,6 @@ const relist = () => {
onMounted(relist); onMounted(relist);
const {timestampToRelative} = useLuxon();
const $lastOutputModal = ref<InstanceType<typeof AdminBackupsLastOutputModal> | null>(null); const $lastOutputModal = ref<InstanceType<typeof AdminBackupsLastOutputModal> | null>(null);
const showLastOutput = () => { const showLastOutput = () => {
$lastOutputModal.value?.show(); $lastOutputModal.value?.show();

View File

@ -28,7 +28,6 @@
class="col-md-6" class="col-md-6"
:field="v$.backup_time_code" :field="v$.backup_time_code"
:label="$gettext('Scheduled Backup Time')" :label="$gettext('Scheduled Backup Time')"
:description="$gettext('If the end time is before the start time, the playlist will play overnight.')"
> >
<template #default="slotProps"> <template #default="slotProps">
<time-code <time-code

View File

@ -41,6 +41,14 @@
> >
{{ $gettext('Clone') }} {{ $gettext('Clone') }}
</button> </button>
<button
type="button"
class="btn btn-sm"
:class="(row.item.is_enabled) ? 'btn-warning' : 'btn-success'"
@click="doToggle(row.item)"
>
{{ (row.item.is_enabled) ? $gettext('Disable') : $gettext('Enable') }}
</button>
<button <button
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
@ -74,7 +82,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue'; import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import AdminStationsEditModal from "./Stations/EditModal.vue"; import AdminStationsEditModal from "./Stations/EditModal.vue";
import {get} from "lodash"; import {get} from "lodash";
import AdminStationsCloneModal from "./Stations/CloneModal.vue"; import AdminStationsCloneModal from "./Stations/CloneModal.vue";
@ -89,6 +97,9 @@ import CardPage from "~/components/Common/CardPage.vue";
import {getApiUrl} from "~/router"; import {getApiUrl} from "~/router";
import AddButton from "~/components/Common/AddButton.vue"; import AddButton from "~/components/Common/AddButton.vue";
import CloneModal from "~/components/Admin/Stations/CloneModal.vue"; import CloneModal from "~/components/Admin/Stations/CloneModal.vue";
import {useSweetAlert} from "~/vendor/sweetalert.ts";
import {useNotify} from "~/functions/useNotify.ts";
import {useAxios} from "~/vendor/axios.ts";
const props = defineProps({ const props = defineProps({
...stationFormProps, ...stationFormProps,
@ -149,6 +160,29 @@ const doClone = (stationName, url) => {
$cloneModal.value.create(stationName, url); $cloneModal.value.create(stationName, url);
}; };
const {showAlert} = useSweetAlert();
const {notifySuccess} = useNotify();
const {axios} = useAxios();
const doToggle = (station) => {
const title = (station.is_enabled)
? $gettext('Disable station?')
: $gettext('Enable station?');
showAlert({
title: title
}).then((result) => {
if (result.value) {
axios.put(station.links.self, {
is_enabled: !station.is_enabled
}).then((resp) => {
notifySuccess(resp.data.message);
relist();
});
}
});
};
const {doDelete} = useConfirmAndDelete( const {doDelete} = useConfirmAndDelete(
$gettext('Delete Station?'), $gettext('Delete Station?'),
relist relist

View File

@ -50,7 +50,7 @@
class="col-md-4" class="col-md-4"
:field="v$.backend_config.hls_segment_length" :field="v$.backend_config.hls_segment_length"
input-type="number" input-type="number"
:input-attrs="{ min: '0', max: '60' }" :input-attrs="{ min: '0', max: '9999' }"
advanced advanced
:label="$gettext('Segment Length (Seconds)')" :label="$gettext('Segment Length (Seconds)')"
/> />

View File

@ -43,13 +43,10 @@
:label="$gettext('Live Broadcast Recording Format')" :label="$gettext('Live Broadcast Recording Format')"
/> />
<form-group-multi-check <bitrate-options
id="edit_form_backend_record_streams_bitrate" id="edit_form_backend_record_streams_bitrate"
class="col-md-6" class="col-md-6"
:field="v$.backend_config.record_streams_bitrate" :field="v$.backend_config.record_streams_bitrate"
:options="recordBitrateOptions"
stacked
radio
:label="$gettext('Live Broadcast Recording Bitrate (kbps)')" :label="$gettext('Live Broadcast Recording Bitrate (kbps)')"
/> />
</div> </div>
@ -140,6 +137,7 @@ import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
import {numeric} from "@vuelidate/validators"; import {numeric} from "@vuelidate/validators";
import {useAzuraCast} from "~/vendor/azuracast"; import {useAzuraCast} from "~/vendor/azuracast";
import Tab from "~/components/Common/Tab.vue"; import Tab from "~/components/Common/Tab.vue";
import BitrateOptions from "~/components/Common/BitrateOptions.vue";
const props = defineProps({ const props = defineProps({
form: { form: {
@ -250,17 +248,4 @@ const recordStreamsOptions = computed(() => {
} }
]; ];
}); });
const recordBitrateOptions = computed(() => {
return [
{text: '32', value: 32},
{text: '48', value: 48},
{text: '64', value: 64},
{text: '96', value: 96},
{text: '128', value: 128},
{text: '192', value: 192},
{text: '256', value: 256},
{text: '320', value: 320}
];
});
</script> </script>

View File

@ -1,7 +1,8 @@
import {getApiUrl} from "~/router.ts"; import {getApiUrl} from "~/router.ts";
import populateComponentRemotely from "~/functions/populateComponentRemotely.ts"; import populateComponentRemotely from "~/functions/populateComponentRemotely.ts";
import {RouteRecordRaw} from "vue-router";
export default function useAdminRoutes() { export default function useAdminRoutes(): RouteRecordRaw[] {
return [ return [
{ {
path: '/', path: '/',

View File

@ -0,0 +1,153 @@
<template>
<form-group
v-bind="$attrs"
:id="id"
>
<template
v-if="label || slots.label"
#label="slotProps"
>
<form-label
:is-required="isRequired"
:advanced="advanced"
>
<slot
name="label"
v-bind="slotProps"
>
{{ label }}
</slot>
</form-label>
</template>
<template #default>
<form-multi-check
:id="id"
v-model="radioField"
:name="name || id"
:options="bitrateOptions"
radio
stacked
>
<template
v-for="(_, slot) of useSlotsExcept(['default', 'label', 'description'])"
#[slot]="scope"
>
<slot
:name="slot"
v-bind="scope"
/>
</template>
<template #label(custom)>
{{ $gettext('Custom') }}
<input
:id="id+'_custom'"
v-model="customField"
class="form-control form-control-sm"
type="number"
min="1"
max="4096"
step="1"
>
</template>
</form-multi-check>
</template>
<template
v-if="description || slots.description"
#description="slotProps"
>
<slot
v-bind="slotProps"
name="description"
>
{{ description }}
</slot>
</template>
</form-group>
</template>
<script setup lang="ts">
import {formFieldProps, useFormField} from "~/components/Form/useFormField";
import {computed, ComputedRef, useSlots} from "vue";
import {includes, map} from "lodash";
import useSlotsExcept from "~/functions/useSlotsExcept.ts";
import FormMultiCheck from "~/components/Form/FormMultiCheck.vue";
import FormLabel from "~/components/Form/FormLabel.vue";
import FormGroup from "~/components/Form/FormGroup.vue";
const props = defineProps({
...formFieldProps,
id: {
type: String,
required: true
},
name: {
type: String,
default: null,
},
label: {
type: String,
default: null
},
description: {
type: String,
default: null
},
advanced: {
type: Boolean,
default: false
}
});
const slots = useSlots();
const emit = defineEmits(['update:modelValue']);
const {model} = useFormField(props, emit);
const radioBitrates = [
32, 48, 64, 96, 128, 192, 256, 320
];
const customField: ComputedRef<number | null> = computed({
get() {
return includes(radioBitrates, model.value)
? ''
: model.value;
},
set(newValue) {
model.value = newValue;
}
});
const radioField: ComputedRef<number | string | null> = computed({
get() {
return includes(radioBitrates, model.value)
? model.value
: 'custom';
},
set(newValue) {
if (newValue !== 'custom') {
model.value = newValue;
}
}
});
const bitrateOptions = map(
radioBitrates,
(val) => {
return {
value: val,
text: val
};
}
);
bitrateOptions.push({
value: 'custom',
text: 'Custom'
});
</script>

View File

@ -1,7 +1,7 @@
<template> <template>
<div <div
:id="id" :id="id"
style="display: contents" class="datatable-wrapper"
> >
<div <div
v-if="showToolbar" v-if="showToolbar"
@ -110,7 +110,7 @@
<div class="px-3 py-1"> <div class="px-3 py-1">
<form-multi-check <form-multi-check
id="field_select" id="field_select"
v-model="settings.visibleFieldKeys" v-model="visibleFieldKeys"
:options="selectableFieldOptions" :options="selectableFieldOptions"
stacked stacked
/> />
@ -184,7 +184,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template v-if="isLoading"> <template v-if="isLoading && hideOnLoading">
<tr> <tr>
<td <td
:colspan="columnCount" :colspan="columnCount"
@ -247,6 +247,7 @@
:name="'cell('+column.key+')'" :name="'cell('+column.key+')'"
:column="column" :column="column"
:item="row" :item="row"
:is-active="isActiveDetailRow(row)"
:toggle-details="() => toggleDetails(row)" :toggle-details="() => toggleDetails(row)"
> >
{{ getColumnValue(column, row) }} {{ getColumnValue(column, row) }}
@ -285,7 +286,7 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts" generic="Row extends object">
import {filter, forEach, get, includes, indexOf, isEmpty, map, reverse, slice, some} from 'lodash'; import {filter, forEach, get, includes, indexOf, isEmpty, map, reverse, slice, some} from 'lodash';
import Icon from './Icon.vue'; import Icon from './Icon.vue';
import {computed, onMounted, ref, shallowRef, toRaw, toRef, useSlots, watch} from "vue"; import {computed, onMounted, ref, shallowRef, toRaw, toRef, useSlots, watch} from "vue";
@ -298,71 +299,43 @@ import useOptionalStorage from "~/functions/useOptionalStorage";
import {IconArrowDropDown, IconArrowDropUp, IconFilterList, IconRefresh, IconSearch} from "~/components/Common/icons"; import {IconArrowDropDown, IconArrowDropUp, IconFilterList, IconRefresh, IconSearch} from "~/components/Common/icons";
import {useAzuraCast} from "~/vendor/azuracast.ts"; import {useAzuraCast} from "~/vendor/azuracast.ts";
const props = defineProps({ export interface DataTableProps {
id: { id?: string,
type: String, fields: DataTableField[],
default: null apiUrl?: string, // URL to fetch for server-side data
}, items?: Row[], // Array of items for client-side data
apiUrl: { responsive?: boolean | string, // Make table responsive (boolean or CSS class for specific responsiveness width)
type: String, paginated?: boolean, // Enable pagination.
default: null loading?: boolean, // Pass to override the "loading" property for this table.
}, hideOnLoading?: boolean, // Replace the table contents with a loading animation when data is being retrieved.
items: { showToolbar?: boolean, // Show the header "Toolbar" with search, refresh, per-page, etc.
type: Array, pageOptions?: number[],
default: null defaultPerPage?: number,
}, selectable?: boolean, // Allow selecting individual rows with checkboxes at the side of each row
responsive: { detailed?: boolean, // Allow showing "Detail" panel for selected rows.
type: [Boolean, String], selectFields?: boolean, // Allow selecting which columns are visible.
default: true handleClientSide?: boolean, // Handle searching, sorting and pagination client-side without API calls.
}, requestConfig?(config: object): object, // Custom server-side request configuration (pre-request)
paginated: { requestProcess?(rawData: object[]): Row[], // Custom server-side request result processing (post-request)
type: Boolean, }
default: false
}, const props = withDefaults(defineProps<DataTableProps>(), {
loading: { id: null,
type: Boolean, apiUrl: null,
default: false items: null,
}, responsive: () => true,
showToolbar: { paginated: false,
type: Boolean, loading: false,
default: true hideOnLoading: true,
}, showToolbar: true,
pageOptions: { pageOptions: () => [10, 25, 50, 100, 250, 500, 0],
type: Array<number>, defaultPerPage: 10,
default: () => [10, 25, 50, 100, 250, 500, 0] selectable: false,
}, detailed: false,
defaultPerPage: { selectFields: false,
type: Number, handleClientSide: false,
default: 10 requestConfig: undefined,
}, requestProcess: undefined
fields: {
type: Array<DataTableField>,
required: true
},
selectable: {
type: Boolean,
default: false
},
detailed: {
type: Boolean,
default: false
},
selectFields: {
type: Boolean,
default: false
},
handleClientSide: {
type: Boolean,
default: false
},
requestConfig: {
type: Function,
default: null
},
requestProcess: {
type: Function,
default: null
}
}); });
const slots = useSlots(); const slots = useSlots();
@ -375,7 +348,7 @@ const emit = defineEmits([
'data-loaded' 'data-loaded'
]); ]);
const selectedRows = shallowRef([]); const selectedRows = shallowRef<Row[]>([]);
const searchPhrase = ref<string>(''); const searchPhrase = ref<string>('');
const currentPage = ref<number>(1); const currentPage = ref<number>(1);
@ -390,10 +363,10 @@ watch(toRef(props, 'loading'), (newLoading: boolean) => {
isLoading.value = newLoading; isLoading.value = newLoading;
}); });
const visibleItems = shallowRef([]); const visibleItems = shallowRef<Row[]>([]);
const totalRows = ref(0); const totalRows = ref(0);
const activeDetailsRow = shallowRef(null); const activeDetailsRow = shallowRef<Row>(null);
export interface DataTableField { export interface DataTableField {
key: string, key: string,
@ -453,15 +426,25 @@ const settings = useOptionalStorage(
} }
); );
const visibleFieldKeys = computed(() => { const visibleFieldKeys = computed({
if (!isEmpty(settings.value.visibleFieldKeys)) { get: () => {
return settings.value.visibleFieldKeys; const settingsKeys = toRaw(settings.value.visibleFieldKeys);
} if (!isEmpty(settingsKeys)) {
return settingsKeys;
}
return map(defaultSelectableFields.value, (field) => field.key); return map(defaultSelectableFields.value, (field) => field.key);
},
set: (newValue) => {
if (isEmpty(newValue)) {
newValue = map(defaultSelectableFields.value, (field) => field.key);
}
settings.value.visibleFieldKeys = newValue;
}
}); });
const perPage = computed(() => { const perPage = computed<number>(() => {
if (!props.paginated) { if (!props.paginated) {
return -1; return -1;
} }
@ -487,15 +470,15 @@ const visibleFields = computed<DataTableField[]>(() => {
}); });
}); });
const getPerPageLabel = (num) => { const getPerPageLabel = (num): string => {
return (num === 0) ? 'All' : num.toString(); return (num === 0) ? 'All' : num.toString();
}; };
const perPageLabel = computed(() => { const perPageLabel = computed<string>(() => {
return getPerPageLabel(perPage.value); return getPerPageLabel(perPage.value);
}); });
const showPagination = computed(() => { const showPagination = computed<boolean>(() => {
return props.paginated && perPage.value !== 0; return props.paginated && perPage.value !== 0;
}); });
@ -683,7 +666,7 @@ const isAllChecked = computed<boolean>(() => {
}); });
}); });
const isRowChecked = (row) => { const isRowChecked = (row: Row) => {
return indexOf(selectedRows.value, row) >= 0; return indexOf(selectedRows.value, row) >= 0;
}; };
@ -711,7 +694,7 @@ const sort = (column: DataTableField) => {
refresh(); refresh();
}; };
const checkRow = (row) => { const checkRow = (row: Row) => {
const newSelectedRows = selectedRows.value; const newSelectedRows = selectedRows.value;
if (isRowChecked(row)) { if (isRowChecked(row)) {
@ -740,11 +723,11 @@ const checkAll = () => {
selectedRows.value = newSelectedRows; selectedRows.value = newSelectedRows;
}; };
const isActiveDetailRow = (row) => { const isActiveDetailRow = (row: Row) => {
return activeDetailsRow.value === row; return activeDetailsRow.value === row;
}; };
const toggleDetails = (row) => { const toggleDetails = (row: Row) => {
activeDetailsRow.value = isActiveDetailRow(row) activeDetailsRow.value = isActiveDetailRow(row)
? null ? null
: row; : row;
@ -758,7 +741,7 @@ const responsiveClass = computed(() => {
return (props.responsive ? 'table-responsive' : ''); return (props.responsive ? 'table-responsive' : '');
}); });
const getColumnValue = (field: DataTableField, row: object): string => { const getColumnValue = (field: DataTableField, row: Row): string => {
const columnValue = get(row, field.key, null); const columnValue = get(row, field.key, null);
return (field.formatter) return (field.formatter)

View File

@ -52,21 +52,37 @@ export const IconCheck: Icon = {
viewBox: materialIconsViewBox, viewBox: materialIconsViewBox,
contents: '<path d="M378-235 142-471l52-52 184 184 388-388 52 52-440 440Z"/>' contents: '<path d="M378-235 142-471l52-52 184 184 388-388 52 52-440 440Z"/>'
}; };
export const IconChevronBarDown: Icon = {
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M3.646 4.146a.5.5 0 0 1 .708 0L8 7.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708M1 11.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13a.5.5 0 0 1-.5-.5"/>'
};
export const IconChevronBarUp: Icon = {
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M3.646 11.854a.5.5 0 0 0 .708 0L8 8.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708M2.4 5.2c0 .22.18.4.4.4h10.4a.4.4 0 0 0 0-.8H2.8a.4.4 0 0 0-.4.4"/>'
};
export const IconChevronDoubleDown: Icon = {
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M1.646 6.646a.5.5 0 0 1 .708 0L8 12.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/> <path xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" d="M1.646 2.646a.5.5 0 0 1 .708 0L8 8.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>'
};
export const IconChevronDoubleUp: Icon = {
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M7.646 2.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 3.707 2.354 9.354a.5.5 0 1 1-.708-.708z"/> <path xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" d="M7.646 6.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 7.707l-5.646 5.647a.5.5 0 0 1-.708-.708z"/>'
};
export const IconChevronDown: Icon = { export const IconChevronDown: Icon = {
viewBox: materialIconsViewBox, viewBox: bootstrapIconsViewBox,
contents: '<path d="M480-335 230-585l53-53 197 199 198-198 52 53-250 249Z"/>' contents: '<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>'
}; };
export const IconChevronLeft: Icon = { export const IconChevronLeft: Icon = {
viewBox: materialIconsViewBox, viewBox: bootstrapIconsViewBox,
contents: '<path d="M562-231 311-482l251-251 52 52-199 199 199 199-52 52Z"/>' contents: '<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0"/>'
}; };
export const IconChevronRight: Icon = { export const IconChevronRight: Icon = {
viewBox: materialIconsViewBox, viewBox: bootstrapIconsViewBox,
contents: '<path d="M522-482 323-681l52-52 251 251-251 251-52-52 199-199Z"/>' contents: '<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708"/>'
}; };
export const IconChevronUp: Icon = { export const IconChevronUp: Icon = {
viewBox: materialIconsViewBox, viewBox: bootstrapIconsViewBox,
contents: '<path d="m283-335-53-53 250-250 250 249-52 53-198-198-197 199Z"/>' contents: '<path fill-rule="evenodd" d="M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708z"/>'
}; };
export const IconClearAll: Icon = { export const IconClearAll: Icon = {
viewBox: materialIconsViewBox, viewBox: materialIconsViewBox,
@ -80,6 +96,10 @@ export const IconCode: Icon = {
viewBox: materialIconsViewBox, viewBox: materialIconsViewBox,
contents: '<path d="M318-232 68-483l253-253 52 53-200 200 198 198-53 53Zm322 2-52-53 201-201-198-198 51-51 251 250-253 253Z"/>' contents: '<path d="M318-232 68-483l253-253 52 53-200 200 198 198-53 53Zm322 2-52-53 201-201-198-198 51-51 251 250-253 253Z"/>'
}; };
export const IconContract: Icon = {
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M3.646 14.854a.5.5 0 0 0 .708 0L8 11.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708m0-13.708a.5.5 0 0 1 .708 0L8 4.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708M1 8a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 8"/>'
};
export const IconCopy: Icon = { export const IconCopy: Icon = {
viewBox: materialIconsViewBox, viewBox: materialIconsViewBox,
contents: '<path d="M305-185q-29.725 0-51.363-22.137Q232-229.275 232-258v-564q0-28.725 21.637-50.862Q275.275-895 305-895h444q28.725 0 50.862 22.138Q822-850.725 822-822v564q0 28.725-22.138 50.863Q777.725-185 749-185H305ZM172-52q-29.725 0-51.363-22.138Q99-96.275 99-125v-637h73v637h517v73H172Z"/>' contents: '<path d="M305-185q-29.725 0-51.363-22.137Q232-229.275 232-258v-564q0-28.725 21.637-50.862Q275.275-895 305-895h444q28.725 0 50.862 22.138Q822-850.725 822-822v564q0 28.725-22.138 50.863Q777.725-185 749-185H305ZM172-52q-29.725 0-51.363-22.138Q99-96.275 99-125v-637h73v637h517v73H172Z"/>'
@ -112,6 +132,10 @@ export const IconExitToApp: Icon = {
viewBox: materialIconsViewBox, viewBox: materialIconsViewBox,
contents: '<path d="M177-104q-28.725 0-50.863-22.137Q104-148.275 104-177v-194h73v194h606v-606H177v193h-73v-193q0-28.725 22.137-50.862Q148.275-856 177-856h606q28.725 0 50.862 22.138Q856-811.725 856-783v606q0 28.725-22.138 50.863Q811.725-104 783-104H177Zm235-166-54-56 118-118H104v-73h372L358-635l54-55 209 209-209 211Z"/>' contents: '<path d="M177-104q-28.725 0-50.863-22.137Q104-148.275 104-177v-194h73v194h606v-606H177v193h-73v-193q0-28.725 22.137-50.862Q148.275-856 177-856h606q28.725 0 50.862 22.138Q856-811.725 856-783v606q0 28.725-22.138 50.863Q811.725-104 783-104H177Zm235-166-54-56 118-118H104v-73h372L358-635l54-55 209 209-209 211Z"/>'
}; };
export const IconExpand: Icon = {
viewBox: bootstrapIconsViewBox,
contents: '<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708m0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708"/>'
};
export const IconFastForward: Icon = { export const IconFastForward: Icon = {
viewBox: materialIconsViewBox, viewBox: materialIconsViewBox,
contents: '<path d="M78-221v-518l375 259L78-221Zm431 0v-518l375 259-375 259Z"/>' contents: '<path d="M78-221v-518l375 259L78-221Zm431 0v-518l375 259-375 259Z"/>'
@ -260,6 +284,10 @@ export const IconRouter: Icon = {
viewBox: materialIconsViewBox, viewBox: materialIconsViewBox,
contents: '<path d="M160-84q-28.725 0-50.863-21.637Q87-127.275 87-157v-209q0-28.725 22.137-50.862Q131.275-439 160-439h460v-178h73v178h99q28.725 0 50.862 22.138Q865-394.725 865-366v209q0 29.725-22.138 51.363Q820.725-84 792-84H160Zm142-177.105Q302-280 288.895-293q-13.106-13-32-13Q238-306 225-292.895q-13 13.106-13 32Q212-241 225.105-228.5q13.106 12.5 32 12.5Q276-216 289-228.605q13-12.606 13-32.5Zm150 0Q452-280 438.895-293q-13.106-13-32-13Q387-306 374.5-292.895q-12.5 13.106-12.5 32Q362-241 374.605-228.5q12.606 12.5 32.5 12.5Q426-216 439-228.605q13-12.606 13-32.5ZM556.105-216Q575-216 588-228.605q13-12.606 13-32.5Q601-280 587.895-293q-13.106-13-32-13Q537-306 524-292.895q-13 13.106-13 32Q511-241 524.105-228.5q13.106 12.5 32 12.5ZM566-664l-50-50q28-27 62.989-43.5 34.988-16.5 78.068-16.5 42.08 0 77.012 16.5Q769-741 797-714l-49 50q-17-16-40.409-26.5t-50-10.5Q630-701 606-690.5T566-664ZM466-764l-53-53q39.2-39.638 103.1-68.819Q580-915 657-915q76 0 139.5 29T901-817l-54 53q-31-33-79.5-55.5T657-842q-63 0-111 22.5T466-764Z"/>' contents: '<path d="M160-84q-28.725 0-50.863-21.637Q87-127.275 87-157v-209q0-28.725 22.137-50.862Q131.275-439 160-439h460v-178h73v178h99q28.725 0 50.862 22.138Q865-394.725 865-366v209q0 29.725-22.138 51.363Q820.725-84 792-84H160Zm142-177.105Q302-280 288.895-293q-13.106-13-32-13Q238-306 225-292.895q-13 13.106-13 32Q212-241 225.105-228.5q13.106 12.5 32 12.5Q276-216 289-228.605q13-12.606 13-32.5Zm150 0Q452-280 438.895-293q-13.106-13-32-13Q387-306 374.5-292.895q-12.5 13.106-12.5 32Q362-241 374.605-228.5q12.606 12.5 32.5 12.5Q426-216 439-228.605q13-12.606 13-32.5ZM556.105-216Q575-216 588-228.605q13-12.606 13-32.5Q601-280 587.895-293q-13.106-13-32-13Q537-306 524-292.895q-13 13.106-13 32Q511-241 524.105-228.5q13.106 12.5 32 12.5ZM566-664l-50-50q28-27 62.989-43.5 34.988-16.5 78.068-16.5 42.08 0 77.012 16.5Q769-741 797-714l-49 50q-17-16-40.409-26.5t-50-10.5Q630-701 606-690.5T566-664ZM466-764l-53-53q39.2-39.638 103.1-68.819Q580-915 657-915q76 0 139.5 29T901-817l-54 53q-31-33-79.5-55.5T657-842q-63 0-111 22.5T466-764Z"/>'
}; };
export const IconRss: Icon = {
viewBox: bootstrapIconsViewBox,
contents: '<path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm1.5 2.5c5.523 0 10 4.477 10 10a1 1 0 1 1-2 0 8 8 0 0 0-8-8 1 1 0 0 1 0-2m0 4a6 6 0 0 1 6 6 1 1 0 1 1-2 0 4 4 0 0 0-4-4 1 1 0 0 1 0-2m.5 7a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/>'
};
export const IconSearch: Icon = { export const IconSearch: Icon = {
viewBox: materialIconsViewBox, viewBox: materialIconsViewBox,
contents: '<path d="M801-108 537-372q-31 26-72.959 40t-86.603 14q-114.6 0-192.519-78Q107-474 107-586t78-190q78-78 190-78t190 78q78 78 78 190.15 0 43.85-13.5 84.35Q616-461 588-425l266 264-53 53ZM375.5-391q81.75 0 138.125-56.792Q570-504.583 570-586t-56.287-138.208Q457.426-781 375.588-781q-82.671 0-139.13 56.792Q180-667.417 180-586t56.458 138.208Q292.917-391 375.5-391Z"/>' contents: '<path d="M801-108 537-372q-31 26-72.959 40t-86.603 14q-114.6 0-192.519-78Q107-474 107-586t78-190q78-78 190-78t190 78q78 78 78 190.15 0 43.85-13.5 84.35Q616-461 588-425l266 264-53 53ZM375.5-391q81.75 0 138.125-56.792Q570-504.583 570-586t-56.287-138.208Q457.426-781 375.588-781q-82.671 0-139.13 56.792Q180-667.417 180-586t56.458 138.208Q292.917-391 375.5-391Z"/>'

View File

@ -143,126 +143,98 @@
</div> </div>
</template> </template>
<loading :loading="stationsLoading"> <data-table
<div class="table-responsive"> id="dashboard_stations"
<table ref="$datatable"
id="station_dashboard" :fields="stationFields"
class="table table-striped" :api-url="stationsUrl"
> paginated
<colgroup> responsive
<col width="5%"> show-toolbar
<col width="30%"> :hide-on-loading="false"
<col width="10%"> >
<col width="40%"> <template #cell(play_button)="{ item }">
<col width="15%"> <play-button
</colgroup> class="file-icon btn-lg"
<thead> :url="item.station.listen_url"
<tr> is-stream
<th class="pe-3"> />
&nbsp; </template>
</th> <template #cell(name)="{ item }">
<th class="ps-2"> <div class="h5 m-0">
{{ $gettext('Station Name') }} {{ item.station.name }}
</th> </div>
<th class="text-center"> <div v-if="item.station.is_public">
{{ $gettext('Listeners') }} <a
</th> :href="item.links.public"
<th>{{ $gettext('Now Playing') }}</th> target="_blank"
<th class="text-end" /> >
</tr> {{ $gettext('Public Page') }}
</thead> </a>
<tbody> </div>
<tr </template>
v-for="item in stations" <template #cell(listeners)="{ item }">
:key="item.station.id" <span class="pe-1">
class="align-middle" <icon
> class="sm align-middle"
<td class="text-center pe-1"> :icon="IconHeadphones"
<play-button />
class="file-icon btn-lg" </span>
:url="item.station.listen_url" <template v-if="item.links.listeners">
is-stream <a
/> :href="item.links.listeners"
</td> :aria-label="$gettext('View Listener Report')"
<td class="ps-2"> >
<div class="h5 m-0"> {{ item.listeners.total }}
{{ item.station.name }} </a>
</div> </template>
<div v-if="item.station.is_public"> <template v-else>
<a {{ item.listeners.total }}
:href="item.links.public" </template>
target="_blank" </template>
> <template #cell(now_playing)="{ item }">
{{ $gettext('Public Page') }} <div class="d-flex align-items-center">
</a> <album-art
</div> v-if="showAlbumArt"
</td> :src="item.now_playing.song.art"
<td class="text-center"> class="flex-shrink-0 pe-3"
<span class="pe-1"> />
<icon
class="sm align-middle"
:icon="IconHeadphones"
/>
</span>
<template v-if="item.links.listeners">
<a
:href="item.links.listeners"
:aria-label="$gettext('View Listener Report')"
>
{{ item.listeners.total }}
</a>
</template>
<template v-else>
{{ item.listeners.total }}
</template>
</td>
<td>
<div class="d-flex align-items-center">
<album-art
v-if="showAlbumArt"
:src="item.now_playing.song.art"
class="flex-shrink-0 pe-3"
/>
<div <div
v-if="!item.is_online" v-if="!item.is_online"
class="flex-fill text-muted" class="flex-fill text-muted"
> >
{{ $gettext('Station Offline') }} {{ $gettext('Station Offline') }}
</div> </div>
<div <div
v-else-if="item.now_playing.song.title !== ''" v-else-if="item.now_playing.song.title !== ''"
class="flex-fill" class="flex-fill"
> >
<strong><span class="nowplaying-title"> <strong><span class="nowplaying-title">
{{ item.now_playing.song.title }} {{ item.now_playing.song.title }}
</span></strong><br> </span></strong><br>
<span class="nowplaying-artist">{{ item.now_playing.song.artist }}</span> <span class="nowplaying-artist">{{ item.now_playing.song.artist }}</span>
</div> </div>
<div <div
v-else v-else
class="flex-fill" class="flex-fill"
> >
<strong><span class="nowplaying-title"> <strong><span class="nowplaying-title">
{{ item.now_playing.song.text }} {{ item.now_playing.song.text }}
</span></strong> </span></strong>
</div> </div>
</div> </div>
</td> </template>
<td class="text-end"> <template #cell(actions)="{ item }">
<a <a
class="btn btn-primary" class="btn btn-primary"
:href="item.links.manage" :href="item.links.manage"
role="button" role="button"
> >
{{ $gettext('Manage') }} {{ $gettext('Manage') }}
</a> </a>
</td> </template>
</tr> </data-table>
</tbody>
</table>
</div>
</loading>
</card-page> </card-page>
</div> </div>
@ -276,11 +248,10 @@ import Icon from '~/components/Common/Icon.vue';
import PlayButton from "~/components/Common/PlayButton.vue"; import PlayButton from "~/components/Common/PlayButton.vue";
import AlbumArt from "~/components/Common/AlbumArt.vue"; import AlbumArt from "~/components/Common/AlbumArt.vue";
import {useAxios} from "~/vendor/axios"; import {useAxios} from "~/vendor/axios";
import {useAsyncState} from "@vueuse/core"; import {useAsyncState, useIntervalFn} from "@vueuse/core";
import {computed, ref} from "vue"; import {computed, ref} from "vue";
import DashboardCharts from "~/components/DashboardCharts.vue"; import DashboardCharts from "~/components/DashboardCharts.vue";
import {useTranslate} from "~/vendor/gettext"; import {useTranslate} from "~/vendor/gettext";
import Loading from "~/components/Common/Loading.vue";
import Lightbox from "~/components/Common/Lightbox.vue"; import Lightbox from "~/components/Common/Lightbox.vue";
import CardPage from "~/components/Common/CardPage.vue"; import CardPage from "~/components/Common/CardPage.vue";
import HeaderInlinePlayer from "~/components/HeaderInlinePlayer.vue"; import HeaderInlinePlayer from "~/components/HeaderInlinePlayer.vue";
@ -289,7 +260,8 @@ import useOptionalStorage from "~/functions/useOptionalStorage";
import {IconAccountCircle, IconHeadphones, IconInfo, IconSettings, IconWarning} from "~/components/Common/icons"; import {IconAccountCircle, IconHeadphones, IconInfo, IconSettings, IconWarning} from "~/components/Common/icons";
import UserInfoPanel from "~/components/Account/UserInfoPanel.vue"; import UserInfoPanel from "~/components/Account/UserInfoPanel.vue";
import {getApiUrl} from "~/router.ts"; import {getApiUrl} from "~/router.ts";
import useAutoRefreshingAsyncState from "~/functions/useAutoRefreshingAsyncState.ts"; import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
const props = defineProps({ const props = defineProps({
profileUrl: { profileUrl: {
@ -332,19 +304,47 @@ const langShowHideCharts = computed(() => {
: $gettext('Show Charts') : $gettext('Show Charts')
}); });
const {axios, axiosSilent} = useAxios(); const {axios} = useAxios();
const {state: notifications, isLoading: notificationsLoading} = useAsyncState( const {state: notifications, isLoading: notificationsLoading} = useAsyncState(
() => axios.get(notificationsUrl.value).then((r) => r.data), () => axios.get(notificationsUrl.value).then((r) => r.data),
[] []
); );
const {state: stations, isLoading: stationsLoading} = useAutoRefreshingAsyncState( const stationFields: DataTableField[] = [
() => axiosSilent.get(stationsUrl.value).then((r) => r.data),
[],
{ {
timeout: 15000 key: 'play_button',
sortable: false,
class: 'shrink'
},
{
key: 'name',
label: $gettext('Station Name'),
sortable: true,
},
{
key: 'listeners',
label: $gettext('Listeners'),
sortable: true
},
{
key: 'now_playing',
label: $gettext('Now Playing'),
sortable: true
},
{
key: 'actions',
sortable: false,
class: 'shrink'
} }
];
const $datatable = ref<DataTableTemplateRef>(null);
const {refresh} = useHasDatatable($datatable);
useIntervalFn(
refresh,
computed(() => (document.hidden) ? 30000 : 15000)
); );
const $lightbox = ref<LightboxTemplateRef>(null); const $lightbox = ref<LightboxTemplateRef>(null);

View File

@ -19,7 +19,7 @@
class="form-check-label" class="form-check-label"
:for="id+'_'+option.value" :for="id+'_'+option.value"
> >
<slot :name="`label(${option.value})`"> <slot :name="'label('+option.value+')'">
<template v-if="option.description"> <template v-if="option.description">
<strong>{{ option.text }}</strong> <strong>{{ option.text }}</strong>
<br> <br>

View File

@ -0,0 +1,32 @@
<template>
<section
id="content"
class="full-height-wrapper"
role="main"
>
<div class="container">
<div class="card">
<div class="card-header text-bg-primary">
<slot name="header">
<h2 class="card-title py-2">
<slot name="title" />
</h2>
</slot>
</div>
<slot name="default" />
</div>
</div>
</section>
<lightbox ref="$lightbox" />
</template>
<script setup lang="ts">
import Lightbox from "~/components/Common/Lightbox.vue";
import {ref} from "vue";
import {LightboxTemplateRef, useProvideLightbox} from "~/vendor/lightbox.ts";
const $lightbox = ref<LightboxTemplateRef>(null);
useProvideLightbox($lightbox);
</script>

View File

@ -1,70 +1,62 @@
<template> <template>
<section <full-height-card>
id="content" <template #header>
class="full-height-wrapper" <div class="d-flex align-items-center">
role="main" <div class="flex-shrink">
> <h2 class="card-title py-2">
<div class="container"> <template v-if="stationName">
<div class="card"> {{ stationName }}
<div class="card-header text-bg-primary">
<div class="d-flex align-items-center">
<div class="flex-shrink">
<h2 class="card-title py-2">
<template v-if="stationName">
{{ stationName }}
</template>
<template v-else>
{{ $gettext('On-Demand Media') }}
</template>
</h2>
</div>
<div class="flex-fill text-end">
<inline-player ref="player" />
</div>
</div>
</div>
<data-table
id="public_on_demand"
ref="datatable"
paginated
select-fields
:fields="fields"
:api-url="listUrl"
>
<template #cell(download_url)="row">
<play-button
class="btn-lg"
:url="row.item.download_url"
/>
<template v-if="showDownloadButton">
<a
class="name btn btn-lg p-0 ms-2"
:href="row.item.download_url"
target="_blank"
:title="$gettext('Download')"
>
<icon :icon="IconDownload" />
</a>
</template>
</template>
<template #cell(art)="row">
<album-art :src="row.item.media.art" />
</template>
<template #cell(size)="row">
<template v-if="!row.item.size">
&nbsp;
</template> </template>
<template v-else> <template v-else>
{{ formatFileSize(row.item.size) }} {{ $gettext('On-Demand Media') }}
</template> </template>
</template> </h2>
</data-table> </div>
<div class="flex-fill text-end">
<inline-player ref="player" />
</div>
</div> </div>
</div> </template>
</section>
<lightbox ref="$lightbox" /> <template #default>
<data-table
id="public_on_demand"
ref="datatable"
paginated
select-fields
:fields="fields"
:api-url="listUrl"
>
<template #cell(download_url)="row">
<play-button
class="btn-lg"
:url="row.item.download_url"
/>
<template v-if="showDownloadButton">
<a
class="name btn btn-lg p-0 ms-2"
:href="row.item.download_url"
target="_blank"
:title="$gettext('Download')"
>
<icon :icon="IconDownload" />
</a>
</template>
</template>
<template #cell(art)="row">
<album-art :src="row.item.media.art" />
</template>
<template #cell(size)="row">
<template v-if="!row.item.size">
&nbsp;
</template>
<template v-else>
{{ formatFileSize(row.item.size) }}
</template>
</template>
</data-table>
</template>
</full-height-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -74,12 +66,9 @@ import {forEach} from 'lodash';
import Icon from '~/components/Common/Icon.vue'; import Icon from '~/components/Common/Icon.vue';
import PlayButton from "~/components/Common/PlayButton.vue"; import PlayButton from "~/components/Common/PlayButton.vue";
import {useTranslate} from "~/vendor/gettext"; import {useTranslate} from "~/vendor/gettext";
import formatFileSize from "../../functions/formatFileSize";
import AlbumArt from "~/components/Common/AlbumArt.vue"; import AlbumArt from "~/components/Common/AlbumArt.vue";
import Lightbox from "~/components/Common/Lightbox.vue";
import {ref} from "vue";
import {LightboxTemplateRef, useProvideLightbox} from "~/vendor/lightbox";
import {IconDownload} from "~/components/Common/icons"; import {IconDownload} from "~/components/Common/icons";
import FullHeightCard from "~/components/Public/FullHeightCard.vue";
const props = defineProps({ const props = defineProps({
listUrl: { listUrl: {
@ -141,7 +130,4 @@ forEach(props.customFields.slice(), (field) => {
formatter: (_value, _key, item) => item.media.custom_fields[field.key] formatter: (_value, _key, item) => item.media.custom_fields[field.key]
}); });
}); });
const $lightbox = ref<LightboxTemplateRef>(null);
useProvideLightbox($lightbox);
</script> </script>

View File

@ -0,0 +1,170 @@
<template>
<div class="full-height-scrollable">
<loading
:loading="isLoading"
lazy
>
<div class="card-body">
<div class="d-flex">
<div class="flex-fill">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<router-link :to="{name: 'public:podcasts'}">
{{ $gettext('Podcasts') }}
</router-link>
</li>
<li class="breadcrumb-item">
{{ podcast.title }}
</li>
</ol>
</nav>
<h4 class="card-title mb-1">
{{ podcast.title }}
<br>
<small>
{{ $gettext('by') }} <a
:href="'mailto:'+podcast.email"
target="_blank"
>{{ podcast.author }}</a>
</small>
</h4>
<div class="badges my-2">
<span class="badge text-bg-info">
{{ podcast.language_name }}
</span>
<span
v-for="category in podcast.categories"
:key="category.category"
class="badge text-bg-secondary"
>
{{ category.text }}
</span>
</div>
<p class="card-text">
{{ podcast.description }}
</p>
<div class="buttons">
<a
class="btn btn-warning btn-sm"
:href="podcast.links.public_feed"
target="_blank"
>
<icon :icon="IconRss" />
{{ $gettext('RSS') }}
</a>
</div>
</div>
<div class="flex-shrink ps-3">
<album-art
:src="podcast.art"
:width="128"
/>
</div>
</div>
</div>
</loading>
<data-table
id="podcast-episodes"
ref="$datatable"
paginated
:fields="fields"
:api-url="episodesUrl"
>
<template #cell(play_button)="{item}">
<play-button
icon-class="lg"
:url="item.links.download"
/>
</template>
<template #cell(art)="{item}">
<album-art
:src="item.art"
:width="64"
/>
</template>
<template #cell(title)="{item}">
<h5 class="m-0">
<router-link
:to="{name: 'public:podcast:episode', params: {podcast_id: podcast.id, episode_id: item.id}}"
>
{{ item.title }}
</router-link>
</h5>
<div class="badges my-2">
<span
v-if="item.publish_at"
class="badge text-bg-secondary"
>
{{ formatTimestampAsDateTime(item.publish_at) }}
</span>
<span
v-else
class="badge text-bg-secondary"
>
{{ formatTimestampAsDateTime(item.created_at) }}
</span>
<span
v-if="item.explicit"
class="badge text-bg-danger"
>
{{ $gettext('Explicit') }}
</span>
</div>
<p class="card-text">
{{ item.description_short }}
</p>
</template>
<template #cell(actions)="{item}">
<div class="btn-group btn-group-sm">
<router-link
:to="{name: 'public:podcast:episode', params: {podcast_id: podcast.id, episode_id: item.id}}"
class="btn btn-primary"
>
{{ $gettext('Details') }}
</router-link>
</div>
</template>
</data-table>
</div>
</template>
<script setup lang="ts">
import {getStationApiUrl} from "~/router.ts";
import {useRoute} from "vue-router";
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
import {useAxios} from "~/vendor/axios.ts";
import Loading from "~/components/Common/Loading.vue";
import AlbumArt from "~/components/Common/AlbumArt.vue";
import {useTranslate} from "~/vendor/gettext.ts";
import {IconRss} from "~/components/Common/icons.ts";
import Icon from "~/components/Common/Icon.vue";
import PlayButton from "~/components/Common/PlayButton.vue";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const {params} = useRoute();
const podcastUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}`);
const {axios} = useAxios();
const {state: podcast, isLoading} = useRefreshableAsyncState(
() => axios.get(podcastUrl.value).then((r) => r.data),
{},
);
const episodesUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}/episodes`);
const {$gettext} = useTranslate();
const fields: DataTableField[] = [
{key: 'play_button', label: '', sortable: false, class: 'shrink pe-0'},
{key: 'art', label: '', sortable: false, class: 'shrink pe-0'},
{key: 'title', label: $gettext('Episode'), sortable: true},
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
];
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
</script>

View File

@ -0,0 +1,148 @@
<template>
<div class="full-height-scrollable">
<div class="card-body">
<loading
:loading="podcastLoading || episodeLoading"
lazy
>
<nav aria-label="breadcrumb">
<ol class="breadcrumb m-0">
<li class="breadcrumb-item">
<router-link :to="{name: 'public:podcasts'}">
{{ $gettext('Podcasts') }}
</router-link>
</li>
<li class="breadcrumb-item">
<router-link :to="{name: 'public:podcast', params: {podcast_id: podcast.id}}">
{{ podcast.title }}
</router-link>
</li>
<li class="breadcrumb-item">
{{ episode.title }}
</li>
</ol>
</nav>
</loading>
</div>
<div
class="card-body alert alert-secondary"
aria-live="polite"
>
<loading
:loading="podcastLoading"
lazy
>
<h4 class="card-title mb-1">
{{ podcast.title }}
<br>
<small>
{{ $gettext('by') }} <a
:href="'mailto:'+podcast.email"
class="alert-link"
target="_blank"
>{{ podcast.author }}</a>
</small>
</h4>
<div class="badges my-2">
<span class="badge text-bg-info">
{{ podcast.language_name }}
</span>
<span
v-for="category in podcast.categories"
:key="category.category"
class="badge text-bg-secondary"
>
{{ category.text }}
</span>
</div>
<p class="card-text">
{{ podcast.description }}
</p>
</loading>
</div>
<div class="card-body">
<loading
:loading="episodeLoading"
lazy
>
<div class="d-flex">
<div class="flex-shrink-0 pe-3">
<play-button
icon-class="xl"
:url="episode.links.download"
/>
</div>
<div class="flex-fill">
<h4 class="card-title mb-1">
{{ episode.title }}
</h4>
<div class="badges my-2">
<span
v-if="episode.publish_at"
class="badge text-bg-secondary"
>
{{ formatTimestampAsDateTime(episode.publish_at) }}
</span>
<span
v-else
class="badge text-bg-secondary"
>
{{ formatTimestampAsDateTime(episode.created_at) }}
</span>
<span
v-if="episode.explicit"
class="badge text-bg-danger"
>
{{ $gettext('Explicit') }}
</span>
</div>
<p class="card-text">
{{ episode.description }}
</p>
</div>
<div class="flex-shrink-0 ps-3">
<album-art
:src="episode.art"
:width="96"
/>
</div>
</div>
</loading>
</div>
</div>
</template>
<script setup lang="ts">
import Loading from "~/components/Common/Loading.vue";
import {useRoute} from "vue-router";
import {getStationApiUrl} from "~/router.ts";
import {useAxios} from "~/vendor/axios.ts";
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
import AlbumArt from "~/components/Common/AlbumArt.vue";
import PlayButton from "~/components/Common/PlayButton.vue";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const {params} = useRoute();
const podcastUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}`);
const episodeUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}/episode/${params.episode_id}`);
const {axios} = useAxios();
const {state: podcast, isLoading: podcastLoading} = useRefreshableAsyncState(
() => axios.get(podcastUrl.value).then((r) => r.data),
{},
);
const {state: episode, isLoading: episodeLoading} = useRefreshableAsyncState(
() => axios.get(episodeUrl.value).then((r) => r.data),
{},
);
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
</script>

View File

@ -0,0 +1,85 @@
<template>
<data-table
id="podcasts"
ref="$datatable"
paginated
:fields="fields"
:api-url="apiUrl"
>
<template #cell(art)="{item}">
<album-art
:src="item.art"
:width="96"
/>
</template>
<template #cell(title)="{item}">
<h5 class="m-0">
<router-link
:to="{name: 'public:podcast', params: {podcast_id: item.id}}"
>
{{ item.title }}
</router-link>
<br>
<small>
{{ $gettext('by') }} <a
:href="'mailto:'+item.email"
target="_blank"
>{{ item.author }}</a>
</small>
</h5>
<div class="badges my-2">
<span class="badge text-bg-info">
{{ item.language_name }}
</span>
<span
v-for="category in item.categories"
:key="category.category"
class="badge text-bg-secondary"
>
{{ category.text }}
</span>
</div>
<p class="card-text">
{{ item.description_short }}
</p>
</template>
<template #cell(actions)="{item}">
<div class="btn-group btn-group-sm">
<router-link
:to="{name: 'public:podcast', params: {podcast_id: item.id}}"
class="btn btn-primary"
>
{{ $gettext('Episodes') }}
</router-link>
<a
class="btn btn-warning"
:href="item.links.public_feed"
target="_blank"
>
<icon :icon="IconRss" />
{{ $gettext('RSS') }}
</a>
</div>
</template>
</data-table>
</template>
<script setup lang="ts">
import AlbumArt from "~/components/Common/AlbumArt.vue";
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import {getStationApiUrl} from "~/router.ts";
import {useTranslate} from "~/vendor/gettext.ts";
import {IconRss} from "~/components/Common/icons.ts";
import Icon from "~/components/Common/Icon.vue";
const apiUrl = getStationApiUrl('/public/podcasts');
const {$gettext} = useTranslate();
const fields: DataTableField[] = [
{key: 'art', label: '', sortable: false, class: 'shrink pe-0'},
{key: 'title', label: $gettext('Podcast'), sortable: true},
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
];
</script>

View File

@ -0,0 +1,32 @@
<template>
<minimal-layout>
<full-height-card>
<template #header>
<div class="d-flex align-items-center">
<div class="flex-shrink">
<h2 class="card-title py-2">
<slot name="title">
{{ name }}
</slot>
</h2>
</div>
<div class="flex-fill text-end">
<inline-player ref="player" />
</div>
</div>
</template>
<template #default>
<router-view />
</template>
</full-height-card>
</minimal-layout>
</template>
<script setup lang="ts">
import FullHeightCard from "~/components/Public/FullHeightCard.vue";
import InlinePlayer from "~/components/InlinePlayer.vue";
import {useAzuraCastStation} from "~/vendor/azuracast.ts";
import MinimalLayout from "~/components/MinimalLayout.vue";
const {name} = useAzuraCastStation();
</script>

View File

@ -0,0 +1,21 @@
import {RouteRecordRaw} from "vue-router";
export default function usePodcastRoutes(): RouteRecordRaw[] {
return [
{
path: '/podcasts',
component: () => import('~/components/Public/Podcasts/PodcastList.vue'),
name: 'public:podcasts'
},
{
path: '/podcast/:podcast_id',
component: () => import('~/components/Public/Podcasts/Podcast.vue'),
name: 'public:podcast'
},
{
path: '/podcast/:podcast_id/episode/:episode_id',
component: () => import('~/components/Public/Podcasts/PodcastEpisode.vue'),
name: 'public:podcast:episode'
}
];
}

View File

@ -1,41 +1,30 @@
<template> <template>
<section <full-height-card>
id="content" <template #title>
class="full-height-wrapper" <template v-if="stationName">
role="main" {{ stationName }}
> </template>
<div class="container"> <template v-else>
<div class="card"> {{ $gettext('Schedule') }}
<div class="card-header text-bg-primary"> </template>
<div class="d-flex align-items-center"> </template>
<div class="flex-shrink">
<h2 class="card-title py-2"> <template #default>
<template v-if="stationName"> <div id="station-schedule-calendar">
{{ stationName }} <schedule
</template> ref="schedule"
<template v-else> :timezone="stationTimeZone"
{{ $gettext('Schedule') }} :schedule-url="scheduleUrl"
</template> :station-time-zone="stationTimeZone"
</h2> />
</div>
</div>
</div>
<div id="station-schedule-calendar">
<schedule
ref="schedule"
:timezone="stationTimeZone"
:schedule-url="scheduleUrl"
:station-time-zone="stationTimeZone"
/>
</div>
</div> </div>
</div> </template>
</section> </full-height-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Schedule from '~/components/Common/ScheduleView.vue'; import Schedule from '~/components/Common/ScheduleView.vue';
import FullHeightCard from "~/components/Public/FullHeightCard.vue";
const props = defineProps({ const props = defineProps({
scheduleUrl: { scheduleUrl: {

View File

@ -241,7 +241,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue'; import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import MediaToolbar from './Media/MediaToolbar.vue'; import MediaToolbar from './Media/MediaToolbar.vue';
import Breadcrumb from './Media/Breadcrumb.vue'; import Breadcrumb from './Media/Breadcrumb.vue';
import FileUpload from './Media/FileUpload.vue'; import FileUpload from './Media/FileUpload.vue';
@ -256,14 +256,13 @@ import PlayButton from "~/components/Common/PlayButton.vue";
import {useTranslate} from "~/vendor/gettext"; import {useTranslate} from "~/vendor/gettext";
import {computed, ref, watch} from "vue"; import {computed, ref, watch} from "vue";
import {forEach, map, partition} from "lodash"; import {forEach, map, partition} from "lodash";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
import formatFileSize from "../../functions/formatFileSize"; import formatFileSize from "../../functions/formatFileSize";
import InfoCard from "~/components/Common/InfoCard.vue"; import InfoCard from "~/components/Common/InfoCard.vue";
import {useLuxon} from "~/vendor/luxon";
import {getStationApiUrl} from "~/router"; import {getStationApiUrl} from "~/router";
import {useRoute, useRouter} from "vue-router"; import {useRoute, useRouter} from "vue-router";
import {IconFile, IconFolder, IconImage} from "~/components/Common/icons"; import {IconFile, IconFolder, IconImage} from "~/components/Common/icons";
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts"; import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const props = defineProps({ const props = defineProps({
initialPlaylists: { initialPlaylists: {
@ -300,9 +299,8 @@ const renameUrl = getStationApiUrl('/files/rename');
const quotaUrl = getStationApiUrl('/quota/station_media'); const quotaUrl = getStationApiUrl('/quota/station_media');
const {$gettext} = useTranslate(); const {$gettext} = useTranslate();
const {timeConfig} = useAzuraCast();
const {timezone} = useAzuraCastStation(); const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
const {DateTime} = useLuxon();
const fields = computed<DataTableField[]>(() => { const fields = computed<DataTableField[]>(() => {
const fields: DataTableField[] = [ const fields: DataTableField[] = [
@ -337,15 +335,7 @@ const fields = computed<DataTableField[]>(() => {
key: 'timestamp', key: 'timestamp',
label: $gettext('Modified'), label: $gettext('Modified'),
sortable: true, sortable: true,
formatter: (value) => { formatter: (value) => formatTimestampAsDateTime(value),
if (!value) {
return '';
}
return DateTime.fromSeconds(value).setZone(timezone).toLocaleString(
{...DateTime.DATETIME_MED, ...timeConfig}
);
},
selectable: true, selectable: true,
visible: true visible: true
}, },

View File

@ -37,7 +37,6 @@
<script setup lang="ts"> <script setup lang="ts">
import {ref, toRef, watch} from "vue"; import {ref, toRef, watch} from "vue";
import {syncRef} from "@vueuse/core";
import {useAxios} from "~/vendor/axios"; import {useAxios} from "~/vendor/axios";
import FormGroup from "~/components/Form/FormGroup.vue"; import FormGroup from "~/components/Form/FormGroup.vue";
import FormFile from "~/components/Form/FormFile.vue"; import FormFile from "~/components/Form/FormFile.vue";
@ -49,9 +48,7 @@ const props = defineProps({
} }
}); });
const albumArtSrc = ref(null); const albumArtSrc = ref(props.albumArtUrl);
syncRef(toRef(props, 'albumArtUrl'), albumArtSrc, {direction: 'ltr'});
const reloadArt = () => { const reloadArt = () => {
albumArtSrc.value = props.albumArtUrl + '?' + Math.floor(Date.now() / 1000); albumArtSrc.value = props.albumArtUrl + '?' + Math.floor(Date.now() / 1000);
} }

View File

@ -27,14 +27,11 @@
:label="$gettext('AutoDJ Format')" :label="$gettext('AutoDJ Format')"
/> />
<form-group-multi-check <bitrate-options
v-if="formatSupportsBitrateOptions" v-if="formatSupportsBitrateOptions"
id="edit_form_autodj_bitrate" id="edit_form_autodj_bitrate"
class="col-md-6" class="col-md-6"
:field="v$.autodj_bitrate" :field="v$.autodj_bitrate"
:options="bitrateOptions"
stacked
radio
:label="$gettext('AutoDJ Bitrate (kbps)')" :label="$gettext('AutoDJ Bitrate (kbps)')"
/> />
</div> </div>
@ -43,12 +40,12 @@
<script setup lang="ts"> <script setup lang="ts">
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue"; import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
import {map} from "lodash";
import {computed} from "vue"; import {computed} from "vue";
import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue"; import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue";
import {useVModel} from "@vueuse/core"; import {useVModel} from "@vueuse/core";
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab"; import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
import Tab from "~/components/Common/Tab.vue"; import Tab from "~/components/Common/Tab.vue";
import BitrateOptions from "~/components/Common/BitrateOptions.vue";
const props = defineProps({ const props = defineProps({
form: { form: {
@ -101,17 +98,7 @@ const formatOptions = [
} }
]; ];
const bitrateOptions = map(
[32, 48, 64, 96, 128, 192, 256, 320],
(val) => {
return {
value: val,
text: val
};
}
);
const formatSupportsBitrateOptions = computed(() => { const formatSupportsBitrateOptions = computed(() => {
return (props.form.autodj_format.$model !== 'flac'); return (props.form.autodj_format !== 'flac');
}); });
</script> </script>

View File

@ -45,38 +45,12 @@
:api-url="listUrl" :api-url="listUrl"
detailed detailed
> >
<template #cell(actions)="{ item, toggleDetails }">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-primary"
@click="doEdit(item.links.self)"
>
{{ $gettext('Edit') }}
</button>
<button
type="button"
class="btn btn-danger"
@click="doDelete(item.links.self)"
>
{{ $gettext('Delete') }}
</button>
<button
class="btn btn-sm btn-secondary"
type="button"
@click="toggleDetails()"
>
{{ $gettext('More') }}
</button>
</div>
</template>
<template #cell(name)="row"> <template #cell(name)="row">
<h5 class="m-0"> <h5 class="m-0">
{{ row.item.name }} {{ row.item.name }}
</h5> </h5>
<div> <div class="badges">
<span class="badge text-bg-secondary me-1"> <span class="badge text-bg-secondary">
<template v-if="row.item.source === 'songs'"> <template v-if="row.item.source === 'songs'">
{{ $gettext('Song-based') }} {{ $gettext('Song-based') }}
</template> </template>
@ -86,31 +60,31 @@
</span> </span>
<span <span
v-if="row.item.is_jingle" v-if="row.item.is_jingle"
class="badge text-bg-primary me-1" class="badge text-bg-primary"
> >
{{ $gettext('Jingle Mode') }} {{ $gettext('Jingle Mode') }}
</span> </span>
<span <span
v-if="row.item.source === 'songs' && row.item.order === 'sequential'" v-if="row.item.source === 'songs' && row.item.order === 'sequential'"
class="badge text-bg-info me-1" class="badge text-bg-info"
> >
{{ $gettext('Sequential') }} {{ $gettext('Sequential') }}
</span> </span>
<span <span
v-if="row.item.include_in_on_demand" v-if="row.item.include_in_on_demand"
class="badge text-bg-info me-1" class="badge text-bg-info"
> >
{{ $gettext('On-Demand') }} {{ $gettext('On-Demand') }}
</span> </span>
<span <span
v-if="row.item.include_in_automation" v-if="row.item.schedule_items.length > 0"
class="badge text-bg-success me-1" class="badge text-bg-info"
> >
{{ $gettext('Auto-Assigned') }} {{ $gettext('Scheduled') }}
</span> </span>
<span <span
v-if="!row.item.is_enabled" v-if="!row.item.is_enabled"
class="badge text-bg-danger me-1" class="badge text-bg-danger"
> >
{{ $gettext('Disabled') }} {{ $gettext('Disabled') }}
</span> </span>
@ -171,18 +145,54 @@
&nbsp; &nbsp;
</template> </template>
</template> </template>
<template #cell(actions)="{ item, isActive, toggleDetails }">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-primary"
@click="doEdit(item.links.self)"
>
{{ $gettext('Edit') }}
</button>
<button
type="button"
class="btn btn-danger"
@click="doDelete(item.links.self)"
>
{{ $gettext('Delete') }}
</button>
<button
class="btn btn-sm btn-secondary"
type="button"
@click="toggleDetails()"
>
<icon :icon="isActive ? IconContract : IconExpand" />
{{ $gettext('More') }}
</button>
</div>
</template>
<template #detail="{ item }"> <template #detail="{ item }">
<div <div
class="buttons" class="buttons"
style="line-height: 2.5;" style="line-height: 2.5;"
> >
<button
v-if="item.links.order"
type="button"
class="btn btn-sm btn-primary"
@click="doReorder(item.links.order)"
>
{{ $gettext('Reorder') }}
</button>
<button <button
type="button" type="button"
class="btn btn-sm" class="btn btn-sm"
:class="toggleButtonClass(item)" :class="(item.is_enabled) ? 'btn-warning' : 'btn-success'"
@click="doModify(item.links.toggle)" @click="doModify(item.links.toggle)"
> >
{{ langToggleButton(item) }} {{ (item.is_enabled) ? $gettext('Disable') : $gettext('Enable') }}
</button> </button>
<button <button
v-if="item.links.empty" v-if="item.links.empty"
@ -208,14 +218,6 @@
> >
{{ $gettext('Import from PLS/M3U') }} {{ $gettext('Import from PLS/M3U') }}
</button> </button>
<button
v-if="item.links.order"
type="button"
class="btn btn-sm btn-secondary"
@click="doReorder(item.links.order)"
>
{{ $gettext('Reorder') }}
</button>
<button <button
v-if="item.links.queue" v-if="item.links.queue"
type="button" type="button"
@ -300,7 +302,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue'; import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import Schedule from '~/components/Common/ScheduleView.vue'; import Schedule from '~/components/Common/ScheduleView.vue';
import EditModal from './Playlists/EditModal.vue'; import EditModal from './Playlists/EditModal.vue';
import ReorderModal from './Playlists/ReorderModal.vue'; import ReorderModal from './Playlists/ReorderModal.vue';
@ -322,6 +324,8 @@ import TimeZone from "~/components/Stations/Common/TimeZone.vue";
import Tabs from "~/components/Common/Tabs.vue"; import Tabs from "~/components/Common/Tabs.vue";
import Tab from "~/components/Common/Tab.vue"; import Tab from "~/components/Common/Tab.vue";
import AddButton from "~/components/Common/AddButton.vue"; import AddButton from "~/components/Common/AddButton.vue";
import {IconContract, IconExpand} from "~/components/Common/icons.ts";
import Icon from "~/components/Common/Icon.vue";
const props = defineProps({ const props = defineProps({
useManualAutoDj: { useManualAutoDj: {
@ -344,18 +348,6 @@ const fields: DataTableField[] = [
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'} {key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
]; ];
const toggleButtonClass = (record) => {
return (record.is_enabled)
? 'btn-warning'
: 'btn-success';
}
const langToggleButton = (record) => {
return (record.is_enabled)
? $gettext('Disable')
: $gettext('Enable');
};
const {Duration} = useLuxon(); const {Duration} = useLuxon();
const formatLength = (length) => { const formatLength = (length) => {

View File

@ -50,11 +50,20 @@
<td>{{ element.media.album }}</td> <td>{{ element.media.album }}</td>
<td> <td>
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button
v-if="index+1 < media.length"
type="button"
class="btn btn-secondary"
:title="$gettext('Move to Bottom')"
@click.prevent="moveToBottom(index)"
>
<icon :icon="IconChevronBarDown" />
</button>
<button <button
v-if="index+1 < media.length" v-if="index+1 < media.length"
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
:title="$gettext('Down')" :title="$gettext('Move Down')"
@click.prevent="moveDown(index)" @click.prevent="moveDown(index)"
> >
<icon :icon="IconChevronDown" /> <icon :icon="IconChevronDown" />
@ -63,11 +72,20 @@
v-if="index > 0" v-if="index > 0"
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
:title="$gettext('Up')" :title="$gettext('Move Up')"
@click.prevent="moveUp(index)" @click.prevent="moveUp(index)"
> >
<icon :icon="IconChevronUp" /> <icon :icon="IconChevronUp" />
</button> </button>
<button
v-if="index > 0"
type="button"
class="btn btn-secondary"
:title="$gettext('Move to Top')"
@click.prevent="moveToTop(index)"
>
<icon :icon="IconChevronBarUp" />
</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -87,7 +105,7 @@ import {useAxios} from "~/vendor/axios";
import {useNotify} from "~/functions/useNotify"; import {useNotify} from "~/functions/useNotify";
import {useTranslate} from "~/vendor/gettext"; import {useTranslate} from "~/vendor/gettext";
import Modal from "~/components/Common/Modal.vue"; import Modal from "~/components/Common/Modal.vue";
import {IconChevronDown, IconChevronUp} from "~/components/Common/icons"; import {IconChevronBarDown, IconChevronBarUp, IconChevronDown, IconChevronUp} from "~/components/Common/icons";
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts"; import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
import {usePlayerStore, useProvidePlayerStore} from "~/functions/usePlayerStore.ts"; import {usePlayerStore, useProvidePlayerStore} from "~/functions/usePlayerStore.ts";
@ -129,12 +147,26 @@ const save = () => {
}; };
const moveDown = (index) => { const moveDown = (index) => {
media.value.splice(index + 1, 0, media.value.splice(index, 1)[0]); const currentItem = media.value.splice(index, 1)[0];
media.value.splice(index + 1, 0, currentItem);
save();
};
const moveToBottom = (index) => {
const currentItem = media.value.splice(index, 1)[0];
media.value.splice(media.value.length, 0, currentItem);
save(); save();
}; };
const moveUp = (index) => { const moveUp = (index) => {
media.value.splice(index - 1, 0, media.value.splice(index, 1)[0]); const currentItem = media.value.splice(index, 1)[0];
media.value.splice(index - 1, 0, currentItem);
save();
};
const moveToTop = (index) => {
const currentItem = media.value.splice(index, 1)[0];
media.value.splice(0, 0, currentItem);
save(); save();
}; };

View File

@ -30,16 +30,14 @@
</div> </div>
<div class="card-body buttons"> <div class="card-body buttons">
<button <router-link
type="button"
class="btn btn-secondary" class="btn btn-secondary"
@click="doClearPodcast()" :to="{name: 'stations:podcasts:index'}"
> >
<icon :icon="IconChevronLeft" /> <icon :icon="IconChevronLeft" />
<span> {{ $gettext('All Podcasts') }}
{{ $gettext('All Podcasts') }} </router-link>
</span>
</button>
<add-button <add-button
:text="$gettext('Add Episode')" :text="$gettext('Add Episode')"
@click="doCreate" @click="doCreate"
@ -50,48 +48,67 @@
id="station_podcast_episodes" id="station_podcast_episodes"
ref="$datatable" ref="$datatable"
paginated paginated
select-fields
:fields="fields" :fields="fields"
:api-url="podcast.links.episodes" :api-url="podcast.links.episodes"
> >
<template #cell(art)="row"> <template #cell(art)="{item}">
<album-art :src="row.item.art" /> <album-art :src="item.art" />
</template> </template>
<template #cell(title)="row"> <template #cell(title)="{item}">
<h5 class="m-0"> <h5 class="m-0">
{{ row.item.title }} {{ item.title }}
</h5> </h5>
<a <div v-if="item.is_published">
:href="row.item.links.public" <a
target="_blank" :href="item.links.public"
>{{ $gettext('Public Page') }}</a> target="_blank"
>{{ $gettext('Public Page') }}</a>
</div>
<div
v-else
class="badges"
>
<span class="badge text-bg-info">
{{ $gettext('Unpublished') }}
</span>
</div>
</template> </template>
<template #cell(podcast_media)="row"> <template #cell(podcast_media)="{item}">
<template v-if="row.item.media"> <template v-if="item.media">
<span>{{ row.item.media.original_name }}</span> <span>{{ item.media.original_name }}</span>
<br> <br>
<small>{{ row.item.media.length_text }}</small> <small>{{ item.media.length_text }}</small>
</template> </template>
</template> </template>
<template #cell(explicit)="row"> <template #cell(is_published)="{item}">
<span v-if="item.is_published">
{{ $gettext('Yes') }}
</span>
<span v-else>
{{ $gettext('No') }}
</span>
</template>
<template #cell(explicit)="{item}">
<span <span
v-if="row.item.explicit" v-if="item.explicit"
class="badge text-bg-danger" class="badge text-bg-danger"
>{{ $gettext('Explicit') }}</span> >{{ $gettext('Explicit') }}</span>
<span v-else>&nbsp;</span> <span v-else>&nbsp;</span>
</template> </template>
<template #cell(actions)="row"> <template #cell(actions)="{item}">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button <button
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
@click="doEdit(row.item.links.self)" @click="doEdit(item.links.self)"
> >
{{ $gettext('Edit') }} {{ $gettext('Edit') }}
</button> </button>
<button <button
type="button" type="button"
class="btn btn-danger" class="btn btn-danger"
@click="doDelete(row.item.links.self)" @click="doDelete(item.links.self)"
> >
{{ $gettext('Delete') }} {{ $gettext('Delete') }}
</button> </button>
@ -111,79 +128,93 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue'; import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import EditModal from './EpisodeEditModal.vue'; import EditModal from './Podcasts/EpisodeEditModal.vue';
import Icon from '~/components/Common/Icon.vue'; import Icon from '~/components/Common/Icon.vue';
import AlbumArt from '~/components/Common/AlbumArt.vue'; import AlbumArt from '~/components/Common/AlbumArt.vue';
import StationsCommonQuota from "~/components/Stations/Common/Quota.vue"; import StationsCommonQuota from "~/components/Stations/Common/Quota.vue";
import {useTranslate} from "~/vendor/gettext"; import {useTranslate} from "~/vendor/gettext";
import {ref} from "vue"; import {ref} from "vue";
import {useSweetAlert} from "~/vendor/sweetalert";
import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios";
import AddButton from "~/components/Common/AddButton.vue"; import AddButton from "~/components/Common/AddButton.vue";
import {IconChevronLeft} from "~/components/Common/icons"; import {IconChevronLeft} from "~/components/Common/icons";
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts"; import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import {getStationApiUrl} from "~/router.ts";
import useConfirmAndDelete from "~/functions/useConfirmAndDelete.ts";
import {ApiPodcast} from "~/entities/ApiInterfaces.ts";
import useHasEditModal from "~/functions/useHasEditModal.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const props = defineProps({ const props = defineProps<{
quotaUrl: { podcast: ApiPodcast
type: String, }>();
required: true
},
podcast: {
type: Object,
required: true
}
});
const emit = defineEmits(['clear-podcast']); const quotaUrl = getStationApiUrl('/quota/station_podcasts');
const {$gettext} = useTranslate(); const {$gettext} = useTranslate();
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
const fields: DataTableField[] = [ const fields: DataTableField[] = [
{key: 'art', label: $gettext('Art'), sortable: false, class: 'shrink pe-0'}, {
{key: 'title', label: $gettext('Episode'), sortable: false}, key: 'art',
{key: 'podcast_media', label: $gettext('File Name'), sortable: false}, label: $gettext('Art'),
{key: 'explicit', label: $gettext('Explicit'), sortable: false}, sortable: false,
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'} class: 'shrink pe-0',
selectable: true
},
{
key: 'title',
label: $gettext('Episode'),
sortable: false
},
{
key: 'podcast_media',
label: $gettext('File Name'),
sortable: false
},
{
key: 'is_published',
label: $gettext('Is Published'),
visible: false,
sortable: true,
selectable: true
},
{
key: 'publish_at',
label: $gettext('Publish At'),
formatter: (_col, _key, item) => formatTimestampAsDateTime(item.publish_at),
sortable: true,
selectable: true
},
{
key: 'explicit',
label: $gettext('Explicit'),
sortable: true,
selectable: true
},
{
key: 'actions',
label: $gettext('Actions'),
sortable: false,
class: 'shrink'
}
]; ];
const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null); const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null);
const $datatable = ref<DataTableTemplateRef>(null); const $datatable = ref<DataTableTemplateRef>(null);
const {refresh} = useHasDatatable($datatable);
const relist = () => { const relist = () => {
$quota.value?.update(); $quota.value?.update();
$datatable.value?.refresh(); refresh();
}; };
const $editEpisodeModal = ref<InstanceType<typeof EditModal> | null>(null); const $editEpisodeModal = ref<InstanceType<typeof EditModal> | null>(null);
const {doCreate, doEdit} = useHasEditModal($editEpisodeModal);
const doCreate = () => { const {doDelete} = useConfirmAndDelete(
$editEpisodeModal.value?.create(); $gettext('Delete Episode?'),
}; () => relist()
);
const doEdit = (url) => {
$editEpisodeModal.value?.edit(url);
};
const doClearPodcast = () => {
emit('clear-podcast');
};
const {confirmDelete} = useSweetAlert();
const {notifySuccess} = useNotify();
const {axios} = useAxios();
const doDelete = (url) => {
confirmDelete({
title: $gettext('Delete Episode?'),
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
notifySuccess(resp.data.message);
relist();
});
}
});
};
</script> </script>

View File

@ -1,39 +1,154 @@
<template> <template>
<episodes-view <card-page>
v-if="activePodcast" <template #header>
:podcast="activePodcast" <div class="row align-items-center">
:quota-url="quotaUrl" <div class="col-md-7">
@clear-podcast="onClearPodcast" <h2 class="card-title">
/> {{ $gettext('Podcasts') }}
<list-view </h2>
v-else </div>
v-bind="pickProps(props, listViewProps)" <div class="col-md-5 text-end">
:quota-url="quotaUrl" <stations-common-quota
@select-podcast="onSelectPodcast" ref="$quota"
:quota-url="quotaUrl"
/>
</div>
</div>
</template>
<template #actions>
<add-button
:text="$gettext('Add Podcast')"
@click="doCreate"
/>
</template>
<data-table
id="station_podcasts"
ref="$datatable"
paginated
:fields="fields"
:api-url="listUrl"
>
<template #cell(art)="{item}">
<album-art :src="item.art" />
</template>
<template #cell(title)="{item}">
<h5 class="m-0">
{{ item.title }}
</h5>
<div v-if="item.is_published">
<a
:href="item.links.public_episodes"
target="_blank"
>{{ $gettext('Public Page') }}</a> &bull;
<a
:href="item.links.public_feed"
target="_blank"
>{{ $gettext('RSS Feed') }}</a>
</div>
<div
v-else
class="badges"
>
<span class="badge text-bg-info">
{{ $gettext('Unpublished') }}
</span>
</div>
</template>
<template #cell(actions)="{item}">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-primary"
@click="doEdit(item.links.self)"
>
{{ $gettext('Edit') }}
</button>
<button
type="button"
class="btn btn-danger"
@click="doDelete(item.links.self)"
>
{{ $gettext('Delete') }}
</button>
<router-link
class="btn btn-secondary"
:to="{name: 'stations:podcast:episodes', params: {podcast_id: item.id}}"
>
{{ $gettext('Episodes') }}
</router-link>
</div>
</template>
</data-table>
</card-page>
<edit-modal
ref="$editPodcastModal"
:create-url="listUrl"
:new-art-url="newArtUrl"
:language-options="languageOptions"
:categories-options="categoriesOptions"
@relist="relist"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import EpisodesView from './Podcasts/EpisodesView.vue'; import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import ListView from './Podcasts/ListView.vue'; import EditModal from './Podcasts/PodcastEditModal.vue';
import AlbumArt from '~/components/Common/AlbumArt.vue';
import StationsCommonQuota from "~/components/Stations/Common/Quota.vue";
import {useTranslate} from "~/vendor/gettext";
import {ref} from "vue"; import {ref} from "vue";
import listViewProps from "./Podcasts/listViewProps";
import {pickProps} from "~/functions/pickProps";
import {getStationApiUrl} from "~/router"; import {getStationApiUrl} from "~/router";
import AddButton from "~/components/Common/AddButton.vue";
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import CardPage from "~/components/Common/CardPage.vue";
import useConfirmAndDelete from "~/functions/useConfirmAndDelete.ts";
import useHasEditModal from "~/functions/useHasEditModal.ts";
const props = defineProps({ const props = defineProps({
...listViewProps languageOptions: {
type: Object,
required: true
},
categoriesOptions: {
type: Object,
required: true
},
}); });
const quotaUrl = getStationApiUrl('/quota/station_podcasts'); const quotaUrl = getStationApiUrl('/quota/station_podcasts');
const listUrl = getStationApiUrl('/podcasts');
const newArtUrl = getStationApiUrl('/podcasts/art');
const activePodcast = ref(null); const {$gettext} = useTranslate();
const onSelectPodcast = (podcast) => { const fields: DataTableField[] = [
activePodcast.value = podcast; {key: 'art', label: $gettext('Art'), sortable: false, class: 'shrink pe-0'},
{key: 'title', label: $gettext('Podcast'), sortable: false},
{
key: 'episodes',
label: $gettext('# Episodes'),
sortable: false,
},
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
];
const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null);
const $datatable = ref<DataTableTemplateRef>(null);
const {refresh} = useHasDatatable($datatable);
const relist = () => {
$quota.value?.update();
refresh();
}; };
const onClearPodcast = () => { const $editPodcastModal = ref<InstanceType<typeof EditModal> | null>(null);
activePodcast.value = null; const {doCreate, doEdit} = useHasEditModal($editPodcastModal);
}
const {doDelete} = useConfirmAndDelete(
$gettext('Delete Podcast?'),
() => relist()
);
</script> </script>

View File

@ -45,7 +45,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, toRef} from "vue"; import {computed, ref, toRef, watch} from "vue";
import {useAxios} from "~/vendor/axios"; import {useAxios} from "~/vendor/axios";
import FormGroup from "~/components/Form/FormGroup.vue"; import FormGroup from "~/components/Form/FormGroup.vue";
import FormFile from "~/components/Form/FormFile.vue"; import FormFile from "~/components/Form/FormFile.vue";
@ -54,15 +54,11 @@ import Tab from "~/components/Common/Tab.vue";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Object, type: Object,
required: true default: null
}, },
artworkSrc: { artworkSrc: {
type: String, type: String,
required: true default: null
},
editArtUrl: {
type: String,
required: true
}, },
newArtUrl: { newArtUrl: {
type: String, type: String,
@ -72,7 +68,12 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
const artworkSrc = toRef(props, 'artworkSrc'); const artworkSrc = ref(props.artworkSrc);
const reloadArt = () => {
artworkSrc.value = props.artworkSrc + '?' + Math.floor(Date.now() / 1000);
}
watch(toRef(props, 'artworkSrc'), reloadArt);
const localSrc = ref(null); const localSrc = ref(null);
const src = computed(() => { const src = computed(() => {
@ -92,21 +93,24 @@ const uploaded = (file) => {
}, false); }, false);
fileReader.readAsDataURL(file); fileReader.readAsDataURL(file);
const url = (props.editArtUrl) ? props.editArtUrl : props.newArtUrl; const url = (props.artworkSrc) ? props.artworkSrc : props.newArtUrl;
const formData = new FormData(); const formData = new FormData();
formData.append('art', file); formData.append('art', file);
axios.post(url, formData).then((resp) => { axios.post(url, formData).then((resp) => {
emit('update:modelValue', resp.data); emit('update:modelValue', resp.data);
reloadArt();
}); });
}; };
const deleteArt = () => { const deleteArt = () => {
if (props.editArtUrl) { if (props.artworkSrc) {
axios.delete(props.editArtUrl).then(() => { axios.delete(props.artworkSrc).then(() => {
reloadArt();
localSrc.value = null; localSrc.value = null;
}); });
} else { } else {
reloadArt();
localSrc.value = null; localSrc.value = null;
} }
} }

View File

@ -18,14 +18,12 @@
:record-has-media="record.has_media" :record-has-media="record.has_media"
:new-media-url="newMediaUrl" :new-media-url="newMediaUrl"
:edit-media-url="record.links.media" :edit-media-url="record.links.media"
:download-url="record.links.download"
/> />
<podcast-common-artwork <podcast-common-artwork
v-model="form.artwork_file" v-model="form.artwork_file"
:artwork-src="record.art" :artwork-src="record.links.art"
:new-art-url="newArtUrl" :new-art-url="newArtUrl"
:edit-art-url="record.links.art"
/> />
</tabs> </tabs>
</modal-form> </modal-form>

View File

@ -32,9 +32,9 @@
<template v-if="hasMedia"> <template v-if="hasMedia">
<div class="block-buttons pt-3"> <div class="block-buttons pt-3">
<a <a
v-if="downloadUrl" v-if="editMediaUrl"
class="btn btn-block btn-dark" class="btn btn-block btn-dark"
:href="downloadUrl" :href="editMediaUrl"
target="_blank" target="_blank"
> >
{{ $gettext('Download') }} {{ $gettext('Download') }}
@ -74,10 +74,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
required: true required: true
}, },
downloadUrl: {
type: String,
required: true
},
editMediaUrl: { editMediaUrl: {
type: String, type: String,
required: true required: true

View File

@ -1,172 +0,0 @@
<template>
<section
class="card"
role="region"
>
<div class="card-header text-bg-primary">
<div class="row align-items-center">
<div class="col-md-7">
<h2 class="card-title">
{{ $gettext('Podcasts') }}
</h2>
</div>
<div class="col-md-5 text-end">
<stations-common-quota
ref="$quota"
:quota-url="quotaUrl"
/>
</div>
</div>
</div>
<div class="card-body buttons">
<add-button
:text="$gettext('Add Podcast')"
@click="doCreate"
/>
</div>
<data-table
id="station_podcasts"
ref="$datatable"
paginated
:fields="fields"
:api-url="listUrl"
>
<template #cell(art)="row">
<album-art :src="row.item.art" />
</template>
<template #cell(title)="row">
<h5 class="m-0">
{{ row.item.title }}
</h5>
<a
:href="row.item.links.public_episodes"
target="_blank"
>{{ $gettext('Public Page') }}</a> &bull;
<a
:href="row.item.links.public_feed"
target="_blank"
>{{ $gettext('RSS Feed') }}</a>
</template>
<template #cell(actions)="row">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-primary"
@click="doEdit(row.item.links.self)"
>
{{ $gettext('Edit') }}
</button>
<button
type="button"
class="btn btn-danger"
@click="doDelete(row.item.links.self)"
>
{{ $gettext('Delete') }}
</button>
<button
type="button"
class="btn btn-secondary"
@click="doSelectPodcast(row.item)"
>
{{ $gettext('Episodes') }}
</button>
</div>
</template>
</data-table>
</section>
<edit-modal
ref="$editPodcastModal"
:create-url="listUrl"
:new-art-url="newArtUrl"
:language-options="languageOptions"
:categories-options="categoriesOptions"
@relist="relist"
/>
</template>
<script setup lang="ts">
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
import EditModal from './PodcastEditModal.vue';
import AlbumArt from '~/components/Common/AlbumArt.vue';
import StationsCommonQuota from "~/components/Stations/Common/Quota.vue";
import listViewProps from "./listViewProps";
import {useTranslate} from "~/vendor/gettext";
import {ref} from "vue";
import {useSweetAlert} from "~/vendor/sweetalert";
import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios";
import {getStationApiUrl} from "~/router";
import AddButton from "~/components/Common/AddButton.vue";
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
const props = defineProps({
...listViewProps,
quotaUrl: {
type: String,
required: true
}
});
const listUrl = getStationApiUrl('/podcasts');
const newArtUrl = getStationApiUrl('/podcasts/art');
const emit = defineEmits(['select-podcast']);
const {$gettext} = useTranslate();
const fields: DataTableField[] = [
{key: 'art', label: $gettext('Art'), sortable: false, class: 'shrink pe-0'},
{key: 'title', label: $gettext('Podcast'), sortable: false},
{
key: 'episodes',
label: $gettext('# Episodes'),
sortable: false,
formatter: (val) => {
return val?.length ?? 0;
}
},
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
];
const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null);
const $datatable = ref<DataTableTemplateRef>(null);
const relist = () => {
$quota.value?.update();
$datatable.value?.refresh();
};
const $editPodcastModal = ref<InstanceType<typeof EditModal> | null>(null);
const doCreate = () => {
$editPodcastModal.value?.create();
};
const doEdit = (url) => {
$editPodcastModal.value?.edit(url);
};
const doSelectPodcast = (podcast) => {
emit('select-podcast', podcast);
};
const {confirmDelete} = useSweetAlert();
const {notifySuccess} = useNotify();
const {axios} = useAxios();
const doDelete = (url) => {
confirmDelete({
title: $gettext('Delete Podcast?'),
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
notifySuccess(resp.data.message);
relist();
});
}
});
};
</script>

View File

@ -17,9 +17,8 @@
<podcast-common-artwork <podcast-common-artwork
v-model="form.artwork_file" v-model="form.artwork_file"
:artwork-src="record.art" :artwork-src="record.links.art"
:new-art-url="newArtUrl" :new-art-url="newArtUrl"
:edit-art-url="record.links.art"
/> />
</tabs> </tabs>
</modal-form> </modal-form>
@ -35,6 +34,7 @@ import {useResettableRef} from "~/functions/useResettableRef";
import {useTranslate} from "~/vendor/gettext"; import {useTranslate} from "~/vendor/gettext";
import ModalForm from "~/components/Common/ModalForm.vue"; import ModalForm from "~/components/Common/ModalForm.vue";
import Tabs from "~/components/Common/Tabs.vue"; import Tabs from "~/components/Common/Tabs.vue";
import {map} from "lodash";
const props = defineProps({ const props = defineProps({
...baseEditModalProps, ...baseEditModalProps,
@ -59,7 +59,9 @@ const $modal = ref<ModalFormTemplateRef>(null);
const {record, reset} = useResettableRef({ const {record, reset} = useResettableRef({
has_custom_art: false, has_custom_art: false,
art: null, art: null,
links: {} links: {
art: null
}
}); });
const { const {
@ -87,6 +89,11 @@ const {
reset(); reset();
}, },
populateForm: (data, formRef) => { populateForm: (data, formRef) => {
data.categories = map(
data.categories,
(row) => row.category
);
record.value = data; record.value = data;
formRef.value = mergeExisting(formRef.value, data); formRef.value = mergeExisting(formRef.value, data);
}, },

View File

@ -1,10 +0,0 @@
export default {
languageOptions: {
type: Object,
required: true
},
categoriesOptions: {
type: Object,
required: true
}
}

View File

@ -120,6 +120,10 @@ const types = computed(() => {
value: 'history', value: 'history',
text: $gettext('History') text: $gettext('History')
}, },
{
value: 'podcasts',
text: $gettext('Podcasts')
},
{ {
value: 'schedule', value: 'schedule',
text: $gettext('Schedule') text: $gettext('Schedule')
@ -174,6 +178,9 @@ const baseEmbedUrl = computed(() => {
case 'schedule': case 'schedule':
return props.publicScheduleEmbedUri; return props.publicScheduleEmbedUri;
case 'podcasts':
return props.publicPodcastsEmbedUri;
case 'player': case 'player':
default: default:
return props.publicPageEmbedUri; return props.publicPageEmbedUri;
@ -181,14 +188,17 @@ const baseEmbedUrl = computed(() => {
}); });
const embedUrl = computed(() => { const embedUrl = computed(() => {
return (selectedTheme.value !== "browser") const baseUrl = new URL(baseEmbedUrl.value);
? baseEmbedUrl.value + '?theme=' + selectedTheme.value if (selectedTheme.value !== 'browser') {
: baseEmbedUrl.value; baseUrl.searchParams.set('theme', selectedTheme.value);
}
return baseUrl.toString();
}); });
const embedHeight = computed(() => { const embedHeight = computed(() => {
switch (selectedType.value) { switch (selectedType.value) {
case 'ondemand': case 'ondemand':
case 'podcasts':
return '400px'; return '400px';
case 'requests': case 'requests':

View File

@ -223,14 +223,29 @@
{{ $gettext('Disconnect Streamer') }} {{ $gettext('Disconnect Streamer') }}
</span> </span>
</button> </button>
<button
id="btn_update_metadata"
type="button"
class="btn btn-link text-secondary"
@click="updateMetadata()"
>
<icon :icon="IconUpdate" />
<span>
{{ $gettext('Update Metadata') }}
</span>
</button>
</template> </template>
</card-page> </card-page>
<template v-if="isLiquidsoap && userAllowedForStation(StationPermission.Broadcasting)">
<update-metadata-modal ref="$updateMetadataModal" />
</template>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {BackendAdapter} from '~/entities/RadioAdapters'; import {BackendAdapter} from '~/entities/RadioAdapters';
import Icon from '~/components/Common/Icon.vue'; import Icon from '~/components/Common/Icon.vue';
import {computed} from "vue"; import {computed, Ref, ref} from "vue";
import {useTranslate} from "~/vendor/gettext"; import {useTranslate} from "~/vendor/gettext";
import nowPlayingPanelProps from "~/components/Stations/Profile/nowPlayingPanelProps"; import nowPlayingPanelProps from "~/components/Stations/Profile/nowPlayingPanelProps";
import useNowPlaying from "~/functions/useNowPlaying"; import useNowPlaying from "~/functions/useNowPlaying";
@ -239,7 +254,16 @@ import CardPage from "~/components/Common/CardPage.vue";
import {useLightbox} from "~/vendor/lightbox"; import {useLightbox} from "~/vendor/lightbox";
import {StationPermission, userAllowedForStation} from "~/acl"; import {StationPermission, userAllowedForStation} from "~/acl";
import {useAzuraCastStation} from "~/vendor/azuracast"; import {useAzuraCastStation} from "~/vendor/azuracast";
import {IconHeadphones, IconLogs, IconMic, IconMusicNote, IconSkipNext, IconVolumeOff} from "~/components/Common/icons"; import {
IconHeadphones,
IconLogs,
IconMic,
IconMusicNote,
IconSkipNext,
IconUpdate,
IconVolumeOff
} from "~/components/Common/icons";
import UpdateMetadataModal from "~/components/Stations/Profile/UpdateMetadataModal.vue";
const props = defineProps({ const props = defineProps({
...nowPlayingPanelProps, ...nowPlayingPanelProps,
@ -275,4 +299,9 @@ const {vLightbox} = useLightbox();
const makeApiCall = (uri) => { const makeApiCall = (uri) => {
emit('api-call', uri); emit('api-call', uri);
}; };
const $updateMetadataModal: Ref<InstanceType<typeof UpdateMetadataModal> | null> = ref(null);
const updateMetadata = () => {
$updateMetadataModal.value?.open();
}
</script> </script>

View File

@ -45,9 +45,9 @@
<script setup lang="ts"> <script setup lang="ts">
import {map} from "lodash"; import {map} from "lodash";
import {computed} from "vue"; import {computed} from "vue";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
import CardPage from "~/components/Common/CardPage.vue"; import CardPage from "~/components/Common/CardPage.vue";
import {useLuxon} from "~/vendor/luxon"; import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
import {useLuxon} from "~/vendor/luxon.ts";
const props = defineProps({ const props = defineProps({
scheduleItems: { scheduleItems: {
@ -56,39 +56,35 @@ const props = defineProps({
} }
}); });
const {timeConfig} = useAzuraCast();
const {timezone} = useAzuraCastStation();
const {DateTime} = useLuxon(); const {DateTime} = useLuxon();
const {
now,
timestampToDateTime,
formatDateTime
} = useStationDateTimeFormatter();
const processedScheduleItems = computed(() => { const processedScheduleItems = computed(() => {
const now = DateTime.now().setZone(timezone); const nowTz = now();
return map(props.scheduleItems, (row) => { return map(props.scheduleItems, (row) => {
const start_moment = DateTime.fromSeconds(row.start_timestamp).setZone(timezone); const startMoment = timestampToDateTime(row.start_timestamp);
const end_moment = DateTime.fromSeconds(row.end_timestamp).setZone(timezone); const endMoment = timestampToDateTime(row.end_timestamp);
row.time_until = start_moment.toRelative(); row.time_until = startMoment.toRelative();
if (start_moment.hasSame(now, 'day')) { row.start_formatted = formatDateTime(
row.start_formatted = start_moment.toLocaleString( startMoment,
{...DateTime.TIME_SIMPLE, ...timeConfig} startMoment.hasSame(nowTz, 'day')
); ? DateTime.TIME_SIMPLE
} else { : DateTime.DATETIME_MED
row.start_formatted = start_moment.toLocaleString( );
{...DateTime.DATETIME_MED, ...timeConfig}
);
}
if (end_moment.hasSame(start_moment, 'day')) { row.end_formatted = formatDateTime(
row.end_formatted = end_moment.toLocaleString( endMoment,
{...DateTime.TIME_SIMPLE, ...timeConfig} endMoment.hasSame(startMoment, 'day')
); ? DateTime.TIME_SIMPLE
} else { : DateTime.DATETIME_MED
row.end_formatted = end_moment.toLocaleString( );
{...DateTime.DATETIME_MED, ...timeConfig}
);
}
return row; return row;
}); });

View File

@ -0,0 +1,115 @@
<template>
<modal
id="update_metadata"
ref="$modal"
centered
:title="$gettext('Update Metadata')"
@hidden="onHidden"
@shown="onShown"
>
<info-card>
{{
$gettext('Use this form to send a manual metadata update. Note that this will override any existing metadata on the stream.')
}}
</info-card>
<form @submit.prevent="doUpdateMetadata">
<div class="row g-3">
<form-group-field
id="update_metadata_title"
ref="$field"
class="col-12"
:field="v$.title"
:label="$gettext('Title')"
/>
<form-group-field
id="update_metadata_artist"
class="col-12"
:field="v$.artist"
:label="$gettext('Artist')"
/>
</div>
<invisible-submit-button />
</form>
<template #modal-footer>
<button
type="button"
class="btn btn-secondary"
@click="hide"
>
{{ $gettext('Close') }}
</button>
<button
type="button"
class="btn"
:class="(v$.$invalid) ? 'btn-danger' : 'btn-primary'"
@click="doUpdateMetadata"
>
{{ $gettext('Update Metadata') }}
</button>
</template>
</modal>
</template>
<script setup lang="ts">
import {required} from '@vuelidate/validators';
import FormGroupField from "~/components/Form/FormGroupField.vue";
import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
import {nextTick, ref} from "vue";
import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios";
import {useTranslate} from "~/vendor/gettext";
import Modal from "~/components/Common/Modal.vue";
import InvisibleSubmitButton from "~/components/Common/InvisibleSubmitButton.vue";
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
import {getStationApiUrl} from "~/router.ts";
import InfoCard from "~/components/Common/InfoCard.vue";
const updateMetadataUrl = getStationApiUrl('/nowplaying/update');
const {form, v$, resetForm, ifValid} = useVuelidateOnForm(
{
title: {required},
artist: {}
},
{
title: null,
artist: null
}
);
const $modal = ref<ModalTemplateRef>(null);
const {hide, show: open} = useHasModal($modal);
const onHidden = () => {
resetForm();
}
const $field = ref<InstanceType<typeof FormGroupField> | null>(null);
const onShown = () => {
nextTick(() => {
$field.value?.focus();
})
};
const {notifySuccess} = useNotify();
const {axios} = useAxios();
const {$gettext} = useTranslate();
const doUpdateMetadata = () => {
ifValid(() => {
axios.post(updateMetadataUrl.value, form.value).then(() => {
notifySuccess($gettext('Metadata updated.'));
}).finally(() => {
hide();
});
});
};
defineExpose({
open
});
</script>

View File

@ -42,5 +42,9 @@ export default {
publicScheduleEmbedUri: { publicScheduleEmbedUri: {
type: String, type: String,
required: true required: true
},
publicPodcastsEmbedUri: {
type: String,
required: true
} }
} }

View File

@ -18,6 +18,7 @@
ref="$datatable" ref="$datatable"
:fields="fields" :fields="fields"
:api-url="listUrl" :api-url="listUrl"
:hide-on-loading="false"
> >
<template #cell(actions)="row"> <template #cell(actions)="row">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
@ -52,8 +53,8 @@
</div> </div>
</template> </template>
<template #cell(played_at)="row"> <template #cell(played_at)="row">
{{ formatTime(row.item.played_at) }}<br> {{ formatTimestampAsTime(row.item.played_at) }}<br>
<small>{{ formatRelativeTime(row.item.played_at) }}</small> <small>{{ formatTimestampAsRelative(row.item.played_at) }}</small>
</template> </template>
<template #cell(source)="row"> <template #cell(source)="row">
<div v-if="row.item.is_request"> <div v-if="row.item.is_request">
@ -70,21 +71,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import DataTable, { DataTableField } from '../Common/DataTable.vue'; import DataTable, {DataTableField} from '../Common/DataTable.vue';
import QueueLogsModal from './Queue/LogsModal.vue'; import QueueLogsModal from './Queue/LogsModal.vue';
import Icon from "~/components/Common/Icon.vue"; import Icon from "~/components/Common/Icon.vue";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
import {useTranslate} from "~/vendor/gettext"; import {useTranslate} from "~/vendor/gettext";
import {ref} from "vue"; import {computed, ref} from "vue";
import useConfirmAndDelete from "~/functions/useConfirmAndDelete"; import useConfirmAndDelete from "~/functions/useConfirmAndDelete";
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable"; import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable";
import {useNotify} from "~/functions/useNotify"; import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios"; import {useAxios} from "~/vendor/axios";
import {useSweetAlert} from "~/vendor/sweetalert"; import {useSweetAlert} from "~/vendor/sweetalert";
import CardPage from "~/components/Common/CardPage.vue"; import CardPage from "~/components/Common/CardPage.vue";
import {useLuxon} from "~/vendor/luxon";
import {getStationApiUrl} from "~/router"; import {getStationApiUrl} from "~/router";
import {IconRemove} from "~/components/Common/icons"; import {IconRemove} from "~/components/Common/icons";
import {useIntervalFn} from "@vueuse/core";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const listUrl = getStationApiUrl('/queue'); const listUrl = getStationApiUrl('/queue');
const clearUrl = getStationApiUrl('/queue/clear'); const clearUrl = getStationApiUrl('/queue/clear');
@ -98,24 +99,19 @@ const fields: DataTableField[] = [
{key: 'source', label: $gettext('Source'), sortable: false} {key: 'source', label: $gettext('Source'), sortable: false}
]; ];
const {timezone} = useAzuraCastStation(); const {
formatTimestampAsTime,
const {DateTime} = useLuxon(); formatTimestampAsRelative
} = useStationDateTimeFormatter();
const getDateTime = (timestamp) =>
DateTime.fromSeconds(timestamp).setZone(timezone);
const {timeConfig} = useAzuraCast();
const formatTime = (time) => getDateTime(time).toLocaleString(
{...DateTime.TIME_WITH_SECONDS, ...timeConfig}
);
const formatRelativeTime = (time) => getDateTime(time).toRelative();
const $datatable = ref<DataTableTemplateRef>(null); const $datatable = ref<DataTableTemplateRef>(null);
const {relist} = useHasDatatable($datatable); const {relist} = useHasDatatable($datatable);
useIntervalFn(
relist,
computed(() => (document.hidden) ? 60000 : 30000)
);
const $logsModal = ref<InstanceType<typeof QueueLogsModal> | null>(null); const $logsModal = ref<InstanceType<typeof QueueLogsModal> | null>(null);
const doShowLogs = (logs) => { const doShowLogs = (logs) => {
$logsModal.value?.show(logs); $logsModal.value?.show(logs);

View File

@ -27,14 +27,11 @@
:label="$gettext('AutoDJ Format')" :label="$gettext('AutoDJ Format')"
/> />
<form-group-multi-check <bitrate-options
v-if="formatSupportsBitrateOptions" v-if="formatSupportsBitrateOptions"
id="edit_form_autodj_bitrate" id="edit_form_autodj_bitrate"
class="col-md-6" class="col-md-6"
:field="v$.autodj_bitrate" :field="v$.autodj_bitrate"
:options="bitrateOptions"
stacked
radio
:label="$gettext('AutoDJ Bitrate (kbps)')" :label="$gettext('AutoDJ Bitrate (kbps)')"
/> />
@ -89,12 +86,12 @@
<script setup lang="ts"> <script setup lang="ts">
import FormGroupField from "~/components/Form/FormGroupField.vue"; import FormGroupField from "~/components/Form/FormGroupField.vue";
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue"; import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
import {map} from "lodash";
import {computed} from "vue"; import {computed} from "vue";
import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue"; import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue";
import {useVModel} from "@vueuse/core"; import {useVModel} from "@vueuse/core";
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab"; import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
import Tab from "~/components/Common/Tab.vue"; import Tab from "~/components/Common/Tab.vue";
import BitrateOptions from "~/components/Common/BitrateOptions.vue";
const props = defineProps({ const props = defineProps({
form: { form: {
@ -153,16 +150,6 @@ const formatOptions = [
} }
]; ];
const bitrateOptions = map(
[32, 48, 64, 96, 128, 192, 256, 320],
(val) => {
return {
value: val,
text: val
};
}
);
const formatSupportsBitrateOptions = computed(() => { const formatSupportsBitrateOptions = computed(() => {
return form.value?.autodj_format !== 'flac'; return form.value?.autodj_format !== 'flac';
}); });

View File

@ -75,67 +75,79 @@
<div id="map"> <div id="map">
<StationReportsListenersMap <StationReportsListenersMap
:listeners="listeners" :listeners="filteredListeners"
/> />
</div> </div>
<div> <div>
<div class="card-body row"> <div class="card-body">
<div class="col-md-4"> <div class="row row-cols-md-auto align-items-center">
<h5> <div class="col-12 text-start text-md-end h5">
{{ $gettext('Unique Listeners') }} {{ $gettext('Unique Listeners') }}
<br> <br>
<small> <small>
{{ $gettext('for selected period') }} {{ $gettext('for selected period') }}
</small> </small>
</h5> </div>
<h3>{{ listeners.length }}</h3> <div class="col-12 h3">
</div> {{ listeners.length }}
<div class="col-md-4"> </div>
<h5> <div class="col-12 text-start text-md-end h5">
{{ $gettext('Total Listener Hours') }} {{ $gettext('Total Listener Hours') }}
<br> <br>
<small> <small>
{{ $gettext('for selected period') }} {{ $gettext('for selected period') }}
</small> </small>
</h5> </div>
<h3>{{ totalListenerHours }}</h3> <div class="col-12 h3">
{{ totalListenerHours }}
</div>
<div class="col-12">
<listener-filters-bar v-model:filters="filters" />
</div>
</div> </div>
</div> </div>
<data-table <data-table
id="station_playlists" id="station_listeners"
ref="$datatable" ref="$datatable"
paginated paginated
handle-client-side handle-client-side
:fields="fields" :fields="fields"
:items="listeners" :items="filteredListeners"
select-fields
@refresh-clicked="updateListeners()" @refresh-clicked="updateListeners()"
> >
<template #cell(time)="row"> <!-- eslint-disable-next-line -->
{{ formatTime(row.item.connected_time) }} <template #cell(device.client)="row">
</template> <div class="d-flex align-items-center">
<template #cell(time_sec)="row"> <div class="flex-shrink-0 pe-2">
{{ row.item.connected_time }} <span v-if="row.item.device.is_bot">
</template> <icon :icon="IconRouter" />
<template #cell(user_agent)="row"> <span class="visually-hidden">
<div> {{ $gettext('Bot/Crawler') }}
<span v-if="row.item.is_mobile"> </span>
<icon :icon="IconSmartphone" />
<span class="visually-hidden">
{{ $gettext('Mobile Device') }}
</span> </span>
</span> <span v-else-if="row.item.device.is_mobile">
<span v-else> <icon :icon="IconSmartphone" />
<icon :icon="IconDesktopWindows" /> <span class="visually-hidden">
<span class="visually-hidden"> {{ $gettext('Mobile') }}
{{ $gettext('Desktop Device') }} </span>
</span> </span>
</span> <span v-else>
<icon :icon="IconDesktopWindows" />
{{ row.item.user_agent }} <span class="visually-hidden">
</div> {{ $gettext('Desktop') }}
<div v-if="row.item.device.client"> </span>
<small>{{ row.item.device.client }}</small> </span>
</div>
<div class="flex-fill">
<div v-if="row.item.device.client">
{{ row.item.device.client }}
</div>
<div class="small">
{{ row.item.user_agent }}
</div>
</div>
</div> </div>
</template> </template>
<template #cell(stream)="row"> <template #cell(stream)="row">
@ -174,17 +186,21 @@
<script setup lang="ts"> <script setup lang="ts">
import StationReportsListenersMap from "./Listeners/Map.vue"; import StationReportsListenersMap from "./Listeners/Map.vue";
import Icon from "~/components/Common/Icon.vue"; import Icon from "~/components/Common/Icon.vue";
import formatTime from "~/functions/formatTime";
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue"; import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import DateRangeDropdown from "~/components/Common/DateRangeDropdown.vue"; import DateRangeDropdown from "~/components/Common/DateRangeDropdown.vue";
import {computed, nextTick, onMounted, ref, shallowRef, watch} from "vue"; import {computed, ComputedRef, nextTick, onMounted, Ref, ref, ShallowRef, shallowRef, watch} from "vue";
import {useTranslate} from "~/vendor/gettext"; import {useTranslate} from "~/vendor/gettext";
import {useAxios} from "~/vendor/axios"; import {useAxios} from "~/vendor/axios";
import {useAzuraCastStation} from "~/vendor/azuracast";
import {useLuxon} from "~/vendor/luxon";
import {getStationApiUrl} from "~/router"; import {getStationApiUrl} from "~/router";
import {IconDesktopWindows, IconDownload, IconSmartphone} from "~/components/Common/icons"; import {IconDesktopWindows, IconDownload, IconRouter, IconSmartphone} from "~/components/Common/icons";
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable"; import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable";
import {ListenerFilters, ListenerTypeFilter} from "~/components/Stations/Reports/Listeners/listenerFilters.ts";
import {filter} from "lodash";
import formatTime from "~/functions/formatTime.ts";
import ListenerFiltersBar from "./Listeners/FiltersBar.vue";
import {ApiListener} from "~/entities/ApiInterfaces.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
import {useLuxon} from "~/vendor/luxon.ts";
const props = defineProps({ const props = defineProps({
attribution: { attribution: {
@ -196,12 +212,15 @@ const props = defineProps({
const apiUrl = getStationApiUrl('/listeners'); const apiUrl = getStationApiUrl('/listeners');
const isLive = ref<boolean>(true); const isLive = ref<boolean>(true);
const listeners = shallowRef([]); const listeners: ShallowRef<ApiListener[]> = shallowRef([]);
const {timezone} = useAzuraCastStation();
const {DateTime} = useLuxon(); const {DateTime} = useLuxon();
const nowTz = DateTime.now().setZone(timezone); const {
now,
formatTimestampAsDateTime
} = useStationDateTimeFormatter();
const nowTz = now();
const minDate = nowTz.minus({years: 5}).toJSDate(); const minDate = nowTz.minus({years: 5}).toJSDate();
const maxDate = nowTz.plus({days: 5}).toJSDate(); const maxDate = nowTz.plus({days: 5}).toJSDate();
@ -211,38 +230,107 @@ const dateRange = ref({
endDate: nowTz.toJSDate() endDate: nowTz.toJSDate()
}); });
const filters: Ref<ListenerFilters> = ref({
minLength: null,
maxLength: null,
type: ListenerTypeFilter.All,
});
const {$gettext} = useTranslate(); const {$gettext} = useTranslate();
const fields: DataTableField[] = [ const fields: DataTableField[] = [
{key: 'ip', label: $gettext('IP'), sortable: false}, {
{key: 'time', label: $gettext('Time'), sortable: false}, key: 'ip', label: $gettext('IP'), sortable: false,
{key: 'time_sec', label: $gettext('Time (sec)'), sortable: false}, selectable: true,
{key: 'user_agent', isRowHeader: true, label: $gettext('User Agent'), sortable: false}, visible: true
{key: 'stream', label: $gettext('Stream'), sortable: false}, },
{key: 'location', label: $gettext('Location'), sortable: false} {
key: 'connected_time',
label: $gettext('Time'),
sortable: true,
formatter: (_col, _key, item) => {
return formatTime(item.connected_time)
},
selectable: true,
visible: true
},
{
key: 'connected_time_sec',
label: $gettext('Time (sec)'),
sortable: false,
formatter: (_col, _key, item) => {
return item.connected_time;
},
selectable: true,
visible: false
},
{
key: 'connected_on',
label: $gettext('Start Time'),
sortable: true,
formatter: (_col, _key, item) => formatTimestampAsDateTime(
item.connected_on,
DateTime.DATETIME_SHORT
),
selectable: true,
visible: false
},
{
key: 'connected_until',
label: $gettext('End Time'),
sortable: true,
formatter: (_col, _key, item) => formatTimestampAsDateTime(
item.connected_until,
DateTime.DATETIME_SHORT
),
selectable: true,
visible: false
},
{
key: 'device.client',
isRowHeader: true,
label: $gettext('User Agent'),
sortable: true,
selectable: true,
visible: true
},
{
key: 'stream',
label: $gettext('Stream'),
sortable: true,
selectable: true,
visible: true
},
{
key: 'location',
label: $gettext('Location'),
sortable: true,
selectable: true,
visible: true
}
]; ];
const exportUrl = computed(() => { const exportUrl = computed(() => {
const exportUrl = new URL(apiUrl.value, document.location.href); const exportUrl = new URL(apiUrl.value, document.location.href);
const exportUrlParams = exportUrl.searchParams; const exportUrlParams = exportUrl.searchParams;
exportUrlParams.set('format', 'csv'); exportUrlParams.set('format', 'csv');
if (!isLive.value) { if (!isLive.value) {
exportUrlParams.set('start', DateTime.fromJSDate(dateRange.value.startDate).toISO()); exportUrlParams.set('start', DateTime.fromJSDate(dateRange.value.startDate).toISO());
exportUrlParams.set('end', DateTime.fromJSDate(dateRange.value.endDate).toISO()); exportUrlParams.set('end', DateTime.fromJSDate(dateRange.value.endDate).toISO());
} }
return exportUrl.toString(); return exportUrl.toString();
}); });
const totalListenerHours = computed(() => { const totalListenerHours = computed(() => {
let tlh_seconds = 0; let tlh_seconds = 0;
listeners.value.forEach(function (listener) { filteredListeners.value.forEach(function (listener) {
tlh_seconds += listener.connected_time; tlh_seconds += listener.connected_time;
}); });
const tlh_hours = tlh_seconds / 3600; const tlh_hours = tlh_seconds / 3600;
return Math.round((tlh_hours + 0.00001) * 100) / 100; return Math.round((tlh_hours + 0.00001) * 100) / 100;
}); });
const {axios} = useAxios(); const {axios} = useAxios();
@ -250,6 +338,40 @@ const {axios} = useAxios();
const $datatable = ref<DataTableTemplateRef>(null); const $datatable = ref<DataTableTemplateRef>(null);
const {navigate} = useHasDatatable($datatable); const {navigate} = useHasDatatable($datatable);
const hasFilters: ComputedRef<boolean> = computed(() => {
return null !== filters.value.minLength
|| null !== filters.value.maxLength
|| ListenerTypeFilter.All !== filters.value.type;
});
const filteredListeners: ComputedRef<ApiListener[]> = computed(() => {
if (!hasFilters.value) {
return listeners.value;
}
return filter(
listeners.value,
(row: ApiListener) => {
const connectedTime: number = row.connected_time;
if (null !== filters.value.minLength && connectedTime < filters.value.minLength) {
return false;
}
if (null !== filters.value.maxLength && connectedTime > filters.value.maxLength) {
return false;
}
if (ListenerTypeFilter.All !== filters.value.type) {
if (ListenerTypeFilter.Mobile === filters.value.type && !row.device.is_mobile) {
return false;
} else if (ListenerTypeFilter.Desktop === filters.value.type && row.device.is_mobile) {
return false;
}
}
return true;
}
);
});
const updateListeners = () => { const updateListeners = () => {
const params: { const params: {
[key: string]: any [key: string]: any

View File

@ -0,0 +1,85 @@
<template>
<div class="row row-cols-md-auto g-3 align-items-center">
<div class="col-12">
<label for="minLength">{{ $gettext('Min. Connected Time') }}</label>
<div class="input-group input-group-sm">
<input
id="minLength"
v-model="filters.minLength"
type="number"
class="form-control"
min="0"
step="1"
>
</div>
</div>
<div class="col-12">
<label for="maxLength">{{ $gettext('Max. Connected Time') }}</label>
<div class="input-group input-group-sm">
<input
id="maxLength"
v-model="filters.maxLength"
type="number"
class="form-control"
min="0"
step="1"
>
</div>
</div>
<div class="col-12">
<label for="type">{{ $gettext('Listener Type') }}</label>
<div class="input-group input-group-sm">
<select
v-model="filters.type"
class="form-select form-select-sm"
>
<option :value="ListenerTypeFilter.All">
{{ $gettext('All Types') }}
</option>
<option :value="ListenerTypeFilter.Mobile">
{{ $gettext('Mobile') }}
</option>
<option :value="ListenerTypeFilter.Desktop">
{{ $gettext('Desktop') }}
</option>
<option :value="ListenerTypeFilter.Bot">
{{ $gettext('Bot/Crawler') }}
</option>
</select>
</div>
</div>
<div class="col-12">
<button
type="button"
class="btn btn-sm btn-secondary"
@click="clearFilters"
>
<icon :icon="IconClearAll" />
<span>
{{ $gettext('Clear Filters') }}
</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import {ListenerFilters, ListenerTypeFilter} from "./listenerFilters.ts";
import {useVModel} from "@vueuse/core";
import {WritableComputedRef} from "vue";
import {IconClearAll} from "~/components/Common/icons.ts";
import Icon from "~/components/Common/Icon.vue";
const props = defineProps<{
filters: ListenerFilters
}>();
const emit = defineEmits(['update:filters']);
const filters: WritableComputedRef<ListenerFilters> = useVModel(props, 'filters', emit);
const clearFilters = () => {
filters.value.minLength = null;
filters.value.maxLength = null;
filters.value.type = ListenerTypeFilter.All;
}
</script>

View File

@ -0,0 +1,12 @@
export enum ListenerTypeFilter {
All = 'all',
Mobile = 'mobile',
Desktop = 'desktop',
Bot = 'bot'
}
export interface ListenerFilters {
type: ListenerTypeFilter,
minLength: number | null,
maxLength: number | null
}

View File

@ -92,11 +92,10 @@ import StreamsTab from "./Overview/StreamsTab.vue";
import ClientsTab from "./Overview/ClientsTab.vue"; import ClientsTab from "./Overview/ClientsTab.vue";
import ListeningTimeTab from "~/components/Stations/Reports/Overview/ListeningTimeTab.vue"; import ListeningTimeTab from "~/components/Stations/Reports/Overview/ListeningTimeTab.vue";
import {ref} from "vue"; import {ref} from "vue";
import {useAzuraCastStation} from "~/vendor/azuracast";
import {useLuxon} from "~/vendor/luxon";
import {getStationApiUrl} from "~/router"; import {getStationApiUrl} from "~/router";
import Tabs from "~/components/Common/Tabs.vue"; import Tabs from "~/components/Common/Tabs.vue";
import Tab from "~/components/Common/Tab.vue"; import Tab from "~/components/Common/Tab.vue";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const props = defineProps({ const props = defineProps({
showFullAnalytics: { showFullAnalytics: {
@ -113,11 +112,9 @@ const byCountryUrl = getStationApiUrl('/reports/overview/by-country');
const byClientUrl = getStationApiUrl('/reports/overview/by-client'); const byClientUrl = getStationApiUrl('/reports/overview/by-client');
const listeningTimeUrl = getStationApiUrl('/reports/overview/by-listening-time'); const listeningTimeUrl = getStationApiUrl('/reports/overview/by-listening-time');
const {timezone} = useAzuraCastStation(); const {now} = useStationDateTimeFormatter();
const {DateTime} = useLuxon();
const nowTz = DateTime.now().setZone(timezone);
const nowTz = now();
const dateRange = ref({ const dateRange = ref({
startDate: nowTz.minus({days: 13}).toJSDate(), startDate: nowTz.minus({days: 13}).toJSDate(),
endDate: nowTz.toJSDate(), endDate: nowTz.toJSDate(),

View File

@ -56,14 +56,14 @@
:api-url="listUrlForType" :api-url="listUrlForType"
> >
<template #cell(timestamp)="row"> <template #cell(timestamp)="row">
{{ formatTime(row.item.timestamp) }} {{ formatTimestampAsDateTime(row.item.timestamp) }}
</template> </template>
<template #cell(played_at)="row"> <template #cell(played_at)="row">
<span v-if="row.item.played_at === 0"> <span v-if="row.item.played_at === 0">
{{ $gettext('Not Played') }} {{ $gettext('Not Played') }}
</span> </span>
<span v-else> <span v-else>
{{ formatTime(row.item.played_at) }} {{ formatTimestampAsDateTime(row.item.played_at) }}
</span> </span>
</template> </template>
<template #cell(song_title)="row"> <template #cell(song_title)="row">
@ -93,18 +93,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue'; import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
import Icon from "~/components/Common/Icon.vue"; import Icon from "~/components/Common/Icon.vue";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
import {computed, nextTick, ref} from "vue"; import {computed, nextTick, ref} from "vue";
import {useTranslate} from "~/vendor/gettext"; import {useTranslate} from "~/vendor/gettext";
import {useSweetAlert} from "~/vendor/sweetalert"; import {useSweetAlert} from "~/vendor/sweetalert";
import {useNotify} from "~/functions/useNotify"; import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios"; import {useAxios} from "~/vendor/axios";
import {useLuxon} from "~/vendor/luxon";
import {getStationApiUrl} from "~/router"; import {getStationApiUrl} from "~/router";
import {IconRemove} from "~/components/Common/icons"; import {IconRemove} from "~/components/Common/icons";
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts"; import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const listUrl = getStationApiUrl('/reports/requests'); const listUrl = getStationApiUrl('/reports/requests');
const clearUrl = getStationApiUrl('/reports/requests/clear'); const clearUrl = getStationApiUrl('/reports/requests/clear');
@ -147,16 +146,7 @@ const setType = (type) => {
nextTick(relist); nextTick(relist);
}; };
const {timeConfig} = useAzuraCast(); const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
const {timezone} = useAzuraCastStation();
const {DateTime} = useLuxon();
const formatTime = (time) => {
return DateTime.fromSeconds(time).setZone(timezone).toLocaleString(
{...DateTime.DATETIME_MED, ...timeConfig}
);
};
const {confirmDelete} = useSweetAlert(); const {confirmDelete} = useSweetAlert();
const {notifySuccess} = useNotify(); const {notifySuccess} = useNotify();

View File

@ -114,16 +114,14 @@ import FormFieldset from "~/components/Form/FormFieldset.vue";
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue"; import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm"; import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
import {getStationApiUrl} from "~/router"; import {getStationApiUrl} from "~/router";
import {useLuxon} from "~/vendor/luxon";
import {useAzuraCastStation} from "~/vendor/azuracast";
import CardPage from "~/components/Common/CardPage.vue"; import CardPage from "~/components/Common/CardPage.vue";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const apiUrl = getStationApiUrl('/reports/soundexchange'); const apiUrl = getStationApiUrl('/reports/soundexchange');
const {DateTime} = useLuxon(); const {now} = useStationDateTimeFormatter();
const {timezone} = useAzuraCastStation();
const lastMonth = DateTime.now().setZone(timezone).minus({months: 1}); const lastMonth = now().minus({months: 1});
const {v$} = useVuelidateOnForm( const {v$} = useVuelidateOnForm(
{ {

View File

@ -86,22 +86,28 @@
<script setup lang="ts"> <script setup lang="ts">
import Icon from "~/components/Common/Icon.vue"; import Icon from "~/components/Common/Icon.vue";
import DataTable, { DataTableField } from "~/components/Common/DataTable.vue"; import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import DateRangeDropdown from "~/components/Common/DateRangeDropdown.vue"; import DateRangeDropdown from "~/components/Common/DateRangeDropdown.vue";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
import {computed, ref, watch} from "vue"; import {computed, ref, watch} from "vue";
import {useTranslate} from "~/vendor/gettext"; import {useTranslate} from "~/vendor/gettext";
import {useLuxon} from "~/vendor/luxon";
import {getStationApiUrl} from "~/router"; import {getStationApiUrl} from "~/router";
import {IconDownload, IconTrendingDown, IconTrendingUp} from "~/components/Common/icons"; import {IconDownload, IconTrendingDown, IconTrendingUp} from "~/components/Common/icons";
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts"; import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
import {useLuxon} from "~/vendor/luxon.ts";
import {useAzuraCastStation} from "~/vendor/azuracast.ts";
const baseApiUrl = getStationApiUrl('/history'); const baseApiUrl = getStationApiUrl('/history');
const {timezone} = useAzuraCastStation(); const {timezone} = useAzuraCastStation();
const {DateTime} = useLuxon(); const {DateTime} = useLuxon();
const {
now,
formatDateTimeAsDateTime,
formatTimestampAsDateTime
} = useStationDateTimeFormatter();
const nowTz = DateTime.now().setZone(timezone); const nowTz = now();
const dateRange = ref( const dateRange = ref(
{ {
@ -111,7 +117,6 @@ const dateRange = ref(
); );
const {$gettext} = useTranslate(); const {$gettext} = useTranslate();
const {timeConfig} = useAzuraCast();
const fields: DataTableField[] = [ const fields: DataTableField[] = [
{ {
@ -119,29 +124,22 @@ const fields: DataTableField[] = [
label: $gettext('Date/Time (Browser)'), label: $gettext('Date/Time (Browser)'),
selectable: true, selectable: true,
sortable: false, sortable: false,
formatter: (value) => { visible: false,
return DateTime.fromSeconds( formatter: (value) => formatDateTimeAsDateTime(
value, DateTime.fromSeconds(value, {zone: 'system'}),
{zone: 'system'} DateTime.DATETIME_SHORT
).toLocaleString( )
{...DateTime.DATETIME_SHORT, ...timeConfig}
);
}
}, },
{ {
key: 'played_at_station', key: 'played_at_station',
label: $gettext('Date/Time (Station)'), label: $gettext('Date/Time (Station)'),
sortable: false, sortable: false,
selectable: true, selectable: true,
visible: false, visible: true,
formatter: (_value, _key, item) => { formatter: (_value, _key, item) => formatTimestampAsDateTime(
return DateTime.fromSeconds( item.played_at,
item.played_at, DateTime.DATETIME_SHORT
{zone: timezone} )
).toLocaleString(
{...DateTime.DATETIME_SHORT, ...timeConfig}
);
}
}, },
{ {
key: 'listeners_start', key: 'listeners_start',

View File

@ -13,7 +13,7 @@
</template> </template>
<data-table <data-table
id="station_remotes" id="station_sftp_users"
ref="$datatable" ref="$datatable"
:show-toolbar="false" :show-toolbar="false"
:fields="fields" :fields="fields"
@ -75,7 +75,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import DataTable, { DataTableField } from "~/components/Common/DataTable.vue"; import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import SftpUsersEditModal from "./SftpUsers/EditModal.vue"; import SftpUsersEditModal from "./SftpUsers/EditModal.vue";
import {useTranslate} from "~/vendor/gettext"; import {useTranslate} from "~/vendor/gettext";
import {ref} from "vue"; import {ref} from "vue";

View File

@ -64,14 +64,15 @@
import {ref} from "vue"; import {ref} from "vue";
import Icon from "~/components/Common/Icon.vue"; import Icon from "~/components/Common/Icon.vue";
import SidebarMenu from "~/components/Common/SidebarMenu.vue"; import SidebarMenu from "~/components/Common/SidebarMenu.vue";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast"; import {useAzuraCastStation} from "~/vendor/azuracast";
import {useEventBus, useIntervalFn} from "@vueuse/core"; import {useEventBus, useIntervalFn} from "@vueuse/core";
import {useStationsMenu} from "~/components/Stations/menu"; import {useStationsMenu} from "~/components/Stations/menu";
import {StationPermission, userAllowedForStation} from "~/acl"; import {StationPermission, userAllowedForStation} from "~/acl";
import {useAxios} from "~/vendor/axios.ts"; import {useAxios} from "~/vendor/axios.ts";
import {getStationApiUrl} from "~/router.ts"; import {getStationApiUrl} from "~/router.ts";
import {useLuxon} from "~/vendor/luxon.ts";
import {IconEdit} from "~/components/Common/icons.ts"; import {IconEdit} from "~/components/Common/icons.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
import {useLuxon} from "~/vendor/luxon.ts";
const props = defineProps({ const props = defineProps({
station: { station: {
@ -82,17 +83,15 @@ const props = defineProps({
const menuItems = useStationsMenu(); const menuItems = useStationsMenu();
const {timeConfig} = useAzuraCast(); const {name} = useAzuraCastStation();
const {name, timezone} = useAzuraCastStation();
const {DateTime} = useLuxon(); const {DateTime} = useLuxon();
const {now, formatDateTimeAsTime} = useStationDateTimeFormatter();
const clock = ref(''); const clock = ref('');
useIntervalFn(() => { useIntervalFn(() => {
clock.value = DateTime.now().setZone(timezone).toLocaleString({ clock.value = formatDateTimeAsTime(now(), DateTime.TIME_WITH_SHORT_OFFSET);
...DateTime.TIME_WITH_SHORT_OFFSET,
...timeConfig
})
}, 1000, { }, 1000, {
immediate: true, immediate: true,
immediateCallback: true immediateCallback: true

View File

@ -63,24 +63,23 @@ import InlinePlayer from '~/components/InlinePlayer.vue';
import Icon from '~/components/Common/Icon.vue'; import Icon from '~/components/Common/Icon.vue';
import PlayButton from "~/components/Common/PlayButton.vue"; import PlayButton from "~/components/Common/PlayButton.vue";
import '~/vendor/sweetalert'; import '~/vendor/sweetalert';
import {useAzuraCast} from "~/vendor/azuracast";
import {ref} from "vue"; import {ref} from "vue";
import {useTranslate} from "~/vendor/gettext"; import {useTranslate} from "~/vendor/gettext";
import {useSweetAlert} from "~/vendor/sweetalert"; import {useSweetAlert} from "~/vendor/sweetalert";
import {useNotify} from "~/functions/useNotify"; import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios"; import {useAxios} from "~/vendor/axios";
import Modal from "~/components/Common/Modal.vue"; import Modal from "~/components/Common/Modal.vue";
import {useLuxon} from "~/vendor/luxon";
import {IconDownload} from "~/components/Common/icons"; import {IconDownload} from "~/components/Common/icons";
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts"; import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts"; import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
import {usePlayerStore, useProvidePlayerStore} from "~/functions/usePlayerStore.ts"; import {usePlayerStore, useProvidePlayerStore} from "~/functions/usePlayerStore.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
const listUrl = ref(null); const listUrl = ref(null);
const {$gettext} = useTranslate(); const {$gettext} = useTranslate();
const {timeConfig} = useAzuraCast();
const {DateTime} = useLuxon(); const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
const fields: DataTableField[] = [ const fields: DataTableField[] = [
{ {
@ -93,11 +92,7 @@ const fields: DataTableField[] = [
key: 'timestampStart', key: 'timestampStart',
label: $gettext('Start Time'), label: $gettext('Start Time'),
sortable: false, sortable: false,
formatter: (value) => { formatter: (value) => formatTimestampAsDateTime(value),
return DateTime.fromSeconds(value).toLocaleString(
{...DateTime.DATETIME_MED, ...timeConfig}
);
},
class: 'ps-3' class: 'ps-3'
}, },
{ {
@ -105,13 +100,9 @@ const fields: DataTableField[] = [
label: $gettext('End Time'), label: $gettext('End Time'),
sortable: false, sortable: false,
formatter: (value) => { formatter: (value) => {
if (value === 0) { return value === 0
return $gettext('Live'); ? $gettext('Live')
} : formatTimestampAsDateTime(value);
return DateTime.fromSeconds(value).toLocaleString(
{...DateTime.DATETIME_MED, ...timeConfig}
);
} }
}, },
{ {

View File

@ -1,7 +1,9 @@
import {getStationApiUrl} from "~/router.ts"; import {getStationApiUrl} from "~/router.ts";
import populateComponentRemotely from "~/functions/populateComponentRemotely.ts"; import populateComponentRemotely from "~/functions/populateComponentRemotely.ts";
import {RouteRecordRaw} from "vue-router";
import {useAxios} from "~/vendor/axios.ts";
export default function useStationsRoutes() { export default function useStationsRoutes(): RouteRecordRaw[] {
return [ return [
{ {
path: '/', path: '/',
@ -61,6 +63,22 @@ export default function useStationsRoutes() {
name: 'stations:podcasts:index', name: 'stations:podcasts:index',
...populateComponentRemotely(getStationApiUrl('/vue/podcasts')) ...populateComponentRemotely(getStationApiUrl('/vue/podcasts'))
}, },
{
path: '/podcast/:podcast_id',
component: () => import('~/components/Stations/PodcastEpisodes.vue'),
name: 'stations:podcast:episodes',
beforeEnter: async (to, _, next) => {
const apiUrl = getStationApiUrl(`/podcast/${to.params.podcast_id}`);
const {axios} = useAxios();
to.meta.state = {
podcast: await axios.get(apiUrl.value).then(r => r.data)
};
next();
},
props: (to) => {
return to.meta.state;
}
},
{ {
path: '/profile', path: '/profile',
name: 'stations:profile:index', name: 'stations:profile:index',

View File

@ -269,10 +269,76 @@ export interface ApiListener {
* @example 30 * @example 30
*/ */
connected_time?: number; connected_time?: number;
/** Device metadata, if available */ device?: ApiListenerDevice;
device?: any[]; location?: ApiListenerLocation;
/** Location metadata, if available */ }
location?: any[];
export interface ApiListenerDevice {
/**
* If the listener device is likely a browser.
* @example true
*/
is_browser?: boolean;
/**
* If the listener device is likely a mobile device.
* @example true
*/
is_mobile?: boolean;
/**
* If the listener device is likely a crawler.
* @example true
*/
is_bot?: boolean;
/**
* Summary of the listener client.
* @example "Firefox 121.0, Windows"
*/
client?: string | null;
/**
* Summary of the listener browser family.
* @example "Firefox"
*/
browser_family?: string | null;
/**
* Summary of the listener OS family.
* @example "Windows"
*/
os_family?: string | null;
}
export interface ApiListenerLocation {
/**
* The approximate city of the listener.
* @example "Austin"
*/
city?: string | null;
/**
* The approximate region/state of the listener.
* @example "Texas"
*/
region?: string | null;
/**
* The approximate country of the listener.
* @example "United States"
*/
country?: string | null;
/**
* A description of the location.
* @example "Austin, Texas, US"
*/
description?: string;
/**
* Latitude.
* @format float
* @example "30.000000"
*/
lat?: number | null;
/**
* Latitude.
* @format float
* @example "-97.000000"
*/
lon?: number | null;
} }
export type ApiNewRecord = ApiStatus & { export type ApiNewRecord = ApiStatus & {
@ -408,6 +474,11 @@ export interface ApiNowPlayingStation {
* @example "liquidsoap" * @example "liquidsoap"
*/ */
backend?: string; backend?: string;
/**
* The station's IANA time zone
* @example "America/Chicago"
*/
timezone?: string;
/** /**
* The full URL to listen to the default mount of the station * The full URL to listen to the default mount of the station
* @example "http://localhost:8000/radio.mp3" * @example "http://localhost:8000/radio.mp3"
@ -531,26 +602,39 @@ export interface ApiNowPlayingStationRemote {
} }
export type ApiPodcast = HasLinks & { export type ApiPodcast = HasLinks & {
id?: string | null; id?: string;
storage_location_id?: number | null; storage_location_id?: number;
title?: string | null; title?: string;
link?: string | null; link?: string | null;
description?: string | null; description?: string;
language?: string | null; description_short?: string;
author?: string | null; language?: string;
email?: string | null; language_name?: string;
author?: string;
email?: string;
has_custom_art?: boolean; has_custom_art?: boolean;
art?: string | null; art?: string;
art_updated_at?: number; art_updated_at?: number;
categories?: string[]; is_published?: boolean;
episodes?: string[]; episodes?: number;
categories?: ApiPodcastCategory[];
}; };
export interface ApiPodcastCategory {
category?: string;
text?: string;
title?: string;
subtitle?: string | null;
}
export type ApiPodcastEpisode = HasLinks & { export type ApiPodcastEpisode = HasLinks & {
id?: string | null; id?: string;
title?: string | null; title?: string;
description?: string | null; description?: string;
description_short?: string;
explicit?: boolean; explicit?: boolean;
created_at?: number;
is_published?: boolean;
publish_at?: number | null; publish_at?: number | null;
has_media?: boolean; has_media?: boolean;
media?: ApiPodcastMedia; media?: ApiPodcastMedia;
@ -583,32 +667,32 @@ export interface ApiSong {
* The song artist. * The song artist.
* @example "Chet Porter" * @example "Chet Porter"
*/ */
artist?: string; artist?: string | null;
/** /**
* The song title. * The song title.
* @example "Aluko River" * @example "Aluko River"
*/ */
title?: string; title?: string | null;
/** /**
* The song album. * The song album.
* @example "Moving Castle" * @example "Moving Castle"
*/ */
album?: string; album?: string | null;
/** /**
* The song genre. * The song genre.
* @example "Rock" * @example "Rock"
*/ */
genre?: string; genre?: string | null;
/** /**
* The International Standard Recording Code (ISRC) of the file. * The International Standard Recording Code (ISRC) of the file.
* @example "US28E1600021" * @example "US28E1600021"
*/ */
isrc?: string; isrc?: string | null;
/** /**
* Lyrics to the song. * Lyrics to the song.
* @example "" * @example ""
*/ */
lyrics?: string; lyrics?: string | null;
/** /**
* URL to the album artwork (if available). * URL to the album artwork (if available).
* @example "https://picsum.photos/1200/1200" * @example "https://picsum.photos/1200/1200"

View File

@ -1,4 +1,4 @@
export default function (seconds: string | number) { export default function (seconds: string | number): string {
seconds = Math.floor(Number(seconds)); seconds = Math.floor(Number(seconds));
const d: number = Math.floor(seconds / 86400), const d: number = Math.floor(seconds / 86400),

View File

@ -1,3 +1,3 @@
export default function (volume: number) { export default function (volume: number): number {
return Math.min((Math.exp(volume / 100) - 1) / (Math.E - 1), 1); return Math.min((Math.exp(volume / 100) - 1) / (Math.E - 1), 1);
} }

View File

@ -1,4 +1,4 @@
export default function isObject(value) { export default function isObject(value: any): boolean {
return typeof value === "object" return typeof value === "object"
&& (Object(value) === value) && (Object(value) === value)
&& !Array.isArray(value); && !Array.isArray(value);

View File

@ -1,7 +1,10 @@
import {reactivePick} from "@vueuse/core"; import {reactivePick} from "@vueuse/core";
import {keys} from "lodash"; import {keys} from "lodash";
export function pickProps(props, subset) { export function pickProps<T extends object, K extends T>(
props: T,
subset: K
): Pick<T, keyof K> {
return reactivePick( return reactivePick(
props, props,
...keys(subset) ...keys(subset)

View File

@ -1,5 +1,5 @@
import {resolveRef, watchOnce} from "@vueuse/core"; import {watchOnce} from "@vueuse/core";
import {ref} from "vue"; import {ref, toRef} from "vue";
/** /**
* Creates a ref that syncs with its "source" value only once. * Creates a ref that syncs with its "source" value only once.
@ -7,7 +7,7 @@ import {ref} from "vue";
* subsequent refreshes. * subsequent refreshes.
*/ */
export default function syncOnce(sourceMaybeRef) { export default function syncOnce(sourceMaybeRef) {
const sourceRef = resolveRef(sourceMaybeRef); const sourceRef = toRef(sourceMaybeRef);
const newRef = ref(sourceRef.value); const newRef = ref(sourceRef.value);
watchOnce(sourceRef, (newVal) => { watchOnce(sourceRef, (newVal) => {

View File

@ -3,7 +3,7 @@ import {useNotify} from "~/functions/useNotify";
import {useAxios} from "~/vendor/axios"; import {useAxios} from "~/vendor/axios";
export default function useConfirmAndDelete( export default function useConfirmAndDelete(
confirmMessage, confirmMessage: string,
onSuccess = null onSuccess = null
) { ) {
const {confirmDelete} = useSweetAlert(); const {confirmDelete} = useSweetAlert();

View File

@ -4,22 +4,41 @@ import {Ref} from "vue";
export type DataTableTemplateRef = InstanceType<typeof DataTable> | null; export type DataTableTemplateRef = InstanceType<typeof DataTable> | null;
export default function useHasDatatable($datatableRef: Ref<DataTableTemplateRef>) { export default function useHasDatatable($datatableRef: Ref<DataTableTemplateRef>) {
/**
* Reset selected rows, active row, and trigger data reload.
*/
const refresh = () => { const refresh = () => {
return $datatableRef.value?.refresh(); return $datatableRef.value?.refresh();
}; };
/**
* Refresh, but clearing the cache where relevant.
* @see refresh
*/
const relist = () => { const relist = () => {
return $datatableRef.value?.relist(); return $datatableRef.value?.relist();
} }
/**
* Clear search phrase and current page, then call refresh().
* @see relist
*/
const navigate = () => { const navigate = () => {
return $datatableRef.value?.navigate(); return $datatableRef.value?.navigate();
} }
/**
* Set the current search filer string.
* @param newTerm The new search term.
*/
const setFilter = (newTerm: string) => { const setFilter = (newTerm: string) => {
return $datatableRef.value?.setFilter(newTerm); return $datatableRef.value?.setFilter(newTerm);
} }
/**
* Either set the specified row as active, or disable it if it already is active.
* @param row
*/
const toggleDetails = (row) => { const toggleDetails = (row) => {
return $datatableRef.value?.toggleDetails(row); return $datatableRef.value?.toggleDetails(row);
}; };

View File

@ -8,7 +8,6 @@ interface EditModalCompatible {
export type EditModalTemplateRef = InstanceType<EditModalCompatible> | null; export type EditModalTemplateRef = InstanceType<EditModalCompatible> | null;
export default function useHasEditModal($modalRef: Ref<EditModalTemplateRef>) { export default function useHasEditModal($modalRef: Ref<EditModalTemplateRef>) {
const doCreate = (): void => { const doCreate = (): void => {
$modalRef.value?.create(); $modalRef.value?.create();

View File

@ -0,0 +1,69 @@
import {useLuxon} from "~/vendor/luxon.ts";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast.ts";
import {DateTimeMaybeValid} from "luxon";
export default function useStationDateTimeFormatter() {
const {DateTime} = useLuxon();
const {timeConfig} = useAzuraCast();
const {timezone} = useAzuraCastStation();
const now = (): DateTimeMaybeValid =>
DateTime.local({zone: timezone});
const timestampToDateTime = (value): DateTimeMaybeValid =>
DateTime.fromSeconds(value, {zone: timezone});
const formatDateTime = (
value: DateTimeMaybeValid,
format: Intl.DateTimeFormatOptions
) => value.toLocaleString(
{...format, ...timeConfig}
);
const formatDateTimeAsDateTime = (
value: DateTimeMaybeValid,
format: Intl.DateTimeFormatOptions | null = null
) => formatDateTime(value, format ?? DateTime.DATETIME_MED);
const formatDateTimeAsTime = (
value: DateTimeMaybeValid,
format: Intl.DateTimeFormatOptions | null = null
) => formatDateTime(value, format ?? DateTime.TIME_WITH_SECONDS);
const formatDateTimeAsRelative = (
value: DateTimeMaybeValid
) => value.toRelative();
const formatTimestampAsDateTime = (
value: any,
format: Intl.DateTimeFormatOptions | null = null
) =>
(value)
? formatDateTimeAsDateTime(timestampToDateTime(value), format)
: ''
const formatTimestampAsTime = (
value: any,
format: Intl.DateTimeFormatOptions | null = null
) =>
(value)
? formatDateTimeAsTime(timestampToDateTime(value), format)
: ''
const formatTimestampAsRelative = (value) =>
(value)
? formatDateTimeAsRelative(timestampToDateTime(value))
: '';
return {
now,
timestampToDateTime,
formatDateTime,
formatDateTimeAsDateTime,
formatDateTimeAsTime,
formatDateTimeAsRelative,
formatTimestampAsDateTime,
formatTimestampAsTime,
formatTimestampAsRelative
};
}

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-bar-down" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 4.146a.5.5 0 0 1 .708 0L8 7.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708M1 11.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13a.5.5 0 0 1-.5-.5"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-bar-up" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 11.854a.5.5 0 0 0 .708 0L8 8.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708M2.4 5.2c0 .22.18.4.4.4h10.4a.4.4 0 0 0 0-.8H2.8a.4.4 0 0 0-.4.4"/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-double-down" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.646 6.646a.5.5 0 0 1 .708 0L8 12.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>
<path fill-rule="evenodd" d="M1.646 2.646a.5.5 0 0 1 .708 0L8 8.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>
</svg>

After

Width:  |  Height:  |  Size: 447 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-double-up" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M7.646 2.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 3.707 2.354 9.354a.5.5 0 1 1-.708-.708z"/>
<path fill-rule="evenodd" d="M7.646 6.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 7.707l-5.646 5.647a.5.5 0 0 1-.708-.708z"/>
</svg>

After

Width:  |  Height:  |  Size: 405 B

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M480-335 230-585l53-53 197 199 198-198 52 53-250 249Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-down" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>
</svg>

Before

Width:  |  Height:  |  Size: 159 B

After

Width:  |  Height:  |  Size: 289 B

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M562-231 311-482l251-251 52 52-199 199 199 199-52 52Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0"/>
</svg>

Before

Width:  |  Height:  |  Size: 159 B

After

Width:  |  Height:  |  Size: 288 B

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M522-482 323-681l52-52 251 251-251 251-52-52 199-199Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708"/>
</svg>

Before

Width:  |  Height:  |  Size: 159 B

After

Width:  |  Height:  |  Size: 290 B

View File

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="m283-335-53-53 250-250 250 249-52 53-198-198-197 199Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-up" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708z"/>
</svg>

Before

Width:  |  Height:  |  Size: 159 B

After

Width:  |  Height:  |  Size: 267 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-bar-contract" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 14.854a.5.5 0 0 0 .708 0L8 11.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708m0-13.708a.5.5 0 0 1 .708 0L8 4.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708M1 8a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 8"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-expand" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708m0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708"/>
</svg>

After

Width:  |  Height:  |  Size: 404 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-rss-fill" viewBox="0 0 16 16">
<path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm1.5 2.5c5.523 0 10 4.477 10 10a1 1 0 1 1-2 0 8 8 0 0 0-8-8 1 1 0 0 1 0-2m0 4a6 6 0 0 1 6 6 1 1 0 1 1-2 0 4 4 0 0 0-4-4 1 1 0 0 1 0-2m.5 7a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/>
</svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1,24 @@
import initApp from "~/layout";
import {h} from "vue";
import {createRouter, createWebHistory} from "vue-router";
import {useAzuraCast} from "~/vendor/azuracast";
import {installRouter} from "~/vendor/router";
import PodcastsLayout from "~/components/Public/Podcasts/PodcastsLayout.vue";
import usePodcastRoutes from "~/components/Public/Podcasts/routes";
initApp({
render() {
return h(PodcastsLayout);
}
}, async (vueApp) => {
const routes = usePodcastRoutes();
const {componentProps} = useAzuraCast();
installRouter(
createRouter({
history: createWebHistory(componentProps.baseUrl),
routes
}),
vueApp
);
});

View File

@ -0,0 +1,9 @@
.badges {
& > * {
margin-right: .25rem;
}
& > *:last-child {
margin-right: 0;
}
}

View File

@ -23,7 +23,7 @@
margin-bottom: 0; margin-bottom: 0;
} }
& > .alert { .card-body.alert {
border-left: 0; border-left: 0;
border-right: 0; border-right: 0;
border-top: 0; border-top: 0;

View File

@ -1,3 +1,7 @@
div.datatable-wrapper {
display: contents;
}
div.datatable-toolbar-top, div.datatable-toolbar-top,
div.datatable-toolbar-bottom { div.datatable-toolbar-bottom {
&:empty { &:empty {

View File

@ -61,10 +61,21 @@ body.page-minimal {
.card { .card {
height: 100%; height: 100%;
}
.datatable-main { .card-body {
overflow-y: auto; flex: 0 1 auto;
}
// Only make the datatable scrollable if it's the only element in the card.
& > .datatable-wrapper {
.datatable-main {
overflow-y: auto;
}
}
& > .full-height-scrollable {
overflow-y: auto;
}
} }
} }
} }

View File

@ -28,6 +28,7 @@
// Overrides for the Daemonite Material theme // Overrides for the Daemonite Material theme
@import "root"; @import "root";
@import 'overrides/badges';
@import 'overrides/body'; @import 'overrides/body';
@import 'overrides/buttons'; @import 'overrides/buttons';
@import 'overrides/card'; @import 'overrides/card';

View File

@ -11,7 +11,7 @@ export function useTranslate(): Language {
export async function installTranslate(vueApp: App): Promise<void> { export async function installTranslate(vueApp: App): Promise<void> {
const {locale} = useAzuraCast(); const {locale} = useAzuraCast();
const translations = import.meta.glob('../../../translations/**/translations.json', {as: 'json'}); const translations = import.meta.glob('../../../translations/**/translations.json', {query: '?json'});
const localePath = '../../../translations/' + locale + '.UTF-8/translations.json'; const localePath = '../../../translations/' + locale + '.UTF-8/translations.json';
gettext = createGettext({ gettext = createGettext({

View File

@ -31,6 +31,10 @@ parameters:
scanDirectories: scanDirectories:
- ./vendor/zircote/swagger-php/src/Annotations - ./vendor/zircote/swagger-php/src/Annotations
stubFiles:
- util/phpstan_di.stub
- util/phpstan_phpdi.stub
universalObjectCratesClasses: universalObjectCratesClasses:
- App\Session\NamespaceInterface - App\Session\NamespaceInterface
- App\View - App\View

View File

@ -19,12 +19,30 @@ use Psr\EventDispatcher\EventDispatcherInterface;
use function in_array; use function in_array;
use function is_array; use function is_array;
/**
* @phpstan-type PermissionsArray array{
* global: array<string, string>,
* station: array<string, string>
* }
*/
final class Acl final class Acl
{ {
use RequestAwareTrait; use RequestAwareTrait;
/**
* @var PermissionsArray
*/
private array $permissions; private array $permissions;
/**
* @var null|array<
* int,
* array{
* stations?: array<int, array<string>>,
* global?: array<string>
* }
* >
*/
private ?array $actions; private ?array $actions;
public function __construct( public function __construct(
@ -41,12 +59,12 @@ final class Acl
{ {
$sql = $this->em->createQuery( $sql = $this->em->createQuery(
<<<'DQL' <<<'DQL'
SELECT rp FROM App\Entity\RolePermission rp SELECT rp.station_id, rp.role_id, rp.action_name FROM App\Entity\RolePermission rp
DQL DQL
); );
$this->actions = []; $this->actions = [];
foreach ($sql->getArrayResult() as $row) { foreach ($sql->toIterable() as $row) {
if ($row['station_id']) { if ($row['station_id']) {
$this->actions[$row['role_id']]['stations'][$row['station_id']][] = $row['action_name']; $this->actions[$row['role_id']]['stations'][$row['station_id']][] = $row['action_name'];
} else { } else {
@ -69,12 +87,11 @@ final class Acl
} }
/** /**
* @return mixed[] * @return array
*/ */
public function listPermissions(): array public function listPermissions(): array
{ {
if (!isset($this->permissions)) { if (!isset($this->permissions)) {
/** @var array<string,array<string, string>> $permissions */
$permissions = [ $permissions = [
'global' => [], 'global' => [],
'station' => [], 'station' => [],

View File

@ -8,6 +8,7 @@ use App\Container\EnvironmentAwareTrait;
use App\Entity\Repository\UserRepository; use App\Entity\Repository\UserRepository;
use App\Entity\User; use App\Entity\User;
use App\Exception\NotLoggedInException; use App\Exception\NotLoggedInException;
use App\Utilities\Types;
use Mezzio\Session\SessionInterface; use Mezzio\Session\SessionInterface;
final class Auth final class Auth
@ -83,7 +84,7 @@ final class Auth
if (!$this->session->has(self::SESSION_MASQUERADE_USER_ID_KEY)) { if (!$this->session->has(self::SESSION_MASQUERADE_USER_ID_KEY)) {
$this->masqueraded_user = false; $this->masqueraded_user = false;
} else { } else {
$maskUserId = (int)$this->session->get(self::SESSION_MASQUERADE_USER_ID_KEY); $maskUserId = Types::int($this->session->get(self::SESSION_MASQUERADE_USER_ID_KEY));
if (0 !== $maskUserId) { if (0 !== $maskUserId) {
$user = $this->userRepo->getRepository()->find($maskUserId); $user = $this->userRepo->getRepository()->find($maskUserId);
} else { } else {
@ -125,7 +126,7 @@ final class Auth
*/ */
public function isLoginComplete(): bool public function isLoginComplete(): bool
{ {
return $this->session->get(self::SESSION_IS_LOGIN_COMPLETE_KEY, false) ?? false; return Types::bool($this->session->get(self::SESSION_IS_LOGIN_COMPLETE_KEY, false));
} }
/** /**
@ -136,7 +137,7 @@ final class Auth
public function getUser(): ?User public function getUser(): ?User
{ {
if (null === $this->user) { if (null === $this->user) {
$userId = (int)$this->session->get(self::SESSION_USER_ID_KEY); $userId = Types::int($this->session->get(self::SESSION_USER_ID_KEY));
if (0 === $userId) { if (0 === $userId) {
$this->user = false; $this->user = false;
@ -238,7 +239,7 @@ final class Auth
$user = $this->getUser(); $user = $this->getUser();
if (!($user instanceof User)) { if (!($user instanceof User)) {
throw new NotLoggedInException(); throw NotLoggedInException::create();
} }
if ($user->verifyTwoFactor($otp)) { if ($user->verifyTwoFactor($otp)) {

View File

@ -6,9 +6,17 @@ namespace App\Cache;
use App\Entity\Api\NowPlaying\NowPlaying; use App\Entity\Api\NowPlaying\NowPlaying;
use App\Entity\Station; use App\Entity\Station;
use App\Utilities\Types;
use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
/**
* @phpstan-type LookupRow array{
* short_name: string,
* is_public: bool,
* updated_at: int
* }
*/
final class NowPlayingCache final class NowPlayingCache
{ {
private const NOWPLAYING_CACHE_TTL = 180; private const NOWPLAYING_CACHE_TTL = 180;
@ -41,9 +49,13 @@ final class NowPlayingCache
$stationCacheItem = $this->getStationCache($station); $stationCacheItem = $this->getStationCache($station);
return ($stationCacheItem->isHit()) if (!$stationCacheItem->isHit()) {
? $stationCacheItem->get() return null;
: null; }
$np = $stationCacheItem->get();
assert($np instanceof NowPlaying);
return $np;
} }
/** /**
@ -58,6 +70,8 @@ final class NowPlayingCache
} }
$np = []; $np = [];
/** @var LookupRow[] $lookupCache */
$lookupCache = (array)$lookupCacheItem->get(); $lookupCache = (array)$lookupCacheItem->get();
foreach ($lookupCache as $stationInfo) { foreach ($lookupCache as $stationInfo) {
@ -78,11 +92,14 @@ final class NowPlayingCache
return $np; return $np;
} }
/**
* @return array<int, LookupRow>
*/
public function getLookup(): array public function getLookup(): array
{ {
$lookupCacheItem = $this->getLookupCache(); $lookupCacheItem = $this->getLookupCache();
return $lookupCacheItem->isHit() return $lookupCacheItem->isHit()
? (array)$lookupCacheItem->get() ? Types::array($lookupCacheItem->get())
: []; : [];
} }
@ -114,7 +131,7 @@ final class NowPlayingCache
$lookupCacheItem = $this->getLookupCache(); $lookupCacheItem = $this->getLookupCache();
$lookupCache = $lookupCacheItem->isHit() $lookupCache = $lookupCacheItem->isHit()
? (array)$lookupCacheItem->get() ? Types::array($lookupCacheItem->get())
: []; : [];
$lookupCache[$station->getIdRequired()] = [ $lookupCache[$station->getIdRequired()] = [
@ -131,12 +148,9 @@ final class NowPlayingCache
private function getStationCache(string $identifier): CacheItemInterface private function getStationCache(string $identifier): CacheItemInterface
{ {
if (is_numeric($identifier)) { if (is_numeric($identifier)) {
$lookupCacheItem = $this->getLookupCache(); $lookupCache = $this->getLookup();
$lookupCache = $lookupCacheItem->isHit()
? (array)$lookupCacheItem->get()
: [];
$identifier = (int)$identifier; $identifier = Types::int($identifier);
if (isset($lookupCache[$identifier])) { if (isset($lookupCache[$identifier])) {
$identifier = $lookupCache[$identifier]['short_name']; $identifier = $lookupCache[$identifier]['short_name'];
} }

Some files were not shown because too many files have changed in this diff Show More