feat: Radio themes + globe rotation pause on interaction

- 5 themes: Sunset (default), Midnight, Forest, Ocean, Cherry
- Theme selector dots in top-right corner, persisted to localStorage
- Globe accent colors (points, atmosphere) update with theme
- Globe pauses auto-rotation for 5s on any interaction (click, drag, scroll)
- All radio CSS vars scoped to .radio-container[data-theme]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-06 02:15:06 +01:00
parent d1ae2db00b
commit 90ef17932c
2 changed files with 156 additions and 11 deletions

View file

@ -46,11 +46,21 @@ interface Favorite {
placeId: string;
}
const THEMES = [
{ id: 'default', color: '#e67e22', label: 'Sunset' },
{ id: 'purple', color: '#9b59b6', label: 'Midnight' },
{ id: 'forest', color: '#2ecc71', label: 'Forest' },
{ id: 'ocean', color: '#3498db', label: 'Ocean' },
{ id: 'cherry', color: '#e74c6f', label: 'Cherry' },
];
// ── Component ──
export default function RadioTab({ data }: { data: any }) {
const containerRef = useRef<HTMLDivElement>(null);
const globeRef = useRef<any>(null);
const rotationResumeRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [theme, setTheme] = useState(() => localStorage.getItem('radio-theme') || 'default');
const [places, setPlaces] = useState<RadioPlace[]>([]);
const [selectedPlace, setSelectedPlace] = useState<RadioPlace | null>(null);
const [stations, setStations] = useState<RadioChannel[]>([]);
@ -101,6 +111,29 @@ export default function RadioTab({ data }: { data: any }) {
}
}, [data, selectedGuild]);
// ── Theme persist + update globe colors ──
useEffect(() => {
localStorage.setItem('radio-theme', theme);
if (globeRef.current && containerRef.current) {
const style = getComputedStyle(containerRef.current.parentElement!);
const accentRgb = style.getPropertyValue('--accent-rgb').trim();
globeRef.current
.pointColor(() => `rgba(${accentRgb}, 0.85)`)
.atmosphereColor(`rgba(${accentRgb}, 0.25)`);
}
}, [theme]);
// ── Helper: pause globe rotation for 5s ──
const pauseRotation = useCallback(() => {
const controls = globeRef.current?.controls() as any;
if (controls) controls.autoRotate = false;
if (rotationResumeRef.current) clearTimeout(rotationResumeRef.current);
rotationResumeRef.current = setTimeout(() => {
const c = globeRef.current?.controls() as any;
if (c) c.autoRotate = true;
}, 5000);
}, []);
// ── Point click handler (stable ref) ──
const handlePointClickRef = useRef<(point: any) => void>(undefined);
handlePointClickRef.current = (point: any) => {
@ -108,9 +141,7 @@ export default function RadioTab({ data }: { data: any }) {
setShowFavorites(false);
setStationsLoading(true);
setStations([]);
// Stop auto-rotation on point click so user can browse stations
const controls = globeRef.current?.controls() as any;
if (controls) controls.autoRotate = false;
pauseRotation();
if (globeRef.current) {
// Radio Garden geo format: [lng, lat]
globeRef.current.pointOfView({ lat: point.geo[1], lng: point.geo[0], altitude: 0.4 }, 800);
@ -130,20 +161,25 @@ export default function RadioTab({ data }: { data: any }) {
return;
}
// Read accent color from theme
const initStyle = getComputedStyle(containerRef.current.parentElement!);
const initRgb = initStyle.getPropertyValue('--accent-rgb').trim() || '230, 126, 34';
const initAccent = initStyle.getPropertyValue('--accent').trim() || '#e67e22';
const globe = new Globe(containerRef.current)
.globeImageUrl('//unpkg.com/three-globe/example/img/earth-night.jpg')
.backgroundColor('rgba(0,0,0,0)')
.atmosphereColor('rgba(230, 126, 34, 0.25)')
.atmosphereColor(`rgba(${initRgb}, 0.25)`)
.atmosphereAltitude(0.12)
.pointsData(places)
// Radio Garden geo format: [lng, lat]
.pointLat((d: any) => d.geo[1])
.pointLng((d: any) => d.geo[0])
.pointColor(() => 'rgba(230, 126, 34, 0.85)')
.pointColor(() => `rgba(${initRgb}, 0.85)`)
.pointRadius((d: any) => Math.max(0.12, Math.min(0.45, 0.06 + (d.size ?? 1) * 0.005)))
.pointAltitude(0.003)
.pointLabel((d: any) =>
`<div style="font-family:system-ui;font-size:13px;color:#fff;background:rgba(30,31,34,0.92);padding:6px 10px;border-radius:6px;border:1px solid rgba(230,126,34,0.3);pointer-events:none">` +
`<div style="font-family:system-ui;font-size:13px;color:#fff;background:rgba(30,31,34,0.92);padding:6px 10px;border-radius:6px;border:1px solid rgba(${initRgb},0.3);pointer-events:none">` +
`<b>${d.title}</b><br/><span style="color:#949ba4;font-size:11px">${d.country}</span></div>`
)
.onPointClick((d: any) => handlePointClickRef.current?.(d))
@ -162,6 +198,13 @@ export default function RadioTab({ data }: { data: any }) {
globeRef.current = globe;
// Pause rotation on any globe interaction (drag, scroll, touch)
const el = containerRef.current;
const onInteract = () => pauseRotation();
el.addEventListener('mousedown', onInteract);
el.addEventListener('touchstart', onInteract);
el.addEventListener('wheel', onInteract);
const onResize = () => {
if (containerRef.current && globeRef.current) {
globeRef.current
@ -172,9 +215,12 @@ export default function RadioTab({ data }: { data: any }) {
window.addEventListener('resize', onResize);
return () => {
el.removeEventListener('mousedown', onInteract);
el.removeEventListener('touchstart', onInteract);
el.removeEventListener('wheel', onInteract);
window.removeEventListener('resize', onResize);
};
}, [places]);
}, [places, pauseRotation]);
// ── Play handler ──
const handlePlay = useCallback(async (
@ -209,9 +255,7 @@ export default function RadioTab({ data }: { data: any }) {
?.voiceChannels.find(c => c.id === selectedChannel)?.name ?? '',
},
}));
// Stoppe Auto-Rotation beim Abspielen
const controls = globeRef.current?.controls() as any;
if (controls) controls.autoRotate = false;
pauseRotation();
}
} catch (e) { console.error(e); }
setPlayingLoading(false);
@ -302,11 +346,24 @@ export default function RadioTab({ data }: { data: any }) {
const currentGuild = guilds.find(g => g.id === selectedGuild);
return (
<div className="radio-container">
<div className="radio-container" data-theme={theme}>
{/* ── Globe ── */}
<div className="radio-globe" ref={containerRef} />
{/* ── Theme Selector ── */}
<div className="radio-theme">
{THEMES.map(t => (
<div
key={t.id}
className={`radio-theme-dot ${theme === t.id ? 'active' : ''}`}
style={{ background: t.color }}
title={t.label}
onClick={() => setTheme(t.id)}
/>
))}
</div>
{/* ── Search ── */}
<div className="radio-search">
<div className="radio-search-wrap">