You've already forked Atomcms-edit
Add radio embed widget, SSE real-time, song history, moderation panel, and Auto DJ
- Embed widget: standalone iframe player with dark/light/transparent themes, copy-paste embed code admin page - Real-time SSE: streaming now-playing/listeners/dj events, replaces polling in radio-player and embed - Song history: auto-records song changes to radio_song_plays table, Filament resource to view - DJ moderation: unified panel for shouts approval, song request queue, DJ applications - Auto DJ: playlist management with round-robin playback when no DJ is live - Refactored radio-player Alpine component to use EventSource API with auto-reconnect
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Miscellaneous\WebsiteSetting;
|
||||||
|
use App\Models\RadioAutoDjTrack;
|
||||||
|
use App\Services\Community\RadioScheduleService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
|
||||||
|
#[AsCommand(name: 'radio:auto-dj')]
|
||||||
|
final class AutoDjPlayback extends Command
|
||||||
|
{
|
||||||
|
#[\Override]
|
||||||
|
protected $signature = 'radio:auto-dj {--force : Update now-playing even if track has not changed}';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected $description = 'Auto DJ fallback: updates now-playing from playlist when no DJ is live';
|
||||||
|
|
||||||
|
public function handle(RadioScheduleService $scheduleService): int
|
||||||
|
{
|
||||||
|
$enabled = $this->getSetting('radio_auto_dj_detection', '0');
|
||||||
|
|
||||||
|
if ($enabled !== '1' && ! $this->option('force')) {
|
||||||
|
$this->info('Auto DJ is uitgeschakeld. Gebruik --force om toch te draaien.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentDjId = $this->getSetting('radio_current_dj_id', '');
|
||||||
|
$currentDj = $scheduleService->getCurrentDJ($currentDjId ?: null);
|
||||||
|
|
||||||
|
$track = RadioAutoDjTrack::getNextTrack();
|
||||||
|
|
||||||
|
if (! $track) {
|
||||||
|
$this->info('Geen Auto DJ tracks in de playlist.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($currentDj !== null) {
|
||||||
|
Cache::forget('radio_auto_dj_active');
|
||||||
|
$this->info('DJ is live. Auto DJ niet actief.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache::forever('radio_auto_dj_active', [
|
||||||
|
'title' => $track->title,
|
||||||
|
'artist' => $track->artist,
|
||||||
|
'is_auto_dj' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$track->markPlayed();
|
||||||
|
|
||||||
|
$this->info("Auto DJ speelt: {$track->title}" . ($track->artist ? " by {$track->artist}" : ''));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSetting(string $key, string $default = ''): string
|
||||||
|
{
|
||||||
|
$setting = WebsiteSetting::where('key', $key)->first();
|
||||||
|
|
||||||
|
return $setting !== null && isset($setting->value) ? (string) $setting->value : $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Miscellaneous\WebsiteSetting;
|
||||||
|
use App\Models\RadioSongPlay;
|
||||||
|
use App\Services\Community\RadioStreamService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
|
||||||
|
#[AsCommand(name: 'radio:record-songs')]
|
||||||
|
final class RecordSongPlays extends Command
|
||||||
|
{
|
||||||
|
#[\Override]
|
||||||
|
protected $signature = 'radio:record-songs
|
||||||
|
{--daemon : Run continuously as a daemon}
|
||||||
|
{--interval=15 : Polling interval in seconds (daemon mode)}';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected $description = 'Poll the now-playing API and record song changes to radio_song_plays';
|
||||||
|
|
||||||
|
public function handle(RadioStreamService $streamService): int
|
||||||
|
{
|
||||||
|
if (! $this->option('daemon')) {
|
||||||
|
$this->recordOnce($streamService);
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$interval = max(5, (int) $this->option('interval'));
|
||||||
|
$this->info("Starting daemon mode (interval: {$interval}s)...");
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
$this->recordOnce($streamService);
|
||||||
|
sleep($interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordOnce(RadioStreamService $streamService): void
|
||||||
|
{
|
||||||
|
$enabled = Cache::remember('setting_radio_enabled', 60, function () {
|
||||||
|
return WebsiteSetting::where('key', 'radio_enabled')->first()?->value;
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($enabled !== '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiUrl = $this->getNowPlayingApiUrl();
|
||||||
|
|
||||||
|
if (! $apiUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nowPlaying = $streamService->getNowPlaying($apiUrl);
|
||||||
|
|
||||||
|
if (! $nowPlaying || empty($nowPlaying['song'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = $nowPlaying['song'];
|
||||||
|
$artist = $nowPlaying['artist'] ?? null;
|
||||||
|
|
||||||
|
$recorded = RadioSongPlay::recordNowPlaying($title, $artist, [
|
||||||
|
'artwork_url' => $nowPlaying['artwork'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($recorded) {
|
||||||
|
$this->info("Recorded: {$title}" . ($artist ? " by {$artist}" : ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getNowPlayingApiUrl(): ?string
|
||||||
|
{
|
||||||
|
$enabled = Cache::remember('setting_radio_now_playing_enabled', 60, function () {
|
||||||
|
return WebsiteSetting::where('key', 'radio_now_playing_enabled')->first()?->value;
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($enabled !== '1') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiUrl = Cache::remember('setting_radio_now_playing_api_url', 60, function () {
|
||||||
|
return WebsiteSetting::where('key', 'radio_now_playing_api_url')->first()?->value;
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($apiUrl) {
|
||||||
|
return $apiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
$azureCastUrl = Cache::remember('setting_radio_azurecast_base_url', 60, function () {
|
||||||
|
return WebsiteSetting::where('key', 'radio_azurecast_base_url')->first()?->value;
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($azureCastUrl) {
|
||||||
|
$stationId = Cache::remember('setting_radio_azurecast_station_id', 60, function () {
|
||||||
|
return WebsiteSetting::where('key', 'radio_azurecast_station_id')->first()?->value ?? '1';
|
||||||
|
});
|
||||||
|
|
||||||
|
return rtrim($azureCastUrl, '/') . '/api/nowplaying/' . $stationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ class Kernel extends ConsoleKernel
|
|||||||
protected function schedule(Schedule $schedule): void
|
protected function schedule(Schedule $schedule): void
|
||||||
{
|
{
|
||||||
$schedule->command('radio:check-dj')->everyMinute()->withoutOverlapping();
|
$schedule->command('radio:check-dj')->everyMinute()->withoutOverlapping();
|
||||||
|
$schedule->command('radio:record-songs')->everyFifteenSeconds()->withoutOverlapping();
|
||||||
|
$schedule->command('radio:auto-dj')->everyMinute()->withoutOverlapping();
|
||||||
$schedule->command('maintenance:check-scheduled')->everyMinute()->withoutOverlapping();
|
$schedule->command('maintenance:check-scheduled')->everyMinute()->withoutOverlapping();
|
||||||
$schedule->command('monitor:emulator')->everyMinute()->withoutOverlapping();
|
$schedule->command('monitor:emulator')->everyMinute()->withoutOverlapping();
|
||||||
$schedule->command('monitor:ddos')->everyFiveMinutes()->withoutOverlapping();
|
$schedule->command('monitor:ddos')->everyFiveMinutes()->withoutOverlapping();
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ enum RadioSettings: string
|
|||||||
case WidgetEnabled = 'radio_widget_enabled';
|
case WidgetEnabled = 'radio_widget_enabled';
|
||||||
case WidgetShowGlobally = 'radio_widget_show_globally';
|
case WidgetShowGlobally = 'radio_widget_show_globally';
|
||||||
case WidgetPosition = 'radio_widget_position';
|
case WidgetPosition = 'radio_widget_position';
|
||||||
|
case EmbedEnabled = 'radio_embed_enabled';
|
||||||
|
case EmbedAllowedDomains = 'radio_embed_allowed_domains';
|
||||||
|
case EmbedTheme = 'radio_embed_theme';
|
||||||
|
case EmbedHeight = 'radio_embed_height';
|
||||||
|
case EmbedWidth = 'radio_embed_width';
|
||||||
|
case EmbedAutoPlay = 'radio_embed_auto_play';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
@@ -40,6 +46,12 @@ enum RadioSettings: string
|
|||||||
self::WidgetEnabled => 'Widget Enabled',
|
self::WidgetEnabled => 'Widget Enabled',
|
||||||
self::WidgetShowGlobally => 'Widget Show Globally',
|
self::WidgetShowGlobally => 'Widget Show Globally',
|
||||||
self::WidgetPosition => 'Widget Position',
|
self::WidgetPosition => 'Widget Position',
|
||||||
|
self::EmbedEnabled => 'Embed Enabled',
|
||||||
|
self::EmbedAllowedDomains => 'Embed Allowed Domains',
|
||||||
|
self::EmbedTheme => 'Embed Theme',
|
||||||
|
self::EmbedHeight => 'Embed Height',
|
||||||
|
self::EmbedWidth => 'Embed Width',
|
||||||
|
self::EmbedAutoPlay => 'Embed Auto Play',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Radio;
|
||||||
|
|
||||||
|
use App\Models\RadioApiKey;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Components\Tabs;
|
||||||
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
|
final class ApiKeys extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-key';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static ?string $navigationLabel = 'API Sleutels';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static ?string $title = 'Radio API Sleutels';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected string $view = 'filament.pages.radio.api-keys';
|
||||||
|
|
||||||
|
public ?string $newKey = null;
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(RadioApiKey::query())
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->label('Naam')
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('key')
|
||||||
|
->label('API Key')
|
||||||
|
->formatStateUsing(fn ($state) => substr($state, 0, 16) . '...')
|
||||||
|
->copyable()
|
||||||
|
->copyMessage('Gekopieerd!'),
|
||||||
|
TextColumn::make('rate_limit')
|
||||||
|
->label('Rate Limit')
|
||||||
|
->badge()
|
||||||
|
->color('info'),
|
||||||
|
IconColumn::make('is_active')
|
||||||
|
->label('Actief')
|
||||||
|
->boolean()
|
||||||
|
->trueIcon('heroicon-o-check-circle')
|
||||||
|
->falseIcon('heroicon-o-x-circle')
|
||||||
|
->trueColor('success')
|
||||||
|
->falseColor('danger'),
|
||||||
|
TextColumn::make('last_used_at')
|
||||||
|
->label('Laatst gebruikt')
|
||||||
|
->dateTime('d-m-Y H:i')
|
||||||
|
->placeholder('Nooit'),
|
||||||
|
TextColumn::make('expires_at')
|
||||||
|
->label('Verloopt')
|
||||||
|
->dateTime('d-m-Y')
|
||||||
|
->placeholder('Nooit'),
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->label('Aangemaakt')
|
||||||
|
->dateTime('d-m-Y H:i'),
|
||||||
|
])
|
||||||
|
->recordActions(function ($record) {
|
||||||
|
return [
|
||||||
|
ActionGroup::make([
|
||||||
|
Action::make('toggle')
|
||||||
|
->label($record->is_active ? 'Deactiveren' : 'Activeren')
|
||||||
|
->icon($record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play')
|
||||||
|
->action(fn () => $this->toggleKey($record)),
|
||||||
|
Action::make('delete')
|
||||||
|
->label('Verwijderen')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(fn () => $record->delete()),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->toolbarActions([
|
||||||
|
Action::make('create')
|
||||||
|
->label('Nieuwe API Sleutel')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->color('primary')
|
||||||
|
->form([
|
||||||
|
TextInput::make('name')
|
||||||
|
->label('Naam')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->placeholder('Bijv. Discord Bot, Mobiele App'),
|
||||||
|
TextInput::make('allowed_ips')
|
||||||
|
->label('Toegestane IP-adressen')
|
||||||
|
->placeholder('Leeg laten voor alle IPs (bijv. 1.2.3.4, 5.6.7.8)')
|
||||||
|
->helperText('Komma-gescheiden lijst van IP-adressen. Leeg = alle IPs toegestaan.'),
|
||||||
|
Select::make('rate_limit')
|
||||||
|
->label('Rate Limit (verzoeken per minuut)')
|
||||||
|
->options([
|
||||||
|
60 => '60/min',
|
||||||
|
100 => '100/min',
|
||||||
|
300 => '300/min',
|
||||||
|
500 => '500/min',
|
||||||
|
1000 => '1000/min',
|
||||||
|
])
|
||||||
|
->default(300),
|
||||||
|
Select::make('permissions')
|
||||||
|
->label('Permissies')
|
||||||
|
->multiple()
|
||||||
|
->options([
|
||||||
|
'*' => 'Alle permissies',
|
||||||
|
'now-playing' => 'Nu Afspelen',
|
||||||
|
'listeners' => 'Luisteraars',
|
||||||
|
'current-dj' => 'Huidige DJ',
|
||||||
|
'config' => 'Configuratie',
|
||||||
|
'shouts' => 'Shouts',
|
||||||
|
'points' => 'Punten',
|
||||||
|
'requests' => 'Song Requests',
|
||||||
|
])
|
||||||
|
->default(['*']),
|
||||||
|
Toggle::make('set_expiration')
|
||||||
|
->label('Vervaldatum instellen')
|
||||||
|
->live(),
|
||||||
|
TextInput::make('expires_at')
|
||||||
|
->label('Vervaldatum')
|
||||||
|
->type('date')
|
||||||
|
->visible(fn ($get) => $get('set_expiration')),
|
||||||
|
])
|
||||||
|
->action(function (array $data) {
|
||||||
|
$this->createKey($data);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createKey(array $data): void
|
||||||
|
{
|
||||||
|
$key = RadioApiKey::generate($data['name'], [
|
||||||
|
'allowed_ips' => $data['allowed_ips'] ?? null,
|
||||||
|
'permissions' => $data['permissions'] ?? ['*'],
|
||||||
|
'rate_limit' => $data['rate_limit'] ?? 300,
|
||||||
|
'expires_at' => ! empty($data['set_expiration']) && ! empty($data['expires_at'])
|
||||||
|
? $data['expires_at'] : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->newKey = $key->key;
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('API Sleutel Aangemaakt!')
|
||||||
|
->body('Kopieer de sleutel nu - deze wordt niet meer getoond.')
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toggleKey(RadioApiKey $key): void
|
||||||
|
{
|
||||||
|
$key->update(['is_active' => ! $key->is_active]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title($key->is_active ? 'API Sleutel geactiveerd' : 'API Sleutel gedeactiveerd')
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNewKey(): ?string
|
||||||
|
{
|
||||||
|
$key = $this->newKey;
|
||||||
|
$this->newKey = null;
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Radio;
|
||||||
|
|
||||||
|
use App\Models\RadioAutoDjTrack;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
final class AutoDjPlaylist extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-play-circle';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static ?string $navigationLabel = 'Auto DJ Playlist';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static ?string $title = 'Auto DJ Playlist';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected string $view = 'filament.pages.radio.auto-dj-playlist';
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(RadioAutoDjTrack::query())
|
||||||
|
->defaultSort('sort_order')
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('sort_order')
|
||||||
|
->label('#')
|
||||||
|
->sortable()
|
||||||
|
->width(50),
|
||||||
|
TextColumn::make('title')
|
||||||
|
->label('Titel')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('artist')
|
||||||
|
->label('Artiest')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('duration')
|
||||||
|
->label('Duur')
|
||||||
|
->formatStateUsing(fn ($state) => $state ? gmdate('i:s', $state) : '-'),
|
||||||
|
TextColumn::make('play_count')
|
||||||
|
->label('Gespeeld')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('last_played_at')
|
||||||
|
->label('Laatst gespeeld')
|
||||||
|
->dateTime('d-m-Y H:i')
|
||||||
|
->placeholder('Nooit'),
|
||||||
|
IconColumn::make('is_active')
|
||||||
|
->label('Actief')
|
||||||
|
->boolean()
|
||||||
|
->trueIcon('heroicon-o-check-circle')
|
||||||
|
->falseIcon('heroicon-o-x-circle')
|
||||||
|
->trueColor('success')
|
||||||
|
->falseColor('danger'),
|
||||||
|
])
|
||||||
|
->recordActions(function ($record) {
|
||||||
|
return [
|
||||||
|
ActionGroup::make([
|
||||||
|
Action::make('toggle_active')
|
||||||
|
->label($record->is_active ? 'Deactiveren' : 'Activeren')
|
||||||
|
->icon($record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play')
|
||||||
|
->action(fn () => $this->toggleActive($record)),
|
||||||
|
Action::make('move_up')
|
||||||
|
->label('Omhoog')
|
||||||
|
->icon('heroicon-o-chevron-up')
|
||||||
|
->action(fn () => $this->moveUp($record)),
|
||||||
|
Action::make('move_down')
|
||||||
|
->label('Omlaag')
|
||||||
|
->icon('heroicon-o-chevron-down')
|
||||||
|
->action(fn () => $this->moveDown($record)),
|
||||||
|
Action::make('delete')
|
||||||
|
->label('Verwijderen')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(fn () => $record->delete()),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->toolbarActions([
|
||||||
|
Action::make('create')
|
||||||
|
->label('Track Toevoegen')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->color('primary')
|
||||||
|
->form([
|
||||||
|
TextInput::make('title')
|
||||||
|
->label('Titel')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('artist')
|
||||||
|
->label('Artiest')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('album')
|
||||||
|
->label('Album')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('artwork_url')
|
||||||
|
->label('Afbeelding URL')
|
||||||
|
->url(),
|
||||||
|
TextInput::make('duration')
|
||||||
|
->label('Duur (seconden)')
|
||||||
|
->numeric()
|
||||||
|
->minValue(0),
|
||||||
|
Toggle::make('is_active')
|
||||||
|
->label('Actief')
|
||||||
|
->default(true),
|
||||||
|
])
|
||||||
|
->action(function (array $data) {
|
||||||
|
$this->createTrack($data);
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->poll('30s');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusBadge(): string
|
||||||
|
{
|
||||||
|
$autoDj = cache('radio_auto_dj_active');
|
||||||
|
|
||||||
|
if ($autoDj) {
|
||||||
|
return 'Auto DJ is actief - speelt: ' . ($autoDj['title'] ?? 'onbekend');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Auto DJ is niet actief';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTrack(array $data): void
|
||||||
|
{
|
||||||
|
$maxOrder = RadioAutoDjTrack::max('sort_order') ?? 0;
|
||||||
|
|
||||||
|
RadioAutoDjTrack::create([
|
||||||
|
'title' => $data['title'],
|
||||||
|
'artist' => $data['artist'] ?? null,
|
||||||
|
'album' => $data['album'] ?? null,
|
||||||
|
'artwork_url' => $data['artwork_url'] ?? null,
|
||||||
|
'duration' => $data['duration'] ? (int) $data['duration'] : null,
|
||||||
|
'is_active' => $data['is_active'] ?? true,
|
||||||
|
'sort_order' => $maxOrder + 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('Track toegevoegd aan playlist')
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toggleActive(RadioAutoDjTrack $track): void
|
||||||
|
{
|
||||||
|
$track->update(['is_active' => ! $track->is_active]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title($track->is_active ? 'Track geactiveerd' : 'Track gedeactiveerd')
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function moveUp(RadioAutoDjTrack $track): void
|
||||||
|
{
|
||||||
|
$prev = RadioAutoDjTrack::where('sort_order', '<', $track->sort_order)
|
||||||
|
->orderBy('sort_order', 'desc')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($prev) {
|
||||||
|
$temp = $track->sort_order;
|
||||||
|
$track->update(['sort_order' => $prev->sort_order]);
|
||||||
|
$prev->update(['sort_order' => $temp]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function moveDown(RadioAutoDjTrack $track): void
|
||||||
|
{
|
||||||
|
$next = RadioAutoDjTrack::where('sort_order', '>', $track->sort_order)
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($next) {
|
||||||
|
$temp = $track->sort_order;
|
||||||
|
$track->update(['sort_order' => $next->sort_order]);
|
||||||
|
$next->update(['sort_order' => $temp]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Radio;
|
||||||
|
|
||||||
|
use App\Models\RadioApplication;
|
||||||
|
use App\Models\RadioShout;
|
||||||
|
use App\Models\RadioSongRequest;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
final class DjModeration extends Page
|
||||||
|
{
|
||||||
|
#[\Override]
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static ?string $navigationLabel = 'Moderatie';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static ?string $title = 'DJ Moderatie';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected string $view = 'filament.pages.radio.dj-moderation';
|
||||||
|
|
||||||
|
public function getShouts()
|
||||||
|
{
|
||||||
|
return RadioShout::with('user')->latest()->paginate(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPendingShouts()
|
||||||
|
{
|
||||||
|
return RadioShout::with('user')->where('approved', false)->latest()->paginate(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReportedShouts()
|
||||||
|
{
|
||||||
|
return RadioShout::with('user')->where('reported', true)->latest()->paginate(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequests()
|
||||||
|
{
|
||||||
|
return RadioSongRequest::with('user')->latest('submitted_at')->paginate(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPendingRequests()
|
||||||
|
{
|
||||||
|
return RadioSongRequest::with('user')->where('is_approved', false)->where('is_played', false)->latest('submitted_at')->paginate(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getApplications()
|
||||||
|
{
|
||||||
|
return RadioApplication::with(['user', 'rank'])->latest()->paginate(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPendingApplications()
|
||||||
|
{
|
||||||
|
return RadioApplication::with(['user', 'rank'])->where('status', 'pending')->latest()->paginate(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approveShout(int $id): void
|
||||||
|
{
|
||||||
|
$shout = RadioShout::findOrFail($id);
|
||||||
|
$shout->update([
|
||||||
|
'approved' => true,
|
||||||
|
'approved_by' => auth()->id(),
|
||||||
|
'approved_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()->success()->title('Shout goedgekeurd')->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dismissShoutReport(int $id): void
|
||||||
|
{
|
||||||
|
$shout = RadioShout::findOrFail($id);
|
||||||
|
$shout->update(['reported' => false, 'reported_by' => null]);
|
||||||
|
|
||||||
|
Notification::make()->success()->title('Melding genegeerd')->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteShout(int $id): void
|
||||||
|
{
|
||||||
|
RadioShout::findOrFail($id)->delete();
|
||||||
|
|
||||||
|
Notification::make()->success()->title('Shout verwijderd')->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approveRequest(int $id): void
|
||||||
|
{
|
||||||
|
$request = RadioSongRequest::findOrFail($id);
|
||||||
|
$request->approve();
|
||||||
|
|
||||||
|
Notification::make()->success()->title('Verzoek goedgekeurd')->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markRequestPlayed(int $id): void
|
||||||
|
{
|
||||||
|
$request = RadioSongRequest::findOrFail($id);
|
||||||
|
$request->markAsPlayed();
|
||||||
|
|
||||||
|
Notification::make()->success()->title('Verzoek gemarkeerd als gespeeld')->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rejectRequest(int $id): void
|
||||||
|
{
|
||||||
|
$request = RadioSongRequest::findOrFail($id);
|
||||||
|
$request->update(['is_approved' => false]);
|
||||||
|
|
||||||
|
Notification::make()->success()->title('Verzoek afgewezen')->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approveApplication(int $id): void
|
||||||
|
{
|
||||||
|
$application = RadioApplication::findOrFail($id);
|
||||||
|
$application->update([
|
||||||
|
'status' => 'approved',
|
||||||
|
'approved_by' => auth()->id(),
|
||||||
|
'approved_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()->success()->title('Aanmelding goedgekeurd')->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rejectApplication(int $id): void
|
||||||
|
{
|
||||||
|
$application = RadioApplication::findOrFail($id);
|
||||||
|
$application->update([
|
||||||
|
'status' => 'rejected',
|
||||||
|
'rejected_by' => auth()->id(),
|
||||||
|
'rejected_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()->success()->title('Aanmelding afgewezen')->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Radio;
|
||||||
|
|
||||||
|
use App\Models\Miscellaneous\WebsiteSetting;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
final class EmbedCode extends Page implements HasForms
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-code-bracket';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static ?string $navigationLabel = 'Embed Code';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static ?string $title = 'Radio Embed Code';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected string $view = 'filament.pages.radio.embed-code';
|
||||||
|
|
||||||
|
public array $data = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->form->fill([
|
||||||
|
'embed_theme' => $this->getSetting('radio_embed_theme', 'dark'),
|
||||||
|
'embed_height' => $this->getSetting('radio_embed_height', '400'),
|
||||||
|
'embed_width' => $this->getSetting('radio_embed_width', '100%'),
|
||||||
|
'embed_auto_play' => $this->getSetting('radio_embed_auto_play', '0'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Section::make('Embed Configuratie')
|
||||||
|
->description('Pas het uiterlijk van de embed player aan')
|
||||||
|
->schema([
|
||||||
|
Select::make('embed_theme')
|
||||||
|
->label('Thema')
|
||||||
|
->options([
|
||||||
|
'dark' => 'Donker',
|
||||||
|
'light' => 'Licht',
|
||||||
|
'transparent' => 'Transparant',
|
||||||
|
])
|
||||||
|
->default('dark'),
|
||||||
|
TextInput::make('embed_height')
|
||||||
|
->label('Hoogte (px)')
|
||||||
|
->numeric()
|
||||||
|
->default(400),
|
||||||
|
TextInput::make('embed_width')
|
||||||
|
->label('Breedte')
|
||||||
|
->placeholder('100% of 400px')
|
||||||
|
->default('100%'),
|
||||||
|
Toggle::make('embed_auto_play')
|
||||||
|
->label('Auto-Play'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIframeCode(): string
|
||||||
|
{
|
||||||
|
$theme = $this->data['embed_theme'] ?? 'dark';
|
||||||
|
$height = $this->data['embed_height'] ?? '400';
|
||||||
|
$width = $this->data['embed_width'] ?? '100%';
|
||||||
|
$autoPlay = ($this->data['embed_auto_play'] ?? false) ? '&autoplay=1' : '';
|
||||||
|
$url = route('radio.embed', ['theme' => $theme]) . $autoPlay;
|
||||||
|
|
||||||
|
return '<iframe src="' . e($url) . '" width="' . e($width) . '" height="' . e($height) . '" frameborder="0" allow="autoplay" style="border-radius: 12px;"></iframe>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getJsSnippet(): string
|
||||||
|
{
|
||||||
|
$theme = $this->data['embed_theme'] ?? 'dark';
|
||||||
|
$autoPlay = ($this->data['embed_auto_play'] ?? false) ? '&autoplay=1' : '';
|
||||||
|
$url = route('radio.embed', ['theme' => $theme]) . $autoPlay;
|
||||||
|
|
||||||
|
return '<div id="radio-embed"></div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var iframe = document.createElement(\'iframe\');
|
||||||
|
iframe.src = \'' . e($url) . '\';
|
||||||
|
iframe.width = \'100%\';
|
||||||
|
iframe.height = \'400\';
|
||||||
|
iframe.frameBorder = \'0\';
|
||||||
|
iframe.style.borderRadius = \'12px\';
|
||||||
|
iframe.allow = \'autoplay\';
|
||||||
|
document.getElementById(\'radio-embed\').appendChild(iframe);
|
||||||
|
})();
|
||||||
|
</script>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDirectUrl(): string
|
||||||
|
{
|
||||||
|
$theme = $this->data['embed_theme'] ?? 'dark';
|
||||||
|
$autoPlay = ($this->data['embed_auto_play'] ?? false) ? '&autoplay=1' : '';
|
||||||
|
|
||||||
|
return route('radio.embed', ['theme' => $theme]) . $autoPlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSetting(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$setting = WebsiteSetting::where('key', $key)->first();
|
||||||
|
|
||||||
|
return $setting?->value ?? $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,12 @@ final class RadioSettings extends Page implements HasForms
|
|||||||
'radio_widget_enabled' => $this->getSettingBool('radio_widget_enabled'),
|
'radio_widget_enabled' => $this->getSettingBool('radio_widget_enabled'),
|
||||||
'radio_widget_show_globally' => $this->getSettingBool('radio_widget_show_globally'),
|
'radio_widget_show_globally' => $this->getSettingBool('radio_widget_show_globally'),
|
||||||
'radio_widget_position' => $this->getSetting('radio_widget_position', 'bottom-right'),
|
'radio_widget_position' => $this->getSetting('radio_widget_position', 'bottom-right'),
|
||||||
|
'radio_embed_enabled' => $this->getSettingBool('radio_embed_enabled'),
|
||||||
|
'radio_embed_allowed_domains' => $this->getSetting('radio_embed_allowed_domains', ''),
|
||||||
|
'radio_embed_theme' => $this->getSetting('radio_embed_theme', 'dark'),
|
||||||
|
'radio_embed_height' => (int) $this->getSetting('radio_embed_height', '400'),
|
||||||
|
'radio_embed_width' => $this->getSetting('radio_embed_width', '100%'),
|
||||||
|
'radio_embed_auto_play' => $this->getSettingBool('radio_embed_auto_play'),
|
||||||
'radio_shouts_enabled' => $this->getSettingBool('radio_shouts_enabled'),
|
'radio_shouts_enabled' => $this->getSettingBool('radio_shouts_enabled'),
|
||||||
'radio_shouts_max_length' => (int) $this->getSetting('radio_shouts_max_length', '280'),
|
'radio_shouts_max_length' => (int) $this->getSetting('radio_shouts_max_length', '280'),
|
||||||
'radio_shouts_cooldown' => (int) $this->getSetting('radio_shouts_cooldown', '30'),
|
'radio_shouts_cooldown' => (int) $this->getSetting('radio_shouts_cooldown', '30'),
|
||||||
@@ -626,6 +632,32 @@ final class RadioSettings extends Page implements HasForms
|
|||||||
'top-left' => 'Links Boven',
|
'top-left' => 'Links Boven',
|
||||||
])
|
])
|
||||||
->default('bottom-right'),
|
->default('bottom-right'),
|
||||||
|
Toggle::make('radio_embed_enabled')
|
||||||
|
->label('Externe Embed Inschakelen')
|
||||||
|
->columnSpanFull()
|
||||||
|
->helperText('Sta toe dat de radio als iframe op externe sites wordt geplaatst.'),
|
||||||
|
TextInput::make('radio_embed_allowed_domains')
|
||||||
|
->label('Toegestane Domeinen')
|
||||||
|
->placeholder('voorbeeld.nl, anderedomein.com')
|
||||||
|
->helperText('Komma-gescheiden lijst. Leeg = alle domeinen toegestaan.'),
|
||||||
|
Select::make('radio_embed_theme')
|
||||||
|
->label('Embed Thema')
|
||||||
|
->options([
|
||||||
|
'dark' => 'Donker',
|
||||||
|
'light' => 'Licht',
|
||||||
|
'transparent' => 'Transparant',
|
||||||
|
])
|
||||||
|
->default('dark'),
|
||||||
|
TextInput::make('radio_embed_height')
|
||||||
|
->label('Embed Hoogte (px)')
|
||||||
|
->numeric()
|
||||||
|
->default(400),
|
||||||
|
TextInput::make('radio_embed_width')
|
||||||
|
->label('Embed Breedte')
|
||||||
|
->placeholder('100% of 400px')
|
||||||
|
->default('100%'),
|
||||||
|
Toggle::make('radio_embed_auto_play')
|
||||||
|
->label('Auto-Play in Embed'),
|
||||||
]),
|
]),
|
||||||
Section::make('Offline Pagina')
|
Section::make('Offline Pagina')
|
||||||
->description('Instellingen voor wanneer de radio offline is')
|
->description('Instellingen voor wanneer de radio offline is')
|
||||||
@@ -890,6 +922,9 @@ final class RadioSettings extends Page implements HasForms
|
|||||||
$this->saveSettings([
|
$this->saveSettings([
|
||||||
'radio_widget_enabled', 'radio_widget_show_globally',
|
'radio_widget_enabled', 'radio_widget_show_globally',
|
||||||
'radio_widget_position',
|
'radio_widget_position',
|
||||||
|
'radio_embed_enabled', 'radio_embed_allowed_domains',
|
||||||
|
'radio_embed_theme', 'radio_embed_height',
|
||||||
|
'radio_embed_width', 'radio_embed_auto_play',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1475,7 +1510,8 @@ final class RadioSettings extends Page implements HasForms
|
|||||||
'radio_stats_show_weekly', 'radio_stats_show_monthly',
|
'radio_stats_show_weekly', 'radio_stats_show_monthly',
|
||||||
'radio_stats_show_top_djs', 'radio_stats_show_top_songs',
|
'radio_stats_show_top_djs', 'radio_stats_show_top_songs',
|
||||||
'radio_show_offline_message', 'radio_widget_enabled',
|
'radio_show_offline_message', 'radio_widget_enabled',
|
||||||
'radio_widget_show_globally', 'radio_applications_enabled',
|
'radio_widget_show_globally', 'radio_embed_enabled',
|
||||||
|
'radio_embed_auto_play', 'radio_applications_enabled',
|
||||||
'radio_applications_require_approval', 'radio_discord_enabled',
|
'radio_applications_require_approval', 'radio_discord_enabled',
|
||||||
'radio_discord_dj_live', 'radio_discord_song_changes',
|
'radio_discord_dj_live', 'radio_discord_song_changes',
|
||||||
'radio_listener_alerts_enabled', 'radio_azurecast_use_proxy',
|
'radio_listener_alerts_enabled', 'radio_azurecast_use_proxy',
|
||||||
@@ -1488,6 +1524,7 @@ final class RadioSettings extends Page implements HasForms
|
|||||||
'radio_dj_avatar_size', 'radio_dj_detection_interval',
|
'radio_dj_avatar_size', 'radio_dj_detection_interval',
|
||||||
'radio_shouts_max_length', 'radio_shouts_cooldown',
|
'radio_shouts_max_length', 'radio_shouts_cooldown',
|
||||||
'radio_chat_width', 'radio_chat_height', 'radio_chat_messages_count',
|
'radio_chat_width', 'radio_chat_height', 'radio_chat_messages_count',
|
||||||
|
'radio_embed_height',
|
||||||
'radio_request_max_per_user', 'radio_request_cooldown',
|
'radio_request_max_per_user', 'radio_request_cooldown',
|
||||||
'radio_min_listeners_threshold', 'radio_listener_alert_threshold_high',
|
'radio_min_listeners_threshold', 'radio_listener_alert_threshold_high',
|
||||||
'radio_listener_alert_threshold_low', 'radio_applications_max_per_day',
|
'radio_listener_alert_threshold_low', 'radio_applications_max_per_day',
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\RadioSongPlay\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\RadioSongPlay\RadioSongPlayResource;
|
||||||
|
use Filament\Resources\Pages\ManageRecords;
|
||||||
|
|
||||||
|
class ManageRadioSongPlays extends ManageRecords
|
||||||
|
{
|
||||||
|
#[\Override]
|
||||||
|
protected static string $resource = RadioSongPlayResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\RadioSongPlay;
|
||||||
|
|
||||||
|
use App\Filament\Resources\RadioSongPlay\Pages\ManageRadioSongPlays;
|
||||||
|
use App\Models\RadioSongPlay;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class RadioSongPlayResource extends Resource
|
||||||
|
{
|
||||||
|
#[\Override]
|
||||||
|
protected static ?string $model = RadioSongPlay::class;
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-musical-note';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected static ?string $slug = 'radio/song-plays';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return 'Song Geschiedenis';
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
public static function getLabel(): string
|
||||||
|
{
|
||||||
|
return 'Song Play';
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
public static function getPluralLabel(): string
|
||||||
|
{
|
||||||
|
return 'Song Geschiedenis';
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(RadioSongPlay::query())
|
||||||
|
->defaultSort('played_at', 'desc')
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('title')
|
||||||
|
->label('Titel')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('artist')
|
||||||
|
->label('Artiest')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('played_at')
|
||||||
|
->label('Gespeeld op')
|
||||||
|
->dateTime('d-m-Y H:i:s')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('duration')
|
||||||
|
->label('Duur')
|
||||||
|
->formatStateUsing(fn ($state) => $state ? gmdate('i:s', $state) : '-')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('artist')
|
||||||
|
->label('Artiest')
|
||||||
|
->options(fn () => RadioSongPlay::distinct()->whereNotNull('artist')->pluck('artist', 'artist')->toArray())
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
DeleteAction::make()
|
||||||
|
->label('Verwijderen'),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
DeleteBulkAction::make()
|
||||||
|
->label('Selectie verwijderen'),
|
||||||
|
])
|
||||||
|
->poll('30s');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ManageRadioSongPlays::route('/'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,6 +167,18 @@ class RadioController extends Controller
|
|||||||
|
|
||||||
public function nowPlaying(): JsonResponse
|
public function nowPlaying(): JsonResponse
|
||||||
{
|
{
|
||||||
|
$autoDj = Cache::get('radio_auto_dj_active');
|
||||||
|
|
||||||
|
if ($autoDj !== null) {
|
||||||
|
return response()->json([
|
||||||
|
'enabled' => true,
|
||||||
|
'song' => $autoDj['title'],
|
||||||
|
'artist' => $autoDj['artist'] ?? null,
|
||||||
|
'title' => $autoDj['title'],
|
||||||
|
'is_auto_dj' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$nowPlaying = Cache::remember('radio_nowplaying', 10, function () {
|
$nowPlaying = Cache::remember('radio_nowplaying', 10, function () {
|
||||||
$apiUrl = $this->getSetting(RadioSettings::NowPlayingEnabled)
|
$apiUrl = $this->getSetting(RadioSettings::NowPlayingEnabled)
|
||||||
? ($this->getSetting(RadioSettings::NowPlayingApiUrl) ?: $this->streamService->getAzureCastApiUrl())
|
? ($this->getSetting(RadioSettings::NowPlayingApiUrl) ?: $this->streamService->getAzureCastApiUrl())
|
||||||
@@ -175,6 +187,8 @@ class RadioController extends Controller
|
|||||||
return $apiUrl ? $this->streamService->getNowPlaying($apiUrl) : ['enabled' => false, 'song' => null];
|
return $apiUrl ? $this->streamService->getNowPlaying($apiUrl) : ['enabled' => false, 'song' => null];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$nowPlaying['is_auto_dj'] = false;
|
||||||
|
|
||||||
return response()->json($nowPlaying);
|
return response()->json($nowPlaying);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,9 +209,13 @@ class RadioController extends Controller
|
|||||||
{
|
{
|
||||||
$dj = $this->scheduleService->getCurrentDJ($this->getSetting(RadioSettings::CurrentDjId));
|
$dj = $this->scheduleService->getCurrentDJ($this->getSetting(RadioSettings::CurrentDjId));
|
||||||
|
|
||||||
|
$autoDj = Cache::get('radio_auto_dj_active');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'dj' => $dj,
|
'dj' => $dj,
|
||||||
'is_live' => $dj !== null,
|
'is_live' => $dj !== null,
|
||||||
|
'is_auto_dj' => $autoDj !== null,
|
||||||
|
'auto_dj_song' => $autoDj['title'] ?? null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +236,8 @@ class RadioController extends Controller
|
|||||||
$streamUrl = $this->streamService->formatStreamUrl($settings[RadioSettings::StreamUrl->value] ?? '');
|
$streamUrl = $this->streamService->formatStreamUrl($settings[RadioSettings::StreamUrl->value] ?? '');
|
||||||
$azureCast = $this->streamService->detectAzureCast();
|
$azureCast = $this->streamService->detectAzureCast();
|
||||||
|
|
||||||
|
$autoDj = Cache::get('radio_auto_dj_active');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'enabled' => (bool) ($settings[RadioSettings::Enabled->value] ?? false),
|
'enabled' => (bool) ($settings[RadioSettings::Enabled->value] ?? false),
|
||||||
'stream_url' => $streamUrl,
|
'stream_url' => $streamUrl,
|
||||||
@@ -231,6 +251,8 @@ class RadioController extends Controller
|
|||||||
'widget_position' => $settings[RadioSettings::WidgetPosition->value] ?? 'bottom-right',
|
'widget_position' => $settings[RadioSettings::WidgetPosition->value] ?? 'bottom-right',
|
||||||
'is_azurecast' => $azureCast['detected'],
|
'is_azurecast' => $azureCast['detected'],
|
||||||
'azurecast_detected' => $azureCast['detected'],
|
'azurecast_detected' => $azureCast['detected'],
|
||||||
|
'is_auto_dj' => $autoDj !== null,
|
||||||
|
'auto_dj_song' => $autoDj['title'] ?? null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Radio;
|
||||||
|
|
||||||
|
use App\Enums\RadioSettings;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Miscellaneous\WebsiteSetting;
|
||||||
|
use App\Services\Community\RadioStreamService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class EmbedController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly RadioStreamService $streamService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(Request $request): View
|
||||||
|
{
|
||||||
|
$theme = $request->query('theme', $this->getSetting(RadioSettings::EmbedTheme, 'dark'));
|
||||||
|
|
||||||
|
$settings = [
|
||||||
|
'streamUrl' => $this->streamService->formatStreamUrl($this->getSetting(RadioSettings::StreamUrl, '')),
|
||||||
|
'theme' => in_array($theme, ['dark', 'light', 'transparent']) ? $theme : 'dark',
|
||||||
|
'autoPlay' => $request->query('autoplay', $this->getSetting(RadioSettings::EmbedAutoPlay, '0')) === '1',
|
||||||
|
'primaryColor' => $this->getSetting('radio_player_color_primary', '#eeb425'),
|
||||||
|
'secondaryColor' => $this->getSetting('radio_player_color_secondary', '#1a1a2e'),
|
||||||
|
'textColor' => $this->getSetting('radio_player_color_text', '#ffffff'),
|
||||||
|
'accentColor' => $this->getSetting('radio_player_color_accent', '#eeb425'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('radio.embed', compact('settings'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function config(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'enabled' => (bool) $this->getSetting(RadioSettings::EmbedEnabled, '0'),
|
||||||
|
'stream_url' => $this->streamService->formatStreamUrl($this->getSetting(RadioSettings::StreamUrl, '')),
|
||||||
|
'theme' => $this->getSetting(RadioSettings::EmbedTheme, 'dark'),
|
||||||
|
'auto_play' => (bool) $this->getSetting(RadioSettings::EmbedAutoPlay, '0'),
|
||||||
|
'primary_color' => $this->getSetting('radio_player_color_primary', '#eeb425'),
|
||||||
|
'secondary_color' => $this->getSetting('radio_player_color_secondary', '#1a1a2e'),
|
||||||
|
'text_color' => $this->getSetting('radio_player_color_text', '#ffffff'),
|
||||||
|
'accent_color' => $this->getSetting('radio_player_color_accent', '#eeb425'),
|
||||||
|
'height' => (int) $this->getSetting(RadioSettings::EmbedHeight, '400'),
|
||||||
|
'width' => $this->getSetting(RadioSettings::EmbedWidth, '100%'),
|
||||||
|
'allowed_domains' => $this->getSetting(RadioSettings::EmbedAllowedDomains, ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSetting(RadioSettings|string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$keyStr = $key instanceof RadioSettings ? $key->value : $key;
|
||||||
|
|
||||||
|
return Cache::remember("setting_{$keyStr}", 60, function () use ($keyStr, $default): mixed {
|
||||||
|
$websiteSetting = WebsiteSetting::where('key', $keyStr)->first();
|
||||||
|
|
||||||
|
return $websiteSetting?->value ?? $default;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Radio;
|
||||||
|
|
||||||
|
use App\Enums\RadioSettings;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Miscellaneous\WebsiteSetting;
|
||||||
|
use App\Services\Community\RadioScheduleService;
|
||||||
|
use App\Services\Community\RadioStreamService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class SseController extends Controller
|
||||||
|
{
|
||||||
|
private const int SSE_KEEPALIVE = 15;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly RadioStreamService $streamService,
|
||||||
|
private readonly RadioScheduleService $scheduleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function stream(Request $request): StreamedResponse
|
||||||
|
{
|
||||||
|
$channels = $request->query('channels', 'now-playing,listeners,dj');
|
||||||
|
|
||||||
|
$wanted = array_flip(array_map('trim', explode(',', $channels)));
|
||||||
|
|
||||||
|
$response = new StreamedResponse(function () use ($wanted): void {
|
||||||
|
$last = [
|
||||||
|
'now-playing' => null,
|
||||||
|
'listeners' => null,
|
||||||
|
'dj' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->sendSseComment('connected');
|
||||||
|
|
||||||
|
while (! connection_aborted()) {
|
||||||
|
$sent = false;
|
||||||
|
|
||||||
|
if (isset($wanted['now-playing'])) {
|
||||||
|
$data = $this->getNowPlaying();
|
||||||
|
if ($data !== $last['now-playing']) {
|
||||||
|
$this->sendSseEvent('now-playing', $data);
|
||||||
|
$last['now-playing'] = $data;
|
||||||
|
$sent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($wanted['listeners'])) {
|
||||||
|
$count = $this->getListeners();
|
||||||
|
if ($count !== $last['listeners']) {
|
||||||
|
$this->sendSseEvent('listeners', ['count' => $count]);
|
||||||
|
$last['listeners'] = $count;
|
||||||
|
$sent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($wanted['dj'])) {
|
||||||
|
$dj = $this->getCurrentDj();
|
||||||
|
if ($dj !== $last['dj']) {
|
||||||
|
$this->sendSseEvent('dj', $dj);
|
||||||
|
$last['dj'] = $dj;
|
||||||
|
$sent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $sent) {
|
||||||
|
$this->sendSseComment('keepalive');
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_flush();
|
||||||
|
flush();
|
||||||
|
|
||||||
|
sleep(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$response->headers->set('Content-Type', 'text/event-stream');
|
||||||
|
$response->headers->set('Cache-Control', 'no-cache');
|
||||||
|
$response->headers->set('Connection', 'keep-alive');
|
||||||
|
$response->headers->set('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendSseEvent(string $event, mixed $data): void
|
||||||
|
{
|
||||||
|
echo "event: {$event}\n";
|
||||||
|
echo 'data: ' . json_encode($data) . "\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendSseComment(string $comment): void
|
||||||
|
{
|
||||||
|
echo ": {$comment}\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getNowPlaying(): ?array
|
||||||
|
{
|
||||||
|
$autoDj = Cache::get('radio_auto_dj_active');
|
||||||
|
|
||||||
|
if ($autoDj !== null) {
|
||||||
|
return [
|
||||||
|
'enabled' => true,
|
||||||
|
'song' => $autoDj['title'],
|
||||||
|
'artist' => $autoDj['artist'] ?? null,
|
||||||
|
'title' => $autoDj['title'],
|
||||||
|
'is_auto_dj' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cached = Cache::get('radio_nowplaying');
|
||||||
|
|
||||||
|
if ($cached !== null) {
|
||||||
|
$cached['is_auto_dj'] = false;
|
||||||
|
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiUrl = $this->getSetting(RadioSettings::NowPlayingEnabled)
|
||||||
|
? ($this->getSetting(RadioSettings::NowPlayingApiUrl) ?: $this->getAzureCastApiUrl())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$result = $apiUrl ? $this->streamService->getNowPlaying($apiUrl) : ['enabled' => false, 'song' => null];
|
||||||
|
|
||||||
|
$result['is_auto_dj'] = false;
|
||||||
|
|
||||||
|
Cache::put('radio_nowplaying', $result, 10);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getListeners(): int
|
||||||
|
{
|
||||||
|
return Cache::remember('radio_listeners', 30, function () {
|
||||||
|
$apiUrl = $this->getSetting(RadioSettings::ListenersEnabled)
|
||||||
|
? ($this->getSetting(RadioSettings::ListenersApiUrl) ?: $this->getAzureCastApiUrl())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return $apiUrl ? $this->streamService->getListenersCount($apiUrl) : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCurrentDj(): ?array
|
||||||
|
{
|
||||||
|
$autoDj = Cache::get('radio_auto_dj_active');
|
||||||
|
|
||||||
|
$dj = $this->scheduleService->getCurrentDJ(
|
||||||
|
$this->getSetting(RadioSettings::CurrentDjId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dj === null && $autoDj !== null) {
|
||||||
|
return [
|
||||||
|
'username' => 'Auto DJ',
|
||||||
|
'look' => null,
|
||||||
|
'show_name' => 'Auto DJ',
|
||||||
|
'is_auto_dj' => true,
|
||||||
|
'song' => $autoDj['title'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dj !== null) {
|
||||||
|
$dj['is_auto_dj'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dj;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAzureCastApiUrl(): ?string
|
||||||
|
{
|
||||||
|
$baseUrl = $this->getSetting(RadioSettings::AzureCastBaseUrl);
|
||||||
|
$stationId = (int) $this->getSetting(RadioSettings::AzureCastStationId, '1');
|
||||||
|
|
||||||
|
if (! $baseUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim($baseUrl, '/') . '/api/nowplaying/' . $stationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSetting(RadioSettings|string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$keyStr = $key instanceof RadioSettings ? $key->value : $key;
|
||||||
|
|
||||||
|
return Cache::remember("setting_{$keyStr}", 60, function () use ($keyStr, $default): mixed {
|
||||||
|
$setting = WebsiteSetting::where('key', $keyStr)->first();
|
||||||
|
|
||||||
|
return $setting?->value ?? $default;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace App\Http;
|
|||||||
use App\Http\Middleware\AdminSecurityMiddleware;
|
use App\Http\Middleware\AdminSecurityMiddleware;
|
||||||
use App\Http\Middleware\ApiResponseCache;
|
use App\Http\Middleware\ApiResponseCache;
|
||||||
use App\Http\Middleware\Authenticate;
|
use App\Http\Middleware\Authenticate;
|
||||||
|
use App\Http\Middleware\RadioApiKey;
|
||||||
use App\Http\Middleware\BannedMiddleware;
|
use App\Http\Middleware\BannedMiddleware;
|
||||||
use App\Http\Middleware\DDoSTrackingMiddleware;
|
use App\Http\Middleware\DDoSTrackingMiddleware;
|
||||||
use App\Http\Middleware\EncryptCookies;
|
use App\Http\Middleware\EncryptCookies;
|
||||||
@@ -120,5 +121,6 @@ class Kernel extends HttpKernel
|
|||||||
'force.staff.2fa' => ForceStaffTwoFactorMiddleware::class,
|
'force.staff.2fa' => ForceStaffTwoFactorMiddleware::class,
|
||||||
'ddos.track' => DDoSTrackingMiddleware::class,
|
'ddos.track' => DDoSTrackingMiddleware::class,
|
||||||
'admin.security' => AdminSecurityMiddleware::class,
|
'admin.security' => AdminSecurityMiddleware::class,
|
||||||
|
'radio.api' => RadioApiKey::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\RadioApiKey;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RadioApiKey
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next, string $permission = '*'): Response
|
||||||
|
{
|
||||||
|
$key = $request->bearerToken() ?? $request->query('api_key');
|
||||||
|
|
||||||
|
if (empty($key)) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'API key is verplicht. Gebruik Authorization: Bearer <key> of ?api_key=<key>',
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKey = RadioApiKey::active()->where('key', $key)->first();
|
||||||
|
|
||||||
|
if (! $apiKey) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'API key is ongeldig of verlopen',
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $apiKey->isAllowedIp($request->ip())) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'IP-adres niet toegestaan voor deze API key',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $apiKey->hasPermission($permission)) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Geen toestemming voor deze actie',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKey->touchLastUsed();
|
||||||
|
|
||||||
|
$request->merge(['radio_api_key_id' => $apiKey->id]);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class RadioApiKey extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'key',
|
||||||
|
'allowed_ips',
|
||||||
|
'permissions',
|
||||||
|
'rate_limit',
|
||||||
|
'expires_at',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'permissions' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'last_used_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generate(string $name, array $options = []): self
|
||||||
|
{
|
||||||
|
return static::create([
|
||||||
|
'name' => $name,
|
||||||
|
'key' => 'rad_' . Str::random(48),
|
||||||
|
'allowed_ips' => $options['allowed_ips'] ?? null,
|
||||||
|
'permissions' => $options['permissions'] ?? ['*'],
|
||||||
|
'rate_limit' => $options['rate_limit'] ?? 300,
|
||||||
|
'expires_at' => $options['expires_at'] ?? null,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true)
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasPermission(string $permission): bool
|
||||||
|
{
|
||||||
|
$permissions = $this->permissions ?? ['*'];
|
||||||
|
|
||||||
|
return in_array('*', $permissions, true) || in_array($permission, $permissions, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAllowedIp(?string $ip): bool
|
||||||
|
{
|
||||||
|
if (empty($this->allowed_ips)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = array_map('trim', explode(',', $this->allowed_ips));
|
||||||
|
|
||||||
|
return in_array($ip, $allowed, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function touchLastUsed(): void
|
||||||
|
{
|
||||||
|
$this->update(['last_used_at' => now()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioAutoDjTrack where($column, $operator = null, $value = null)
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioAutoDjTrack create($attributes = [])
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioAutoDjTrack active()
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioAutoDjTrack orderBy($column, $direction = 'asc')
|
||||||
|
*
|
||||||
|
* @property string $title
|
||||||
|
* @property string|null $artist
|
||||||
|
* @property string|null $album
|
||||||
|
* @property string|null $artwork_url
|
||||||
|
* @property int|null $duration
|
||||||
|
* @property int $play_count
|
||||||
|
* @property \Carbon\Carbon|null $last_played_at
|
||||||
|
* @property bool $is_active
|
||||||
|
* @property int $sort_order
|
||||||
|
*/
|
||||||
|
class RadioAutoDjTrack extends Model
|
||||||
|
{
|
||||||
|
#[\Override]
|
||||||
|
protected $table = 'radio_auto_dj_playlist';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected $guarded = ['id', 'created_at', 'updated_at'];
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected $casts = [
|
||||||
|
'duration' => 'integer',
|
||||||
|
'play_count' => 'integer',
|
||||||
|
'last_played_at' => 'datetime',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOrdered($query)
|
||||||
|
{
|
||||||
|
return $query->orderBy('sort_order')->orderBy('title');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markPlayed(): void
|
||||||
|
{
|
||||||
|
$this->increment('play_count');
|
||||||
|
$this->update(['last_played_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNextTrack(): ?self
|
||||||
|
{
|
||||||
|
$last = self::active()->orderBy('last_played_at', 'asc')->orderBy('sort_order')->first();
|
||||||
|
|
||||||
|
if ($last) {
|
||||||
|
return $last;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::active()->ordered()->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Miscellaneous\WebsiteSetting;
|
||||||
|
use App\Services\Community\RadioScheduleService;
|
||||||
|
use App\Services\Community\RadioStreamService;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioSongPlay where($column, $operator = null, $value = null)
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioSongPlay create($attributes = [])
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioSongPlay orderBy($column, $direction = 'asc')
|
||||||
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioSongPlay latest($column = 'played_at')
|
||||||
|
*
|
||||||
|
* @property string $title
|
||||||
|
* @property string|null $artist
|
||||||
|
* @property string|null $album
|
||||||
|
* @property string|null $artwork_url
|
||||||
|
* @property int|null $duration
|
||||||
|
* @property \Carbon\Carbon $played_at
|
||||||
|
* @property int|null $dj_id
|
||||||
|
* @property array|null $metadata
|
||||||
|
*/
|
||||||
|
class RadioSongPlay extends Model
|
||||||
|
{
|
||||||
|
#[\Override]
|
||||||
|
protected $table = 'radio_song_plays';
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected $guarded = ['id', 'created_at', 'updated_at'];
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
protected $casts = [
|
||||||
|
'played_at' => 'datetime',
|
||||||
|
'metadata' => 'array',
|
||||||
|
'duration' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function recordNowPlaying(?string $title, ?string $artist = null, ?array $extra = null): ?self
|
||||||
|
{
|
||||||
|
if (! $title) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$last = self::latest('played_at')->first();
|
||||||
|
|
||||||
|
if ($last && $last->title === $title && $last->artist === $artist) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$djId = WebsiteSetting::where('key', 'radio_current_dj_id')->first()?->value;
|
||||||
|
|
||||||
|
return self::create([
|
||||||
|
'title' => $title,
|
||||||
|
'artist' => $artist,
|
||||||
|
'album' => $extra['album'] ?? null,
|
||||||
|
'artwork_url' => $extra['artwork_url'] ?? null,
|
||||||
|
'duration' => $extra['duration'] ?? null,
|
||||||
|
'played_at' => $extra['played_at'] ?? now(),
|
||||||
|
'dj_id' => $djId ? (int) $djId : null,
|
||||||
|
'metadata' => $extra['metadata'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,12 @@ class RouteServiceProvider extends ServiceProvider
|
|||||||
RateLimiter::for('two-factor', fn (Request $request) => Limit::perMinute(15)->by($request->ip()));
|
RateLimiter::for('two-factor', fn (Request $request) => Limit::perMinute(15)->by($request->ip()));
|
||||||
|
|
||||||
// Rate limit for radio endpoints (high traffic)
|
// Rate limit for radio endpoints (high traffic)
|
||||||
RateLimiter::for('radio', fn (Request $request) => Limit::perMinute(120)->by($request->user()?->id ?: $request->ip()));
|
RateLimiter::for('radio', function (Request $request) {
|
||||||
|
$key = $request->get('radio_api_key_id')
|
||||||
|
?? $request->user()?->id
|
||||||
|
?? $request->ip();
|
||||||
|
|
||||||
|
return Limit::perMinute(120)->by((string) $key);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('radio_api_keys', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('key', 64)->unique();
|
||||||
|
$table->string('allowed_ips')->nullable();
|
||||||
|
$table->json('permissions')->nullable();
|
||||||
|
$table->integer('rate_limit')->default(300);
|
||||||
|
$table->timestamp('last_used_at')->nullable();
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('radio_api_keys');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('radio_song_plays', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('artist')->nullable();
|
||||||
|
$table->string('album')->nullable();
|
||||||
|
$table->string('artwork_url')->nullable();
|
||||||
|
$table->unsignedInteger('duration')->nullable();
|
||||||
|
$table->timestamp('played_at');
|
||||||
|
$table->unsignedBigInteger('dj_id')->nullable()->index();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('played_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('radio_song_plays');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('radio_shouts', function (Blueprint $table) {
|
||||||
|
$table->boolean('approved')->default(true)->after('reported_by');
|
||||||
|
$table->unsignedBigInteger('approved_by')->nullable()->after('approved');
|
||||||
|
$table->timestamp('approved_at')->nullable()->after('approved_by');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('radio_shouts', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['approved', 'approved_by', 'approved_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('radio_auto_dj_playlist', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('artist')->nullable();
|
||||||
|
$table->string('album')->nullable();
|
||||||
|
$table->string('artwork_url')->nullable();
|
||||||
|
$table->unsignedInteger('duration')->nullable();
|
||||||
|
$table->unsignedInteger('play_count')->default(0);
|
||||||
|
$table->timestamp('last_played_at')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->unsignedInteger('sort_order')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('radio_auto_dj_playlist');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -42,10 +42,14 @@
|
|||||||
<!-- DJ Info -->
|
<!-- DJ Info -->
|
||||||
<div x-show="currentDJ" class="flex items-center gap-4 mb-4">
|
<div x-show="currentDJ" class="flex items-center gap-4 mb-4">
|
||||||
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center overflow-hidden">
|
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center overflow-hidden">
|
||||||
<img x-bind:src="djAvatar" x-bind:alt="djName" class="w-full h-full object-cover">
|
<img x-show="!isAutoDj" x-bind:src="djAvatar" x-bind:alt="djName" class="w-full h-full object-cover">
|
||||||
|
<svg x-show="isAutoDj" class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-amber-400">PRESENTATOR</p>
|
<p x-show="!isAutoDj" class="text-xs text-amber-400">PRESENTATOR</p>
|
||||||
|
<p x-show="isAutoDj" class="text-xs text-gray-400">AUTO DJ</p>
|
||||||
<p x-text="djName" class="font-semibold">--</p>
|
<p x-text="djName" class="font-semibold">--</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,15 +110,72 @@ function radioPlayer() {
|
|||||||
currentDJ: null,
|
currentDJ: null,
|
||||||
djName: '--',
|
djName: '--',
|
||||||
djAvatar: '',
|
djAvatar: '',
|
||||||
|
isAutoDj: false,
|
||||||
|
sseSource: null,
|
||||||
|
|
||||||
initPlayer() {
|
initPlayer() {
|
||||||
this.checkVisibility();
|
this.checkVisibility();
|
||||||
this.loadSettings();
|
this.loadSettings();
|
||||||
setInterval(() => this.updateListeners(), 30000);
|
this.connectSse();
|
||||||
setInterval(() => this.updateNowPlaying(), 15000);
|
|
||||||
this.updateVisibilityByUrl();
|
this.updateVisibilityByUrl();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.sseSource) {
|
||||||
|
this.sseSource.close();
|
||||||
|
this.sseSource = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
connectSse() {
|
||||||
|
this.sseSource = new EventSource('/api/radio/sse');
|
||||||
|
|
||||||
|
this.sseSource.addEventListener('now-playing', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data && data.title) {
|
||||||
|
this.trackTitle = data.title;
|
||||||
|
this.trackArtist = data.artist || '';
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sseSource.addEventListener('listeners', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data && data.count !== undefined) {
|
||||||
|
this.listenerCount = data.count.toLocaleString();
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sseSource.addEventListener('dj', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data && data.username) {
|
||||||
|
this.djName = data.username;
|
||||||
|
this.isAutoDj = data.is_auto_dj || false;
|
||||||
|
if (data.look && !data.is_auto_dj) {
|
||||||
|
this.djAvatar = 'https://www.habbo.nl/habbo-imaging/avatarimage?figure=' + data.look + '&size=m';
|
||||||
|
} else {
|
||||||
|
this.djAvatar = '';
|
||||||
|
}
|
||||||
|
this.currentDJ = data;
|
||||||
|
} else {
|
||||||
|
this.currentDJ = null;
|
||||||
|
this.djName = '--';
|
||||||
|
this.djAvatar = '';
|
||||||
|
this.isAutoDj = false;
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sseSource.onerror = () => {
|
||||||
|
this.sseSource.close();
|
||||||
|
setTimeout(() => this.connectSse(), 5000);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
checkVisibility() {
|
checkVisibility() {
|
||||||
if (!this.showWidget) {
|
if (!this.showWidget) {
|
||||||
this.showWidget = false;
|
this.showWidget = false;
|
||||||
@@ -164,29 +225,6 @@ function radioPlayer() {
|
|||||||
audio.play().catch(() => {});
|
audio.play().catch(() => {});
|
||||||
this.isPlaying = true;
|
this.isPlaying = true;
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
updateListeners() {
|
|
||||||
fetch('/api/radio/listeners')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
this.listenerCount = data.count.toLocaleString();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
this.listenerCount = '--';
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
updateNowPlaying() {
|
|
||||||
fetch('/api/radio/now-playing')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.title) {
|
|
||||||
this.trackTitle = data.title;
|
|
||||||
this.trackArtist = data.artist || '';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
?>
|
||||||
|
|
||||||
|
<x-filament-panels::page>
|
||||||
|
@if($newKey = $this->getNewKey())
|
||||||
|
<div class="bg-success-50 dark:bg-success-950 border border-success-300 dark:border-success-700 rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<x-filament::icon name="heroicon-o-key" class="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||||
|
<strong class="text-success-700 dark:text-success-300">Nieuwe API Sleutel Aangemaakt!</strong>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-success-600 dark:text-success-400 mb-2">Kopieer deze sleutel nu. Deze wordt niet meer getoond:</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" value="{{ $newKey }}" readonly
|
||||||
|
class="flex-1 bg-white dark:bg-gray-900 border border-success-300 dark:border-success-700 rounded-lg px-3 py-2 text-sm font-mono"
|
||||||
|
id="newApiKey" />
|
||||||
|
<button onclick="navigator.clipboard.writeText('{{ $newKey }}').then(() => { this.textContent = 'Gekopieerd!'; setTimeout(() => this.textContent = 'Kopieer', 2000); })"
|
||||||
|
class="px-4 py-2 bg-success-600 text-white rounded-lg text-sm hover:bg-success-700 transition">
|
||||||
|
Kopieer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{ $this->table }}
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
?>
|
||||||
|
|
||||||
|
<x-filament-panels::page>
|
||||||
|
@php $autoDj = \Illuminate\Support\Facades\Cache::get('radio_auto_dj_active'); @endphp
|
||||||
|
|
||||||
|
@if($autoDj)
|
||||||
|
<div class="bg-success-50 dark:bg-success-950 border border-success-300 dark:border-success-700 rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<x-filament::icon name="heroicon-o-play-circle" class="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||||
|
<div>
|
||||||
|
<strong class="text-success-700 dark:text-success-300">Auto DJ is actief</strong>
|
||||||
|
<p class="text-sm text-success-600 dark:text-success-400">Speelt: {{ $autoDj['title'] ?? 'Onbekend' }}{{ $autoDj['artist'] ? ' - ' . $autoDj['artist'] : '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<x-filament::icon name="heroicon-o-pause-circle" class="w-5 h-5 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<strong class="text-gray-700 dark:text-gray-300">Auto DJ is niet actief</strong>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Auto DJ wordt geactiveerd wanneer er geen DJ live is en er tracks in de playlist staan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{ $this->table }}
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\RadioApplication;
|
||||||
|
use App\Models\RadioShout;
|
||||||
|
use App\Models\RadioSongRequest;
|
||||||
|
|
||||||
|
?>
|
||||||
|
|
||||||
|
<x-filament-panels::page>
|
||||||
|
<x-filament::tabs>
|
||||||
|
{{-- Shouts Tab --}}
|
||||||
|
<x-filament::tabs.tab
|
||||||
|
icon="heroicon-o-chat-bubble-oval-left"
|
||||||
|
label="Shouts ({{ RadioShout::where('approved', false)->count() }} wachtend, {{ RadioShout::where('reported', true)->count() }} gerapporteerd)"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
@php $pendingShouts = $this->getPendingShouts(); @endphp
|
||||||
|
@if($pendingShouts->count() > 0)
|
||||||
|
<h3 class="text-lg font-semibold">Wachtend op goedkeuring</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach($pendingShouts as $shout)
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-sm">{{ $shout->user?->username ?? 'Onbekend' }}</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 truncate">{{ $shout->message }}</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{ $shout->created_at->diffForHumans() }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 shrink-0">
|
||||||
|
<x-filament::button wire:click="approveShout({{ $shout->id }})" color="success" size="xs" icon="heroicon-o-check">
|
||||||
|
Goedkeuren
|
||||||
|
</x-filament::button>
|
||||||
|
<x-filament::button wire:click="deleteShout({{ $shout->id }})" color="danger" size="xs" icon="heroicon-o-trash" onclick="return confirm('Zeker weten?')">
|
||||||
|
Verwijderen
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
{{ $pendingShouts->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@php $reportedShouts = $this->getReportedShouts(); @endphp
|
||||||
|
@if($reportedShouts->count() > 0)
|
||||||
|
<h3 class="text-lg font-semibold mt-6">Gerapporteerde shouts</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach($reportedShouts as $shout)
|
||||||
|
<div class="bg-red-50 dark:bg-red-950 rounded-lg p-4 flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-sm">{{ $shout->user?->username ?? 'Onbekend' }}</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 truncate">{{ $shout->message }}</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{ $shout->created_at->diffForHumans() }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 shrink-0">
|
||||||
|
<x-filament::button wire:click="dismissShoutReport({{ $shout->id }})" color="info" size="xs" icon="heroicon-o-flag">
|
||||||
|
Negeren
|
||||||
|
</x-filament::button>
|
||||||
|
<x-filament::button wire:click="deleteShout({{ $shout->id }})" color="danger" size="xs" icon="heroicon-o-trash" onclick="return confirm('Zeker weten?')">
|
||||||
|
Verwijderen
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
{{ $reportedShouts->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($pendingShouts->count() === 0 && $reportedShouts->count() === 0)
|
||||||
|
<p class="text-gray-500 text-center py-8">Geen shouts wachtend op moderatie</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::tabs.tab>
|
||||||
|
|
||||||
|
{{-- Song Requests Tab --}}
|
||||||
|
<x-filament::tabs.tab
|
||||||
|
icon="heroicon-o-musical-note"
|
||||||
|
label="Verzoeken ({{ RadioSongRequest::where('is_approved', false)->where('is_played', false)->count() }} wachtend)"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
@php $requests = $this->getPendingRequests(); @endphp
|
||||||
|
@if($requests->count() > 0)
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach($requests as $request)
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-sm">{{ $request->user?->username ?? 'Onbekend' }}</p>
|
||||||
|
<p class="text-sm font-semibold">{{ $request->song_title }}</p>
|
||||||
|
<p class="text-sm text-gray-500" x-show="{{ $request->song_artist }}">{{ $request->song_artist }}</p>
|
||||||
|
<div class="flex items-center gap-3 mt-1">
|
||||||
|
<span class="text-xs text-gray-400">{{ $request->submitted_at->diffForHumans() }}</span>
|
||||||
|
<span class="text-xs text-amber-500">{{ $request->votes }} stemmen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 shrink-0">
|
||||||
|
<x-filament::button wire:click="approveRequest({{ $request->id }})" color="success" size="xs" icon="heroicon-o-check">
|
||||||
|
Goedkeuren
|
||||||
|
</x-filament::button>
|
||||||
|
<x-filament::button wire:click="rejectRequest({{ $request->id }})" color="danger" size="xs" icon="heroicon-o-x-mark" onclick="return confirm('Zeker weten?')">
|
||||||
|
Afwijzen
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
{{ $requests->links() }}
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-gray-500 text-center py-8">Geen verzoeken wachtend op goedkeuring</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::tabs.tab>
|
||||||
|
|
||||||
|
{{-- Applications Tab --}}
|
||||||
|
<x-filament::tabs.tab
|
||||||
|
icon="heroicon-o-user-group"
|
||||||
|
label="Aanmeldingen ({{ RadioApplication::where('status', 'pending')->count() }} wachtend)"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
@php $applications = $this->getPendingApplications(); @endphp
|
||||||
|
@if($applications->count() > 0)
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach($applications as $app)
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium">{{ $app->user?->username ?? 'Onbekend' }}</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $app->rank?->name ?? 'Geen functie' }} · {{ $app->age }} jaar
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mt-2">{{ $app->motivation }}</p>
|
||||||
|
@if($app->experience)
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Ervaring: {{ $app->experience }}</p>
|
||||||
|
@endif
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{ $app->created_at->diffForHumans() }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 shrink-0">
|
||||||
|
<x-filament::button wire:click="approveApplication({{ $app->id }})" color="success" size="xs" icon="heroicon-o-check" onclick="return confirm('Aanmelding goedkeuren?')">
|
||||||
|
Goedkeuren
|
||||||
|
</x-filament::button>
|
||||||
|
<x-filament::button wire:click="rejectApplication({{ $app->id }})" color="danger" size="xs" icon="heroicon-o-x-mark" onclick="return confirm('Aanmelding afwijzen?')">
|
||||||
|
Afwijzen
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
{{ $applications->links() }}
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-gray-500 text-center py-8">Geen aanmeldingen wachtend</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::tabs.tab>
|
||||||
|
</x-filament::tabs>
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
?>
|
||||||
|
|
||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->form }}
|
||||||
|
|
||||||
|
<div class="space-y-6 mt-6">
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Directe URL</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Gebruik deze URL om direct naar de embed player te navigeren:</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" value="{{ $this->getDirectUrl() }}" readonly
|
||||||
|
class="flex-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm font-mono"
|
||||||
|
id="directUrl" />
|
||||||
|
<button onclick="navigator.clipboard.writeText('{{ $this->getDirectUrl() }}').then(() => { this.textContent = 'Gekopieerd!'; setTimeout(() => this.textContent = 'Kopieer', 2000); })"
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 transition">
|
||||||
|
Kopieer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Iframe Embed</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Voeg deze code toe aan je website om de radio player als iframe te tonen:</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" value="{{ $this->getIframeCode() }}" readonly
|
||||||
|
class="flex-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm font-mono"
|
||||||
|
id="iframeCode" />
|
||||||
|
<button onclick="navigator.clipboard.writeText('{{ $this->getIframeCode() }}').then(() => { this.textContent = 'Gekopieerd!'; setTimeout(() => this.textContent = 'Kopieer', 2000); })"
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 transition">
|
||||||
|
Kopieer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">JavaScript Embed</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Voeg deze code toe aan je website voor een dynamische embed (geeft meer controle):</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<textarea readonly rows="6"
|
||||||
|
class="flex-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm font-mono"
|
||||||
|
id="jsCode">{{ $this->getJsSnippet() }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<button onclick="navigator.clipboard.writeText(document.getElementById('jsCode').value).then(() => { this.textContent = 'Gekopieerd!'; setTimeout(() => this.textContent = 'Kopieer JavaScript', 2000); })"
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 transition">
|
||||||
|
Kopieer JavaScript
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Voorbeeld</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Live voorbeeld van de embed player:</p>
|
||||||
|
<div class="border border-gray-300 dark:border-gray-600 rounded-xl overflow-hidden">
|
||||||
|
<iframe src="{{ $this->getDirectUrl() }}" width="100%" height="400" frameborder="0" allow="autoplay"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Radio Player</title>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: {{ $settings['theme'] === 'transparent' ? 'transparent' : ($settings['theme'] === 'light' ? '#ffffff' : '#1a1a2e') }};
|
||||||
|
color: {{ $settings['textColor'] }};
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ep { width: 100%; height: 100vh; display: flex; flex-direction: column; padding: 16px; }
|
||||||
|
.ep-hdr { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||||
|
.ep-brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 14px; text-transform: uppercase; letter-spacing: 1px; }
|
||||||
|
.ep-brand .dot { width: 8px; height: 8px; border-radius: 50%; background: {{ $settings['primaryColor'] }}; animation: pulse 1.5s ease-in-out infinite; }
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
.ep-listeners { font-size: 12px; opacity: 0.7; display: flex; align-items: center; gap: 4px; }
|
||||||
|
.ep-np { flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 24px 0; }
|
||||||
|
.ep-track { font-size: 18px; font-weight: 600; margin-bottom: 4px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ep-artist { font-size: 14px; opacity: 0.7; }
|
||||||
|
.ep-ctrls { display: flex; align-items: center; justify-content: center; gap: 16px; margin-bottom: 16px; }
|
||||||
|
.ep-btn { width: 56px; height: 56px; border-radius: 50%; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: transform 0.15s; background: {{ $settings['primaryColor'] }}; color: #fff; }
|
||||||
|
.ep-btn:hover { transform: scale(1.05); }
|
||||||
|
.ep-btn:active { transform: scale(0.95); }
|
||||||
|
.ep-btn svg { width: 24px; height: 24px; fill: currentColor; }
|
||||||
|
.ep-vol { display: flex; align-items: center; gap: 8px; width: 120px; margin: 0 auto; }
|
||||||
|
.ep-vol svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; opacity: 0.6; flex-shrink: 0; }
|
||||||
|
.ep-vol input[type="range"] { flex: 1; height: 4px; -webkit-appearance: none; appearance: none; background: rgba(255,255,255,0.2); border-radius: 2px; outline: none; }
|
||||||
|
.ep-vol input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: {{ $settings['primaryColor'] }}; cursor: pointer; }
|
||||||
|
.ep-vol input[type="range"]::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: {{ $settings['primaryColor'] }}; cursor: pointer; border: none; }
|
||||||
|
.ep-dj { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 8px; font-size: 12px; opacity: 0.8; }
|
||||||
|
.ep-dj img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; }
|
||||||
|
.ep-offline { text-align: center; padding: 32px; opacity: 0.6; font-size: 14px; }
|
||||||
|
body.light .ep-vol input[type="range"] { background: rgba(0,0,0,0.15); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="{{ $settings['theme'] === 'light' ? 'light' : '' }}">
|
||||||
|
<div class="ep" x-data="radioEmbed()" x-init="init()">
|
||||||
|
<div x-show="!enabled" class="ep-offline">Radio is offline</div>
|
||||||
|
|
||||||
|
<div x-show="enabled">
|
||||||
|
<div class="ep-hdr">
|
||||||
|
<div class="ep-brand">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>RADIO</span>
|
||||||
|
</div>
|
||||||
|
<div class="ep-listeners" x-show="listenerCount !== null">
|
||||||
|
<span x-text="listenerCount"></span> listeners
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ep-np">
|
||||||
|
<div class="ep-track" x-text="trackTitle">Radio</div>
|
||||||
|
<div class="ep-artist" x-show="trackArtist" x-text="trackArtist"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ep-ctrls">
|
||||||
|
<button class="ep-btn" @click="togglePlay()" x-show="!isPlaying">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ep-btn" @click="togglePlay()" x-show="isPlaying">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ep-vol">
|
||||||
|
<svg viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.536 8.464a5 5 0 010 7.072M18.364 5.636a9 9 0 010 12.728"/></svg>
|
||||||
|
<input type="range" x-model="volume" min="0" max="100" @input="updateVolume()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ep-dj" x-show="djName">
|
||||||
|
<img x-show="djAvatar" x-bind:src="djAvatar" x-bind:alt="djName">
|
||||||
|
<span x-text="djName"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio x-ref="audio" preload="none">
|
||||||
|
<source x-bind:src="streamUrl" type="audio/mpeg">
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function radioEmbed() {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
streamUrl: '{{ $settings['streamUrl'] }}',
|
||||||
|
isPlaying: {{ $settings['autoPlay'] ? 'true' : 'false' }},
|
||||||
|
volume: 80,
|
||||||
|
trackTitle: 'Radio',
|
||||||
|
trackArtist: '',
|
||||||
|
listenerCount: null,
|
||||||
|
djName: '',
|
||||||
|
djAvatar: '',
|
||||||
|
sseSource: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadConfig();
|
||||||
|
this.connectSse();
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.sseSource) {
|
||||||
|
this.sseSource.close();
|
||||||
|
this.sseSource = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
connectSse() {
|
||||||
|
this.sseSource = new EventSource('/api/radio/sse');
|
||||||
|
|
||||||
|
this.sseSource.addEventListener('now-playing', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data && data.title) {
|
||||||
|
this.trackTitle = data.title;
|
||||||
|
this.trackArtist = data.artist || '';
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sseSource.addEventListener('listeners', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data && data.count !== undefined) {
|
||||||
|
this.listenerCount = data.count.toLocaleString();
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sseSource.addEventListener('dj', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data && data.username) {
|
||||||
|
this.djName = data.username;
|
||||||
|
if (data.look && !data.is_auto_dj) {
|
||||||
|
this.djAvatar = 'https://www.habbo.nl/habbo-imaging/avatarimage?figure=' + data.look + '&size=m';
|
||||||
|
} else {
|
||||||
|
this.djAvatar = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sseSource.onerror = () => {
|
||||||
|
this.sseSource.close();
|
||||||
|
setTimeout(() => this.connectSse(), 5000);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
loadConfig() {
|
||||||
|
fetch('/api/radio/config')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.enabled && data.stream_url) {
|
||||||
|
this.enabled = true;
|
||||||
|
this.streamUrl = data.stream_url;
|
||||||
|
if ({{ $settings['autoPlay'] ? 'true' : 'false' }}) {
|
||||||
|
setTimeout(() => this.togglePlay(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePlay() {
|
||||||
|
const audio = this.$refs.audio;
|
||||||
|
if (this.isPlaying) {
|
||||||
|
audio.pause();
|
||||||
|
this.isPlaying = false;
|
||||||
|
} else {
|
||||||
|
audio.play().catch(() => {});
|
||||||
|
this.isPlaying = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateVolume() {
|
||||||
|
const audio = this.$refs.audio;
|
||||||
|
if (audio) audio.volume = this.volume / 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -5,6 +5,7 @@ use App\Http\Controllers\Api\HotelApiController;
|
|||||||
use App\Http\Controllers\Community\RadioController;
|
use App\Http\Controllers\Community\RadioController;
|
||||||
use App\Http\Controllers\RadioListenerPointController;
|
use App\Http\Controllers\RadioListenerPointController;
|
||||||
use App\Models\Miscellaneous\WebsiteSetting;
|
use App\Models\Miscellaneous\WebsiteSetting;
|
||||||
|
use App\Models\RadioApiKey;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -74,6 +75,12 @@ Route::get('/radio/now-playing', [RadioController::class, 'nowPlaying'])->middle
|
|||||||
Route::get('/radio/listeners', [RadioController::class, 'listeners'])->middleware('throttle:100,1')->name('api.radio.listeners');
|
Route::get('/radio/listeners', [RadioController::class, 'listeners'])->middleware('throttle:100,1')->name('api.radio.listeners');
|
||||||
Route::get('/radio/shouts', [RadioController::class, 'getShouts'])->middleware('throttle:100,1')->name('api.radio.shouts');
|
Route::get('/radio/shouts', [RadioController::class, 'getShouts'])->middleware('throttle:100,1')->name('api.radio.shouts');
|
||||||
|
|
||||||
|
// Radio SSE (Server-Sent Events) stream
|
||||||
|
Route::get('/radio/sse', [\App\Http\Controllers\Radio\SseController::class, 'stream'])->name('api.radio.sse');
|
||||||
|
|
||||||
|
// Radio embed config
|
||||||
|
Route::get('/radio/embed/config', [\App\Http\Controllers\Radio\EmbedController::class, 'config'])->middleware('throttle:100,1')->name('api.radio.embed.config');
|
||||||
|
|
||||||
// Radio Settings
|
// Radio Settings
|
||||||
Route::get('/settings/radio/auto-play', function () {
|
Route::get('/settings/radio/auto-play', function () {
|
||||||
$autoPlaySetting = cache()->remember('radio_auto_play_setting', 300, fn () => WebsiteSetting::where('key', 'radio_auto_play')->first());
|
$autoPlaySetting = cache()->remember('radio_auto_play_setting', 300, fn () => WebsiteSetting::where('key', 'radio_auto_play')->first());
|
||||||
@@ -100,3 +107,21 @@ Route::post('/photos/upload', [HotelApiController::class, 'uploadPhoto'])->middl
|
|||||||
|
|
||||||
// Shop Purchase
|
// Shop Purchase
|
||||||
Route::post('/shop/packages/{packageId}/purchase', [HotelApiController::class, 'purchasePackage'])->middleware('auth:sanctum');
|
Route::post('/shop/packages/{packageId}/purchase', [HotelApiController::class, 'purchasePackage'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
|
// Protected Radio API (requires API key)
|
||||||
|
Route::prefix('radio')->middleware(['radio.api', 'throttle:radio'])->group(function () {
|
||||||
|
Route::get('/current-dj', [RadioController::class, 'currentDJ'])->name('api.radio.v2.current-dj');
|
||||||
|
Route::get('/now-playing', [RadioController::class, 'nowPlaying'])->name('api.radio.v2.now-playing');
|
||||||
|
Route::get('/listeners', [RadioController::class, 'listeners'])->name('api.radio.v2.listeners');
|
||||||
|
Route::get('/config', [RadioController::class, 'config'])->name('api.radio.v2.config');
|
||||||
|
Route::get('/shouts', [RadioController::class, 'getShouts'])->name('api.radio.v2.shouts');
|
||||||
|
Route::get('/points', [RadioListenerPointController::class, 'index'])->name('api.radio.v2.points');
|
||||||
|
Route::get('/points/leaderboard', [RadioListenerPointController::class, 'leaderboard'])->name('api.radio.v2.points.leaderboard');
|
||||||
|
Route::get('/points/stats', [RadioListenerPointController::class, 'stats'])->name('api.radio.v2.points.stats');
|
||||||
|
Route::get('/verify', function () {
|
||||||
|
return response()->json([
|
||||||
|
'valid' => true,
|
||||||
|
'message' => 'API key is geldig',
|
||||||
|
]);
|
||||||
|
})->name('api.radio.v2.verify');
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ use App\Http\Controllers\Miscellaneous\MaintenanceController;
|
|||||||
use App\Http\Controllers\User\BannedController;
|
use App\Http\Controllers\User\BannedController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
// Radio embed (public, no auth required)
|
||||||
|
Route::get('/radio/embed', [\App\Http\Controllers\Radio\EmbedController::class, 'show'])->name('radio.embed');
|
||||||
|
|
||||||
// Language route
|
// Language route
|
||||||
Route::get('/language/{locale}', LocaleController::class)->name('language.select');
|
Route::get('/language/{locale}', LocaleController::class)->name('language.select');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user