一个轻量的 astro 网易云音乐播放组件

效果类似于 aplayer.js

---
interface Props {
  uid: number;
  color: string;
}
const { uid, color } = Astro.props as Props;
const {
  name: _name,
  artists: _arts,
  album: { picUrl: _purl },
} = (
  await (await fetch(`https://music.163.com/api/song/detail/?id=${uid}&ids=%5B${uid}%5D`)).json()
).songs[0];
const data = {
  name: _name as string,
  artist: _arts.map((artist: { name: string }) => artist.name).join(", ") as string,
  url: `https://music.163.com/song/media/outer/url?id=${uid}.mp3`,
  pic: _purl as string,
  lrc: (
    await (await fetch(`https://music.163.com/api/song/lyric?id=${uid}&lv=1&kv=1&tv=-1`)).json()
  ).lrc.lyric
    .split("\n")
    .flatMap((l: string) => {
      const t = [...l.matchAll(/\[(\d{2}:\d{2}\.\d{2})\]/g)].map(m => m[1]);
      return t.length ? t.map(m => [m, l.replace(/\[\d{2}:\d{2}\.\d{2}\]/g, "")]) : [];
    }) as string[][],
};
---

<music-player class="player" style={{ "--color": color }} url={data.url}>
  <div
    class="pic"
    style={{
      "background-image": `url(${data.pic})`,
    }}
  >
    <div class="switch">
      <svg class="play" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-1 0 15 31">
        <path
          d="M15.552 15.168q0.448 0.32 0.448 0.832 0 0.448-0.448 0.768l-13.696 8.512q-0.768 0.512-1.312 0.192t-0.544-1.28v-16.448q0-0.96 0.544-1.28t1.312 0.192z"
        ></path>
      </svg>
      <svg class="pause" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 16 31">
        <path
          d="M14.080 4.8q2.88 0 2.88 2.048v18.24q0 2.112-2.88 2.112t-2.88-2.112v-18.24q0-2.048 2.88-2.048zM2.88 4.8q2.88 0 2.88 2.048v18.24q0 2.112-2.88 2.112t-2.88-2.112v-18.24q0-2.048 2.88-2.048z"
        ></path>
      </svg>
    </div>
  </div>
  <div class="info">
    <div class="title">
      <span class="name">{data.name}</span>
      <span class="artist">&nbsp;&ndash;&nbsp;{data.artist}</span>
    </div>
    <div class="v-lrc">
      <div class="lrc">
        {data.lrc.map(l => <span data-time={l[0]}>{l[1]}</span>)}
      </div>
    </div>
    <div class="ctrl">
      <div class="bar">
        <div class="a-bar"></div>
        <div class="thumb"></div>
      </div>
      <div class="time">00:00&nbsp;/&nbsp;00:00</div>
      <div class="voice">
        <div class="vol-icon">
          <svg
            class="vol-normal"
            xmlns="http://www.w3.org/2000/svg"
            version="1.1"
            viewBox="0 0 28 32"
          >
            <path
              d="M13.728 6.272v19.456q0 0.448-0.352 0.8t-0.8 0.32-0.8-0.32l-5.952-5.952h-4.672q-0.48 0-0.8-0.352t-0.352-0.8v-6.848q0-0.48 0.352-0.8t0.8-0.352h4.672l5.952-5.952q0.32-0.32 0.8-0.32t0.8 0.32 0.352 0.8zM20.576 16q0 1.344-0.768 2.528t-2.016 1.664q-0.16 0.096-0.448 0.096-0.448 0-0.8-0.32t-0.32-0.832q0-0.384 0.192-0.64t0.544-0.448 0.608-0.384 0.512-0.64 0.192-1.024-0.192-1.024-0.512-0.64-0.608-0.384-0.544-0.448-0.192-0.64q0-0.48 0.32-0.832t0.8-0.32q0.288 0 0.448 0.096 1.248 0.48 2.016 1.664t0.768 2.528z"
            ></path>
          </svg>
          <svg class="vol-max" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 28 32">
            <path
              d="M13.728 6.272v19.456q0 0.448-0.352 0.8t-0.8 0.32-0.8-0.32l-5.952-5.952h-4.672q-0.48 0-0.8-0.352t-0.352-0.8v-6.848q0-0.48 0.352-0.8t0.8-0.352h4.672l5.952-5.952q0.32-0.32 0.8-0.32t0.8 0.32 0.352 0.8zM20.576 16q0 1.344-0.768 2.528t-2.016 1.664q-0.16 0.096-0.448 0.096-0.448 0-0.8-0.32t-0.32-0.832q0-0.384 0.192-0.64t0.544-0.448 0.608-0.384 0.512-0.64 0.192-1.024-0.192-1.024-0.512-0.64-0.608-0.384-0.544-0.448-0.192-0.64q0-0.48 0.32-0.832t0.8-0.32q0.288 0 0.448 0.096 1.248 0.48 2.016 1.664t0.768 2.528zM25.152 16q0 2.72-1.536 5.056t-4 3.36q-0.256 0.096-0.448 0.096-0.48 0-0.832-0.352t-0.32-0.8q0-0.704 0.672-1.056 1.024-0.512 1.376-0.8 1.312-0.96 2.048-2.4t0.736-3.104-0.736-3.104-2.048-2.4q-0.352-0.288-1.376-0.8-0.672-0.352-0.672-1.056 0-0.448 0.32-0.8t0.8-0.352q0.224 0 0.48 0.096 2.496 1.056 4 3.36t1.536 5.056zM29.728 16q0 4.096-2.272 7.552t-6.048 5.056q-0.224 0.096-0.448 0.096-0.48 0-0.832-0.352t-0.32-0.8q0-0.64 0.704-1.056 0.128-0.064 0.384-0.192t0.416-0.192q0.8-0.448 1.44-0.896 2.208-1.632 3.456-4.064t1.216-5.152-1.216-5.152-3.456-4.064q-0.64-0.448-1.44-0.896-0.128-0.096-0.416-0.192t-0.384-0.192q-0.704-0.416-0.704-1.056 0-0.448 0.32-0.8t0.832-0.352q0.224 0 0.448 0.096 3.776 1.632 6.048 5.056t2.272 7.552z"
            ></path>
          </svg>
          <svg
            class="vol-mute"
            xmlns="http://www.w3.org/2000/svg"
            version="1.1"
            viewBox="0 0 28 32"
          >
            <path
              d="M13.728 6.272v19.456q0 0.448-0.352 0.8t-0.8 0.32-0.8-0.32l-5.952-5.952h-4.672q-0.48 0-0.8-0.352t-0.352-0.8v-6.848q0-0.48 0.352-0.8t0.8-0.352h4.672l5.952-5.952q0.32-0.32 0.8-0.32t0.8 0.32 0.352 0.8z"
            ></path>
          </svg>
        </div>
        <div class="vol-bar">
          <div class="vol-fill"></div>
        </div>
      </div>
    </div>
  </div>
