🎮ArcadeLab

YIHENG音乐

by FlashCaptain47
1515 lines46.8 KB🛠️ Three.js (3D graphics)
▶ Play
<!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.