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: Bearer of ?api_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); + } +} diff --git a/app/Models/RadioApiKey.php b/app/Models/RadioApiKey.php new file mode 100644 index 0000000..40dd566 --- /dev/null +++ b/app/Models/RadioApiKey.php @@ -0,0 +1,76 @@ + '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()]); + } +} diff --git a/app/Models/RadioAutoDjTrack.php b/app/Models/RadioAutoDjTrack.php new file mode 100644 index 0000000..ce16866 --- /dev/null +++ b/app/Models/RadioAutoDjTrack.php @@ -0,0 +1,68 @@ +|RadioAutoDjTrack where($column, $operator = null, $value = null) + * @method static \Illuminate\Database\Eloquent\Builder|RadioAutoDjTrack create($attributes = []) + * @method static \Illuminate\Database\Eloquent\Builder|RadioAutoDjTrack active() + * @method static \Illuminate\Database\Eloquent\Builder|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(); + } +} diff --git a/app/Models/RadioSongPlay.php b/app/Models/RadioSongPlay.php new file mode 100644 index 0000000..e4e554d --- /dev/null +++ b/app/Models/RadioSongPlay.php @@ -0,0 +1,67 @@ +|RadioSongPlay where($column, $operator = null, $value = null) + * @method static \Illuminate\Database\Eloquent\Builder|RadioSongPlay create($attributes = []) + * @method static \Illuminate\Database\Eloquent\Builder|RadioSongPlay orderBy($column, $direction = 'asc') + * @method static \Illuminate\Database\Eloquent\Builder|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, + ]); + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index d7b4e74..7777d80 100755 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -69,6 +69,12 @@ class RouteServiceProvider extends ServiceProvider RateLimiter::for('two-factor', fn (Request $request) => Limit::perMinute(15)->by($request->ip())); // 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); + }); } } diff --git a/database/migrations/2026_05_24_120000_create_radio_api_keys_table.php b/database/migrations/2026_05_24_120000_create_radio_api_keys_table.php new file mode 100644 index 0000000..0c88970 --- /dev/null +++ b/database/migrations/2026_05_24_120000_create_radio_api_keys_table.php @@ -0,0 +1,29 @@ +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'); + } +}; diff --git a/database/migrations/2026_05_24_130000_create_radio_song_plays_table.php b/database/migrations/2026_05_24_130000_create_radio_song_plays_table.php new file mode 100644 index 0000000..c2b3eea --- /dev/null +++ b/database/migrations/2026_05_24_130000_create_radio_song_plays_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/database/migrations/2026_05_24_140000_add_moderation_to_radio_shouts_table.php b/database/migrations/2026_05_24_140000_add_moderation_to_radio_shouts_table.php new file mode 100644 index 0000000..05d05b9 --- /dev/null +++ b/database/migrations/2026_05_24_140000_add_moderation_to_radio_shouts_table.php @@ -0,0 +1,24 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_05_24_150000_create_radio_auto_dj_playlist_table.php b/database/migrations/2026_05_24_150000_create_radio_auto_dj_playlist_table.php new file mode 100644 index 0000000..e43fa53 --- /dev/null +++ b/database/migrations/2026_05_24_150000_create_radio_auto_dj_playlist_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/resources/views/components/radio-player.blade.php b/resources/views/components/radio-player.blade.php index 53ed7e7..7ef8ad4 100755 --- a/resources/views/components/radio-player.blade.php +++ b/resources/views/components/radio-player.blade.php @@ -42,10 +42,14 @@
- + + + +
-

PRESENTATOR

+

PRESENTATOR

+

AUTO DJ

--

