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
+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>