diff --git a/.env.example b/.env.example index 049df13..5316429 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,14 @@ # Github Token -VITE_GITHUB_TOKEN=your-github-token +NUXT_PUBLIC_GITHUB_TOKEN=your-github-token # UMAMI API KEY -VITE_UMAMI_API_KEY=your-umami-api-key +NUXT_PUBLIC_UMAMI_API_KEY=your-umami-api-key + +# Wakatime API Key +WAKATIME_API_KEY=your-wakatime-api-key + +# Wakatime API Base(可选) +WAKATIME_API_URL=https://wakatime.com/api/v1 # SMTP 服务器地址 SMTP_HOST=smtp.example.com diff --git a/.gitignore b/.gitignore index 14aaedd..b4cca23 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ build/ .next/ .vercel/ .vite/ +.nuxt/ +.output/ +.edgeone # env & secrets .env diff --git a/README.md b/README.md index 60c463f..6783822 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Cloud Home -一个基于 Vue 的个人主页模板,内置友链申请、网站展示、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。 +一个基于 Nuxt 3 (Vue 3) 的个人主页模板,内置友链申请、网站展示、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。 ## 特性 @@ -11,16 +11,16 @@ ## 技术栈 -- 前端:Vue 3 + HTML + CSS -- 构建:Vite -- 部署:Vercel(静态构建 + Serverless Functions) +- 前端:Nuxt 3(Vue 3)+ HTML + CSS +- 构建 / 运行:Nuxt 3 + Nitro +- 部署:Vercel(Nuxt 构建 + Nitro 函数) ## 致谢 排名不分先后 - [Skill Icons](https://github.com/tandpfun/skill-icons):技能图标库,本项目的技能图标来源。 -- [Netease Mini Player](https://github.com/numakkiyu/NeteaseMiniPlayer):迷你网易云播放器组件,为本项目的音乐播放功能提供支持。 +- [Netease Mini Player](https://github.com/numakkiyu/NeteaseMiniPlayer):迷你网易云播放器组件,为本项目的音乐播放功能提供支持。(本项目使用[本人fork的版本](https://github.com/RhenCloud/NeteaseMiniPlayer)) 感谢以上开源项目原作者与维护者的贡献。 @@ -35,7 +35,7 @@ const siteConfig: SiteConfig = { profile: { name: "Example User", // 你的名字 title: "I'm a software developer.", // 你的简介,可为空 - avatar: "avatar.webp", // 你的头像,可为public目录下的文件或外部链接 + avatar: "/avatar.webp", // 你的头像,可为public目录下的文件或外部链接 bio: "Hello World", // 你的喜欢的一句话,可为空 birthday: "xxxx-xx-xx", // 你的生日,可为空 gender: "", // 你的性别,可为空 @@ -66,7 +66,7 @@ const siteConfig: SiteConfig = { siteMeta: { title: "Example Title", // 网站标题 - icon: "favicon.ico", // 网站图标,可为public目录下的文件或外部链接 + icon: "/favicon.ico", // 网站图标,可为public目录下的文件或外部链接 startDate:"xxxx-xx-xx", // 网站创建日期 }, @@ -90,6 +90,10 @@ const siteConfig: SiteConfig = { autoplay: false, // 是否默认以黑胶唱片状态启动(仅浮动模式) defaultMinimized: true, + // 标签页非激活时是否自动暂停 + autoPause: false, + // Music API 配置 + apiUrls: ["https://www.bilibili.uno/api", "https://meting-api.wangcy.site/api"], }, umami: { @@ -166,16 +170,17 @@ const siteConfig: SiteConfig = { 在 Vercel 控制台或本地 `.env` 配置: -- `VITE_GITHUB_TOKEN`: 具有仓库读取权限的 GitHub Token,用于绕过 GitHub API 速率限制。(可选) -- `UMAMI_API_KEY`: Umami 分析的 API Key。 +- `NUXT_PUBLIC_GITHUB_TOKEN`: 具有仓库读取权限的 GitHub Token,用于绕过 GitHub API 速率限制。(可选) +- `NUXT_PUBLIC_UMAMI_API_KEY`: 可选的 Umami API Key,用于展示访问量统计数据。 - `WAKATIME_API_KEY`: Wakatime API Key,用于获取编码统计数据。 +- `WAKATIME_API_URL`: Wakatime API 地址,覆盖默认 `https://wakatime.com/api/v1`(可选)。 - `SMTP_HOST`: 邮件服务器主机名 - `SMTP_PORT`: 端口(如 465 或 587) - `SMTP_USER`: 发件人邮箱账号 - `SMTP_PASS`: 邮箱授权码或密码 -- `MAIL_FROM`: 发件人地址(通常同 SMTP_USER) +- `SENDER_EMAIL`: 发件人地址(通常同 SMTP_USER) - `ADMIN_EMAIL`: 接收通知的邮箱地址 -- `SMTP_SECURE`:是否启用 SSL/TLS 加密(默认 `true`) +- `SMTP_SECURE`:是否强制启用 SSL/TLS(默认为 `true` 当端口为 465)。 ## 本地开发 @@ -184,7 +189,7 @@ pnpm install pnpm dev ``` -访问 `http://localhost:5173/`。 +访问 `http://localhost:3000/`。 ## 构建 @@ -192,7 +197,7 @@ pnpm dev pnpm build ``` -产物输出到 +产物输出到 Nuxt 的 `.output/` 目录,该目录同时包含静态资源与 Nitro 服务器入口。 ## 部署到 Vercel @@ -201,6 +206,8 @@ pnpm build ## API +雁型的 Nitro 路由位于 `server/api`,依旧暴露同样的 `/api` 前缀。 + - `POST /api/send-mail`:友链申请邮件发送。请求体示例: ```json diff --git a/api/send-mail.ts b/api/send-mail.ts deleted file mode 100644 index 0413a3e..0000000 --- a/api/send-mail.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { VercelRequest, VercelResponse } from "@vercel/node"; -import nodemailer from "nodemailer"; - -export default async function handler(req: VercelRequest, res: VercelResponse) { - // CORS 处理 - // res.setHeader("Access-Control-Allow-Origin", "*"); - // res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); - // res.setHeader("Access-Control-Allow-Headers", "Content-Type"); - - // if (req.method === "OPTIONS") { - // return res.status(200).end(); - // } - - // if (req.method === "GET") { - // return res.status(200).json({ status: "ok", message: "use POST to send mail" }); - // } - - // if (req.method !== "POST") { - // return res.status(405).json({ error: "Method Not Allowed" }); - // } - - try { - const data = typeof req.body === "string" ? JSON.parse(req.body) : req.body; - const { name, email, site, desc, avatar } = data || {}; - if (!name || !email || !desc) { - return res.status(400).json({ error: "Missing required fields: name, email, desc" }); - } - - const transporter = nodemailer.createTransport({ - host: process.env.SMTP_HOST, - port: Number(process.env.SMTP_PORT || 465), - secure: Number(process.env.SMTP_PORT || 465) === 465, - auth: { - user: process.env.SMTP_USER, - pass: process.env.SMTP_PASS, - }, - }); - - const info = await transporter.sendMail({ - from: process.env.SENDER_EMAIL, - to: process.env.ADMIN_EMAIL, - subject: "友链申请 / 联系表单", - html: ` -

名称:${name}

-

邮箱:${email}

-

站点:${site || "未填写"}

-

描述:${desc}

-

头像:${avatar || "未填写"}

-

时间:${new Date().toISOString()}

