navbar changes, radio/webplayer rewrite + additional stations

This commit is contained in:
2025-07-17 06:55:01 -04:00
parent 8f7b0f2719
commit 11b5dfb09d
9 changed files with 330 additions and 126 deletions

View File

@ -23,9 +23,13 @@ initialize = () => {
var sound = new Howl({ var sound = new Howl({
src: ["https://stream.codey.lol/sfm.ogg"], src: ["https://stream.codey.lol/sfm.ogg"],
html5: true, html5: true,
// onend: () => { sound.unload(); } onloaderror: (id) => { console.log(`Fail: ${id}`) },
onplayerror: (id) => { console.log(`Fail b: ${id}`)},
onend: (e) => { sound.play(); }
}); });
console.log(sound);
let currentTime = 0; let currentTime = 0;
let currentDuration = 0; let currentDuration = 0;
let currentUUID = null; let currentUUID = null;

View File

@ -182,9 +182,9 @@ Custom
transform: rotate(90deg); transform: rotate(90deg);
} }
#alert { .alert {
margin-top: 5px; margin-top: 10%;
margin-bottom: 5px; margin-bottom: 1%;
} }
#spinner { #spinner {

View File

@ -53,6 +53,10 @@ section {
padding: 1em; padding: 1em;
} }
.station-tabs {
top: 4rem;
}
.music-container { .music-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -64,6 +68,7 @@ section {
max-width: 700px; max-width: 700px;
box-shadow: 1px 1px 5px 0 rgba(0, 0, 0, 0.3); box-shadow: 1px 1px 5px 0 rgba(0, 0, 0, 0.3);
max-height: 290px; max-height: 290px;
margin-bottom: 50px;
} }
.album-cover { .album-cover {

View File

@ -8,7 +8,7 @@ import Alert from '@mui/joy/Alert';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import CustomToastContainer from '../components/ToastProvider.jsx'; import CustomToastContainer from '../components/ToastProvider.jsx';
import LyricSearch from './LyricSearch.jsx'; import LyricSearch from './LyricSearch.jsx';
import 'primereact/resources/themes/bootstrap4-light-blue/theme.css'; // TEMP import 'primereact/resources/themes/bootstrap4-light-blue/theme.css';
import 'primereact/resources/primereact.min.css'; import 'primereact/resources/primereact.min.css';
export default function Root({child}) { export default function Root({child}) {
@ -24,6 +24,7 @@ export default function Root({child}) {
closeOnClick={true}/> closeOnClick={true}/>
<JoyUIRootIsland> <JoyUIRootIsland>
<Alert <Alert
className="alert"
startDecorator={<WarningIcon />} startDecorator={<WarningIcon />}
variant="soft" variant="soft"
color="danger"> color="danger">

View File

@ -1,57 +1,243 @@
import {useState, React} from "react"; import { useState, useEffect, useRef } from "react";
import jQuery from "jquery"; import { Howl, Howler } from "howler";
import Play from '@mui/icons-material/PlayArrow'; import Play from "@mui/icons-material/PlayArrow";
import Pause from '@mui/icons-material/Pause'; import Pause from "@mui/icons-material/Pause";
<style> import "@styles/player.css";
@import "@styles/player.css";
</style>
export const render = false; const API_URL = "https://api.codey.lol";
Howler.html5PoolSize = 32;
const PlayIcon = () => { const STATIONS = {
return ( main: { label: "Main", streamPath: "/sfm.ogg" },
<Play /> rock: { label: "Rock", streamPath: "/rock.ogg" },
); rap: { label: "Rap", streamPath: "/rap.ogg" },
electronic: { label: "Electronic", streamPath: "/electronic.ogg" },
classical: { label: "Classical", streamPath: "/classical.ogg" },
pop: { label: "Pop", streamPath: "/pop.ogg" },
};
// Global interval tracking (required for Astro + client:only)
let activeInterval = null;
let currentStationForInterval = null;
function clearGlobalMetadataInterval() {
if (activeInterval) {
clearInterval(activeInterval);
activeInterval = null;
currentStationForInterval = null;
} }
const PauseIcon = () => {
return (
<Pause />
)
} }
export function Player() { export function Player() {
const [isPlaying, setPlaying] = useState(false); const [activeStation, setActiveStation] = useState("main");
window.isPlaying = isPlaying; const [isPlaying, setIsPlaying] = useState(false);
const [trackTitle, setTrackTitle] = useState("");
const [trackArtist, setTrackArtist] = useState("");
const [trackGenre, setTrackGenre] = useState("");
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
const [elapsed, setElapsed] = useState(0);
const [duration, setDuration] = useState(0);
const soundRef = useRef(null);
const uuidRef = useRef(null);
const lastStationData = useRef(null);
const formatTime = (seconds) => {
const m = String(Math.floor(seconds / 60)).padStart(2, "0");
const s = String(Math.floor(seconds % 60)).padStart(2, "0");
return `${m}:${s}`;
};
const progress = duration > 0 ? (elapsed / duration) * 100 : 0;
// Create Howl instance on activeStation change
useEffect(() => {
if (soundRef.current) {
soundRef.current.unload();
soundRef.current = null;
}
const streamUrl = "https://stream.codey.lol" + STATIONS[activeStation].streamPath;
const howl = new Howl({
src: [streamUrl],
html5: true,
onend: () => howl.play(),
onplay: () => setIsPlaying(true),
onpause: () => setIsPlaying(false),
onstop: () => setIsPlaying(false),
onloaderror: (id, err) => console.error("Load error", err),
onplayerror: (id, err) => {
console.error("Play error", err);
setTimeout(() => howl.play(), 1000);
},
});
soundRef.current = howl;
uuidRef.current = null;
lastStationData.current = null;
// Autoplay if previously playing
if (isPlaying) {
setTimeout(() => {
if (soundRef.current) {
soundRef.current.play();
}
}, 100);
}
return () => {
howl.stop();
howl.unload();
};
}, [activeStation]);
const togglePlayback = () => {
if (isPlaying) {
if (soundRef.current) {
soundRef.current.pause();
}
setIsPlaying(false);
} else {
// If a previous Howl exists, unload it
if (soundRef.current) {
soundRef.current.stop();
soundRef.current.unload();
}
const streamUrl =
"https://stream.codey.lol" +
STATIONS[activeStation].streamPath +
`?t=${Date.now()}`; // Cache-busting param
const newHowl = new Howl({
src: [streamUrl],
html5: true,
onend: () => newHowl.play(),
onplay: () => {
setIsPlaying(true);
},
onpause: () => setIsPlaying(false),
onstop: () => setIsPlaying(false),
onloaderror: (_, err) => console.error("Load error", err),
onplayerror: (_, err) => {
console.error("Play error", err);
setTimeout(() => newHowl.play(), 1000);
},
});
soundRef.current = newHowl;
newHowl.play();
}
};
// Metadata fetcher: global-safe
useEffect(() => {
clearGlobalMetadataInterval();
currentStationForInterval = activeStation;
const fetchTrackData = async () => {
try {
const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, {
method: "POST",
});
const data = await response.json();
// Ignore stale interval calls
if (currentStationForInterval !== activeStation) return;
if (data.artist === "N/A" && data.song === "N/A") {
if (lastStationData.current !== "offline") {
setTrackTitle("Offline");
setTrackArtist("");
setTrackGenre("");
setCoverArt("/images/radio_art_default.jpg");
setElapsed(0);
setDuration(0);
lastStationData.current = "offline";
}
return;
}
if (data.uuid === uuidRef.current) {
if (lastStationData.current === data.uuid) {
setElapsed(data.elapsed);
setDuration(data.duration);
}
return;
}
uuidRef.current = data.uuid;
lastStationData.current = data.uuid;
setTrackTitle(data.song);
setTrackArtist(data.artist);
setTrackGenre(data.genre !== "N/A" ? data.genre : "");
setCoverArt(`${API_URL}/radio/album_art?station=${activeStation}&_=${Date.now()}`);
setElapsed(data.elapsed);
setDuration(data.duration);
} catch (error) {
console.error("Failed to fetch track data:", error);
}
};
fetchTrackData();
activeInterval = setInterval(fetchTrackData, 1000);
return () => {
clearGlobalMetadataInterval();
};
}, [activeStation]);
return ( return (
<div> <>
<div className="station-tabs flex gap-2 justify-center mb-4 flex-wrap z-10 relative">
{Object.entries(STATIONS).map(([key, { label }]) => (
<button
key={key}
className={`px-3 py-1 rounded-full text-sm font-semibold transition-colors ${
activeStation === key
? "bg-neutral-800 text-white dark:bg-white dark:text-black"
: "bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"
}`}
onClick={() => setActiveStation(key)}
aria-pressed={activeStation === key}
>
{label}
</button>
))}
</div>
<div className="c-containter"> <div className="c-containter">
<div className="music-container"> <div className="music-container mt-8">
<section className="album-cover"> <section className="album-cover">
<img src={coverArt} className="cover" alt="Cover Art" />
<img src="https://api.codey.lol/radio/album_art" className="cover" alt="Cover Art" />
</section> </section>
<section className="music-player"> <section className="music-player">
<h1 className='music-player__header'>serious.FM</h1> <h1 className="music-player__header">serious.FM</h1>
<h1 className="music-player__title"></h1> <h1 className="music-player__title">{trackTitle}</h1>
<h2 className="music-player__author"></h2> <h2 className="music-player__author">{trackArtist}</h2>
<h2 className="music-player__genre"></h2> {trackGenre && <h2 className="music-player__genre">{trackGenre}</h2>}
<div className="music-time"> <div className="music-time">
<p className="music-time__current"></p> <p className="music-time__current">{formatTime(elapsed)}</p>
<p className="music-time__last"></p> <p className="music-time__last">{formatTime(duration - elapsed)}</p>
</div> </div>
<div className="music-bar" id="progress"> <div className="music-bar" id="progress">
<div id="length"></div> <div id="length" style={{ width: `${progress}%` }}></div>
</div> </div>
<div className="music-control"> <div className="music-control">
<div className="music-control__play" id="play"> <div className="music-control__play" id="play">
{isPlaying == false && (<Play onClick={(e) => { setPlaying(!isPlaying); togglePlayback(); } } />)} {!isPlaying ? (
{isPlaying && (<Pause onClick={(e) => { setPlaying(!isPlaying); togglePlayback(); }} />)} <Play onClick={togglePlayback} />
) : (
<Pause onClick={togglePlayback} />
)}
</div> </div>
</div> </div>
</section> </section>
</div> </div>
</div> </div>
</div> </>
)}; );
}

View File

@ -9,7 +9,7 @@ import { metaData } from "../config";
import { SEO } from "astro-seo"; import { SEO } from "astro-seo";
import { getImagePath } from "astro-opengraph-images"; import { getImagePath } from "astro-opengraph-images";
import { JoyUIRootIsland } from "./Components" import { JoyUIRootIsland } from "./Components"
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr"; // your existing theme hook import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr";
import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher"; import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher";
const { title, description = metaData.description, image } = Astro.props; const { title, description = metaData.description, image } = Astro.props;

View File

@ -4,6 +4,9 @@ import RandomMsg from "../components/RandomMsg";
import { buildTime } from '../utils/buildTime.js'; import { buildTime } from '../utils/buildTime.js';
const YEAR = new Date().getFullYear(); const YEAR = new Date().getFullYear();
var ENVIRONMENT = (Astro.url.hostname === "localhost") ? "Dev": "Prod";
--- ---
<div class="footer"> <div class="footer">
<small class="block lg:mt-24 mt-16 text-[#1C1C1C] dark:text-[#D4D4D4] footer-text"> <small class="block lg:mt-24 mt-16 text-[#1C1C1C] dark:text-[#D4D4D4] footer-text">
@ -15,6 +18,7 @@ const YEAR = new Date().getFullYear();
<div style="margin-top: 15px; bottom: 0%"> <div style="margin-top: 15px; bottom: 0%">
<small>Built: {buildTime} UTC</small> <small>Built: {buildTime} UTC</small>
</div> </div>
<span style="font-size: 0.75rem; font-style: italic; margin-left: 100%">{ENVIRONMENT}</span>
</div> </div>
<style> <style>
@media screen and (max-width: 480px) { @media screen and (max-width: 480px) {

View File

@ -2,79 +2,84 @@
import { metaData } from "../config"; import { metaData } from "../config";
import { Icon } from "astro-icon/components"; import { Icon } from "astro-icon/components";
import ExitToApp from '@mui/icons-material/ExitToApp'; import ExitToApp from '@mui/icons-material/ExitToApp';
import HorizontalRuleIcon from '@mui/icons-material/HorizontalRule';
const navItems = { const navItems = [
"/": { name: "Home", className: "", icon: null }, { label: "Home", href: "/" },
"divider-1": { name: "", className: "", icon: HorizontalRuleIcon }, { label: "Radio", href: "/radio" },
"/radio": { name: "Radio", className: "", icon: null }, { blockSeparator: true },
"divider-2": { name: "", className: "", icon: HorizontalRuleIcon }, { label: "Status", href: "https://status.boatson.boats", icon: ExitToApp },
"https://status.boatson.boats": { name: "Status", className: "", icon: ExitToApp }, { label: "Git", href: "https://kode.boatson.boats", icon: ExitToApp },
"divider-3": { name: "", className: "", icon: HorizontalRuleIcon }, { label: "Old Site", href: "https://old.codey.lol", icon: ExitToApp },
"https://kode.boatson.boats": { name: "Git", className: "", icon: ExitToApp }, ];
"divider-4": { name: "", className: "", icon: HorizontalRuleIcon },
"https://old.codey.lol": { name: "Old Site", className: "", icon: ExitToApp }, const currentPath = Astro.url.pathname;
};
--- ---
<script is:inline>
<nav class="lg:mb-16 mb-12 py-5"> toggleTheme = () => {
<div class="flex flex-col md:flex-row md:items-center justify-between"> console.log("Toggle!")
<div class="flex items-center"> const currentTheme = document.documentElement.getAttribute("data-theme");
<a href="/" class="text-3xl font-semibold header-text"> const newTheme = currentTheme === "dark" ? "light" : "dark";
document.dispatchEvent(new CustomEvent("set-theme", { detail: newTheme }));
}
</script>
<nav class="w-full px-4 py-4 bg-transparent">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<a href="/" class="text-xl font-semibold header-text whitespace-nowrap">
{metaData.title} {metaData.title}
</a> </a>
</div>
<div class="flex flex-row gap-4 mt-6 md:mt-0 md:ml-auto items-center">
{
Object.entries(navItems).map(([path, { name, className, icon }]) =>
{
let isVisualSeparator = icon === HorizontalRuleIcon
if (isVisualSeparator) { <ul class="flex flex-wrap items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
{navItems.map((item, index) => {
if (item.blockSeparator) {
return ( return (
<HorizontalRuleIcon client:load <li class="text-neutral-500 dark:text-neutral-500 px-2 select-none" aria-hidden="true">
size="sm"
className="hr" </li>
sx={{ );
color: 'inherit',
}} />
)
} }
const isExternal = item.href?.startsWith("http");
const isActive = item.href === currentPath;
const nextItem = navItems[index + 1];
const shouldShowThinBar = nextItem && !nextItem.blockSeparator;
return ( return (
<>
<li>
<a <a
href={path} href={item.href}
class={`transition-all hover:text-neutral-800 dark:hover:text-neutral-200 flex align-middle relative`}> class={`flex items-center gap-1 px-2 py-1 rounded-md transition-colors
{name} hover:bg-neutral-200 dark:hover:bg-neutral-800
${isActive ? "font-semibold underline underline-offset-4" : ""}`}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
>
{item.label}
{item.icon === ExitToApp && <ExitToApp className="w-4 h-4" client:load />}
</a> </a>
)}) </li>
} {shouldShowThinBar && (
<li class="text-neutral-400 dark:text-neutral-600 hidden sm:inline select-none" aria-hidden="true">
|
</li>
)}
</>
);
})}
<li>
<button <button
id="theme-toggle" id="theme-toggle"
aria-label="Toggle theme" aria-label="Toggle theme"
class="flex items-center justify-center transition-opacity duration-300 hover:opacity-90"> type="button"
class="flex items-center justify-center px-2 py-1 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-opacity"
onclick="toggleTheme()"
>
<Icon <Icon
name="fa6-solid:circle-half-stroke" name="fa6-solid:circle-half-stroke"
class="h-[14px] w-[14px] text-[#1c1c1c] dark:text-[#D4D4D4]" class="h-4 w-4 text-[#1c1c1c] dark:text-[#D4D4D4]"
/> />
</button> </button>
</div> </li>
</ul>
</div> </div>
</nav> </nav>
<script is:inline>
function setTheme(theme) {
document.dispatchEvent(new CustomEvent("set-theme", { detail: theme }));
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute("data-theme");
const newTheme = currentTheme === "dark" ? "light" : "dark";
setTheme(newTheme);
}
document.addEventListener("astro:page-load", () => {
document
.getElementById("theme-toggle")
.addEventListener("click", toggleTheme);
});
</script>

View File

@ -11,6 +11,5 @@ import "@styles/player.css";
</Root> </Root>
<script is:inline src="/scripts/jquery/dist/jquery.js" /> <script is:inline src="/scripts/jquery/dist/jquery.js" />
<script is:inline src="/scripts/howler/dist/howler.js" /> <script is:inline src="/scripts/howler/dist/howler.js" />
<script is:inline src="/scripts/player.js" />
</section> </section>
</Base> </Base>