</music-player>
<style>
  .player {
    display: flex;
    min-height: 90px;
    max-height: 90px;
    min-width: 90px;
    max-width: 1000px;
    width: 100%;
    user-select: none;
    backdrop-filter: blur(20px);
    background: #ffffff0d;
    border-radius: 11px;
    overflow: hidden;
    box-shadow: 0 15px 35px #0003;
    margin: 5px;

    --text-color: #000;
  }
  @media (prefers-color-scheme: dark) {
    .player {
      --text-color: #fff;
    }
  }
  .pic {
    background-color: var(--color);
    background-position: 50%;
    background-repeat: no-repeat;
    background-size: cover;
    min-height: 90px;
    min-width: 90px;
    max-height: 90px;
    max-width: 90px;
    display: flex;
    align-items: center;
    justify-content: center;
    position: relative;
    cursor: pointer;
  }
  .switch {
    position: absolute;
    border: 2px solid #fff;
    opacity: 0.8;
    text-shadow: 0 1px 1px #0003;
    background: #0003;
    border-radius: 50%;
    box-shadow: 0 1px 1px #0003;
    display: flex;
    align-items: center;
    justify-content: center;

    width: 26px;
    height: 26px;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);

    transition:
      width 0.3s ease,
      height 0.3s ease,
      transform 0.3s ease,
      top 0.3s ease,
      left 0.3s ease;
  }
  .pic:hover .switch {
    opacity: 1;
  }
  .playing .switch {
    width: 16px;
    height: 16px;
    top: auto;
    left: auto;
    bottom: 5px;
    right: 5px;
    transform: none;
  }
  .play,
  .pause {
    fill: #fff;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    transition:
      opacity 0.3s ease,
      transform 0.3s ease;
  }
  .play {
    width: 20px;
    height: 20px;
    opacity: 1;
    transform: translate(-50%, -50%) scale(1);
  }
  .playing .play {
    opacity: 0;
    transform: translate(-50%, -50%) scale(0.5);
    pointer-events: none;
  }
  .pause {
    width: 12px;
    height: 12px;
    opacity: 0;
    transform: translate(-50%, -50%) scale(0.5);
  }
  .playing .pause {
    opacity: 1;
    transform: translate(-50%, -50%) scale(1);
  }
  .info {
    display: flex;
    flex-direction: column;
    padding: 10px 7px 6px 7px;
    justify-content: space-between;
    color: var(--text-color);
    flex: 1;
    min-width: 0;
  }
  .title {
    margin: 0 0 0 5px;
  }
  .name {
    font-size: 14px;
  }
  .artist {
    font-size: 12px;
    color: var(--color);
  }
  .v-lrc {
    height: 30px;
    width: 100%;
    overflow-y: hidden;
  }
  .lrc {
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 100%;
    height: 100%;
    font-size: 12px;
    transition: transform 0.3s ease;
    will-change: transform;
  }
  .lrc .current {
    color: var(--color);
  }
  .ctrl {
    display: flex;
    flex-direction: row;
    align-items: center;
  }
  .bar {
    height: 2px;
    width: 100%;
    background-color: #aaa;
    margin: 5px;
    display: flex;
    position: relative;
    cursor: pointer;
  }
  .a-bar {
    height: 2px;
    background-color: var(--color);
    pointer-events: none;
  }
  .thumb {
    width: 0;
    height: 0;
    border-radius: 100%;
    background-color: var(--color);
    margin-top: 0;
    margin-left: 0;
    transition: all 0.2s ease;
    pointer-events: none;
    flex-shrink: 0;
  }
  .bar:hover .thumb,
  .bar.dragging .thumb {
    width: 10px;
    height: 10px;
    margin-top: -4px;
    margin-left: -5px;
  }
  .time {
    font-size: 11px;
    margin: 0 3px;
    white-space: nowrap;
  }
  .voice {
    min-height: 15px;
    min-width: 15px;
    max-height: 15px;
    display: flex;
    align-items: center;
    gap: 6px;
    position: relative;
    cursor: pointer;
    flex-shrink: 0;
  }
  .vol-icon {
    width: 15px;
    height: 15px;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    position: relative;
  }
  .vol-icon svg {
    width: 100%;
    height: 100%;
    fill: var(--text-color);
    transition: opacity 0.2s;
    position: absolute;
    top: 0;
    left: 0;
    opacity: 0;
    pointer-events: none;
  }
  .vol-icon .vol-normal {
    opacity: 1;
    pointer-events: auto;
  }
  .voice.vol-max .vol-normal {
    opacity: 0;
    pointer-events: none;
  }
  .voice.vol-max .vol-max {
    opacity: 1;
    pointer-events: auto;
  }
  .voice.muted .vol-normal,
  .voice.muted .vol-max {
    opacity: 0;
    pointer-events: none;
  }
  .voice.muted .vol-mute {
    opacity: 1;
    pointer-events: auto;
  }
  .vol-bar {
    width: 0;
    height: 4px;
    background: #aaa;
    border-radius: 2px;
    position: relative;
    transition: width 0.3s ease;
    overflow: visible;
    cursor: pointer;
  }
  .voice:hover .vol-bar,
  .vol-bar.dragging {
    width: 50px;
  }
  .vol-fill {
    height: 100%;
    background: var(--color);
    border-radius: 2px;
    width: 100%;
    position: relative;
    pointer-events: none;
  }
