radio web player changes / iOS support (still WIP, only main station supported; iOS Safari does not support streaming of Ogg/Vorbis, so an HLS stream output was created for that station.

This commit is contained in:
2025-07-19 08:50:04 -04:00
parent cc833e6694
commit f8bf23dd4f
2 changed files with 89 additions and 116 deletions

View File

@@ -266,3 +266,15 @@ body {
width: 24px !important; width: 24px !important;
} }
} }
.tap-overlay {
position: fixed;
inset: 0;
z-index: 50;
background-color: rgba(0, 0, 0, 0.8);
color: white;
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}

View File

@@ -6,7 +6,7 @@ import "@styles/player.css";
const API_URL = "https://api.codey.lol"; const API_URL = "https://api.codey.lol";
Howler.html5PoolSize = 32; Howler.html5PoolSize = 32;
const isIOS = /iP(ad|hone|od)/.test(navigator.userAgent);
const STATIONS = { const STATIONS = {
main: { label: "Main", streamPath: "/sfm.ogg" }, main: { label: "Main", streamPath: "/sfm.ogg" },
rock: { label: "Rock", streamPath: "/rock.ogg" }, rock: { label: "Rock", streamPath: "/rock.ogg" },
@@ -16,12 +16,6 @@ const STATIONS = {
pop: { label: "Pop", streamPath: "/pop.ogg" }, pop: { label: "Pop", streamPath: "/pop.ogg" },
}; };
// Detect iOS user agent (can refine if needed)
const isIOS = (() => {
if (typeof window === "undefined") return false;
return /iP(ad|hone|od)/.test(navigator.userAgent);
})();
let activeInterval = null; let activeInterval = null;
let currentStationForInterval = null; let currentStationForInterval = null;
@@ -42,6 +36,7 @@ export function Player() {
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg"); const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [userHasInteracted, setUserHasInteracted] = useState(false);
const soundRef = useRef(null); const soundRef = useRef(null);
const uuidRef = useRef(null); const uuidRef = useRef(null);
@@ -55,131 +50,71 @@ export function Player() {
const progress = duration > 0 ? (elapsed / duration) * 100 : 0; const progress = duration > 0 ? (elapsed / duration) * 100 : 0;
// UseEffect: create Howl on station change (as before) const playStream = () => {
useEffect(() => { let streamPath = STATIONS[activeStation].streamPath;
if (activeStation === "main" && isIOS) {
streamPath = "/hls/sfm.m3u8";
}
const streamUrl =
"https://stream.codey.lol" +
streamPath +
`?t=${Date.now()}`;
if (soundRef.current) { if (soundRef.current) {
soundRef.current.stop();
soundRef.current.unload(); soundRef.current.unload();
soundRef.current = null;
} }
// On iOS we defer creation until user plays, so skip creating Howl here const newHowl = new Howl({
if (isIOS) {
setIsPlaying(false);
return;
}
const streamUrl = "https://stream.codey.lol" + STATIONS[activeStation].streamPath;
const howl = new Howl({
src: [streamUrl], src: [streamUrl],
html5: true, html5: true,
onend: () => howl.play(), onend: () => newHowl.play(),
onplay: () => setIsPlaying(true), onplay: () => setIsPlaying(true),
onpause: () => setIsPlaying(false), onpause: () => setIsPlaying(false),
onstop: () => setIsPlaying(false), onstop: () => setIsPlaying(false),
onloaderror: (id, err) => console.error("Load error", err), onloaderror: (_, err) => console.error("Load error", err),
onplayerror: (id, err) => { onplayerror: (_, err) => {
console.error("Play error", err); console.error("Play error", err);
setTimeout(() => howl.play(), 1000); setTimeout(() => newHowl.play(), 1000);
}, },
}); });
soundRef.current = howl; soundRef.current = newHowl;
uuidRef.current = null; newHowl.play();
lastStationData.current = null;
// Autoplay if previously playing
if (isPlaying) {
setTimeout(() => {
if (soundRef.current) {
soundRef.current.play();
}
}, 100);
}
return () => {
howl.stop();
howl.unload();
};
}, [activeStation]);
// Toggle playback handler
const togglePlayback = () => {
if (isPlaying) {
if (soundRef.current) {
soundRef.current.pause();
}
setIsPlaying(false);
} else {
if (isIOS) {
// On iOS, defer creation until user explicitly plays
if (soundRef.current) {
soundRef.current.stop();
soundRef.current.unload();
soundRef.current = null;
}
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();
} else {
// Desktop browsers: current logic
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 (unchanged) const togglePlayback = () => {
if (isIOS && !userHasInteracted) return; // block only on iOS until interaction
if (isPlaying) {
soundRef.current?.pause();
setIsPlaying(false);
} else {
if (!soundRef.current) {
playStream();
} else {
soundRef.current.play();
}
setIsPlaying(true);
}
};
useEffect(() => {
if (!userHasInteracted) return;
if (isPlaying) {
playStream();
} else {
if (soundRef.current) {
soundRef.current.stop();
soundRef.current.unload();
soundRef.current = null;
}
}
}, [activeStation]);
useEffect(() => { useEffect(() => {
clearGlobalMetadataInterval(); clearGlobalMetadataInterval();
currentStationForInterval = activeStation; currentStationForInterval = activeStation;
const fetchTrackData = async () => { const fetchTrackData = async () => {
@@ -235,6 +170,25 @@ export function Player() {
return ( return (
<> <>
{/* Unlock overlay */}
{!userHasInteracted && isIOS && (
<div
className="fixed inset-0 z-50 bg-black bg-opacity-80 flex items-center justify-center text-white text-xl cursor-pointer"
onClick={() => {
setUserHasInteracted(true);
if (Howler.ctx?.state === "suspended") {
Howler.ctx.resume();
}
playStream(); // <-- Immediate play within same gesture
}}
>
Tap to Start Audio
</div>
)}
<div className="station-tabs flex gap-2 justify-center mb-4 flex-wrap z-10 relative"> <div className="station-tabs flex gap-2 justify-center mb-4 flex-wrap z-10 relative">
{Object.entries(STATIONS).map(([key, { label }]) => ( {Object.entries(STATIONS).map(([key, { label }]) => (
<button <button
@@ -270,7 +224,14 @@ export function Player() {
<div id="length" style={{ width: `${progress}%` }}></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" onClick={togglePlayback} role="button" tabIndex={0} aria-pressed={isPlaying}> <div
className="music-control__play"
id="play"
onClick={togglePlayback}
role="button"
tabIndex={0}
aria-pressed={isPlaying}
>
{!isPlaying ? <Play /> : <Pause />} {!isPlaying ? <Play /> : <Pause />}
</div> </div>
</div> </div>