@@ -106,15 +110,72 @@ function radioPlayer() { currentDJ: null, djName: '--', djAvatar: '', + isAutoDj: false, + sseSource: null, initPlayer() { this.checkVisibility(); this.loadSettings(); - setInterval(() => this.updateListeners(), 30000); - setInterval(() => this.updateNowPlaying(), 15000); + this.connectSse(); 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() { if (!this.showWidget) { this.showWidget = false; @@ -164,29 +225,6 @@ function radioPlayer() { audio.play().catch(() => {}); 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(() => {}); } } } diff --git a/resources/views/filament/pages/radio/api-keys.blade.php b/resources/views/filament/pages/radio/api-keys.blade.php new file mode 100644 index 0000000..40f07fa --- /dev/null +++ b/resources/views/filament/pages/radio/api-keys.blade.php @@ -0,0 +1,26 @@ + + + + @if($newKey = $this->getNewKey()) +
+
+ + Nieuwe API Sleutel Aangemaakt! +
+

Kopieer deze sleutel nu. Deze wordt niet meer getoond:

+
+ + +
+
+ @endif + + {{ $this->table }} +
diff --git a/resources/views/filament/pages/radio/auto-dj-playlist.blade.php b/resources/views/filament/pages/radio/auto-dj-playlist.blade.php new file mode 100644 index 0000000..ce12c95 --- /dev/null +++ b/resources/views/filament/pages/radio/auto-dj-playlist.blade.php @@ -0,0 +1,31 @@ + + + + @php $autoDj = \Illuminate\Support\Facades\Cache::get('radio_auto_dj_active'); @endphp + + @if($autoDj) +
+
+ +
+ Auto DJ is actief +

Speelt: {{ $autoDj['title'] ?? 'Onbekend' }}{{ $autoDj['artist'] ? ' - ' . $autoDj['artist'] : '' }}

+
+
+
+ @else +
+
+ +
+ Auto DJ is niet actief +

Auto DJ wordt geactiveerd wanneer er geen DJ live is en er tracks in de playlist staan.

+
+
+
+ @endif + + {{ $this->table }} +
diff --git a/resources/views/filament/pages/radio/dj-moderation.blade.php b/resources/views/filament/pages/radio/dj-moderation.blade.php new file mode 100644 index 0000000..62badc9 --- /dev/null +++ b/resources/views/filament/pages/radio/dj-moderation.blade.php @@ -0,0 +1,151 @@ + + + + + {{-- Shouts Tab --}} + +
+ @php $pendingShouts = $this->getPendingShouts(); @endphp + @if($pendingShouts->count() > 0) +

Wachtend op goedkeuring

+
+ @foreach($pendingShouts as $shout) +
+
+

{{ $shout->user?->username ?? 'Onbekend' }}

+

{{ $shout->message }}

+

{{ $shout->created_at->diffForHumans() }}

+
+
+ + Goedkeuren + + + Verwijderen + +
+
+ @endforeach + {{ $pendingShouts->links() }} +
+ @endif + + @php $reportedShouts = $this->getReportedShouts(); @endphp + @if($reportedShouts->count() > 0) +

Gerapporteerde shouts

+
+ @foreach($reportedShouts as $shout) +
+
+

{{ $shout->user?->username ?? 'Onbekend' }}

+

{{ $shout->message }}

+

{{ $shout->created_at->diffForHumans() }}

+
+
+ + Negeren + + + Verwijderen + +
+
+ @endforeach + {{ $reportedShouts->links() }} +
+ @endif + + @if($pendingShouts->count() === 0 && $reportedShouts->count() === 0) +

Geen shouts wachtend op moderatie

+ @endif +
+
+ + {{-- Song Requests Tab --}} + +
+ @php $requests = $this->getPendingRequests(); @endphp + @if($requests->count() > 0) +
+ @foreach($requests as $request) +
+
+

{{ $request->user?->username ?? 'Onbekend' }}

+

{{ $request->song_title }}

+

{{ $request->song_artist }}

+
+ {{ $request->submitted_at->diffForHumans() }} + {{ $request->votes }} stemmen +
+
+
+ + Goedkeuren + + + Afwijzen + +
+
+ @endforeach + {{ $requests->links() }} +
+ @else +

Geen verzoeken wachtend op goedkeuring

+ @endif +
+
+ + {{-- Applications Tab --}} + +
+ @php $applications = $this->getPendingApplications(); @endphp + @if($applications->count() > 0) +
+ @foreach($applications as $app) +
+
+

{{ $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() }}

+
+
+ + Goedkeuren + + + Afwijzen + +
+
+ @endforeach + {{ $applications->links() }} +
+ @else +

Geen aanmeldingen wachtend

+ @endif +
+
+
+
diff --git a/resources/views/filament/pages/radio/embed-code.blade.php b/resources/views/filament/pages/radio/embed-code.blade.php new file mode 100644 index 0000000..1e3f49c --- /dev/null +++ b/resources/views/filament/pages/radio/embed-code.blade.php @@ -0,0 +1,61 @@ + + + + {{ $this->form }} + +
+
+

Directe URL

+

Gebruik deze URL om direct naar de embed player te navigeren:

+
+ + +
+
+ +
+

Iframe Embed

+

Voeg deze code toe aan je website om de radio player als iframe te tonen:

+
+ + +
+
+ +
+

JavaScript Embed

+

Voeg deze code toe aan je website voor een dynamische embed (geeft meer controle):

+
+ +
+
+ +
+
+ +
+

Voorbeeld

+

Live voorbeeld van de embed player:

+
+ +
+
+
+
diff --git a/resources/views/radio/embed.blade.php b/resources/views/radio/embed.blade.php new file mode 100644 index 0000000..cdcf86f --- /dev/null +++ b/resources/views/radio/embed.blade.php @@ -0,0 +1,188 @@ + + + + + + Radio Player + + + + +
+
Radio is offline
+ +
+
+
+ + RADIO +
+
+ listeners +
+
+ +
+
Radio
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + + + diff --git a/routes/api.php b/routes/api.php index c204552..9cf011f 100755 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Api\HotelApiController; use App\Http\Controllers\Community\RadioController; use App\Http\Controllers\RadioListenerPointController; use App\Models\Miscellaneous\WebsiteSetting; +use App\Models\RadioApiKey; 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/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 Route::get('/settings/radio/auto-play', function () { $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 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'); +}); diff --git a/routes/web.php b/routes/web.php index 0e657a6..b7f1111 100755 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,9 @@ use App\Http\Controllers\Miscellaneous\MaintenanceController; use App\Http\Controllers\User\BannedController; 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 Route::get('/language/{locale}', LocaleController::class)->name('language.select');