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({
src: ["https://stream.codey.lol/sfm.ogg"],
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 currentDuration = 0;
let currentUUID = null;

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import Alert from '@mui/joy/Alert';
import WarningIcon from '@mui/icons-material/Warning';
import CustomToastContainer from '../components/ToastProvider.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';
export default function Root({child}) {
@ -24,6 +24,7 @@ export default function Root({child}) {
closeOnClick={true}/>
<JoyUIRootIsland>
<Alert
className="alert"
startDecorator={<WarningIcon />}
variant="soft"
color="danger">

View File

@ -1,57 +1,243 @@
import {useState, React} from "react";
import jQuery from "jquery";
import Play from '@mui/icons-material/PlayArrow';
import Pause from '@mui/icons-material/Pause';
<style>
@import "@styles/player.css";
</style>
import { useState, useEffect, useRef } from "react";
import { Howl, Howler } from "howler";
import Play from "@mui/icons-material/PlayArrow";
import Pause from "@mui/icons-material/Pause";
import "@styles/player.css";
export const render = false;
const API_URL = "https://api.codey.lol";
Howler.html5PoolSize = 32;
const PlayIcon = () => {
return (
<Play />
);
}
const STATIONS = {
main: { label: "Main", streamPath: "/sfm.ogg" },
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" },
};
const PauseIcon = () => {
return (
<Pause />
)
// Global interval tracking (required for Astro + client:only)
let activeInterval = null;
let currentStationForInterval = null;
function clearGlobalMetadataInterval() {
if (activeInterval) {
clearInterval(activeInterval);
activeInterval = null;
currentStationForInterval = null;
}
}
export function Player() {
const [isPlaying, setPlaying] = useState(false);
window.isPlaying = isPlaying;
const [activeStation, setActiveStation] = useState("main");
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 (
<div>
<div className="c-containter">
<div className="music-container">
<section className="album-cover">
<img src="https://api.codey.lol/radio/album_art" className="cover" alt="Cover Art" />
</section>
<section className="music-player">
<h1 className='music-player__header'>serious.FM</h1>
<h1 className="music-player__title"></h1>
<h2 className="music-player__author"></h2>
<h2 className="music-player__genre"></h2>
<div className="music-time">
<p className="music-time__current"></p>
<p className="music-time__last"></p>
</div>
<div className="music-bar" id="progress">
<div id="length"></div>
</div>
<div className="music-control">
<div className="music-control__play" id="play">
{isPlaying == false && (<Play onClick={(e) => { setPlaying(!isPlaying); togglePlayback(); } } />)}
{isPlaying && (<Pause onClick={(e) => { setPlaying(!isPlaying); togglePlayback(); }} />)}
</div>
</div>
</section>
<>
<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="music-container mt-8">
<section className="album-cover">
<img src={coverArt} className="cover" alt="Cover Art" />
</section>
<section className="music-player">
<h1 className="music-player__header">serious.FM</h1>
<h1 className="music-player__title">{trackTitle}</h1>
<h2 className="music-player__author">{trackArtist}</h2>
{trackGenre && <h2 className="music-player__genre">{trackGenre}</h2>}
<div className="music-time">
<p className="music-time__current">{formatTime(elapsed)}</p>
<p className="music-time__last">{formatTime(duration - elapsed)}</p>
</div>
<div className="music-bar" id="progress">
<div id="length" style={{ width: `${progress}%` }}></div>
</div>
<div className="music-control">
<div className="music-control__play" id="play">
{!isPlaying ? (
<Play onClick={togglePlayback} />
) : (
<Pause onClick={togglePlayback} />
)}
</div>
</div>
</section>
</div>
</div>
</div>
)};
</div>
</>
);
}

View File

@ -9,7 +9,7 @@ import { metaData } from "../config";
import { SEO } from "astro-seo";
import { getImagePath } from "astro-opengraph-images";
import { JoyUIRootIsland } from "./Components"
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr"; // your existing theme hook
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr";
import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher";
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';
const YEAR = new Date().getFullYear();
var ENVIRONMENT = (Astro.url.hostname === "localhost") ? "Dev": "Prod";
---
<div class="footer">
<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%">
<small>Built: {buildTime} UTC</small>
</div>
<span style="font-size: 0.75rem; font-style: italic; margin-left: 100%">{ENVIRONMENT}</span>
</div>
<style>
@media screen and (max-width: 480px) {

View File

@ -2,79 +2,84 @@
import { metaData } from "../config";
import { Icon } from "astro-icon/components";
import ExitToApp from '@mui/icons-material/ExitToApp';
import HorizontalRuleIcon from '@mui/icons-material/HorizontalRule';
const navItems = {
"/": { name: "Home", className: "", icon: null },
"divider-1": { name: "", className: "", icon: HorizontalRuleIcon },
"/radio": { name: "Radio", className: "", icon: null },
"divider-2": { name: "", className: "", icon: HorizontalRuleIcon },
"https://status.boatson.boats": { name: "Status", className: "", icon: ExitToApp },
"divider-3": { name: "", className: "", icon: HorizontalRuleIcon },
"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 navItems = [
{ label: "Home", href: "/" },
{ label: "Radio", href: "/radio" },
{ blockSeparator: true },
{ label: "Status", href: "https://status.boatson.boats", icon: ExitToApp },
{ label: "Git", href: "https://kode.boatson.boats", icon: ExitToApp },
{ label: "Old Site", href: "https://old.codey.lol", icon: ExitToApp },
];
const currentPath = Astro.url.pathname;
---
<nav class="lg:mb-16 mb-12 py-5">
<div class="flex flex-col md:flex-row md:items-center justify-between">
<div class="flex items-center">
<a href="/" class="text-3xl font-semibold header-text">
{metaData.title}
</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) {
return (
<HorizontalRuleIcon client:load
size="sm"
className="hr"
sx={{
color: 'inherit',
}} />
)
}
return (
<a
href={path}
class={`transition-all hover:text-neutral-800 dark:hover:text-neutral-200 flex align-middle relative`}>
{name}
</a>
)})
}
<button
id="theme-toggle"
aria-label="Toggle theme"
class="flex items-center justify-center transition-opacity duration-300 hover:opacity-90">
<Icon
name="fa6-solid:circle-half-stroke"
class="h-[14px] w-[14px] text-[#1c1c1c] dark:text-[#D4D4D4]"
/>
</button>
</div>
</div>
</nav>
<script is:inline>
function setTheme(theme) {
document.dispatchEvent(new CustomEvent("set-theme", { detail: theme }));
}
function toggleTheme() {
toggleTheme = () => {
console.log("Toggle!")
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);
});
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}
</a>
<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 (
<li class="text-neutral-500 dark:text-neutral-500 px-2 select-none" aria-hidden="true">
</li>
);
}
const isExternal = item.href?.startsWith("http");
const isActive = item.href === currentPath;
const nextItem = navItems[index + 1];
const shouldShowThinBar = nextItem && !nextItem.blockSeparator;
return (
<>
<li>
<a
href={item.href}
class={`flex items-center gap-1 px-2 py-1 rounded-md transition-colors
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>
</li>
{shouldShowThinBar && (
<li class="text-neutral-400 dark:text-neutral-600 hidden sm:inline select-none" aria-hidden="true">
|
</li>
)}
</>
);
})}
<li>
<button
id="theme-toggle"
aria-label="Toggle theme"
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
name="fa6-solid:circle-half-stroke"
class="h-4 w-4 text-[#1c1c1c] dark:text-[#D4D4D4]"
/>
</button>
</li>
</ul>
</div>
</nav>

View File

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