</style>
<script>
  class MusicPlayer extends HTMLElement {
    async connectedCallback() {
      const formatTime = (seconds: number): string => {
        if (!isFinite(seconds) || isNaN(seconds)) return "00:00";
        return seconds
          ? `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`
          : "00:00";
      };
      const toTime = (f: string): number => {
        const [minutes, seconds] = f.split(":");
        return parseFloat(minutes) * 60 + parseFloat(seconds);
      };

      const picE = this.querySelector(".pic") as HTMLElement;
      const lrcE = this.querySelector(".lrc") as HTMLElement;
      const timeE = this.querySelector(".time") as HTMLElement;
      const a_barE = this.querySelector(".a-bar") as HTMLElement;
      const barE = this.querySelector(".bar") as HTMLElement;
      const voiceE = this.querySelector(".voice") as HTMLElement;
      const volBarE = voiceE.querySelector(".vol-bar") as HTMLElement;
      const volFillE = voiceE.querySelector(".vol-fill") as HTMLElement;
      const volIconE = voiceE.querySelector(".vol-icon") as HTMLElement;

      const audio = new Audio(this.getAttribute("url") as string);
      audio.volume = 1;

      let lastVolume = 1;
      let isDraggingProgress = false;
      let isDraggingVolume = false;

      // 更新图标状态
      const updateVolIcon = () => {
        voiceE.classList.remove("muted", "vol-max");
        if (audio.volume === 0) {
          voiceE.classList.add("muted");
        } else if (audio.volume === 1) {
          voiceE.classList.add("vol-max");
        }
      };
      updateVolIcon();

      picE.addEventListener("click", () => {
        if (picE.classList.contains("playing")) {
          picE.classList.remove("playing");
          audio.pause();
        } else {
          picE.classList.add("playing");
          audio.play();
        }
      });

      const seek = (e: MouseEvent) => {
        const rect = barE.getBoundingClientRect();
        const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
        if (audio.duration && isFinite(audio.duration)) {
          audio.currentTime = percent * audio.duration;
        }
      };

      barE.addEventListener("mousedown", e => {
        isDraggingProgress = true;
        barE.classList.add("dragging");
        seek(e);
      });

      const setVolume = (e: MouseEvent) => {
        const rect = volBarE.getBoundingClientRect();
        const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
        audio.volume = percent;
        volFillE.style.width = `${percent * 100}%`;
        lastVolume = percent;
        updateVolIcon();
      };

      volBarE.addEventListener("mousedown", e => {
        isDraggingVolume = true;
        volBarE.classList.add("dragging");
        setVolume(e);
      });

      volIconE.addEventListener("click", e => {
        e.stopPropagation();
        if (audio.volume > 0) {
          lastVolume = audio.volume;
          audio.volume = 0;
          volFillE.style.width = "0%";
        } else {
          audio.volume = lastVolume || 1;
          volFillE.style.width = `${audio.volume * 100}%`;
        }
        updateVolIcon();
      });

      document.addEventListener("mousemove", e => {
        if (isDraggingProgress) seek(e);
        if (isDraggingVolume) setVolume(e);
      });

      document.addEventListener("mouseup", () => {
        if (isDraggingProgress) {
          isDraggingProgress = false;
          barE.classList.remove("dragging");
        }
        if (isDraggingVolume) {
          isDraggingVolume = false;
          volBarE.classList.remove("dragging");
        }
      });

      audio.addEventListener("loadedmetadata", () => {
        if (audio.duration && isFinite(audio.duration)) {
          timeE.innerHTML = formatTime(0) + "&nbsp;/&nbsp;" + formatTime(audio.duration);
        }
      });

      audio.addEventListener("timeupdate", () => {
        const currentT = audio.currentTime;
        const durationT = audio.duration;

        [...lrcE.children].forEach(c => {
          c.classList.remove("current");
        });
        const c = lrcE.children;
        let f: HTMLElement | null = null;
        for (let i = 1; i < c.length; i++) {
          const cT = toTime((c[i] as HTMLElement).dataset.time as string);
          if (cT > currentT) {
            c[i - 1].classList.add("current");
            f = c[i - 1] as HTMLElement;
            break;
          }
        }
        if (!f && c.length > 0) {
          c[c.length - 1].classList.add("current");
          f = c[c.length - 1] as HTMLElement;
        }
        if (f) {
          lrcE.style.transform = `translateY(${-f.offsetTop}px)`;
        }

        if (durationT && isFinite(durationT)) {
          a_barE.style.width = `${(currentT * 100) / durationT}%`;
          timeE.innerHTML = formatTime(currentT) + "&nbsp;/&nbsp;" + formatTime(durationT);
        }
      });
    }
  }

  customElements.define("music-player", MusicPlayer);
</script>
posted @ 2026-08-18 00:00  Expector  阅读(4)  评论(0)    收藏  举报