From 90ef17932c7e96d61912007036f706a1187b4621 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 02:15:06 +0100 Subject: [PATCH] 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 --- web/src/plugins/radio/RadioTab.tsx | 79 +++++++++++++++++++++++---- web/src/styles.css | 88 ++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 11 deletions(-) diff --git a/web/src/plugins/radio/RadioTab.tsx b/web/src/plugins/radio/RadioTab.tsx index 067e0c4..ec4c887 100644 --- a/web/src/plugins/radio/RadioTab.tsx +++ b/web/src/plugins/radio/RadioTab.tsx @@ -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(null); const globeRef = useRef(null); + const rotationResumeRef = useRef>(undefined); + const [theme, setTheme] = useState(() => localStorage.getItem('radio-theme') || 'default'); const [places, setPlaces] = useState([]); const [selectedPlace, setSelectedPlace] = useState(null); const [stations, setStations] = useState([]); @@ -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) => - `
` + + `
` + `${d.title}
${d.country}
` ) .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 ( -
+
{/* ── Globe ── */}
+ {/* ── Theme Selector ── */} +
+ {THEMES.map(t => ( +
setTheme(t.id)} + /> + ))} +
+ {/* ── Search ── */}
diff --git a/web/src/styles.css b/web/src/styles.css index ea3a539..cf2032e 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -359,6 +359,60 @@ html, body { height: 100%; overflow: hidden; 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 ── */ @@ -914,6 +968,40 @@ html, body { 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 ── */ .radio-counter { position: absolute;