Initial commit: QZMusic Web version
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
58
README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# QZMusic Web
|
||||
|
||||
QZMusic 网页版,基于 Vue 3 + TypeScript + Vite 构建的音乐播放器。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🎵 音乐播放控制(播放/暂停、上一首、下一首)
|
||||
- 📜 播放列表管理
|
||||
- 🎨 深色/浅色主题切换
|
||||
- 🎚️ 音量控制
|
||||
- 📊 音频可视化(基于 Web Audio API)
|
||||
- 🔍 搜索功能
|
||||
|
||||
## 开发
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── src/
|
||||
│ ├── assets/ # 静态资源
|
||||
│ ├── components/ # 组件
|
||||
│ ├── layout/ # 布局组件
|
||||
│ ├── stores/ # Pinia 状态管理
|
||||
│ ├── styles/ # 样式文件
|
||||
│ ├── types/ # TypeScript 类型定义
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.ts # 入口文件
|
||||
├── public/ # 公开资源
|
||||
├── index.html # HTML 模板
|
||||
├── vite.config.ts # Vite 配置
|
||||
└── tsconfig.json # TypeScript 配置
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 网页版移除了原 Electron 项目的本地文件系统访问和原生插件功能
|
||||
- 音乐播放使用 HTML5 Audio API
|
||||
- 音频可视化使用 Web Audio API
|
||||
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>QZMusic Web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
5838
package-lock.json
generated
Normal file
44
package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "qzmusic-web",
|
||||
"private": true,
|
||||
"author": "lqtmcstudio",
|
||||
"description": "QZMusic Web - 简洁、美观的网页版音乐播放器",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@applemusic-like-lyrics/core": "^0.2.0",
|
||||
"@applemusic-like-lyrics/lyric": "^0.3.0",
|
||||
"@applemusic-like-lyrics/vue": "^0.2.0",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@pixi/app": "^7.4.3",
|
||||
"@pixi/core": "^7.4.3",
|
||||
"@pixi/display": "^7.4.3",
|
||||
"@pixi/filter-blur": "^7.4.3",
|
||||
"@pixi/filter-bulge-pinch": "^5.1.1",
|
||||
"@pixi/filter-color-matrix": "^7.4.3",
|
||||
"@pixi/sprite": "^7.4.3",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.4",
|
||||
"element-plus": "^2.13.0",
|
||||
"jss": "^10.10.0",
|
||||
"jss-preset-default": "^10.10.0",
|
||||
"pinia": "^3.0.4",
|
||||
"tdesign-vue-next": "^1.18.2",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"sass-embedded": "^1.97.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.6",
|
||||
"vite-plugin-node-polyfills": "^0.25.0",
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
"vue-tsc": "^2.0.26"
|
||||
}
|
||||
}
|
||||
34
public/electron-vite.animate.svg
Normal file
@@ -0,0 +1,34 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M63.9202 127.84C99.2223 127.84 127.84 99.2223 127.84 63.9202C127.84 28.6181 99.2223 0 63.9202 0C28.6181 0 0 28.6181 0 63.9202C0 99.2223 28.6181 127.84 63.9202 127.84Z" fill="url(#paint0_linear_103_2)"/>
|
||||
<g id="not-lightning" clip-path="url(#clip0_103_2)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
from="0 64 64"
|
||||
to="360 64 64"
|
||||
dur="20s"
|
||||
repeatCount="indefinite"/>
|
||||
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
|
||||
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
|
||||
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M63.9835 27.6986C66.4621 27.6986 68.4714 25.6861 68.4714 23.2036C68.4714 20.7211 66.4621 18.7086 63.9835 18.7086C61.5049 18.7086 59.4956 20.7211 59.4956 23.2036C59.4956 25.6861 61.5049 27.6986 63.9835 27.6986Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
</g>
|
||||
<path d="M70.7175 48.0096L56.3133 50.676C56.0766 50.7199 55.9013 50.9094 55.887 51.1369L55.001 65.2742C54.9801 65.6072 55.3038 65.8656 55.6478 65.7907L59.6582 64.9163C60.0334 64.8346 60.3724 65.1468 60.2953 65.5033L59.1038 71.0151C59.0237 71.386 59.3923 71.7032 59.7758 71.5932L62.2528 70.8822C62.6368 70.7721 63.0057 71.0902 62.9245 71.4615L61.031 80.1193C60.9126 80.6608 61.6751 80.9561 61.9931 80.4918L62.2055 80.1817L73.9428 58.053C74.1393 57.6825 73.8004 57.26 73.3696 57.3385L69.2417 58.0912C68.8538 58.1618 68.5237 57.8206 68.6332 57.462L71.3274 48.6385C71.437 48.2794 71.1058 47.9378 70.7175 48.0096Z" fill="url(#paint1_linear_103_2)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_103_2" x1="1.43824" y1="7.91009" x2="56.3296" y2="82.4569" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#41D1FF"/>
|
||||
<stop offset="1" stop-color="#BD34FE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_103_2" x1="60.3173" y1="48.7336" x2="64.237" y2="77.1962" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFEA83"/>
|
||||
<stop offset="0.0833333" stop-color="#FFDD35"/>
|
||||
<stop offset="1" stop-color="#FFA800"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_103_2">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
26
public/electron-vite.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_103_2)">
|
||||
<path d="M63.9202 127.84C99.2223 127.84 127.84 99.2223 127.84 63.9202C127.84 28.6181 99.2223 0 63.9202 0C28.6181 0 0 28.6181 0 63.9202C0 99.2223 28.6181 127.84 63.9202 127.84Z" fill="url(#paint0_linear_103_2)"/>
|
||||
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
|
||||
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
|
||||
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M63.9835 27.6986C66.4621 27.6986 68.4714 25.6861 68.4714 23.2036C68.4714 20.7211 66.4621 18.7086 63.9835 18.7086C61.5049 18.7086 59.4956 20.7211 59.4956 23.2036C59.4956 25.6861 61.5049 27.6986 63.9835 27.6986Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M70.7175 48.0096L56.3133 50.676C56.0766 50.7199 55.9013 50.9094 55.887 51.1369L55.001 65.2742C54.9801 65.6072 55.3038 65.8656 55.6478 65.7907L59.6582 64.9163C60.0334 64.8346 60.3724 65.1468 60.2953 65.5033L59.1038 71.0151C59.0237 71.386 59.3923 71.7032 59.7758 71.5932L62.2528 70.8822C62.6368 70.7721 63.0057 71.0902 62.9245 71.4615L61.031 80.1193C60.9126 80.6608 61.6751 80.9561 61.9931 80.4918L62.2055 80.1817L73.9428 58.053C74.1393 57.6825 73.8004 57.26 73.3696 57.3385L69.2417 58.0912C68.8538 58.1618 68.5237 57.8206 68.6332 57.462L71.3274 48.6385C71.437 48.2794 71.1058 47.9378 70.7175 48.0096Z" fill="url(#paint1_linear_103_2)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_103_2" x1="1.43824" y1="7.91009" x2="56.3296" y2="82.4569" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#41D1FF"/>
|
||||
<stop offset="1" stop-color="#BD34FE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_103_2" x1="60.3173" y1="48.7336" x2="64.237" y2="77.1962" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFEA83"/>
|
||||
<stop offset="0.0833333" stop-color="#FFDD35"/>
|
||||
<stop offset="1" stop-color="#FFA800"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_103_2">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
30
src/App.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<MainLayout />
|
||||
<FullScreenPlayer />
|
||||
<Settings v-if="showSettings" @close="showSettings = false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, provide, onMounted } from 'vue';
|
||||
import MainLayout from './layout/MainLayout.vue';
|
||||
import Settings from './components/Settings.vue';
|
||||
import FullScreenPlayer from './components/FullScreenPlayer.vue';
|
||||
|
||||
const showSettings = ref(false);
|
||||
|
||||
// Provide to child components
|
||||
provide('openSettings', () => { showSettings.value = true; });
|
||||
|
||||
// Apply saved theme on app startup
|
||||
onMounted(async () => {
|
||||
if (window.electronAPI?.settings) {
|
||||
const settings = await window.electronAPI.settings.getAll();
|
||||
document.documentElement.setAttribute('data-theme', settings.theme);
|
||||
document.documentElement.style.setProperty('--color-accent', settings.accentColor);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "styles/main.css";
|
||||
</style>
|
||||
5
src/assets/airplay.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M11.8633 31.8984C11.8633 29.138 12.3841 26.5469 13.4258 24.125C14.4674 21.6901 15.9128 19.5482 17.7617 17.6992C19.6107 15.8372 21.7526 14.3854 24.1875 13.3438C26.6224 12.2891 29.2266 11.7617 32 11.7617C34.7734 11.7617 37.3776 12.2891 39.8125 13.3438C42.2474 14.3854 44.3893 15.8372 46.2383 17.6992C48.0872 19.5482 49.5326 21.6901 50.5742 24.125C51.6159 26.5469 52.1367 29.138 52.1367 31.8984C52.1367 34.6719 51.6029 37.2826 50.5352 39.7305C49.4674 42.1784 48.0156 44.3073 46.1797 46.1172C46.0365 46.2604 45.8802 46.332 45.7109 46.332C45.5547 46.332 45.4049 46.2539 45.2617 46.0977L44.3438 45.0625C44.0964 44.763 44.1094 44.4635 44.3828 44.1641C45.9453 42.6016 47.1823 40.7656 48.0938 38.6562C49.0182 36.5339 49.4805 34.2812 49.4805 31.8984C49.4805 29.5026 49.0247 27.2565 48.1133 25.1602C47.2018 23.0508 45.9388 21.1953 44.3242 19.5938C42.7227 17.9792 40.8672 16.7161 38.7578 15.8047C36.6484 14.8932 34.3958 14.4375 32 14.4375C29.6042 14.4375 27.3516 14.8932 25.2422 15.8047C23.1328 16.7161 21.2708 17.9792 19.6562 19.5938C18.0547 21.1953 16.7982 23.0508 15.8867 25.1602C14.9753 27.2565 14.5195 29.5026 14.5195 31.8984C14.5195 34.2812 14.9753 36.5273 15.8867 38.6367C16.7982 40.7461 18.0417 42.5885 19.6172 44.1641C19.8776 44.4635 19.8841 44.7565 19.6367 45.043L18.7188 46.0781C18.5885 46.2344 18.4323 46.3125 18.25 46.3125C18.0807 46.3125 17.9245 46.2409 17.7812 46.0977C15.9583 44.2878 14.513 42.1654 13.4453 39.7305C12.3906 37.2826 11.8633 34.6719 11.8633 31.8984ZM17.5469 31.8984C17.5469 29.9193 17.918 28.0573 18.6602 26.3125C19.4154 24.5677 20.457 23.0312 21.7852 21.7031C23.1133 20.375 24.6497 19.3333 26.3945 18.5781C28.1393 17.8229 30.0078 17.4453 32 17.4453C33.9792 17.4453 35.8411 17.8229 37.5859 18.5781C39.3438 19.3333 40.8867 20.375 42.2148 21.7031C43.543 23.0312 44.5781 24.5677 45.3203 26.3125C46.0755 28.0573 46.4531 29.9193 46.4531 31.8984C46.4531 33.8255 46.0885 35.6419 45.3594 37.3477C44.6432 39.0404 43.6667 40.5312 42.4297 41.8203C42.2865 41.9766 42.1237 42.0547 41.9414 42.0547C41.7721 42.0547 41.6224 41.9766 41.4922 41.8203L40.5547 40.7852C40.3073 40.5117 40.3073 40.2122 40.5547 39.8867C41.5573 38.8581 42.3451 37.6602 42.918 36.293C43.4909 34.9128 43.7773 33.4479 43.7773 31.8984C43.7773 30.2839 43.4714 28.7669 42.8594 27.3477C42.2474 25.9284 41.3945 24.6784 40.3008 23.5977C39.2201 22.5169 37.9701 21.6706 36.5508 21.0586C35.1315 20.4336 33.6146 20.1211 32 20.1211C30.3724 20.1211 28.849 20.4336 27.4297 21.0586C26.0104 21.6706 24.7604 22.5169 23.6797 23.5977C22.599 24.6784 21.7526 25.9284 21.1406 27.3477C20.5286 28.7669 20.2227 30.2839 20.2227 31.8984C20.2227 33.4349 20.5026 34.8932 21.0625 36.2734C21.6354 37.6536 22.4232 38.8581 23.4258 39.8867C23.6732 40.2122 23.6732 40.5117 23.4258 40.7852L22.5078 41.8008C22.3776 41.957 22.2214 42.0417 22.0391 42.0547C21.8568 42.0547 21.694 41.9701 21.5508 41.8008C20.3138 40.5117 19.3372 39.0208 18.6211 37.3281C17.9049 35.6224 17.5469 33.8125 17.5469 31.8984ZM23.2305 31.8984C23.2305 30.2969 23.6276 28.832 24.4219 27.5039C25.2161 26.1758 26.2708 25.1146 27.5859 24.3203C28.9141 23.526 30.3854 23.1289 32 23.1289C33.6146 23.1289 35.0794 23.526 36.3945 24.3203C37.7227 25.1146 38.7839 26.1758 39.5781 27.5039C40.3724 28.832 40.7695 30.2969 40.7695 31.8984C40.7695 32.9661 40.5807 33.9753 40.2031 34.9258C39.8255 35.8633 39.3047 36.7031 38.6406 37.4453C38.5104 37.6276 38.3542 37.7188 38.1719 37.7188C38.0026 37.7188 37.8398 37.6406 37.6836 37.4844L36.7266 36.4688C36.4792 36.2214 36.4596 35.9414 36.668 35.6289C37.1237 35.1341 37.4753 34.5677 37.7227 33.9297C37.9701 33.2917 38.0938 32.6146 38.0938 31.8984C38.0938 30.7917 37.8138 29.776 37.2539 28.8516C36.707 27.9271 35.9714 27.1914 35.0469 26.6445C34.1224 26.0846 33.1068 25.8047 32 25.8047C30.8932 25.8047 29.8776 26.0846 28.9531 26.6445C28.0286 27.1914 27.2865 27.9271 26.7266 28.8516C26.1797 29.776 25.9062 30.7917 25.9062 31.8984C25.9062 32.6016 26.0299 33.2721 26.2773 33.9102C26.5247 34.5482 26.8698 35.1146 27.3125 35.6094C27.5078 35.9219 27.4883 36.2018 27.2539 36.4492L26.2969 37.4648C26.1406 37.6211 25.9714 37.6992 25.7891 37.6992C25.6198 37.6992 25.4701 37.6146 25.3398 37.4453C24.6758 36.7031 24.1549 35.8568 23.7773 34.9062C23.4128 33.9557 23.2305 32.9531 23.2305 31.8984ZM19.8906 51.3711C19.4089 51.3711 19.0898 51.1758 18.9336 50.7852C18.7773 50.3945 18.8555 50.0299 19.168 49.6914L31.1016 36.1758C31.3359 35.9023 31.6289 35.7656 31.9805 35.7656C32.332 35.7656 32.625 35.9023 32.8594 36.1758L44.8125 49.6914C45.112 50.0299 45.1836 50.3945 45.0273 50.7852C44.8711 51.1758 44.5586 51.3711 44.0898 51.3711H19.8906Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
12
src/assets/icon_forward.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg id="vector" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 134 134"
|
||||
fill="currentColor" color="#fff">
|
||||
<path
|
||||
d="M62 60.0717C65.938 62.3453 67.9069 63.4821 68.5677 64.9662C69.1441 66.2608 69.1441 67.7391 68.5677 69.0336C67.9069 70.5177 65.938 71.6545 62 73.9281L41 86.0525C37.062 88.326 35.0931 89.4628 33.4774 89.293C32.0681 89.1449 30.7878 88.4057 29.9549 87.2593C29 85.945 29 83.6714 29 79.1243V54.8755C29 50.3284 29 48.0548 29.9549 46.7405C30.7878 45.5941 32.0681 44.8549 33.4774 44.7068C35.0931 44.537 37.062 45.6738 41 47.9473L62 60.0717Z"
|
||||
class="amll-forward-left-arrow"></path>
|
||||
<path
|
||||
d="M62 60.0717C65.938 62.3453 67.9069 63.4821 68.5677 64.9662C69.1441 66.2608 69.1441 67.7391 68.5677 69.0336C67.9069 70.5177 65.938 71.6545 62 73.9281L41 86.0525C37.062 88.326 35.0931 89.4628 33.4774 89.293C32.0681 89.1449 30.7878 88.4057 29.9549 87.2593C29 85.945 29 83.6714 29 79.1243V54.8755C29 50.3284 29 48.0548 29.9549 46.7405C30.7878 45.5941 32.0681 44.8549 33.4774 44.7068C35.0931 44.537 37.062 45.6738 41 47.9473L62 60.0717Z"
|
||||
class="amll-forward-left-standby"></path>
|
||||
<path
|
||||
d="M102 60.0717C105.938 62.3453 107.907 63.4821 108.568 64.9662C109.144 66.2608 109.144 67.7391 108.568 69.0336C107.907 70.5177 105.938 71.6545 102 73.9281L81 86.0525C77.062 88.326 75.0931 89.4628 73.4774 89.293C72.0681 89.1449 70.7878 88.4057 69.9549 87.2593C69 85.945 69 83.6714 69 79.1243V54.8755C69 50.3284 69 48.0548 69.9549 46.7405C70.7878 45.5941 72.0681 44.8549 73.4774 44.7068C75.0931 44.537 77.062 45.6738 81 47.9473L102 60.0717Z"
|
||||
class="amll-forward-right-arrow"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
5
src/assets/icon_pause.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg id="vector" width="38" height="38" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg" color="#fff">
|
||||
<path
|
||||
d="M8.46953 37C7.37801 37 6.56603 36.7271 6.03359 36.1814C5.51445 35.6489 5.25488 34.8502 5.25488 33.7854V4.21464C5.25488 3.14975 5.52111 2.35108 6.05355 1.81864C6.59931 1.27288 7.40463 1 8.46953 1H13.3813C14.4329 1 15.2249 1.27288 15.7574 1.81864C16.3031 2.35108 16.576 3.14975 16.576 4.21464V33.7854C16.576 34.8502 16.3031 35.6489 15.7574 36.1814C15.2249 36.7271 14.4329 37 13.3813 37H8.46953ZM24.6426 37C23.5644 37 22.759 36.7271 22.2266 36.1814C21.6942 35.6489 21.4279 34.8502 21.4279 33.7854V4.21464C21.4279 3.14975 21.6942 2.35108 22.2266 1.81864C22.7724 1.27288 23.5777 1 24.6426 1H29.5544C30.6193 1 31.4179 1.27288 31.9504 1.81864C32.4828 2.35108 32.7491 3.14975 32.7491 4.21464V33.7854C32.7491 34.8502 32.4828 35.6489 31.9504 36.1814C31.4179 36.7271 30.6193 37 29.5544 37H24.6426Z"
|
||||
fill="currentColor" fill-rule="nonzero" id="path_0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 989 B |
5
src/assets/icon_play.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg id="vector" width="38" height="38" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg" color="#fff">
|
||||
<path
|
||||
d="M5.80762 32.4896V5.4925C5.80762 4.305 6.12305 3.41438 6.75391 2.82063C7.38477 2.22688 8.13932 1.93 9.01758 1.93C9.78451 1.93 10.5391 2.14029 11.2812 2.56086L33.7324 15.6605C34.5859 16.1553 35.223 16.6562 35.6436 17.1634C36.0641 17.6582 36.2744 18.2705 36.2744 19.0003C36.2744 19.7054 36.0641 20.3177 35.6436 20.8372C35.223 21.3444 34.5859 21.8392 33.7324 22.3216L11.2812 35.4212C10.5391 35.8542 9.78451 36.0706 9.01758 36.0706C8.13932 36.0706 7.38477 35.7676 6.75391 35.1614C6.12305 34.5677 5.80762 33.6771 5.80762 32.4896Z"
|
||||
fill="currentColor" fill-rule="nonzero"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 713 B |
12
src/assets/icon_rewind.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg id="vector" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 134 134"
|
||||
fill="currentColor" color="#fff">
|
||||
<path
|
||||
d="M72 60.0717C68.062 62.3453 66.0931 63.4821 65.4323 64.9662C64.8559 66.2608 64.8559 67.7391 65.4323 69.0336C66.0931 70.5177 68.062 71.6545 72 73.9281L93 86.0525C96.938 88.326 98.9069 89.4628 100.523 89.293C101.932 89.1449 103.212 88.4057 104.045 87.2593C105 85.945 105 83.6714 105 79.1243V54.8755C105 50.3284 105 48.0548 104.045 46.7405C103.212 45.5941 101.932 44.8549 100.523 44.7068C98.9069 44.537 96.938 45.6738 93 47.9473L72 60.0717Z"
|
||||
class="amll-rewind-right-arrow" />
|
||||
<path
|
||||
d="M72 60.0717C68.062 62.3453 66.0931 63.4821 65.4323 64.9662C64.8559 66.2608 64.8559 67.7391 65.4323 69.0336C66.0931 70.5177 68.062 71.6545 72 73.9281L93 86.0525C96.938 88.326 98.9069 89.4628 100.523 89.293C101.932 89.1449 103.212 88.4057 104.045 87.2593C105 85.945 105 83.6714 105 79.1243V54.8755C105 50.3284 105 48.0548 104.045 46.7405C103.212 45.5941 101.932 44.8549 100.523 44.7068C98.9069 44.537 96.938 45.6738 93 47.9473L72 60.0717Z"
|
||||
class="amll-rewind-right-standby" />
|
||||
<path
|
||||
d="M32 60.0717C28.062 62.3453 26.0931 63.4821 25.4323 64.9662C24.8559 66.2608 24.8559 67.7391 25.4323 69.0336C26.0931 70.5177 28.062 71.6545 32 73.9281L53 86.0525C56.938 88.326 58.9069 89.4628 60.5226 89.293C61.9319 89.1449 63.2122 88.4057 64.0451 87.2593C65 85.945 65 83.6714 65 79.1243V54.8755C65 50.3284 65 48.0548 64.0451 46.7405C63.2122 45.5941 61.9319 44.8549 60.5226 44.7068C58.9069 44.537 56.938 45.6738 53 47.9473L32 60.0717Z"
|
||||
class="amll-rewind-left-arrow" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
5
src/assets/icon_speaker.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="40" viewBox="0 0 32 40" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M14.9042 27.1802C14.4202 27.1802 14.0473 26.9897 13.595 26.5612L10.3815 23.5461C10.3339 23.5065 10.2863 23.4906 10.2228 23.4906H8.01703C6.70778 23.4906 5.99365 22.7527 5.99365 21.38V18.4442C5.99365 17.0715 6.70778 16.3257 8.01703 16.3257H10.2307C10.2863 16.3257 10.3418 16.3019 10.3815 16.2622L13.595 13.2709C14.079 12.8107 14.4361 12.6282 14.8883 12.6282C15.6104 12.6282 16.142 13.1915 16.142 13.8977V25.9344C16.142 26.6406 15.6104 27.1802 14.9042 27.1802Z"
|
||||
class="speaker-bounce-1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 628 B |
14
src/assets/icon_speaker_3.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="43" height="40" viewBox="0 0 43 40" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M24.0403 27.1802C23.5642 27.1802 23.1913 26.9897 22.739 26.5612L19.5176 23.5461C19.4779 23.5065 19.4224 23.4906 19.3668 23.4906H17.161C15.8518 23.4906 15.1377 22.7527 15.1377 21.38V18.4442C15.1377 17.0715 15.8518 16.3257 17.161 16.3257H19.3668C19.4303 16.3257 19.4779 16.3019 19.5255 16.2622L22.739 13.2709C23.223 12.8107 23.5721 12.6282 24.0324 12.6282C24.7544 12.6282 25.286 13.1915 25.286 13.8977V25.9344C25.286 26.6406 24.7544 27.1802 24.0403 27.1802Z"
|
||||
class="speaker-bounce-1"/>
|
||||
<path
|
||||
d="M28.0948 23.6653C27.6028 23.3559 27.4996 22.7687 27.8964 22.1101C28.2931 21.4991 28.5232 20.7136 28.5232 19.8964C28.5232 19.0712 28.301 18.2856 27.8964 17.6826C27.4917 17.032 27.6028 16.4369 28.0948 16.1274C28.547 15.8418 29.1104 15.9529 29.404 16.3576C30.0863 17.3097 30.491 18.5713 30.491 19.8964C30.491 21.2214 30.0863 22.4831 29.404 23.4273C29.1104 23.8399 28.547 23.943 28.0948 23.6653Z"
|
||||
class="speaker-bounce-2"/>
|
||||
<path
|
||||
d="M31.6733 25.8711C31.1576 25.5696 31.0942 24.9428 31.4432 24.3794C32.2526 23.1257 32.7207 21.5468 32.7207 19.8964C32.7207 18.2459 32.2605 16.6591 31.4432 15.4133C31.0942 14.8499 31.1576 14.2231 31.6733 13.9137C32.1415 13.6439 32.7128 13.755 33.0143 14.2152C34.0855 15.7783 34.6885 17.8016 34.6885 19.8964C34.6885 21.9911 34.0775 23.9985 33.0143 25.5775C32.7128 26.0377 32.1415 26.1488 31.6733 25.8711Z"
|
||||
class="speaker-bounce-3"/>
|
||||
<path
|
||||
d="M35.2362 28.1007C34.7363 27.7992 34.6569 27.1803 34.9981 26.6249C36.1883 24.7286 36.9104 22.4196 36.9104 19.9122C36.9104 17.397 36.1883 15.0881 34.9981 13.1917C34.6569 12.6362 34.7363 12.0174 35.2362 11.7159C35.7123 11.4302 36.3073 11.5651 36.6088 12.0571C38.0133 14.2866 38.8702 16.9765 38.8702 19.9122C38.8702 22.8401 38.0291 25.5379 36.6088 27.7675C36.3073 28.2515 35.7123 28.3864 35.2362 28.1007Z"
|
||||
class="speaker-bounce-4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/lyricfont.ttf
Normal file
5
src/assets/lyrics_off.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M22.8594 53.9102C22 53.9102 21.3229 53.6302 20.8281 53.0703C20.3464 52.5104 20.1055 51.7617 20.1055 50.8242V46.1953H18.9727C17.1237 46.1953 15.5156 45.8242 14.1484 45.082C12.7812 44.3398 11.7201 43.2721 10.9648 41.8789C10.2227 40.4857 9.85156 38.8125 9.85156 36.8594V21.5664C9.85156 19.6133 10.2161 17.9401 10.9453 16.5469C11.6875 15.1536 12.7552 14.0859 14.1484 13.3438C15.5417 12.5885 17.2214 12.2109 19.1875 12.2109H44.793C46.7721 12.2109 48.4518 12.5885 49.832 13.3438C51.2253 14.0859 52.2865 15.1536 53.0156 16.5469C53.7578 17.9401 54.1289 19.6133 54.1289 21.5664V36.8594C54.1289 38.8125 53.7578 40.4857 53.0156 41.8789C52.2865 43.2721 51.2253 44.3398 49.832 45.082C48.4518 45.8242 46.7721 46.1953 44.793 46.1953H33.0742L26.4336 52.0547C25.7044 52.6927 25.0729 53.1615 24.5391 53.4609C24.0182 53.7604 23.4583 53.9102 22.8594 53.9102ZM23.9141 49.0469L30.0859 42.9727C30.5156 42.5299 30.9193 42.237 31.2969 42.0938C31.6745 41.9375 32.1758 41.8594 32.8008 41.8594H44.5977C46.3424 41.8594 47.6445 41.4232 48.5039 40.5508C49.3633 39.6784 49.793 38.3828 49.793 36.6641V21.7422C49.793 20.0365 49.3633 18.7474 48.5039 17.875C47.6445 17.0026 46.3424 16.5664 44.5977 16.5664H19.3828C17.625 16.5664 16.3164 17.0026 15.457 17.875C14.6107 18.7474 14.1875 20.0365 14.1875 21.7422V36.6641C14.1875 38.3828 14.6107 39.6784 15.457 40.5508C16.3164 41.4232 17.625 41.8594 19.3828 41.8594H22.2344C22.8073 41.8594 23.2305 41.9896 23.5039 42.25C23.7773 42.4974 23.9141 42.9271 23.9141 43.5391V49.0469ZM22.4492 27.1914C22.4492 25.9935 22.8529 25.0104 23.6602 24.2422C24.4674 23.474 25.4701 23.0898 26.668 23.0898C28.0221 23.0898 29.1029 23.5716 29.9102 24.5352C30.7305 25.4857 31.1406 26.6576 31.1406 28.0508C31.1406 29.2096 30.9323 30.2383 30.5156 31.1367C30.112 32.0221 29.5911 32.7708 28.9531 33.3828C28.3281 33.9948 27.6771 34.457 27 34.7695C26.3229 35.082 25.7174 35.2383 25.1836 35.2383C24.8841 35.2383 24.6367 35.1536 24.4414 34.9844C24.2461 34.8151 24.1484 34.5938 24.1484 34.3203C24.1484 34.0859 24.2135 33.8906 24.3438 33.7344C24.487 33.5651 24.7214 33.4414 25.0469 33.3633C25.5677 33.2331 26.0625 33.0312 26.5312 32.7578C27.013 32.4714 27.4362 32.1328 27.8008 31.7422C28.1654 31.3385 28.4453 30.8828 28.6406 30.375H28.3867C28.1263 30.7005 27.7943 30.9284 27.3906 31.0586C26.987 31.1758 26.5573 31.2344 26.1016 31.2344C25.0078 31.2344 24.1224 30.8503 23.4453 30.082C22.7812 29.3008 22.4492 28.3372 22.4492 27.1914ZM33.0742 27.1914C33.0742 25.9935 33.4714 25.0104 34.2656 24.2422C35.0729 23.474 36.082 23.0898 37.293 23.0898C38.6471 23.0898 39.7279 23.5716 40.5352 24.5352C41.3555 25.4857 41.7656 26.6576 41.7656 28.0508C41.7656 29.2096 41.5573 30.2383 41.1406 31.1367C40.737 32.0221 40.2161 32.7708 39.5781 33.3828C38.9531 33.9948 38.2956 34.457 37.6055 34.7695C36.9284 35.082 36.3294 35.2383 35.8086 35.2383C35.5091 35.2383 35.2617 35.1536 35.0664 34.9844C34.8711 34.8151 34.7734 34.5938 34.7734 34.3203C34.7734 34.0859 34.8385 33.8906 34.9688 33.7344C35.112 33.5651 35.3529 33.4414 35.6914 33.3633C36.1992 33.2331 36.6875 33.0312 37.1562 32.7578C37.638 32.4714 38.0612 32.1328 38.4258 31.7422C38.7904 31.3385 39.0703 30.8828 39.2656 30.375H39.0117C38.7513 30.7005 38.4193 30.9284 38.0156 31.0586C37.612 31.1758 37.1823 31.2344 36.7266 31.2344C35.6328 31.2344 34.7474 30.8503 34.0703 30.082C33.4062 29.3008 33.0742 28.3372 33.0742 27.1914Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
8
src/assets/lyrics_on.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M1.91256 7.67068C0 11.0858 0 15.6405 0 24.75V39.25C0 48.3595 0 52.9142 1.91256 56.3293C3.26425 58.7429 5.25707 60.7357 7.67068 62.0874C11.0858 64 15.6405 64 24.75 64H39.25C48.3595 64 52.9142 64 56.3293 62.0874C58.7429 60.7357 60.7357 58.7429 62.0874 56.3293C64 52.9142 64 48.3595 64 39.25V24.75C64 15.6405 64 11.0858 62.0874 7.67068C60.7357 5.25707 58.7429 3.26425 56.3293 1.91256C52.9142 0 48.3595 0 39.25 0H24.75C15.6405 0 11.0858 0 7.67068 1.91256C5.25707 3.26425 3.26425 5.25707 1.91256 7.67068ZM20.8281 53.0703C21.3229 53.6302 22 53.9102 22.8594 53.9102C23.4583 53.9102 24.0182 53.7604 24.5391 53.4609C25.0729 53.1615 25.7044 52.6927 26.4336 52.0547L33.0742 46.1953H44.793C46.7721 46.1953 48.4518 45.8242 49.832 45.082C51.2253 44.3398 52.2865 43.2721 53.0156 41.8789C53.7578 40.4857 54.1289 38.8125 54.1289 36.8594V21.5664C54.1289 19.6133 53.7578 17.9401 53.0156 16.5469C52.2865 15.1536 51.2253 14.0859 49.832 13.3438C48.4518 12.5885 46.7721 12.2109 44.793 12.2109H19.1875C17.2214 12.2109 15.5417 12.5885 14.1484 13.3438C12.7552 14.0859 11.6875 15.1536 10.9453 16.5469C10.2161 17.9401 9.85156 19.6133 9.85156 21.5664V36.8594C9.85156 38.8125 10.2227 40.4857 10.9648 41.8789C11.7201 43.2721 12.7812 44.3398 14.1484 45.082C15.5156 45.8242 17.1237 46.1953 18.9727 46.1953H20.1055V50.8242C20.1055 51.7617 20.3464 52.5104 20.8281 53.0703Z"
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M22.4492 27.1914C22.4492 25.9935 22.8529 25.0104 23.6602 24.2422C24.4674 23.474 25.4701 23.0898 26.668 23.0898C28.0221 23.0898 29.1029 23.5716 29.9102 24.5352C30.7305 25.4857 31.1406 26.6576 31.1406 28.0508C31.1406 29.2096 30.9323 30.2383 30.5156 31.1367C30.112 32.0221 29.5911 32.7708 28.9531 33.3828C28.3281 33.9948 27.6771 34.457 27 34.7695C26.3229 35.082 25.7174 35.2383 25.1836 35.2383C24.8841 35.2383 24.6367 35.1536 24.4414 34.9844C24.2461 34.8151 24.1484 34.5938 24.1484 34.3203C24.1484 34.0859 24.2135 33.8906 24.3438 33.7344C24.487 33.5651 24.7214 33.4414 25.0469 33.3633C25.5677 33.2331 26.0625 33.0312 26.5312 32.7578C27.013 32.4714 27.4362 32.1328 27.8008 31.7422C28.1654 31.3385 28.4453 30.8828 28.6406 30.375H28.3867C28.1263 30.7005 27.7943 30.9284 27.3906 31.0586C26.987 31.1758 26.5573 31.2344 26.1016 31.2344C25.0078 31.2344 24.1224 30.8503 23.4453 30.082C22.7812 29.3008 22.4492 28.3372 22.4492 27.1914ZM33.0742 27.1914C33.0742 25.9935 33.4714 25.0104 34.2656 24.2422C35.0729 23.474 36.082 23.0898 37.293 23.0898C38.6471 23.0898 39.7279 23.5716 40.5352 24.5352C41.3555 25.4857 41.7656 26.6576 41.7656 28.0508C41.7656 29.2096 41.5573 30.2383 41.1406 31.1367C40.737 32.0221 40.2161 32.7708 39.5781 33.3828C38.9531 33.9948 38.2956 34.457 37.6055 34.7695C36.9284 35.082 36.3294 35.2383 35.8086 35.2383C35.5091 35.2383 35.2617 35.1536 35.0664 34.9844C34.8711 34.8151 34.7734 34.5938 34.7734 34.3203C34.7734 34.0859 34.8385 33.8906 34.9688 33.7344C35.112 33.5651 35.3529 33.4414 35.6914 33.3633C36.1992 33.2331 36.6875 33.0312 37.1562 32.7578C37.638 32.4714 38.0612 32.1328 38.4258 31.7422C38.7904 31.3385 39.0703 30.8828 39.2656 30.375H39.0117C38.7513 30.7005 38.4193 30.9284 38.0156 31.0586C37.612 31.1758 37.1823 31.2344 36.7266 31.2344C35.6328 31.2344 34.7474 30.8503 34.0703 30.082C33.4062 29.3008 33.0742 28.3372 33.0742 27.1914Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
5
src/assets/playlist_off.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M23.9922 21.8594C23.4062 21.8594 22.9115 21.6641 22.5078 21.2734C22.1172 20.8698 21.9219 20.375 21.9219 19.7891C21.9219 19.2161 22.1172 18.7279 22.5078 18.3242C22.9115 17.9206 23.4062 17.7188 23.9922 17.7188H50.418C50.9909 17.7188 51.4792 17.9206 51.8828 18.3242C52.2865 18.7279 52.4883 19.2161 52.4883 19.7891C52.4883 20.375 52.2865 20.8698 51.8828 21.2734C51.4792 21.6641 50.9909 21.8594 50.418 21.8594H23.9922ZM23.9922 33.9883C23.4062 33.9883 22.9115 33.7865 22.5078 33.3828C22.1172 32.9792 21.9219 32.4909 21.9219 31.918C21.9219 31.3451 22.1172 30.8633 22.5078 30.4727C22.9115 30.069 23.4062 29.8672 23.9922 29.8672H50.418C50.9909 29.8672 51.4792 30.069 51.8828 30.4727C52.2865 30.8633 52.4883 31.3451 52.4883 31.918C52.4883 32.5039 52.2865 32.9987 51.8828 33.4023C51.4792 33.793 50.9909 33.9883 50.418 33.9883H23.9922ZM23.9922 46.1172C23.4062 46.1172 22.9115 45.9219 22.5078 45.5312C22.1172 45.1276 21.9219 44.6328 21.9219 44.0469C21.9219 43.474 22.1172 42.9857 22.5078 42.582C22.9115 42.1784 23.4062 41.9766 23.9922 41.9766H50.418C50.9909 41.9766 51.4792 42.1784 51.8828 42.582C52.2865 42.9857 52.4883 43.474 52.4883 44.0469C52.4883 44.6328 52.2865 45.1276 51.8828 45.5312C51.4792 45.9219 50.9909 46.1172 50.418 46.1172H23.9922ZM14.4805 22.7383C13.6602 22.7383 12.957 22.4518 12.3711 21.8789C11.7982 21.306 11.5117 20.6094 11.5117 19.7891C11.5117 18.9688 11.7982 18.2721 12.3711 17.6992C12.957 17.1263 13.6602 16.8398 14.4805 16.8398C15.2878 16.8398 15.9844 17.1263 16.5703 17.6992C17.1562 18.2721 17.4492 18.9688 17.4492 19.7891C17.4492 20.6094 17.1562 21.306 16.5703 21.8789C15.9844 22.4518 15.2878 22.7383 14.4805 22.7383ZM14.4805 34.8867C13.6602 34.8867 12.957 34.5938 12.3711 34.0078C11.7982 33.4219 11.5117 32.7253 11.5117 31.918C11.5117 31.1107 11.7982 30.4141 12.3711 29.8281C12.957 29.2422 13.6602 28.9492 14.4805 28.9492C15.2878 28.9492 15.9844 29.2422 16.5703 29.8281C17.1562 30.4141 17.4492 31.1107 17.4492 31.918C17.4492 32.7253 17.1562 33.4219 16.5703 34.0078C15.9844 34.5938 15.2878 34.8867 14.4805 34.8867ZM14.4805 47.0156C13.6602 47.0156 12.957 46.7227 12.3711 46.1367C11.7982 45.5638 11.5117 44.8672 11.5117 44.0469C11.5117 43.2266 11.7982 42.5299 12.3711 41.957C12.957 41.3841 13.6602 41.0977 14.4805 41.0977C15.2878 41.0977 15.9844 41.3841 16.5703 41.957C17.1562 42.5299 17.4492 43.2266 17.4492 44.0469C17.4492 44.8672 17.1562 45.5638 16.5703 46.1367C15.9844 46.7227 15.2878 47.0156 14.4805 47.0156Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
5
src/assets/playlist_on.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M1.91256 7.67068C0 11.0858 0 15.6405 0 24.75V39.25C0 48.3595 0 52.9142 1.91256 56.3293C3.26425 58.7429 5.25707 60.7357 7.67068 62.0874C11.0858 64 15.6405 64 24.75 64H39.25C48.3595 64 52.9142 64 56.3293 62.0874C58.7429 60.7357 60.7357 58.7429 62.0874 56.3293C64 52.9142 64 48.3595 64 39.25V24.75C64 15.6405 64 11.0858 62.0874 7.67068C60.7357 5.25706 58.7429 3.26425 56.3293 1.91256C52.9142 0 48.3595 0 39.25 0H24.75C15.6405 0 11.0858 0 7.67068 1.91256C5.25707 3.26425 3.26425 5.25706 1.91256 7.67068ZM22.5078 21.2734C22.9115 21.6641 23.4062 21.8594 23.9922 21.8594H50.418C50.9909 21.8594 51.4792 21.6641 51.8828 21.2734C52.2865 20.8698 52.4883 20.375 52.4883 19.7891C52.4883 19.2161 52.2865 18.7279 51.8828 18.3242C51.4792 17.9206 50.9909 17.7188 50.418 17.7188H23.9922C23.4062 17.7188 22.9115 17.9206 22.5078 18.3242C22.1172 18.7279 21.9219 19.2161 21.9219 19.7891C21.9219 20.375 22.1172 20.8698 22.5078 21.2734ZM22.5078 33.3828C22.9115 33.7865 23.4062 33.9883 23.9922 33.9883H50.418C50.9909 33.9883 51.4792 33.793 51.8828 33.4023C52.2865 32.9987 52.4883 32.5039 52.4883 31.918C52.4883 31.3451 52.2865 30.8633 51.8828 30.4727C51.4792 30.069 50.9909 29.8672 50.418 29.8672H23.9922C23.4062 29.8672 22.9115 30.069 22.5078 30.4727C22.1172 30.8633 21.9219 31.3451 21.9219 31.918C21.9219 32.4909 22.1172 32.9792 22.5078 33.3828ZM22.5078 45.5312C22.9115 45.9219 23.4062 46.1172 23.9922 46.1172H50.418C50.9909 46.1172 51.4792 45.9219 51.8828 45.5312C52.2865 45.1276 52.4883 44.6328 52.4883 44.0469C52.4883 43.474 52.2865 42.9857 51.8828 42.582C51.4792 42.1784 50.9909 41.9766 50.418 41.9766H23.9922C23.4062 41.9766 22.9115 42.1784 22.5078 42.582C22.1172 42.9857 21.9219 43.474 21.9219 44.0469C21.9219 44.6328 22.1172 45.1276 22.5078 45.5312ZM12.3711 21.8789C12.957 22.4518 13.6602 22.7383 14.4805 22.7383C15.2878 22.7383 15.9844 22.4518 16.5703 21.8789C17.1562 21.306 17.4492 20.6094 17.4492 19.7891C17.4492 18.9688 17.1562 18.2721 16.5703 17.6992C15.9844 17.1263 15.2878 16.8398 14.4805 16.8398C13.6602 16.8398 12.957 17.1263 12.3711 17.6992C11.7982 18.2721 11.5117 18.9688 11.5117 19.7891C11.5117 20.6094 11.7982 21.306 12.3711 21.8789ZM12.3711 34.0078C12.957 34.5938 13.6602 34.8867 14.4805 34.8867C15.2878 34.8867 15.9844 34.5938 16.5703 34.0078C17.1562 33.4219 17.4492 32.7253 17.4492 31.918C17.4492 31.1107 17.1562 30.4141 16.5703 29.8281C15.9844 29.2422 15.2878 28.9492 14.4805 28.9492C13.6602 28.9492 12.957 29.2422 12.3711 29.8281C11.7982 30.4141 11.5117 31.1107 11.5117 31.918C11.5117 32.7253 11.7982 33.4219 12.3711 34.0078ZM12.3711 46.1367C12.957 46.7227 13.6602 47.0156 14.4805 47.0156C15.2878 47.0156 15.9844 46.7227 16.5703 46.1367C17.1562 45.5638 17.4492 44.8672 17.4492 44.0469C17.4492 43.2266 17.1562 42.5299 16.5703 41.957C15.9844 41.3841 15.2878 41.0977 14.4805 41.0977C13.6602 41.0977 12.957 41.3841 12.3711 41.957C11.7982 42.5299 11.5117 43.2266 11.5117 44.0469C11.5117 44.8672 11.7982 45.5638 12.3711 46.1367Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
3
src/assets/repeat-active.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.5805 0H18.4195C12.0146 0 9.69208 0.666878 7.35056 1.91914C5.00904 3.1714 3.1714 5.00904 1.91914 7.35056C0.666879 9.69208 0 12.0146 0 18.4195V37.5805C0 43.9854 0.666879 46.3079 1.91914 48.6494C3.1714 50.991 5.00904 52.8286 7.35056 54.0809C9.69208 55.3331 12.0146 56 18.4195 56H37.5805C43.9854 56 46.3079 55.3331 48.6495 54.0809C50.991 52.8286 52.8286 50.991 54.0809 48.6494C55.3331 46.3079 56 43.9854 56 37.5805V18.4195C56 12.0146 55.3331 9.69208 54.0809 7.35056C52.8286 5.00904 50.991 3.1714 48.6495 1.91914C46.3079 0.666878 43.9854 0 37.5805 0ZM12.7334 28.376C13.1465 28.7891 13.6519 28.9956 14.2495 28.9956C14.8296 28.9956 15.3262 28.7891 15.7393 28.376C16.1523 27.9541 16.3589 27.4531 16.3589 26.873V25.9502C16.3589 24.7812 16.7236 23.8628 17.4531 23.1948C18.1826 22.5269 19.1846 22.1929 20.459 22.1929H30.3599V25.0669C30.3599 25.5415 30.4961 25.9194 30.7686 26.2007C31.041 26.4819 31.4146 26.6226 31.8892 26.6226C32.1177 26.6226 32.3198 26.5874 32.4956 26.5171C32.6714 26.4468 32.8296 26.3501 32.9702 26.2271L38.7578 21.3096C39.1094 21.0107 39.2852 20.6504 39.2852 20.2285C39.2939 19.7979 39.1182 19.4331 38.7578 19.1343L32.9702 14.2168C32.8296 14.1113 32.6714 14.0234 32.4956 13.9531C32.3198 13.874 32.1177 13.8345 31.8892 13.8345C31.4146 13.8345 31.041 13.9751 30.7686 14.2563C30.4961 14.5288 30.3599 14.9111 30.3599 15.4033V18.0532H20.6436C18.877 18.0532 17.3564 18.3477 16.082 18.9365C14.8076 19.5166 13.8276 20.3516 13.1421 21.4414C12.4565 22.5312 12.1138 23.832 12.1138 25.3438V26.873C12.1138 27.4531 12.3203 27.9541 12.7334 28.376ZM43.2402 27.3213C42.8271 26.9082 42.3306 26.7017 41.7505 26.7017C41.1528 26.7017 40.6475 26.9082 40.2344 27.3213C39.8301 27.7344 39.6279 28.2354 39.6279 28.8242V29.7471C39.6279 30.916 39.2632 31.8345 38.5337 32.5024C37.8042 33.1616 36.8022 33.4912 35.5278 33.4912H25.627V30.6172C25.627 30.125 25.4907 29.7427 25.2183 29.4702C24.9458 29.189 24.5723 29.0483 24.0977 29.0483C23.8691 29.0483 23.6626 29.0835 23.478 29.1538C23.3022 29.2241 23.1484 29.3164 23.0166 29.4307L17.2158 34.3481C16.8643 34.6558 16.6885 35.0249 16.6885 35.4556C16.6973 35.8862 16.873 36.2466 17.2158 36.5366L23.0166 41.4409C23.1484 41.5552 23.3022 41.6475 23.478 41.7178C23.6626 41.7969 23.8691 41.8364 24.0977 41.8364C24.5723 41.8364 24.9458 41.6958 25.2183 41.4146C25.4907 41.1333 25.627 40.751 25.627 40.2676V37.644H35.3301C37.1055 37.644 38.6304 37.354 39.9048 36.7739C41.1792 36.1851 42.1592 35.3457 42.8447 34.2559C43.5303 33.166 43.873 31.8652 43.873 30.3535V28.8242C43.873 28.2354 43.6621 27.7344 43.2402 27.3213Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
3
src/assets/repeat-one-active.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.5805 0H18.4195C12.0146 0 9.69208 0.666878 7.35056 1.91914C5.00904 3.1714 3.1714 5.00904 1.91914 7.35056C0.666879 9.69208 0 12.0146 0 18.4195V37.5805C0 43.9854 0.666879 46.3079 1.91914 48.6494C3.1714 50.991 5.00904 52.8286 7.35056 54.0809C9.69208 55.3331 12.0146 56 18.4195 56H37.5805C43.9854 56 46.3079 55.3331 48.6495 54.0809C50.991 52.8286 52.8286 50.991 54.0809 48.6494C55.3331 46.3079 56 43.9854 56 37.5805V18.4195C56 12.0146 55.3331 9.69208 54.0809 7.35056C52.8286 5.00904 50.991 3.1714 48.6495 1.91914C46.3079 0.666878 43.9854 0 37.5805 0ZM13.2212 28.4946C13.5552 28.8198 13.9858 28.9824 14.5132 28.9824C15.0405 28.9824 15.4668 28.8198 15.792 28.4946C16.1172 28.1606 16.2798 27.73 16.2798 27.2026V26.1743C16.2798 25.0142 16.6401 24.1001 17.3608 23.4321C18.0903 22.7554 19.0791 22.417 20.3271 22.417H26.5103V25.3701C26.5103 25.7744 26.6245 26.0952 26.853 26.3325C27.0903 26.561 27.4111 26.6753 27.8154 26.6753C28 26.6753 28.1714 26.6445 28.3296 26.583C28.4878 26.5215 28.6284 26.438 28.7515 26.3325L34.2622 21.6919C34.5698 21.437 34.7236 21.1294 34.7236 20.769C34.7236 20.3999 34.5698 20.0879 34.2622 19.833L28.7515 15.1924C28.6284 15.0869 28.4878 15.0034 28.3296 14.9419C28.1714 14.8804 28 14.8496 27.8154 14.8496C27.4111 14.8496 27.0903 14.9683 26.853 15.2056C26.6245 15.4341 26.5103 15.7549 26.5103 16.168V18.9629H20.5249C18.9165 18.9629 17.5322 19.2354 16.3721 19.7803C15.2119 20.3164 14.3154 21.0898 13.6826 22.1006C13.0498 23.1113 12.7334 24.3198 12.7334 25.7261V27.2026C12.7334 27.73 12.896 28.1606 13.2212 28.4946ZM42.7393 27.6509C42.4141 27.3257 41.9878 27.1631 41.4604 27.1631C40.9331 27.1631 40.5068 27.3257 40.1816 27.6509C39.8564 27.9761 39.6938 28.4067 39.6938 28.9429V29.9712C39.6938 31.1313 39.3291 32.0454 38.5996 32.7134C37.8789 33.3813 36.8945 33.7153 35.6465 33.7153H25.6138V30.7754C25.6138 30.3623 25.4995 30.0415 25.271 29.813C25.0425 29.5757 24.7261 29.457 24.3218 29.457C24.1284 29.457 23.9526 29.4878 23.7944 29.5493C23.6362 29.6108 23.4956 29.6943 23.3726 29.7998L17.8618 34.4404C17.563 34.7041 17.4136 35.0161 17.4136 35.3765C17.4136 35.7368 17.563 36.0444 17.8618 36.2993L23.3726 40.9399C23.4956 41.0454 23.6362 41.1289 23.7944 41.1904C23.9526 41.252 24.1284 41.2827 24.3218 41.2827C24.7261 41.2827 25.0425 41.1641 25.271 40.9268C25.4995 40.6982 25.6138 40.3818 25.6138 39.9775V37.1826H35.4487C37.0483 37.1826 38.4282 36.9146 39.5884 36.3784C40.7573 35.8335 41.6582 35.0557 42.291 34.0449C42.9238 33.0254 43.2402 31.8169 43.2402 30.4194V28.9429C43.2402 28.4067 43.0732 27.9761 42.7393 27.6509ZM40.2871 24.1968C40.6035 24.4868 41.021 24.6318 41.5396 24.6318C42.0669 24.6318 42.4844 24.4868 42.792 24.1968C43.1084 23.9067 43.2666 23.4629 43.2666 22.8652V16.603C43.2666 16.0142 43.0776 15.5396 42.6997 15.1792C42.3306 14.8188 41.856 14.6387 41.2759 14.6387C40.8188 14.6387 40.4189 14.709 40.0762 14.8496C39.7422 14.9902 39.3994 15.188 39.0479 15.4429L37.2812 16.7612C37.0176 16.9458 36.8374 17.1216 36.7407 17.2886C36.644 17.4556 36.5957 17.6533 36.5957 17.8818C36.5957 18.207 36.7012 18.4707 36.9121 18.6729C37.123 18.875 37.3911 18.9761 37.7163 18.9761C38.0151 18.9761 38.2832 18.8838 38.5205 18.6992L39.707 17.7632H39.8125V22.8652C39.8125 23.4629 39.9707 23.9067 40.2871 24.1968Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
3
src/assets/repeat.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.2495 28.9956C13.6519 28.9956 13.1465 28.7891 12.7334 28.376C12.3203 27.9541 12.1138 27.4531 12.1138 26.873V25.3438C12.1138 23.832 12.4565 22.5312 13.1421 21.4414C13.8276 20.3516 14.8076 19.5166 16.082 18.9365C17.3564 18.3477 18.877 18.0532 20.6436 18.0532H30.3599V15.4033C30.3599 14.9111 30.4961 14.5288 30.7686 14.2563C31.041 13.9751 31.4146 13.8345 31.8892 13.8345C32.1177 13.8345 32.3198 13.874 32.4956 13.9531C32.6714 14.0234 32.8296 14.1113 32.9702 14.2168L38.7578 19.1343C39.1182 19.4331 39.2939 19.7979 39.2852 20.2285C39.2852 20.6504 39.1094 21.0107 38.7578 21.3096L32.9702 26.2271C32.8296 26.3501 32.6714 26.4468 32.4956 26.5171C32.3198 26.5874 32.1177 26.6226 31.8892 26.6226C31.4146 26.6226 31.041 26.4819 30.7686 26.2007C30.4961 25.9194 30.3599 25.5415 30.3599 25.0669V22.1929H20.459C19.1846 22.1929 18.1826 22.5269 17.4531 23.1948C16.7236 23.8628 16.3589 24.7812 16.3589 25.9502V26.873C16.3589 27.4531 16.1523 27.9541 15.7393 28.376C15.3262 28.7891 14.8296 28.9956 14.2495 28.9956ZM41.7505 26.7017C42.3306 26.7017 42.8271 26.9082 43.2402 27.3213C43.6621 27.7344 43.873 28.2354 43.873 28.8242V30.3535C43.873 31.8652 43.5303 33.166 42.8447 34.2559C42.1592 35.3457 41.1792 36.1851 39.9048 36.7739C38.6304 37.354 37.1055 37.644 35.3301 37.644H25.627V40.2676C25.627 40.751 25.4907 41.1333 25.2183 41.4146C24.9458 41.6958 24.5723 41.8364 24.0977 41.8364C23.8691 41.8364 23.6626 41.7969 23.478 41.7178C23.3022 41.6475 23.1484 41.5552 23.0166 41.4409L17.2158 36.5366C16.873 36.2466 16.6973 35.8862 16.6885 35.4556C16.6885 35.0249 16.8643 34.6558 17.2158 34.3481L23.0166 29.4307C23.1484 29.3164 23.3022 29.2241 23.478 29.1538C23.6626 29.0835 23.8691 29.0483 24.0977 29.0483C24.5723 29.0483 24.9458 29.189 25.2183 29.4702C25.4907 29.7427 25.627 30.125 25.627 30.6172V33.4912H35.5278C36.8022 33.4912 37.8042 33.1616 38.5337 32.5024C39.2632 31.8345 39.6279 30.916 39.6279 29.7471V28.8242C39.6279 28.2354 39.8301 27.7344 40.2344 27.3213C40.6475 26.9082 41.1528 26.7017 41.7505 26.7017Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
3
src/assets/shuffle-active.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.5805 0H18.4195C12.0146 0 9.69208 0.666878 7.35056 1.91914C5.00904 3.1714 3.1714 5.00904 1.91914 7.35056C0.666879 9.69208 0 12.0146 0 18.4195V37.5805C0 43.9854 0.666879 46.3079 1.91914 48.6494C3.1714 50.991 5.00904 52.8286 7.35056 54.0809C9.69208 55.3331 12.0146 56 18.4195 56H37.5805C43.9854 56 46.3079 55.3331 48.6495 54.0809C50.991 52.8286 52.8286 50.991 54.0809 48.6494C55.3331 46.3079 56 43.9854 56 37.5805V18.4195C56 12.0146 55.3331 9.69208 54.0809 7.35056C52.8286 5.00904 50.991 3.1714 48.6495 1.91914C46.3079 0.666878 43.9854 0 37.5805 0ZM11.2173 34.8887C10.8218 35.2754 10.624 35.75 10.624 36.3125C10.624 36.875 10.8218 37.3496 11.2173 37.7363C11.6216 38.123 12.1094 38.3164 12.6807 38.3164H16.082C17.3389 38.3164 18.3936 38.1099 19.2461 37.6968C20.0986 37.2749 20.8853 36.6113 21.606 35.7061L24.7747 31.75L27.7363 35.4556C28.5625 36.4927 29.5249 37.231 30.6235 37.6704C31.7222 38.1011 32.9043 38.3164 34.1699 38.3164H36.4243V41.23C36.4243 41.7134 36.5605 42.0957 36.833 42.377C37.1055 42.6582 37.479 42.7988 37.9536 42.7988C38.1821 42.7988 38.3843 42.7593 38.5601 42.6802C38.7446 42.6099 38.9072 42.5132 39.0479 42.3901L44.8223 37.4858C45.1826 37.187 45.3628 36.8223 45.3628 36.3916C45.3628 35.9609 45.1826 35.5962 44.8223 35.2974L39.0479 30.4062C38.9072 30.2832 38.7446 30.1865 38.5601 30.1162C38.3843 30.0371 38.1821 29.9976 37.9536 29.9976C37.479 29.9976 37.1055 30.1382 36.833 30.4194C36.5605 30.7007 36.4243 31.083 36.4243 31.5664V34.2954H34.2227C33.291 34.2954 32.5 34.1328 31.8496 33.8076C31.1992 33.4824 30.5972 32.9727 30.0435 32.2783L27.1993 28.7231L30.0962 25.1064C30.4829 24.6143 30.8521 24.2275 31.2036 23.9463C31.564 23.6562 31.9551 23.4497 32.377 23.3267C32.8076 23.2036 33.3218 23.1421 33.9194 23.1421H36.4243V25.8184C36.4243 26.3018 36.5605 26.6841 36.833 26.9653C37.1055 27.2466 37.479 27.3872 37.9536 27.3872C38.1821 27.3872 38.3843 27.3521 38.5601 27.2817C38.7446 27.2026 38.9072 27.106 39.0479 26.9917L44.8223 22.0742C45.1826 21.7754 45.3628 21.4106 45.3628 20.98C45.3628 20.5493 45.1826 20.1846 44.8223 19.8857L39.0479 14.9814C38.9072 14.8672 38.7446 14.7749 38.5601 14.7046C38.3843 14.6343 38.1821 14.5991 37.9536 14.5991C37.479 14.5991 37.1055 14.7397 36.833 15.021C36.5605 15.2935 36.4243 15.6714 36.4243 16.1548V19.1079H34.1963C32.79 19.1079 31.5552 19.332 30.4917 19.7803C29.4282 20.2285 28.3955 21.0811 27.3936 22.3379L24.7534 25.6657L21.606 21.7314C20.8853 20.8174 20.0371 20.1538 19.0615 19.7407C18.0947 19.3188 16.9829 19.1079 15.7261 19.1079H12.6807C12.1094 19.1079 11.6216 19.3057 11.2173 19.7012C10.8218 20.0879 10.624 20.5625 10.624 21.125C10.624 21.6787 10.8218 22.1533 11.2173 22.5488C11.6216 22.9443 12.1094 23.1421 12.6807 23.1421H15.1064C16.0381 23.1421 16.8599 23.3003 17.5718 23.6167C18.2925 23.9331 18.9341 24.4429 19.4966 25.146L22.3415 28.7056L19.4966 32.2915C18.9341 32.9946 18.3496 33.5044 17.7432 33.8208C17.1455 34.1372 16.3896 34.2954 15.4756 34.2954H12.6807C12.1094 34.2954 11.6216 34.4932 11.2173 34.8887Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
3
src/assets/shuffle.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.624 36.3125C10.624 35.75 10.8218 35.2754 11.2173 34.8887C11.6216 34.4932 12.1094 34.2954 12.6807 34.2954H15.4756C16.3896 34.2954 17.1455 34.1372 17.7432 33.8208C18.3496 33.5044 18.9341 32.9946 19.4966 32.2915L27.3936 22.3379C28.3955 21.0811 29.4282 20.2285 30.4917 19.7803C31.5552 19.332 32.79 19.1079 34.1963 19.1079H36.4243V16.1548C36.4243 15.6714 36.5605 15.2935 36.833 15.021C37.1055 14.7397 37.479 14.5991 37.9536 14.5991C38.1821 14.5991 38.3843 14.6343 38.5601 14.7046C38.7446 14.7749 38.9072 14.8672 39.0479 14.9814L44.8223 19.8857C45.1826 20.1846 45.3628 20.5493 45.3628 20.98C45.3628 21.4106 45.1826 21.7754 44.8223 22.0742L39.0479 26.9917C38.9072 27.106 38.7446 27.2026 38.5601 27.2817C38.3843 27.3521 38.1821 27.3872 37.9536 27.3872C37.479 27.3872 37.1055 27.2466 36.833 26.9653C36.5605 26.6841 36.4243 26.3018 36.4243 25.8184V23.1421H33.9194C33.3218 23.1421 32.8076 23.2036 32.377 23.3267C31.9551 23.4497 31.564 23.6562 31.2036 23.9463C30.8521 24.2275 30.4829 24.6143 30.0962 25.1064L21.606 35.7061C20.8853 36.6113 20.0986 37.2749 19.2461 37.6968C18.3936 38.1099 17.3389 38.3164 16.082 38.3164H12.6807C12.1094 38.3164 11.6216 38.123 11.2173 37.7363C10.8218 37.3496 10.624 36.875 10.624 36.3125ZM10.624 21.125C10.624 20.5625 10.8218 20.0879 11.2173 19.7012C11.6216 19.3057 12.1094 19.1079 12.6807 19.1079H15.7261C16.9829 19.1079 18.0947 19.3188 19.0615 19.7407C20.0371 20.1538 20.8853 20.8174 21.606 21.7314L30.0435 32.2783C30.5972 32.9727 31.1992 33.4824 31.8496 33.8076C32.5 34.1328 33.291 34.2954 34.2227 34.2954H36.4243V31.5664C36.4243 31.083 36.5605 30.7007 36.833 30.4194C37.1055 30.1382 37.479 29.9976 37.9536 29.9976C38.1821 29.9976 38.3843 30.0371 38.5601 30.1162C38.7446 30.1865 38.9072 30.2832 39.0479 30.4062L44.8223 35.2974C45.1826 35.5962 45.3628 35.9609 45.3628 36.3916C45.3628 36.8223 45.1826 37.187 44.8223 37.4858L39.0479 42.3901C38.9072 42.5132 38.7446 42.6099 38.5601 42.6802C38.3843 42.7593 38.1821 42.7988 37.9536 42.7988C37.479 42.7988 37.1055 42.6582 36.833 42.377C36.5605 42.0957 36.4243 41.7134 36.4243 41.23V38.3164H34.1699C32.9043 38.3164 31.7222 38.1011 30.6235 37.6704C29.5249 37.231 28.5625 36.4927 27.7363 35.4556L19.4966 25.146C18.9341 24.4429 18.2925 23.9331 17.5718 23.6167C16.8599 23.3003 16.0381 23.1421 15.1064 23.1421H12.6807C12.1094 23.1421 11.6216 22.9443 11.2173 22.5488C10.8218 22.1533 10.624 21.6787 10.624 21.125Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
620
src/components/FullScreenPlayer.vue
Normal file
@@ -0,0 +1,620 @@
|
||||
<template>
|
||||
<div class="fullscreen-player" :class="{ active: isPlayerFullScreen }">
|
||||
<div class="background-container">
|
||||
<BackgroundRender
|
||||
:album="playerStore.currentSong?.picUrl"
|
||||
:album-is-video="false"
|
||||
ref="bgRef"
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isPlayerFullScreen" class="drag-bar"></div>
|
||||
|
||||
<div class="horizontal-layout" :class="{ hideLyric: playerStore.hideLyricView }">
|
||||
<div class="thumb">
|
||||
<ControlThumb @click="toggleFullScreen" />
|
||||
</div>
|
||||
<Cover
|
||||
class="cover"
|
||||
:cover-url="playerStore.currentSong?.picUrl"
|
||||
:music-paused="!isPlaying"
|
||||
:cover-video-paused="!isPlaying"
|
||||
:pause-shrink-aspect="0.75"
|
||||
/>
|
||||
<div class="controls">
|
||||
<MusicInfo
|
||||
:name="playerStore.currentSong?.name"
|
||||
:artists="playerStore.currentSong?.artist.split('、')??undefined"
|
||||
class="music-info-container"
|
||||
/>
|
||||
<div>
|
||||
<BouncingSlider
|
||||
:value="playerStore.currentTime"
|
||||
:min="0"
|
||||
:max="playerStore.duration"
|
||||
:is-playing="isPlaying"
|
||||
@update:value="handleSeek"
|
||||
/>
|
||||
<div class="progressBarLabels">
|
||||
<div class="time-label">
|
||||
{{ formatTime(playerStore.currentTime) }}
|
||||
</div>
|
||||
<div
|
||||
class="time-label remaining"
|
||||
@click="showRemaining = !showRemaining"
|
||||
>
|
||||
{{ showRemaining ? `-${formatTime(playerStore.duration - playerStore.currentTime)}` : formatTime(playerStore.duration) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mediaControlls">
|
||||
<MediaButton class="songMediaButton" @click="playerStore.toggleMode">
|
||||
<template v-if="playMode === 'random'">
|
||||
<img :src="IconShuffleActive" :style="iconStyle"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<img :src="IconShuffle" :style="iconStyle"/>
|
||||
</template>
|
||||
</MediaButton>
|
||||
|
||||
<MediaButton class="songMediaButton" @click="()=>playerStore.prev()">
|
||||
<img :src="IconRewind" />
|
||||
</MediaButton>
|
||||
|
||||
<MediaButton class="songMediaPlayButton" @click="playerStore.togglePlay">
|
||||
<img :src="isPlaying ? IconPause : IconPlay"/>
|
||||
</MediaButton>
|
||||
|
||||
<MediaButton class="songMediaButton" @click="()=>playerStore.next()">
|
||||
<img :src="IconForward" />
|
||||
</MediaButton>
|
||||
|
||||
<MediaButton class="songMediaButton" @click="playerStore.toggleMode">
|
||||
<template v-if="playMode === 'single'">
|
||||
<img :src="IconRepeatOneActive" :style="iconStyle" />
|
||||
</template>
|
||||
<template v-else-if="playMode === 'list'">
|
||||
<img :src="IconRepeatActive" :style="iconStyle" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<img :src="IconRepeat" :style="iconStyle" />
|
||||
</template>
|
||||
</MediaButton>
|
||||
</div>
|
||||
<div class="volumeControllBar">
|
||||
<VolumeControl
|
||||
v-model="playerStore.volume"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:on-update="value => {
|
||||
playerStore.setVolume( Math.floor(value) )
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lyric">
|
||||
<LyricPlayer
|
||||
v-if="isPlayerFullScreen"
|
||||
ref="lyricPlayerRef"
|
||||
:lyric-lines="toRaw(playerStore.lyrics.lines)"
|
||||
:current-time="playerStore.currentTime"
|
||||
:playing="isPlaying"
|
||||
:align-position="0.5"
|
||||
:wordFadeWidth="0.5"
|
||||
:enable-scale="false"
|
||||
:enable-blur="true"
|
||||
:enable-spring="true"
|
||||
@line-click="jumpTime"
|
||||
style="width:100%;height:100%;font-family: 'LyricFont',sans-serif"
|
||||
>
|
||||
</LyricPlayer>
|
||||
</div>
|
||||
<div class="bottomControls">
|
||||
<ToggleIconButton
|
||||
type="playlist"
|
||||
/>
|
||||
<ToggleIconButton
|
||||
type="lyrics"
|
||||
:checked="!playerStore.hideLyricView"
|
||||
@click="playerStore.hideLyricView = !playerStore.hideLyricView"
|
||||
/>
|
||||
<div style="flex: 1" />
|
||||
<ToggleIconButton
|
||||
type="airplay"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas ref="canvasRef" class="spectrum-canvas"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, toRaw, watch, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
import { LyricPlayer, type LyricPlayerRef, BackgroundRender, type BackgroundRenderRef} from "@applemusic-like-lyrics/vue";
|
||||
import { LyricLineMouseEvent } from "@applemusic-like-lyrics/core";
|
||||
import Cover from './player/Cover.vue';
|
||||
import ControlThumb from './player/ControlThumb.vue';
|
||||
import BouncingSlider from './player/BouncingSlider.vue';
|
||||
import MusicInfo from './player/MusicInfo.vue';
|
||||
import MediaButton from './player/MediaButton.vue';
|
||||
import VolumeControl from './player/VolumeControl.vue';
|
||||
import ToggleIconButton from './player/ToggleIconButton.vue';
|
||||
|
||||
// Icons
|
||||
import IconRewind from '@assets/icon_rewind.svg';
|
||||
import IconPlay from '@assets/icon_play.svg';
|
||||
import IconPause from '@assets/icon_pause.svg';
|
||||
import IconForward from '@assets/icon_forward.svg';
|
||||
import IconShuffle from '@assets/shuffle.svg';
|
||||
import IconShuffleActive from '@assets/shuffle-active.svg';
|
||||
import IconRepeat from '@assets/repeat.svg';
|
||||
import IconRepeatOneActive from '@assets/repeat-one-active.svg';
|
||||
import IconRepeatActive from '@assets/repeat-active.svg';
|
||||
|
||||
const lyricPlayerRef = ref<LyricPlayerRef>()
|
||||
const bgRef = ref<BackgroundRenderRef>();
|
||||
|
||||
const showRemaining = ref(false);
|
||||
|
||||
const formatTime = (miliseconds: number) => {
|
||||
const seconds = miliseconds / 1000;
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = Math.floor(seconds % 60);
|
||||
return `${min}:${sec.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleSeek = (val: number) => {
|
||||
playerStore.seek(val);
|
||||
lyricPlayerRef.value?.lyricPlayer.value?.setCurrentTime(val,true);
|
||||
};
|
||||
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
const isPlayerFullScreen = computed(() => playerStore.isPlayerFullScreen);
|
||||
//const currentSong = computed(() => playerStore.currentSong);
|
||||
const isPlaying = computed(() => playerStore.isPlaying);
|
||||
const playMode = computed(() => playerStore.playMode);
|
||||
|
||||
const iconStyle = {
|
||||
width: "1.3em",
|
||||
height: "1.3em",
|
||||
};
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
let animationId: number | null = null;
|
||||
let currentData: number[] = new Array(32).fill(0); // For temporal smoothing
|
||||
|
||||
const drawSpectrum = () => {
|
||||
if (!canvasRef.value) return;
|
||||
const canvas = canvasRef.value;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Adjust canvas size
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const targetData = playerStore.spectrum;
|
||||
if (!targetData) return;
|
||||
|
||||
// 1. Temporal Smoothing (LERP)
|
||||
// Adjust speed (0.1 - 0.3) for desired smoothness
|
||||
const lerpSpeed = 0.15;
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const target = targetData[i] || 0;
|
||||
currentData[i] = currentData[i] + (target - currentData[i]) * lerpSpeed;
|
||||
}
|
||||
|
||||
// 2. Draw Smooth Wave
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, height);
|
||||
|
||||
const pointCount = currentData.length;
|
||||
// Use a subset if desired, but 32 is fine.
|
||||
// We want the wave to be centered or span the width.
|
||||
const step = width / (pointCount - 1);
|
||||
|
||||
// Start point
|
||||
let x = 0;
|
||||
let y = height - (currentData[0] * height * 0.5); // Scale 0.5
|
||||
ctx.lineTo(x, y);
|
||||
|
||||
for (let i = 0; i < pointCount - 1; i++) {
|
||||
const xCurr = i * step;
|
||||
const yCurr = height - (currentData[i] * height * 0.5);
|
||||
|
||||
const xNext = (i + 1) * step;
|
||||
const yNext = height - (currentData[i + 1] * height * 0.5);
|
||||
|
||||
// Control point for quadratic curve (midpoint)
|
||||
const xMid = (xCurr + xNext) / 2;
|
||||
const yMid = (yCurr + yNext) / 2;
|
||||
|
||||
ctx.quadraticCurveTo(xCurr, yCurr, xMid, yMid);
|
||||
}
|
||||
|
||||
// Connect to last point
|
||||
const lastX = (pointCount - 1) * step;
|
||||
const lastY = height - (currentData[pointCount - 1] * height * 0.5);
|
||||
ctx.lineTo(lastX, lastY);
|
||||
|
||||
// Close path to bottom
|
||||
ctx.lineTo(width, height);
|
||||
ctx.closePath();
|
||||
|
||||
// Style
|
||||
const gradient = ctx.createLinearGradient(0, height - 200, 0, height);
|
||||
gradient.addColorStop(0, 'rgba(255, 255, 255, 0.4)');
|
||||
gradient.addColorStop(1, 'rgba(255, 255, 255, 0.05)');
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
|
||||
// Add blur effect
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
|
||||
|
||||
ctx.fill();
|
||||
|
||||
// Reset shadow for next frame (performance)
|
||||
ctx.shadowBlur = 0;
|
||||
};
|
||||
|
||||
const loop = () => {
|
||||
if (isPlayerFullScreen.value) {
|
||||
drawSpectrum();
|
||||
animationId = requestAnimationFrame(loop);
|
||||
}
|
||||
};
|
||||
|
||||
watch(isPlayerFullScreen, (val) => {
|
||||
if (val) {
|
||||
loop();
|
||||
} else {
|
||||
if (animationId) cancelAnimationFrame(animationId);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (isPlayerFullScreen.value) loop();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationId) cancelAnimationFrame(animationId);
|
||||
});
|
||||
|
||||
const toggleFullScreen = () => {
|
||||
playerStore.toggleFullScreen();
|
||||
};
|
||||
|
||||
const jumpTime = (e: LyricLineMouseEvent) => {
|
||||
playerStore.seek(e.line.getLine().startTime)
|
||||
lyricPlayerRef.value?.lyricPlayer.value?.setCurrentTime(e.line.getLine().startTime,true);
|
||||
}
|
||||
|
||||
// watch(()=>playerStore.currentTime,(t)=>{
|
||||
// console.log(toRaw(t))
|
||||
// })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fullscreen-player {
|
||||
--height: calc(100vh);
|
||||
position: fixed;
|
||||
top: var(--height);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: var(--height);
|
||||
z-index: 9999;
|
||||
transition: top 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
background: black; /* Default background if image fails */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fullscreen-player.active {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.background-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drag-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
z-index: 100;
|
||||
-webkit-app-region: drag;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
.spectrum-canvas {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
z-index: 15;
|
||||
mask-image: linear-gradient(to top, black 0%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to top, black 0%, transparent 100%);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'LyricFont';
|
||||
src: url('@assets/lyricfont.ttf');
|
||||
font-weight: 350;
|
||||
}
|
||||
|
||||
/* New Horizontal Layout Styles */
|
||||
.horizontal-layout {
|
||||
/* --info-size-fract: 0.85fr; */
|
||||
/* --player-size-fract: 1fr; */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: [drag-area] minmax(20px, .20fr) [thumb] auto [cover] auto [music-info] 3fr [buttom-controls] 0fr .1fr;
|
||||
grid-template-columns: [info-side] .50fr [player-side] .50fr [side-controls] 0fr;
|
||||
gap: 8px;
|
||||
transition: all 0.5s ease-in-out;
|
||||
left: 0;
|
||||
|
||||
--hide-lyric-left: 50%;
|
||||
|
||||
--horizontal-layout-max-width: min(50vh, 38vw);
|
||||
}
|
||||
|
||||
.horizontal-layout .thumb,
|
||||
.horizontal-layout .cover,
|
||||
.horizontal-layout .controls {
|
||||
transition: left 0.5s cubic-bezier(0.5, 0, 0.5, 1);
|
||||
left: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.horizontal-layout.hideLyric .lyric {
|
||||
transition:
|
||||
opacity 0.25s cubic-bezier(0.5, 0, 0.5, 1),
|
||||
transform 0.5s cubic-bezier(0.5, 0, 0.5, 1);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.horizontal-layout.hideLyric .thumb,
|
||||
.horizontal-layout.hideLyric .cover,
|
||||
.horizontal-layout.hideLyric .controls {
|
||||
left: var(--hide-lyric-left);
|
||||
}
|
||||
|
||||
@media screen and (max-height: 1000px) {
|
||||
.horizontal-layout {
|
||||
--horizontal-layout-max-width: min(45vh, 38vw);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 768px) {
|
||||
.horizontal-layout {
|
||||
font-size: 0.8em;
|
||||
gap: 2px;
|
||||
grid-template-rows: [drag-area] minmax(30px, 0.25fr) [thumb] auto [cover] auto [music-info] 3fr [buttom-controls] 0fr 0.2fr;
|
||||
}
|
||||
}
|
||||
|
||||
.thumb {
|
||||
grid-column: info-side;
|
||||
grid-row: thumb;
|
||||
will-change: transform;
|
||||
justify-self: center;
|
||||
margin: 2vh;
|
||||
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.cover {
|
||||
margin: 0;
|
||||
aspect-ratio: 1 / 1;
|
||||
grid-column: info-side;
|
||||
grid-row: cover;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
width: var(--horizontal-layout-max-width);
|
||||
height: var(--horizontal-layout-max-width);
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.controls {
|
||||
grid-area: music-info / info-side;
|
||||
will-change: transform;
|
||||
justify-self: center;
|
||||
|
||||
mix-blend-mode: plus-lighter;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: var(--horizontal-layout-max-width);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
height: 100%;
|
||||
|
||||
position: relative;
|
||||
margin-top: calc(-8px + 1.75em);
|
||||
}
|
||||
|
||||
.progressBarLabels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
font-size: max(1.2vh, 0.8em);
|
||||
opacity: 0.5;
|
||||
margin-top: 4px;
|
||||
|
||||
@media screen and (max-height: 768px) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
& > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
& > *:nth-child(2) {
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
& > *:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.time-label.remaining {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.music-info-container {
|
||||
width: 100%;
|
||||
margin-bottom: 2vh;
|
||||
}
|
||||
|
||||
.lyric {
|
||||
box-sizing: border-box;
|
||||
grid-column: player-side;
|
||||
grid-row: 2 / 5;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: opacity 0.5s 0.25s cubic-bezier(0.5, 0, 0.5, 1);
|
||||
padding-right: 15%;
|
||||
|
||||
mask-image: linear-gradient(transparent, black 10%, black 90%, transparent);
|
||||
|
||||
contain: paint;
|
||||
pointer-events: none; /* Allow clicks to pass through to elements below */
|
||||
|
||||
/* 修复呼吸点,不要删!*/
|
||||
:deep(.amll-lyric-player) {
|
||||
box-sizing: content-box;
|
||||
pointer-events: auto; /* Re-enable pointer events for the actual lyric player */
|
||||
[class*="interludeDots"] {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1600px), (max-height: 1000px) {
|
||||
.lyric {
|
||||
padding-right: 8%;
|
||||
}
|
||||
}
|
||||
|
||||
.bottomControls {
|
||||
grid-area: buttom-controls / 1 / buttom-controls / 4;
|
||||
gap: 2em;
|
||||
padding-left: 2em;
|
||||
padding-right: 2em;
|
||||
mix-blend-mode: plus-lighter;
|
||||
flex-direction: row-reverse;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.songMediaButton,
|
||||
.songMediaPlayButton {
|
||||
width: 18%;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.songMediaButton > img {
|
||||
display: block;
|
||||
scale: 3;
|
||||
transition: scale 0.3s;
|
||||
|
||||
@media screen and (max-height: 1080px) {
|
||||
scale: 2;
|
||||
}
|
||||
|
||||
@media screen and (max-height: 768px) {
|
||||
scale: 1.5;
|
||||
}
|
||||
|
||||
@media screen and (max-height: 512px) {
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
scale: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.songMediaPlayButton > img {
|
||||
scale: 2;
|
||||
transition: scale 0.3s;
|
||||
|
||||
@media screen and (max-height: 1080px) {
|
||||
scale: 1.1;
|
||||
}
|
||||
|
||||
@media screen and (max-height: 768px) {
|
||||
scale: 0.8;
|
||||
}
|
||||
|
||||
@media screen and (max-height: 512px) {
|
||||
scale: 0.5;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
scale: 0.5;
|
||||
}
|
||||
}
|
||||
.bigControls :deep(button) {
|
||||
height: 10vh !important;
|
||||
width: 10vh !important;
|
||||
}
|
||||
|
||||
.mediaControlls {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.volumeControllBar {
|
||||
touch-action: none;
|
||||
justify-content: stretch;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
</style>
|
||||
543
src/components/Settings.vue
Normal file
@@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div class="settings-overlay" v-if="isLoaded">
|
||||
<div class="settings-container">
|
||||
<!-- Header -->
|
||||
<div class="settings-header">
|
||||
<h1 class="settings-title">设置</h1>
|
||||
<button class="close-btn" @click="$emit('close')">
|
||||
<Icon icon="lucide:x" class="close-icon" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-body">
|
||||
<!-- Left Sidebar -->
|
||||
<nav class="settings-nav">
|
||||
<div
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="nav-item"
|
||||
:class="{ active: activeCategory === category.id }"
|
||||
@click="activeCategory = category.id"
|
||||
>
|
||||
<Icon :icon="category.icon" class="nav-icon" />
|
||||
<span>{{ category.name }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Right Content -->
|
||||
<div class="settings-content">
|
||||
<!-- 外观设置 -->
|
||||
<div v-if="activeCategory === 'appearance'" class="section">
|
||||
<h2 class="section-title">外观设置</h2>
|
||||
|
||||
<!-- 亮暗模式 -->
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">主题模式</div>
|
||||
<div class="setting-desc">选择深色或浅色主题</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div class="theme-toggle">
|
||||
<button
|
||||
class="theme-btn"
|
||||
:class="{ active: appearance.theme === 'dark' }"
|
||||
@click="setTheme('dark')"
|
||||
>
|
||||
<Icon icon="lucide:moon" />
|
||||
深色
|
||||
</button>
|
||||
<button
|
||||
class="theme-btn"
|
||||
:class="{ active: appearance.theme === 'light' }"
|
||||
@click="setTheme('light')"
|
||||
>
|
||||
<Icon icon="lucide:sun" />
|
||||
浅色
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主题色 -->
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">主题色</div>
|
||||
<div class="setting-desc">选择你喜欢的强调色</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div class="color-swatches">
|
||||
<button
|
||||
v-for="color in accentColors"
|
||||
:key="color.value"
|
||||
class="color-swatch"
|
||||
:class="{ active: appearance.accentColor === color.value }"
|
||||
:style="{ '--swatch-color': color.value }"
|
||||
:title="color.name"
|
||||
@click="setAccentColor(color.value)"
|
||||
>
|
||||
<Icon v-if="appearance.accentColor === color.value" icon="lucide:check" class="check-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 播放设置 -->
|
||||
<div v-else-if="activeCategory === 'playback'" class="section">
|
||||
<h2 class="section-title">播放设置</h2>
|
||||
|
||||
<!-- 列表添加模式 -->
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">列表添加模式</div>
|
||||
<div class="setting-desc">选择点击歌曲时的播放行为</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div class="radio-group">
|
||||
<label class="radio-option">
|
||||
<input type="radio" value="replace" v-model="playerStore.addListMode">
|
||||
<span class="radio-label">替换当前列表</span>
|
||||
</label>
|
||||
<label class="radio-option">
|
||||
<input type="radio" value="append" v-model="playerStore.addListMode">
|
||||
<span class="radio-label">添加到列表末尾</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-content">
|
||||
<Icon icon="lucide:headphones" class="placeholder-icon" />
|
||||
<p>更多设置即将推出</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关于 -->
|
||||
<div v-else-if="activeCategory === 'about'" class="section">
|
||||
<h2 class="section-title">关于</h2>
|
||||
<div class="about-content">
|
||||
<div class="app-logo">🎶</div>
|
||||
<h3>QZ Music Web</h3>
|
||||
<p class="version">版本 1.0.0</p>
|
||||
<p class="copyright">©2026 QZ Music</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onBeforeMount, nextTick } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
defineEmits(['close']);
|
||||
|
||||
const categories = [
|
||||
{ id: 'appearance', name: '外观', icon: 'lucide:palette' },
|
||||
{ id: 'playback', name: '播放', icon: 'lucide:headphones' },
|
||||
{ id: 'about', name: '关于', icon: 'lucide:info' },
|
||||
];
|
||||
|
||||
const accentColors = [
|
||||
{ name: '红色', value: '#ec4141' },
|
||||
{ name: '橙色', value: '#f97316' },
|
||||
{ name: '金色', value: '#eab308' },
|
||||
{ name: '绿色', value: '#22c55e' },
|
||||
{ name: '青色', value: '#06b6d4' },
|
||||
{ name: '蓝色', value: '#3b82f6' },
|
||||
{ name: '紫色', value: '#8b5cf6' },
|
||||
{ name: '粉色', value: '#ec4899' },
|
||||
];
|
||||
|
||||
const activeCategory = ref('appearance');
|
||||
const isLoaded = ref(false);
|
||||
const enableTransition = ref(false);
|
||||
|
||||
const appearance = reactive({
|
||||
theme: 'dark' as 'dark' | 'light',
|
||||
accentColor: '#ec4141',
|
||||
});
|
||||
|
||||
const applyTheme = (theme: 'dark' | 'light') => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('qz-theme', theme);
|
||||
};
|
||||
|
||||
const applyAccentColor = (color: string) => {
|
||||
document.documentElement.style.setProperty('--color-accent', color);
|
||||
localStorage.setItem('qz-accent-color', color);
|
||||
};
|
||||
|
||||
const setTheme = (theme: 'dark' | 'light') => {
|
||||
appearance.theme = theme;
|
||||
applyTheme(theme);
|
||||
};
|
||||
|
||||
const setAccentColor = (color: string) => {
|
||||
appearance.accentColor = color;
|
||||
applyAccentColor(color);
|
||||
};
|
||||
|
||||
const loadAppearance = () => {
|
||||
const savedTheme = localStorage.getItem('qz-theme') as 'dark' | 'light';
|
||||
const savedAccentColor = localStorage.getItem('qz-accent-color');
|
||||
|
||||
if (savedTheme) {
|
||||
appearance.theme = savedTheme;
|
||||
}
|
||||
if (savedAccentColor) {
|
||||
appearance.accentColor = savedAccentColor;
|
||||
}
|
||||
|
||||
applyTheme(appearance.theme);
|
||||
applyAccentColor(appearance.accentColor);
|
||||
};
|
||||
|
||||
// Load settings BEFORE mount to avoid visual flicker
|
||||
onBeforeMount(async () => {
|
||||
loadAppearance();
|
||||
isLoaded.value = true;
|
||||
// Enable transition after initial render
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
enableTransition.value = true;
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Fade transition */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--color-bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Left Navigation */
|
||||
.settings-nav {
|
||||
width: 200px;
|
||||
padding: 20px 12px;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-right: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background-color: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Right Content */
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
padding: 32px 48px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Setting Item */
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.setting-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
margin-left: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.placeholder-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* About */
|
||||
.about-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #ec4141, #ff6b6b);
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.about-content h3 {
|
||||
font-size: var(--font-size-2xl);
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.version {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.settings-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.settings-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-light);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.settings-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-sm);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.theme-btn.active {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Color Swatches */
|
||||
.color-swatches {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 3px solid transparent;
|
||||
background-color: var(--swatch-color);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.color-swatch:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.color-swatch.active {
|
||||
box-shadow: 0 0 16px var(--swatch-color);
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: white;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
/* Radio Group */
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.radio-option input[type="radio"] {
|
||||
accent-color: var(--color-accent);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
302
src/components/Sidebar.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<!-- 顶部区域 -->
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-area">
|
||||
<div class="logo-icon">🎶</div>
|
||||
<span class="app-name">QZ Music</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主导航 -->
|
||||
<div class="nav-section">
|
||||
<router-link to="/" class="nav-item" active-class="active">
|
||||
<Icon icon="lucide:home" class="nav-icon" />
|
||||
<span class="nav-text">推荐</span>
|
||||
</router-link>
|
||||
<router-link to="/local" class="nav-item" active-class="active">
|
||||
<Icon icon="lucide:hard-drive" class="nav-icon" />
|
||||
<span class="nav-text">本地音乐</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 我的音乐 -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="section-title">我的音乐</div>
|
||||
<router-link to="/liked" class="nav-item" active-class="active">
|
||||
<Icon icon="lucide:heart" class="nav-icon" />
|
||||
<span class="nav-text">我喜欢的</span>
|
||||
</router-link>
|
||||
<router-link to="/recent" class="nav-item" active-class="active">
|
||||
<Icon icon="lucide:clock" class="nav-icon" />
|
||||
<span class="nav-text">最近播放</span>
|
||||
</router-link>
|
||||
<div class="nav-item">
|
||||
<Icon icon="lucide:download" class="nav-icon" />
|
||||
<span class="nav-text">下载管理</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的歌单 -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="section-header" @click="togglePlaylists">
|
||||
<span class="section-title">我的歌单</span>
|
||||
<Icon
|
||||
icon="lucide:chevron-down"
|
||||
class="collapse-icon"
|
||||
:class="{ 'collapsed': !isPlaylistsOpen }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="playlists-list" v-show="isPlaylistsOpen">
|
||||
<div class="nav-item playlist-item">
|
||||
<div class="playlist-cover">
|
||||
<Icon icon="lucide:music" />
|
||||
</div>
|
||||
<span class="nav-text">驾驶模式</span>
|
||||
</div>
|
||||
<div class="nav-item playlist-item">
|
||||
<div class="playlist-cover">
|
||||
<Icon icon="lucide:music" />
|
||||
</div>
|
||||
<span class="nav-text">放松时光</span>
|
||||
</div>
|
||||
<div class="nav-item playlist-item">
|
||||
<div class="playlist-cover">
|
||||
<Icon icon="lucide:music" />
|
||||
</div>
|
||||
<span class="nav-text">工作专注</span>
|
||||
</div>
|
||||
<div class="nav-item create-playlist">
|
||||
<Icon icon="lucide:plus" class="nav-icon" />
|
||||
<span class="nav-text">新建歌单</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
const isPlaylistsOpen = ref(true);
|
||||
|
||||
const togglePlaylists = () => {
|
||||
isPlaylistsOpen.value = !isPlaylistsOpen.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
box-sizing: border-box;
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 12px;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 滚动条样式 - 默认隐藏,悬停时显示 */
|
||||
.sidebar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar:hover::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-light);
|
||||
}
|
||||
|
||||
.sidebar:hover::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 顶部Logo区域 */
|
||||
.sidebar-header {
|
||||
padding: 8px 8px 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.logo-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.logo-area:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #ec4141, #ff6b6b);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* 导航区域 */
|
||||
.nav-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background-color: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
.nav-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
|
||||
.nav-text {
|
||||
font-size: var(--font-size-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 分割线 */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--color-border), transparent);
|
||||
margin: 16px 8px;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 区域标题 */
|
||||
.section-title {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--transition-base);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.section-header:hover {
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
transition: transform var(--transition-base);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.collapse-icon.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* 歌单项 */
|
||||
.playlist-item {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.playlist-cover {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playlist-item:hover .playlist-cover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* 新建歌单 */
|
||||
.create-playlist {
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 8px;
|
||||
border: 1px dashed var(--color-border-light);
|
||||
}
|
||||
|
||||
.create-playlist:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
background-color: var(--color-accent-soft);
|
||||
}
|
||||
</style>
|
||||
227
src/components/TopBar.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<div class="left-controls">
|
||||
<div class="nav-group">
|
||||
<button class="nav-btn ripple-btn" @click="goBack" title="返回">
|
||||
<Icon icon="lucide:chevron-left" class="nav-icon" />
|
||||
</button>
|
||||
<button class="nav-btn ripple-btn" @click="goForward" title="前进">
|
||||
<Icon icon="lucide:chevron-right" class="nav-icon" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search-wrapper">
|
||||
<div class="search-container">
|
||||
<Icon icon="lucide:search" class="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索音乐、歌手、专辑..."
|
||||
class="search-input"
|
||||
v-model="searchQuery"
|
||||
@keydown.enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-controls">
|
||||
<div class="app-actions">
|
||||
<button class="action-btn ripple-btn" title="设置" @click="openSettings">
|
||||
<Icon icon="lucide:settings" class="action-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
const router = useRouter();
|
||||
const searchQuery = ref('');
|
||||
|
||||
const goBack = () => router.back();
|
||||
const goForward = () => router.forward();
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!searchQuery.value.trim()) return;
|
||||
router.push({
|
||||
name: 'Search',
|
||||
query: { q: searchQuery.value }
|
||||
});
|
||||
};
|
||||
|
||||
// Settings
|
||||
const openSettings = inject<() => void>('openSettings', () => {});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 变量定义 */
|
||||
:root {
|
||||
--radius-soft: 10px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
box-sizing: border-box;
|
||||
height: 64px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px 0 24px;
|
||||
background-color: var(--color-bg-primary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* --- 左侧区域 --- */
|
||||
.left-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* --- 统一功能按钮(Nav / Settings)--- */
|
||||
.nav-btn,
|
||||
.action-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-secondary);
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
.nav-btn:hover,
|
||||
.action-btn:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* 图标 */
|
||||
.nav-icon,
|
||||
.action-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.ripple-btn::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ripple-btn:active::after {
|
||||
transform: translate(-50%, -50%) scale(2.5);
|
||||
opacity: 1;
|
||||
transition: 0s;
|
||||
}
|
||||
|
||||
.ripple-btn:not(:active):after {
|
||||
transform: translate(-50%, -50%) scale(2.5);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* --- 搜索框 --- */
|
||||
.search-wrapper {
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
width: 260px;
|
||||
padding: 0 14px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.search-container:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.search-container:focus-within {
|
||||
width: 340px;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-primary) 0%,
|
||||
var(--color-bg-tertiary) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: var(--color-text-muted);
|
||||
margin-right: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.search-container:focus-within .search-icon {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* --- 右侧区域布局调整 --- */
|
||||
.right-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
223
src/components/player/BouncingSlider.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div
|
||||
class="bouncing-slider"
|
||||
ref="containerRef"
|
||||
@mousedown="handlePanStart"
|
||||
@touchstart="handlePanStart"
|
||||
@mouseenter="handleHoverStart"
|
||||
@mouseleave="handleHoverEnd"
|
||||
:style="{
|
||||
transform: `translateX(${bounceX}px)`
|
||||
}"
|
||||
>
|
||||
<div class="inner" :style="{ clipPath: clipPath }">
|
||||
<div
|
||||
class="thumb"
|
||||
:style="{
|
||||
transform: `scaleX(${progress})`
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
min: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
isPlaying: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
changeOnDrag: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:value', 'drag-start', 'drag-end', 'seeking']);
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const isHovering = ref(false);
|
||||
const isDragging = ref(false);
|
||||
const localValue = ref(props.value);
|
||||
const bounceX = ref(0);
|
||||
|
||||
const THROTTLE_MS = 20;
|
||||
let lastEmitTime = 0;
|
||||
|
||||
const MAX_HEIGHT = 20;
|
||||
const MIN_HEIGHT = 8;
|
||||
const INITIAL_INSET = (MAX_HEIGHT - MIN_HEIGHT) / 2;
|
||||
const MAX_BOUNCE_DISTANCE = 12;
|
||||
|
||||
const inset = ref(INITIAL_INSET);
|
||||
|
||||
const clipPath = computed(() => {
|
||||
return `inset(${inset.value}px 0px round 100px)`;
|
||||
});
|
||||
|
||||
const progress = computed(() => {
|
||||
const range = props.max - props.min;
|
||||
if (range === 0) return 0;
|
||||
return Math.max(0, Math.min(1, (localValue.value - props.min) / range));
|
||||
});
|
||||
|
||||
// Sync localValue with props.value when not dragging
|
||||
watch(() => props.value, (newVal) => {
|
||||
if (!isDragging.value) {
|
||||
localValue.value = newVal;
|
||||
}
|
||||
});
|
||||
|
||||
const expand = () => {
|
||||
inset.value = 0;
|
||||
};
|
||||
|
||||
const collapse = () => {
|
||||
inset.value = INITIAL_INSET;
|
||||
};
|
||||
|
||||
const handleHoverStart = () => {
|
||||
isHovering.value = true;
|
||||
if (!isDragging.value) {
|
||||
expand();
|
||||
}
|
||||
};
|
||||
|
||||
const handleHoverEnd = () => {
|
||||
isHovering.value = false;
|
||||
if (!isDragging.value) {
|
||||
collapse();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePanStart = (event: MouseEvent | TouchEvent) => {
|
||||
isDragging.value = true;
|
||||
expand();
|
||||
emit('drag-start');
|
||||
emit('seeking', true);
|
||||
|
||||
// Initial calculation
|
||||
calculateValue(event);
|
||||
|
||||
window.addEventListener('mousemove', handlePan);
|
||||
window.addEventListener('touchmove', handlePan);
|
||||
window.addEventListener('mouseup', handlePanEnd);
|
||||
window.addEventListener('touchend', handlePanEnd);
|
||||
};
|
||||
|
||||
const calculateValue = (event: MouseEvent | TouchEvent) => {
|
||||
if (!containerRef.value) return;
|
||||
const rect = containerRef.value.getBoundingClientRect();
|
||||
const clientX = 'touches' in event ? event.touches[0].clientX : (event as MouseEvent).clientX;
|
||||
|
||||
const relPos = (clientX - rect.left) / rect.width;
|
||||
|
||||
// Bounce effect
|
||||
if (relPos < 0) {
|
||||
bounceX.value = Math.tanh(relPos * 2) * MAX_BOUNCE_DISTANCE;
|
||||
} else if (relPos > 1) {
|
||||
bounceX.value = Math.tanh((relPos - 1) * 2) * MAX_BOUNCE_DISTANCE;
|
||||
} else {
|
||||
bounceX.value = 0;
|
||||
}
|
||||
|
||||
const clampedPos = Math.max(0, Math.min(1, relPos));
|
||||
const newValue = props.min + clampedPos * (props.max - props.min);
|
||||
|
||||
localValue.value = newValue;
|
||||
|
||||
if (props.changeOnDrag) {
|
||||
const now = Date.now();
|
||||
if (now - lastEmitTime >= THROTTLE_MS) {
|
||||
lastEmitTime = now;
|
||||
emit('update:value', newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePan = (event: Event) => {
|
||||
// MouseEvent or TouchEvent
|
||||
calculateValue(event as MouseEvent | TouchEvent);
|
||||
};
|
||||
|
||||
const handlePanEnd = () => {
|
||||
isDragging.value = false;
|
||||
bounceX.value = 0;
|
||||
|
||||
if (isHovering.value) {
|
||||
expand();
|
||||
} else {
|
||||
collapse();
|
||||
}
|
||||
|
||||
emit('seeking', false);
|
||||
emit('drag-end');
|
||||
|
||||
// Commit value
|
||||
emit('update:value', localValue.value);
|
||||
|
||||
window.removeEventListener('mousemove', handlePan);
|
||||
window.removeEventListener('touchmove', handlePan);
|
||||
window.removeEventListener('mouseup', handlePanEnd);
|
||||
window.removeEventListener('touchend', handlePanEnd);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', handlePan);
|
||||
window.removeEventListener('touchmove', handlePan);
|
||||
window.removeEventListener('mouseup', handlePanEnd);
|
||||
window.removeEventListener('touchend', handlePanEnd);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bouncing-slider {
|
||||
touch-action: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: stretch;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
width: 100%; /* Ensure it takes full width */
|
||||
transform: translateZ(0);
|
||||
/* Transition for bounce effect on container */
|
||||
transition: transform 0.1s linear;
|
||||
}
|
||||
|
||||
.inner {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background-color: #ffffff26;
|
||||
transition: clip-path 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
|
||||
overflow: hidden; /* Needed for clip-path visualization in some contexts/fallbacks */
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: white;
|
||||
opacity: 0.4;
|
||||
transform-origin: left center;
|
||||
transition: transform 0.1s linear; /* Smooth visual update during drag/play */
|
||||
}
|
||||
|
||||
.bouncing-slider > svg {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
47
src/components/player/ControlThumb.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="controlThumb" @click="onClick">
|
||||
<button type="button" class="knob"></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.controlThumb {
|
||||
width: 100%;
|
||||
height: 25px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 70px;
|
||||
height: 10px;
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff2d;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
/* 可选:加一点反馈 */
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.knob:hover {
|
||||
background-color: #ffffff4d;
|
||||
}
|
||||
|
||||
.knob:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
134
src/components/player/Cover.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div
|
||||
ref="frameRef"
|
||||
class="cover"
|
||||
:class="{ musicPaused }"
|
||||
>
|
||||
<div
|
||||
class="coverInner"
|
||||
:style="{
|
||||
borderRadius: `${cornerRadius}px`,
|
||||
backgroundImage: !coverIsVideo ? `url(${coverUrl})` : undefined,
|
||||
}"
|
||||
>
|
||||
<video
|
||||
v-if="coverIsVideo"
|
||||
ref="videoRef"
|
||||
class="coverInner"
|
||||
:src="coverUrl"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
coverUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
coverIsVideo: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
coverVideoPaused: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
musicPaused: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const frameRef = ref<HTMLElement | null>(null);
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const cornerRadius = ref(20);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
watch(() => props.coverVideoPaused, (paused) => {
|
||||
if (videoRef.value) {
|
||||
if (paused) {
|
||||
videoRef.value.pause();
|
||||
} else {
|
||||
videoRef.value.play();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const frameEl = frameRef.value;
|
||||
if (frameEl) {
|
||||
const onResize = () => {
|
||||
const size = Math.min(frameEl.clientWidth, frameEl.clientHeight);
|
||||
// Logic from React component: Math.max(size * 0.02, window.innerHeight * 0.007)
|
||||
cornerRadius.value = Math.max(size * 0.02, window.innerHeight * 0.007);
|
||||
};
|
||||
resizeObserver = new ResizeObserver(onResize);
|
||||
onResize();
|
||||
resizeObserver.observe(frameEl);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cover {
|
||||
--base-box-shadow-v-0: rgba(0, 0, 0, 0.19);
|
||||
--base-box-shadow-y-0: 1em;
|
||||
--base-box-shadow-r-0: 1.2em;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* object-fit: cover; removed as it applies to img/video, here it's div container */
|
||||
/* object-position: center; removed */
|
||||
/* background-position: center; removed - moved to inner */
|
||||
/* background-size: cover; removed - moved to inner */
|
||||
|
||||
/* border-radius: max(2%, 0.7vh); */
|
||||
filter: drop-shadow(
|
||||
var(--base-box-shadow-v-0) 0px var(--base-box-shadow-y-0)
|
||||
var(--base-box-shadow-r-0)
|
||||
);
|
||||
transform: scale(1);
|
||||
will-change: transform; /* Optimized for animation */
|
||||
transition:
|
||||
background-image 0.5s linear,
|
||||
filter 0.5s ease,
|
||||
transform 0.5s cubic-bezier(0.3, 0.2, 0.2, 1.4);
|
||||
}
|
||||
|
||||
.cover.musicPaused {
|
||||
/* --base-box-shadow-v-0: rgba(0, 0, 0, 0.19); */
|
||||
/* --base-box-shadow-y-0: 0.8em; */
|
||||
/* --base-box-shadow-r-0: 0.8em; */
|
||||
/* transform: scale(var(--scale-level)); */
|
||||
transition:
|
||||
background-image 0.5s linear,
|
||||
filter 0.5s ease,
|
||||
transform 0.6s cubic-bezier(0.4, 0.2, 0.1, 1);
|
||||
}
|
||||
|
||||
.coverInner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-color: black;
|
||||
transition: background-image 0.5s linear;
|
||||
overflow: hidden; /* Important for border-radius clipping */
|
||||
object-fit: cover; /* For video element */
|
||||
}
|
||||
</style>
|
||||
71
src/components/player/MediaButton.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<button
|
||||
class="mediaButton"
|
||||
:class="className"
|
||||
type="button"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
className?: string;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes pressed-animation {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.mediaButton {
|
||||
aspect-ratio: 1 / 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.mediaButton:hover {
|
||||
background-color: rgba(255, 255, 255, 0.133); /* #fff2 approx */
|
||||
}
|
||||
|
||||
.mediaButton:active {
|
||||
background-color: rgba(255, 255, 255, 0.133);
|
||||
}
|
||||
|
||||
.mediaButton:active :deep(*) {
|
||||
animation-name: none !important;
|
||||
}
|
||||
|
||||
.mediaButton :deep(*) {
|
||||
transition: transform 0.5s;
|
||||
transform-origin: center;
|
||||
animation: pressed-animation 0.7s;
|
||||
animation-composition: accumulate;
|
||||
}
|
||||
</style>
|
||||
107
src/components/player/MusicInfo.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="musicInfo" :class="className">
|
||||
<div class="info">
|
||||
<TextMarquee v-if="name !== undefined" class="name">
|
||||
{{ name }}
|
||||
</TextMarquee>
|
||||
<TextMarquee v-if="artists && artists.length > 0" class="artists">
|
||||
<span v-for="(artist, index) in artists" :key="`artist-${artist}-${index}`">
|
||||
<a @click.stop="onArtistClicked && onArtistClicked(artist, index)">{{ artist }}</a>
|
||||
</span>
|
||||
</TextMarquee>
|
||||
<TextMarquee v-if="album !== undefined" class="album">
|
||||
<a @click.stop="onAlbumClicked && onAlbumClicked()">{{ album }}</a>
|
||||
</TextMarquee>
|
||||
</div>
|
||||
<!-- MenuButton placeholder or implementation -->
|
||||
<div class="menu-button-placeholder" @click="onMenuButtonClicked">
|
||||
<!-- Icon could go here -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TextMarquee from './TextMarquee.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
name?: string;
|
||||
artists?: string[];
|
||||
album?: string;
|
||||
onArtistClicked?: (artist: string, index: number) => void;
|
||||
onAlbumClicked?: () => void;
|
||||
onMenuButtonClicked?: () => void;
|
||||
className?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.musicInfo {
|
||||
display: flex;
|
||||
color: white;
|
||||
font-size: max(2vh, 1em);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
/* Fixed line-height to prevent layout shifting */
|
||||
line-height: 1.25em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.name {
|
||||
white-space: nowrap;
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.4px;
|
||||
opacity: 0.9;
|
||||
mix-blend-mode: normal !important;
|
||||
}
|
||||
|
||||
.artists,
|
||||
.album {
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
opacity: 0.45;
|
||||
font-weight: 400;
|
||||
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.artists :deep(a),
|
||||
.album :deep(a) {
|
||||
text-decoration: none;
|
||||
user-select: text;
|
||||
cursor: default;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.artists :deep(a:hover),
|
||||
.album :deep(a:hover) {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.artists :deep(a:active),
|
||||
.album :deep(a:active) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Add separators between artists */
|
||||
.artists :deep(span::after) {
|
||||
content: ", ";
|
||||
}
|
||||
|
||||
.artists :deep(span:nth-last-child(2)::after) {
|
||||
content: "/";
|
||||
}
|
||||
|
||||
.artists :deep(span:last-child::after) {
|
||||
content: "";
|
||||
}
|
||||
|
||||
</style>
|
||||
467
src/components/player/PlayerBar.vue
Normal file
@@ -0,0 +1,467 @@
|
||||
<template>
|
||||
<transition name="slide-up">
|
||||
<div class="player-bar" v-if="hasSongs" @click="handleBarClick">
|
||||
<!-- Left: Vinyl & Info -->
|
||||
<div class="player-left">
|
||||
<div class="vinyl-wrapper" :class="{ 'playing': isPlaying }">
|
||||
<img
|
||||
v-if="currentSong?.picUrl"
|
||||
:src="currentSong.picUrl"
|
||||
class="album-art"
|
||||
alt="Album Art"
|
||||
/>
|
||||
<div v-else class="album-placeholder"></div>
|
||||
<div class="vinyl-bg"></div>
|
||||
</div>
|
||||
|
||||
<div class="track-info">
|
||||
<div class="track-title-row">
|
||||
<span class="track-name">{{ currentSong?.name || '未知歌曲' }}</span>
|
||||
<span class="track-artist"> - {{ currentSong?.artist || '未知歌手' }}</span>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button class="icon-btn tiny" title="添加到歌单">
|
||||
<Icon icon="lucide:plus-circle" />
|
||||
</button>
|
||||
<button class="icon-btn tiny" title="下载">
|
||||
<Icon icon="lucide:download" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Controls & Progress -->
|
||||
<div class="player-center">
|
||||
<div class="controls-row">
|
||||
<button class="icon-btn small" :class="{ active: isLiked }" @click="toggleLike" title="喜欢">
|
||||
<Icon :icon="isLiked ? 'lucide:heart' : 'lucide:heart'" :class="{ filled: isLiked }" />
|
||||
</button>
|
||||
|
||||
<button class="icon-btn" @click="prev" title="上一首">
|
||||
<Icon icon="lucide:skip-back" />
|
||||
</button>
|
||||
|
||||
<button class="play-btn" @click="togglePlay">
|
||||
<Icon :icon="isPlaying ? 'lucide:pause' : 'lucide:play'" class="play-icon" />
|
||||
</button>
|
||||
|
||||
<button class="icon-btn" @click="next" title="下一首">
|
||||
<Icon icon="lucide:skip-forward" />
|
||||
</button>
|
||||
|
||||
<button class="icon-btn small" @click="toggleMode" :title="modeTitle">
|
||||
<Icon :icon="modeIcon" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<span class="time-text">{{ formatTime(currentTime) }}</span>
|
||||
<div class="slider-container">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="duration || 100"
|
||||
:value="currentTime"
|
||||
class="custom-slider"
|
||||
@input="onSeek"
|
||||
>
|
||||
<div class="slider-track" :style="{ width: progressPercent + '%' }"></div>
|
||||
</div>
|
||||
<span class="time-text">{{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Volume & Playlist -->
|
||||
<div class="player-right">
|
||||
<div class="volume-control">
|
||||
<button class="icon-btn small" @click="toggleMute">
|
||||
<Icon :icon="volumeIcon" />
|
||||
</button>
|
||||
<div class="slider-container volume-slider">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="volume"
|
||||
class="custom-slider"
|
||||
@input="onVolumeChange"
|
||||
>
|
||||
<div class="slider-track" :style="{ width: volume + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider">|</div>
|
||||
|
||||
<button class="icon-btn playlist-toggle" @click="togglePlaylist" title="播放列表">
|
||||
<Icon icon="lucide:list-music" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore, PlayMode } from '../../stores/player';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
const { isPlaying, currentSong, currentTime, duration, volume, playMode, playlist } = storeToRefs(playerStore);
|
||||
|
||||
const hasSongs = computed(() => playlist.value.length > 0);
|
||||
const isLiked = ref(false); // Mock like state
|
||||
|
||||
// Icons
|
||||
const modeIcon = computed(() => {
|
||||
switch (playMode.value) {
|
||||
case PlayMode.Single: return 'lucide:repeat-1';
|
||||
case PlayMode.Random: return 'lucide:shuffle';
|
||||
default: return 'lucide:repeat';
|
||||
}
|
||||
});
|
||||
|
||||
const modeTitle = computed(() => {
|
||||
switch (playMode.value) {
|
||||
case PlayMode.Single: return '单曲循环';
|
||||
case PlayMode.Random: return '随机播放';
|
||||
default: return '列表循环';
|
||||
}
|
||||
});
|
||||
|
||||
const volumeIcon = computed(() => {
|
||||
if (volume.value === 0) return 'lucide:volume-x';
|
||||
if (volume.value < 50) return 'lucide:volume-1';
|
||||
return 'lucide:volume-2';
|
||||
});
|
||||
|
||||
// Progress
|
||||
const progressPercent = computed(() => {
|
||||
if (!duration.value) return 0;
|
||||
return (currentTime.value / duration.value) * 100;
|
||||
});
|
||||
|
||||
// Actions
|
||||
const togglePlay = () => playerStore.togglePlay();
|
||||
const next = () => playerStore.next();
|
||||
const prev = () => playerStore.prev();
|
||||
const toggleMode = () => playerStore.toggleMode();
|
||||
const toggleLike = () => { isLiked.value = !isLiked.value; };
|
||||
const togglePlaylist = () => { /* TODO: Toggle Playlist Drawer */ };
|
||||
|
||||
const onSeek = (e: Event) => {
|
||||
const val = Number((e.target as HTMLInputElement).value);
|
||||
playerStore.seek(val);
|
||||
};
|
||||
|
||||
const onVolumeChange = (e: Event) => {
|
||||
const val = Number((e.target as HTMLInputElement).value);
|
||||
playerStore.setVolume(val);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (volume.value > 0) playerStore.setVolume(0);
|
||||
else playerStore.setVolume(50);
|
||||
};
|
||||
|
||||
const handleBarClick = (e: MouseEvent) => {
|
||||
// Prevent triggering when clicking on controls/inputs
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.closest('button') ||
|
||||
target.closest('input') ||
|
||||
target.closest('.icon-btn') ||
|
||||
target.closest('.play-btn')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
playerStore.toggleFullScreen();
|
||||
};
|
||||
|
||||
// Utils
|
||||
const formatTime = (seconds2: number) => {
|
||||
const seconds = Math.floor(seconds2 / 1000);
|
||||
if (!seconds || isNaN(seconds)) return '00:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.player-bar {
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 80px; /* Established height */
|
||||
background-color: var(--color-bg-secondary);
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
|
||||
transition: var(--theme-transition);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* --- Left Section --- */
|
||||
.player-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vinyl-wrapper {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
min-width: 64px;
|
||||
min-height: 64px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Dark background for vinyl effect */
|
||||
background: #1a1a1a;
|
||||
box-shadow: var(--shadow-md);
|
||||
animation: spin 10s linear infinite;
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.vinyl-wrapper.playing {
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
/* Vinyl overlay (Background) */
|
||||
.vinyl-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background-image: url('https://s5.music.126.net/static_public/68aea63daca57500bb3fb4b6_68aea63daca57500bb3fb4b7/public/assets/img/play/miniVinyl.png');
|
||||
background-size: cover;
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Album art (Foreground) */
|
||||
.album-art {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
z-index: 10;
|
||||
position: relative; /* Ensure z-index applies */
|
||||
}
|
||||
|
||||
.album-placeholder {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-bg-tertiary);
|
||||
z-index: 10;
|
||||
position: relative; /* Ensure z-index applies */
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.track-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.track-title-row {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.track-name {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* --- Center Section --- */
|
||||
.player-center {
|
||||
flex: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
width: 35px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* --- Right Section --- */
|
||||
.player-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: var(--color-border);
|
||||
font-size: 12px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* --- Common UI Components --- */
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.icon-btn.small { width: 32px; height: 32px; }
|
||||
.icon-btn.tiny { width: 24px; height: 24px; padding: 0; color: var(--color-text-muted); }
|
||||
|
||||
.icon-btn.active { color: var(--color-accent); }
|
||||
.filled { fill: currentColor; }
|
||||
|
||||
.play-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Custom Slider */
|
||||
.slider-container {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
flex: 1;
|
||||
border-radius: 2px;
|
||||
background: var(--color-bg-tertiary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.custom-slider {
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
105
src/components/player/TextMarquee.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div
|
||||
ref="outerDiv"
|
||||
class="textMarquee"
|
||||
:class="{ [className || '']: true }"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<div ref="innerDiv">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
className?: string;
|
||||
}>();
|
||||
|
||||
const outerDiv = ref<HTMLDivElement | null>(null);
|
||||
const innerDiv = ref<HTMLDivElement | null>(null);
|
||||
const currentAnimations = new Set<Animation>();
|
||||
|
||||
const onMouseEnter = () => {
|
||||
if (innerDiv.value && outerDiv.value) {
|
||||
const outerWidth = outerDiv.value.clientWidth;
|
||||
const innerWidth = innerDiv.value.clientWidth;
|
||||
|
||||
if (innerWidth <= outerWidth * 0.95) {
|
||||
return;
|
||||
}
|
||||
|
||||
outerDiv.value.classList.add('animating');
|
||||
|
||||
const distance = innerWidth - outerWidth * 0.95;
|
||||
|
||||
const ani = innerDiv.value.animate(
|
||||
[
|
||||
{
|
||||
transform: "translateX(0px)",
|
||||
},
|
||||
{
|
||||
transform: `translateX(${-distance}px)`,
|
||||
},
|
||||
],
|
||||
{
|
||||
iterations: 2,
|
||||
direction: "alternate",
|
||||
easing: "linear",
|
||||
duration: Math.max(0, ((distance * 2) / 64) * 1000),
|
||||
},
|
||||
);
|
||||
|
||||
ani.finished.then(() => {
|
||||
outerDiv.value?.classList.remove('animating');
|
||||
});
|
||||
|
||||
currentAnimations.add(ani);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
for (const ani of currentAnimations) {
|
||||
ani.finish();
|
||||
}
|
||||
outerDiv.value?.classList.remove('animating');
|
||||
currentAnimations.clear();
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
onMouseLeave();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.textMarquee {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
-webkit-mask-image: linear-gradient(to right, #000 95%, #0000);
|
||||
mask-image: linear-gradient(to right, #000 95%, #0000);
|
||||
|
||||
&.animating {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to left,
|
||||
#0000,
|
||||
#000 5%,
|
||||
#000 95%,
|
||||
#0000
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to left,
|
||||
#0000,
|
||||
#000 5%,
|
||||
#000 95%,
|
||||
#0000
|
||||
);
|
||||
}
|
||||
|
||||
& > * {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
89
src/components/player/ToggleIconButton.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<button
|
||||
class="toggleIconButton"
|
||||
:class="className"
|
||||
type="button"
|
||||
@click="handleClick"
|
||||
>
|
||||
<img :src="iconSrc" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
// Icons
|
||||
import LyricsOffIcon from '@assets/lyrics_off.svg';
|
||||
import LyricsOnIcon from '@assets/lyrics_on.svg';
|
||||
import PlaylistOffIcon from '@assets/playlist_off.svg';
|
||||
import PlaylistOnIcon from '@assets/playlist_on.svg';
|
||||
import AirplayIcon from '@assets/airplay.svg';
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'lyrics' | 'playlist' | 'airplay';
|
||||
checked?: boolean;
|
||||
className?: string;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
}>();
|
||||
|
||||
const icons = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'lyrics':
|
||||
return [LyricsOffIcon, LyricsOnIcon];
|
||||
case 'playlist':
|
||||
return [PlaylistOffIcon, PlaylistOnIcon];
|
||||
case 'airplay':
|
||||
return [AirplayIcon, AirplayIcon];
|
||||
default:
|
||||
return [AirplayIcon, AirplayIcon];
|
||||
}
|
||||
});
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
return props.checked ? icons.value[1] : icons.value[0];
|
||||
});
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (props.onClick) {
|
||||
props.onClick(event);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toggleIconButton {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
aspect-ratio: 1 / 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: currentColor;
|
||||
|
||||
opacity: 0.7;
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
cursor: default;
|
||||
|
||||
& img {
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
aspect-ratio: 1 / 1;
|
||||
filter: brightness(0) invert(1); /* Force white color */
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1600px), (max-height: 1000px) {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
|
||||
& img {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggleIconButton:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
191
src/components/player/VolumeControl.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="volumeControl" :class="className" :style="style">
|
||||
<div class="icon-container" ref="minSpeakerRef" @click="onClickMin">
|
||||
<svg width="24" height="24" viewBox="0 0 32 40" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M14.9042 27.1802C14.4202 27.1802 14.0473 26.9897 13.595 26.5612L10.3815 23.5461C10.3339 23.5065 10.2863 23.4906 10.2228 23.4906H8.01703C6.70778 23.4906 5.99365 22.7527 5.99365 21.38V18.4442C5.99365 17.0715 6.70778 16.3257 8.01703 16.3257H10.2307C10.2863 16.3257 10.3418 16.3019 10.3815 16.2622L13.595 13.2709C14.079 12.8107 14.4361 12.6282 14.8883 12.6282C15.6104 12.6282 16.142 13.1915 16.142 13.8977V25.9344C16.142 26.6406 15.6104 27.1802 14.9042 27.1802Z"
|
||||
class="speaker-bounce-1"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<BouncingSlider
|
||||
:value="modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:change-on-drag="true"
|
||||
@update:value="onUpdateValue"
|
||||
class="slider"
|
||||
/>
|
||||
|
||||
<div class="icon-container" ref="maxSpeakerRef" @click="onClickMax">
|
||||
<svg width="32" height="32" viewBox="0 0 43 40" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M24.0403 27.1802C23.5642 27.1802 23.1913 26.9897 22.739 26.5612L19.5176 23.5461C19.4779 23.5065 19.4224 23.4906 19.3668 23.4906H17.161C15.8518 23.4906 15.1377 22.7527 15.1377 21.38V18.4442C15.1377 17.0715 15.8518 16.3257 17.161 16.3257H19.3668C19.4303 16.3257 19.4779 16.3019 19.5255 16.2622L22.739 13.2709C23.223 12.8107 23.5721 12.6282 24.0324 12.6282C24.7544 12.6282 25.286 13.1915 25.286 13.8977V25.9344C25.286 26.6406 24.7544 27.1802 24.0403 27.1802Z"
|
||||
class="speaker-bounce-1"
|
||||
/>
|
||||
<path
|
||||
d="M28.0948 23.6653C27.6028 23.3559 27.4996 22.7687 27.8964 22.1101C28.2931 21.4991 28.5232 20.7136 28.5232 19.8964C28.5232 19.0712 28.301 18.2856 27.8964 17.6826C27.4917 17.032 27.6028 16.4369 28.0948 16.1274C28.547 15.8418 29.1104 15.9529 29.404 16.3576C30.0863 17.3097 30.491 18.5713 30.491 19.8964C30.491 21.2214 30.0863 22.4831 29.404 23.4273C29.1104 23.8399 28.547 23.943 28.0948 23.6653Z"
|
||||
class="speaker-bounce-2"
|
||||
/>
|
||||
<path
|
||||
d="M31.6733 25.8711C31.1576 25.5696 31.0942 24.9428 31.4432 24.3794C32.2526 23.1257 32.7207 21.5468 32.7207 19.8964C32.7207 18.2459 32.2605 16.6591 31.4432 15.4133C31.0942 14.8499 31.1576 14.2231 31.6733 13.9137C32.1415 13.6439 32.7128 13.755 33.0143 14.2152C34.0855 15.7783 34.6885 17.8016 34.6885 19.8964C34.6885 21.9911 34.0775 23.9985 33.0143 25.5775C32.7128 26.0377 32.1415 26.1488 31.6733 25.8711Z"
|
||||
class="speaker-bounce-3"
|
||||
/>
|
||||
<path
|
||||
d="M35.2362 28.1007C34.7363 27.7992 34.6569 27.1803 34.9981 26.6249C36.1883 24.7286 36.9104 22.4196 36.9104 19.9122C36.9104 17.397 36.1883 15.0881 34.9981 13.1917C34.6569 12.6362 34.7363 12.0174 35.2362 11.7159C35.7123 11.4302 36.3073 11.5651 36.6088 12.0571C38.0133 14.2866 38.8702 16.9765 38.8702 19.9122C38.8702 22.8401 38.0291 25.5379 36.6088 27.7675C36.3073 28.2515 35.7123 28.3864 35.2362 28.1007Z"
|
||||
class="speaker-bounce-4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import BouncingSlider from './BouncingSlider.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number;
|
||||
min: number;
|
||||
max: number;
|
||||
className?: string;
|
||||
onUpdate: (value: number) => void;
|
||||
style?: any;
|
||||
}>();
|
||||
|
||||
const minSpeakerRef = ref<HTMLElement | null>(null);
|
||||
const maxSpeakerRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const onUpdateValue = (value: number) => {
|
||||
props.onUpdate(value);
|
||||
};
|
||||
|
||||
const triggerAnimation = (ref: HTMLElement | null) => {
|
||||
if(!ref) return;
|
||||
ref.classList.remove('speakerAnimate');
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
ref.classList.add('speakerAnimate');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const onClickMin = () => {
|
||||
onUpdateValue(props.min);
|
||||
triggerAnimation(minSpeakerRef.value);
|
||||
}
|
||||
|
||||
const onClickMax = () => {
|
||||
onUpdateValue(props.max);
|
||||
triggerAnimation(maxSpeakerRef.value);
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (newVal, oldVal) => {
|
||||
if (newVal === oldVal) return;
|
||||
|
||||
if (newVal <= props.min && minSpeakerRef.value) {
|
||||
minSpeakerRef.value.classList.remove('speakerAnimate');
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
minSpeakerRef.value?.classList.add('speakerAnimate');
|
||||
});
|
||||
});
|
||||
} else if (newVal >= props.max && maxSpeakerRef.value) {
|
||||
maxSpeakerRef.value.classList.remove('speakerAnimate');
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
maxSpeakerRef.value?.classList.add('speakerAnimate');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.volumeControl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: white; /* Ensure icons are white */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Ensure SVG fills container and maintains aspect ratio */
|
||||
.icon-container svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes speaker-bounce-part1 {
|
||||
to {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes speaker-bounce-part2 {
|
||||
to {
|
||||
transform: scale(0.85);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes speaker-bounce-part3 {
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Global scope needed for SVG internal classes if they are not scoped by Vue deep selector?
|
||||
Actually, since SVGs are inlined, scoped CSS should work if we use deep selector or just global class.
|
||||
The React code used :global, so we'll simulate that by not scoping these specific animations classes or using :deep()
|
||||
*/
|
||||
|
||||
:deep(.speakerAnimate) .speaker-bounce-1 {
|
||||
animation:
|
||||
speaker-bounce-part1 0.2s ease-in-out 0s forwards,
|
||||
speaker-bounce-part2 0.2s ease-in-out 0.2s forwards,
|
||||
speaker-bounce-part3 0.2s ease-in-out 0.4s forwards;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
:deep(.speakerAnimate) .speaker-bounce-2 {
|
||||
animation:
|
||||
speaker-bounce-part1 0.2s ease-in-out 0.05s forwards,
|
||||
speaker-bounce-part2 0.2s ease-in-out 0.25s forwards,
|
||||
speaker-bounce-part3 0.2s ease-in-out 0.45s forwards;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
:deep(.speakerAnimate) .speaker-bounce-3 {
|
||||
animation:
|
||||
speaker-bounce-part1 0.2s ease-in-out 0.1s forwards,
|
||||
speaker-bounce-part2 0.2s ease-in-out 0.3s forwards,
|
||||
speaker-bounce-part3 0.2s ease-in-out 0.5s forwards;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
:deep(.speakerAnimate) .speaker-bounce-4 {
|
||||
animation:
|
||||
speaker-bounce-part1 0.2s ease-in-out 0.15s forwards,
|
||||
speaker-bounce-part2 0.2s ease-in-out 0.35s forwards,
|
||||
speaker-bounce-part3 0.2s ease-in-out 0.55s forwards;
|
||||
transform-origin: center;
|
||||
}
|
||||
</style>
|
||||
14
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-svg-loader" />
|
||||
|
||||
declare module '*.svg' {
|
||||
import { Component } from 'vue';
|
||||
const content: Component;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.svg?component' {
|
||||
import { Component } from 'vue';
|
||||
const content: Component;
|
||||
export default content;
|
||||
}
|
||||
107
src/layout/MainLayout.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="main-layout" :class="{ 'has-player': hasSongs }">
|
||||
<Sidebar class="layout-sidebar" />
|
||||
<main class="content-area">
|
||||
<TopBar />
|
||||
<div class="page-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</main>
|
||||
<PlayerBar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import Sidebar from '../components/Sidebar.vue';
|
||||
import TopBar from '../components/TopBar.vue';
|
||||
import PlayerBar from '../components/player/PlayerBar.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
const hasSongs = computed(() => playerStore.playlist.length > 0);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 这确保 width: 100% + padding 不会撑破容器 */
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 隐藏 body 的滚动条,防止整体页面出现原生滚动条 */
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.main-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background-color: var(--color-bg-primary);
|
||||
overflow: hidden; /* 确保整个应用不会出现双重滚动条 */
|
||||
}
|
||||
|
||||
/* Dynamic Spacing for PlayerBar */
|
||||
.layout-sidebar,
|
||||
.content-area {
|
||||
transition: padding-bottom 0.3s ease;
|
||||
}
|
||||
|
||||
.main-layout.has-player .layout-sidebar,
|
||||
.main-layout.has-player .content-area {
|
||||
padding-bottom: 80px; /* PlayerBar Height */
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
background-color: var(--color-bg-primary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 滚动条样式优化 */
|
||||
.page-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.page-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.page-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Route Transition Utils */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
44
src/main.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// src/renderer/main.ts
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
import App from './App.vue'
|
||||
import 'tdesign-vue-next/es/style/index.css'
|
||||
|
||||
const pinia = createPinia()
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('./views/Home.vue')
|
||||
},
|
||||
{
|
||||
path: '/local',
|
||||
name: 'Local',
|
||||
component: () => import('./views/LocalMusic.vue')
|
||||
},
|
||||
{
|
||||
path: '/liked',
|
||||
name: 'Liked',
|
||||
component: () => import('./views/Playlist.vue')
|
||||
},
|
||||
{
|
||||
path: '/recent',
|
||||
name: 'Recent',
|
||||
component: () => import('./views/Playlist.vue')
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
name: 'Search',
|
||||
component: () => import('./views/Search.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
14
src/shims.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
376
src/stores/player.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, shallowRef, watch } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import type { Song } from '../types/song';
|
||||
import { parseLyric } from '../utils/lyricUtil'
|
||||
|
||||
export enum PlayMode {
|
||||
List = 'list',
|
||||
Single = 'single',
|
||||
Random = 'random'
|
||||
}
|
||||
|
||||
let audioElement: HTMLAudioElement | null = null;
|
||||
let audioContext: AudioContext | null = null;
|
||||
let analyser: AnalyserNode | null = null;
|
||||
let animationFrameId: number | null = null;
|
||||
|
||||
export const usePlayerStore = defineStore('player', () => {
|
||||
// State
|
||||
const isPlaying = ref(false);
|
||||
const currentSong = ref<Song | null>(null);
|
||||
const savedVolume = localStorage.getItem('qz-player-volume');
|
||||
const volume = ref(savedVolume ? Number(savedVolume) : 50); // 1~100
|
||||
|
||||
// Persistence & Sync
|
||||
watch(volume, (newVol) => {
|
||||
localStorage.setItem('qz-player-volume', newVol.toString());
|
||||
if (audioElement) {
|
||||
audioElement.volume = newVol / 100;
|
||||
}
|
||||
});
|
||||
|
||||
const duration = ref(0); //毫秒级
|
||||
const currentTime = ref(0); //毫秒级
|
||||
|
||||
// Audio Visualization State
|
||||
const loudness = ref(0);
|
||||
const spectrum = ref<number[]>([]);
|
||||
|
||||
// UI State
|
||||
const isPlayerFullScreen = ref(false);
|
||||
const hideLyricView = ref(false);
|
||||
|
||||
// Playlist State
|
||||
const savedPlaylist = localStorage.getItem('qz-player-playlist');
|
||||
const savedIndex = localStorage.getItem('qz-player-index');
|
||||
|
||||
const playlist = ref<Song[]>(savedPlaylist ? JSON.parse(savedPlaylist) : []);
|
||||
const currentIndex = ref(savedIndex ? Number(savedIndex) : -1);
|
||||
const playMode = ref<PlayMode>(PlayMode.List);
|
||||
const savedAddMode = localStorage.getItem('qz-player-add-mode');
|
||||
const addListMode = ref<'replace' | 'append'>((savedAddMode as 'replace' | 'append') || 'replace');
|
||||
|
||||
// Error Handling
|
||||
const playErrorCount = ref(0);
|
||||
const MAX_RETRY_COUNT = 3;
|
||||
|
||||
// Lyrics State
|
||||
const lyrics = shallowRef<{ lines: any[] }>({ lines: [] });
|
||||
|
||||
// Initialize audio element
|
||||
const initAudio = () => {
|
||||
if (!audioElement && typeof window !== 'undefined') {
|
||||
audioElement = new Audio();
|
||||
audioElement.volume = volume.value / 100;
|
||||
|
||||
// Setup audio context for visualization
|
||||
if (window.AudioContext || (window as any).webkitAudioContext) {
|
||||
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
const source = audioContext.createMediaElementSource(audioElement);
|
||||
source.connect(analyser);
|
||||
analyser.connect(audioContext.destination);
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
audioElement.addEventListener('loadedmetadata', () => {
|
||||
if (audioElement) {
|
||||
duration.value = audioElement.duration * 1000;
|
||||
}
|
||||
});
|
||||
|
||||
audioElement.addEventListener('timeupdate', () => {
|
||||
if (audioElement) {
|
||||
currentTime.value = audioElement.currentTime * 1000;
|
||||
}
|
||||
});
|
||||
|
||||
audioElement.addEventListener('play', () => {
|
||||
isPlaying.value = true;
|
||||
startVisualization();
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = 'playing';
|
||||
}
|
||||
});
|
||||
|
||||
audioElement.addEventListener('pause', () => {
|
||||
isPlaying.value = false;
|
||||
stopVisualization();
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = 'paused';
|
||||
}
|
||||
});
|
||||
|
||||
audioElement.addEventListener('ended', () => {
|
||||
next(false);
|
||||
});
|
||||
|
||||
audioElement.addEventListener('error', (e) => {
|
||||
console.error('Audio error:', e);
|
||||
handlePlayError();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const startVisualization = () => {
|
||||
if (!analyser || !audioContext) return;
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
const animate = () => {
|
||||
if (!isPlaying.value) return;
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
analyser?.getByteFrequencyData(dataArray);
|
||||
|
||||
// Calculate average loudness
|
||||
let sum = 0;
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
sum += dataArray[i];
|
||||
}
|
||||
loudness.value = sum / bufferLength;
|
||||
|
||||
// Update spectrum
|
||||
spectrum.value = Array.from(dataArray);
|
||||
};
|
||||
animate();
|
||||
};
|
||||
|
||||
const stopVisualization = () => {
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
const setPlaylist = async (list: any[], startIndex = 0) => {
|
||||
playlist.value = list;
|
||||
currentIndex.value = startIndex;
|
||||
if (list.length > 0 && startIndex >= 0 && startIndex < list.length) {
|
||||
await playSong(list[startIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const playFromList = async (song: Song, contextList: Song[]) => {
|
||||
if (addListMode.value === 'replace') {
|
||||
const index = contextList.findIndex(s => s.id === song.id);
|
||||
if (index !== -1) {
|
||||
await setPlaylist(contextList, index);
|
||||
} else {
|
||||
await setPlaylist([song], 0);
|
||||
}
|
||||
} else {
|
||||
playlist.value.push(song);
|
||||
const newIndex = playlist.value.length - 1;
|
||||
currentIndex.value = newIndex;
|
||||
await playSong(song);
|
||||
}
|
||||
};
|
||||
|
||||
const playSong = async (song: Song, autoPlay = true) => {
|
||||
if (!song) return;
|
||||
initAudio();
|
||||
|
||||
console.log(song);
|
||||
currentSong.value = song;
|
||||
const foundIndex = playlist.value.findIndex(s => s.id === song.id);
|
||||
if (foundIndex !== -1) {
|
||||
currentIndex.value = foundIndex;
|
||||
}
|
||||
|
||||
updateMediaSession(song);
|
||||
fetchLyrics(song);
|
||||
|
||||
// Resume audio context if suspended (for autoplay policy)
|
||||
if (audioContext && audioContext.state === 'suspended') {
|
||||
await audioContext.resume();
|
||||
}
|
||||
|
||||
let playUrl = song.url;
|
||||
if (song.type === 'Remote' && song.source) {
|
||||
const quality = 'hires';
|
||||
playUrl = `http://localhost:5266/music?source=${song.source}&id=${song.id}&quality=${quality}`;
|
||||
console.log('[Player] Using Proxy:', playUrl);
|
||||
}
|
||||
|
||||
if (playUrl && audioElement) {
|
||||
console.log('Playing:', song.name, 'AutoPlay:', autoPlay);
|
||||
try {
|
||||
audioElement.src = playUrl;
|
||||
audioElement.load();
|
||||
if (autoPlay) {
|
||||
await audioElement.play();
|
||||
}
|
||||
song.url = playUrl;
|
||||
playErrorCount.value = 0;
|
||||
} catch (e) {
|
||||
console.error("Play request failed:", e);
|
||||
if (autoPlay) handlePlayError();
|
||||
}
|
||||
} else {
|
||||
console.warn("Song has no URL");
|
||||
if (autoPlay) handlePlayError();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLyrics = async (song: Song) => {
|
||||
lyrics.value = { lines: [] };
|
||||
if (!song || !song.id) return;
|
||||
try {
|
||||
// TODO: Implement web-based lyric fetching
|
||||
MessagePlugin.info("网页版暂不支持歌词获取").then();
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch lyrics:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMediaSession = (song: Song) => {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: song.name,
|
||||
artist: song.artist,
|
||||
album: song.albumName || '',
|
||||
artwork: song.picUrl ? [{ src: song.picUrl, sizes: '512x512', type: 'image/png' }] : []
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', () => togglePlay());
|
||||
navigator.mediaSession.setActionHandler('pause', () => togglePlay());
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => prev());
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => next(true));
|
||||
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
||||
if (details.seekTime != null) {
|
||||
seek(details.seekTime);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const next = async (manual = true) => {
|
||||
if (playlist.value.length === 0) return;
|
||||
let nextIndex = currentIndex.value;
|
||||
if (playMode.value === PlayMode.Single && !manual) {
|
||||
nextIndex = currentIndex.value;
|
||||
} else if (playMode.value === PlayMode.Random) {
|
||||
nextIndex = Math.floor(Math.random() * playlist.value.length);
|
||||
} else {
|
||||
nextIndex = (currentIndex.value + 1) % playlist.value.length;
|
||||
}
|
||||
currentIndex.value = nextIndex;
|
||||
await playSong(playlist.value[nextIndex]);
|
||||
};
|
||||
|
||||
const prev = async () => {
|
||||
if (playlist.value.length === 0) return;
|
||||
let prevIndex = currentIndex.value;
|
||||
if (playMode.value === PlayMode.Random) {
|
||||
prevIndex = Math.floor(Math.random() * playlist.value.length);
|
||||
} else {
|
||||
prevIndex = (currentIndex.value - 1 + playlist.value.length) % playlist.value.length;
|
||||
}
|
||||
currentIndex.value = prevIndex;
|
||||
await playSong(playlist.value[prevIndex]);
|
||||
};
|
||||
|
||||
const handlePlayError = async () => {
|
||||
playErrorCount.value++;
|
||||
if (playlist.value.length === 0) {
|
||||
isPlaying.value = false;
|
||||
playErrorCount.value = 0;
|
||||
return;
|
||||
}
|
||||
if (playErrorCount.value >= MAX_RETRY_COUNT) {
|
||||
audioElement?.pause();
|
||||
isPlaying.value = false;
|
||||
MessagePlugin.error('连续多次播放失败,已停止播放').then();
|
||||
playErrorCount.value = 0;
|
||||
} else {
|
||||
MessagePlugin.warning(`播放失败,尝试播放下一首 (${playErrorCount.value}/${MAX_RETRY_COUNT})`).then();
|
||||
next(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlay = async () => {
|
||||
if (!audioElement) {
|
||||
initAudio();
|
||||
}
|
||||
if (audioElement) {
|
||||
if (isPlaying.value) {
|
||||
audioElement.pause();
|
||||
} else {
|
||||
await audioElement.play();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setVolume = async (vol: number) => {
|
||||
volume.value = vol;
|
||||
if (audioElement) {
|
||||
audioElement.volume = vol / 100;
|
||||
}
|
||||
};
|
||||
|
||||
const seek = async (time: number) => {
|
||||
if (audioElement) {
|
||||
audioElement.currentTime = time / 1000;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMode = () => {
|
||||
if (playMode.value === PlayMode.List) playMode.value = PlayMode.Single;
|
||||
else if (playMode.value === PlayMode.Single) playMode.value = PlayMode.Random;
|
||||
else playMode.value = PlayMode.List;
|
||||
};
|
||||
|
||||
const toggleFullScreen = () => {
|
||||
isPlayerFullScreen.value = !isPlayerFullScreen.value;
|
||||
};
|
||||
|
||||
// Persistence Listeners
|
||||
watch(() => [playlist.value, currentIndex.value], () => {
|
||||
localStorage.setItem('qz-player-playlist', JSON.stringify(playlist.value));
|
||||
localStorage.setItem('qz-player-index', currentIndex.value.toString());
|
||||
}, { deep: true });
|
||||
|
||||
watch(addListMode, (newMode) => {
|
||||
localStorage.setItem('qz-player-add-mode', newMode);
|
||||
});
|
||||
|
||||
// Restore initial state
|
||||
if (playlist.value.length > 0 && currentIndex.value >= 0 && currentIndex.value < playlist.value.length) {
|
||||
const restoredSong = playlist.value[currentIndex.value];
|
||||
if (restoredSong) {
|
||||
// Do not auto-play on restore
|
||||
currentSong.value = restoredSong;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
currentSong,
|
||||
volume,
|
||||
duration,
|
||||
currentTime,
|
||||
playlist,
|
||||
playMode,
|
||||
loudness,
|
||||
spectrum,
|
||||
isPlayerFullScreen,
|
||||
setPlaylist,
|
||||
playSong,
|
||||
next,
|
||||
prev,
|
||||
togglePlay,
|
||||
setVolume,
|
||||
seek,
|
||||
toggleMode,
|
||||
toggleFullScreen,
|
||||
lyrics,
|
||||
fetchLyrics,
|
||||
addListMode,
|
||||
playFromList,
|
||||
hideLyricView
|
||||
};
|
||||
});
|
||||
90
src/styles/main.css
Normal file
@@ -0,0 +1,90 @@
|
||||
@import 'variables.css';
|
||||
@import 'touch.css';
|
||||
|
||||
* , *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family-base);
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
/* App-like feel */
|
||||
user-select: none;
|
||||
/* Prevent text selection generally in app */
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input[type='radio'] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 50%;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
input[type='radio']:checked {
|
||||
border-color: var(--color-accent);
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
input[type='radio']:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for 'Exquisite' look */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
-electron-corner-smoothing: 65%;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
109
src/styles/touch.css
Normal file
@@ -0,0 +1,109 @@
|
||||
/* 触控适配样式 */
|
||||
|
||||
/* 基础触控优化 */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* 按钮触控尺寸优化 */
|
||||
button,
|
||||
.nav-item,
|
||||
.playlist-card,
|
||||
.artist-card,
|
||||
.song-item,
|
||||
.more-btn,
|
||||
.play-btn {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* 触控时的视觉反馈 */
|
||||
.touch-feedback {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.touch-feedback::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.touch-feedback:active::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 滚动优化 */
|
||||
.page-content,
|
||||
.sidebar {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* 滚动条触控优化 - 更宽的滚动条便于触控 */
|
||||
.page-content::-webkit-scrollbar,
|
||||
.sidebar::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
/* 交互元素触控优化 */
|
||||
.nav-item,
|
||||
.playlist-card,
|
||||
.artist-card,
|
||||
.song-item {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* 输入框触控优化 */
|
||||
input,
|
||||
textarea {
|
||||
font-size: 16px; /* 防止 iOS 缩放 */
|
||||
}
|
||||
|
||||
/* 播放控制按钮触控优化 */
|
||||
.media-button {
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 响应式触摸友好的间距 */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* 触控设备的优化 */
|
||||
.content-wrapper {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.playlist-grid,
|
||||
.artist-grid {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 显示覆盖层,不需要悬停也能看到 */
|
||||
.play-overlay {
|
||||
opacity: 0.7 !important;
|
||||
}
|
||||
|
||||
/* 触控时的点击反馈 */
|
||||
.playlist-card:active,
|
||||
.artist-card:active,
|
||||
.song-item:active,
|
||||
.nav-item:active {
|
||||
transform: scale(0.98);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
}
|
||||
90
src/styles/variables.css
Normal file
@@ -0,0 +1,90 @@
|
||||
:root {
|
||||
/* Theme transition */
|
||||
--theme-transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
|
||||
/* Dynamic accent color (set via JS) */
|
||||
--color-accent: #ec4141;
|
||||
--color-accent-hover: color-mix(in srgb, var(--color-accent) 85%, white);
|
||||
--color-accent-soft: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||
|
||||
/* Typography */
|
||||
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--font-size-2xl: 1.5rem;
|
||||
|
||||
/* Spacing & Radius */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 20px;
|
||||
--radius-xl: 24px;
|
||||
--radius-2xl: 32px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--sidebar-width: 240px;
|
||||
--topbar-height: 64px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-base: 0.25s ease;
|
||||
--transition-slow: 0.35s ease;
|
||||
}
|
||||
|
||||
/* Dark Theme (default) */
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--color-bg-primary: #121212;
|
||||
--color-bg-secondary: #181818;
|
||||
--color-bg-tertiary: #282828;
|
||||
--color-bg-elevated: #2a2a2a;
|
||||
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #b3b3b3;
|
||||
--color-text-muted: #737373;
|
||||
|
||||
--color-border: #2a2a2a;
|
||||
--color-border-light: #3a3a3a;
|
||||
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.16);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.24);
|
||||
--shadow-elevated: 0 12px 48px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
/* Light Theme */
|
||||
[data-theme="light"] {
|
||||
--color-bg-primary: #ffffff;
|
||||
--color-bg-secondary: rgba(40, 50, 72, 0.03);
|
||||
--color-bg-tertiary: #ebebeb;
|
||||
--color-bg-elevated: #e0e0e0;
|
||||
|
||||
--color-text-primary: #1a1a1a;
|
||||
--color-text-secondary: #5c5c5c;
|
||||
--color-text-muted: #8c8c8c;
|
||||
|
||||
--color-border: #e0e0e0;
|
||||
--color-border-light: #d0d0d0;
|
||||
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
--shadow-elevated: 0 12px 48px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
/* Apply theme transition to common elements */
|
||||
body,
|
||||
.sidebar,
|
||||
.topbar,
|
||||
.settings-overlay,
|
||||
.settings-container,
|
||||
.settings-nav,
|
||||
.settings-content,
|
||||
.nav-item,
|
||||
.setting-item,
|
||||
.action-btn,
|
||||
.toggle-slider {
|
||||
transition: var(--theme-transition);
|
||||
}
|
||||
24
src/types/song.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Song Type Definition
|
||||
|
||||
export type SongType = 'Local' | 'Remote';
|
||||
|
||||
export interface SongQualityMap {
|
||||
[quality: string]: string; // e.g., "standard": "5.5M", "exhigh": "8.0M"
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
id: string;
|
||||
hash?: string | null;
|
||||
picUrl: string;
|
||||
url: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
duration: string;
|
||||
source: string;
|
||||
quality?: string; // default 'auto'
|
||||
albumId?: string | null;
|
||||
albumName?: string | null;
|
||||
artistIds?: string[] | null;
|
||||
type: SongType;
|
||||
types?: SongQualityMap;
|
||||
}
|
||||
57
src/utils/lyricUtil.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {LyricLine, parseLrc, parseQrc, parseTTML, parseYrc} from "@applemusic-like-lyrics/lyric";
|
||||
const sanitizeLyricLines = (lines: LyricLine[]): LyricLine[] => {
|
||||
const defaultLineDuration = 3000
|
||||
const toFiniteNumber = (v: any, fallback: number) => {
|
||||
const n = typeof v === 'number' ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
const cleaned: LyricLine[] = []
|
||||
for (const rawLine of lines || []) {
|
||||
const rawWords = Array.isArray((rawLine as any).words) ? (rawLine as any).words : []
|
||||
const fixedWords: any[] = []
|
||||
let prevEnd = -1
|
||||
for (const rawWord of rawWords) {
|
||||
const rawStart = toFiniteNumber(rawWord?.startTime, Number.NaN)
|
||||
const rawEnd = toFiniteNumber(rawWord?.endTime, Number.NaN)
|
||||
if (!Number.isFinite(rawStart)) continue
|
||||
let startTime = Math.max(0, rawStart)
|
||||
if (startTime < prevEnd) startTime = prevEnd
|
||||
let endTime = Number.isFinite(rawEnd) ? rawEnd : startTime + 1
|
||||
if (endTime <= startTime) endTime = startTime + 1
|
||||
prevEnd = endTime
|
||||
fixedWords.push({ ...rawWord, startTime, endTime })
|
||||
}
|
||||
if (fixedWords.length === 0) continue
|
||||
|
||||
const firstWordStart = fixedWords[0].startTime
|
||||
const lastWordEnd = fixedWords[fixedWords.length - 1].endTime
|
||||
let startTime = toFiniteNumber((rawLine as any).startTime, firstWordStart)
|
||||
startTime = Math.max(0, startTime)
|
||||
let endTime = toFiniteNumber((rawLine as any).endTime, lastWordEnd)
|
||||
if (!Number.isFinite(endTime) || endTime <= startTime) endTime = startTime + defaultLineDuration
|
||||
if (endTime < lastWordEnd) endTime = lastWordEnd
|
||||
|
||||
cleaned.push({ ...(rawLine as any), startTime, endTime, words: fixedWords })
|
||||
}
|
||||
cleaned.sort((a: any, b: any) => (a?.startTime ?? 0) - (b?.startTime ?? 0))
|
||||
return cleaned
|
||||
}
|
||||
interface LyricData {
|
||||
ttml?: string,
|
||||
yrc?: string,
|
||||
lrc?: string,
|
||||
qrc?: string
|
||||
}
|
||||
export function parseLyric(lyric: LyricData):LyricLine[] {
|
||||
let parsed:LyricLine[] = []
|
||||
if (lyric.ttml != undefined) {
|
||||
parsed = parseTTML(lyric.ttml).lines;
|
||||
} else if (lyric.yrc != undefined) {
|
||||
parsed = parseYrc(lyric.yrc);
|
||||
} else if (lyric.lrc != undefined) {
|
||||
parsed = parseLrc(lyric.lrc);
|
||||
} else if (lyric.qrc != undefined) {
|
||||
parsed = parseQrc(lyric.qrc)
|
||||
}
|
||||
return sanitizeLyricLines(parsed);
|
||||
}
|
||||
25
src/utils/songUtils.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Song } from '../types/song';
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function transformSearchSong(raw: any): Song {
|
||||
return {
|
||||
id: String(raw.songmid),
|
||||
name: raw.name,
|
||||
artist: raw.singer,
|
||||
picUrl: raw.img || raw.m_img || raw.s_img,
|
||||
url: '', // Empty initially
|
||||
duration: formatDuration(raw.interval ? Number(raw.interval) : 0),
|
||||
source: raw.source,
|
||||
albumId: raw.albumId ? String(raw.albumId) : null,
|
||||
albumName: raw.albumName,
|
||||
type: 'Remote',
|
||||
quality: 'auto',
|
||||
types: raw.types // Store raw types for quality selection later
|
||||
};
|
||||
}
|
||||
490
src/views/Home.vue
Normal file
@@ -0,0 +1,490 @@
|
||||
<template>
|
||||
<div class="view-container home-view">
|
||||
<div class="content-wrapper">
|
||||
<!-- 每日推荐横幅 -->
|
||||
<div class="daily-recommend">
|
||||
<div class="banner-content" @click="openDailyRecommend">
|
||||
<div class="date-badge">
|
||||
<div class="day">{{ currentDate.day }}</div>
|
||||
<div class="month">{{ currentDate.month }}月</div>
|
||||
</div>
|
||||
<div class="banner-info">
|
||||
<h2 class="banner-title">每日推荐</h2>
|
||||
<p class="banner-desc">根据你的音乐口味,为你精选30首歌曲</p>
|
||||
<button class="play-btn" @click.stop="playDailyRecommend">
|
||||
<Icon icon="lucide:play" class="play-icon" />
|
||||
立即播放
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推荐歌单 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">推荐歌单</h3>
|
||||
<button class="more-btn">更多</button>
|
||||
</div>
|
||||
<div class="playlist-grid">
|
||||
<div
|
||||
class="playlist-card"
|
||||
v-for="playlist in playlists"
|
||||
:key="playlist.id"
|
||||
@click="openPlaylist(playlist)"
|
||||
>
|
||||
<div class="playlist-cover" :style="{ background: playlist.gradient }">
|
||||
<div class="play-overlay">
|
||||
<Icon icon="lucide:play" class="overlay-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="playlist-info">
|
||||
<h4 class="playlist-name">{{ playlist.name }}</h4>
|
||||
<p class="playlist-desc">{{ playlist.songCount }}首歌曲</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热门歌手 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">热门歌手</h3>
|
||||
<button class="more-btn">更多</button>
|
||||
</div>
|
||||
<div class="artist-grid">
|
||||
<div
|
||||
class="artist-card"
|
||||
v-for="artist in artists"
|
||||
:key="artist.id"
|
||||
@click="openArtist(artist)"
|
||||
>
|
||||
<div class="artist-avatar" :style="{ background: artist.gradient }">
|
||||
</div>
|
||||
<p class="artist-name">{{ artist.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新歌速递 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">新歌速递</h3>
|
||||
<button class="more-btn" @click="playAllNewSongs">播放全部</button>
|
||||
</div>
|
||||
<div class="song-list">
|
||||
<div
|
||||
class="song-item"
|
||||
v-for="(song, index) in newSongs"
|
||||
:key="song.id"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<div class="song-index">{{ index + 1 }}</div>
|
||||
<div class="song-cover" :style="{ background: song.gradient }">
|
||||
</div>
|
||||
<div class="song-info">
|
||||
<h4 class="song-title">{{ song.title }}</h4>
|
||||
<p class="song-artist">{{ song.artist }}</p>
|
||||
</div>
|
||||
<div class="song-duration">{{ song.duration }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const currentDate = computed(() => {
|
||||
const now = new Date();
|
||||
return {
|
||||
day: now.getDate(),
|
||||
month: now.getMonth() + 1
|
||||
};
|
||||
});
|
||||
|
||||
// 模拟数据
|
||||
const playlists = ref([
|
||||
{ id: 1, name: '华语流行精选', songCount: 50, gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
|
||||
{ id: 2, name: '欧美金曲榜', songCount: 45, gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
|
||||
{ id: 3, name: '轻音乐助眠', songCount: 30, gradient: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
|
||||
{ id: 4, name: '怀旧经典老歌', songCount: 60, gradient: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' },
|
||||
{ id: 5, name: '电子舞曲', songCount: 40, gradient: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)' },
|
||||
{ id: 6, name: '民谣小调', songCount: 35, gradient: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)' }
|
||||
]);
|
||||
|
||||
const artists = ref([
|
||||
{ id: 1, name: '周杰伦', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||||
{ id: 2, name: '林俊杰', gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
|
||||
{ id: 3, name: '邓紫棋', gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
|
||||
{ id: 4, name: '陈奕迅', gradient: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
|
||||
{ id: 5, name: 'Taylor Swift', gradient: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' },
|
||||
{ id: 6, name: 'Ed Sheeran', gradient: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)' },
|
||||
{ id: 7, name: '薛之谦', gradient: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)' },
|
||||
{ id: 8, name: '李荣浩', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }
|
||||
]);
|
||||
|
||||
const newSongs = ref([
|
||||
{ id: 1, title: '稻香', artist: '周杰伦', duration: '03:45', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||||
{ id: 2, title: '晴天', artist: '周杰伦', duration: '04:29', gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
|
||||
{ id: 3, title: '夜曲', artist: '周杰伦', duration: '03:58', gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
|
||||
{ id: 4, title: '江南', artist: '林俊杰', duration: '04:06', gradient: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
|
||||
{ id: 5, title: '光年之外', artist: '邓紫棋', duration: '03:55', gradient: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' },
|
||||
{ id: 6, title: '十年', artist: '陈奕迅', duration: '03:25', gradient: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)' },
|
||||
{ id: 7, title: '演员', artist: '薛之谦', duration: '04:16', gradient: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)' },
|
||||
{ id: 8, title: '李白', artist: '李荣浩', duration: '03:43', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||||
{ id: 9, title: '七里香', artist: '周杰伦', duration: '04:58', gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
|
||||
{ id: 10, title: '可惜没如果', artist: '林俊杰', duration: '04:52', gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' }
|
||||
]);
|
||||
|
||||
// 交互函数
|
||||
const openDailyRecommend = () => {
|
||||
router.push('/playlist');
|
||||
};
|
||||
|
||||
const playDailyRecommend = () => {
|
||||
console.log('播放每日推荐');
|
||||
};
|
||||
|
||||
const openPlaylist = (playlist: any) => {
|
||||
console.log('打开歌单:', playlist);
|
||||
router.push('/playlist');
|
||||
};
|
||||
|
||||
const openArtist = (artist: any) => {
|
||||
console.log('打开歌手:', artist);
|
||||
};
|
||||
|
||||
const playSong = (song: any) => {
|
||||
console.log('播放歌曲:', song);
|
||||
};
|
||||
|
||||
const playAllNewSongs = () => {
|
||||
console.log('播放全部新歌');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 每日推荐横幅 */
|
||||
.daily-recommend {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: var(--radius-2xl);
|
||||
-electron-corner-smoothing: 65%;
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.banner-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -20%;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.date-badge {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 16px 20px;
|
||||
text-align: center;
|
||||
min-width: 100px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.day {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.month {
|
||||
font-size: var(--font-size-lg);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.banner-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.banner-desc {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: var(--font-size-base);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
padding: 12px 32px;
|
||||
border-radius: var(--radius-full);
|
||||
border: none;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* 区域样式 */
|
||||
.section {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-light);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 8px 20px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.more-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
background-color: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
/* 歌单网格 */
|
||||
.playlist-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.playlist-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.playlist-cover {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.playlist-card:hover .play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.overlay-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: white;
|
||||
transform: scale(0.8);
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.playlist-card:hover .overlay-icon {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.playlist-name {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.playlist-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 歌手网格 */
|
||||
.artist-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.artist-card {
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.artist-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.artist-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
margin: 0 auto 12px;
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.artist-name {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 歌曲列表 */
|
||||
.song-list {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.song-item:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.song-index {
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-duration {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
54
src/views/LocalMusic.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="view-container local-view">
|
||||
<h1 class="view-title">Local Files</h1>
|
||||
<div class="empty-state">
|
||||
<div class="icon-box">
|
||||
<Icon icon="lucide:music" width="48" height="48" />
|
||||
</div>
|
||||
<p>No local files scanned yet.</p>
|
||||
<button class="action-btn">Scan Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
background-color: var(--color-bg-secondary);
|
||||
padding: 24px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
margin-top: 24px;
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-bg-primary);
|
||||
padding: 12px 24px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
469
src/views/Playlist.vue
Normal file
@@ -0,0 +1,469 @@
|
||||
<template>
|
||||
<div class="view-container playlist-view">
|
||||
<div class="content-wrapper">
|
||||
<!-- 动态头部设计 -->
|
||||
<div class="playlist-header">
|
||||
<div class="header-bg" :class="themeClass">
|
||||
<div class="flow-circle c1"></div>
|
||||
<div class="flow-circle c2"></div>
|
||||
</div>
|
||||
|
||||
<div class="header-content">
|
||||
<div class="cover-box" :style="{ background: coverGradient }">
|
||||
<div class="icon-wrapper">
|
||||
<Icon :icon="iconName" class="big-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<div class="sub-title">PLAYLIST</div>
|
||||
<h1 class="title">{{ title }}</h1>
|
||||
<div class="meta-info">
|
||||
<div class="avatar-row">
|
||||
<div class="user-avatar">
|
||||
<Icon icon="lucide:user" />
|
||||
</div>
|
||||
<span class="user-name">User</span>
|
||||
</div>
|
||||
<span class="divider">•</span>
|
||||
<span class="count">{{ songCount }} 首歌曲</span>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button class="play-all-btn" :class="themeClass" @click="handlePlayAll">
|
||||
<Icon icon="lucide:play" class="btn-icon" />
|
||||
播放全部
|
||||
</button>
|
||||
<button class="action-btn">
|
||||
<Icon icon="lucide:download" />
|
||||
</button>
|
||||
<button class="action-btn">
|
||||
<Icon icon="lucide:share-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<div class="song-list-container">
|
||||
<div class="list-header">
|
||||
<div class="col-index">#</div>
|
||||
<div class="col-title">标题</div>
|
||||
<div class="col-album">专辑</div>
|
||||
<div class="col-time">时长</div>
|
||||
</div>
|
||||
|
||||
<div class="song-list">
|
||||
<div class="song-item" v-for="(song, i) in songs" :key="song.id" @dblclick="handlePlaySong(i)">
|
||||
<div class="song-index">{{ i + 1 }}</div>
|
||||
<div class="song-cover">
|
||||
<div class="cover-gradient" :class="themeClass"></div>
|
||||
</div>
|
||||
<div class="song-info">
|
||||
<h4 class="song-title">{{ song.title }}</h4>
|
||||
<p class="song-artist">{{ song.artist }}</p>
|
||||
</div>
|
||||
<div class="song-album">{{ song.album }}</div>
|
||||
<div class="song-duration">{{ song.duration }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
|
||||
const route = useRoute();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
// 判断页面类型
|
||||
const isLiked = computed(() => route.path.includes('liked'));
|
||||
const isRecent = computed(() => route.path.includes('recent'));
|
||||
|
||||
// 动态数据
|
||||
const title = computed(() => {
|
||||
if (isLiked.value) return '我喜欢的音乐';
|
||||
if (isRecent.value) return '最近播放';
|
||||
return '歌单';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (isLiked.value) return 'lucide:heart';
|
||||
if (isRecent.value) return 'lucide:clock';
|
||||
return 'lucide:music';
|
||||
});
|
||||
|
||||
const themeClass = computed(() => {
|
||||
if (isLiked.value) return 'theme-liked';
|
||||
if (isRecent.value) return 'theme-recent';
|
||||
return 'theme-default';
|
||||
});
|
||||
|
||||
const coverGradient = computed(() => {
|
||||
if (isLiked.value) return 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #fecfef 100%)';
|
||||
if (isRecent.value) return 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)';
|
||||
return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
|
||||
});
|
||||
|
||||
// Mock Playlist Data
|
||||
const songs = ref(Array.from({ length: 15 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `Song Title ${i + 1}`,
|
||||
artist: `Artist Name ${i + 1}`,
|
||||
albumName: `Album Mock`,
|
||||
duration: '03:30',
|
||||
url: 'http://commondatastorage.googleapis.com/codeskulptor-demos/riceracer_assets/music/win.ogg',
|
||||
picUrl: '',
|
||||
source: 'local',
|
||||
type: 'Local'
|
||||
})));
|
||||
|
||||
const songCount = computed(() => songs.value.length);
|
||||
|
||||
const handlePlayAll = () => {
|
||||
playerStore.setPlaylist(songs.value);
|
||||
};
|
||||
|
||||
const handlePlaySong = (index: number) => {
|
||||
playerStore.playFromList(songs.value[index], songs.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.playlist-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
box-sizing: border-box;
|
||||
padding: 30px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header Styles */
|
||||
.playlist-header {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
border-radius: var(--radius-2xl);
|
||||
overflow: hidden;
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 40px;
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.header-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: 400% 400%;
|
||||
animation: gradientBG 15s ease infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Default Dark Dynamic */
|
||||
.header-bg {
|
||||
background-image: linear-gradient(-45deg, #1e1e1e, #2a2a2a, #3a1c1c, #1a1a1a);
|
||||
}
|
||||
|
||||
.header-bg.theme-liked {
|
||||
background-image: linear-gradient(-45deg, #2a1a1a, #4a2c2c, #3a1c1c, #1a1a1a);
|
||||
}
|
||||
|
||||
.header-bg.theme-recent {
|
||||
background-image: linear-gradient(-45deg, #1a1a2a, #2c2c4a, #1c1c3a, #1a1a1a);
|
||||
}
|
||||
|
||||
@keyframes gradientBG {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
.flow-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px);
|
||||
opacity: 0.4;
|
||||
animation: float 10s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: #ec4141; /* Default/Liked Color */
|
||||
top: -50px;
|
||||
left: -50px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: #4facfe;
|
||||
bottom: -100px;
|
||||
right: -50px;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
/* Recent Theme Colors */
|
||||
.theme-recent .c1 { background: #a18cd1; }
|
||||
.theme-recent .c2 { background: #fbc2eb; }
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(20px) scale(1.1); }
|
||||
}
|
||||
|
||||
.header-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cover-box {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
background: rgba(255,255,255,0.2);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.big-icon {
|
||||
color: #fff;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
flex: 1;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 42px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #555;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.play-all-btn {
|
||||
background: #ec4141; /* Default */
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.play-all-btn.theme-recent {
|
||||
background: #a18cd1;
|
||||
}
|
||||
|
||||
.play-all-btn:hover {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
background: transparent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
/* List Styles */
|
||||
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
padding: 0 16px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.col-index { width: 40px; text-align: center; }
|
||||
.col-title { flex: 1; }
|
||||
.col-album { width: 200px; }
|
||||
.col-time { width: 60px; text-align: right; }
|
||||
|
||||
|
||||
|
||||
.song-item {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.song-item:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.song-item:hover .song-index {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.song-index {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
margin-right: 16px;
|
||||
background: #333;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-gradient {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
}
|
||||
|
||||
/* Different gradients for list items too, maybe? */
|
||||
.cover-gradient.theme-liked {
|
||||
background: linear-gradient(45deg, #ff9a9e, #fecfef);
|
||||
}
|
||||
|
||||
.cover-gradient.theme-recent {
|
||||
background: linear-gradient(45deg, #a18cd1, #fbc2eb);
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.song-album {
|
||||
width: 200px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-duration {
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
866
src/views/Search.vue
Normal file
@@ -0,0 +1,866 @@
|
||||
<template>
|
||||
<div class="view-container search-view">
|
||||
<div class="content-wrapper">
|
||||
<div class="search-header">
|
||||
<div class="header-left">
|
||||
<h1 class="search-title">搜索: "{{ query }}"</h1>
|
||||
<span class="result-count">找到 {{ total }} 个结果</span>
|
||||
</div>
|
||||
<button class="icon-btn" @click="showSettings = !showSettings" :class="{ active: showSettings }" title="搜索设置">
|
||||
<Icon icon="lucide:settings-2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<transition name="slide-fade">
|
||||
<div class="settings-panel" v-if="showSettings">
|
||||
<div class="limit-setting">
|
||||
<span class="setting-label">每页显示: {{ limit }} 首</span>
|
||||
<div class="slider-container">
|
||||
<input type="range" min="10" max="100" step="10" v-model.number="limit" class="setting-slider">
|
||||
<div class="slider-track" :style="{ width: ((limit - 10) / 90) * 100 + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugin Selector -->
|
||||
<div class="plugin-select-container">
|
||||
<button class="select-trigger" @click.stop="toggleDropdown" :title="'当前源: ' + activePluginName">
|
||||
<span>{{ activePluginName }}</span>
|
||||
<Icon icon="lucide:chevron-down" class="dropdown-icon" :class="{ open: isDropdownOpen }" />
|
||||
</button>
|
||||
|
||||
<transition name="fade">
|
||||
<div class="select-options" v-if="isDropdownOpen">
|
||||
<div
|
||||
v-for="plugin in plugins"
|
||||
:key="plugin.id"
|
||||
class="option"
|
||||
:class="{ active: plugin.id === activePlugin }"
|
||||
@click="selectPlugin(plugin)"
|
||||
>
|
||||
{{ plugin.name }}
|
||||
<Icon icon="lucide:check" v-if="plugin.id === activePlugin" class="check-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="error-state" v-if="!loading && error">
|
||||
<div class="icon-box">
|
||||
<Icon icon="lucide:alert-circle" class="state-icon" />
|
||||
</div>
|
||||
<p class="state-text">搜索出错了,请稍后重试</p>
|
||||
<button class="retry-btn" @click="fetchData">
|
||||
<Icon icon="lucide:refresh-cw" class="btn-icon" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="song-list-container" v-else-if="!loading && songs.length > 0">
|
||||
<div class="list-header">
|
||||
<div class="col-index">#</div>
|
||||
<div class="col-title">标题</div>
|
||||
<div class="col-album">专辑</div>
|
||||
<div class="col-time">时长</div>
|
||||
</div>
|
||||
|
||||
<div class="song-list">
|
||||
<div
|
||||
class="song-item"
|
||||
v-for="(song, i) in songs"
|
||||
:key="song.id"
|
||||
@click="handlePlaySong(i)"
|
||||
>
|
||||
<div class="song-index">{{ (currentPage - 1) * limit + i + 1 }}</div>
|
||||
<div class="song-cover">
|
||||
<img v-if="song.picUrl" :src="song.picUrl" loading="lazy" />
|
||||
<div v-else class="cover-placeholder"></div>
|
||||
</div>
|
||||
<div class="song-info">
|
||||
<h4 class="song-title" v-html="highlight(song.name)"></h4>
|
||||
<p class="song-artist" v-html="highlight(song.artist)"></p>
|
||||
</div>
|
||||
<div class="song-album" v-html="highlight(song.albumName || '-')"></div>
|
||||
<div class="song-duration">{{ song.duration }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination">
|
||||
<button class="pagination-btn" :disabled="currentPage <= 1" @click="changePage(currentPage - 1)">
|
||||
<Icon icon="lucide:chevron-left" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
class="pagination-btn"
|
||||
:class="{ active: page === currentPage }"
|
||||
@click="changePage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
|
||||
<button class="pagination-btn" :disabled="currentPage >= totalPages" @click="changePage(currentPage + 1)">
|
||||
<Icon icon="lucide:chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loading-state" v-if="loading">
|
||||
<Icon icon="lucide:loader-2" class="spin" />
|
||||
<span>正在搜索...</span>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" v-if="!loading && !error && songs.length === 0">
|
||||
<span>未找到相关歌曲</span>
|
||||
<button class="retry-btn" @click="fetchData">
|
||||
<Icon icon="lucide:refresh-cw" class="btn-icon" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { transformSearchSong } from '../utils/songUtils';
|
||||
import type { Song } from '../types/song';
|
||||
|
||||
const route = useRoute();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
// --- State ---
|
||||
const query = computed(() => route.query.q as string || '');
|
||||
const currentPage = ref(1);
|
||||
const showSettings = ref(false);
|
||||
const savedLimit = localStorage.getItem('qz-search-limit');
|
||||
const limit = ref(savedLimit ? Number(savedLimit) : 30);
|
||||
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const error = ref(false);
|
||||
const songs = ref<Song[]>([]);
|
||||
|
||||
// Plugin Selector State
|
||||
const plugins = ref<any[]>([]);
|
||||
const activePlugin = ref<string>(''); // Plugin ID
|
||||
const isDropdownOpen = ref(false);
|
||||
|
||||
const activePluginName = computed(() => {
|
||||
const p = plugins.value.find(p => p.id === activePlugin.value);
|
||||
return p ? p.name : '选择源';
|
||||
});
|
||||
|
||||
const toggleDropdown = () => {
|
||||
isDropdownOpen.value = !isDropdownOpen.value;
|
||||
};
|
||||
|
||||
const selectPlugin = (plugin: any) => {
|
||||
if (activePlugin.value !== plugin.id) {
|
||||
activePlugin.value = plugin.id;
|
||||
sessionStorage.setItem('qz-active-plugin', plugin.id);
|
||||
isDropdownOpen.value = false;
|
||||
} else {
|
||||
isDropdownOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Close dropdown on click outside
|
||||
const closeDropdown = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.plugin-select-container')) {
|
||||
isDropdownOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Computed ---
|
||||
const totalPages = computed(() => {
|
||||
const t = Number(total.value) || 0;
|
||||
const l = Number(limit.value) || 30;
|
||||
return Math.ceil(t / l) || 1;
|
||||
});
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const current = currentPage.value;
|
||||
const total = totalPages.value;
|
||||
const delta = 2;
|
||||
|
||||
let start = Math.max(1, current - delta);
|
||||
let end = Math.min(total, current + delta);
|
||||
|
||||
if (current - delta < 1) {
|
||||
end = Math.min(total, end + (1 - (current - delta)));
|
||||
}
|
||||
if (current + delta > total) {
|
||||
start = Math.max(1, start - ((current + delta) - total));
|
||||
}
|
||||
|
||||
const pages: number[] = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
|
||||
// --- Methods ---
|
||||
const loadPlugins = async () => {
|
||||
try {
|
||||
if (window.electronAPI?.plugin?.getAll) {
|
||||
const all = await window.electronAPI.plugin.getAll();
|
||||
// Filter valid plugins basically (have id and name)
|
||||
plugins.value = all.filter((p: any) => p.id && p.name);
|
||||
|
||||
// Restore selection or default
|
||||
const saved = sessionStorage.getItem('qz-active-plugin');
|
||||
if (saved && plugins.value.find(p => p.id === saved)) {
|
||||
activePlugin.value = saved;
|
||||
} else if (plugins.value.length > 0) {
|
||||
// Default to 'wy' if present, else first
|
||||
const wy = plugins.value.find(p => p.id === 'wy');
|
||||
activePlugin.value = wy ? 'wy' : plugins.value[0].id;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load plugins", e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!query.value || !activePlugin.value) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
songs.value = [];
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.plugin.search(activePlugin.value, query.value, currentPage.value, limit.value);
|
||||
|
||||
if (result && result.list) {
|
||||
songs.value = result.list.map((item: any) => transformSearchSong(item));
|
||||
total.value = result.songCount || result.total || 0;
|
||||
} else {
|
||||
total.value = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Search failed", e);
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const changePage = (page: number) => {
|
||||
currentPage.value = page;
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const handlePlaySong = (index: number) => {
|
||||
playerStore.playFromList(songs.value[index], songs.value);
|
||||
};
|
||||
|
||||
const getHighlightRegex = (q: string) => {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) return null;
|
||||
const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (trimmed.length <= 2) {
|
||||
return new RegExp(escapeRegExp(trimmed), 'gi');
|
||||
}
|
||||
const substrings = new Set<string>();
|
||||
substrings.add(trimmed);
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
for (let j = i + 2; j <= trimmed.length; j++) {
|
||||
substrings.add(trimmed.slice(i, j));
|
||||
}
|
||||
}
|
||||
const sorted = Array.from(substrings).sort((a, b) => b.length - a.length);
|
||||
const pattern = sorted.map(s => escapeRegExp(s)).join('|');
|
||||
return new RegExp(pattern, 'gi');
|
||||
};
|
||||
|
||||
const highlight = (text: string) => {
|
||||
if (!query.value || !text) return text;
|
||||
const regex = getHighlightRegex(query.value);
|
||||
if (!regex) return text;
|
||||
return text.replace(regex, match => `<span class="highlight">${match}</span>`);
|
||||
};
|
||||
|
||||
// --- Watchers & Lifecycle ---
|
||||
watch(query, () => {
|
||||
currentPage.value = 1;
|
||||
// ensure plugins loaded before fetching? usually mounted happens first
|
||||
if (activePlugin.value) fetchData();
|
||||
}, { immediate: false }); // Wait for mount init
|
||||
|
||||
watch(limit, (newLimit) => {
|
||||
localStorage.setItem('qz-search-limit', newLimit.toString());
|
||||
currentPage.value = 1;
|
||||
fetchData();
|
||||
});
|
||||
|
||||
watch(activePlugin, (newVal, oldVal) => {
|
||||
if (newVal && newVal !== oldVal && query.value) {
|
||||
currentPage.value = 1;
|
||||
fetchData();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', closeDropdown);
|
||||
await loadPlugins();
|
||||
// After plugins loaded, if we have a query, fetch data
|
||||
if (query.value && activePlugin.value) {
|
||||
fetchData();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', closeDropdown);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.view-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
box-sizing: border-box;
|
||||
padding: 20px 30px; /* Reduced vertical padding, kept horizontal for spacing but flexible */
|
||||
width: 100%;
|
||||
/* Removed max-width to allow full width usage as requested */
|
||||
/* margin: 0 auto; */
|
||||
}
|
||||
|
||||
.search-header {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.search-title::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 70%;
|
||||
background-color: var(--color-accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.icon-btn.active {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Settings Panel */
|
||||
.settings-panel {
|
||||
background: var(--color-bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative; /* Context */
|
||||
}
|
||||
|
||||
.limit-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Plugin Selector */
|
||||
.plugin-select-container {
|
||||
position: relative;
|
||||
/* ensure it stays on top when options open? No, options use absolute */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-bg-primary);
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
min-width: 120px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.select-trigger:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 16px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-icon.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.select-options {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 160px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.option.active {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Fade util */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.setting-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 4px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-slider {
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active,
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
max-height: 100px;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin-bottom: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* List Grid Layout */
|
||||
.list-header, .song-item {
|
||||
display: grid;
|
||||
/* Define columns: Index | Title (40%) | Album (30%) | Time */
|
||||
grid-template-columns: 50px 4fr 3fr 60px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
/* Header doesn't need hover background usually, but padding ensures alignment */
|
||||
}
|
||||
|
||||
/* Override existing mixins/styles if necessary */
|
||||
.col-index, .col-title, .col-album, .col-time {
|
||||
width: auto; /* Let grid handle width */
|
||||
}
|
||||
.col-index { text-align: center; }
|
||||
.col-time { text-align: right; }
|
||||
|
||||
.song-item {
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
width: 100%; /* Fill width */
|
||||
}
|
||||
|
||||
.song-item:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.song-index {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
/* Cover is not in the top grid definition, wait.
|
||||
The previous request structure had a cover inside.
|
||||
The Grid should account for cover?
|
||||
Previous structure: Index | Cover | Info (Title+Artist) | Album | Time
|
||||
Let's adjust Grid columns to: Index | Cover+Info | Album | Time?
|
||||
Or Index | Info (with Cover flow) | Album | Time.
|
||||
Let's stick to the structure:
|
||||
.song-item > .song-index
|
||||
.song-item > .song-cover (This was separate div in previous template)
|
||||
.song-item > .song-info
|
||||
.song-item > .song-album
|
||||
.song-item > .song-duration
|
||||
|
||||
So we have 5 direct children in .song-item?
|
||||
Checking template:
|
||||
1. .song-index
|
||||
2. .song-cover
|
||||
3. .song-info
|
||||
4. .song-album
|
||||
5. .song-duration
|
||||
|
||||
Yes, 5 columns.
|
||||
Updated Grid: Index(50px) Cover(40px) Info(4fr) Album(3fr) Time(60px)
|
||||
*/
|
||||
display: flex; /* Reset for grid child if needed */
|
||||
}
|
||||
|
||||
/* Refined Grid for 5 columns */
|
||||
.list-header, .song-item {
|
||||
grid-template-columns: 50px 40px 4fr 3fr 60px;
|
||||
}
|
||||
|
||||
/* Header has 4 children (Index, Title, Album, Time).
|
||||
We need to make Title span 2 columns (Cover + Info area) or Insert a spacer?
|
||||
Title header usually aligns with Title text.
|
||||
Let's make Header Grid: 50px (Spacer 40px) Title ...
|
||||
Actually, usually Cover doesn't have a header.
|
||||
Let's align "Title" header to start of Info.
|
||||
|
||||
List Header children:
|
||||
div.col-index
|
||||
div.col-title
|
||||
div.col-album
|
||||
div.col-time
|
||||
|
||||
We need to adjust .col-title to span the Cover+Info space? Or just Info space?
|
||||
If we want "Title" label to align with Song Title text, we should skip the Cover column.
|
||||
*/
|
||||
|
||||
.list-header {
|
||||
/* 50px | 40px spacer | 4fr | 3fr | 60px */
|
||||
grid-template-columns: 50px 40px 4fr 3fr 60px;
|
||||
}
|
||||
/* We need a phantom element or nth-child hacking for header?
|
||||
Easiest is to add a spacer div in template or just use grid-column on col-title?
|
||||
If we say .col-title { grid-column: 3 / 4; } ?
|
||||
*/
|
||||
.list-header .col-title {
|
||||
grid-column: 3;
|
||||
}
|
||||
.list-header .col-album {
|
||||
grid-column: 4;
|
||||
}
|
||||
.list-header .col-time {
|
||||
grid-column: 5;
|
||||
}
|
||||
|
||||
|
||||
.song-cover {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.song-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding-left: 10px; /* Small gap between cover and text */
|
||||
}
|
||||
|
||||
.song-title {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.song-album {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.song-duration {
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Highlight */
|
||||
:deep(.highlight) {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-start; /* Alignment Change: Left Align */
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
gap: 8px; /* Closer gap */
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
background: transparent;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: var(--color-bg-tertiary);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: var(--color-accent);
|
||||
color: #fff; /* Ensure readable text on accent */
|
||||
border-color: var(--color-accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* States */
|
||||
.loading-state, .empty-state, .error-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: var(--color-text-muted);
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
font-size: 32px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.error-state .icon-box {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.state-icon {
|
||||
font-size: 32px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 8px;
|
||||
padding: 8px 24px;
|
||||
background: var(--color-accent);
|
||||
color: white; /* Ensure text is readable on accent color */
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.retry-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.retry-btn .btn-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
31
vite.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import wasm from 'vite-plugin-wasm'
|
||||
import svgLoader from 'vite-svg-loader'
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
wasm(),
|
||||
svgLoader(),
|
||||
nodePolyfills()
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
'@assets': resolve(__dirname, 'src/assets')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
open: false
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true
|
||||
}
|
||||
})
|
||||