- `, - }); - - return res.status(200).json({ - message: "Mail sent", - id: info.messageId, - }); - } catch (err) { - return res.status(500).json({ error: "Send mail failed", detail: (err as Error).message }); - } -} diff --git a/api/wakatime.ts b/api/wakatime.ts deleted file mode 100644 index 1a03691..0000000 --- a/api/wakatime.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { VercelRequest, VercelResponse } from "@vercel/node"; - -export default async function handler(req: VercelRequest, res: VercelResponse) { - // CORS 处理 - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type"); - - if (req.method === "OPTIONS") { - return res.status(200).end(); - } - - if (req.method !== "GET") { - return res.status(405).json({ error: "Method Not Allowed" }); - } - - const apiKey = process.env.WAKATIME_API_KEY; - const defaultApiUrl = process.env.WAKATIME_API_URL || "https://wakatime.com/api/v1"; - const apiUrl = (req.query.apiUrl as string) || defaultApiUrl; - if (!apiKey) { - return res.status(500).json({ error: "Wakatime API Key not configured" }); - } - - try { - // 获取一周统计数据 - const weeklyStatsResponse = await fetch(`${apiUrl}/users/current/stats/last_7_days`, { - headers: { - Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`, - }, - }); - - if (!weeklyStatsResponse.ok) { - throw new Error(`Wakatime API error: ${weeklyStatsResponse.status}`); - } - - const weeklyStatsData = await weeklyStatsResponse.json(); - - // 获取所有时间统计数据 - const allTimeStatsResponse = await fetch(`${apiUrl}/users/current/stats/all_time`, { - headers: { - Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`, - }, - }); - - let allTimeStatsData = null; - if (allTimeStatsResponse.ok) { - allTimeStatsData = await allTimeStatsResponse.json(); - } - - // 获取当前用户状态 - const statusResponse = await fetch(`${apiUrl}/users/current/status`, { - headers: { - Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`, - }, - }); - - let statusData = null; - if (statusResponse.ok) { - statusData = await statusResponse.json(); - } - - res.status(200).json({ - weekly: weeklyStatsData.data, - allTime: allTimeStatsData ? allTimeStatsData.data : null, - status: statusData, - }); - } catch (error) { - console.error("Wakatime API error:", error); - res.status(500).json({ error: "Failed to fetch Wakatime data" }); - } -} diff --git a/index.html b/index1.html similarity index 100% rename from index.html rename to index1.html diff --git a/nuxt.config.ts b/nuxt.config.ts new file mode 100644 index 0000000..d5c8c79 --- /dev/null +++ b/nuxt.config.ts @@ -0,0 +1,35 @@ +import { defineNuxtConfig } from "nuxt/config"; +import siteConfig from "./src/config/siteConfig"; + +export default defineNuxtConfig({ + compatibilityDate: "2025-12-12", + srcDir: "src/", + css: ["~/styles.css"], + app: { + head: { + title: siteConfig.siteMeta.title, + link: [{ rel: "icon", href: siteConfig.siteMeta.icon }], + }, + }, + nitro: { + prerender: { + crawlLinks: true, + routes: ["/sitemap.xml", "/rss.xml"], + }, + }, + runtimeConfig: { + smtpHost: process.env.SMTP_HOST ?? "", + smtpPort: Number(process.env.SMTP_PORT ?? 465), + smtpUser: process.env.SMTP_USER ?? "", + smtpPass: process.env.SMTP_PASS ?? "", + senderEmail: process.env.SENDER_EMAIL ?? "", + adminEmail: process.env.ADMIN_EMAIL ?? "", + smtpSecure: process.env.SMTP_SECURE ? process.env.SMTP_SECURE === "true" : undefined, + wakatimeApiKey: process.env.WAKATIME_API_KEY ?? "", + wakatimeApiUrl: process.env.WAKATIME_API_URL ?? "https://wakatime.com/api/v1", + public: { + githubToken: process.env.NUXT_PUBLIC_GITHUB_TOKEN ?? "", + umamiApiKey: process.env.NUXT_PUBLIC_UMAMI_API_KEY ?? "", + }, + }, +}); diff --git a/package.json b/package.json index 86a71df..aeeac6a 100644 --- a/package.json +++ b/package.json @@ -3,27 +3,19 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" + "dev": "nuxt dev", + "build": "nuxt build", + "generate": "nuxt generate", + "preview": "nuxt preview" }, "dependencies": { "@jaseeey/vue-umami-plugin": "^1.4.0", - "@vercel/node": "^5.5.15", - "cros": "^1.1.0", - "express": "^5.2.1", - "he": "^1.2.0", "nodemailer": "^7.0.11", - "vue": "^3.5.25", - "vue-router": "^4.6.3" + "nuxt": "^4.2.2" }, "devDependencies": { - "@types/express": "^5.0.6", "@types/node": "^24.10.1", "@types/nodemailer": "^7.0.4", - "@vitejs/plugin-vue": "^6.0.2", - "typescript": "^5.9.3", - "vite": "^7.2.6", - "vue-tsc": "^3.1.6" + "typescript": "^5.9.3" } } \ No newline at end of file diff --git a/public/css/netease-mini-player-v2.css b/public/css/netease-mini-player-v2.css new file mode 100644 index 0000000..1945ec1 --- /dev/null +++ b/public/css/netease-mini-player-v2.css @@ -0,0 +1,1340 @@ +/** + * [NMPv2] NeteaseMiniPlayer v2 CSS + * Lightweight Player Component Based on NetEase Cloud Music API + * + * Copyright 2025 BHCN STUDIO & 北海的佰川(ImBHCN[numakkiyu]) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:root { + --primary-bg: #fafafa; + --secondary-bg: #f0f0f0; + --bg-color: #fafafa; + --accent-color: #ff6b35; + --accent-color-2: #7a66ff; + --accent-color-3: #00c2ff; + --accent-gradient: linear-gradient( + 135deg, + var(--accent-color) 0%, + var(--accent-color-2) 50%, + var(--accent-color-3) 100% + ); + --text-primary: #333333; + --text-secondary: #666666; + --text-muted: #999999; + --shadow-light: rgba(255, 255, 255, 0.9); + --shadow-dark: rgba(0, 0, 0, 0.1); + --shadow-inset: inset 3px 3px 6px var(--shadow-dark), inset -3px -3px 6px var(--shadow-light); + --shadow-outset: 6px 6px 12px var(--shadow-dark), -6px -6px 12px var(--shadow-light); + --border-radius: 12px; + --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + --theme-transition: background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease; + + --flow-color-1: rgba(255, 107, 53, 0.32); + --flow-color-2: rgba(86, 151, 227, 0.26); + --flow-color-3: rgba(255, 209, 102, 0.26); + --flow-color-4: rgba(122, 102, 255, 0.28); + --flow-color-5: rgba(0, 194, 255, 0.22); + --flow-opacity: 0.45; + --flow-speed: 10s; + --volume-total-width: 60px; + --playlist-flow-opacity: 0.25; +} + +.netease-mini-player[data-theme="dark"] { + --primary-bg: #1e1e1e; + --secondary-bg: #2a2a2a; + --bg-color: #1e1e1e; + --accent-color: #ff8a50; + --accent-color-2: #6c63ff; + --accent-color-3: #00d4ff; + --accent-gradient: linear-gradient( + 135deg, + var(--accent-color) 0%, + var(--accent-color-2) 50%, + var(--accent-color-3) 100% + ); + --text-primary: #e0e0e0; + --text-secondary: #b0b0b0; + --text-muted: #808080; + --shadow-light: rgba(255, 255, 255, 0.05); + --shadow-dark: rgba(0, 0, 0, 0.4); + --shadow-inset: inset 3px 3px 6px var(--shadow-dark), inset -3px -3px 6px var(--shadow-light); + --shadow-outset: 6px 6px 12px var(--shadow-dark), -6px -6px 12px var(--shadow-light); + + --flow-color-1: rgba(255, 138, 80, 0.28); + --flow-color-2: rgba(86, 151, 227, 0.24); + --flow-color-3: rgba(140, 100, 255, 0.26); + --flow-color-4: rgba(124, 110, 255, 0.24); + --flow-color-5: rgba(0, 212, 255, 0.22); + --flow-opacity: 0.36; + --flow-speed: 12s; +} + +@media (prefers-color-scheme: dark) { + .netease-mini-player[data-theme="auto"] { + --primary-bg: #1e1e1e; + --secondary-bg: #2a2a2a; + --bg-color: #1e1e1e; + --accent-color: #ff8a50; + --accent-color-2: #6c63ff; + --accent-color-3: #00d4ff; + --accent-gradient: linear-gradient( + 135deg, + var(--accent-color) 0%, + var(--accent-color-2) 50%, + var(--accent-color-3) 100% + ); + --text-primary: #e0e0e0; + --text-secondary: #b0b0b0; + --text-muted: #808080; + --shadow-light: rgba(255, 255, 255, 0.05); + --shadow-dark: rgba(0, 0, 0, 0.4); + --shadow-inset: inset 3px 3px 6px var(--shadow-dark), inset -3px -3px 6px var(--shadow-light); + --shadow-outset: 6px 6px 12px var(--shadow-dark), -6px -6px 12px var(--shadow-light); + + --flow-color-1: rgba(255, 138, 80, 0.28); + --flow-color-2: rgba(86, 151, 227, 0.24); + --flow-color-3: rgba(140, 100, 255, 0.26); + --flow-color-4: rgba(124, 110, 255, 0.24); + --flow-color-5: rgba(0, 212, 255, 0.22); + --flow-opacity: 0.36; + --flow-speed: 12s; + } +} +@media (prefers-color-scheme: light) { + .netease-mini-player[data-theme="auto"] { + --primary-bg: #fafafa; + --secondary-bg: #f0f0f0; + --bg-color: #fafafa; + --accent-color: #ff6b35; + --accent-color-2: #7a66ff; + --accent-color-3: #00c2ff; + --accent-gradient: linear-gradient( + 135deg, + var(--accent-color) 0%, + var(--accent-color-2) 50%, + var(--accent-color-3) 100% + ); + --text-primary: #333333; + --text-secondary: #666666; + --text-muted: #999999; + --shadow-light: rgba(255, 255, 255, 0.9); + --shadow-dark: rgba(0, 0, 0, 0.1); + --shadow-inset: inset 3px 3px 6px var(--shadow-dark), inset -3px -3px 6px var(--shadow-light); + --shadow-outset: 6px 6px 12px var(--shadow-dark), -6px -6px 12px var(--shadow-light); + + --flow-color-1: rgba(255, 107, 53, 0.32); + --flow-color-2: rgba(86, 151, 227, 0.26); + --flow-color-3: rgba(255, 209, 102, 0.26); + --flow-color-4: rgba(122, 102, 255, 0.28); + --flow-color-5: rgba(0, 194, 255, 0.22); + --flow-opacity: 0.45; + --flow-speed: 10s; + } +} + +.netease-mini-player { + width: 400px; + height: 120px; + background: var(--bg-color); + border-radius: 16px; + padding: 12px; + box-shadow: var(--shadow-outset); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + position: relative; + overflow: visible; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 8px; + transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1), height 0.5s cubic-bezier(0.4, 0, 0.2, 1), + border-radius 0.5s cubic-bezier(0.4, 0, 0.2, 1), padding 0.5s cubic-bezier(0.4, 0, 0.2, 1), + var(--theme-transition); + background: 0.5s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1); +} + +.netease-mini-player::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: radial-gradient(circle at 15% 20%, var(--flow-color-1) 0%, transparent 60%), + radial-gradient(circle at 80% 25%, var(--flow-color-2) 0%, transparent 60%), + radial-gradient(circle at 30% 85%, var(--flow-color-3) 0%, transparent 60%), + radial-gradient(circle at 10% 75%, var(--flow-color-4) 0%, transparent 55%), + radial-gradient(circle at 85% 80%, var(--flow-color-5) 0%, transparent 55%); + opacity: var(--flow-opacity); + background-size: 220% 220%, 220% 220%, 220% 220%, 220% 220%, 220% 220%; + background-position: 0% 0%, 100% 0%, 0% 100%, 20% 80%, 80% 20%; + filter: saturate(1.02) brightness(1.01); + transform: scale(1); + animation: flow-breathe var(--flow-speed) ease-in-out infinite; + animation-play-state: paused; +} + +.netease-mini-player.player-playing::before { + animation-play-state: running; +} + +@keyframes flow-breathe { + 0% { + background-position: 0% 0%, 100% 0%, 0% 100%, 20% 80%, 80% 20%; + transform: scale(1); + filter: saturate(1.02) brightness(1.01); + } + 50% { + background-position: 100% 50%, 0% 100%, 50% 0%, 35% 65%, 65% 35%; + transform: scale(1.03); + filter: saturate(1.15) brightness(1.06); + } + 100% { + background-position: 0% 100%, 100% 50%, 100% 0%, 20% 80%, 80% 20%; + transform: scale(1); + filter: saturate(1.02) brightness(1.01); + } +} + +.netease-mini-player.minimized { + width: 80px; + height: 80px; + border-radius: 50%; + padding: 0; + background: radial-gradient(120px 120px at 30% 30%, rgba(255, 138, 80, 0.25), transparent 60%), + radial-gradient(120px 120px at 70% 70%, rgba(0, 210, 255, 0.22), transparent 60%), + linear-gradient(135deg, #151515, #1e1e1e); + box-shadow: 4px 4px 12px rgba(0, 0, 0, 0.4), -2px -2px 8px rgba(255, 255, 255, 0.1), + inset 0 0 20px rgba(0, 0, 0, 0.3); +} + +.netease-mini-player.minimized .song-content, +.netease-mini-player.minimized .controls, +.netease-mini-player.minimized .playlist-container { + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; +} + +.netease-mini-player.minimized .player-bottom { + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; +} + +.netease-mini-player.minimized .minimize-btn { + opacity: 1 !important; + visibility: visible !important; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1000; + background: rgba(0, 0, 0, 0.7); + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: all 0.3s ease; +} + +.netease-mini-player.minimized .minimize-btn:hover { + background: rgba(0, 0, 0, 0.9); + transform: translate(-50%, -50%) scale(1.1); +} + +.netease-mini-player .song-content, +.netease-mini-player .controls, +.netease-mini-player .player-bottom { + opacity: 1; + visibility: visible; + transition: opacity 0.3s ease 0.2s, visibility 0.3s ease 0.2s; +} + +.netease-mini-player.minimized .album-cover-container { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + border-radius: 50%; + box-shadow: none; + transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1), height 0.5s cubic-bezier(0.4, 0, 0.2, 1), + top 0.5s cubic-bezier(0.4, 0, 0.2, 1), left 0.5s cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1); +} + +.netease-mini-player.minimized .album-cover { + width: 100%; + height: 100%; + border-radius: 50%; + filter: brightness(0.8) contrast(1.1); + transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1), height 0.5s cubic-bezier(0.4, 0, 0.2, 1), + filter 0.5s cubic-bezier(0.4, 0, 0.2, 1); +} + +.netease-mini-player.minimized .vinyl-overlay { + background: radial-gradient( + circle at center, + transparent 15%, + rgba(0, 0, 0, 0.1) 15%, + rgba(0, 0, 0, 0.1) 16%, + transparent 16%, + transparent 25%, + rgba(0, 0, 0, 0.15) 25%, + rgba(0, 0, 0, 0.15) 26%, + transparent 26%, + transparent 35%, + rgba(0, 0, 0, 0.1) 35%, + rgba(0, 0, 0, 0.1) 36%, + transparent 36%, + transparent 45%, + rgba(0, 0, 0, 0.15) 45%, + rgba(0, 0, 0, 0.15) 46%, + transparent 46% + ); + box-shadow: inset 0 0 15px rgba(0, 0, 0, 0.5), inset 0 0 5px rgba(0, 0, 0, 0.8); + transition: background 0.5s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1); +} + +.netease-mini-player.minimized .vinyl-center { + width: 16px; + height: 16px; + background: radial-gradient(circle at 30% 30%, #666 0%, #333 50%, #111 100%); + box-shadow: 0 0 3px rgba(0, 0, 0, 0.8), inset 1px 1px 2px rgba(255, 255, 255, 0.2); + border: 1px solid #000; + transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1), height 0.5s cubic-bezier(0.4, 0, 0.2, 1), + background 0.5s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1), + border 0.5s cubic-bezier(0.4, 0, 0.2, 1); +} + +.netease-mini-player[data-position="top-left"] { + position: fixed; + top: 20px; + left: 20px; + z-index: 1000; +} + +.netease-mini-player[data-position="top-right"] { + position: fixed; + top: 20px; + right: 20px; + z-index: 1000; +} + +.netease-mini-player[data-position="bottom-left"] { + position: fixed; + bottom: 20px; + left: 20px; + z-index: 1000; +} + +.netease-mini-player[data-position="bottom-right"] { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 1000; +} + +.netease-mini-player[data-position="static"] { + position: relative; +} + +.player-main { + display: flex; + align-items: center; + gap: 12px; + flex: 1; +} + +.album-cover-container { + width: 60px; + height: 60px; + border-radius: 50%; + position: relative; + flex-shrink: 0; + background: var(--secondary-bg); + box-shadow: inset 2px 2px 4px var(--shadow-dark), inset -2px -2px 4px var(--shadow-light), + 2px 2px 6px rgba(0, 0, 0, 0.2), -1px -1px 3px rgba(255, 255, 255, 0.1); + overflow: hidden; + cursor: pointer; + border: 2px solid rgba(0, 0, 0, 0.1); +} +.album-cover-container::after { + content: ""; + position: absolute; + inset: -4px; + border-radius: 50%; + pointer-events: none; + background: conic-gradient( + from 0deg, + var(--accent-color), + var(--accent-color-2), + var(--accent-color-3), + var(--accent-color) + ); + opacity: 0.18; + filter: blur(6px) saturate(1.05); +} + +.album-cover { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + transition: var(--transition); + filter: brightness(0.9) contrast(1.1); +} + +.vinyl-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 50%; + background: radial-gradient( + circle at center, + transparent 12%, + rgba(0, 0, 0, 0.8) 12%, + rgba(0, 0, 0, 0.8) 15%, + transparent 15%, + transparent 20%, + rgba(0, 0, 0, 0.15) 20%, + rgba(0, 0, 0, 0.15) 20.5%, + transparent 20.5%, + transparent 25%, + rgba(0, 0, 0, 0.1) 25%, + rgba(0, 0, 0, 0.1) 25.5%, + transparent 25.5%, + transparent 30%, + rgba(0, 0, 0, 0.08) 30%, + rgba(0, 0, 0, 0.08) 30.5%, + transparent 30.5%, + transparent 35%, + rgba(0, 0, 0, 0.06) 35%, + rgba(0, 0, 0, 0.06) 35.5%, + transparent 35.5%, + transparent 40%, + rgba(0, 0, 0, 0.05) 40%, + rgba(0, 0, 0, 0.05) 40.5%, + transparent 40.5%, + transparent 85%, + rgba(0, 0, 0, 0.1) 85%, + rgba(0, 0, 0, 0.2) 100% + ); + pointer-events: none; + box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.3), inset 0 0 20px rgba(0, 0, 0, 0.1); +} + +.vinyl-center { + position: absolute; + top: 50%; + left: 50%; + width: 12px; + height: 12px; + background: radial-gradient(circle at center, #1a1a1a 0%, #333 50%, #1a1a1a 100%); + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: 0 0 4px rgba(0, 0, 0, 0.5), inset 0 0 3px rgba(0, 0, 0, 0.8), inset 0 0 1px rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +@keyframes vinyl-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.album-cover { + animation: vinyl-spin 6s linear infinite; + animation-play-state: paused; +} + +.album-cover.playing { + animation-play-state: running; +} + +.vinyl-overlay { + animation: vinyl-spin 3s linear infinite; + animation-play-state: paused; +} + +.album-cover.playing + .vinyl-overlay { + animation-play-state: running; +} + +.song-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.song-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.song-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; + transition: var(--theme-transition); +} + +.song-artist { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; + transition: var(--theme-transition); +} + +.lyrics-container { + min-height: 20px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.lyrics-container.hidden { + display: none; +} + +.lyric-line { + font-size: 11px; + color: #5697e3; + line-height: 1.4; + transition: var(--transition); + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; +} + +.lyric-line.current { + color: #5697e3; + font-weight: 600; + opacity: 1; + text-shadow: 0 0 8px rgba(86, 151, 227, 0.3); + animation: lyric-glow 2s ease-in-out infinite alternate; + + animation: none; + opacity: 0; + transform: translateY(10px); + animation: lyric-fade-in 0.2s ease-out forwards; +} + +.lyric-line.translation { + font-size: 10px; + color: var(--text-muted); + opacity: 0.7; + margin-top: 1px; + transition: var(--theme-transition); +} + +.lyric-line.translation.current { + color: var(--text-secondary); + opacity: 0.9; + font-weight: 500; + transition: var(--theme-transition); +} + +@keyframes lyric-fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes lyric-glow { + 0% { + text-shadow: 0 0 8px rgba(86, 151, 227, 0.3); + } + 100% { + text-shadow: 0 0 12px rgba(86, 151, 227, 0.5); + } +} + +.lyric-line.word-lyric { + position: relative; + display: inline-block; + max-width: 100%; + white-space: nowrap; + overflow: hidden; +} + +.lyric-word-line { + position: relative; + display: inline-block; + transition: transform 0.1s ease-out; +} + +.lyric-line.word-lyric-active { + -webkit-mask: linear-gradient(90deg, #000 0%, #000 var(--line-progress, 0%), transparent var(--line-progress, 0%)); + mask: linear-gradient(90deg, #000 0%, #000 var(--line-progress, 0%), transparent var(--line-progress, 0%)); +} + +.netease-mini-player[data-theme="dark"] .lyric-line.word-lyric-active { + text-shadow: 0 0 8px rgba(255, 165, 0, 0.7); +} + +.lyric-line.word-lyric::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + width: var(--line-progress, 0%); + height: 2px; + background: var(--accent-gradient); + border-radius: 1px; + transition: width 0.1s ease; +} + +.controls { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.control-btn { + width: 32px; + height: 32px; + border: none; + background: var(--primary-bg); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: var(--transition); + box-shadow: 2px 2px 4px var(--shadow-dark), -2px -2px 4px var(--shadow-light); + font-size: 12px; + color: var(--text-primary); + position: relative; +} + +.control-btn:hover { + box-shadow: inset 1px 1px 2px var(--shadow-dark), inset -1px -1px 2px var(--shadow-light); + background: var(--secondary-bg); +} + +.play-btn { + width: 30px; + height: 30px; + font-size: 14px; + background: var(--primary-bg); + box-shadow: 2px 2px 4px var(--shadow-dark), -2px -2px 4px var(--shadow-light); +} + +.play-icon, +.pause-icon { + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: var(--text-primary); + transition: var(--transition); +} + +.play-btn:hover { + background: var(--secondary-bg); + box-shadow: inset 1px 1px 2px var(--shadow-dark), inset -1px -1px 2px var(--shadow-light); +} + +.control-btn:active { + box-shadow: inset 2px 2px 4px var(--shadow-dark), inset -2px -2px 4px var(--shadow-light); + background: var(--accent-gradient); + color: white; +} + +.control-btn:active span { + color: white; +} + +.play-icon { + margin-left: 2px; +} + +.volume-icon { + font-size: 10px; + color: var(--text-secondary); + margin-right: 2px; + flex-shrink: 0; +} + +.player-bottom { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + width: 100%; +} + +.progress-container { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + max-width: var(--progress-max, 60%); +} + +.netease-mini-player[data-embed="true"] .progress-container { + max-width: calc(100% - 60px); + flex: 1; +} + +.time-display { + font-size: 10px; + color: var(--text-muted); + white-space: nowrap; + min-width: 35px; + text-align: center; +} + +.progress-bar-container { + flex: 1; + height: 4px; + background: var(--secondary-bg); + border-radius: 2px; + position: relative; + cursor: pointer; + box-shadow: inset 1px 1px 2px var(--shadow-dark), inset -1px -1px 2px var(--shadow-light); +} + +.progress-bar { + height: 100%; + background: var(--accent-gradient); + border-radius: 2px; + width: 0%; + transition: width 0.1s ease; + position: relative; +} + +.progress-bar::after { + content: ""; + position: absolute; + right: -2px; + top: 50%; + width: 8px; + height: 8px; + background: var(--accent-color-3); + border-radius: 50%; + transform: translateY(-50%); + box-shadow: 0 0 8px rgba(0, 210, 255, 0.6), 1px 1px 2px var(--shadow-dark), -1px -1px 2px var(--shadow-light); +} + +.bottom-controls { + display: flex; + align-items: center; + gap: 8px; + justify-content: flex-end; + flex-shrink: 0; + min-width: 0; +} + +.volume-container { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.volume-slider-container { + display: flex; + align-items: center; + width: 40px; +} + +.volume-slider { + flex: 1; + height: 3px; + background: var(--secondary-bg); + border-radius: 2px; + position: relative; + cursor: pointer; + box-shadow: inset 1px 1px 2px var(--shadow-dark), inset -1px -1px 2px var(--shadow-light); +} + +.volume-bar { + height: 100%; + background: var(--accent-color); + border-radius: 2px; + width: 70%; + transition: width 0.1s ease; +} + +.netease-mini-player svg { + width: 100%; + height: 100%; + fill: currentColor; + display: block; +} + +.control-btn svg { + width: 18px; + height: 18px; +} + +.feature-btn svg { + width: 16px; + height: 16px; +} + +.volume-icon svg { + width: 14px; + height: 14px; +} + +.play-btn svg { + width: 22px; + height: 22px; + margin-left: 2px; +} + +.play-btn .pause-icon svg { + margin-left: 0; +} + +.control-btn, +.feature-btn { + font-size: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.minimize-btn svg { + width: 18px; + height: 18px; +} + +.sr-visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0 0 0 0) !important; + clip-path: inset(50%) !important; + white-space: nowrap !important; + border: 0 !important; + padding: 0 !important; +} + +.netease-mini-player.mobile-env .volume-container { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; + padding: 0; + opacity: 0; + pointer-events: none; +} +.netease-mini-player.mobile-env .progress-container { + --progress-max: calc(60% + var(--volume-total-width, 60px)); +} +.netease-mini-player.mobile-env[data-embed="true"] .progress-container { + max-width: calc(100% - 60px + var(--volume-total-width, 60px)); +} + +@media (hover: none) and (pointer: coarse) { + .netease-mini-player.mobile-env .bottom-controls { + gap: 10px; + } +} + +.feature-btn { + width: 20px; + height: 20px; + border: none; + background: transparent; + cursor: pointer; + color: var(--text-muted); + transition: var(--transition); + font-size: 11px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.feature-btn:hover { + color: var(--accent-color); +} + +.feature-btn.active { + color: var(--accent-color); +} + +.netease-mini-player[data-embed="true"] .list-btn { + display: none !important; +} + +.netease-mini-player[data-embed="true"] .minimize-btn { + display: none !important; +} + +.netease-mini-player[data-position="static"] .minimize-btn { + display: none !important; +} + +.netease-mini-player[data-embed="true"] .prev-btn, +.netease-mini-player[data-embed="true"] .next-btn { + display: none !important; +} + +.netease-mini-player[data-embed="true"] .playlist-container { + display: none !important; +} + +.netease-mini-player-embed .list-btn, +.netease-mini-player-embed .minimize-btn, +.netease-mini-player-embed .prev-btn, +.netease-mini-player-embed .next-btn { + display: none !important; +} + +.netease-mini-player-embed .playlist-container { + display: none !important; +} + +.playlist-container { + position: absolute; + left: 0; + right: 0; + top: 100%; + background: var(--primary-bg); + border-radius: var(--border-radius); + box-shadow: 4px 4px 8px var(--shadow-dark), -4px -4px 8px var(--shadow-light); + max-height: 200px; + overflow: hidden; + z-index: 1001; + opacity: 0; + transform: translateY(-10px); + visibility: hidden; + transition: all 0.3s ease; + margin-top: 8px; +} + +.playlist-container::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: radial-gradient(circle at 20% 15%, var(--flow-color-1) 0%, transparent 60%), + radial-gradient(circle at 75% 30%, var(--flow-color-2) 0%, transparent 55%), + radial-gradient(circle at 35% 85%, var(--flow-color-3) 0%, transparent 55%), + radial-gradient(circle at 25% 70%, var(--flow-color-4) 0%, transparent 50%), + radial-gradient(circle at 85% 75%, var(--flow-color-5) 0%, transparent 50%); + opacity: var(--playlist-flow-opacity); + background-size: 220% 220%, 220% 220%, 220% 220%, 220% 220%, 220% 220%; + background-position: 0% 0%, 100% 0%, 0% 100%, 20% 80%, 80% 20%; + filter: saturate(1.02) brightness(1.01); + transform: scale(1); + animation: flow-breathe var(--flow-speed) ease-in-out infinite; + animation-play-state: paused; +} + +.netease-mini-player.player-playing .playlist-container::before { + animation-play-state: running; +} + +.playlist-container.expand-up { + top: auto; + bottom: 100%; + margin-top: 0; + margin-bottom: 8px; + transform: translateY(10px); +} + +.playlist-container.expand-up.show { + transform: translateY(0); +} + +.playlist-container.show { + opacity: 1; + transform: translateY(0); + visibility: visible; +} + +.playlist-content { + max-height: 200px; + overflow-y: auto; + &::-webkit-scrollbar { + display: none; + } + scrollbar-width: none; +} + +.playlist-item { + padding: 8px 12px; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + cursor: pointer; + transition: var(--transition); + display: flex; + align-items: center; + gap: 8px; +} + +.playlist-item:last-child { + border-bottom: none; +} + +.playlist-item:hover { + background: rgba(255, 107, 53, 0.08); + box-shadow: inset 1px 1px 2px var(--shadow-dark), inset -1px -1px 2px var(--shadow-light); + transform: translateX(2px); + transition: all 0.2s ease; +} + +.playlist-item.active { + background: rgba(255, 107, 53, 0.15); + color: var(--accent-color); + font-weight: 600; +} + +.playlist-item-index { + font-size: 10px; + color: var(--text-muted); + min-width: 16px; + text-align: center; +} + +.playlist-item.active .playlist-item-index { + color: var(--accent-color); +} + +.playlist-item-info { + flex: 1; + min-width: 0; +} + +.playlist-item-name { + font-size: 11px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +} + +.playlist-item-artist { + font-size: 10px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} + +.playlist-item.active .playlist-item-name, +.playlist-item.active .playlist-item-artist { + color: var(--accent-color); +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 12px; +} + +.loading::after { + content: ""; + width: 12px; + height: 12px; + border: 2px solid var(--secondary-bg); + border-top: 2px solid var(--accent-color); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-left: 8px; +} + +.playlist-item-cover { + width: 36px; + height: 36px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; + background: var(--secondary-bg); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.error { + color: #ff4757; + font-size: 12px; + text-align: center; +} + +@media (max-width: 480px) { + .netease-mini-player { + width: 100%; + max-width: 360px; + height: 100px; + padding: 8px; + margin: 0 auto; + } + + .album-cover-container { + width: 50px; + height: 50px; + } + + .song-title { + font-size: 12px; + } + + .song-artist { + font-size: 10px; + } + + .lyric-line { + font-size: 10px; + } + + .control-btn { + width: 28px; + height: 28px; + font-size: 10px; + } + + .play-btn { + width: 32px; + height: 32px; + font-size: 12px; + } + + .time-display { + font-size: 9px; + } + + .feature-btn { + width: 20px; + height: 20px; + font-size: 10px; + } + + .volume-slider-container { + width: 50px; + } +} + +.netease-mini-player.dragging { + cursor: grabbing; + -webkit-user-select: none; + user-select: none; + pointer-events: none; + opacity: 0.8; + transform: scale(0.95); + z-index: 9999; + transition: none; +} + +.netease-mini-player { + opacity: 1; +} + +.netease-mini-player.minimized.idle { + opacity: 0.7; +} + +.netease-mini-player.minimized.fading-out { + animation: player-fade-out var(--opacity-duration-down, 0.6s) + var(--opacity-ease-out, cubic-bezier(0.22, 1, 0.36, 1)) forwards; +} + +.netease-mini-player.minimized.fading-in { + animation: player-fade-in var(--opacity-duration-up, 0.25s) var(--opacity-ease-in, cubic-bezier(0.4, 0, 0.2, 1)) + forwards; +} + +@keyframes player-fade-out { + from { + opacity: 1; + } + to { + opacity: 0.7; + } +} + +@keyframes player-fade-in { + from { + opacity: 0.7; + } + to { + opacity: 1; + } +} + +.netease-mini-player.minimized.docked-right { + transform: translateX(calc(50% + var(--dock-gap, 20px))); +} +.netease-mini-player.minimized.docked-left { + transform: translateX(calc(-50% - var(--dock-gap, 20px))); +} + +.netease-mini-player.minimized.fading-out.docked-right { + animation: player-fade-out var(--opacity-duration-down, 0.6s) + var(--opacity-ease-out, cubic-bezier(0.22, 1, 0.36, 1)) forwards, + player-dock-right var(--dock-duration, 0.45s) var(--dock-ease-out, cubic-bezier(0.18, 0.9, 0.2, 1)) forwards; +} +.netease-mini-player.minimized.fading-out.docked-left { + animation: player-fade-out var(--opacity-duration-down, 0.6s) + var(--opacity-ease-out, cubic-bezier(0.22, 1, 0.36, 1)) forwards, + player-dock-left var(--dock-duration, 0.45s) var(--dock-ease-out, cubic-bezier(0.18, 0.9, 0.2, 1)) forwards; +} + +.netease-mini-player.minimized.popping-right { + animation: player-popout-right var(--popout-duration, 0.28s) var(--popout-ease, cubic-bezier(0.4, 0, 0.2, 1)) + forwards; +} +.netease-mini-player.minimized.popping-left { + animation: player-popout-left var(--popout-duration, 0.28s) var(--popout-ease, cubic-bezier(0.4, 0, 0.2, 1)) + forwards; +} + +@keyframes player-dock-right { + from { + transform: translateX(0); + } + to { + transform: translateX(calc(50% + var(--dock-gap, 20px))); + } +} +@keyframes player-dock-left { + from { + transform: translateX(0); + } + to { + transform: translateX(calc(-50% - var(--dock-gap, 20px))); + } +} +@keyframes player-popout-right { + from { + transform: translateX(calc(50% + var(--dock-gap, 20px))); + } + to { + transform: translateX(0); + } +} +@keyframes player-popout-left { + from { + transform: translateX(calc(-50% - var(--dock-gap, 20px))); + } + to { + transform: translateX(0); + } +} + +.netease-mini-player .feature-btn, +.netease-mini-player .control-btn { + font-size: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.netease-mini-player .bottom-controls .feature-btn, +.netease-mini-player .volume-icon { + color: var(--text-primary); +} + +.netease-mini-player .control-btn svg { + width: 18px; + height: 18px; + display: block; + fill: currentColor; +} + +.netease-mini-player .play-btn svg { + width: 20px; + height: 20px; +} + +.netease-mini-player .play-icon { + margin-left: 0; +} + +.netease-mini-player .bottom-controls .feature-btn svg, +.netease-mini-player .volume-icon svg { + width: 16px; + height: 16px; + display: block; + fill: currentColor; +} + +.netease-mini-player .volume-icon { + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + font-size: 0; +} + +.netease-mini-player .feature-btn { + width: auto; + height: auto; +} + +.netease-mini-player .bottom-controls { + gap: 6px; +} + +.netease-mini-player .volume-icon svg { + width: 18px; + height: 18px; +} + +.netease-mini-player.minimized .minimize-btn { + font-size: 0; +} diff --git a/public/js/netease-mini-player-v2.js b/public/js/netease-mini-player-v2.js index c4efbf3..0b672d3 100644 --- a/public/js/netease-mini-player-v2.js +++ b/public/js/netease-mini-player-v2.js @@ -1,22 +1,29 @@ /** * [NMPv2] NeteaseMiniPlayer v2 JavaScript * Lightweight Player Component Based on NetEase Cloud Music API - * + * * Copyright 2025 BHCN STUDIO & 北海的佰川(ImBHCN[numakkiyu]) - * + * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ -(()=>{try{const s=document.currentScript;if(s&&s.src){fetch(s.src,{mode:'cors',credentials:'omit'}).catch(()=>{});}}catch(e){}})(); +(() => { + try { + const s = document.currentScript; + if (s && s.src) { + fetch(s.src, { mode: "cors", credentials: "omit" }).catch(() => {}); + } + } catch (e) {} +})(); const GlobalAudioManager = { currentPlayer: null, setCurrent(player) { @@ -24,7 +31,7 @@ const GlobalAudioManager = { this.currentPlayer.pause(); } this.currentPlayer = player; - } + }, }; const ICONS = { @@ -35,11 +42,11 @@ const ICONS = { volume: ``, lyrics: ``, list: ``, - minimize: ``, - maximize: ``, + minimize: ``, + maximize: ``, loopList: ``, - loopSingle: ``, - shuffle: `` + loopSingle: ``, + shuffle: ``, }; class NeteaseMiniPlayer { constructor(element) { @@ -60,7 +67,7 @@ class NeteaseMiniPlayer { this.showLyrics = this.config.lyric; this.cache = new Map(); this.init(); - this.playMode = 'list'; + this.playMode = "list"; this.shuffleHistory = []; this.idleTimeout = null; this.idleDelay = 5000; @@ -68,40 +75,52 @@ class NeteaseMiniPlayer { } parseConfig() { const element = this.element; - const position = element.dataset.position || 'static'; - const validPositions = ['static', 'top-left', 'top-right', 'bottom-left', 'bottom-right']; - const finalPosition = validPositions.includes(position) ? position : 'static'; - const defaultMinimized = element.dataset.defaultMinimized === 'true'; - - const embedValue = element.getAttribute('data-embed') || element.dataset.embed; - const isEmbed = embedValue === 'true' || embedValue === true; + const position = element.dataset.position || "static"; + const validPositions = ["static", "top-left", "top-right", "bottom-left", "bottom-right"]; + const finalPosition = validPositions.includes(position) ? position : "static"; + const defaultMinimized = element.dataset.defaultMinimized === "true"; - const autoPauseAttr = element.getAttribute('data-auto-pause') ?? element.dataset.autoPause; - const autoPauseDisabled = autoPauseAttr === 'true' || autoPauseAttr === true; + const embedValue = element.getAttribute("data-embed") || element.dataset.embed; + const isEmbed = embedValue === "true" || embedValue === true; + + const autoPauseAttr = element.getAttribute("data-auto-pause") ?? element.dataset.autoPause; + const autoPauseDisabled = autoPauseAttr === "true" || autoPauseAttr === true; + + const apiUrls = JSON.parse(element.dataset.apiUrls) || apiUrls === []; + + // 读取 autoPause 配置 + let autoPause = true; // 默认启用自动暂停 + if (window.__NETEASE_MUSIC_CONFIG__?.autoPause !== undefined) { + autoPause = window.__NETEASE_MUSIC_CONFIG__.autoPause; + } else if (element.dataset.autoPause !== undefined) { + autoPause = element.dataset.autoPause !== "false"; + } return { embed: isEmbed, - autoplay: element.dataset.autoplay === 'true', + autoplay: element.dataset.autoplay === "true", playlistId: element.dataset.playlistId, songId: element.dataset.songId, position: finalPosition, - lyric: element.dataset.lyric !== 'false', - theme: element.dataset.theme || 'auto', - size: element.dataset.size || 'compact', + lyric: element.dataset.lyric !== "false", + theme: element.dataset.theme || "auto", + size: element.dataset.size || "compact", defaultMinimized: defaultMinimized, - autoPauseDisabled: autoPauseDisabled + autoPauseDisabled: autoPauseDisabled, + autoPause: autoPause, + apiUrls: apiUrls, }; } async init() { if (this.config.embed) { - this.element.setAttribute('data-embed', 'true'); + this.element.setAttribute("data-embed", "true"); } - this.element.setAttribute('data-position', this.config.position); - + this.element.setAttribute("data-position", this.config.position); + if (this.config.embed) { - this.element.classList.add('netease-mini-player-embed'); + this.element.classList.add("netease-mini-player-embed"); } - + this.initTheme(); this.createPlayerHTML(); this.applyResponsiveControls?.(); @@ -129,12 +148,12 @@ class NeteaseMiniPlayer { this.play(); } } - if (this.config.defaultMinimized && !this.config.embed && this.config.position !== 'static') { + if (this.config.defaultMinimized && !this.config.embed && this.config.position !== "static") { this.toggleMinimize(); } } catch (error) { - console.error('播放器初始化失败:', error); - this.showError('加载失败,请稍后重试'); + console.error("播放器初始化失败:", error); + this.showError("加载失败,请稍后重试"); } } createPlayerHTML() { @@ -157,12 +176,20 @@ class NeteaseMiniPlayer {
- ${!this.config.embed ? `` : ''} + ${ + !this.config.embed + ? `` + : "" + } - ${!this.config.embed ? `` : ''} + ${ + !this.config.embed + ? `` + : "" + }
@@ -183,9 +210,21 @@ class NeteaseMiniPlayer {
${ICONS.lyrics} - ${!this.config.embed ? `${ICONS.loopList}` : ''} - ${!this.config.embed ? `${ICONS.list}` : ''} - ${!this.config.embed ? `${ICONS.minimize}` : ''} + ${ + !this.config.embed + ? `${ICONS.loopList}` + : "" + } + ${ + !this.config.embed + ? `${ICONS.list}` + : "" + } + ${ + !this.config.embed + ? `${ICONS.minimize}` + : "" + }
@@ -193,107 +232,104 @@ class NeteaseMiniPlayer {
`; this.elements = { - albumCover: this.element.querySelector('.album-cover'), - albumCoverContainer: this.element.querySelector('.album-cover-container'), - songTitle: this.element.querySelector('.song-title'), - songArtist: this.element.querySelector('.song-artist'), - lyricsContainer: this.element.querySelector('.lyrics-container'), - lyricLine: this.element.querySelector('.lyric-line.original'), - lyricTranslation: this.element.querySelector('.lyric-line.translation'), - playBtn: this.element.querySelector('.play-btn'), - playIcon: this.element.querySelector('.play-icon'), - pauseIcon: this.element.querySelector('.pause-icon'), - prevBtn: this.element.querySelector('.prev-btn'), - nextBtn: this.element.querySelector('.next-btn'), - progressContainer: this.element.querySelector('.progress-bar-container'), - progressBar: this.element.querySelector('.progress-bar'), - currentTime: this.element.querySelector('.current-time'), - totalTime: this.element.querySelector('.total-time'), - volumeContainer: this.element.querySelector('.volume-container'), - volumeSlider: this.element.querySelector('.volume-slider'), - volumeBar: this.element.querySelector('.volume-bar'), - volumeIcon: this.element.querySelector('.volume-icon'), - lyricsBtn: this.element.querySelector('.lyrics-btn'), - listBtn: this.element.querySelector('.list-btn'), - minimizeBtn: this.element.querySelector('.minimize-btn'), - playlistContainer: this.element.querySelector('.playlist-container'), - playlistContent: this.element.querySelector('.playlist-content') + albumCover: this.element.querySelector(".album-cover"), + albumCoverContainer: this.element.querySelector(".album-cover-container"), + songTitle: this.element.querySelector(".song-title"), + songArtist: this.element.querySelector(".song-artist"), + lyricsContainer: this.element.querySelector(".lyrics-container"), + lyricLine: this.element.querySelector(".lyric-line.original"), + lyricTranslation: this.element.querySelector(".lyric-line.translation"), + playBtn: this.element.querySelector(".play-btn"), + playIcon: this.element.querySelector(".play-icon"), + pauseIcon: this.element.querySelector(".pause-icon"), + prevBtn: this.element.querySelector(".prev-btn"), + nextBtn: this.element.querySelector(".next-btn"), + progressContainer: this.element.querySelector(".progress-bar-container"), + progressBar: this.element.querySelector(".progress-bar"), + currentTime: this.element.querySelector(".current-time"), + totalTime: this.element.querySelector(".total-time"), + volumeContainer: this.element.querySelector(".volume-container"), + volumeSlider: this.element.querySelector(".volume-slider"), + volumeBar: this.element.querySelector(".volume-bar"), + volumeIcon: this.element.querySelector(".volume-icon"), + lyricsBtn: this.element.querySelector(".lyrics-btn"), + listBtn: this.element.querySelector(".list-btn"), + minimizeBtn: this.element.querySelector(".minimize-btn"), + playlistContainer: this.element.querySelector(".playlist-container"), + playlistContent: this.element.querySelector(".playlist-content"), }; this.isMinimized = false; - this.elements.loopModeBtn = this.element.querySelector('.loop-mode-btn'); + this.elements.loopModeBtn = this.element.querySelector(".loop-mode-btn"); } bindEvents() { - this.elements.playBtn.addEventListener('click', () => this.togglePlay()); + this.elements.playBtn.addEventListener("click", () => this.togglePlay()); if (this.elements.prevBtn) { - this.elements.prevBtn.addEventListener('click', () => this.previousSong()); + this.elements.prevBtn.addEventListener("click", () => this.previousSong()); } if (this.elements.nextBtn) { - this.elements.nextBtn.addEventListener('click', () => this.nextSong()); + this.elements.nextBtn.addEventListener("click", () => this.nextSong()); } if (this.elements.loopModeBtn) { - this.elements.loopModeBtn.addEventListener('click', () => this.togglePlayMode()); + this.elements.loopModeBtn.addEventListener("click", () => this.togglePlayMode()); } - this.elements.albumCoverContainer.addEventListener('click', () => { - if (this.element.classList.contains('minimized')) { - this.elements.albumCoverContainer.classList.toggle('expanded'); + this.elements.albumCoverContainer.addEventListener("click", () => { + if (this.element.classList.contains("minimized")) { + this.elements.albumCoverContainer.classList.toggle("expanded"); return; } if (this.currentSong && this.currentSong.id) { - const songUrl = `https://music.163.com/song?id=${this.currentSong.id}`; - window.open(songUrl, '_blank', 'noopener,noreferrer'); + const songUrl = `https://music.163.com/song?id=${this.currentSong.id}`; + window.open(songUrl, "_blank", "noopener,noreferrer"); } }); let isDragging = false; - this.elements.progressContainer.addEventListener('mousedown', (e) => { + this.elements.progressContainer.addEventListener("mousedown", (e) => { isDragging = true; this.seekTo(e); }); - document.addEventListener('mousemove', (e) => { + document.addEventListener("mousemove", (e) => { if (isDragging) { this.seekTo(e); } }); - document.addEventListener('mouseup', () => { + document.addEventListener("mouseup", () => { isDragging = false; }); - this.elements.progressContainer.addEventListener('click', (e) => this.seekTo(e)); + this.elements.progressContainer.addEventListener("click", (e) => this.seekTo(e)); let isVolumesDragging = false; - this.elements.volumeSlider.addEventListener('mousedown', (e) => { + this.elements.volumeSlider.addEventListener("mousedown", (e) => { isVolumesDragging = true; this.setVolume(e); }); - document.addEventListener('mousemove', (e) => { + document.addEventListener("mousemove", (e) => { if (isVolumesDragging) { this.setVolume(e); } }); - document.addEventListener('mouseup', () => { + document.addEventListener("mouseup", () => { isVolumesDragging = false; }); - this.elements.volumeSlider.addEventListener('click', (e) => this.setVolume(e)); - this.elements.lyricsBtn.addEventListener('click', () => this.toggleLyrics()); + this.elements.volumeSlider.addEventListener("click", (e) => this.setVolume(e)); + this.elements.lyricsBtn.addEventListener("click", () => this.toggleLyrics()); if (this.elements.listBtn) { - this.elements.listBtn.addEventListener('click', () => this.togglePlaylist()); + this.elements.listBtn.addEventListener("click", () => this.togglePlaylist()); } if (this.elements.minimizeBtn) { - this.elements.minimizeBtn.addEventListener('click', () => this.toggleMinimize()); + this.elements.minimizeBtn.addEventListener("click", () => this.toggleMinimize()); } - document.addEventListener('click', (e) => { - if (this.elements.playlistContainer && - this.elements.playlistContainer.classList.contains('show')) { + document.addEventListener("click", (e) => { + if (this.elements.playlistContainer && this.elements.playlistContainer.classList.contains("show")) { if (!this.element.contains(e.target)) { this.togglePlaylist(false); } } }); - if (this.config.position !== 'static' && !this.config.embed) { + if (this.config.position !== "static" && !this.config.embed) { this.setupDragAndDrop(); } - if (typeof document.hidden !== 'undefined') { - document.addEventListener('visibilitychange', () => { - if (this.config.autoPauseDisabled === true) { - return; - } + // 标签页非激活时自动暂停的处理 + if (typeof document.hidden !== "undefined" && this.config.autoPause) { + document.addEventListener("visibilitychange", () => { if (document.hidden && this.isPlaying) { this.wasPlayingBeforeHidden = true; this.pause(); @@ -304,10 +340,10 @@ class NeteaseMiniPlayer { }); } - this.element.addEventListener('mouseenter', () => { + this.element.addEventListener("mouseenter", () => { this.restoreOpacity(); }); - this.element.addEventListener('mouseleave', () => { + this.element.addEventListener("mouseleave", () => { this.startIdleTimer(); }); this.applyIdlePolicyOnInit(); @@ -332,19 +368,19 @@ class NeteaseMiniPlayer { if (!this.shouldEnableIdleOpacity()) return; if (this.isIdle) return; this.isIdle = true; - this.element.classList.remove('fading-in'); + this.element.classList.remove("fading-in"); const side = this.getDockSide(); if (side) { this.element.classList.add(`docked-${side}`); } - this.element.classList.add('fading-out'); + this.element.classList.add("fading-out"); const onEnd = (e) => { - if (e.animationName !== 'player-fade-out') return; - this.element.classList.remove('fading-out'); - this.element.classList.add('idle'); - this.element.removeEventListener('animationend', onEnd); + if (e.animationName !== "player-fade-out") return; + this.element.classList.remove("fading-out"); + this.element.classList.add("idle"); + this.element.removeEventListener("animationend", onEnd); }; - this.element.addEventListener('animationend', onEnd); + this.element.addEventListener("animationend", onEnd); } restoreOpacity() { @@ -352,38 +388,38 @@ class NeteaseMiniPlayer { const side = this.getDockSide(); const hasDock = side ? this.element.classList.contains(`docked-${side}`) : false; if (hasDock) { - const popAnim = side === 'right' ? 'player-popout-right' : 'player-popout-left'; + const popAnim = side === "right" ? "player-popout-right" : "player-popout-left"; this.element.classList.add(`popping-${side}`); const onPopEnd = (e) => { if (e.animationName !== popAnim) return; - this.element.removeEventListener('animationend', onPopEnd); + this.element.removeEventListener("animationend", onPopEnd); this.element.classList.remove(`popping-${side}`); this.element.classList.remove(`docked-${side}`); if (this.isIdle) { this.isIdle = false; } - this.element.classList.remove('idle', 'fading-out'); - this.element.classList.add('fading-in'); + this.element.classList.remove("idle", "fading-out"); + this.element.classList.add("fading-in"); const onEndIn = (ev) => { - if (ev.animationName !== 'player-fade-in') return; - this.element.classList.remove('fading-in'); - this.element.removeEventListener('animationend', onEndIn); + if (ev.animationName !== "player-fade-in") return; + this.element.classList.remove("fading-in"); + this.element.removeEventListener("animationend", onEndIn); }; - this.element.addEventListener('animationend', onEndIn); + this.element.addEventListener("animationend", onEndIn); }; - this.element.addEventListener('animationend', onPopEnd); + this.element.addEventListener("animationend", onPopEnd); return; } if (!this.isIdle) return; this.isIdle = false; - this.element.classList.remove('idle', 'fading-out'); - this.element.classList.add('fading-in'); + this.element.classList.remove("idle", "fading-out"); + this.element.classList.add("fading-in"); const onEndIn = (ev) => { - if (ev.animationName !== 'player-fade-in') return; - this.element.classList.remove('fading-in'); - this.element.removeEventListener('animationend', onEndIn); + if (ev.animationName !== "player-fade-in") return; + this.element.classList.remove("fading-in"); + this.element.removeEventListener("animationend", onEndIn); }; - this.element.addEventListener('animationend', onEndIn); + this.element.addEventListener("animationend", onEndIn); } shouldEnableIdleOpacity() { @@ -394,58 +430,78 @@ class NeteaseMiniPlayer { if (!this.shouldEnableIdleOpacity()) { this.clearIdleTimer(); this.isIdle = false; - this.element.classList.remove('idle', 'fading-in', 'fading-out', 'docked-left', 'docked-right', 'popping-left', 'popping-right'); + this.element.classList.remove( + "idle", + "fading-in", + "fading-out", + "docked-left", + "docked-right", + "popping-left", + "popping-right" + ); } } getDockSide() { const pos = this.config.position; - if (pos === 'top-left' || pos === 'bottom-left') return 'left'; - if (pos === 'top-right' || pos === 'bottom-right') return 'right'; - return 'right'; + if (pos === "top-left" || pos === "bottom-left") return "left"; + if (pos === "top-right" || pos === "bottom-right") return "right"; + return "right"; } static getUAInfo() { if (NeteaseMiniPlayer._uaCache) return NeteaseMiniPlayer._uaCache; - const nav = typeof navigator !== 'undefined' ? navigator : {}; - const uaRaw = (nav.userAgent || ''); + const nav = typeof navigator !== "undefined" ? navigator : {}; + const uaRaw = nav.userAgent || ""; const ua = uaRaw.toLowerCase(); - const platform = (nav.platform || '').toLowerCase(); + const platform = (nav.platform || "").toLowerCase(); const maxTP = nav.maxTouchPoints || 0; const isWeChat = /micromessenger/.test(ua); const isQQ = /(mqqbrowser| qq)/.test(ua); const isInAppWebView = /\bwv\b|; wv/.test(ua) || /version\/\d+.*chrome/.test(ua); const isiPhone = /iphone/.test(ua); const isiPadUA = /ipad/.test(ua); - const isIOSLikePad = !isiPadUA && platform.includes('mac') && maxTP > 1; + const isIOSLikePad = !isiPadUA && platform.includes("mac") && maxTP > 1; const isiOS = isiPhone || isiPadUA || isIOSLikePad; const isAndroid = /android/.test(ua); const isHarmonyOS = /harmonyos/.test(uaRaw) || /huawei|honor/.test(ua); const isMobileToken = /mobile/.test(ua) || /sm-|mi |redmi|huawei|honor|oppo|vivo|oneplus/.test(ua); const isHarmonyDesktop = isHarmonyOS && !isMobileToken && !isAndroid && !isiOS; - const isPWA = (typeof window !== 'undefined' && ( - (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || - (nav.standalone === true) - )) || false; - const isMobile = (isiOS || isAndroid || (isHarmonyOS && !isHarmonyDesktop) || isMobileToken || isInAppWebView); - const info = { isMobile, isiOS, isAndroid, isHarmonyOS, isHarmonyDesktop, isWeChat, isQQ, isInAppWebView, isPWA, isiPad: isiPadUA || isIOSLikePad }; + const isPWA = + (typeof window !== "undefined" && + ((window.matchMedia && window.matchMedia("(display-mode: standalone)").matches) || + nav.standalone === true)) || + false; + const isMobile = isiOS || isAndroid || (isHarmonyOS && !isHarmonyDesktop) || isMobileToken || isInAppWebView; + const info = { + isMobile, + isiOS, + isAndroid, + isHarmonyOS, + isHarmonyDesktop, + isWeChat, + isQQ, + isInAppWebView, + isPWA, + isiPad: isiPadUA || isIOSLikePad, + }; NeteaseMiniPlayer._uaCache = info; return info; } applyResponsiveControls() { const env = NeteaseMiniPlayer.getUAInfo(); const shouldHideVolume = !!env.isMobile; - this.element.classList.toggle('mobile-env', shouldHideVolume); + this.element.classList.toggle("mobile-env", shouldHideVolume); if (this.elements && this.elements.volumeContainer == null) { - this.elements.volumeContainer = this.element.querySelector('.volume-container'); + this.elements.volumeContainer = this.element.querySelector(".volume-container"); } if (this.elements.volumeContainer) { if (shouldHideVolume) { - this.elements.volumeContainer.classList.add('sr-visually-hidden'); - this.elements.volumeContainer.setAttribute('aria-hidden', 'false'); - this.elements.volumeSlider?.setAttribute('aria-label', '音量控制(移动端隐藏,仅无障碍可见)'); + this.elements.volumeContainer.classList.add("sr-visually-hidden"); + this.elements.volumeContainer.setAttribute("aria-hidden", "false"); + this.elements.volumeSlider?.setAttribute("aria-label", "音量控制(移动端隐藏,仅无障碍可见)"); } else { - this.elements.volumeContainer.classList.remove('sr-visually-hidden'); - this.elements.volumeContainer.removeAttribute('aria-hidden'); - this.elements.volumeSlider?.removeAttribute('aria-label'); + this.elements.volumeContainer.classList.remove("sr-visually-hidden"); + this.elements.volumeContainer.removeAttribute("aria-hidden"); + this.elements.volumeSlider?.removeAttribute("aria-label"); } } } @@ -453,74 +509,92 @@ class NeteaseMiniPlayer { const reapply = () => this.applyResponsiveControls(); if (window.matchMedia) { try { - const mq1 = window.matchMedia('(orientation: portrait)'); - const mq2 = window.matchMedia('(orientation: landscape)'); - mq1.addEventListener?.('change', reapply); - mq2.addEventListener?.('change', reapply); + const mq1 = window.matchMedia("(orientation: portrait)"); + const mq2 = window.matchMedia("(orientation: landscape)"); + mq1.addEventListener?.("change", reapply); + mq2.addEventListener?.("change", reapply); } catch (e) { mq1.onchange = reapply; mq2.onchange = reapply; } } else { - window.addEventListener('orientationchange', reapply); + window.addEventListener("orientationchange", reapply); } - window.addEventListener('resize', reapply); + window.addEventListener("resize", reapply); } setupAudioEvents() { - this.audio.addEventListener('loadedmetadata', () => { + this.audio.addEventListener("loadedmetadata", () => { this.duration = this.audio.duration; this.updateTimeDisplay(); }); - this.audio.addEventListener('timeupdate', () => { + this.audio.addEventListener("timeupdate", () => { this.currentTime = this.audio.currentTime; this.updateProgress(); this.updateLyrics(); this.updateTimeDisplay(); }); - this.audio.addEventListener('ended', async () => { + this.audio.addEventListener("ended", async () => { await this.nextSong(); }); - this.audio.addEventListener('error', async (e) => { - console.error('音频播放错误:', e); - console.error('错误详情:', { + this.audio.addEventListener("error", async (e) => { + console.error("音频播放错误:", e); + console.error("错误详情:", { code: e.target.error?.code, message: e.target.error?.message, - src: e.target.src + src: e.target.src, }); - this.showError('播放失败,尝试下一首'); + this.showError("播放失败,尝试下一首"); setTimeout(async () => { await this.nextSong(); }, 1000); }); - this.audio.addEventListener('abort', () => { - console.warn('音频加载被中断'); + this.audio.addEventListener("abort", () => { + console.warn("音频加载被中断"); }); - this.audio.addEventListener('stalled', () => { - console.warn('音频加载停滞'); + this.audio.addEventListener("stalled", () => { + console.warn("音频加载停滞"); }); - this.audio.addEventListener('canplay', () => { + this.audio.addEventListener("canplay", () => { if (this.isPlaying && this.audio.paused) { - this.audio.play().catch(e => console.error('自动播放失败:', e)); + this.audio.play().catch((e) => console.error("自动播放失败:", e)); } }); this.audio.volume = this.volume; this.updateVolumeDisplay(); } async apiRequest(endpoint, params = {}) { - const baseUrl = 'https://api.hypcvgm.top/NeteaseMiniPlayer/nmp.php'; - const queryString = new URLSearchParams(params).toString(); - const url = `${baseUrl}${endpoint}${queryString ? '?' + queryString : ''}`; - try { - const response = await fetch(url); - const data = await response.json(); - if (data.code !== 200) { - throw new Error(`API错误: ${data.code}`); + const apiUrls = this.config.apiUrls; + + for (const baseUrl of apiUrls) { + try { + const queryParams = { + server: "netease", + type: "playlist", + id: params.id, + ...params, + }; + + const queryString = new URLSearchParams(queryParams).toString(); + const url = `${baseUrl}?${queryString}`; + + const response = await fetch(url, { mode: "cors", timeout: 5000 }); + const data = await response.json(); + + if (!data) { + continue; + } + + return { + code: 200, + songs: data || [], + }; + } catch (error) { + console.warn(`API ${baseUrl} 请求失败:`, error); + continue; } - return data; - } catch (error) { - console.error('API请求失败:', error); - throw error; } + + throw new Error("所有 API 都请求失败"); } getCacheKey(type, id) { return `${type}_${id}`; @@ -528,7 +602,7 @@ class NeteaseMiniPlayer { setCache(key, data, expiry = 5 * 60 * 1000) { this.cache.set(key, { data, - expiry: Date.now() + expiry + expiry: Date.now() + expiry, }); } getCache(key) { @@ -540,103 +614,157 @@ class NeteaseMiniPlayer { return null; } async loadPlaylist(playlistId) { - const cacheKey = this.getCacheKey('playlist_all', playlistId); + const cacheKey = this.getCacheKey("playlist_all", playlistId); let tracks = this.getCache(cacheKey); if (!tracks) { - const response = await this.apiRequest('/playlist/track/all', { + const response = await this.apiRequest("", { id: playlistId, - limit: 1000, - offset: 0 }); - tracks = response.songs; + tracks = response.songs || []; this.setCache(cacheKey, tracks); } - this.playlist = tracks.map(song => ({ - id: song.id, - name: song.name, - artists: song.ar.map(ar => ar.name).join(', '), - album: song.al.name, - picUrl: song.al.picUrl, - duration: song.dt - })); + + if (!tracks || tracks.length === 0) { + console.warn("歌单为空或无法加载,使用默认歌曲"); + // 提供一个默认歌曲,避免播放器崩溃 + // 使用特殊 ID "_empty" 表示这是一个占位符歌曲 + this.playlist = [ + { + id: "_empty", + name: "网络加载失败", + artists: "Cloud Home", + album: "Demo", + picUrl: "", + duration: 0, + }, + ]; + return; + } + + this.playlist = tracks.map((song) => { + // Meting API 返回格式可能变化,需要从多个地方获取 ID + let songId = song.id || song.mid; + + // 如果没有直接的 ID,尝试从 URL 中提取 + if (!songId && song.url) { + const urlMatch = song.url.match(/id=(\d+)/); + songId = urlMatch ? urlMatch[1] : null; + } + + // 或从 lrc URL 中提取 + if (!songId && song.lrc) { + const lrcMatch = song.lrc.match(/id=(\d+)/); + songId = lrcMatch ? lrcMatch[1] : null; + } + + if (!songId) { + console.warn("歌曲缺少ID,无法播放:", song.title || song.name); + } + + return { + id: songId || "", + name: song.name || song.title || "Unknown", + artists: song.artist || song.author || "Unknown Artist", + album: song.album || "Unknown Album", + picUrl: song.pic || song.cover || "", + duration: song.duration + ? typeof song.duration === "string" + ? parseInt(song.duration) * 1000 + : song.duration * 1000 + : 0, + // 保存原始 API 返回的 URL 供后续使用 + rawUrl: song.url || null, + rawLyricUrl: song.lrc || null, + }; + }); + + this.setCache(cacheKey, tracks); this.updatePlaylistDisplay(); } async loadSingleSong(songId) { - const cacheKey = this.getCacheKey('song', songId); + const cacheKey = this.getCacheKey("song", songId); let songData = this.getCache(cacheKey); if (!songData) { - try { - const response = await this.apiRequest('/song/detail', { ids: songId }); - if (response.songs && response.songs.length > 0) { - const song = response.songs[0]; - songData = { - id: song.id, - name: song.name, - artists: song.ar.map(ar => ar.name).join(', '), - album: song.al.name, - picUrl: song.al.picUrl, - duration: song.dt - }; - this.setCache(cacheKey, songData); - } else { - throw new Error('歌曲信息获取失败'); + const apiUrls = [ + `https://www.bilibili.uno/api?server=netease&type=song&id=${songId}`, + `https://meting-api.wangcy.site/api?server=netease&type=song&id=${songId}`, + ]; + + for (const url of apiUrls) { + try { + const response = await fetch(url); + const songs = await response.json(); + if (songs && songs.length > 0) { + const song = songs[0]; + songData = { + id: song.id || song.mid || songId, + name: song.name || song.title || "Unknown", + artists: song.artist || song.author || "Unknown Artist", + album: song.album || "Unknown Album", + picUrl: song.pic || song.cover || "", + duration: song.duration + ? typeof song.duration === "string" + ? parseInt(song.duration) * 1000 + : song.duration * 1000 + : 0, + }; + this.setCache(cacheKey, songData); + break; + } + } catch (error) { + console.warn("从此API获取歌曲失败:", error); + continue; } - } catch (error) { - console.error('获取歌曲详情失败:', error); - songData = { - id: songId, - name: '歌曲加载失败', - artists: '未知艺术家', - album: '未知专辑', - picUrl: '', - duration: 0 - }; + } + + if (!songData) { + throw new Error("歌曲信息获取失败"); } } this.playlist = [songData]; } async loadCurrentSong() { if (this.playlist.length === 0) return; - + if (this.showLyrics) { - this.elements.lyricLine.textContent = '♪ 加载歌词中... ♪'; - this.elements.lyricTranslation.style.display = 'none'; - this.elements.lyricLine.classList.remove('current', 'scrolling'); - this.elements.lyricTranslation.classList.remove('current', 'scrolling'); + this.elements.lyricLine.textContent = "♪ 加载歌词中... ♪"; + this.elements.lyricTranslation.style.display = "none"; + this.elements.lyricLine.classList.remove("current", "scrolling"); + this.elements.lyricTranslation.classList.remove("current", "scrolling"); this.lyrics = []; this.currentLyricIndex = -1; } - + const song = this.playlist[this.currentIndex]; this.currentSong = song; this.updateSongInfo(song); if (song.picUrl) { this.elements.albumCover.src = song.picUrl; } - await this.loadSongUrl(song.id); + await this.loadSongUrl(song); if (this.showLyrics) { - await this.loadLyrics(song.id); + await this.loadLyrics(song); } } updateSongInfo(song) { if (!song) return; - this.elements.songTitle.textContent = song.name || '未知歌曲'; + this.elements.songTitle.textContent = song.name || "未知歌曲"; if (song.artists) { const truncatedArtist = this.truncateArtistName(song.artists); this.elements.songArtist.textContent = truncatedArtist; if (truncatedArtist !== song.artists) { - this.elements.songArtist.setAttribute('title', song.artists); + this.elements.songArtist.setAttribute("title", song.artists); } else { - this.elements.songArtist.removeAttribute('title'); + this.elements.songArtist.removeAttribute("title"); } } } truncateArtistName(artistText) { - if (!artistText) return ''; - const tempElement = document.createElement('span'); - tempElement.style.visibility = 'hidden'; - tempElement.style.position = 'absolute'; - tempElement.style.fontSize = '12px'; + if (!artistText) return ""; + const tempElement = document.createElement("span"); + tempElement.style.visibility = "hidden"; + tempElement.style.position = "absolute"; + tempElement.style.fontSize = "12px"; tempElement.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; tempElement.textContent = artistText; document.body.appendChild(tempElement); @@ -646,12 +774,12 @@ class NeteaseMiniPlayer { document.body.removeChild(tempElement); return artistText; } - const artists = artistText.split(' / '); - let result = ''; + const artists = artistText.split(" / "); + let result = ""; let currentWidth = 0; for (let i = 0; i < artists.length; i++) { const testText = result ? `${result} / ${artists[i]}` : artists[i]; - tempElement.textContent = testText + '...'; + tempElement.textContent = testText + "..."; const testWidth = tempElement.offsetWidth; if (testWidth > availableWidth) { if (result) { @@ -660,7 +788,7 @@ class NeteaseMiniPlayer { const artist = artists[i]; for (let j = 1; j < artist.length; j++) { const partialArtist = artist.substring(0, j); - tempElement.textContent = partialArtist + '...'; + tempElement.textContent = partialArtist + "..."; if (tempElement.offsetWidth > availableWidth) { result = artist.substring(0, Math.max(1, j - 1)); break; @@ -673,64 +801,112 @@ class NeteaseMiniPlayer { result = testText; } document.body.removeChild(tempElement); - return result + (result !== artistText ? '...' : ''); + return result + (result !== artistText ? "..." : ""); } - async loadSongUrl(songId) { - const cacheKey = this.getCacheKey('song_url', songId); + async loadSongUrl(song) { + if (!song || !song.id || song.id === "_empty") { + console.warn("歌曲对象无效,跳过加载音频URL"); + return; + } + + const songId = String(song.id); // 确保转换为字符串 + const cacheKey = this.getCacheKey("song_url", songId); let urlData = this.getCache(cacheKey); + if (!urlData) { - try { - const response = await this.apiRequest('/song/url/v1', { - id: songId, - level: 'exhigh' - }); - if (response.data && response.data.length > 0) { - urlData = response.data[0]; - this.setCache(cacheKey, urlData, 30 * 60 * 1000); - } - } catch (error) { - console.error('获取音频URL失败:', error); + // 优先尝试使用 playlist 中已有的 URL + if (song.rawUrl) { try { - const fallbackResponse = await this.apiRequest('/song/url/v1', { - id: songId, - level: 'standard' - }); - if (fallbackResponse.data && fallbackResponse.data.length > 0) { - urlData = fallbackResponse.data[0]; + const response = await fetch(song.rawUrl, { method: "HEAD" }); + if (response.ok) { + urlData = { url: song.rawUrl }; + this.setCache(cacheKey, urlData, 30 * 60 * 1000); + } + } catch (error) { + console.warn("验证歌单URL失败:", error); + } + } + + // 如果没有原始 URL,尝试从 API 获取 + if (!urlData) { + const baseUrls = this.config.apiUrls; + const apiUrls = baseUrls.map((baseUrl) => `${baseUrl}?server=netease&type=song&id=${songId}`); + + for (const url of apiUrls) { + try { + const response = await fetch(url, { mode: "cors" }); + const data = await response.json(); + if (data && data.length > 0) { + urlData = { + url: data[0].url || data[0], + }; + this.setCache(cacheKey, urlData, 30 * 60 * 1000); + break; + } + } catch (error) { + console.warn("从此API获取失败:", error); + continue; } - } catch (fallbackError) { - console.error('降级获取音频URL也失败:', fallbackError); } } } + if (urlData && urlData.url) { const httpsUrl = this.ensureHttps(urlData.url); - console.log('设置音频源:', httpsUrl); this.audio.src = httpsUrl; } else { - throw new Error('无法获取播放地址'); + console.warn("无法获取音频URL"); } } ensureHttps(url) { if (!url) return url; - if (url.includes('music.126.net')) { - return url.replace(/^http:\/\//, 'https://'); + if (url.includes("music.126.net")) { + return url.replace(/^http:\/\//, "https://"); } - if (url.startsWith('http://')) { - return url.replace('http://', 'https://'); + if (url.startsWith("http://")) { + return url.replace("http://", "https://"); } return url; } - async loadLyrics(songId) { - const cacheKey = this.getCacheKey('lyric', songId); + async loadLyrics(song) { + if (!song || !song.id || song.id === "_empty") { + console.warn("歌曲对象无效,跳过加载歌词"); + return; + } + + const songId = String(song.id); // 确保转换为字符串 + const cacheKey = this.getCacheKey("lyric", songId); let lyricData = this.getCache(cacheKey); + if (!lyricData) { - try { - const response = await this.apiRequest('/lyric', { id: songId }); - lyricData = response; - this.setCache(cacheKey, lyricData, 60 * 60 * 1000); - } catch (error) { - console.error('获取歌词失败:', error); + const baseUrls = this.config.apiUrls; + const apiUrls = baseUrls.map((baseUrl) => `${baseUrl}?server=netease&type=lrc&id=${songId}`); + + for (const url of apiUrls) { + try { + const response = await fetch(url); + const contentType = response.headers.get("content-type") || ""; + + if (contentType.includes("application/json")) { + lyricData = await response.json(); + } else { + // API 直接返回 lrc 文件内容(纯文本) + const lrcText = await response.text(); + lyricData = { lrc: { lyric: lrcText } }; + } + + if (lyricData) { + this.setCache(cacheKey, lyricData, 60 * 60 * 1000); + break; + } + } catch (error) { + console.warn("从此API获取歌词失败:", error); + continue; + } + } + + if (!lyricData) { + console.warn("无法获取歌词"); this.lyrics = []; return; } @@ -740,24 +916,26 @@ class NeteaseMiniPlayer { parseLyrics(lyricData) { this.lyrics = []; this.currentLyricIndex = -1; - + if (!lyricData || (!lyricData.lrc?.lyric && !lyricData.tlyric?.lyric)) { - this.elements.lyricLine.textContent = '暂无歌词'; - this.elements.lyricTranslation.style.display = 'none'; - this.elements.lyricLine.classList.remove('current', 'scrolling'); - this.elements.lyricTranslation.classList.remove('current', 'scrolling'); + this.elements.lyricLine.textContent = "暂无歌词"; + this.elements.lyricTranslation.style.display = "none"; + this.elements.lyricLine.classList.remove("current", "scrolling"); + this.elements.lyricTranslation.classList.remove("current", "scrolling"); return; } - const lrcLines = lyricData.lrc.lyric.split('\n'); - const tlyricLines = lyricData.tlyric && lyricData.tlyric.lyric ? - lyricData.tlyric.lyric.split('\n') : []; + // 处理 lrc 数据可能是字符串或对象的情况 + const lrcContent = typeof lyricData.lrc === "string" ? lyricData.lrc : lyricData.lrc?.lyric || ""; + const tlyricContent = typeof lyricData.tlyric === "string" ? lyricData.tlyric : lyricData.tlyric?.lyric || ""; + const lrcLines = lrcContent.split("\n"); + const tlyricLines = tlyricContent ? tlyricContent.split("\n") : []; const lrcMap = new Map(); - lrcLines.forEach(line => { + lrcLines.forEach((line) => { const match = line.match(/\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/); if (match) { const minutes = parseInt(match[1]); const seconds = parseInt(match[2]); - const milliseconds = parseInt(match[3].padEnd(3, '0')); + const milliseconds = parseInt(match[3].padEnd(3, "0")); const time = minutes * 60 + seconds + milliseconds / 1000; const text = match[4].trim(); if (text) { @@ -766,12 +944,12 @@ class NeteaseMiniPlayer { } }); const tlyricMap = new Map(); - tlyricLines.forEach(line => { + tlyricLines.forEach((line) => { const match = line.match(/\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/); if (match) { const minutes = parseInt(match[1]); const seconds = parseInt(match[2]); - const milliseconds = parseInt(match[3].padEnd(3, '0')); + const milliseconds = parseInt(match[3].padEnd(3, "0")); const time = minutes * 60 + seconds + milliseconds / 1000; const text = match[4].trim(); if (text) { @@ -780,10 +958,10 @@ class NeteaseMiniPlayer { } }); const allTimes = Array.from(new Set([...lrcMap.keys(), ...tlyricMap.keys()])).sort((a, b) => a - b); - this.lyrics = allTimes.map(time => ({ + this.lyrics = allTimes.map((time) => ({ time, - text: lrcMap.get(time) || '', - translation: tlyricMap.get(time) || '' + text: lrcMap.get(time) || "", + translation: tlyricMap.get(time) || "", })); this.currentLyricIndex = -1; this.updateLyrics(); @@ -800,22 +978,22 @@ class NeteaseMiniPlayer { try { await this.audio.play(); this.isPlaying = true; - this.elements.playIcon.style.display = 'none'; - this.elements.pauseIcon.style.display = 'inline'; - this.elements.albumCover.classList.add('playing'); - this.element.classList.add('player-playing'); + this.elements.playIcon.style.display = "none"; + this.elements.pauseIcon.style.display = "inline"; + this.elements.albumCover.classList.add("playing"); + this.element.classList.add("player-playing"); } catch (error) { - console.error('播放失败:', error); - this.showError('播放失败'); + console.error("播放失败:", error); + this.showError("播放失败"); } } pause() { this.audio.pause(); this.isPlaying = false; - this.elements.playIcon.style.display = 'inline'; - this.elements.pauseIcon.style.display = 'none'; - this.elements.albumCover.classList.remove('playing'); - this.element.classList.remove('player-playing'); + this.elements.playIcon.style.display = "inline"; + this.elements.pauseIcon.style.display = "none"; + this.elements.albumCover.classList.remove("playing"); + this.element.classList.remove("player-playing"); } async previousSong() { if (this.playlist.length <= 1) return; @@ -826,9 +1004,9 @@ class NeteaseMiniPlayer { } } async nextSong() { - const wasPlaying = this.isPlaying; + const wasPlaying = this.isPlaying; if (this.playlist.length <= 1) { - if (this.playMode === 'single') { + if (this.playMode === "single") { this.audio.currentTime = 0; if (wasPlaying) await this.play(); return; @@ -837,13 +1015,11 @@ class NeteaseMiniPlayer { if (wasPlaying) await this.play(); return; } - + let newIndex; - if (this.playMode === 'shuffle') { - const availableIndices = this.playlist - .map((_, i) => i) - .filter(i => i !== this.currentIndex); - + if (this.playMode === "shuffle") { + const availableIndices = this.playlist.map((_, i) => i).filter((i) => i !== this.currentIndex); + if (availableIndices.length === 0) { newIndex = this.currentIndex; } else { @@ -853,23 +1029,23 @@ class NeteaseMiniPlayer { if (this.shuffleHistory.length > 2) { this.shuffleHistory.shift(); } - } else if (this.playMode === 'single') { + } else if (this.playMode === "single") { newIndex = this.currentIndex; } else { newIndex = (this.currentIndex + 1) % this.playlist.length; } - + this.currentIndex = newIndex; await this.loadCurrentSong(); - + this.updatePlaylistDisplay(); - + if (wasPlaying) { setTimeout(async () => { try { await this.play(); } catch (error) { - console.error('自动播放下一首失败:', error); + console.error("自动播放下一首失败:", error); } }, 100); } @@ -884,7 +1060,7 @@ class NeteaseMiniPlayer { const formatTime = (time) => { const minutes = Math.floor(time / 60); const seconds = Math.floor(time % 60); - return `${minutes}:${seconds.toString().padStart(2, '0')}`; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; }; this.elements.currentTime.textContent = formatTime(this.currentTime); this.elements.totalTime.textContent = formatTime(this.duration); @@ -906,55 +1082,55 @@ class NeteaseMiniPlayer { this.currentLyricIndex = newIndex; if (newIndex >= 0 && newIndex < this.lyrics.length) { const lyric = this.lyrics[newIndex]; - const lyricText = lyric.text || '♪'; - - this.elements.lyricLine.classList.remove('current'); - + const lyricText = lyric.text || "♪"; + + this.elements.lyricLine.classList.remove("current"); + requestAnimationFrame(() => { this.elements.lyricLine.textContent = lyricText; this.checkLyricScrolling(this.elements.lyricLine, lyricText); - - this.elements.lyricLine.classList.add('current'); - + + this.elements.lyricLine.classList.add("current"); + if (lyric.translation) { this.elements.lyricTranslation.textContent = lyric.translation; - this.elements.lyricTranslation.style.display = 'block'; - this.elements.lyricTranslation.classList.remove('current'); + this.elements.lyricTranslation.style.display = "block"; + this.elements.lyricTranslation.classList.remove("current"); requestAnimationFrame(() => { - this.elements.lyricTranslation.classList.add('current'); + this.elements.lyricTranslation.classList.add("current"); }); } else { - this.elements.lyricTranslation.style.display = 'none'; - this.elements.lyricTranslation.classList.remove('current', 'scrolling'); + this.elements.lyricTranslation.style.display = "none"; + this.elements.lyricTranslation.classList.remove("current", "scrolling"); } }); - - this.elements.lyricsContainer.classList.add('switching'); + + this.elements.lyricsContainer.classList.add("switching"); setTimeout(() => { - this.elements.lyricsContainer.classList.remove('switching'); + this.elements.lyricsContainer.classList.remove("switching"); }, 500); if (lyric.translation) { this.elements.lyricTranslation.textContent = lyric.translation; - this.elements.lyricTranslation.classList.add('current'); - this.elements.lyricTranslation.style.display = 'block'; + this.elements.lyricTranslation.classList.add("current"); + this.elements.lyricTranslation.style.display = "block"; this.checkLyricScrolling(this.elements.lyricTranslation, lyric.translation); } else { - this.elements.lyricTranslation.style.display = 'none'; - this.elements.lyricTranslation.classList.remove('current', 'scrolling'); + this.elements.lyricTranslation.style.display = "none"; + this.elements.lyricTranslation.classList.remove("current", "scrolling"); } } else { - this.elements.lyricLine.textContent = '♪ 纯音乐,请欣赏 ♪'; - this.elements.lyricLine.classList.remove('current', 'scrolling'); - this.elements.lyricTranslation.style.display = 'none'; - this.elements.lyricTranslation.classList.remove('current', 'scrolling'); + this.elements.lyricLine.textContent = "♪ 纯音乐,请欣赏 ♪"; + this.elements.lyricLine.classList.remove("current", "scrolling"); + this.elements.lyricTranslation.style.display = "none"; + this.elements.lyricTranslation.classList.remove("current", "scrolling"); } } } checkLyricScrolling(element, text) { if (!element || !text) return; - const tempElement = document.createElement('span'); - tempElement.style.visibility = 'hidden'; - tempElement.style.position = 'absolute'; + const tempElement = document.createElement("span"); + tempElement.style.visibility = "hidden"; + tempElement.style.position = "absolute"; tempElement.style.fontSize = window.getComputedStyle(element).fontSize; tempElement.style.fontFamily = window.getComputedStyle(element).fontFamily; tempElement.style.fontWeight = window.getComputedStyle(element).fontWeight; @@ -964,26 +1140,30 @@ class NeteaseMiniPlayer { document.body.removeChild(tempElement); const containerWidth = element.parentElement.offsetWidth - 16; if (textWidth > containerWidth) { - element.classList.add('scrolling'); + element.classList.add("scrolling"); } else { - element.classList.remove('scrolling'); + element.classList.remove("scrolling"); } } updatePlaylistDisplay() { if (!this.elements.playlistContent || !this.playlist || this.playlist.length === 0) return; - const html = this.playlist.map((song, index) => ` -
-
${(index + 1).toString().padStart(2, '0')}
- 专辑封面 + const html = this.playlist + .map( + (song, index) => ` +
+
${(index + 1).toString().padStart(2, "0")}
+ 专辑封面
${song.name}
${song.artists}
- `).join(''); + ` + ) + .join(""); this.elements.playlistContent.innerHTML = html; - this.elements.playlistContent.querySelectorAll('.playlist-item').forEach(item => { - item.addEventListener('click', async () => { + this.elements.playlistContent.querySelectorAll(".playlist-item").forEach((item) => { + item.addEventListener("click", async () => { const index = parseInt(item.dataset.index); if (index !== this.currentIndex) { this.currentIndex = index; @@ -996,9 +1176,9 @@ class NeteaseMiniPlayer { } }); }); - const activeItem = this.elements.playlistContent.querySelector('.playlist-item.active'); + const activeItem = this.elements.playlistContent.querySelector(".playlist-item.active"); if (activeItem) { - activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + activeItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); } } seekTo(e) { @@ -1020,68 +1200,84 @@ class NeteaseMiniPlayer { } toggleLyrics() { this.showLyrics = !this.showLyrics; - this.elements.lyricsContainer.classList.toggle('hidden', !this.showLyrics); - this.elements.lyricsBtn.classList.toggle('active', this.showLyrics); + this.elements.lyricsContainer.classList.toggle("hidden", !this.showLyrics); + this.elements.lyricsBtn.classList.toggle("active", this.showLyrics); } togglePlaylist(show = null) { if (!this.elements.playlistContainer) return; - const isShowing = this.elements.playlistContainer.classList.contains('show'); + const isShowing = this.elements.playlistContainer.classList.contains("show"); const shouldShow = show !== null ? show : !isShowing; if (shouldShow) { this.determinePlaylistDirection(); this.updatePlaylistDisplay(); - this.elements.playlistContainer.classList.add('show'); + this.elements.playlistContainer.classList.add("show"); if (this.elements.listBtn) { - this.elements.listBtn.classList.add('active'); + this.elements.listBtn.classList.add("active"); } } else { - this.elements.playlistContainer.classList.remove('show', 'show-above', 'show-below'); + this.elements.playlistContainer.classList.remove("show", "show-above", "show-below"); if (this.elements.listBtn) { - this.elements.listBtn.classList.remove('active'); + this.elements.listBtn.classList.remove("active"); } } } togglePlayMode() { - const modes = ['list', 'single', 'shuffle']; + const modes = ["list", "single", "shuffle"]; const currentIndex = modes.indexOf(this.playMode); this.playMode = modes[(currentIndex + 1) % 3]; - + const iconSvgs = { list: ICONS.loopList, single: ICONS.loopSingle, shuffle: ICONS.shuffle }; - const titles = { list: '列表循环', single: '单曲循环', shuffle: '随机播放' }; - + const titles = { list: "列表循环", single: "单曲循环", shuffle: "随机播放" }; + if (this.elements.loopModeBtn) { this.elements.loopModeBtn.innerHTML = iconSvgs[this.playMode]; this.elements.loopModeBtn.title = titles[this.playMode]; } } toggleMinimize() { - const isCurrentlyMinimized = this.element.classList.contains('minimized'); + const isCurrentlyMinimized = this.element.classList.contains("minimized"); this.isMinimized = isCurrentlyMinimized; if (!isCurrentlyMinimized) { - this.element.classList.add('minimized'); + this.element.classList.add("minimized"); this.isMinimized = true; if (this.elements.minimizeBtn) { - this.elements.minimizeBtn.classList.add('active'); - this.elements.minimizeBtn.title = '展开'; - this.elements.minimizeBtn.innerHTML = ICONS.maximize; + this.elements.minimizeBtn.classList.add("active"); + this.elements.minimizeBtn.title = "展开"; + this.elements.minimizeBtn.innerHTML = ICONS.maximize; } this.clearIdleTimer(); this.isIdle = false; - this.element.classList.remove('idle', 'fading-in', 'fading-out', 'docked-left', 'docked-right', 'popping-left', 'popping-right'); + this.element.classList.remove( + "idle", + "fading-in", + "fading-out", + "docked-left", + "docked-right", + "popping-left", + "popping-right" + ); this.startIdleTimer(); } else { - this.element.classList.remove('minimized'); + this.element.classList.remove("minimized"); this.isMinimized = false; if (this.elements.minimizeBtn) { - this.elements.minimizeBtn.classList.remove('active'); - this.elements.minimizeBtn.title = '缩小'; + this.elements.minimizeBtn.classList.remove("active"); + this.elements.minimizeBtn.title = "缩小"; this.elements.minimizeBtn.innerHTML = ICONS.minimize; } this.clearIdleTimer(); if (this.isIdle) { this.restoreOpacity(); } else { - this.element.classList.remove('idle', 'fading-in', 'fading-out', 'docked-left', 'docked-right', 'popping-left', 'popping-right'); + this.element.classList.remove( + "idle", + "fading-in", + "fading-out", + "docked-left", + "docked-right", + "popping-left", + "popping-right" + ); } this.isIdle = false; } @@ -1092,10 +1288,10 @@ class NeteaseMiniPlayer { const spaceBelow = viewportHeight - playerRect.bottom; const spaceAbove = playerRect.top; const playlistHeight = 220; - this.elements.playlistContainer.classList.remove('expand-up'); + this.elements.playlistContainer.classList.remove("expand-up"); if (spaceBelow >= playlistHeight || spaceBelow >= spaceAbove) { } else { - this.elements.playlistContainer.classList.add('expand-up'); + this.elements.playlistContainer.classList.add("expand-up"); } } setupDragAndDrop() { @@ -1103,27 +1299,27 @@ class NeteaseMiniPlayer { } showError(message) { this.elements.songTitle.textContent = message; - this.elements.songArtist.textContent = ''; - this.elements.lyricLine.textContent = ''; + this.elements.songArtist.textContent = ""; + this.elements.lyricLine.textContent = ""; } initTheme() { this.setTheme(this.config.theme); - if (this.config.theme === 'auto') { + if (this.config.theme === "auto") { this.setupThemeListener(); } } setTheme(theme) { - if (theme === 'auto') { + if (theme === "auto") { const detectedTheme = this.detectTheme(); - this.element.setAttribute('data-theme', 'auto'); - if (detectedTheme === 'dark') { - this.element.classList.add('theme-dark-detected'); + this.element.setAttribute("data-theme", "auto"); + if (detectedTheme === "dark") { + this.element.classList.add("theme-dark-detected"); } else { - this.element.classList.remove('theme-dark-detected'); + this.element.classList.remove("theme-dark-detected"); } } else { - this.element.setAttribute('data-theme', theme); - this.element.classList.remove('theme-dark-detected'); + this.element.setAttribute("data-theme", theme); + this.element.classList.remove("theme-dark-detected"); } } detectTheme() { @@ -1140,28 +1336,28 @@ class NeteaseMiniPlayer { detectHostTheme() { const html = document.documentElement; const body = document.body; - const darkClasses = ['dark', 'theme-dark', 'dark-theme', 'dark-mode']; - const lightClasses = ['light', 'theme-light', 'light-theme', 'light-mode']; + const darkClasses = ["dark", "theme-dark", "dark-theme", "dark-mode"]; + const lightClasses = ["light", "theme-light", "light-theme", "light-mode"]; for (const className of darkClasses) { - if (html.classList.contains(className)) return 'dark'; + if (html.classList.contains(className)) return "dark"; } for (const className of lightClasses) { - if (html.classList.contains(className)) return 'light'; + if (html.classList.contains(className)) return "light"; } if (body) { for (const className of darkClasses) { - if (body.classList.contains(className)) return 'dark'; + if (body.classList.contains(className)) return "dark"; } for (const className of lightClasses) { - if (body.classList.contains(className)) return 'light'; + if (body.classList.contains(className)) return "light"; } } - const htmlTheme = html.getAttribute('data-theme'); - if (htmlTheme === 'dark' || htmlTheme === 'light') { + const htmlTheme = html.getAttribute("data-theme"); + if (htmlTheme === "dark" || htmlTheme === "light") { return htmlTheme; } - const bodyTheme = body?.getAttribute('data-theme'); - if (bodyTheme === 'dark' || bodyTheme === 'light') { + const bodyTheme = body?.getAttribute("data-theme"); + if (bodyTheme === "dark" || bodyTheme === "light") { return bodyTheme; } return null; @@ -1169,37 +1365,39 @@ class NeteaseMiniPlayer { detectCSSTheme() { try { const rootStyles = getComputedStyle(document.documentElement); - const bgColor = rootStyles.getPropertyValue('--bg-color') || - rootStyles.getPropertyValue('--background-color') || - rootStyles.getPropertyValue('--color-bg'); - const textColor = rootStyles.getPropertyValue('--text-color') || - rootStyles.getPropertyValue('--color-text') || - rootStyles.getPropertyValue('--text-primary'); + const bgColor = + rootStyles.getPropertyValue("--bg-color") || + rootStyles.getPropertyValue("--background-color") || + rootStyles.getPropertyValue("--color-bg"); + const textColor = + rootStyles.getPropertyValue("--text-color") || + rootStyles.getPropertyValue("--color-text") || + rootStyles.getPropertyValue("--text-primary"); if (bgColor || textColor) { const isDarkBg = this.isColorDark(bgColor); const isLightText = this.isColorLight(textColor); if (isDarkBg || isLightText) { - return 'dark'; + return "dark"; } if (!isDarkBg || !isLightText) { - return 'light'; + return "light"; } } } catch (error) { - console.warn('CSS主题检测失败:', error); + console.warn("CSS主题检测失败:", error); } return null; } detectSystemTheme() { - if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { - return 'dark'; + if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { + return "dark"; } - return 'light'; + return "light"; } isColorDark(color) { if (!color) return false; - color = color.replace(/\s/g, '').toLowerCase(); - if (color.includes('dark') || color.includes('black') || color === 'transparent') { + color = color.replace(/\s/g, "").toLowerCase(); + if (color.includes("dark") || color.includes("black") || color === "transparent") { return true; } const rgb = color.match(/rgb\((\d+),(\d+),(\d+)\)/); @@ -1224,47 +1422,49 @@ class NeteaseMiniPlayer { } setupThemeListener() { if (window.matchMedia) { - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleThemeChange = () => { - if (this.config.theme === 'auto') { - this.setTheme('auto'); + if (this.config.theme === "auto") { + this.setTheme("auto"); } }; if (mediaQuery.addEventListener) { - mediaQuery.addEventListener('change', handleThemeChange); + mediaQuery.addEventListener("change", handleThemeChange); } else { mediaQuery.addListener(handleThemeChange); } } if (window.MutationObserver) { const observer = new MutationObserver((mutations) => { - if (this.config.theme === 'auto') { + if (this.config.theme === "auto") { let shouldUpdate = false; mutations.forEach((mutation) => { - if (mutation.type === 'attributes' && - (mutation.attributeName === 'class' || mutation.attributeName === 'data-theme')) { + if ( + mutation.type === "attributes" && + (mutation.attributeName === "class" || mutation.attributeName === "data-theme") + ) { shouldUpdate = true; } }); if (shouldUpdate) { - this.setTheme('auto'); + this.setTheme("auto"); } } }); observer.observe(document.documentElement, { attributes: true, - attributeFilter: ['class', 'data-theme'] + attributeFilter: ["class", "data-theme"], }); if (document.body) { observer.observe(document.body, { attributes: true, - attributeFilter: ['class', 'data-theme'] + attributeFilter: ["class", "data-theme"], }); } } } static init() { - document.querySelectorAll('.netease-mini-player').forEach(element => { + document.querySelectorAll(".netease-mini-player").forEach((element) => { if (!element._neteasePlayer) { element._neteasePlayer = new NeteaseMiniPlayer(element); } @@ -1277,10 +1477,10 @@ class NeteaseMiniPlayer { return element._neteasePlayer; } } -if (typeof window !== 'undefined') { +if (typeof window !== "undefined") { window.NeteaseMiniPlayer = NeteaseMiniPlayer; - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', NeteaseMiniPlayer.init); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", NeteaseMiniPlayer.init); } else { NeteaseMiniPlayer.init(); } @@ -1289,18 +1489,18 @@ if (typeof window !== 'undefined') { class NMPv2ShortcodeParser { constructor() { this.paramMappings = { - 'position': 'data-position', - 'theme': 'data-theme', - 'lyric': 'data-lyric', - 'embed': 'data-embed', - 'minimized': 'data-default-minimized', - 'autoplay': 'data-autoplay', - 'idle-opacity': 'data-idle-opacity', - 'auto-pause': 'data-auto-pause' + position: "data-position", + theme: "data-theme", + lyric: "data-lyric", + embed: "data-embed", + minimized: "data-default-minimized", + autoplay: "data-autoplay", + "idle-opacity": "data-idle-opacity", + "auto-pause": "data-auto-pause", }; - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => this.init()); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => this.init()); } else { this.init(); } @@ -1323,34 +1523,29 @@ class NMPv2ShortcodeParser { * 处理文本节点中的短语法 */ processTextNodes(container) { - const walker = document.createTreeWalker( - container, - NodeFilter.SHOW_TEXT, - null, - false - ); + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null, false); const textNodes = []; let node; - while (node = walker.nextNode()) { - if (node.textContent.includes('{nmpv2:')) { + while ((node = walker.nextNode())) { + if (node.textContent.includes("{nmpv2:")) { textNodes.push(node); } } - textNodes.forEach(node => { + textNodes.forEach((node) => { const content = node.textContent; const shortcodes = this.extractShortcodes(content); - + if (shortcodes.length > 0) { const fragment = document.createDocumentFragment(); let lastIndex = 0; - shortcodes.forEach(shortcode => { + shortcodes.forEach((shortcode) => { if (shortcode.startIndex > lastIndex) { - fragment.appendChild(document.createTextNode( - content.substring(lastIndex, shortcode.startIndex) - )); + fragment.appendChild( + document.createTextNode(content.substring(lastIndex, shortcode.startIndex)) + ); } const playerElement = this.createPlayerElement(shortcode); @@ -1360,9 +1555,7 @@ class NMPv2ShortcodeParser { }); if (lastIndex < content.length) { - fragment.appendChild(document.createTextNode( - content.substring(lastIndex) - )); + fragment.appendChild(document.createTextNode(content.substring(lastIndex))); } node.parentNode.replaceChild(fragment, node); @@ -1371,18 +1564,16 @@ class NMPv2ShortcodeParser { } processExistingElements(container) { - container.querySelectorAll('.netease-mini-player:not([data-shortcode-processed])') - .forEach(element => { - element.setAttribute('data-shortcode-processed', 'true'); - }); + container.querySelectorAll(".netease-mini-player:not([data-shortcode-processed])").forEach((element) => { + element.setAttribute("data-shortcode-processed", "true"); + }); } initializePlayers(container) { - container.querySelectorAll('.netease-mini-player:not([data-initialized])') - .forEach(element => { - element.setAttribute('data-initialized', 'true'); - NeteaseMiniPlayer.initPlayer(element); - }); + container.querySelectorAll(".netease-mini-player:not([data-initialized])").forEach((element) => { + element.setAttribute("data-initialized", "true"); + NeteaseMiniPlayer.initPlayer(element); + }); } extractShortcodes(text) { @@ -1397,11 +1588,11 @@ class NMPv2ShortcodeParser { const endIndex = match.index + match[0].length; let shortcode = { - type: 'song', + type: "song", id: null, params: {}, startIndex, - endIndex + endIndex, }; this.parseShortcodeContent(content, shortcode); @@ -1412,49 +1603,49 @@ class NMPv2ShortcodeParser { } parseShortcodeContent(content, shortcode) { - if (content.startsWith('playlist=')) { - shortcode.type = 'playlist'; + if (content.startsWith("playlist=")) { + shortcode.type = "playlist"; const parts = content.split(/,\s*/); const firstPart = parts.shift(); - shortcode.id = firstPart.replace('playlist=', '').trim(); - - parts.forEach(part => this.parseParam(part, shortcode.params)); - } else if (content.includes('=')) { + shortcode.id = firstPart.replace("playlist=", "").trim(); + + parts.forEach((part) => this.parseParam(part, shortcode.params)); + } else if (content.includes("=")) { const parts = content.split(/,\s*/); const firstPart = parts.shift(); - - if (firstPart.includes('=')) { + + if (firstPart.includes("=")) { this.parseParam(firstPart, shortcode.params); - parts.forEach(part => this.parseParam(part, shortcode.params)); + parts.forEach((part) => this.parseParam(part, shortcode.params)); } else { shortcode.id = firstPart.trim(); - parts.forEach(part => this.parseParam(part, shortcode.params)); + parts.forEach((part) => this.parseParam(part, shortcode.params)); } } else { shortcode.id = content.trim(); } - if (shortcode.params.position === undefined || shortcode.params.position === 'static') { - shortcode.params.embed = shortcode.params.embed ?? 'true'; + if (shortcode.params.position === undefined || shortcode.params.position === "static") { + shortcode.params.embed = shortcode.params.embed ?? "true"; } else if (shortcode.params.embed === undefined) { - shortcode.params.embed = 'false'; + shortcode.params.embed = "false"; } } parseParam(paramStr, params) { - const [key, value] = paramStr.split('='); + const [key, value] = paramStr.split("="); if (!key || !value) return; const cleanKey = key.trim().toLowerCase(); const cleanValue = value.trim().toLowerCase(); - if (cleanKey === 'song-id') { + if (cleanKey === "song-id") { params.songId = cleanValue; - } else if (cleanKey === 'playlist-id') { + } else if (cleanKey === "playlist-id") { params.playlistId = cleanValue; - params.type = 'playlist'; - } else if (cleanKey === 'minimized') { - params.defaultMinimized = cleanValue === 'true' ? 'true' : 'false'; + params.type = "playlist"; + } else if (cleanKey === "minimized") { + params.defaultMinimized = cleanValue === "true" ? "true" : "false"; } else { const mapping = this.paramMappings[cleanKey] || `data-${cleanKey}`; params[cleanKey] = cleanValue; @@ -1462,22 +1653,22 @@ class NMPv2ShortcodeParser { } createPlayerElement(shortcode) { - const div = document.createElement('div'); - div.className = 'netease-mini-player'; - div.setAttribute('data-shortcode-processed', 'true'); + const div = document.createElement("div"); + div.className = "netease-mini-player"; + div.setAttribute("data-shortcode-processed", "true"); - if (shortcode.type === 'playlist' && shortcode.id) { - div.setAttribute('data-playlist-id', shortcode.id); + if (shortcode.type === "playlist" && shortcode.id) { + div.setAttribute("data-playlist-id", shortcode.id); } else if (shortcode.id) { - div.setAttribute('data-song-id', shortcode.id); + div.setAttribute("data-song-id", shortcode.id); } Object.entries(shortcode.params).forEach(([key, value]) => { - if (key === 'songId') { - div.setAttribute('data-song-id', value); - } else if (key === 'playlistId') { - div.setAttribute('data-playlist-id', value); - } else if (key === 'type') { + if (key === "songId") { + div.setAttribute("data-song-id", value); + } else if (key === "playlistId") { + div.setAttribute("data-playlist-id", value); + } else if (key === "type") { } else { const dataKey = this.paramMappings[key] || `data-${key}`; div.setAttribute(dataKey, value); @@ -1488,98 +1679,107 @@ class NMPv2ShortcodeParser { } static processDynamicContent(content) { - const tempDiv = document.createElement('div'); + const tempDiv = document.createElement("div"); tempDiv.innerHTML = content; window.nmpv2ShortcodeParser.processContainer(tempDiv); return tempDiv.innerHTML; } } -if (typeof window !== 'undefined') { +if (typeof window !== "undefined") { window.nmpv2ShortcodeParser = new NMPv2ShortcodeParser(); - - window.processNMPv2Shortcodes = function(container) { + + window.processNMPv2Shortcodes = function (container) { if (container instanceof Element) { window.nmpv2ShortcodeParser.processContainer(container); } else { - console.warn('processNMPv2Shortcodes requires a DOM element'); + console.warn("processNMPv2Shortcodes requires a DOM element"); } }; } -if (typeof module !== 'undefined' && module.exports) { +if (typeof module !== "undefined" && module.exports) { module.exports = { - renderShortcodes: function(html) { + renderShortcodes: function (html) { return html.replace(/\{nmpv2:([^}]*)\}/g, (match, content) => { let shortcode = { - type: 'song', + type: "song", id: null, - params: {} + params: {}, }; - - if (content.startsWith('playlist=')) { - shortcode.type = 'playlist'; + + if (content.startsWith("playlist=")) { + shortcode.type = "playlist"; const parts = content.split(/,\s*/); - shortcode.id = parts[0].replace('playlist=', '').trim(); - parts.slice(1).forEach(part => { - const [key, value] = part.split('='); + shortcode.id = parts[0].replace("playlist=", "").trim(); + parts.slice(1).forEach((part) => { + const [key, value] = part.split("="); if (key && value) shortcode.params[key.trim()] = value.trim(); }); } else { const parts = content.split(/,\s*/); - if (parts[0].includes('=')) { - parts.forEach(part => { - const [key, value] = part.split('='); + if (parts[0].includes("=")) { + parts.forEach((part) => { + const [key, value] = part.split("="); if (key && value) shortcode.params[key.trim()] = value.trim(); }); } else { shortcode.id = parts[0].trim(); - parts.slice(1).forEach(part => { - const [key, value] = part.split('='); + parts.slice(1).forEach((part) => { + const [key, value] = part.split("="); if (key && value) shortcode.params[key.trim()] = value.trim(); }); } } - if (!shortcode.params.position || shortcode.params.position === 'static') { - shortcode.params.embed = shortcode.params.embed ?? 'true'; + if (!shortcode.params.position || shortcode.params.position === "static") { + shortcode.params.embed = shortcode.params.embed ?? "true"; } else if (shortcode.params.embed === undefined) { - shortcode.params.embed = 'false'; + shortcode.params.embed = "false"; } let html = '
{ - if (key === 'songId') { + if (key === "songId") { html += ` data-song-id="${value}"`; - } else if (key === 'playlistId') { + } else if (key === "playlistId") { html += ` data-playlist-id="${value}"`; } else { - const dataKey = { - 'position': 'data-position', - 'theme': 'data-theme', - 'lyric': 'data-lyric', - 'embed': 'data-embed', - 'minimized': 'data-default-minimized', - 'autoplay': 'data-autoplay', - 'idle-opacity': 'data-idle-opacity', - 'auto-pause': 'data-auto-pause' - }[key] || `data-${key}`; + const dataKey = + { + position: "data-position", + theme: "data-theme", + lyric: "data-lyric", + embed: "data-embed", + minimized: "data-default-minimized", + autoplay: "data-autoplay", + "idle-opacity": "data-idle-opacity", + "auto-pause": "data-auto-pause", + }[key] || `data-${key}`; html += ` ${dataKey}="${value}"`; } }); - html += '>
'; + html += ">
"; return html; }); - } + }, }; } -console.log(["版本号 v2.1.0", "NeteaseMiniPlayer V2 [NMPv2]", "BHCN STUDIO & 北海的佰川(ImBHCN[numakkiyu])", "GitHub地址:https://github.com/numakkiyu/NeteaseMiniPlayer", "基于 Apache 2.0 开源协议发布"].join("\n")); +console.log( + [ + "版本号 v2.1.0", + "NeteaseMiniPlayer V2 [NMPv2]", + "BHCN STUDIO & 北海的佰川(ImBHCN[numakkiyu])", + "GitHub地址:https://github.com/numakkiyu/NeteaseMiniPlayer", + "基于 Apache 2.0 开源协议发布", + ].join("\n") +); diff --git a/src/App.vue b/src/App.vue deleted file mode 100644 index 7366aae..0000000 --- a/src/App.vue +++ /dev/null @@ -1,215 +0,0 @@ - - - - - diff --git a/src/app.vue b/src/app.vue new file mode 100644 index 0000000..669ae64 --- /dev/null +++ b/src/app.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/src/components/FooterSection.vue b/src/components/FooterSection.vue index 7d2f063..0c32047 100644 --- a/src/components/FooterSection.vue +++ b/src/components/FooterSection.vue @@ -18,8 +18,10 @@ diff --git a/src/components/PageSwitcher.vue b/src/components/PageSwitcher.vue index 81af53a..bbedab5 100644 --- a/src/components/PageSwitcher.vue +++ b/src/components/PageSwitcher.vue @@ -3,15 +3,15 @@
- + @@ -22,21 +22,26 @@ import { useRoute, useRouter } from "vue-router"; const router = useRouter(); const route = useRoute(); -const ordered = computed(() => - router - .getRoutes() - .filter((r) => typeof r.meta?.order === "number") - .sort((a, b) => a.meta.order - b.meta.order) -); +const pages = [ + { name: "index", label: "首页" }, + { name: "about", label: "关于" }, + { name: "sites", label: "网站" }, + { name: "projects", label: "项目" }, + { name: "friends", label: "友链" }, +]; -const currentIndex = computed(() => ordered.value.findIndex((r) => r.name === route.name)); +const currentIndex = computed(() => pages.findIndex((item) => item.name === route.name)); const goPrev = () => { - if (currentIndex.value > 0) router.push({ name: ordered.value[currentIndex.value - 1].name }); + if (currentIndex.value > 0) { + router.push({ name: pages[currentIndex.value - 1].name }); + } }; + const goNext = () => { - if (currentIndex.value < ordered.value.length - 1) - router.push({ name: ordered.value[currentIndex.value + 1].name }); + if (currentIndex.value < pages.length - 1) { + router.push({ name: pages[currentIndex.value + 1].name }); + } }; diff --git a/src/components/StatsSection.vue b/src/components/StatsSection.vue index 281d944..e2c1e93 100644 --- a/src/components/StatsSection.vue +++ b/src/components/StatsSection.vue @@ -53,24 +53,24 @@
{{ - currentWakatimeData.total_seconds ? formatTime(currentWakatimeData.total_seconds) : "N/A" + currentWakatimeData?.total_seconds ? formatTime(currentWakatimeData.total_seconds) : "N/A" }} 总时间
{{ - currentWakatimeData.daily_average ? formatTime(currentWakatimeData.daily_average) : "N/A" + currentWakatimeData?.daily_average ? formatTime(currentWakatimeData.daily_average) : "N/A" }} 日均
- {{ currentWakatimeData.days_including_holidays || "N/A" }} + {{ currentWakatimeData?.days_including_holidays ?? "N/A" }} 活跃天数
-
+

编程语言

语言使用统计 · Languages

@@ -177,13 +177,10 @@ const fetchWakatimeData = async () => { params.append("apiUrl", wakatime.apiUrl); } const url = `/api/wakatime${params.toString() ? `?${params.toString()}` : ""}`; - console.log("Fetching Wakatime data from:", url); const response = await fetch(url); - console.log("Response status:", response.status); if (response.ok) { const data = await response.json(); - console.log("Wakatime data:", data); weeklyData.value = data.weekly; allTimeData.value = data.allTime; statusData.value = data.status; @@ -199,34 +196,6 @@ const fetchWakatimeData = async () => { } } catch (error) { console.error("Failed to fetch Wakatime data:", error); - // 在开发环境中,如果 API 不可用,设置一些示例数据 - if (import.meta.env.DEV) { - console.log("Using mock data for development"); - weeklyData.value = { - total_seconds: 36000, - daily_average: 5142, - days_including_holidays: 7, - languages: [ - { name: "TypeScript", percent: 45.2, total_seconds: 16272 }, - { name: "Vue", percent: 30.1, total_seconds: 10836 }, - { name: "JavaScript", percent: 15.3, total_seconds: 5508 }, - { name: "Python", percent: 9.4, total_seconds: 3384 }, - ], - }; - allTimeData.value = { - total_seconds: 864000, - daily_average: 2800, - days_including_holidays: 308, - languages: [ - { name: "JavaScript", percent: 35.2, total_seconds: 304128 }, - { name: "TypeScript", percent: 28.1, total_seconds: 242688 }, - { name: "Python", percent: 20.3, total_seconds: 175392 }, - { name: "Vue", percent: 10.1, total_seconds: 87296 }, - { name: "CSS", percent: 6.3, total_seconds: 54432 }, - ], - }; - statusData.value = { is_coding: false }; - } } }; diff --git a/src/config/siteConfig.ts b/src/config/siteConfig.ts index 10c2d80..8dcb5ca 100644 --- a/src/config/siteConfig.ts +++ b/src/config/siteConfig.ts @@ -2,7 +2,7 @@ const siteConfig = { profile: { name: "RhenCloud", title: "I'm RhenCloud.", - avatar: "avatar.webp", // public/avatar.webp + avatar: "/avatar.webp", // public/avatar.webp bio: "趁世界还未重启之前 约一次爱恋", birthday: "2010-03-28", // gender: "女", @@ -31,7 +31,7 @@ const siteConfig = { siteMeta: { title: "RhenCloud", - icon: "favicon.svg", // public/favicon.svg + icon: "/favicon.svg", // public/favicon.svg startDate: "2025-12-06", }, @@ -65,6 +65,10 @@ const siteConfig = { autoplay: false, // 是否默认以黑胶唱片状态启动(仅浮动模式) defaultMinimized: true, + // 标签页非激活时是否自动暂停 + autoPause: false, + // Music API 配置 + apiUrls: ["https://www.bilibili.uno/api", "https://meting-api.wangcy.site/api"], }, umami: { diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index eb4b0f3..0000000 --- a/src/main.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createApp } from "vue"; -import { VueUmamiPlugin } from "@jaseeey/vue-umami-plugin"; -import App from "./App.vue"; -import router from "./router"; -import "./styles.css"; -import siteConfig from "./config/siteConfig"; - -const app = createApp(App); - -if (process.env.NODE_ENV !== "development") { - if (siteConfig.umami?.enable) { - app.use(VueUmamiPlugin({ websiteID: siteConfig.umami.websiteId, scriptSrc: siteConfig.umami.url, router })); - } -} - -app.use(router).mount("#app"); diff --git a/src/pages/about.vue b/src/pages/about.vue new file mode 100644 index 0000000..461f44f --- /dev/null +++ b/src/pages/about.vue @@ -0,0 +1,75 @@ + + + diff --git a/src/pages/friends.vue b/src/pages/friends.vue new file mode 100644 index 0000000..b89e7ae --- /dev/null +++ b/src/pages/friends.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/pages/index.vue b/src/pages/index.vue new file mode 100644 index 0000000..a46c7e3 --- /dev/null +++ b/src/pages/index.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/pages/projects.vue b/src/pages/projects.vue new file mode 100644 index 0000000..df4a25c --- /dev/null +++ b/src/pages/projects.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/pages/sites.vue b/src/pages/sites.vue new file mode 100644 index 0000000..c47a1dc --- /dev/null +++ b/src/pages/sites.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/plugins/neteaseMiniPlayer.client.ts b/src/plugins/neteaseMiniPlayer.client.ts new file mode 100644 index 0000000..05a4aca --- /dev/null +++ b/src/plugins/neteaseMiniPlayer.client.ts @@ -0,0 +1,91 @@ +import { defineNuxtPlugin } from "#app"; +import siteConfig from "~/config/siteConfig"; + +export default defineNuxtPlugin(() => { + if (import.meta.server) return; + + // 检查配置是否启用了音乐播放器 + if (!siteConfig.music?.enable) { + return; + } + + // 在本地开发环境禁用网易音乐播放器,避免网络超时 + // if ( + // typeof window !== "undefined" && + // (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") + // ) { + // console.log("Netease Music Player disabled on localhost"); + // return; + // } + + const cssHref = "/css/netease-mini-player-v2.css"; + const scriptSrc = "/js/netease-mini-player-v2.js"; + + const ensureStyle = () => { + if (document.querySelector(`link[href="${cssHref}"]`)) return; + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = cssHref; + link.onerror = () => { + console.warn("Failed to load Netease music player styles"); + }; + document.head.appendChild(link); + }; + + const ensureScript = () => + new Promise((resolve) => { + // 检查全局对象是否已存在,表示脚本已加载 + const anyWin = window as any; + if (anyWin.NeteaseMiniPlayer) { + resolve(); + return; + } + + const existing = document.querySelector(`script[src="${scriptSrc}"]`) as HTMLScriptElement | null; + if (existing) { + // 脚本已存在但未加载,等待它加载 + existing.addEventListener("load", () => resolve(), { once: true }); + existing.addEventListener("error", () => resolve(), { once: true }); + return; + } + + // 脚本不存在,创建并加载 + const script = document.createElement("script"); + script.src = scriptSrc; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => { + console.warn("Failed to load Netease music player script"); + resolve(); + }; + document.body.appendChild(script); + }); + + const initPlayer = () => { + const anyWin = window as any; + + // 将 siteConfig 的音乐配置传递给全局 window 对象 + if (!anyWin.__NETEASE_MUSIC_CONFIG__) { + anyWin.__NETEASE_MUSIC_CONFIG__ = siteConfig.music; + } + + if (anyWin.NeteaseMiniPlayer?.init) { + try { + anyWin.NeteaseMiniPlayer.init(); + } catch (error) { + console.warn("Failed to initialize Netease music player:", error); + } + } + }; + + // 使用超时机制防止永久挂起 + const timeout = setTimeout(() => { + console.warn("Netease music player initialization timeout"); + }, 15000); + + ensureStyle(); + ensureScript().then(() => { + clearTimeout(timeout); + initPlayer(); + }); +}); diff --git a/src/plugins/umami.client.ts b/src/plugins/umami.client.ts new file mode 100644 index 0000000..9fbc97f --- /dev/null +++ b/src/plugins/umami.client.ts @@ -0,0 +1,29 @@ +import { defineNuxtPlugin } from "#app"; +import { VueUmamiPlugin } from "@jaseeey/vue-umami-plugin"; +import type { Router } from "vue-router"; +import siteConfig from "~/config/siteConfig"; + +export default defineNuxtPlugin((nuxtApp) => { + if (!process.client) return; + if (!siteConfig.umami?.enable) return; + + // 跳过在 localhost 环境下加载 Umami + if ( + typeof window !== "undefined" && + (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") + ) { + console.log("Umami plugin skipped on localhost"); + return; + } + + const router = nuxtApp.$router as Router | undefined; + if (!router) return; + + nuxtApp.vueApp.use( + VueUmamiPlugin({ + websiteID: siteConfig.umami.websiteId, + scriptSrc: siteConfig.umami.url, + router, + }) + ); +}); diff --git a/src/router/index.ts b/src/router/index.ts deleted file mode 100644 index 7319b7d..0000000 --- a/src/router/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router"; -import Home from "../views/Home.vue"; -import About from "../views/About.vue"; -import Sites from "../views/Sites.vue"; -import Projects from "../views/Projects.vue"; -import Friends from "../views/Friends.vue"; - -const routes: RouteRecordRaw[] = [ - { path: "/", name: "Home", component: Home, meta: { order: 0, label: "首页" } }, - { path: "/about", name: "About", component: About, meta: { order: 1, label: "关于" } }, - { path: "/sites", name: "Sites", component: Sites, meta: { order: 2, label: "网站" } }, - { path: "/projects", name: "Projects", component: Projects, meta: { order: 3, label: "项目" } }, - { path: "/friends", name: "Friends", component: Friends, meta: { order: 4, label: "友链" } }, -]; - -export default createRouter({ - history: createWebHistory(), - routes, -}); diff --git a/src/server/api/send-mail.ts b/src/server/api/send-mail.ts new file mode 100644 index 0000000..2c75026 --- /dev/null +++ b/src/server/api/send-mail.ts @@ -0,0 +1,96 @@ +import { defineEventHandler, createError, readBody } from "h3"; +import nodemailer from "nodemailer"; +import type SMTPTransport from "nodemailer/lib/smtp-transport"; +import { useRuntimeConfig } from "#imports"; + +type MailConfig = { + smtpHost?: string; + smtpPort?: number | string; + smtpUser?: string; + smtpPass?: string; + senderEmail?: string; + adminEmail?: string; + smtpSecure?: boolean; +}; + +type SendMailPayload = { + name?: string; + url?: string; + desc?: string; + email?: string; + avatar?: string; +}; + +const ensureValue = (value?: string, fallback = "未填写") => (value?.trim() ? value.trim() : fallback); + +export default defineEventHandler(async (event) => { + const method = event.node.req.method; + if (method === "OPTIONS") { + event.node.res.statusCode = 200; + return { status: "ok" }; + } + + if (method !== "POST") { + throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" }); + } + + const payload = (await readBody(event)) || {}; + const { name, url, desc, email, avatar } = payload; + + if (!name?.trim() || !url?.trim() || !email?.trim()) { + throw createError({ + statusCode: 400, + statusMessage: "Missing required fields: name, url, and email", + }); + } + + const config = useRuntimeConfig() as MailConfig; + const { + smtpHost, + smtpPort: configSmtpPort, + smtpUser, + smtpPass, + senderEmail, + adminEmail, + smtpSecure, + } = config; + + const smtpPort = Number(configSmtpPort ?? 465); + if (!smtpHost || !smtpUser || !smtpPass || !senderEmail || !adminEmail) { + throw createError({ statusCode: 500, statusMessage: "SMTP server is not fully configured" }); + } + + const secure = typeof smtpSecure === "boolean" ? smtpSecure : smtpPort === 465; + const smtpOptions: SMTPTransport.Options = { + host: smtpHost, + port: smtpPort, + secure, + auth: { + user: smtpUser, + pass: smtpPass, + }, + }; + + const transporter = nodemailer.createTransport(smtpOptions); + const htmlMessage = ` +

名称:${ensureValue(name)}

+

邮箱:${ensureValue(email)}

+

站点:${ensureValue(url)}

+

描述:${ensureValue(desc)}

+

头像:${ensureValue(avatar)}

+

时间:${new Date().toISOString()}

+ `; + + const info = await transporter.sendMail({ + from: senderEmail, + to: adminEmail, + replyTo: email, + subject: `友链申请 / 联系表单 · ${ensureValue(name)}`, + html: htmlMessage, + }); + + return { + message: "Mail sent", + id: info.messageId, + }; +}); diff --git a/src/server/api/wakatime.ts b/src/server/api/wakatime.ts new file mode 100644 index 0000000..9c97922 --- /dev/null +++ b/src/server/api/wakatime.ts @@ -0,0 +1,56 @@ +import { defineEventHandler, getQuery, createError } from "h3"; +import { useRuntimeConfig } from "#imports"; + +export default defineEventHandler(async (event) => { + const res = event.node.res; + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (event.node.req.method === "OPTIONS") { + res.statusCode = 200; + return "ok"; + } + + if (event.node.req.method !== "GET") { + throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" }); + } + + const config = useRuntimeConfig(); + const apiKey = config.wakatimeApiKey; + if (typeof apiKey !== "string") { + throw createError({ statusCode: 500, statusMessage: "Invalid WakaTime API Key configuration" }); + } + + const query = getQuery(event); + const apiUrl = (query.apiUrl as string) || config.wakatimeApiUrl; + + const headers = { + Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`, + }; + + try { + const [weeklyStatsResponse, allTimeStatsResponse, statusResponse] = await Promise.all([ + fetch(`${apiUrl}/users/current/stats/last_7_days`, { headers }), + fetch(`${apiUrl}/users/current/stats/all_time`, { headers }), + fetch(`${apiUrl}/users/current/status`, { headers }), + ]); + + if (!weeklyStatsResponse.ok) { + throw new Error(`Wakatime API error: ${weeklyStatsResponse.status}`); + } + + const weeklyStatsData = await weeklyStatsResponse.json(); + const allTimeStatsData = allTimeStatsResponse.ok ? await allTimeStatsResponse.json() : null; + const statusData = statusResponse.ok ? await statusResponse.json() : null; + + return { + weekly: weeklyStatsData.data, + allTime: allTimeStatsData ? allTimeStatsData.data : null, + status: statusData, + }; + } catch (error) { + console.error("Wakatime API error:", error); + throw createError({ statusCode: 500, statusMessage: "Failed to fetch Wakatime data" }); + } +}); diff --git a/src/styles.css b/src/styles.css index 8013756..b5e89d4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -15,6 +15,42 @@ body { background: radial-gradient(circle at 20% 20%, #1b2b4b, #0f1629); } +/* Layout shell keeps background and toggle visible even when content is hidden */ + +.app-shell { + position: relative; + min-height: 100vh; + color: #e8eefc; + overflow: hidden; + display: flex; + flex-direction: column; + isolation: isolate; +} + +.background-overlay { + position: fixed; + inset: 0; + pointer-events: none; + z-index: -1; + background-repeat: no-repeat; +} + +.content-stack { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.app-body { + position: relative; + z-index: 1; + flex: 1; + display: flex; + flex-direction: column; +} + a { color: #7cc1ff; text-decoration: none; @@ -106,7 +142,7 @@ p { /* do not force width here — let the player's own minimized/expanded styles control sizing. Only constrain max width as a safety net. */ max-width: calc(100% - 40px) !important; - z-index: 9999 !important; + z-index: 10001 !important; margin: 0 !important; transform: none !important; } @@ -127,7 +163,7 @@ p { bottom: 20px !important; left: 20px !important; right: auto !important; - z-index: 9999 !important; + z-index: 10001 !important; } /* Fix: prevent playlist dropdown from increasing document height @@ -141,7 +177,7 @@ p { width: 290px !important; max-height: 50vh !important; overflow: auto !important; - z-index: 10001 !important; + z-index: 10002 !important; } .netease-mini-player[data-position="top-left"] .playlist-container, @@ -153,7 +189,7 @@ p { width: 290px !important; max-height: 50vh !important; overflow: auto !important; - z-index: 10001 !important; + z-index: 10002 !important; } /* If player is docked to the right, align playlist to right edge */ @@ -189,3 +225,65 @@ p { object-fit: cover !important; border-radius: 50% !important; } + +/* Floating background show/hide toggle (bottom-right) */ +.background-toggle { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 10000; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 14px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.14); + backdrop-filter: blur(10px); + color: #f7fbff; + font-weight: 600; + cursor: pointer; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28); + transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, border-color 0.18s ease; +} + +.background-toggle:hover, +.background-toggle:focus-visible { + background: rgba(124, 193, 255, 0.25); + border-color: rgba(124, 193, 255, 0.65); + box-shadow: 0 14px 36px rgba(124, 193, 255, 0.28); + outline: none; +} + +.background-toggle:active { + transform: translateY(1px) scale(0.99); +} + +.background-toggle .toggle-icon { + font-size: 18px; + line-height: 1; +} + +.background-toggle .toggle-label { + font-size: 14px; + letter-spacing: 0.2px; +} + +.background-toggle.active { + background: linear-gradient(135deg, rgba(124, 193, 255, 0.4), rgba(255, 255, 255, 0.2)); + border-color: rgba(124, 193, 255, 0.8); + color: #0f1629; + box-shadow: 0 16px 42px rgba(124, 193, 255, 0.32); +} + +@media (max-width: 640px) { + .background-toggle { + right: 12px; + bottom: 12px; + padding: 9px 12px; + gap: 5px; + } + .background-toggle .toggle-label { + font-size: 13px; + } +} diff --git a/src/views/About.vue b/src/views/About.vue deleted file mode 100644 index bcd0747..0000000 --- a/src/views/About.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - diff --git a/src/views/Friends.vue b/src/views/Friends.vue deleted file mode 100644 index cdbd8a4..0000000 --- a/src/views/Friends.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/src/views/Home.vue b/src/views/Home.vue deleted file mode 100644 index 420b1e4..0000000 --- a/src/views/Home.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - - - diff --git a/src/views/Projects.vue b/src/views/Projects.vue deleted file mode 100644 index c313fb3..0000000 --- a/src/views/Projects.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/src/views/Sites.vue b/src/views/Sites.vue deleted file mode 100644 index f06bb17..0000000 --- a/src/views/Sites.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/tsconfig.json b/tsconfig.json index 5bd2acc..6b07ed8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,25 +1,7 @@ { + "extends": "./.nuxt/tsconfig.json", "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "preserve", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "types": ["node"] }, - "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], - "references": [{ "path": "./tsconfig.node.json" }] + "include": ["nuxt.config.ts", "src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts", "server/**/*.ts"] } diff --git a/tsconfig.node.json b/tsconfig.node.json index 2f699d8..d02ef0f 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -4,7 +4,8 @@ "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "types": ["node"] }, - "include": ["vite.config.ts"] + "include": ["nuxt.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index c5c15df..0000000 --- a/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vite"; -import vue from "@vitejs/plugin-vue"; - -export default defineConfig({ - plugins: [vue()], - server: { port: 5173 }, -});