YIHENG音乐
by FlashCaptain471515 lines46.8 KB🛠️ Three.js (3D graphics)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>YIHENG音乐</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@300;400;600&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
:root {
--bg: #FFFFFF;
--surface: #F5F5F5;
--on-surface: #000000;
--primary: #000000;
--border: #0000001E;
--hover: #0000001E;
--active: #0000003E;
--outline: #00000055;
--transparent: #00000000;
--accent: #0099CC;
--btn-color: #FFFFFF;
--btn-bg: #000000;
--inverse-color: #000000;
}
.night {
--bg: #000000;
--surface: #121212;
--on-surface: #FFFFFF;
--primary: #FFFFFF;
--border: #FFFFFF3E;
--hover: #FFFFFF3E;
--active: #FFFFFF3E;
--outline: #FFFFFF55;
--transparent: #FFFFFF00;
--accent: #0099CC;
--btn-color: #000000;
--btn-bg: #FFFFFF;
--inverse-color: #FFFFFF;
}
body {
font-family: "Roboto", "Droid Sans", sans-serif;
background: var(--bg);
color: var(--on-surface);
transition: background-color 0.3s, color 0.3s;
min-height: 100vh;
padding-top: 56px;
padding-bottom: 56px;
overflow: hidden;
}
.app-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
z-index: 100;
gap: 16px;
}
.app-bar-title {
font-size: 20px;
font-weight: 500;
flex: 1;
}
.playlist-toggle-btn {
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
background: var(--transparent);
border: none;
color: var(--on-surface);
cursor: pointer;
transition: 0.3s;
}
.playlist-toggle-btn:active {
background: var(--hover);
}
.playlist-toggle-btn .material-icons {
font-size: 24px;
}
.top-loader {
position: fixed;
top: 56px;
left: 0;
right: 0;
height: 3px;
background: #1A1A1A;
overflow: hidden;
z-index: 99;
display: none;
pointer-events: none;
}
.top-loader.active {
display: block;
}
.top-loader-fill {
height: 100%;
width: 20%;
background: #0099CC;
position: absolute;
left: -20%;
animation: holo-classic 2s infinite cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes holo-classic {
0% { left: -20%; width: 20%; }
25% { width: 40%; }
50% { width: 20%; }
75% { width: 40%; }
100% { left: 100%; width: 20%; }
}
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 56px;
background: var(--surface);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-around;
align-items: center;
z-index: 100;
}
.bottom-nav-item {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
cursor: pointer;
color: var(--on-surface);
opacity: 0.6;
transition: 0.3s;
text-decoration: none;
background: none;
border: none;
font-family: inherit;
}
.bottom-nav-item.active {
opacity: 1;
}
.bottom-nav-item .material-icons {
font-size: 24px;
}
.bottom-nav-item span:last-child {
font-size: 12px;
}
.page {
display: none;
height: calc(100vh - 112px);
overflow-y: auto;
}
.page.active {
display: block;
}
#bg-canvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
opacity: 0.25;
pointer-events: none;
}
.album-art-square {
width: 200px;
height: 200px;
background: #1a1a1d;
border: 1px solid rgba(255,255,255,0.08);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.album-art-square img {
width: 100%;
height: 100%;
object-fit: cover;
}
.album-art-square .material-icons {
font-size: 72px;
opacity: 0.3;
color: #fff;
}
/* ===== 直线滑块进度条 ===== */
.slider-progress-area {
width: 100%;
max-width: 320px;
margin: 0 auto;
}
.time-display {
display: flex;
justify-content: space-between;
font-size: 12px;
opacity: 0.6;
margin-bottom: 8px;
font-family: 'SF Mono', Monaco, monospace;
}
.slider {
width: 100%;
height: 4px;
background: rgba(255,255,255,0.08);
position: relative;
cursor: pointer;
}
.slider-fill {
height: 100%;
background: linear-gradient(90deg, #ff6b9d 0%, #4ecdc4 100%);
width: 0%;
pointer-events: none;
box-shadow: 0 0 10px rgba(255,107,157,0.3);
}
.slider-thumb {
width: 14px;
height: 14px;
background: var(--inverse-color);
position: absolute;
top: 50%;
transform: translate(-50%, -50%) scale(0);
cursor: pointer;
box-shadow: 0 0 12px rgba(255,255,255,0.4), 0 2px 4px rgba(0,0,0,0.3);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.slider:hover .slider-thumb,
.slider.dragging .slider-thumb {
transform: translate(-50%, -50%) scale(1);
}
.control-btn {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: none;
color: var(--inverse-color);
border: none;
cursor: pointer;
transition: all 0.2s;
}
.control-btn:active {
opacity: 0.6;
transform: scale(0.9);
}
.control-btn .material-icons {
font-size: 32px;
}
.control-btn-small {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: none;
color: var(--inverse-color);
border: none;
cursor: pointer;
transition: all 0.2s;
}
.control-btn-small:active {
opacity: 0.6;
transform: scale(0.9);
}
.control-btn-small .material-icons {
font-size: 24px;
}
.playlist-drawer {
position: fixed;
top: 0;
right: -100%;
width: 75%;
max-width: 300px;
height: 100%;
background: var(--surface);
border-left: 1px solid var(--border);
z-index: 90;
transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
overflow-y: auto;
}
.playlist-drawer.active {
right: 0;
}
.drawer-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 80;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.drawer-overlay.active {
opacity: 1;
pointer-events: all;
}
.song-card {
transition: all 0.15s ease;
border-left: 2px solid transparent;
padding: 10px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.song-card:active {
background: var(--hover);
}
.song-card.playing {
border-left-color: #ff6b9d;
background: rgba(255,107,157,0.05);
}
.loading-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(10,10,12,0.05);
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.loading-overlay.active {
opacity: 1;
}
.toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--surface);
border: 1px solid var(--border);
padding: 10px 20px;
font-size: 12px;
color: var(--on-surface);
letter-spacing: 1px;
z-index: 300;
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
white-space: nowrap;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.search-input {
width: 100%;
height: 48px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--on-surface);
padding: 0 16px;
font-size: 14px;
outline: none;
font-family: inherit;
}
.search-input::placeholder {
color: var(--outline);
}
.platform-pill {
padding: 6px 14px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--on-surface);
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
letter-spacing: 1px;
margin-right: 8px;
margin-bottom: 8px;
opacity: 0.6;
}
.platform-pill.active {
opacity: 1;
background: var(--on-surface);
color: var(--bg);
border-color: var(--on-surface);
}
.search-results-box {
margin-top: 12px;
background: var(--surface);
border: 1px solid var(--border);
overflow: hidden;
}
.result-item {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.2s;
}
.result-item:active {
background: var(--hover);
}
.result-item img {
width: 40px;
height: 40px;
object-fit: cover;
border: 1px solid var(--border);
}
.result-info {
flex: 1;
min-width: 0;
}
.result-title {
font-size: 12px;
color: var(--on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-artist {
font-size: 10px;
color: var(--outline);
margin-top: 2px;
}
.result-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
background: none;
color: var(--inverse-color);
}
.result-btn:active {
opacity: 0.6;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border);
}
.setting-label {
font-size: 16px;
}
.setting-desc {
font-size: 12px;
opacity: 0.6;
margin-top: 2px;
}
.switch {
width: 36px;
height: 20px;
background: var(--border);
position: relative;
cursor: pointer;
transition: 0.3s;
}
.switch.on {
background: var(--on-surface);
}
.switch-knob {
width: 16px;
height: 16px;
background: var(--btn-color);
position: absolute;
top: 2px;
left: 2px;
transition: 0.3s;
}
.switch.on .switch-knob {
left: 18px;
}
.pl-download {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--outline);
cursor: pointer;
margin-left: 4px;
}
.pl-download:active {
opacity: 0.6;
color: #4ecdc4;
}
.playlist-section-title {
font-size: 12px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.7;
margin-bottom: 12px;
padding: 0 4px;
}
.playlist-row {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 8px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.playlist-row::-webkit-scrollbar {
display: none;
}
.playlist-card {
flex: none;
width: 120px;
cursor: pointer;
transition: transform 0.15s;
}
.playlist-card:active {
transform: scale(0.95);
opacity: 0.8;
}
.playlist-thumb {
width: 120px;
height: 120px;
position: relative;
overflow: hidden;
border: 1px solid var(--border);
margin-bottom: 8px;
}
.playlist-thumb::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, transparent 50%, rgba(0,0,0,0.5) 100%);
}
.playlist-name {
font-size: 11px;
color: var(--on-surface);
letter-spacing: 0.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
}
.hidden {
display: none !important;
}
</style>
</head>
<body class="night">
<!-- 应用栏 -->
<div class="app-bar">
<div class="app-bar-title" id="page-title">YIHENG音乐</div>
<button class="playlist-toggle-btn" onclick="togglePlaylist()" title="播放列表">
<span class="material-icons">queue_music</span>
</button>
</div>
<!-- 顶部加载进度条 -->
<div class="top-loader" id="topLoader">
<div class="top-loader-fill"></div>
</div>
<canvas id="bg-canvas"></canvas>
<div class="drawer-overlay" id="drawerOverlay" onclick="togglePlaylist()"></div>
<div class="playlist-drawer" id="playlistDrawer">
<div style="padding:16px; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center;">
<div>
<h3 style="font-size:11px; font-weight:bold; letter-spacing:0.2em; text-transform:uppercase; opacity:0.8;">Playlist</h3>
<p style="font-size:9px; opacity:0.5; margin-top:2px;">楽曲一覧</p>
</div>
<button onclick="togglePlaylist()" style="background:none; border:none; color:var(--on-surface); cursor:pointer; padding:4px; opacity:0.6;">
<span class="material-icons" style="font-size:20px;">close</span>
</button>
</div>
<div id="playlistContainer"></div>
</div>
<!-- ========== 搜索页 ========== -->
<div class="page" id="page-search">
<div style="padding:16px;">
<div style="display:flex; gap:8px; margin-bottom:16px;">
<input type="text" id="searchInput" placeholder="歌曲名 / 歌手名..."
class="search-input"
onkeypress="if(event.key==='Enter') player.searchMusic()">
<button onclick="player.searchMusic()"
style="background:none; color:var(--inverse-color); border:none; padding:0 16px; font-size:14px; cursor:pointer; font-family:inherit;">
<span class="material-icons">search</span>
</button>
</div>
<div style="margin-bottom:20px;">
<button class="platform-pill active" data-platform="netease" onclick="player.selectPlatform(this)">网易云</button>
<button class="platform-pill" data-platform="tencent" onclick="player.selectPlatform(this)">QQ音乐</button>
<button class="platform-pill" data-platform="kugou" onclick="player.selectPlatform(this)">酷狗</button>
</div>
<div style="margin-bottom:20px;">
<div class="playlist-section-title">推荐歌单</div>
<div class="playlist-row" id="recPlaylistRow"></div>
</div>
<div class="search-results-box hidden" id="searchResults"></div>
</div>
</div>
<!-- ========== YIHENG音乐页 ========== -->
<div class="page active" id="page-music">
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; gap:20px; padding:16px;">
<div class="album-art-square" id="albumArt">
<img id="vinylCover" src="" alt="cover" style="display:none;">
<span class="material-icons" id="defaultIcon">music_note</span>
</div>
<div style="text-align:center; width:100%; max-width:320px;">
<h2 id="currentTitle" style="font-size:16px; font-weight:600; margin-bottom:4px; min-height:24px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></h2>
<p id="currentArtist" style="font-size:12px; opacity:0.6;"></p>
</div>
<!-- 直线滑块进度条 -->
<div class="slider-progress-area">
<div class="time-display">
<span id="currentTime">0:00</span>
<span id="duration">0:00</span>
</div>
<div class="slider" id="progressSlider">
<div class="slider-fill" id="progressFill" style="width:0%"></div>
<div class="slider-thumb" id="progressThumb" style="left:0%"></div>
</div>
</div>
<div style="display:flex; align-items:center; gap:32px;">
<button class="control-btn-small" onclick="player.prev()" title="上一首">
<span class="material-icons">skip_previous</span>
</button>
<button id="playPauseBtn" class="control-btn" onclick="player.togglePlay()">
<span class="material-icons" id="playIcon" style="font-size:36px;">play_arrow</span>
<span class="material-icons hidden" id="pauseIcon" style="font-size:36px;">pause</span>
</button>
<button class="control-btn-small" onclick="player.next()" title="下一首">
<span class="material-icons">skip_next</span>
</button>
</div>
<div style="display:flex; align-items:center; gap:16px;">
<button class="control-btn-small" onclick="player.downloadCurrent()" title="下载">
<span class="material-icons">download</span>
</button>
</div>
</div>
</div>
<!-- ========== 设置页 ========== -->
<div class="page" id="page-settings">
<div class="setting-item">
<div>
<div class="setting-label">夜间模式</div>
<div class="setting-desc">使用深色主题</div>
</div>
<div class="switch on" id="theme-switch" onclick="toggleThemeSwitch()">
<div class="switch-knob"></div>
</div>
</div>
</div>
<!-- ========== 底部导航 ========== -->
<div class="bottom-nav">
<button class="bottom-nav-item" onclick="setPage('search', this)">
<span class="material-icons">search</span>
<span>搜索</span>
</button>
<button class="bottom-nav-item active" onclick="setPage('music', this)">
<span class="material-icons">album</span>
<span>YIHENG音乐</span>
</button>
<button class="bottom-nav-item" onclick="setPage('settings', this)">
<span class="material-icons">settings</span>
<span>设置</span>
</button>
</div>
<div class="loading-overlay" id="loadingOverlay"></div>
<div class="toast" id="toast"></div>
<script>
function showTopLoader() {
document.getElementById('topLoader').classList.add('active');
}
function hideTopLoader() {
document.getElementById('topLoader').classList.remove('active');
}
function toggleThemeSwitch() {
const switchEl = document.getElementById('theme-switch');
const isNight = switchEl.classList.contains('on');
if (isNight) {
switchEl.classList.remove('on');
document.body.classList.remove('night');
} else {
switchEl.classList.add('on');
document.body.classList.add('night');
}
}
function setPage(page, el) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
document.querySelectorAll('.bottom-nav-item').forEach(i => i.classList.remove('active'));
if (el) el.classList.add('active');
const titles = { search: '搜索', music: 'YIHENG音乐', settings: '设置' };
document.getElementById('page-title').textContent = titles[page];
}
const initBackground = () => {
const canvas = document.getElementById('bg-canvas');
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const particlesGeometry = new THREE.BufferGeometry();
const particlesCount = 40;
const posArray = new Float32Array(particlesCount * 3);
for(let i = 0; i < particlesCount * 3; i++) {
posArray[i] = (Math.random() - 0.5) * 10;
}
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
const particlesMaterial = new THREE.PointsMaterial({
size: 0.015, color: 0xff6b9d, transparent: true, opacity: 0.25, blending: THREE.AdditiveBlending
});
const particlesMesh = new THREE.Points(particlesGeometry, particlesMaterial);
scene.add(particlesMesh);
camera.position.z = 4;
let isPlaying = false;
let time = 0;
const animate = () => {
requestAnimationFrame(animate);
time += 0.001;
particlesMesh.rotation.y = time * 0.2;
if(isPlaying) particlesMesh.position.y = Math.sin(time * 3) * 0.05;
renderer.render(scene, camera);
};
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
return { setPlaying: (state) => { isPlaying = state; } };
};
const bgAnimation = initBackground();
/* ==================== 直线滑块进度条 ==================== */
const initProgressBar = (player) => {
const slider = document.getElementById('progressSlider');
const fill = document.getElementById('progressFill');
const thumb = document.getElementById('progressThumb');
let isDragging = false;
const updateProgress = (percent) => {
fill.style.width = percent + '%';
thumb.style.left = percent + '%';
};
const seek = (e) => {
const rect = slider.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
const percent = (x / rect.width) * 100;
updateProgress(percent);
if(player.video && player.video.duration) {
player.video.currentTime = (percent / 100) * player.video.duration;
}
};
slider.addEventListener('mousedown', (e) => {
isDragging = true;
slider.classList.add('dragging');
seek(e);
e.preventDefault();
});
slider.addEventListener('touchstart', (e) => {
isDragging = true;
slider.classList.add('dragging');
seek(e);
}, {passive: false});
const moveHandler = (e) => {
if(isDragging) {
seek(e);
e.preventDefault();
}
};
const endHandler = () => {
isDragging = false;
slider.classList.remove('dragging');
};
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', endHandler);
document.addEventListener('touchmove', moveHandler, {passive: false});
document.addEventListener('touchend', endHandler);
return { update: updateProgress };
};
let toastTimer = null;
function showToast(msg) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.add('show');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove('show'), 2500);
}
function sanitizeFilename(name) {
return name.replace(/[\\/:*?"<>|]/g, '_').substring(0, 100);
}
async function downloadEngine(url, filename) {
const safeName = sanitizeFilename(filename) + '.mp3';
showToast('正在下载...');
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
const res = await fetch(url, {
method: 'GET',
signal: controller.signal,
referrerPolicy: 'no-referrer',
headers: { 'Accept': '*/*' }
});
clearTimeout(timer);
if (res.ok) {
const blob = await res.blob();
if (blob.size > 1024) {
triggerSave(blob, safeName);
showToast('下载完成');
return;
}
}
} catch (e) { console.log('L1 failed', e); }
try {
const res = await fetch(url, { mode: 'no-cors', referrerPolicy: 'no-referrer' });
const blob = await res.blob();
if (blob && blob.size > 0) {
triggerSave(blob, safeName);
showToast('下载完成');
return;
}
} catch (e) { console.log('L2 failed', e); }
try {
const blob = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'blob';
xhr.timeout = 8000;
xhr.onload = () => {
if (xhr.status === 200 && xhr.response.size > 1024) resolve(xhr.response);
else reject(new Error('empty'));
};
xhr.onerror = () => reject(new Error('err'));
xhr.ontimeout = () => reject(new Error('timeout'));
xhr.send();
});
triggerSave(blob, safeName);
showToast('下载完成');
return;
} catch (e) { console.log('L3 failed', e); }
try {
const a = document.createElement('a');
a.href = url;
a.download = safeName;
a.style.display = 'none';
a.referrerPolicy = 'no-referrer';
document.body.appendChild(a);
a.click();
setTimeout(() => {
if (a.parentNode) document.body.removeChild(a);
}, 100);
showToast('已触发浏览器下载');
return;
} catch (e) { console.log('L4 failed', e); }
try {
await navigator.clipboard.writeText(url);
showToast('链接已复制,请手动保存');
} catch (e) {
window.open(url, '_blank');
showToast('已打开音频链接,请按 Ctrl+S');
}
}
function triggerSave(blob, filename) {
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(blobUrl);
}, 100);
}
const RECOMMEND_PLAYLISTS = {
netease: [
{ id: '3778678', name: '飙升榜', color: 'linear-gradient(135deg,#ff6b9d,#ff8e53)' },
{ id: '3779629', name: '新歌榜', color: 'linear-gradient(135deg,#4ecdc4,#44a08d)' },
{ id: '19723756', name: '热歌榜', color: 'linear-gradient(135deg,#667eea,#764ba2)' },
{ id: '60198', name: 'Billboard', color: 'linear-gradient(135deg,#f093fb,#f5576c)' },
{ id: '180106', name: 'UK榜', color: 'linear-gradient(135deg,#4facfe,#00f2fe)' },
{ id: '11641012', name: '摇滚榜', color: 'linear-gradient(135deg,#43e97b,#38f9d7)' },
{ id: '2884035', name: '古风榜', color: 'linear-gradient(135deg,#fa709a,#fee140)' },
{ id: '2250011882', name: 'ACG榜', color: 'linear-gradient(135deg,#a8edea,#fed6e3)' },
],
tencent: [
{ id: '62', name: '飙升榜', color: 'linear-gradient(135deg,#ff6b9d,#ff8e53)' },
{ id: '26', name: '热歌榜', color: 'linear-gradient(135deg,#667eea,#764ba2)' },
{ id: '27', name: '新歌榜', color: 'linear-gradient(135deg,#4ecdc4,#44a08d)' },
{ id: '4', name: '流行指数', color: 'linear-gradient(135deg,#f093fb,#f5576c)' },
{ id: '58', name: '说唱榜', color: 'linear-gradient(135deg,#43e97b,#38f9d7)' },
{ id: '59', name: '摇滚榜', color: 'linear-gradient(135deg,#fa709a,#fee140)' },
],
kugou: [
{ id: '6666', name: '飙升榜', color: 'linear-gradient(135deg,#ff6b9d,#ff8e53)' },
{ id: '8888', name: 'TOP500', color: 'linear-gradient(135deg,#667eea,#764ba2)' },
{ id: '52144', name: '蜂鸟流行音乐榜', color: 'linear-gradient(135deg,#4ecdc4,#44a08d)' },
{ id: '52735', name: '抖音热歌榜', color: 'linear-gradient(135deg,#f093fb,#f5576c)' },
{ id: '52736', name: '快手热歌榜', color: 'linear-gradient(135deg,#43e97b,#38f9d7)' },
{ id: '52737', name: 'DJ热歌榜', color: 'linear-gradient(135deg,#fa709a,#fee140)' },
]
};
const player = {
tracks: [],
currentIndex: -1,
isPlaying: false,
isLoading: false,
video: null,
albumArt: document.getElementById('albumArt'),
vinylCover: document.getElementById('vinylCover'),
defaultIcon: document.getElementById('defaultIcon'),
loadingOverlay: document.getElementById('loadingOverlay'),
progressBar: null,
apiBase: 'https://api.i-meto.com/meting/api',
currentPlatform: 'netease',
searchResults: [],
init() {
this.video = document.createElement('video');
this.video.loop = true;
this.video.playsInline = true;
this.video.preload = 'auto';
this.video.style.display = 'none';
this.video.volume = 1.0;
document.body.appendChild(this.video);
this.progressBar = initProgressBar(this);
this.setupVideoEvents();
this.loadPlaylist('netease', '3778678', false);
this.renderRecommendPlaylists();
},
setupVideoEvents() {
this.video.addEventListener('timeupdate', () => {
if(!this.isLoading) this.updateProgress();
});
this.video.addEventListener('loadedmetadata', () => {
document.getElementById('duration').textContent = this.formatTime(this.video.duration);
});
this.video.addEventListener('waiting', () => this.showLoading());
this.video.addEventListener('playing', () => this.hideLoading());
this.video.addEventListener('canplay', () => this.hideLoading());
this.video.addEventListener('ended', () => this.next());
this.video.addEventListener('error', () => {
this.hideLoading();
showToast('播放失败,尝试下一首');
setTimeout(() => this.next(), 1500);
});
},
showLoading() {
this.isLoading = true;
this.loadingOverlay.classList.add('active');
showTopLoader();
},
hideLoading() {
this.isLoading = false;
this.loadingOverlay.classList.remove('active');
hideTopLoader();
},
renderRecommendPlaylists() {
const container = document.getElementById('recPlaylistRow');
const list = RECOMMEND_PLAYLISTS[this.currentPlatform] || RECOMMEND_PLAYLISTS.netease;
container.innerHTML = list.map(item => `
<div class="playlist-card" onclick="player.loadPlaylist('${this.currentPlatform}', '${item.id}', true); setPage('music', document.querySelectorAll('.bottom-nav-item')[1]);">
<div class="playlist-thumb" style="background:${item.color};">
<div style="position:absolute; bottom:6px; left:6px; z-index:2;">
<span class="material-icons" style="font-size:16px; color:#fff; opacity:0.9;">play_arrow</span>
</div>
</div>
<div class="playlist-name">${item.name}</div>
</div>
`).join('');
},
async loadTrack(index, autoPlay = true) {
if (this.isLoading) return;
if (this.tracks.length === 0) return;
const previousIndex = this.currentIndex;
if (previousIndex === index && previousIndex !== -1) {
if (autoPlay && !this.isPlaying) this.togglePlay();
return;
}
const track = this.tracks[index];
if (!track || !track.url) {
showToast('无效的歌曲链接');
return;
}
this.isLoading = true;
this.showLoading();
this.currentIndex = index;
document.getElementById('currentTitle').textContent = track.name || track.title || 'Unknown';
document.getElementById('currentArtist').textContent = `feat. ${track.artist || track.author || 'Unknown'}`;
if (track.cover || track.pic) {
this.vinylCover.src = track.cover || track.pic;
this.vinylCover.style.display = 'block';
this.defaultIcon.style.display = 'none';
} else {
this.vinylCover.style.display = 'none';
this.defaultIcon.style.display = 'block';
}
try {
console.log('加载歌曲:', track.url);
this.video.src = track.url;
this.video.load();
await new Promise((resolve) => {
const onCanPlay = () => {
console.log('歌曲可以播放');
this.hideLoading();
resolve();
};
const onError = (e) => {
console.error('歌曲加载错误:', e);
this.hideLoading();
resolve();
};
this.video.addEventListener('canplay', onCanPlay, { once: true });
this.video.addEventListener('error', onError, { once: true });
setTimeout(() => {
console.log('加载超时');
this.hideLoading();
resolve();
}, 8000);
});
this.video.currentTime = 0;
this.progressBar.update(0);
document.getElementById('currentTime').textContent = '0:00';
document.getElementById('duration').textContent = this.formatTime(this.video.duration || 0);
if (autoPlay) {
try {
await this.video.play();
this.isPlaying = true;
this.updatePlayButton();
bgAnimation.setPlaying(true);
console.log('开始播放');
} catch (playErr) {
console.error('播放失败:', playErr);
showToast('播放失败,尝试其他歌曲');
}
}
} catch (error) {
console.error('Track load failed:', error);
this.isPlaying = false;
this.updatePlayButton();
showToast('加载失败: ' + error.message);
} finally {
this.isLoading = false;
this.hideLoading();
this.renderPlaylist();
}
},
togglePlay() {
if (this.isLoading) return;
this.isPlaying = !this.isPlaying;
this.updatePlayButton();
if (this.isPlaying) {
if (!this.video.src && this.tracks.length > 0) {
this.loadTrack(this.currentIndex === -1 ? 0 : this.currentIndex, true);
return;
}
this.video.play().catch(e => {
console.error('Play failed:', e);
this.isPlaying = false;
this.updatePlayButton();
});
bgAnimation.setPlaying(true);
} else {
this.video.pause();
bgAnimation.setPlaying(false);
}
this.renderPlaylist();
},
updatePlayButton() {
const playIcon = document.getElementById('playIcon');
const pauseIcon = document.getElementById('pauseIcon');
if (this.isPlaying) {
playIcon.classList.add('hidden');
pauseIcon.classList.remove('hidden');
} else {
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
}
},
next() {
if (this.isLoading || this.tracks.length === 0) return;
this.loadTrack((this.currentIndex + 1) % this.tracks.length);
},
prev() {
if (this.isLoading || this.tracks.length === 0) return;
this.loadTrack((this.currentIndex - 1 + this.tracks.length) % this.tracks.length);
},
updateProgress() {
if (!this.video.duration || isNaN(this.video.duration)) return;
const current = this.video.currentTime;
const duration = this.video.duration;
const percent = (current / duration) * 100;
this.progressBar.update(percent);
document.getElementById('currentTime').textContent = this.formatTime(current);
document.getElementById('duration').textContent = this.formatTime(duration);
},
formatTime(seconds) {
if (isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
},
renderPlaylist() {
const container = document.getElementById('playlistContainer');
container.innerHTML = '';
this.tracks.forEach((track, index) => {
const div = document.createElement('div');
const isCurrent = index === this.currentIndex;
const isPlaying = isCurrent && this.isPlaying;
div.className = `song-card ${isCurrent ? 'playing' : ''}`;
div.innerHTML = `
<div style="width:36px; height:36px; background:linear-gradient(135deg,#2a2a35,#1a1a1d); display:flex; align-items:center; justify-content:center; font-size:10px; font-weight:bold; border:1px solid rgba(255,255,255,0.05); position:relative; overflow:hidden;">
${isPlaying ? '<div style="position:absolute; inset:0; background:rgba(255,107,157,0.2);"></div>' : ''}
<span style="position:relative; z-index:10; ${isCurrent ? 'color:#ff6b9d' : 'color:#666'}">${String(index + 1).padStart(2, '0')}</span>
</div>
<div style="flex:1; min-width:0;">
<h4 style="font-size:11px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; ${isCurrent ? 'color:#ccc' : 'color:#888'}; letter-spacing:0.5px;">${track.name || track.title}</h4>
<p style="font-size:9px; color:#666; margin-top:2px;">${track.artist || track.author}</p>
</div>
<button class="pl-download" onclick="event.stopPropagation(); player.downloadByIndex(${index})" title="下载">
<span class="material-icons" style="font-size:14px;">download</span>
</button>
`;
div.addEventListener('click', () => {
if (this.currentIndex !== index) this.loadTrack(index);
else if (!this.isPlaying) this.togglePlay();
togglePlaylist();
});
container.appendChild(div);
});
},
selectPlatform(el) {
document.querySelectorAll('.platform-pill').forEach(p => p.classList.remove('active'));
el.classList.add('active');
this.currentPlatform = el.dataset.platform;
const names = { netease: '网易云', tencent: 'QQ音乐', kugou: '酷狗' };
showToast(`已切换至 ${names[this.currentPlatform]}`);
this.renderRecommendPlaylists();
},
async searchMusic() {
const keyword = document.getElementById('searchInput').value.trim();
if (!keyword) { showToast('请输入搜索内容'); return; }
const resultsDiv = document.getElementById('searchResults');
resultsDiv.innerHTML = '<div style="padding:20px; text-align:center; opacity:0.6; font-size:12px; letter-spacing:2px;">搜索中...</div>';
resultsDiv.classList.remove('hidden');
showToast('正在搜索...');
try {
const searchUrl = `${this.apiBase}?server=${this.currentPlatform}&type=search&id=${encodeURIComponent(keyword)}`;
console.log('搜索URL:', searchUrl);
const res = await fetch(searchUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
console.log('搜索结果:', data);
if (!data || !Array.isArray(data) || data.length === 0) {
resultsDiv.innerHTML = '<div style="padding:20px; text-align:center; opacity:0.6; font-size:12px;">未找到相关歌曲</div>';
showToast('无搜索结果');
return;
}
this.searchResults = data;
this.displaySearchResults(data);
showToast(`找到 ${data.length} 首歌曲`);
} catch (err) {
console.error('搜索错误:', err);
resultsDiv.innerHTML = `<div style="padding:20px; text-align:center; opacity:0.6; font-size:12px;">搜索失败: ${err.message}<br>请检查网络连接</div>`;
showToast('搜索失败: ' + err.message);
}
},
displaySearchResults(data) {
const resultsDiv = document.getElementById('searchResults');
const validData = data.filter(item => item && item.url && item.url.trim() !== '');
if (validData.length === 0) {
resultsDiv.innerHTML = '<div style="padding:20px; text-align:center; opacity:0.6; font-size:12px;">找到歌曲但无可用播放链接</div>';
showToast('无可用播放链接');
return;
}
this.searchResults = validData;
let html = `<div style="padding:10px 14px; background:rgba(255,255,255,0.03); border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center;">
<span style="font-size:11px; opacity:0.6; letter-spacing:1px;">找到 ${validData.length} 首可用</span>
<button onclick="player.playAllSearch()" style="font-size:11px; color:#ff6b9d; background:none; border:none; cursor:pointer; letter-spacing:1px;">播放全部</button>
</div>`;
validData.forEach((item, idx) => {
html += `
<div class="result-item" onclick="player.playFromSearch(${idx})">
<img src="${item.pic || ''}" alt="" onerror="this.style.display='none'" style="width:40px; height:40px; object-fit:cover; border:1px solid var(--border);">
<div class="result-info">
<div class="result-title">${item.title || 'Unknown'}</div>
<div class="result-artist">${item.author || 'Unknown'}</div>
</div>
<div style="display:flex; gap:6px;">
<button class="result-btn" onclick="event.stopPropagation(); player.playFromSearch(${idx})" title="播放">
<span class="material-icons" style="font-size:18px;">play_arrow</span>
</button>
<button class="result-btn" onclick="event.stopPropagation(); player.downloadSearch(${idx})" title="下载">
<span class="material-icons" style="font-size:18px;">download</span>
</button>
</div>
</div>
`;
});
resultsDiv.innerHTML = html;
},
playFromSearch(index) {
const item = this.searchResults[index];
if (!item || !item.url) {
showToast('无法播放:缺少音频链接');
return;
}
this.tracks = this.searchResults.map(i => ({
name: i.title,
artist: i.author,
url: i.url,
cover: i.pic,
lrc: i.lrc
}));
this.currentIndex = index;
this.renderPlaylist();
this.loadTrack(index, true);
document.getElementById('searchResults').classList.add('hidden');
setPage('music', document.querySelectorAll('.bottom-nav-item')[1]);
},
playAllSearch() {
const validResults = this.searchResults.filter(i => i && i.url && i.url.trim() !== '');
if (validResults.length === 0) {
showToast('没有可播放的歌曲');
return;
}
this.tracks = validResults.map(i => ({ name: i.title, artist: i.author, url: i.url, cover: i.pic, lrc: i.lrc }));
this.currentIndex = 0;
this.renderPlaylist();
this.loadTrack(0, true);
document.getElementById('searchResults').classList.add('hidden');
setPage('music', document.querySelectorAll('.bottom-nav-item')[1]);
},
downloadSearch(index) {
const item = this.searchResults[index];
if (item && item.url) downloadEngine(item.url, `${item.author} - ${item.title}`);
else showToast('无可下载链接');
},
downloadByIndex(index) {
const track = this.tracks[index];
if (track && track.url) downloadEngine(track.url, `${track.artist || track.author} - ${track.name || track.title}`);
else showToast('无可下载链接');
},
downloadCurrent() {
if (this.currentIndex === -1 || !this.tracks[this.currentIndex]) { showToast('当前无播放歌曲'); return; }
const track = this.tracks[this.currentIndex];
downloadEngine(track.url, `${track.artist || track.author} - ${track.name || track.title}`);
},
async loadPlaylist(server, id, autoPlay = true) {
showTopLoader();
try {
const res = await fetch(`${this.apiBase}?server=${server}&type=playlist&id=${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
console.log('歌单数据:', data);
if (!data || !Array.isArray(data) || data.length === 0) {
showToast('歌单为空');
hideTopLoader();
return;
}
const validTracks = data.filter(item => item && item.url && item.url.trim() !== '');
this.tracks = validTracks.map(item => ({
name: item.title,
artist: item.author,
url: item.url,
cover: item.pic,
lrc: item.lrc
}));
this.renderPlaylist();
if (this.tracks.length === 0) {
showToast('歌单中无可用歌曲');
hideTopLoader();
return;
}
if (autoPlay) this.loadTrack(0, true);
else this.loadTrack(0, false);
showToast(`已加载 ${this.tracks.length} 首歌曲`);
} catch (err) {
console.error('加载歌单失败:', err);
showToast('加载失败: ' + err.message);
} finally {
hideTopLoader();
}
}
};
function togglePlaylist() {
const drawer = document.getElementById('playlistDrawer');
const overlay = document.getElementById('drawerOverlay');
drawer.classList.toggle('active');
overlay.classList.toggle('active');
}
player.init();
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT') return;
if (e.code === 'Space') { e.preventDefault(); player.togglePlay(); }
else if (e.code === 'ArrowLeft' && e.ctrlKey) player.prev();
else if (e.code === 'ArrowRight' && e.ctrlKey) player.next();
});
let lastTouchEnd = 0;
document.addEventListener('touchend', (e) => {
const now = Date.now();
if (now - lastTouchEnd <= 300) e.preventDefault();
lastTouchEnd = now;
}, false);
window.togglePlaylist = togglePlaylist;
</script>
</body>
</html>
Game Source: YIHENG音乐
Creator: FlashCaptain47
Libraries: three
Complexity: complex (1515 lines, 46.8 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: yiheng-flashcaptain47" to link back to the original. Then publish at arcadelab.ai/publish.