461 lines
13 KiB
JavaScript
461 lines
13 KiB
JavaScript
(() => {
|
||
let songs, audio, currentIndex = 0;
|
||
let floatingButton, progressRing, playerPanel, coverImg, popupBgImg, animationFrameId;
|
||
let lyricLines = [];
|
||
let isPlayerClosed = false;
|
||
|
||
const playlistApiUrl = "https://api.i-meto.com/meting/api?server=netease&type=playlist&id=8034031191";
|
||
|
||
// =========================
|
||
// 工具
|
||
// =========================
|
||
const getRandomSongIndex = () => {
|
||
if (!songs || !songs.length) return 0;
|
||
return Math.floor(Math.random() * songs.length);
|
||
};
|
||
|
||
const getCoverSrc = () => {
|
||
if (!songs || !songs.length) return "/logo.webp";
|
||
const pic = songs[currentIndex % songs.length]?.pic;
|
||
return pic?.trim() ? pic.trim() : "/logo.webp";
|
||
};
|
||
|
||
const setPopupBackground = (src) => {
|
||
if (!popupBgImg) return;
|
||
|
||
const fallback = "/logo.webp";
|
||
const nextSrc = src?.trim() ? src.trim() : fallback;
|
||
popupBgImg.src = nextSrc;
|
||
};
|
||
|
||
// =========================
|
||
// 封面
|
||
// =========================
|
||
const setCover = () => {
|
||
if (!coverImg) return;
|
||
const cover = getCoverSrc();
|
||
coverImg.src = cover;
|
||
setPopupBackground(cover);
|
||
};
|
||
|
||
// =========================
|
||
// audio
|
||
// =========================
|
||
const initAudio = () => {
|
||
if (!audio) {
|
||
audio = new Audio();
|
||
audio.volume = 0.5;
|
||
|
||
audio.onplay = () => { syncUI(); setRotate(); };
|
||
audio.onpause = () => { syncUI(); setRotate(); };
|
||
audio.onended = () => next();
|
||
}
|
||
};
|
||
|
||
// =========================
|
||
// LRC解析
|
||
// =========================
|
||
const parseLrc = (text) => {
|
||
const lines = text.split("\n");
|
||
const res = [];
|
||
const reg = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/;
|
||
|
||
for (let line of lines) {
|
||
const timeMatch = line.match(reg);
|
||
if (!timeMatch) continue;
|
||
|
||
const time =
|
||
(+timeMatch[1]) * 60 +
|
||
(+timeMatch[2]) +
|
||
(+timeMatch[3]) / (timeMatch[3].length === 3 ? 1000 : 100);
|
||
|
||
const txt = line.replace(reg, "").trim();
|
||
if (txt) res.push({ time, text: txt });
|
||
}
|
||
|
||
return res.sort((left, right) => left.time - right.time);
|
||
};
|
||
|
||
// =========================
|
||
// 歌词渲染
|
||
// =========================
|
||
const renderLrc = (top, mid, bottom) => {
|
||
const box = playerPanel?.querySelector(".lrc");
|
||
if (!box) return;
|
||
|
||
box.innerHTML = `
|
||
<div style="opacity:.4;font-size:12px;line-height:1.2;min-height:16px">${top}</div>
|
||
<div style="font-size:14px;font-weight:600;margin:2px 0;line-height:1.2;min-height:18px">${mid}</div>
|
||
<div style="opacity:.4;font-size:12px;line-height:1.2;min-height:16px">${bottom}</div>
|
||
`;
|
||
};
|
||
|
||
const renderLoadingLrc = () => {
|
||
renderLrc("加", "载", "中");
|
||
};
|
||
|
||
// =========================
|
||
// loading歌词
|
||
// =========================
|
||
const loadLyric = (song) => {
|
||
lyricLines = [];
|
||
|
||
renderLoadingLrc();
|
||
|
||
if (!song?.lrc) {
|
||
renderLrc("-", "暂无歌词", "-");
|
||
return;
|
||
}
|
||
|
||
fetch(song.lrc)
|
||
.then(response => response.text())
|
||
.then(text => {
|
||
lyricLines = parseLrc(text);
|
||
})
|
||
.catch(() => {
|
||
lyricLines = [];
|
||
renderLrc("-", "加载失败", "-");
|
||
});
|
||
};
|
||
|
||
// =========================
|
||
// load song
|
||
// =========================
|
||
const loadSong = () => {
|
||
if (!songs || !songs.length) return;
|
||
|
||
const currentSong = songs[currentIndex % songs.length];
|
||
initAudio();
|
||
|
||
audio.src = currentSong.url;
|
||
loadLyric(currentSong);
|
||
};
|
||
|
||
// =========================
|
||
// UI(标题)
|
||
// =========================
|
||
const syncUI = () => {
|
||
if (!playerPanel) return;
|
||
|
||
const titleBox = playerPanel.querySelector(".t");
|
||
if (!titleBox) return;
|
||
|
||
const currentSong = songs?.length ? songs[currentIndex % songs.length] : null;
|
||
|
||
titleBox.innerHTML = `
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:6px">
|
||
<div style="flex:1;line-height:1.2">
|
||
<div style="font-size:13px">${currentSong?.title || "加载中"}</div>
|
||
<div style="font-size:11px;opacity:.7;margin-top:2px">${currentSong?.author || "请稍候"}</div>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:6px;align-items:center;flex-shrink:0;">
|
||
<button class="minPlayerBtn" style="
|
||
width:24px;height:24px;
|
||
border:none;
|
||
border-radius:6px;
|
||
background:rgba(255,255,255,.08);
|
||
color:#fff;
|
||
font-size:14px;
|
||
cursor:pointer;
|
||
">−</button>
|
||
|
||
<button class="closePlayerBtn" style="
|
||
width:24px;height:24px;
|
||
border:none;
|
||
border-radius:6px;
|
||
background:rgba(255,255,255,.08);
|
||
color:#fff;
|
||
font-size:14px;
|
||
cursor:pointer;
|
||
">×</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const isPlaying = audio && !audio.paused;
|
||
playerPanel.querySelector(".pp").textContent = isPlaying ? "暂停" : "播放";
|
||
|
||
// 关闭(彻底关闭)
|
||
titleBox.querySelector(".closePlayerBtn").onclick = (event) => {
|
||
event.stopPropagation();
|
||
|
||
if (audio) {
|
||
audio.pause();
|
||
audio.src = "";
|
||
}
|
||
|
||
isPlayerClosed = true;
|
||
|
||
floatingButton.style.display = "none";
|
||
playerPanel.style.display = "none";
|
||
};
|
||
|
||
// 最小化(只收起面板)
|
||
titleBox.querySelector(".minPlayerBtn").onclick = (event) => {
|
||
event.stopPropagation();
|
||
if (playerPanel) playerPanel.style.display = "none";
|
||
};
|
||
};
|
||
|
||
// =========================
|
||
// rotate
|
||
// =========================
|
||
const setRotate = () => {
|
||
if (!coverImg) return;
|
||
coverImg.style.animationPlayState = (audio && !audio.paused) ? "running" : "paused";
|
||
};
|
||
|
||
// =========================
|
||
// 歌词更新
|
||
// =========================
|
||
const updateLrc = () => {
|
||
if (!lyricLines.length || !audio) return;
|
||
|
||
const currentTime = audio.currentTime;
|
||
|
||
let lyricIndex = 0;
|
||
for (let lyricLineIndex = 0; lyricLineIndex < lyricLines.length; lyricLineIndex++) {
|
||
if (currentTime >= lyricLines[lyricLineIndex].time) lyricIndex = lyricLineIndex;
|
||
else break;
|
||
}
|
||
|
||
renderLrc(
|
||
lyricLines[lyricIndex - 1]?.text || "-",
|
||
lyricLines[lyricIndex]?.text || "-",
|
||
lyricLines[lyricIndex + 1]?.text || "-"
|
||
);
|
||
};
|
||
|
||
// =========================
|
||
// progress
|
||
// =========================
|
||
const progress = () => {
|
||
if (!audio || !audio.duration) return;
|
||
|
||
const progressPercent = audio.currentTime / audio.duration;
|
||
|
||
progressRing.style.background =
|
||
`conic-gradient(#fff ${progressPercent * 360}deg,rgba(255,255,255,.15) 0)`;
|
||
|
||
const seek = playerPanel?.querySelector(".seek");
|
||
if (seek && !seek.matches(":active")) {
|
||
seek.value = Math.floor(progressPercent * 1000);
|
||
}
|
||
|
||
updateLrc();
|
||
};
|
||
|
||
// =========================
|
||
// play / pause / next
|
||
// =========================
|
||
const play = async () => {
|
||
initAudio();
|
||
if (!audio.src) loadSong();
|
||
|
||
try { await audio.play(); } catch { }
|
||
|
||
syncUI();
|
||
setRotate();
|
||
|
||
cancelAnimationFrame(animationFrameId);
|
||
loop();
|
||
};
|
||
|
||
const pause = () => {
|
||
if (audio) audio.pause();
|
||
syncUI();
|
||
setRotate();
|
||
};
|
||
|
||
const next = () => {
|
||
if (!songs || !songs.length) return;
|
||
|
||
let newIndex = getRandomSongIndex();
|
||
if (songs.length > 1) {
|
||
while (newIndex === currentIndex) newIndex = getRandomSongIndex();
|
||
}
|
||
|
||
currentIndex = newIndex;
|
||
|
||
const currentSong = songs[currentIndex % songs.length];
|
||
|
||
loadSong();
|
||
syncUI();
|
||
setRotate();
|
||
setCover();
|
||
|
||
loadLyric(currentSong);
|
||
|
||
audio.play().catch(() => { });
|
||
};
|
||
|
||
// =========================
|
||
// volume
|
||
// =========================
|
||
const volUp = () => {
|
||
if (!audio) return;
|
||
audio.volume = Math.min(1, audio.volume + 0.1);
|
||
};
|
||
|
||
const volDown = () => {
|
||
if (!audio) return;
|
||
audio.volume = Math.max(0, audio.volume - 0.1);
|
||
};
|
||
|
||
// =========================
|
||
// loop
|
||
// =========================
|
||
const loop = () => {
|
||
progress();
|
||
animationFrameId = requestAnimationFrame(loop);
|
||
};
|
||
|
||
// =========================
|
||
// UI init
|
||
// =========================
|
||
const initUI = () => {
|
||
floatingButton = document.createElement("div");
|
||
floatingButton.style.cssText =
|
||
"position:fixed;right:18px;bottom:18px;width:58px;height:58px;border-radius:50%;background:#111;z-index:99999;display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden";
|
||
|
||
progressRing = document.createElement("div");
|
||
progressRing.style.cssText =
|
||
"position:absolute;inset:0;border-radius:50%;background:conic-gradient(rgba(255,255,255,.15) 0deg,transparent 0);z-index:0";
|
||
|
||
coverImg = document.createElement("img");
|
||
coverImg.src = "/logo.webp";
|
||
coverImg.style.cssText =
|
||
"width:46px;height:46px;border-radius:50%;object-fit:cover;z-index:2;position:relative;animation:spin 6s linear infinite;animation-play-state:paused";
|
||
|
||
floatingButton.appendChild(progressRing);
|
||
floatingButton.appendChild(coverImg);
|
||
|
||
playerPanel = document.createElement("div");
|
||
playerPanel.style.cssText =
|
||
"position:fixed;right:18px;bottom:90px;width:240px;background:#000;color:#fff;border-radius:12px;display:none;z-index:99999;overflow:hidden";
|
||
|
||
playerPanel.innerHTML = `
|
||
<div class="t"></div>
|
||
<div class="lrc" style="margin:10px 0 8px 0;text-align:center"></div>
|
||
<input class="seek" type="range" min="0" max="1000" value="0"
|
||
style="width:100%;margin:0 0 8px 0;accent-color:#fff">
|
||
<div style="display:flex;justify-content:space-evenly;align-items:center">
|
||
<button class="pp">播放</button>
|
||
<button class="cc">切歌</button>
|
||
<button class="vu">音量+</button>
|
||
<button class="vd">音量-</button>
|
||
</div>`;
|
||
|
||
const popupBg = document.createElement("img");
|
||
popupBg.className = "popupBg";
|
||
popupBg.alt = "";
|
||
|
||
const popupShade = document.createElement("div");
|
||
popupShade.className = "popupShade";
|
||
|
||
const popupContent = document.createElement("div");
|
||
popupContent.className = "popupContent";
|
||
|
||
while (playerPanel.firstChild) popupContent.appendChild(playerPanel.firstChild);
|
||
|
||
playerPanel.appendChild(popupBg);
|
||
playerPanel.appendChild(popupShade);
|
||
playerPanel.appendChild(popupContent);
|
||
|
||
popupBgImg = popupBg;
|
||
setPopupBackground("/logo.webp");
|
||
|
||
document.body.appendChild(floatingButton);
|
||
document.body.appendChild(playerPanel);
|
||
|
||
const style = document.createElement("style");
|
||
style.textContent = `
|
||
@keyframes spin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
button {
|
||
transition: transform .05s ease, background .05s ease;
|
||
}
|
||
|
||
button:active {
|
||
transform: scale(0.92);
|
||
}
|
||
|
||
.popupBg {
|
||
position:absolute;
|
||
inset:0;
|
||
width:100%;
|
||
height:100%;
|
||
object-fit:cover;
|
||
z-index:0;
|
||
}
|
||
|
||
.popupShade {
|
||
position:absolute;
|
||
inset:0;
|
||
background:rgba(0,0,0,.58);
|
||
z-index:1;
|
||
}
|
||
|
||
.popupContent {
|
||
position:relative;
|
||
z-index:2;
|
||
padding:10px;
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
floatingButton.onclick = () => {
|
||
if (isPlayerClosed) return;
|
||
playerPanel.style.display = playerPanel.style.display === "none" ? "block" : "none";
|
||
};
|
||
|
||
playerPanel.querySelector(".pp").onclick = (event) => {
|
||
event.stopPropagation();
|
||
if (!audio || audio.paused) play();
|
||
else pause();
|
||
};
|
||
|
||
playerPanel.querySelector(".cc").onclick = () => next();
|
||
playerPanel.querySelector(".vu").onclick = () => volUp();
|
||
playerPanel.querySelector(".vd").onclick = () => volDown();
|
||
|
||
const seek = playerPanel.querySelector(".seek");
|
||
seek.addEventListener("input", () => {
|
||
if (!audio || !audio.duration) return;
|
||
audio.currentTime = (seek.value / 1000) * audio.duration;
|
||
});
|
||
|
||
setInterval(progress, 100);
|
||
};
|
||
|
||
// =========================
|
||
// init
|
||
// =========================
|
||
initUI();
|
||
|
||
renderLoadingLrc();
|
||
syncUI();
|
||
|
||
fetch(playlistApiUrl)
|
||
.then(response => response.json())
|
||
.then(responseSongs => {
|
||
songs = responseSongs;
|
||
|
||
if (songs && songs.length) {
|
||
currentIndex = getRandomSongIndex();
|
||
|
||
setCover();
|
||
syncUI();
|
||
|
||
loadSong();
|
||
} else {
|
||
syncUI();
|
||
}
|
||
});
|
||
|
||
})();
|