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:
root
2026-05-24 14:07:32 +02:00
parent 5476dce882
commit 0c6c558a59
32 changed files with 2236 additions and 29 deletions
@@ -42,10 +42,14 @@
<!-- DJ Info -->
<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">
<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>
<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>
</div>
</div>
@@ -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(() => {});
}
}
}
@@ -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' }} &middot; {{ $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>
+188
View File
@@ -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>