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