左下角添加音乐播放元素(进电脑端生效)

This commit is contained in:
2026-05-28 18:54:36 +08:00
parent bf8bd7be26
commit 4a60769d87
5 changed files with 343 additions and 3 deletions

View File

@ -0,0 +1,259 @@
---
import { siteConfig } from "../../config";
const cfg = siteConfig.music;
if (!cfg?.enable) return;
const server = cfg.server || "netease";
const playlistId = cfg.playlistId;
const defaultVolume = cfg.defaultVolume ?? 1;
---
<div id="mp-wrapper" class="mp-wrapper hidden lg:block" data-server={server} data-id={playlistId} data-volume={defaultVolume}>
<audio id="mp-audio" preload="metadata"></audio>
<div id="mp-body" class="mp-body mp-collapsed">
<div id="mp-cover-wrap" class="mp-cover-wrap">
<img id="mp-cover" class="mp-cover" src="" alt="" />
<div id="mp-cover-ph" class="mp-cover-ph">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55C7.79 13 6 14.79 6 17s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
</div>
</div>
<div class="mp-content-anim">
<div id="mp-content" class="mp-content">
<div class="mp-header">
<div class="mp-meta">
<div id="mp-title" class="mp-title">加载中...</div>
<div id="mp-artist" class="mp-artist"></div>
</div>
</div>
<div class="mp-progress-section">
<div id="mp-progress" class="mp-progress-bar">
<div id="mp-progress-loaded" class="mp-progress-loaded"></div>
<div id="mp-progress-played" class="mp-progress-played"></div>
</div>
<div class="mp-time">
<span id="mp-current">0:00</span>
<span id="mp-total">0:00</span>
</div>
</div>
<div class="mp-controls">
<button id="mp-btn-prev" class="mp-ctrl" aria-label="上一首">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
</button>
<button id="mp-btn-play" class="mp-ctrl mp-ctrl-play" aria-label="播放">
<svg id="mp-icon-play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg id="mp-icon-pause" viewBox="0 0 24 24" fill="currentColor" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<button id="mp-btn-next" class="mp-ctrl" aria-label="下一首">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
<div class="mp-spacer"></div>
<button id="mp-btn-vol" class="mp-ctrl mp-ctrl-sm" aria-label="音量">
<svg id="mp-icon-vol" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
<svg id="mp-icon-muted" viewBox="0 0 24 24" fill="currentColor" style="display:none"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
</button>
<button id="mp-btn-list" class="mp-ctrl mp-ctrl-sm" aria-label="列表">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/></svg>
</button>
</div>
<div id="mp-playlist" class="mp-playlist mp-list-hidden">
<div id="mp-list-inner" class="mp-list-inner"></div>
</div>
</div>
</div>
</div>
</div>
<style is:global>
.mp-wrapper { position: fixed; left: 1rem; bottom: 1rem; z-index: 100; font-size: 0.8rem; line-height: 1.4; }
.mp-wrapper * { box-sizing: border-box; }
.mp-body {
position: relative;
background: var(--card-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 4px 24px rgba(0,0,0,.12), 0 0 0 1px rgba(0,0,0,.04);
overflow: hidden;
min-height: 3rem;
transition: width 400ms cubic-bezier(.5,0,.3,1), border-radius 400ms cubic-bezier(.5,0,.3,1), box-shadow 300ms;
}
:root.dark .mp-body { box-shadow: 0 4px 24px rgba(0,0,0,.4), 0 0 0 1px rgba(255,255,255,.06); }
.mp-body.mp-collapsed { width: 3rem; border-radius: 50%; cursor: pointer; }
.mp-body.mp-collapsed:hover { box-shadow: 0 6px 28px rgba(0,0,0,.2); }
:root.dark .mp-body.mp-collapsed:hover { box-shadow: 0 6px 28px rgba(0,0,0,.5), 0 0 0 1px rgba(255,255,255,.08); }
.mp-body.mp-expanded { width: 20rem; border-radius: var(--radius-large); cursor: default; }
.mp-cover-wrap {
position: absolute; top: 0; left: 0; width: 3rem; height: 3rem;
border-radius: 50%; overflow: hidden; z-index: 2;
background: var(--btn-regular-bg);
display: flex; align-items: center; justify-content: center;
transition: top 400ms cubic-bezier(.5,0,.3,1), left 400ms cubic-bezier(.5,0,.3,1),
width 400ms cubic-bezier(.5,0,.3,1), height 400ms cubic-bezier(.5,0,.3,1),
border-radius 400ms cubic-bezier(.5,0,.3,1), box-shadow 400ms cubic-bezier(.5,0,.3,1);
}
.mp-body.mp-expanded .mp-cover-wrap { top: .875rem; left: .875rem; width: 3.25rem; height: 3.25rem; border-radius: .5rem; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
.mp-cover { width: 100%; height: 100%; object-fit: cover; display: none; }
.mp-cover[src]:not([src=""]) { display: block; }
.mp-cover[src]:not([src=""]) + .mp-cover-ph { display: none; }
.mp-cover-ph { color: var(--btn-content); display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.mp-cover-ph svg { width: 1.25rem; height: 1.25rem; }
.mp-body.mp-collapsed.mp-spinning .mp-cover-wrap { animation: mp-rotate 8s linear infinite; }
.mp-body.mp-collapsed:hover .mp-cover-wrap { animation-play-state: paused; }
@keyframes mp-rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.mp-content-anim { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 400ms cubic-bezier(.5,0,.3,1); }
.mp-body.mp-expanded .mp-content-anim { grid-template-rows: 1fr; }
.mp-content { overflow: hidden; min-height: 0; padding: .875rem; }
.mp-header { display: flex; gap: .75rem; align-items: center; margin-bottom: .75rem; padding-left: 4rem; }
.mp-meta { flex: 1; min-width: 0; }
.mp-title { font-weight: 600; font-size: .875rem; color: var(--deep-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
:root.dark .mp-title { color: rgba(255,255,255,.88); }
.mp-artist { font-size: .725rem; color: var(--btn-content); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: .1rem; }
.mp-progress-section { margin-bottom: .5rem; padding-left: 4rem; }
.mp-progress-bar { width: 100%; height: 4px; background: var(--btn-regular-bg); border-radius: 2px; cursor: pointer; position: relative; overflow: hidden; transition: height 150ms; }
.mp-progress-bar:hover { height: 6px; }
.mp-progress-loaded { position: absolute; top: 0; left: 0; height: 100%; background: var(--line-divider); border-radius: 2px; width: 0; pointer-events: none; }
.mp-progress-played { position: absolute; top: 0; left: 0; height: 100%; background: var(--primary); border-radius: 2px; width: 0; pointer-events: none; }
.mp-time { display: flex; justify-content: space-between; font-size: .625rem; color: var(--btn-content); margin-top: .2rem; font-variant-numeric: tabular-nums; }
.mp-controls { display: flex; align-items: center; gap: .25rem; }
.mp-ctrl {
display: flex; align-items: center; justify-content: center;
width: 2rem; height: 2rem; border: none; border-radius: .5rem;
cursor: pointer; background: transparent; color: var(--btn-content);
transition: background 150ms, color 150ms; padding: 0;
}
.mp-ctrl svg { width: 1.125rem; height: 1.125rem; }
.mp-ctrl:hover { background: var(--btn-plain-bg-hover); color: var(--primary); }
.mp-ctrl:active { background: var(--btn-plain-bg-active); }
.mp-ctrl-play { width: 2.25rem; height: 2.25rem; color: var(--primary); }
.mp-ctrl-play svg { width: 1.25rem; height: 1.25rem; }
.mp-ctrl-play:hover { background: var(--btn-plain-bg-hover); }
.mp-ctrl-sm { width: 1.75rem; height: 1.75rem; }
.mp-ctrl-sm svg { width: .875rem; height: .875rem; }
.mp-spacer { flex: 1; }
.mp-playlist { max-height: 180px; overflow: hidden; transition: max-height 300ms cubic-bezier(.4,0,.2,1), margin-top 300ms cubic-bezier(.4,0,.2,1); margin-top: .5rem; }
.mp-list-hidden { max-height: 0 !important; margin-top: 0 !important; }
.mp-list-inner { overflow-y: auto; overflow-x: hidden; max-height: 180px; border-top: 1px solid var(--line-divider); padding-top: .35rem; }
.mp-list-inner::-webkit-scrollbar { width: 3px; }
.mp-list-inner::-webkit-scrollbar-thumb { background: var(--scrollbar-bg); border-radius: 2px; }
.mp-item { display: flex; align-items: center; padding: .3rem .5rem; border-radius: .375rem; cursor: pointer; gap: .5rem; transition: background 150ms; font-size: .725rem; color: var(--deep-text); }
:root.dark .mp-item { color: rgba(255,255,255,.75); }
.mp-item:hover { background: var(--btn-plain-bg-hover); }
.mp-item-active { color: var(--primary); background: var(--btn-plain-bg-hover); }
.mp-item-name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mp-item-artist { color: var(--btn-content); font-size: .625rem; white-space: nowrap; }
</style>
<script>
declare global { interface Window { __mpInit?: boolean } }
interface MpSong { name: string; artist: string; pic: string; url: string }
if (!window.__mpInit) {
window.__mpInit = true;
const wrapper = document.getElementById('mp-wrapper');
if (wrapper) {
const server = wrapper.dataset.server ?? 'netease';
const playlistId = wrapper.dataset.id ?? '';
const defaultVolume = parseFloat(wrapper.dataset.volume ?? '1') || 1;
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const audio = $<HTMLAudioElement>('mp-audio');
const body = $<HTMLElement>('mp-body');
const coverImg = $<HTMLImageElement>('mp-cover');
const titleEl = $<HTMLElement>('mp-title');
const artistEl = $<HTMLElement>('mp-artist');
const progressBar = $<HTMLElement>('mp-progress');
const progressPlayed = $<HTMLElement>('mp-progress-played');
const progressLoaded = $<HTMLElement>('mp-progress-loaded');
const currentEl = $<HTMLElement>('mp-current');
const totalEl = $<HTMLElement>('mp-total');
const playBtn = $<HTMLButtonElement>('mp-btn-play');
const prevBtn = $<HTMLButtonElement>('mp-btn-prev');
const nextBtn = $<HTMLButtonElement>('mp-btn-next');
const volBtn = $<HTMLButtonElement>('mp-btn-vol');
const listBtn = $<HTMLButtonElement>('mp-btn-list');
const iconPlay = $<HTMLElement>('mp-icon-play');
const iconPause = $<HTMLElement>('mp-icon-pause');
const iconVol = $<HTMLElement>('mp-icon-vol');
const iconMuted = $<HTMLElement>('mp-icon-muted');
const playlistEl = $<HTMLElement>('mp-playlist');
const listInner = $<HTMLElement>('mp-list-inner');
let playlist: MpSong[] = [];
let curIdx = 0;
let playing = false;
let expanded = false;
let listOpen = false;
const fmt = (s: number) => { if (!s || isNaN(s)) return '0:00'; const m = Math.floor(s / 60); return m + ':' + String(Math.floor(s % 60)).padStart(2, '0'); };
const setPlaying = (v: boolean) => {
playing = v;
iconPlay.style.display = v ? 'none' : '';
iconPause.style.display = v ? '' : 'none';
body.classList.toggle('mp-spinning', v);
};
const load = (idx: number) => {
const s = playlist[idx];
if (!s) return;
curIdx = idx;
titleEl.textContent = s.name || '未知歌曲';
artistEl.textContent = s.artist || '未知艺术家';
coverImg.src = s.pic || '';
audio.src = s.url;
audio.volume = defaultVolume;
renderList();
};
const renderList = () => {
listInner.innerHTML = '';
playlist.forEach((s, i) => {
const div = document.createElement('div');
div.className = 'mp-item' + (i === curIdx ? ' mp-item-active' : '');
div.innerHTML = `<span class="mp-item-name">${s.name || '未知'}</span><span class="mp-item-artist">${s.artist || ''}</span>`;
div.addEventListener('click', () => { load(i); audio.play(); });
listInner.appendChild(div);
});
};
const expand = () => { if (expanded) return; expanded = true; body.classList.replace('mp-collapsed', 'mp-expanded'); };
const collapse = () => { if (!expanded) return; expanded = false; listOpen = false; playlistEl.classList.add('mp-list-hidden'); body.classList.replace('mp-expanded', 'mp-collapsed'); };
const stop = (e: Event) => e.stopPropagation();
body.addEventListener('click', (e) => { stop(e); if (!expanded) expand(); });
audio.addEventListener('timeupdate', () => { if (!isNaN(audio.duration)) { progressPlayed.style.width = (audio.currentTime / audio.duration * 100) + '%'; currentEl.textContent = fmt(audio.currentTime); } });
audio.addEventListener('loadedmetadata', () => { totalEl.textContent = fmt(audio.duration); });
audio.addEventListener('progress', () => { if (audio.buffered.length > 0 && !isNaN(audio.duration)) progressLoaded.style.width = (audio.buffered.end(audio.buffered.length - 1) / audio.duration * 100) + '%'; });
audio.addEventListener('ended', () => { load((curIdx + 1) % playlist.length); audio.play(); });
audio.addEventListener('play', () => setPlaying(true));
audio.addEventListener('pause', () => setPlaying(false));
progressBar.addEventListener('click', (e: MouseEvent) => { const r = progressBar.getBoundingClientRect(); if (!isNaN(audio.duration)) audio.currentTime = ((e.clientX - r.left) / r.width) * audio.duration; });
playBtn.addEventListener('click', (e) => { stop(e); playing ? audio.pause() : audio.play(); });
prevBtn.addEventListener('click', (e) => { stop(e); load((curIdx - 1 + playlist.length) % playlist.length); if (playing) audio.play(); });
nextBtn.addEventListener('click', (e) => { stop(e); load((curIdx + 1) % playlist.length); if (playing) audio.play(); });
volBtn.addEventListener('click', (e) => { stop(e); audio.muted = !audio.muted; iconVol.style.display = audio.muted ? 'none' : ''; iconMuted.style.display = audio.muted ? '' : 'none'; });
listBtn.addEventListener('click', (e) => { stop(e); listOpen = !listOpen; playlistEl.classList.toggle('mp-list-hidden', !listOpen); });
document.addEventListener('click', (e) => { if (expanded && !wrapper.contains(e.target as Node)) collapse(); });
fetch(`https://api.injahow.cn/meting/?type=playlist&id=${playlistId}&server=${server}`)
.then(r => r.json())
.then((data: MpSong[]) => { playlist = data; if (playlist.length > 0) load(0); })
.catch(() => { titleEl.textContent = '加载失败'; });
}
}
</script>

View File

@ -1,6 +1,7 @@
import type {
ExpressiveCodeConfig,
LicenseConfig,
MusicPlayerConfig,
NavBarConfig,
ProfileConfig,
SiteConfig,
@ -38,6 +39,13 @@ export const siteConfig: SiteConfig = {
// sizes: '32x32', // (Optional) Size of the favicon, set only if you have favicons of different sizes
// }
],
music: {
enable: true,
playlistId: "7708342577",
server: "netease",
defaultVolume: 1,
} satisfies MusicPlayerConfig,
};
export const navBarConfig: NavBarConfig = {

View File

@ -4,6 +4,7 @@ import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import ConfigCarrier from "@components/ConfigCarrier.astro";
import MusicPlayer from "@components/widget/MusicPlayer.astro";
import { profileConfig, siteConfig } from "@/config";
import {
AUTO_MODE,
@ -157,6 +158,8 @@ const bannerOffset =
<SpeedInsights />
<MusicPlayer />
<!-- increase the page height during page transition to prevent the scrolling animation from jumping -->
<div id="page-height-extend" class="hidden h-[300vh]"></div>
</body>
@ -568,3 +571,27 @@ if (window.swup) {
document.addEventListener("swup:enable", setup)
}
</script>
<script>
function adjustTocLayout() {
const hasToc = !!document.querySelector('[data-has-toc]');
const isWide = window.innerWidth >= 1536;
if (hasToc && isWide) {
document.body.classList.add('toc-offset');
} else {
document.body.classList.remove('toc-offset');
}
}
adjustTocLayout();
window.addEventListener('resize', adjustTocLayout);
if (window.swup) {
window.swup.hooks.on('content:replace', () => requestAnimationFrame(adjustTocLayout));
window.swup.hooks.on('page:view', () => requestAnimationFrame(adjustTocLayout));
} else {
document.addEventListener('swup:enable', () => {
adjustTocLayout();
window.swup.hooks.on('content:replace', () => requestAnimationFrame(adjustTocLayout));
window.swup.hooks.on('page:view', () => requestAnimationFrame(adjustTocLayout));
});
}
</script>

View File

@ -62,7 +62,7 @@ const mainPanelTop = siteConfig.banner.enable
<!-- Main content -->
<div class="absolute w-full z-30 pointer-events-none" style={`top: ${mainPanelTop}`}>
<!-- The pointer-events-none here prevent blocking the click event of the TOC -->
<div class="relative max-w-[var(--page-width)] mx-auto pointer-events-auto">
<div id="content-center-wrap" class="relative max-w-[var(--page-width)] mx-auto pointer-events-auto">
<div id="main-grid" class="transition duration-700 w-full left-0 right-0 grid grid-cols-[17.5rem_auto] grid-rows-[auto_1fr_auto] lg:grid-rows-[auto]
mx-auto gap-4 px-0 md:px-4"
>
@ -82,7 +82,7 @@ const mainPanelTop = siteConfig.banner.enable
<SideBar class="mb-4 row-start-2 row-end-3 col-span-2 lg:row-start-1 lg:row-end-2 lg:col-span-1 lg:max-w-[17.5rem] onload-animation" headings={headings}></SideBar>
<main id="swup-container" class="transition-swup-fade col-span-2 lg:col-span-1 overflow-hidden">
<main id="swup-container" class="transition-swup-fade col-span-2 lg:col-span-1 overflow-hidden" data-has-toc={headings.length > 0 ? "" : undefined}>
<div id="content-wrapper" class="onload-animation">
<!-- the overflow-hidden here prevent long text break the layout-->
<!-- make id different from windows.swup global property -->
@ -104,7 +104,7 @@ const mainPanelTop = siteConfig.banner.enable
<!-- The things that should be under the banner, only the TOC for now -->
<div class="absolute w-full z-0 hidden 2xl:block">
<div class="relative max-w-[var(--page-width)] mx-auto">
<div id="toc-center-wrap" class="relative max-w-[var(--page-width)] mx-auto">
<!-- TOC component -->
{siteConfig.toc.enable && <div id="toc-wrapper" class:list={["hidden lg:block transition absolute top-0 -right-[var(--toc-width)] w-[var(--toc-width)] items-center",
{"toc-hide": siteConfig.banner.enable}]}
@ -123,3 +123,40 @@ const mainPanelTop = siteConfig.banner.enable
</div>
</div>
</Layout>
<style is:global>
@media (min-width: 1536px) {
#content-center-wrap,
#toc-center-wrap {
margin-left: calc((100% - var(--page-width)) / 2) !important;
margin-right: calc((100% - var(--page-width)) / 2) !important;
transition: margin 500ms cubic-bezier(0.5, 0, 0.3, 1);
}
#main-grid {
transition-property: none !important;
}
#sidebar-sticky {
transition-property: none !important;
}
#top-row {
transition: max-width 500ms cubic-bezier(0.5, 0, 0.3, 1),
padding 500ms cubic-bezier(0.5, 0, 0.3, 1);
}
#top-row .card-base {
transition: background-color 300ms,
max-width 500ms cubic-bezier(0.5, 0, 0.3, 1);
}
body.toc-offset #content-center-wrap,
body.toc-offset #toc-center-wrap {
margin-left: calc((100% - var(--page-width)) / 2 - var(--toc-width) / 2) !important;
margin-right: calc((100% - var(--page-width)) / 2 - var(--toc-width) / 2) !important;
}
body.toc-offset #top-row {
max-width: calc(var(--page-width) + var(--toc-width)) !important;
}
body.toc-offset #top-row .card-base {
max-width: calc(var(--page-width) + var(--toc-width)) !important;
}
}
</style>

View File

@ -38,6 +38,8 @@ export type SiteConfig = {
favicon: Favicon[];
startDate?: Date;
music?: MusicPlayerConfig;
};
export type Favicon = {
@ -108,3 +110,10 @@ export type TwikooConfig = {
envId?: string;
lang?: string;
};
export type MusicPlayerConfig = {
enable: boolean;
playlistId: string;
server?: "netease" | "tencent" | "kugou" | "xiami" | "baidu";
defaultVolume?: number;
};