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:
parent
d1ae2db00b
commit
90ef17932c
2 changed files with 156 additions and 11 deletions
|
|
@ -46,11 +46,21 @@ interface Favorite {
|
||||||
placeId: string;
|
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 ──
|
// ── Component ──
|
||||||
export default function RadioTab({ data }: { data: any }) {
|
export default function RadioTab({ data }: { data: any }) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const globeRef = useRef<any>(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 [places, setPlaces] = useState<RadioPlace[]>([]);
|
||||||
const [selectedPlace, setSelectedPlace] = useState<RadioPlace | null>(null);
|
const [selectedPlace, setSelectedPlace] = useState<RadioPlace | null>(null);
|
||||||
const [stations, setStations] = useState<RadioChannel[]>([]);
|
const [stations, setStations] = useState<RadioChannel[]>([]);
|
||||||
|
|
@ -101,6 +111,29 @@ export default function RadioTab({ data }: { data: any }) {
|
||||||
}
|
}
|
||||||
}, [data, selectedGuild]);
|
}, [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) ──
|
// ── Point click handler (stable ref) ──
|
||||||
const handlePointClickRef = useRef<(point: any) => void>(undefined);
|
const handlePointClickRef = useRef<(point: any) => void>(undefined);
|
||||||
handlePointClickRef.current = (point: any) => {
|
handlePointClickRef.current = (point: any) => {
|
||||||
|
|
@ -108,9 +141,7 @@ export default function RadioTab({ data }: { data: any }) {
|
||||||
setShowFavorites(false);
|
setShowFavorites(false);
|
||||||
setStationsLoading(true);
|
setStationsLoading(true);
|
||||||
setStations([]);
|
setStations([]);
|
||||||
// Stop auto-rotation on point click so user can browse stations
|
pauseRotation();
|
||||||
const controls = globeRef.current?.controls() as any;
|
|
||||||
if (controls) controls.autoRotate = false;
|
|
||||||
if (globeRef.current) {
|
if (globeRef.current) {
|
||||||
// Radio Garden geo format: [lng, lat]
|
// Radio Garden geo format: [lng, lat]
|
||||||
globeRef.current.pointOfView({ lat: point.geo[1], lng: point.geo[0], altitude: 0.4 }, 800);
|
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;
|
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)
|
const globe = new Globe(containerRef.current)
|
||||||
.globeImageUrl('//unpkg.com/three-globe/example/img/earth-night.jpg')
|
.globeImageUrl('//unpkg.com/three-globe/example/img/earth-night.jpg')
|
||||||
.backgroundColor('rgba(0,0,0,0)')
|
.backgroundColor('rgba(0,0,0,0)')
|
||||||
.atmosphereColor('rgba(230, 126, 34, 0.25)')
|
.atmosphereColor(`rgba(${initRgb}, 0.25)`)
|
||||||
.atmosphereAltitude(0.12)
|
.atmosphereAltitude(0.12)
|
||||||
.pointsData(places)
|
.pointsData(places)
|
||||||
// Radio Garden geo format: [lng, lat]
|
// Radio Garden geo format: [lng, lat]
|
||||||
.pointLat((d: any) => d.geo[1])
|
.pointLat((d: any) => d.geo[1])
|
||||||
.pointLng((d: any) => d.geo[0])
|
.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)))
|
.pointRadius((d: any) => Math.max(0.12, Math.min(0.45, 0.06 + (d.size ?? 1) * 0.005)))
|
||||||
.pointAltitude(0.003)
|
.pointAltitude(0.003)
|
||||||
.pointLabel((d: any) =>
|
.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>`
|
`<b>${d.title}</b><br/><span style="color:#949ba4;font-size:11px">${d.country}</span></div>`
|
||||||
)
|
)
|
||||||
.onPointClick((d: any) => handlePointClickRef.current?.(d))
|
.onPointClick((d: any) => handlePointClickRef.current?.(d))
|
||||||
|
|
@ -162,6 +198,13 @@ export default function RadioTab({ data }: { data: any }) {
|
||||||
|
|
||||||
globeRef.current = globe;
|
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 = () => {
|
const onResize = () => {
|
||||||
if (containerRef.current && globeRef.current) {
|
if (containerRef.current && globeRef.current) {
|
||||||
globeRef.current
|
globeRef.current
|
||||||
|
|
@ -172,9 +215,12 @@ export default function RadioTab({ data }: { data: any }) {
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
el.removeEventListener('mousedown', onInteract);
|
||||||
|
el.removeEventListener('touchstart', onInteract);
|
||||||
|
el.removeEventListener('wheel', onInteract);
|
||||||
window.removeEventListener('resize', onResize);
|
window.removeEventListener('resize', onResize);
|
||||||
};
|
};
|
||||||
}, [places]);
|
}, [places, pauseRotation]);
|
||||||
|
|
||||||
// ── Play handler ──
|
// ── Play handler ──
|
||||||
const handlePlay = useCallback(async (
|
const handlePlay = useCallback(async (
|
||||||
|
|
@ -209,9 +255,7 @@ export default function RadioTab({ data }: { data: any }) {
|
||||||
?.voiceChannels.find(c => c.id === selectedChannel)?.name ?? '',
|
?.voiceChannels.find(c => c.id === selectedChannel)?.name ?? '',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
// Stoppe Auto-Rotation beim Abspielen
|
pauseRotation();
|
||||||
const controls = globeRef.current?.controls() as any;
|
|
||||||
if (controls) controls.autoRotate = false;
|
|
||||||
}
|
}
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
setPlayingLoading(false);
|
setPlayingLoading(false);
|
||||||
|
|
@ -302,11 +346,24 @@ export default function RadioTab({ data }: { data: any }) {
|
||||||
const currentGuild = guilds.find(g => g.id === selectedGuild);
|
const currentGuild = guilds.find(g => g.id === selectedGuild);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="radio-container">
|
<div className="radio-container" data-theme={theme}>
|
||||||
|
|
||||||
{/* ── Globe ── */}
|
{/* ── Globe ── */}
|
||||||
<div className="radio-globe" ref={containerRef} />
|
<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 ── */}
|
{/* ── Search ── */}
|
||||||
<div className="radio-search">
|
<div className="radio-search">
|
||||||
<div className="radio-search-wrap">
|
<div className="radio-search-wrap">
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,60 @@ html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-deep);
|
background: var(--bg-deep);
|
||||||
|
|
||||||
|
/* Default-Theme Vars (scoped, damit data-theme sie überschreiben kann) */
|
||||||
|
--bg-deep: #1a1b1e;
|
||||||
|
--bg-primary: #1e1f22;
|
||||||
|
--bg-secondary: #2b2d31;
|
||||||
|
--bg-tertiary: #313338;
|
||||||
|
--text-normal: #dbdee1;
|
||||||
|
--text-muted: #949ba4;
|
||||||
|
--text-faint: #6d6f78;
|
||||||
|
--accent: #e67e22;
|
||||||
|
--accent-rgb: 230, 126, 34;
|
||||||
|
--accent-hover: #d35400;
|
||||||
|
--border: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Radio Themes ── */
|
||||||
|
.radio-container[data-theme="purple"] {
|
||||||
|
--bg-deep: #13111c;
|
||||||
|
--bg-primary: #1a1726;
|
||||||
|
--bg-secondary: #241f35;
|
||||||
|
--bg-tertiary: #2e2845;
|
||||||
|
--accent: #9b59b6;
|
||||||
|
--accent-rgb: 155, 89, 182;
|
||||||
|
--accent-hover: #8e44ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-container[data-theme="forest"] {
|
||||||
|
--bg-deep: #0f1a14;
|
||||||
|
--bg-primary: #142119;
|
||||||
|
--bg-secondary: #1c2e22;
|
||||||
|
--bg-tertiary: #253a2c;
|
||||||
|
--accent: #2ecc71;
|
||||||
|
--accent-rgb: 46, 204, 113;
|
||||||
|
--accent-hover: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-container[data-theme="ocean"] {
|
||||||
|
--bg-deep: #0a1628;
|
||||||
|
--bg-primary: #0f1e33;
|
||||||
|
--bg-secondary: #162a42;
|
||||||
|
--bg-tertiary: #1e3652;
|
||||||
|
--accent: #3498db;
|
||||||
|
--accent-rgb: 52, 152, 219;
|
||||||
|
--accent-hover: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-container[data-theme="cherry"] {
|
||||||
|
--bg-deep: #1a0f14;
|
||||||
|
--bg-primary: #22141a;
|
||||||
|
--bg-secondary: #301c25;
|
||||||
|
--bg-tertiary: #3e2530;
|
||||||
|
--accent: #e74c6f;
|
||||||
|
--accent-rgb: 231, 76, 111;
|
||||||
|
--accent-hover: #c0392b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Globe ── */
|
/* ── Globe ── */
|
||||||
|
|
@ -914,6 +968,40 @@ html, body {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Theme Selector ── */
|
||||||
|
.radio-theme {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 25;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(30, 31, 34, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-theme-dot {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 150ms ease, border-color 150ms ease;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-theme-dot:hover {
|
||||||
|
transform: scale(1.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-theme-dot.active {
|
||||||
|
border-color: #fff;
|
||||||
|
box-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Station count ── */
|
/* ── Station count ── */
|
||||||
.radio-counter {
|
.radio-counter {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue