diff --git a/app/Console/Commands/AutoDjPlayback.php b/app/Console/Commands/AutoDjPlayback.php new file mode 100644 index 0000000..0aef6b6 --- /dev/null +++ b/app/Console/Commands/AutoDjPlayback.php @@ -0,0 +1,70 @@ +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; + } +} diff --git a/app/Console/Commands/RecordSongPlays.php b/app/Console/Commands/RecordSongPlays.php new file mode 100644 index 0000000..5ccfcca --- /dev/null +++ b/app/Console/Commands/RecordSongPlays.php @@ -0,0 +1,108 @@ +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; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e88a322..afa8406 100755 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -23,6 +23,8 @@ class Kernel extends ConsoleKernel protected function schedule(Schedule $schedule): void { $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('monitor:emulator')->everyMinute()->withoutOverlapping(); $schedule->command('monitor:ddos')->everyFiveMinutes()->withoutOverlapping(); diff --git a/app/Enums/RadioSettings.php b/app/Enums/RadioSettings.php index 6daa4cb..3476a23 100755 --- a/app/Enums/RadioSettings.php +++ b/app/Enums/RadioSettings.php @@ -20,6 +20,12 @@ enum RadioSettings: string case WidgetEnabled = 'radio_widget_enabled'; case WidgetShowGlobally = 'radio_widget_show_globally'; 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 { @@ -40,6 +46,12 @@ enum RadioSettings: string self::WidgetEnabled => 'Widget Enabled', self::WidgetShowGlobally => 'Widget Show Globally', 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', }; } } diff --git a/app/Filament/Pages/Radio/ApiKeys.php b/app/Filament/Pages/Radio/ApiKeys.php new file mode 100644 index 0000000..b4e8770 --- /dev/null +++ b/app/Filament/Pages/Radio/ApiKeys.php @@ -0,0 +1,196 @@ +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; + } +} diff --git a/app/Filament/Pages/Radio/AutoDjPlaylist.php b/app/Filament/Pages/Radio/AutoDjPlaylist.php new file mode 100644 index 0000000..18848e7 --- /dev/null +++ b/app/Filament/Pages/Radio/AutoDjPlaylist.php @@ -0,0 +1,207 @@ +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 []; + } +} diff --git a/app/Filament/Pages/Radio/DjModeration.php b/app/Filament/Pages/Radio/DjModeration.php new file mode 100644 index 0000000..4043872 --- /dev/null +++ b/app/Filament/Pages/Radio/DjModeration.php @@ -0,0 +1,146 @@ +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 []; + } +} diff --git a/app/Filament/Pages/Radio/EmbedCode.php b/app/Filament/Pages/Radio/EmbedCode.php new file mode 100644 index 0000000..dc189c3 --- /dev/null +++ b/app/Filament/Pages/Radio/EmbedCode.php @@ -0,0 +1,131 @@ +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 ''; + } + + 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 '
+'; + } + + 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; + } +} diff --git a/app/Filament/Pages/Radio/RadioSettings.php b/app/Filament/Pages/Radio/RadioSettings.php index 06f4061..59643c9 100755 --- a/app/Filament/Pages/Radio/RadioSettings.php +++ b/app/Filament/Pages/Radio/RadioSettings.php @@ -83,6 +83,12 @@ final class RadioSettings extends Page implements HasForms 'radio_widget_enabled' => $this->getSettingBool('radio_widget_enabled'), 'radio_widget_show_globally' => $this->getSettingBool('radio_widget_show_globally'), '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_max_length' => (int) $this->getSetting('radio_shouts_max_length', '280'), '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', ]) ->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') ->description('Instellingen voor wanneer de radio offline is') @@ -890,6 +922,9 @@ final class RadioSettings extends Page implements HasForms $this->saveSettings([ 'radio_widget_enabled', 'radio_widget_show_globally', '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_top_djs', 'radio_stats_show_top_songs', '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_discord_dj_live', 'radio_discord_song_changes', '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_shouts_max_length', 'radio_shouts_cooldown', 'radio_chat_width', 'radio_chat_height', 'radio_chat_messages_count', + 'radio_embed_height', 'radio_request_max_per_user', 'radio_request_cooldown', 'radio_min_listeners_threshold', 'radio_listener_alert_threshold_high', 'radio_listener_alert_threshold_low', 'radio_applications_max_per_day', diff --git a/app/Filament/Resources/RadioSongPlay/Pages/ManageRadioSongPlays.php b/app/Filament/Resources/RadioSongPlay/Pages/ManageRadioSongPlays.php new file mode 100644 index 0000000..9a98d9b --- /dev/null +++ b/app/Filament/Resources/RadioSongPlay/Pages/ManageRadioSongPlays.php @@ -0,0 +1,14 @@ +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('/'), + ]; + } +} diff --git a/app/Http/Controllers/Community/RadioController.php b/app/Http/Controllers/Community/RadioController.php index ad87222..8e92975 100755 --- a/app/Http/Controllers/Community/RadioController.php +++ b/app/Http/Controllers/Community/RadioController.php @@ -167,6 +167,18 @@ class RadioController extends Controller 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 () { $apiUrl = $this->getSetting(RadioSettings::NowPlayingEnabled) ? ($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]; }); + $nowPlaying['is_auto_dj'] = false; + return response()->json($nowPlaying); } @@ -195,9 +209,13 @@ class RadioController extends Controller { $dj = $this->scheduleService->getCurrentDJ($this->getSetting(RadioSettings::CurrentDjId)); + $autoDj = Cache::get('radio_auto_dj_active'); + return response()->json([ 'dj' => $dj, '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] ?? ''); $azureCast = $this->streamService->detectAzureCast(); + $autoDj = Cache::get('radio_auto_dj_active'); + return response()->json([ 'enabled' => (bool) ($settings[RadioSettings::Enabled->value] ?? false), 'stream_url' => $streamUrl, @@ -231,6 +251,8 @@ class RadioController extends Controller 'widget_position' => $settings[RadioSettings::WidgetPosition->value] ?? 'bottom-right', 'is_azurecast' => $azureCast['detected'], 'azurecast_detected' => $azureCast['detected'], + 'is_auto_dj' => $autoDj !== null, + 'auto_dj_song' => $autoDj['title'] ?? null, ]); } diff --git a/app/Http/Controllers/Radio/EmbedController.php b/app/Http/Controllers/Radio/EmbedController.php new file mode 100644 index 0000000..b422e95 --- /dev/null +++ b/app/Http/Controllers/Radio/EmbedController.php @@ -0,0 +1,66 @@ +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; + }); + } +} diff --git a/app/Http/Controllers/Radio/SseController.php b/app/Http/Controllers/Radio/SseController.php new file mode 100644 index 0000000..54123f5 --- /dev/null +++ b/app/Http/Controllers/Radio/SseController.php @@ -0,0 +1,193 @@ +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; + }); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 275db38..f794644 100755 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -7,6 +7,7 @@ namespace App\Http; use App\Http\Middleware\AdminSecurityMiddleware; use App\Http\Middleware\ApiResponseCache; use App\Http\Middleware\Authenticate; +use App\Http\Middleware\RadioApiKey; use App\Http\Middleware\BannedMiddleware; use App\Http\Middleware\DDoSTrackingMiddleware; use App\Http\Middleware\EncryptCookies; @@ -120,5 +121,6 @@ class Kernel extends HttpKernel 'force.staff.2fa' => ForceStaffTwoFactorMiddleware::class, 'ddos.track' => DDoSTrackingMiddleware::class, 'admin.security' => AdminSecurityMiddleware::class, + 'radio.api' => RadioApiKey::class, ]; } diff --git a/app/Http/Middleware/RadioApiKey.php b/app/Http/Middleware/RadioApiKey.php new file mode 100644 index 0000000..fe3030f --- /dev/null +++ b/app/Http/Middleware/RadioApiKey.php @@ -0,0 +1,50 @@ +bearerToken() ?? $request->query('api_key'); + + if (empty($key)) { + return response()->json([ + 'error' => 'API key is verplicht. Gebruik Authorization: BearerPRESENTATOR
+PRESENTATOR
+AUTO DJ
--
Kopieer deze sleutel nu. Deze wordt niet meer getoond:
+Speelt: {{ $autoDj['title'] ?? 'Onbekend' }}{{ $autoDj['artist'] ? ' - ' . $autoDj['artist'] : '' }}
+Auto DJ wordt geactiveerd wanneer er geen DJ live is en er tracks in de playlist staan.
+{{ $shout->user?->username ?? 'Onbekend' }}
+{{ $shout->message }}
+{{ $shout->created_at->diffForHumans() }}
+{{ $shout->user?->username ?? 'Onbekend' }}
+{{ $shout->message }}
+{{ $shout->created_at->diffForHumans() }}
+Geen shouts wachtend op moderatie
+ @endif +{{ $request->user?->username ?? 'Onbekend' }}
+{{ $request->song_title }}
+{{ $request->song_artist }}
+Geen verzoeken wachtend op goedkeuring
+ @endif +{{ $app->user?->username ?? 'Onbekend' }}
++ {{ $app->rank?->name ?? 'Geen functie' }} · {{ $app->age }} jaar +
+{{ $app->motivation }}
+ @if($app->experience) +Ervaring: {{ $app->experience }}
+ @endif +{{ $app->created_at->diffForHumans() }}
+Geen aanmeldingen wachtend
+ @endif +Gebruik deze URL om direct naar de embed player te navigeren:
+Voeg deze code toe aan je website om de radio player als iframe te tonen:
+Voeg deze code toe aan je website voor een dynamische embed (geeft meer controle):
+Live voorbeeld van de embed player:
+