Refactor AudioPlayer component: streamline state management, add lyric display, and improve stream handling
This commit is contained in:
@@ -20,7 +20,7 @@ body {
|
||||
.music-container {
|
||||
width: 800px; /* fixed desktop width */
|
||||
max-width: 90vw; /* prevent overflow on smaller screens */
|
||||
height: 290px;
|
||||
height: auto !important;
|
||||
margin: 0 auto 120px auto; /* increased bottom margin */
|
||||
overflow-x: visible; /* allow horizontal overflow if needed */
|
||||
overflow-y: hidden;
|
||||
@@ -57,7 +57,7 @@ body {
|
||||
flex: 1 1 70%; /* Take remaining ~70% */
|
||||
max-width: 70%;
|
||||
width: auto;
|
||||
height: 100%; /* Match container height */
|
||||
height: auto !important; /* Match container height */
|
||||
padding: 1em;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
@@ -174,6 +174,9 @@ body {
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
margin: 0 1em;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.music-control__backward,
|
||||
@@ -220,3 +223,76 @@ body {
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
}
|
||||
.progress-bar-container {
|
||||
position: relative;
|
||||
margin: 0.5rem 0;
|
||||
height: 0.5rem; /* Ensure visible height */
|
||||
background-color: #e5e7eb; /* fallback light gray */
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
transition: width 0.2s linear;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.lrc-text {
|
||||
max-height: 125px; /* limit block height */
|
||||
max-width: 100%;
|
||||
overflow-y: auto; /* scroll if lyrics exceed height */
|
||||
margin-top: 1rem;
|
||||
padding: 0 1rem;
|
||||
text-align: center;
|
||||
font-family: "Mukta", sans-serif;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: #ccc; /* subtle default color */
|
||||
user-select: none; /* prevent accidental text selection */
|
||||
background-color: rgba(0, 0, 0, 0.05); /* very light highlight behind text */
|
||||
border-radius: 0.375rem; /* rounded corners */
|
||||
box-shadow: inset 0 0 5px rgba(0,0,0,0.8);
|
||||
box-sizing: border-box;
|
||||
scrollbar-width: thin; /* Firefox thinner scrollbar */
|
||||
scrollbar-color: #999 transparent;
|
||||
}
|
||||
|
||||
.lrc-text::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.lrc-text::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.lrc-text::-webkit-scrollbar-thumb {
|
||||
background-color: #999;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Each lyric line */
|
||||
.lrc-line {
|
||||
margin: 0.1rem 0;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
line-height: 1.4;
|
||||
transition: color 0.3s ease, font-weight 0.3s ease;
|
||||
color: inherit;
|
||||
cursor: default;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Highlight the active lyric line */
|
||||
.lrc-line.active {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-shadow: 0 0 4px rgba(212, 175, 55, 0.6);
|
||||
}
|
||||
|
||||
|
||||
.lrc-line:hover {
|
||||
color: #4fa2ff;
|
||||
text-decoration: dotted underline;
|
||||
}
|
||||
|
@@ -15,17 +15,6 @@ const STATIONS = {
|
||||
pop: { label: "Pop" },
|
||||
};
|
||||
|
||||
let activeInterval = null;
|
||||
let currentStationForInterval = null;
|
||||
|
||||
function clearGlobalMetadataInterval() {
|
||||
if (activeInterval) {
|
||||
clearInterval(activeInterval);
|
||||
activeInterval = null;
|
||||
currentStationForInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function Player() {
|
||||
const [activeStation, setActiveStation] = useState("main");
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -34,39 +23,51 @@ export function Player() {
|
||||
const [trackGenre, setTrackGenre] = useState("");
|
||||
const [trackAlbum, setTrackAlbum] = useState("");
|
||||
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [lyrics, setLyrics] = useState([]);
|
||||
const [currentLyricIndex, setCurrentLyricIndex] = useState(0);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [trackDuration, setTrackDuration] = useState(0);
|
||||
|
||||
const audioRef = useRef(null);
|
||||
const hlsRef = useRef(null);
|
||||
const uuidRef = useRef(null);
|
||||
const lastStationData = useRef(null);
|
||||
const audioElement = useRef(null);
|
||||
const hlsInstance = useRef(null);
|
||||
const currentTrackUuid = useRef(null);
|
||||
const baseTrackElapsed = useRef(0);
|
||||
const lastUpdateTimestamp = useRef(Date.now());
|
||||
|
||||
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 mins = String(Math.floor(seconds / 60)).padStart(2, "0");
|
||||
const secs = String(Math.floor(seconds % 60)).padStart(2, "0");
|
||||
return `${mins}:${secs}`;
|
||||
};
|
||||
|
||||
const progress = duration > 0 ? (elapsed / duration) * 100 : 0;
|
||||
|
||||
const playStream = () => {
|
||||
const lcStation = activeStation.toLowerCase();
|
||||
const streamUrl = `https://stream.codey.lol/hls/${lcStation}/${lcStation}.m3u8?t=${Date.now()}`;
|
||||
|
||||
// Cleanup
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
|
||||
const audio = audioRef.current;
|
||||
// Initialize or switch HLS stream
|
||||
const initializeStream = (station) => {
|
||||
const audio = audioElement.current;
|
||||
if (!audio) return;
|
||||
const streamUrl = `https://stream.codey.lol/hls/${station}/${station}.m3u8`;
|
||||
|
||||
// Clean up previous HLS
|
||||
if (hlsInstance.current) {
|
||||
hlsInstance.current.destroy();
|
||||
hlsInstance.current = null;
|
||||
}
|
||||
audio.pause();
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
|
||||
// Handle audio load errors
|
||||
audio.onerror = () => {
|
||||
setTrackTitle("Offline");
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
if (audio.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
audio.src = streamUrl;
|
||||
audio.load();
|
||||
audio.play().then(() => setIsPlaying(true)).catch(console.error);
|
||||
audio.play().then(() => setIsPlaying(true)).catch(() => {
|
||||
setTrackTitle("Offline");
|
||||
setIsPlaying(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,197 +76,187 @@ export function Player() {
|
||||
return;
|
||||
}
|
||||
|
||||
const hls = new Hls({
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
autoStartLoad: true,
|
||||
startLevel: -1,
|
||||
});
|
||||
|
||||
const hls = new Hls({ lowLatencyMode: true, abrEnabled: false });
|
||||
hlsInstance.current = hls;
|
||||
hls.attachMedia(audio);
|
||||
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
hls.loadSource(streamUrl);
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl));
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
audio.play().then(() => setIsPlaying(true)).catch(console.error);
|
||||
audio.play().then(() => setIsPlaying(true)).catch(() => {
|
||||
setTrackTitle("Offline");
|
||||
setIsPlaying(false);
|
||||
});
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.warn("HLS error:", data);
|
||||
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
case Hls.ErrorTypes.OTHER_ERROR:
|
||||
console.error("Fatal HLS error, attempting recovery…");
|
||||
try {
|
||||
hls.destroy();
|
||||
hlsRef.current = null;
|
||||
setIsPlaying(false);
|
||||
// Delay before trying to reconnect
|
||||
setTimeout(() => {
|
||||
playStream();
|
||||
}, 3000);
|
||||
} catch (e) {
|
||||
console.error("HLS recovery failed:", e);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
hls.destroy();
|
||||
hlsRef.current = null;
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
hlsRef.current = hls;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio && isPlaying && audio.readyState < 2) {
|
||||
console.warn("Playback seems stalled. Reloading stream.");
|
||||
playStream();
|
||||
}
|
||||
}, 15000); // Check every 15 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isPlaying]);
|
||||
|
||||
const togglePlayback = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
audio.play().then(() => setIsPlaying(true)).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.src = "";
|
||||
setIsPlaying(false);
|
||||
}
|
||||
|
||||
playStream();
|
||||
|
||||
return () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeStation]);
|
||||
|
||||
useEffect(() => {
|
||||
clearGlobalMetadataInterval();
|
||||
currentStationForInterval = activeStation;
|
||||
|
||||
const setPageTitle = (artist, song) => {
|
||||
document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStation}]`;
|
||||
};
|
||||
|
||||
const fetchTrackData = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (currentStationForInterval !== activeStation) return;
|
||||
|
||||
if (data.artist === "N/A" && data.song === "N/A") {
|
||||
if (lastStationData.current !== "offline") {
|
||||
hlsInstance.current = null;
|
||||
setTrackTitle("Offline");
|
||||
setTrackArtist("");
|
||||
setTrackAlbum("");
|
||||
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 : "");
|
||||
setTrackAlbum(data.album);
|
||||
setPageTitle(data.artist, data.song);
|
||||
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);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
fetchTrackData();
|
||||
activeInterval = setInterval(fetchTrackData, 1000);
|
||||
// Update elapsed time smoothly
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
return () => clearGlobalMetadataInterval();
|
||||
const intervalId = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const deltaSec = (now - lastUpdateTimestamp.current) / 1000;
|
||||
let liveElapsed = baseTrackElapsed.current + deltaSec;
|
||||
if (trackDuration && liveElapsed > trackDuration) liveElapsed = trackDuration;
|
||||
setElapsedTime(liveElapsed);
|
||||
}, 200);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [isPlaying, trackDuration]);
|
||||
|
||||
|
||||
|
||||
// Use both base elapsed and audio time for precise lyric syncing
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !lyrics.length) return;
|
||||
|
||||
const lyricsInterval = setInterval(() => {
|
||||
let newIndex = 0;
|
||||
for (let i = 0; i < lyrics.length; i++) {
|
||||
if (elapsedTime >= lyrics[i].timestamp) newIndex = i;
|
||||
else break;
|
||||
}
|
||||
setCurrentLyricIndex(newIndex);
|
||||
}, 200);
|
||||
|
||||
return () => clearInterval(lyricsInterval);
|
||||
}, [isPlaying, lyrics, elapsedTime]);
|
||||
|
||||
|
||||
|
||||
|
||||
// Scroll active lyric into view
|
||||
useEffect(() => {
|
||||
const activeElement = document.querySelector('.lrc-line.active');
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [currentLyricIndex]);
|
||||
|
||||
// Handle station changes: reset and start new stream
|
||||
useEffect(() => {
|
||||
setIsPlaying(false);
|
||||
setTrackTitle("");
|
||||
setTrackArtist("");
|
||||
setTrackGenre("");
|
||||
setTrackAlbum("");
|
||||
setCoverArt("/images/radio_art_default.jpg");
|
||||
|
||||
setLyrics([]);
|
||||
setCurrentLyricIndex(0);
|
||||
setElapsedTime(0);
|
||||
setTrackDuration(0);
|
||||
|
||||
currentTrackUuid.current = null;
|
||||
baseTrackElapsed.current = 0;
|
||||
lastUpdateTimestamp.current = Date.now();
|
||||
|
||||
initializeStream(activeStation);
|
||||
}, [activeStation]);
|
||||
|
||||
const remaining = duration - elapsed;
|
||||
// Fetch metadata and lyrics periodically
|
||||
useEffect(() => {
|
||||
const fetchMetadataAndLyrics = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, { method: 'POST' });
|
||||
const trackData = await response.json();
|
||||
|
||||
if (trackData.artist === 'N/A') {
|
||||
setTrackTitle('Offline');
|
||||
setLyrics([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trackData.uuid !== currentTrackUuid.current) {
|
||||
currentTrackUuid.current = trackData.uuid;
|
||||
|
||||
setTrackTitle(trackData.song);
|
||||
setTrackArtist(trackData.artist);
|
||||
setTrackGenre(trackData.genre || '');
|
||||
setTrackAlbum(trackData.album);
|
||||
setCoverArt(`${API_URL}/radio/album_art?station=${activeStation}&_=${Date.now()}`);
|
||||
|
||||
baseTrackElapsed.current = trackData.elapsed;
|
||||
lastUpdateTimestamp.current = Date.now();
|
||||
setElapsedTime(trackData.elapsed);
|
||||
|
||||
setTrackDuration(trackData.duration);
|
||||
|
||||
setLyrics([]);
|
||||
setCurrentLyricIndex(0);
|
||||
|
||||
// Fetch lyrics as before
|
||||
const lyricsResponse = await fetch(`${API_URL}/lyric/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
a: trackData.artist,
|
||||
s: trackData.song,
|
||||
excluded_sources: [],
|
||||
extra: false,
|
||||
lrc: true,
|
||||
src: 'WEB-RADIO',
|
||||
}),
|
||||
});
|
||||
const lyricsData = await lyricsResponse.json();
|
||||
if (!lyricsData.err && Array.isArray(lyricsData.lrc)) {
|
||||
const parsedLyrics = lyricsData.lrc.map(({ timeTag, words }) => {
|
||||
const [mins, rest] = timeTag.split(':');
|
||||
const secs = parseFloat(rest);
|
||||
return { timestamp: Number(mins) * 60 + secs, line: words };
|
||||
});
|
||||
setLyrics(parsedLyrics);
|
||||
setCurrentLyricIndex(0);
|
||||
}
|
||||
} else {
|
||||
// Same track - do not update elapsed or elapsed base here
|
||||
setTrackDuration(trackData.duration);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching metadata:', error);
|
||||
setTrackTitle('Offline');
|
||||
setLyrics([]);
|
||||
}
|
||||
};
|
||||
fetchMetadataAndLyrics();
|
||||
const metadataInterval = setInterval(fetchMetadataAndLyrics, 700);
|
||||
return () => clearInterval(metadataInterval);
|
||||
}, [activeStation]);
|
||||
|
||||
const progressColorClass =
|
||||
progress >= 90
|
||||
? "bg-red-500 dark:bg-red-400"
|
||||
: progress >= 75 || remaining <= 20
|
||||
? "bg-yellow-400 dark:bg-yellow-300"
|
||||
: "bg-blue-500 dark:bg-blue-400";
|
||||
|
||||
return (
|
||||
<>
|
||||
<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(([stationKey, { 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"
|
||||
key={stationKey}
|
||||
onClick={() => setActiveStation(stationKey)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-semibold transition-colors ${activeStation === stationKey
|
||||
? '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}
|
||||
aria-pressed={activeStation === stationKey}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="c-containter">
|
||||
<div className="music-container mt-8">
|
||||
<section className="album-cover">
|
||||
<div className="music-player__album" title="Album">{trackAlbum}</div>
|
||||
<img
|
||||
src={coverArt}
|
||||
className="cover"
|
||||
title={trackAlbum ? `"${trackAlbum}" Cover Art` : "Cover Art"}
|
||||
alt={trackAlbum ? `"${trackAlbum}" Cover Art` : "Cover Art"}
|
||||
/>
|
||||
<div className="music-player__album" title="Album">
|
||||
{trackAlbum}
|
||||
</div>
|
||||
<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>
|
||||
@@ -273,32 +264,48 @@ export function Player() {
|
||||
{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(remaining)}</p>
|
||||
<p className="music-time__current">{formatTime(elapsedTime)}</p>
|
||||
<p className="music-time__last">{formatTime(trackDuration - elapsedTime)}</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-2 rounded bg-neutral-300 dark:bg-neutral-700 overflow-hidden">
|
||||
<div className="progress-bar-container w-full h-2 rounded bg-neutral-300 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-200 ${progressColorClass}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
className="progress-bar-fill h-full transition-all duration-200 bg-blue-500"
|
||||
style={{ width: `${(elapsedTime / trackDuration) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="lrc-text">
|
||||
{lyrics.map((lyricObj, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className={`lrc-line ${index === currentLyricIndex ? 'active' : ''}`}
|
||||
>
|
||||
{lyricObj.line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="music-control">
|
||||
<div
|
||||
className="music-control__play"
|
||||
onClick={togglePlayback}
|
||||
onClick={() => {
|
||||
const audio = audioElement.current;
|
||||
if (!audio) return;
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
audio.play().then(() => setIsPlaying(true));
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isPlaying}
|
||||
>
|
||||
{!isPlaying ? <Play /> : <Pause />}
|
||||
{isPlaying ? <Pause /> : <Play />}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<audio ref={audioRef} preload="none" />
|
||||
<audio ref={audioElement} preload="none" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
Reference in New Issue
Block a user