Initial commit

This commit is contained in:
2026-04-26 13:01:40 +02:00
commit 7e256a669e
28 changed files with 5711 additions and 0 deletions

344
src/App.jsx Normal file
View File

@@ -0,0 +1,344 @@
import { useEffect, useState } from 'react';
function formatClock(date) {
return new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
function formatDate(date) {
return new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
}).format(date);
}
function EditIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
);
}
function ListIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
);
}
function InstallIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 3v12" />
<path d="m7 10 5 5 5-5" />
<path d="M5 21h14" />
</svg>
);
}
function CastIcon({ size = 22 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 16.1A5 5 0 0 1 5.9 20" />
<path d="M2 12.05A9 9 0 0 1 9.95 20" />
<path d="M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6" />
</svg>
);
}
function VolumeIcon() {
return (
<svg id="icon-volume" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
);
}
function MutedIcon() {
return (
<svg id="icon-muted" className="hidden" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
);
}
function HeaderControls() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const intervalId = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(intervalId);
}, []);
return (
<header>
<div className="header-top-row">
<div className="brand-block">
<img className="brand-logo" src="assets/radioplayer-logo-192.png" alt="" aria-hidden="true" />
<div className="brand-copy">
<div className="app-title">RadioPlayer</div>
<time className="datetime" dateTime={now.toISOString()}>
<span className="datetime-time">{formatClock(now)}</span>
<span className="datetime-date">{formatDate(now)}</span>
</time>
</div>
</div>
<div className="header-icons-left">
<button id="edit-stations-btn" className="icon-btn" title="Edit Stations" aria-label="Edit Stations" type="button">
<EditIcon />
</button>
<button id="stations-list-btn" className="icon-btn" aria-label="All Stations" title="All Stations" type="button">
<ListIcon />
</button>
<button id="install-app-btn" className="icon-btn install-btn hidden" title="Install app" aria-label="Install app" type="button">
<InstallIcon />
<span>Install</span>
</button>
<button id="cast-btn" className="icon-btn cast-btn" title="Cast to device" aria-label="Cast to device" type="button">
<CastIcon />
<span>Cast</span>
</button>
</div>
<div className="header-close" />
</div>
</header>
);
}
function ArtworkPanel() {
return (
<section className="artwork-section">
<div className="artwork-stack">
<div className="artwork-container">
<div className="artwork-placeholder">
<img id="station-logo-img" className="station-logo-img hidden" alt="station logo" />
<span className="station-logo-text">1</span>
</div>
</div>
<div id="artwork-coverflow" className="artwork-coverflow" aria-label="Stations">
<button id="artwork-prev" className="coverflow-arrow left" aria-label="Previous station" type="button">&lsaquo;</button>
<div id="artwork-coverflow-stage" className="artwork-coverflow-stage" role="list" aria-label="Station icons" />
<button id="artwork-next" className="coverflow-arrow right" aria-label="Next station" type="button">&rsaquo;</button>
</div>
</div>
</section>
);
}
function TrackInfo() {
return (
<section className="track-info">
<h2 id="station-name" />
<div id="now-playing" className="now-playing hidden" aria-live="polite">
<div id="now-artist" className="now-artist" aria-hidden="false" />
<div id="now-title" className="now-title" aria-hidden="false" />
</div>
<p id="station-subtitle" />
<div id="status-indicator" className="status-indicator-wrap" aria-hidden="true">
<span className="status-dot" />
<span id="status-text" />
<span id="engine-badge" className="engine-badge engine-html" title="HTML5 Audio playback">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round" aria-hidden="true">
<path d="M4 15V9" />
<path d="M8 19V5" />
<path d="M12 16V8" />
<path d="M16 18V6" />
<path d="M20 15V9" />
</svg>
<span id="engine-label">HTML5</span>
</span>
</div>
<div id="cast-output-row" className="cast-output-row hidden" aria-live="polite">
<span className="cast-output-label">Output:</span>
<button id="cast-output-btn" className="cast-output-toggle" aria-pressed="false"
title="Toggle: Cast only / Cast + This computer" type="button">
<span id="cast-output-icon">
<CastIcon size={14} />
</span>
<span id="cast-output-text">Cast only</span>
</button>
</div>
</section>
);
}
function ProgressBar() {
return (
<div className="progress-container">
<div className="progress-bar">
<div className="progress-fill" />
<div className="progress-handle" />
</div>
</div>
);
}
function PlayerControls() {
return (
<section className="controls-section">
<button id="prev-btn" className="control-btn secondary" aria-label="Previous Station" type="button">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
</svg>
</button>
<button id="play-btn" className="control-btn primary" aria-label="Play" type="button">
<div className="icon-container">
<svg id="icon-play" width="32" height="32" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M8 5v14l11-7z" />
</svg>
<svg id="icon-stop" className="hidden" width="32" height="32" viewBox="0 0 24 24" fill="currentColor"
aria-hidden="true">
<path d="M6 6h12v12H6z" />
</svg>
</div>
</button>
<button id="next-btn" className="control-btn secondary" aria-label="Next Station" type="button">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</svg>
</button>
</section>
);
}
function VolumeControl() {
return (
<section className="volume-section">
<button id="mute-btn" className="icon-btn small" type="button" aria-label="Mute">
<VolumeIcon />
<MutedIcon />
</button>
<div className="slider-container">
<input type="range" id="volume-slider" min="0" max="100" defaultValue="80" aria-label="Volume" />
</div>
<span id="volume-value">80%</span>
</section>
);
}
function RetroStarfield() {
const stars = Array.from({ length: 120 }, (_, index) => {
const angle = (index * 137.508) % 360;
const radius = 90 + ((index * 47) % 520);
const verticalBias = ((index * 29) % 180) - 90;
const x0 = Math.cos(angle * Math.PI / 180) * radius * 0.34;
const y0 = Math.sin(angle * Math.PI / 180) * radius * 0.24 + verticalBias * 0.18;
const x1 = Math.cos((angle + 24) * Math.PI / 180) * radius * 1.08;
const y1 = Math.sin((angle + 18) * Math.PI / 180) * radius * 0.72 + verticalBias;
const size = 2 + (index % 6) * 0.75;
const duration = 7 + (index % 9) * 1.1;
const delay = -((index * 0.71) % duration);
return {
id: index,
style: {
'--x0': `${x0.toFixed(1)}px`,
'--y0': `${y0.toFixed(1)}px`,
'--x1': `${x1.toFixed(1)}px`,
'--y1': `${y1.toFixed(1)}px`,
'--star-size': `${size.toFixed(1)}px`,
'--star-duration': `${duration.toFixed(1)}s`,
'--star-delay': `${delay.toFixed(1)}s`,
},
};
});
return (
<div className="starfield" aria-hidden="true">
<div className="starfield-plane">
{stars.map((star) => (
<span key={star.id} className="star" style={star.style} />
))}
</div>
</div>
);
}
function StationsOverlay() {
return (
<div id="cast-overlay" className="overlay hidden" aria-hidden="true">
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="deviceTitle">
<h2 id="deviceTitle">Stations</h2>
<ul id="device-list" className="device-list">
<li className="device">
<div className="device-main">Loading...</div>
<div className="device-sub">Preparing stations</div>
</li>
</ul>
<button id="close-overlay" className="btn cancel" type="button">Close</button>
</div>
</div>
);
}
function EditorOverlay() {
return (
<div id="editor-overlay" className="overlay hidden" aria-hidden="true">
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="editorTitle">
<h2 id="editorTitle">Edit Stations</h2>
<ul id="editor-list" className="device-list" />
<form id="add-station-form">
<div className="field-row">
<input id="us_title" placeholder="Title" required />
</div>
<div className="field-row">
<input id="us_url" placeholder="Stream URL" required />
</div>
<div className="field-row">
<input id="us_logo" placeholder="Logo URL (optional)" />
</div>
<div className="field-row">
<input id="us_www" placeholder="Website (optional)" />
</div>
<input type="hidden" id="us_id" />
<input type="hidden" id="us_index" />
<div className="editor-actions">
<button id="us_save_btn" className="btn cancel" type="submit">Save</button>
<button id="editor-close-btn" className="btn secondary" type="button">Close</button>
</div>
</form>
</div>
</div>
);
}
export default function App() {
return (
<div className="app-container">
<main className="glass-card">
<RetroStarfield />
<HeaderControls />
<ArtworkPanel />
<TrackInfo />
<ProgressBar />
<PlayerControls />
<VolumeControl />
<StationsOverlay />
<EditorOverlay />
</main>
</div>
);
}